在前面几篇文章中,我们耗费了很多笔墨去介绍“组件”的相关知识,一步一步的,从平地起大厦,一砖一瓦的几乎把所有Blazor框架中有关“组件”的知识都给大家介绍了个遍,但唯独遗漏了一个非常重要的知识点:布局组件。
我这样做是有原因的:我自己尽我可能的想给大家描绘一条舒缓的学习曲线,让系列文章有连贯性与前后因果,而不是列流水账一样的把框架所有知识点罗列出来。之所以把没有把布局组件这个知识点,放在“组件”这个大话题下去讲,是因为布局组件本身与另外一个重要知识体系是有关联的,就是标题中提到的路由。
这个关联并不是说这两个知识点之间有什么联系,而是因为路由组件本身经常是搭配着布局组件使用的,无论是在解读代码还是在解释路由相关的知识点的时候,布局组件都会掺和进来。所以我们直接在一篇文章中,把这两个东西都介绍掉。不过本篇文章主要还是以路由相关的知识点为主,布局组件是一个相对比较简单的知识点(前提是你已经掌握了我们前面系列文章介绍的内容)。
之前的文章中,我们示例所使用的代码大多都是在我们手撸出的解决方案的基础上跑起来的,在介绍如何构建一个前后端一把梭的解决方案的那篇文章中,我们也是着重介绍了如何手撸出一个解决方案的骨架出来,而不是直接使用官方模板。当时也提到了这样做的原因:因为官方模板中多多少少都包含着一些我们有前期无法解释的知识点与概念。即便是在blazorwasm-empty
中也包含着一些我们在早期不太好解释的知识点。
今天,时机成熟了,我们来清点一下blazorwasm-empty
中剩下的知识点
如下所示,新建一个解决方案,下面这个解决方案将是后续示例代码的运行方案:
> dotnet new blazorwasm-empty -o HelloRouter --hosted
> cd HelloRouter
默认模板生成的解决方案中有以下几点需要注意:
*.csproj
文件,天生都自带<ImplicitUsings>enable</ImplicitUsing>
这个声明。这个特性是自dotnet 6.0版本后新加的一个特性,主要功能就是工具链自动的为所有代码文件引入一些常用的namespace。
obj/Debug/net7.0
目录下看到一个名为HelloRouter.[Client/Server/Shared].GlobalUsings.g.cs
文件,里面通过global using ...
的方式将常见常用的namespace都给整了进来<Nullable>enable</Nullable>
的声明,表示提醒工具链做必要的空指针检查,并且在有风险的代码处报warning。这个建议保持原样。Client
文件,是不带<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
。这意味着默认情况下框架并不会把Razor转译后的C#代码文件放在编译产出目录中,我们也就没法观察Razor引擎的工作方式。我个人建议把这个特性打开,无论是在学习过程中,还是开发过程中,观察Razor引擎的转译结果对理解razor语法、v-dom、生命周期、组件、参数这些基础知识点都非常有帮助。Client
项目下,默认模板会生成一个_Imports.razor
组件,这个组件内部的实现是空的,只是写了很多@using
指令。
_Imports.razor
的组件做特殊处理,简单来说,会把它内部写的@using
指令以复制粘贴的形式贴给项目中的所有*.razor
文件。*.razor
文件的global usingMicrosoft.AspNetCore.Components.Web
这个namespace吗?我们在自建解决方案的时候,没有使用_Imports.razor
,所以会发现,如果组件内部不引入这个namespace的话,事件处理回调就不会被正常转译。这是因为所有组件里有关事件回调的组件参数都定义在这个namespace中_Imports.razor
并不会引入@using HelloRouter.Shared
,建议手动加上。上述四个点基本都是工具链或者细枝末节的小点,理解起来没什么门槛,而真正值得说的其实是程序的“入口点”,即App.razor
里的细节,在之前的系列文章中,我们都没有细看这个“入口点”,而是把关注点放在了组件上,做的最多的事情就是去Components
目录下,或者另外一个组件类库中写一个组件,然后在Pages/Index.razor
中去调用它。但谁调用了Index.razor
我们从未提及,App.razor
中的技术细节我们也从未提及
首先,Client项目中的App.razor
是如下这样写的:
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
通过前面的学习,我们已经能看出来,Router
显然是一个框架提供给我们的组件。AppAssembly
是这个组件下的一个组件参数,接受类型信息。
而像Found
和NotFound
两个东西,就非常像是Router
组件的两个类型为RenderFragment
的组件参数,通过XML子元素的方式进行参数传递。不过就我们目前的知识储备来说,还存在有一种可能,即Found
与NotFound
是两个另外的组件,它们的渲染结果拼在一起,传递给了Router.ChildContent
。
从Found
本身还接受了一个名为Context
的参数这个写法来看,后者的可能性更大一些。不过查看源代码后,你就会发现,真相是,Found
与NotFound
都是组件参数,在Router
类中如下定义着:
public partial class Router : IComponent, IHandleAfterRender, IDisposable
{
// ...
// ...
[Parameter]
[EditorRequired]
public Assembly AppAssembly { get; set; }
[Parameter]
[EditorRequired]
public RenderFragment<RouteData> Found { get; set; }
[Parameter]
[EditorRequired]
public RenderFragment NotFound { get; set; }
// ...
// ...
}
而翻开Razor引擎转译后的App_razor.g.cs
的话,你会看到如下虽然有点绕,但其实并不难懂的代码(以下代码经过了精简,部分强制类型转换,与冗长的namespace被省略掉了)
public partial class App : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.OpenComponent<Router>(0);
__builder.AddAttribute(1, "AppAssembly", typeof(App).Assembly);
__builder.AddAttribute(2, "Found",
(RenderFragment<RouteData>)(
(routeData) => (__builder2) => {
__builder2.OpenComponent<RouteView>(3);
__builder2.AddAttribute(4, "RouteData", routeData);
__builder2.AddAttribute(5, "DefaultLayout", typeof(MainLayout));
__builder2.CloseComponent();
__builder2.AddMarkupContent(6, "\r\n ");
__builder2.OpenComponent<FocusOnNavigate>(7);
__builder2.AddAttribute(8, "RouteData", routeData);
__builder2.AddAttribute(9, "Selector", "h1");
__builder2.CloseComponent();
}
)
);
__builder.AddAttribute(10, "NotFound",
(RenderFragment)(
(__builder2) => {
__builder2.OpenComponent<PageTitle>(11);
__builder2.AddAttribute(12, "ChildContent",
(RenderFragment)(
(__builder3) => {
__builder3.AddContent(13, "Not Found");
}
)
);
__builder2.CloseComponent();
__builder2.AddMarkupContent("\r\n ");
__builder2.OpenComponent<LayoutView>(15);
__builder2.AddAttribute(16, "Layout", typeof(MainLayout));
__builder2.AddAttribute(17, "ChildContent",
(RenderFragment)(
(__builder3) => {
__builder3.AddMarkupContent(18, "<p role=\"alert\">Sorry, there\'s nothing at this address.</p>");
}
)
);
__builder2.CloseComponent();
}
)
);
__builder.CloseComponent();
}
}
我们先看看Found
和后面的Context
是怎么回事。
首先从上面的转译代码能明确的看到,Found
是一个类型为RenderFragment<RouteData>
类型的组件参数,我们前面在模板组件章节介绍了,RenderFragment<RouteData>
是这样一个函数指针:
RouteData
的参数RenderFragment
所以Found
的字面量可以写成下面这样的Lambda表达式:
var foundLiteral = (RouteData routeData) => {
RenderFragment res = ...;
return res;
}
它描述的语义是:如何去渲染类型为RouteData
的数据
另外,RenderFragment
本身也是个函数指针类型:
RenderTreeBuilder
的参数所以进一步的,Found
的字面量可以完善成下面这样:
var foundLiteral = (RouteData routeData) => {
RenderFragment res = (RenderTreeBuilder builder) => {
builder.xxxx;
builder.xxxx;
};
return res;
}
再回头去看引擎转译的结果,此时就清晰不少了:
__builder.AddAttribute(2, "Found",
(RenderFragment<RouteData>)(
(routeData) => (__builder2) => {
__builder2.OpenComponent<RouteView>(3);
__builder2.AddAttribute(4, "RouteData", routeData);
__builder2.AddAttribute(5, "DefaultLayout", typeof(MainLayout));
__builder2.CloseComponent();
__builder2.AddMarkupContent(6, "\r\n ");
__builder2.OpenComponent<FocusOnNavigate>(7);
__builder2.AddAttribute(8, "RouteData", routeData);
__builder2.AddAttribute(9, "Selector", "h1");
__builder2.CloseComponent();
}
)
);
如果还不够清晰,我们可以改写成下面这样:
RenderFragment<RouteData> foundLiteral = (RouteData routeData) => {
RenderFragment res = (RenderTreeBuilder builder) => {
builder.OpenComponent<RouteView>(3);
builder.AddAttribute(4, "RouteData", routeData); // !!!!!!
builder.AddAttribute(5, "DefaultLayout", typeof(MainLayout));
builder.CloseComponent();
builder.AddMarkupContent(6, "\r\n ");
builder.OpenComponent<FocusOnNavigate>(7);
builder.AddAttribute(8, "RouteData", routeData); // !!!!!!
builder.AddAttribute(9, "Selector", "h1");
builder.CloseComponent();
};
};
__builder.AddAttribute(2, "Found", foundLiteral);
这里的关键在于上面改写的带// !!!!!
注释的那两行:相当于我们在内部Lambda表达式体内,捕获了外部lambda表达式的参数。还捕获了两次。
这两次捕获其实对应的就是<Found>
内部的如下两行:
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
正因为在内部lambda表达式体内,捕获的那玩意对它自己来说,已经是个变量了,所以在上面,它使用@routeData
去取值,并把参数分别传给RouteView
和FocusOnNavigate
但在外部lambda表达式体内,routeData
只是lambda表达式的参数,这个参数不光可以叫routeData
,还可以叫任何名字。比如,我们可以把它的名字叫做something
,即改写成如下这样:
- RenderFragment<RouteData> foundLiteral = (RouteData routeData) => {
+ RenderFragment<RouteData> foundLiteral = (RouteData something) => {
RenderFragment res = (RenderTreeBuilder builder) => {
builder.OpenComponent<RouteView>(3);
- builder.AddAttribute(4, "RouteData", routeData); // !!!!!!
+ builder.AddAttribute(4, "RouteData", something); // !!!!!!
builder.AddAttribute(5, "DefaultLayout", typeof(MainLayout));
builder.CloseComponent();
builder.AddMarkupContent(6, "\r\n ");
builder.OpenComponent<FocusOnNavigate>(7);
- builder.AddAttribute(8, "RouteData", routeData); // !!!!!!
+ builder.AddAttribute(8, "RouteData", something); // !!!!!!
builder.AddAttribute(9, "Selector", "h1");
builder.CloseComponent();
};
};
__builder.AddAttribute(2, "Found", foundLiteral);
一点毛病没有,不过需要注意的是,外部lambda表达式换了参数的变量名,那么内部捕获的时候,也得用新名字。。
所以回过头来看,<Found Context="routeData">
到底是什么意思?现在应该能猜出来了,这也是我们之前讲模板组件时没有提到的一个边角知识点:
RenderFragment<T>
类型的组件参数赋值的时候,默认在内部标记语言中,是以@context
来引用T
数据本身的,但可以通过为子元素添加Context
属性的方式,改写这个变量名有了这个知识点,我们就可以把App.razor
改写成下面这样:
<Found Context="something">
<RouteView RouteData="@something" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@something" Selector="h1" />
</Found>
甚至于是下面这样:
<Found>
<RouteView RouteData="@context" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@context" Selector="h1" />
</Found>
是完全等价的。
<Router>
组件的基础知识好,从细节里抬起头,调整一下视角,暂时性的总结一下,目前,我们所知道的内容如下:
<Router>
是一个框架自带的组件,它有三个参数:
AppAssembly
,类型是Assembly
,尚不得知有什么用Found
,类型是RenderFragment<RouteData>
。尚不得知这个RouteData
类型是啥玩意,也不知道执行时传递给这个Found
的RouteData
实例是从哪来的。NotFound
,类型是RenderFragment
。Found
赋值的体内,连续渲染了两个组件:RouteView
和FocusOnNavigate
,它们都捕获了传递给Found
的RouteData
实例作为自己的RouteData
组件参数的值。(有点绕,你品一下)
RouteView
还接受一个名为DefaultLayout
的组件参数,实际是把MainLayout.razor
组件的类型信息传递给了它FocusOnNavigate
还接受了一个字符串组件参数,名为Selector
,值是h1
,不知道有什么软用NotFound
赋值的体内,连续渲染了两个组件:
PageTitle
组件看起来非常简单,将一个字符串传递给了它的ChildContent
LayoutView
组件稍微复杂了一点:
ChildContent
组件参数MainLayout.razor
的类型信息传递给了它的Layout
组件参数简直有太多的疑问了,不过不要着急,我们一个个的看。
现在我们从写法上,已经搞明白了App.razor
里的代码是在干什么,只是我们不知道的是诸如<Router>
,<RouteView>
, <LayoutView>
等这些组件的功能是什么而已。我们来一个个的介绍,一层层的介绍,循序渐进的介绍。
设想一下从用户在浏览器地址栏敲入网址,到页面上展示出内容的整个过程中,Blazor干了什么。比如我们访问了一个/Counter
页面:
先说Hosted Blazor WASM:我们的请求是发送Server项目的,Server项目的Program.cs
中,没有任何的API、静态资源能匹配上这个/Counter
这个路径,所以就根据app.MapFallbackToFile("index.html");
的指引,将静态资源index.html
返回给客户端,当index.html
返回给客户端浏览器后,浏览器根据<script src="_framework/blazor.webassembly.js"></script>
这一行指示,开始下载这个js文件,然后后续接着下载整个dotnet runtime等等,再之后就是我们的Client.dll
也被下载,被执行,index.html
中的<div id="app">Loading...</div>
被WASM的渲染结果所取代。
上面这些知识我们之前都介绍过,这里再温习一遍。之前我们只是说到,<div id="app">
会被Blazor WASM的渲染结果所取代,更细节一点的问题是:被谁的渲染结果所取代呢?
答案就是App.razor
:在高一点的视角来看,是App.razor
的渲染结果替代了div#id
,从低一点的视角来看,是App.razor
的内部通过一些魔法,找到了与请求路径/Counter
匹配的Pages/Counter.razor
,并把其进行渲染。
魔法是什么呢?魔法有两个关键:
App.razor
开始渲染的时候,框架一定以某种方式,获取到了用户的请求路径,知道了用户要请求的路径是/Counter
App.razor
内部,一定通过某种方式,扫描了整个Client.dll
中所有脑门带@page
指令的所有组件App.razor
在渲染时就能找到与路径所匹配的那个页面组件,在本例中,就是Pages/Counter.razor
对于Blazor Server而言,虽然前半段流程并不一致,但到App.razor
开始被渲染时,故事就基本一致了:虽然App.razor
是在服务端渲染的,渲染引擎运行在服务端,但渲染引擎依然做到了上面的#1与#2两件事,并且通过#3的逻辑,能把用户的请求匹配到对应的网页组件上。
对于#1,我们尚不知道框架,或者说是渲染引擎,是如何拿到用户的请求路径的,这也不重要,因为背后的原理和实现,在WASM模式下和Server模式下,肯定是不一样的,但这不重要,重要的是:框架能拿到用户的请求路径。
而对于#2,说明框架,或者说渲染引擎,在正式工作之前,一定要把Client.dll
中所有页面组件,甚至于所有组件,甚至于可能是所有的类,都扫描一遍,把所有带@page
指令的组件类都登记在册。这就是Router
组件中Assembly
参数的语义:通过这个参数来指定,去哪个assembly中去扫描所有的页面组件类。所以,现在来看下面这块代码,是不是就理解了那么一点点呢?
<Router AppAssembly="@typeof(App).Assembly">
< ... >
</Router>
对于#3,在匹配请求路径的时候,显然会得到两种结果:要么用户的请求路径确实能与某个页面组件匹配上,要么并不存在这样一个页面组件。框架要为两种情况都做打算:
而这两个分支的具体行为,就是通过Router
组件的Found
和NotFound
两个参数指定的。
所以现在再来看下面这块代码,是不是就又理解了那么一点点呢?
<Router AppAssembly="@typeof(App).Assembly">
<Found>
< ...>
</Found>
<NotFound>
< ...>
</NotFound>
</Router>
好了,到此为止,有关路径的基本概念就讲完了,关键点如下:
@page
指令的组件,我们为了描述方便,把它称为“页面组件”Router
组件是负责路由的核心组件
AppAssembly
来指定一个assembly,Router
组件内部在正式工作之前,会对这个assembly中的所有页面组件进行登记,记下它们所匹配的路径与组件类之间的映射关系Router
组件内部会去做路径匹配工作
Router
就会去渲染由Found
参数传递进来的内容Router
就会去渲染由NotFound
参数传递进来的内容有关路径的“基本知识”,就是这么的简单。
接下来,在继续介绍Found
与NotFound
参数的细节之前,我们需要从用法层面上,再介绍几个知识点:
@page
指令@page
指令其实就是RouteAttribute
我们现在翻开Pages/Index.razor
被转译后的C#文件,你会赫然在类脑门上发现这样一个修饰属性:
namespace HelloRouter.Client.Pages
{
[global::Microsoft.AspNetCore.Components.RouteAttribute("/")]
public partial class Index : global::Microsoft.AspNetCore.Components.ComponentBase
{
protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "<h1>Hello, world!</h1>");
}
}
}
正常我们在C#中写修饰属性的时候,会省略掉属性后面的Attribute
,所以说如果上述代码是人手写的,我们会写成下面这样:
namespace HelloRouter.Client.Pages
{
[Route("/")]
public partial class Index : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "<h1>Hello, world!</h1>");
}
}
}
而如果你恰好有一些AspNetCore相关领域的知识积累,你可能会惊呼:诶,这不就是我们写在控制器脑门上的Route
修饰属性吗?
但其实并不是,两者看起来很像,但不是同一个东西,我们在服务端控制器脑门上用到的其实是Microsoft.AspNetCore.Mvc.RouteAttribute
,它的定义基本如下:
namespace Microsoft.AspNetCore.Mvc;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class RouteAttribute : Attribute, IRouteTemplateProvider
{
private int? _order;
public RouteAttribute([StringSyntax("Route")] string template)
{
Template = template ?? throw new ArgumentNullException(nameof(template));
}
[StringSyntax("Route")]
public string Template { get; }
public int Order
{
get { return _order ?? 0; }
set { _order = value; }
}
int? IRouteTemplateProvider.Order => _order;
public string? Name { get; set; }
}
而在Blazor中我们用到的其实是Microsoft.AspNetCore.Components.RouteAttribute
,它的定义如下:
namespace Microsoft.AspNetCore.Components;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class RouteAttribute : Attribute
{
public RouteAttribute(string template)
{
if (template == null)
{
throw new ArgumentNullException(nameof(template));
}
Template = template;
}
public string Template { get; }
}
传统AspNetCore中的RouteAttribute
看起来更复杂一点,而Blazor中使用到的RouteAttribute
就简单了很多:它内部只有一个字段,并且在构造后被包装成了一个只读属性Template
显然在我们之前所有举的例子中,我们都是直接把页面路径传递给构造函数的,所以显然Template
的值就是诸如"/"
,"/Counter"
之类的绝对路径值。按照我们目前的知识积累,Router
组件很有可能是在构造的过程中,通过反射的方式,把AppAssembly
参数内部的所有类都扫描一遍,如果这个类是个组件类(是ComponentBase
的子类),并且脑门上有Microsoft.AspNetCore.Components.RouteAttribute
修饰,那么就把Template
的值和这个类名关联起来,记在一个小本本上。然后实际运行的时候按请求路径相询即可。
可如果真有这么简单,为什么RouteAttribute
里的这个字段/属性的名字不叫Path
或者RequestPath
之类的名字,而要起名叫Template
呢?难道它也是模板?它模了什么板?
诶,你这么想,就对了
在Blazor中,我们把传递给RouteAttribute
构造函数的实参,也就是@page
指令后面的内容,那个字符串,叫路由模板。
我们的系列文章中已经好多次解释过“模板”这个词语在编程领域的语义:90%内容写死,10%信息待填充。路由模板也不例外。
我们之前接触的所有示例代码都没有展示它模板的那一面:因为我们只是把写死的路径传递给RouteAttribute
的构造函数:100%的内容都写死了,没什么可模的板。这就是第一种路由模板:死模板。
这没什么可说的,就如它的名字一样:写死了。就比如@page "/"
或@page "/Counter"
一样。
模板参数,或者叫参数模板,两个名字叫法不一样,侧重点不一样,但其实描述的是同一个东西,我直接举个例子你就明白了:
比如现在我们给某个页面的脑门上写上@page "/ProductDetail/{ProductId}"
,这时,传递给RouteAttribute
构造函数的字符串将变成"/ProductDetail/{ProductId}"
。这个字符串看起来不像是正经字符串,因为这个大括号里面括起来的东西太像一个占位符了。
你说的没错,这就是路由参数占位符,它有两层含义:
/ProductDetail/123
,会被匹配到这个页面上,解析过程中,路由参数的值会被解析为123
(显然路由参数的名是ProductId
)。同理,如果用户的请求路径是/ProductDetail/Apple
,路由参数的值会被解析为Apple
所以路由参数其实是组件参数赋值的一种方式,或者换句话来说,对于组件参数来说,就我们目前所知的,有三种赋值方式:
[Parameter]
,由其父组件在调用时赋值[CascadingParameter]
,由其祖先组件使用<CascadingValue>
进行赋值[Parameter]
,还可以通过路由模板的设定,从用户的请求路径中解析值有关模板参数的基础知识就介绍完毕了,这里有两个小知识点需要大家额外注意一下:
@page /ProductDetail/{productId}
和[Parameter]public string ProductId{get; set;}
是能匹配起来的。/ProductDetail/aBcD
,那么解析出来的模板参数值就是aBcD
,最终传递给组件参数的值也会是"aBcD"
。@page /ProductDetail/Apple_{AppleId}
这种模板是会抛出运行期异常的,框架不认为这是合法的模板,一个合法的模板不能只占半个路径层级@page /ProductDetail/{ProductId}
这种模板,是不会与/ProductDetail/A/B/C/D
这种请求路径匹配的,更不会把"A/B/C/D"
解析为模板参数的值那么最后,有一个疑问要抛给大家:
用户的请求路径是字符串,路由模板匹配成功后,模板参数的值也是字符串,此时如果对应的组件参数的类型也是字符串类型,没什么问题。
但如果对应的组件参数的类型是其它类型呢?比如整数?会发生什么?
我们来做个实验:让模板参数对应的组件参数类型为整型,比如写下面这样一个Pages/Product.razor
页面,代码如下:
@page "/Product/{ProductId}"
<h1>ProductId == @this.ProductId</h1>
@code {
[Parameter]
public int ProductId { get; set; } = default!;
}
我们先猜一下运行结果:按照我们朴素的直觉经验,很容易做出如下猜想:
/Product/233
的时候,程序应当正常运行,组件参数ProductId
应该被赋值为233
xxx.TryParse()
不抛异常的版本,那么此时程序不会招异常,但ProductId
的值会被赋值为default
,对于整型来说,是0
xxx.Parse()
招异常的版本,程序应该抛异常终止运行那么,我们试运行一下吧,结果如下图:
很不幸,我们猜错了,框架并没有为我们做“符合直觉的自动类型转换”,而是直接抛出了一个异常:System.InvalidCastException: Specified cast is not valid
从异常信息上我们能看出来,框架抱怨的是:没有指定所需的类型转换(的实现)。那么,我们到底应当如何去设定这个类型转换的实现?在哪写处理类型转换的代码呢?
这个问题的答案出乎意料的简单:框架不提供这样的插拔能力,框架没有给使用者“自定义模板参数如何转换类型至组件参数的实现”这个口子。而要修复以上的代码,目前我们只能如下迂回着去做:
@page "/Product/{ProductId}"
<h1>ProductId == @this.NumericProductId</h1>
@code {
[Parameter]
public string ProductId { get; set; } = default!;
public int NumericProductId => int.Parse(this.ProductId);
}
在任何公开的Blazor文档中,你都找不到如何自定义类型转换器,让模板参数的值转换为复杂类型。这明显是框架设计者有意为之的。这背后的原因也非常显而易见:
/ProductDetail/{ProductId}
这种设计太好用了,框架顺手用“模板参数”给你支持了而已。还想在这上面搞花活?对不起请不要太放飞自我。但是呢,话又说回来,真是由于/ProductDetail/{ProductId}
这种设计太流行了,Blazor也知道真的就只传个字符串进来,也确实在某些开发场合下不太方便,所以框架还是提供了一些,有限的,类型转换能力。
我们可以在声明路由模板的时候,给路径模板参数添加额外的类型约束信息,来告诉框架:嗨,这个参数应当是数值/布尔值/日期,请帮我做自动转换。
比如上面的示例代码,我们可以如下修改:
@page "/Product/{ProductId:int}"
<h1>ProductId == @this.ProductId</h1>
@code {
[Parameter]
public int ProductId { get; set; } = default!;
}
语法就如上所示:在模板参数名后面,加个冒号,再加上具体的类型名,框架就会自动的把模板参数的值,从字符串转向指定的类型。但这里有几个知识点需要注意:
bool
, datetime
, decimal
, double
, float
, guid
, int
, long
。其它类型都不支持,想都不要想NumericProductId
的实现,二者的区别主要在于,当用户请求的路径中的模板参数的值,无法被成功转换为指定类型时,程序的行为不一致
/Product/abc
的话,路由的匹配结果会转向NotFound
datetime
类型,从字符串做数据类型转换时,是按invariant culture转换的。如果你不知道什么是invariant culture,就只需要记住:只有类似于"2023-12-20"
,或"2023-12-20 3:32pm
,以及"2023-12-20 15:57:35"
这种正常的年月日,时分秒表达才会被正确解析,其它乱七八糟的格式统统都不支持guid
类型,转换也是按invariant culture转换的,或者你简单的理解为,"7BD77E5E-11B1-479A-B5FD-E410988932CA"
或带大括号的"{7BD77E5E-11B1-479A-B5FD-E410988932CA}"
两种格式都支持string
本身并不在八种类型之间,所以有个很吊诡的事:{ProductId:string}
会引起运行时异常。模板参数的类型限制支持八种CLR基础类型,这些基础类型都是值类型。框架在此基础上,还添加了一个特性:可以将路径模板参数声明为可选参数,有两个操作需要做:
比如下面这个例子,我们把上面的ProductId
改写为可空类型,可空有两个含义:在组件参数层面上,这个参数的值可能为null,在请求路径方面,请求路径中可以不包含这个ProductId
,而框架则会把null
作为参数传递给组件参数:
@page "/Product/{ProductId:int?}"
<h1>ProductId == @(this.ProductId is null ? "null" : this.ProductId)</h1>
@code {
[Parameter]
public int? ProductId { get; set; } = 466;
}
上面这个例子之所以要给ProductId
赋一个466的初始值,是为了验证框架确实在参数传递阶段,把null
作为模板参数的值,传递给了组件参数。运行效果如下:
而显然,与程序设计语言中函数领域的“可选参数”一样:路由模板中的可选参数,可以有多个,但可选参数之后不应当再有其它参数。什么意思呢?
@page "/Product/{Id:int?}/{Name?}"
,这没毛病。多个可选参数,框架基本会按照你的直觉去做事。但@page "/Product/{Id:int?}/{Name?}/Tail"
这就不行了,直接运行期错误:模板参数右边不能再有其它内容了。
上面讲模板参数的时候,我们强调了模板参数的一个特性:它只能匹配请求路径中的一个层级,如果将请求路径看作是类似于文件系统中的目录路径或文件路径的话,那么模板参数只能匹配路径中的一个目录名,或者文件名。
Blazor框架还提供了另外一种模板参数:它在匹配路由的时候,只匹配前半部分,把后半部分的所有内容都当作模板参数值,而无论后半部分是一个层级,还是多个层级,什么意思呢?直接看例子:
@page "/Product/{*ProductDetails}"
<h2>ProductDetails =@(this.ProductDetails is null ? "<null>" : this.ProductDetails)</h2>
@code {
[Parameter]
public string ProductDetails { get; set; } = "defaultValue";
}
运行效果如下图所示:
这种特殊的模板参数的官方名称叫catch all parameter。它有以下几个特点:
/Product
或/Product/
时,框架为参数赋值为null
@page "/Product/{*ProductDetails?}"
这种写法是错误的,会抛运行时异常@page "/Product/{*ProductDetails:guid}"
这种写法也是错误的,也会抛运行时异常。它对应的组件参数类型只能是string
或string?
我们上面提到过,在请求路径里传递参数其实是一件值得商榷的事,因为按HTTP的设计思想,URL分两部分:路径部分应该指代服务器资源的路径,而多余的,其它参数性质的信息,应当写在查询字符串里。只不过在实践和历史的发展过程中,工程师发现,“资源路径”本身可以被“参数化”:上古时期服务端的资源就是服务端的文件目录路径,后来服务端渲染技术出现后,大家发现,诶,用户请求的所谓“资源”,其实我服务端是可以“动态生成”的,这时候天然的,“资源路径”也就在实践中被“参数化”了。
而参数信息,到底是应该写在路径中,像/Product/233
这样,还是写在查询字符串中,像/Product?product_id=233
这种,没有一个标准答案,简单来说就是:都行
我们上面以介绍@page
指令为引子,介绍了在Blazor框架中,如何把参数从请求路径中扒出来,这里也对应的介绍一下,在Blazor框架中,如何把参数从查询字符串中扒出来。
从查询字符串中扒参数,并把扒出来的参数赋值给组件中的组件参数,只需要做一件事:即是额外再用SupplyParameterFromQueryAttribute
去修饰这个组件参数即可,如下例所示:
@page "/"
<ol>
<li>Param1 == @this.Param1</li>
<li>Param2 == @this.Param2</li>
<li>Param3 == @this.Param3</li>
<li>Param4, "date" == @this.Param4</li>
<li>Param5, "numbers" == @("[" + String.Join(", ", this.Param5.Select(num => num.ToString()).ToArray()) + "]")</li>
</ol>
@code {
[SupplyParameterFromQuery]
[Parameter]
public string Param1{ get; set; }
[SupplyParameterFromQuery]
[Parameter]
public int Param2{ get; set; }
[SupplyParameterFromQuery]
[Parameter]
public bool Param3{ get; set; }
[SupplyParameterFromQuery(Name ="date")]
[Parameter]
public DateTime Param4{ get; set; }
[SupplyParameterFromQuery(Name ="numbers")]
[Parameter]
public double[] Param5{ get; set; }
}
如下图,我们在浏览器地址栏中输入https://localhost:7116/?param1=abc¶m2=169¶m3=false&date=2023-01-27%2013:23:49&numbers=1.7&numbers=2.5&numbers=3.2&numbers=4.9
的话,运行结果如下所示:
基本的用法就如上所示,这里总结一下要点,再补充一些知识点:
SupplyParameterFromQuery
依然支持上面提到的八种CLR基本类型和基本的string
类型,但额外的
int?
与DateTime?
int[]
,和decimal?[]
?arr=[1,2,3,4,5]
或?arr=1,2,3,4,5
这种查询字符串的解析,而是?arr=1&arr=2&arr=3&arr=4&arr=5
这种风格的查询字符串SupplyParameterFromQuery
传递一个名为Name
的参数,比如上例中的Param4
和Param5
,它们在查询字符串中的参数名分别为date
和numbers
Found
入手,搞清楚App.razor
中的每行代码,顺便讲一下什么是布局组件现在我们介绍了<Router>
组件的基础知识,也介绍了@page
指令更多的细节,我们这里捋一下:
@page xxx
指令的组件App.razor
中,会调用<Router>
组件,并为其AppAssembly
属性赋值,那么在运行时,框架会去扫描AppAssembly
指代的dll,以反射的方式搜索出所有页面组件,并记录下它们的路由模板与组件类之间的映射关系Router
组件中Found
参数所指代的内容Router
组件中NotFound
参数所指代的内容我们再回头来看Found
组件参数的赋值:
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
...
</NotFound>
</Router>
我们前面分析过,Found
组件参数的类型,是RenderFragment<RouteData>
,<Found>
内部的另外两个组件,<RouteView>
与<FocusOnNavigate>
组件都捕获了Found
的Context:名为routeData
,类型为RouteData
的入参。
虽然重命名RenderFragment<T>
中入参变量名是一个非必要的事情,但官方模板还是把这引入参变量名修改为了routeData
,这是有一定的用意的。我们现在要搞清楚三个问题:
Router
组件在渲染时,调用Found
的时候,传入的RouteData
的实例到底是个什么东西?<RouteView>
组件是什么东西?连带着有两个问题:为什么它需要捕获routeData
,以及那个DefaultLayout
参数是怎么回事?<FodusOnNavigate>
组件又是个什么东西?为什么它也需要捕获routeData
?以及那个Selector
组件是怎么回事?我们一个个来分析
routeData
到底是什么要回答这个问题,最粗暴的解决方法是,翻开<Router>
组件的源代码,去仔细研究。。但这对于我们来说实在大可不必,我们其实并不是非常关心routeData
是从哪来的,我们只需要知道它是框架拼凑出来的一个对象就足够了,我们真正作为框架使用者应当关心的是:它是什么?它里面有哪些信息?
从名字,无论是类型名,还是官方项目模板改的变量名,都能看出来,这个由框架生成的对象,里面一定包含着一些与路由机制相关的信息。
所以,我们先来看这个类型是如何定义的,Microsoft.AspNetCore.Components.RouteData
的定义还算是简单明了,如下:
namespace Microsoft.AspNetCore.Components;
public sealed class RouteData
{
public RouteData([DynamicallyAccessedMembers(Component)] Type pageType, IReadOnlyDictionary<string, object> routeValues)
{
if (pageType == null)
{
throw new ArgumentNullException(nameof(pageType));
}
if (!typeof(IComponent).IsAssignableFrom(pageType))
{
throw new ArgumentException($"The value must implement {nameof(IComponent)}.", nameof(pageType));
}
PageType = pageType;
RouteValues = routeValues ?? throw new ArgumentNullException(nameof(routeValues));
}
/// <summary>
/// Gets the type of the page matching the route.
/// </summary>
[DynamicallyAccessedMembers(Component)]
public Type PageType { get; }
/// <summary>
/// Gets route parameter values extracted from the matched route.
/// </summary>
public IReadOnlyDictionary<string, object> RouteValues { get; }
}
撇开构造函数的注释,与类本身的注释不看,单独看两个公开成员的注释,其实我们已经非常清晰的能知道这个数据类型的设计目的了:它其中,存储的就是在路由匹配动作完成之后,有关路由的一些信息。
换句话说,是框架完成了路由匹配操作,并找到了一个匹配的组件的时候,会构造出一个RouteData
的实例,来存储下面两个信息:
PageType
属性RoutesValues
中去。根据RouteValues
的类型我们也能猜出来,如果模板参数附加了类型限制的话,这里存储的是已经经过类型转换后的参数值,是即将,或者已经赋给对应组件参数的值我们可以构造一具骚一点的例子来验证我们的猜想:首先,我们在Pages
目录下写一个特殊的组件RouteTest.razor
,代码如下:
@page "/RouteTest/param1/{param1}"
@page "/RouteTest/param2/{param2:int}"
@page "/RouteTest/param3/{param3:bool?}"
@page "/RouteTest/param4/{*param4}"
@page "/RouteTest/{param1}/{param2:int}/{param3:bool?}/{*param4}"
<h3>RouteTest</h3>
@code {
[Parameter]
public string Param1 { get; set; }
[Parameter]
public int Param2 { get; set; }
[Parameter]
public bool? Param3 { get; set; }
[Parameter]
public string Param4 { get; set; }
}
当我们写出这个组件的时候,就意味着<Router>
组件在运行时初始化时,就会搜索到这个组件类,就会把它脑门上五条模板路由都登记在册。
然后,我们魔改一下App.razor
,内容如下:
<Router AppAssembly="@typeof(App).Assembly" Found=@this.CustomFoundFunc>
<!--
<Found>
<RouteView RouteData="@context" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@context" Selector="h1" />
</Found>
-->
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@code {
private RenderFragment CustomFoundFunc(RouteData routeData)
{
Console.WriteLine(routeData);
return
@<div>
<h1>Found</h1>
<h2>PageType == @routeData.PageType.FullName</h2>
<h2>RouteValues</h2>
<ol>
@foreach(var kvp in routeData.RouteValues)
{
<li>key == @kvp.Key, value == @kvp.Value</li>
}
</ol>
</div>;
}
}
我们直接手搓一个Found
的值传递给Router
组件。这样就改写的程序的原始逻辑:路由匹配机制依然会工作,当我们访问诸如RouteTest/param1/abcdef
的时候,框架依然会做路由匹配操作,并且得出操作结果:
RouteTest
类param1
,字符串类型,值是abcdef
框架内部会把上面的信息打包,创建出一个RouteData
实例,并用这个实例去调用Router
组件下的Found
参数。正常情况下,Found
参数会触发RouteTest
类最终被渲染,但在本例中,Found
参数只是将框架生成的RouteData
实例打印在了页面上而已。
以上程序的运行结果如下所示:
与我们猜的完全一样。
RouteView
到底是个什么组件?在官方文档中,对于RouteView
的介绍非常精简,只有两句话:
At runtime, the RouteView component:
- Receives the RouteData from the Router along with any route parameters.
- Renders the specified component with its layout, including any further nested layouts.
翻译过来也非常直白:
Router
组件渲染过程中构造的)RouteData
对象好,我们先抛开“布局组件”这个东西不谈的话,那这个介绍还算是清晰明了,如果我们翻开RouteView
组件的源代码(里的核心方法),会更清晰:
namespace Microsoft.AspNetCore.Components;
public class RouteView : IComponent
{
[Parameter]
[EditorRequired]
public RouteData RouteData { get; set; }
[Parameter]
public Type DefaultLayout { get; set; }
// ...
protected virtual void Render(RenderTreeBuilder builder)
{
var pageLayoutType = RouteData.PageType.GetCustomAttribute<LayoutAttribute>()?.LayoutType
?? DefaultLayout;
builder.OpenComponent<LayoutView>(0);
builder.AddAttribute(1, nameof(LayoutView.Layout), pageLayoutType);
builder.AddAttribute(2, nameof(LayoutView.ChildContent), RenderPageWithParameters);
builder.CloseComponent();
}
// ...
private void RenderPageWithParameters(RenderTreeBuilder builder)
{
builder.OpenComponent(0, RouteData.PageType);
// 以下foreach循环是在将路由模板参数传递给组件
foreach (var kvp in RouteData.RouteValues)
{
builder.AddAttribute(1, kvp.Key, kvp.Value);
}
// 以下if代码块是在将查询字符串参数传递给组件
var queryParameterSupplier = QueryParameterValueSupplier.ForType(RouteData.PageType);
if (queryParameterSupplier is not null)
{
// Since this component does accept some parameters from query, we must supply values for all of them,
// even if the querystring in the URI is empty. So don't skip the following logic.
var url = NavigationManager.Uri;
ReadOnlyMemory<char> query = default;
var queryStartPos = url.IndexOf('?');
if (queryStartPos >= 0)
{
var queryEndPos = url.IndexOf('#', queryStartPos);
query = url.AsMemory(queryStartPos..(queryEndPos < 0 ? url.Length : queryEndPos));
}
queryParameterSupplier.RenderParametersFromQueryString(builder, query);
}
builder.CloseComponent();
}
}
上面剪裁掉了所有无关的字段、方法代码,并小小的修改了一下源代码,让逻辑更清晰。虽然方法RenderPageWithParameters
的实现还是触及了我们的一些知识盲区,但忽略掉我们目前看不懂的代码后,整体来看,结构是非常清晰的
RouteView
接受两个参数:RouteData
和DefaultLayout
。前者我们已经明白了是怎么回事了,后者只是一个类型,不难推测它的值应当是某个组件类Render
方法中的pageLayoutType
变量指代的类型
RouteData
中的页面组件本身,被LayoutAttribute
修饰,那么就取这个Layout
里面的“布局组件类”RouteData
中的页面组件本身,没有被LayoutAttribute
修饰,那么就使用传入的DefaultLayout
作为“布局组件类”TLayout
吧,另外把RouteData
中路由匹配的页面组件称为TPage
的话,其余的渲染逻辑,可以用类Razor标记语言的伪代码描述如下 <LayoutView Layout=@TLayout>
<TPage>
<!--
在调用 TPage 组件时,还额外做了两件事:
1. 把RouteData中保存的模板参数,传递给TPage的组件参数
2. 通过QueryParameterValueSupplier拿到请求URL的附加查询参数,并把查询参数也传递给TPage的对应组件参数
-->
</TPage>
</LayoutView>
好烦呀,怎么又冒出来个LayoutView
?
LayoutView
又是个什么东西?从上面RouteView
的源代码中能看出来,LayoutView
被调用时,接受了两个传递的参数:
Layout
的参数,类型是Type
,值是某个“布局组件类”ChildContent
,一个平平无奇的RenderFragment ChildContent
组件参数那么这个LayoutView
又起什么作用呢?这里我们可以直接把它的源代码拉出来品鉴一番:并不是很复杂
namespace Microsoft.AspNetCore.Components;
/// <summary>
/// Displays the specified content inside the specified layout and any further
/// nested layouts.
/// </summary>
public class LayoutView : IComponent
{
private static readonly RenderFragment EmptyRenderFragment = builder => { };
private RenderHandle _renderHandle;
[Parameter]
public RenderFragment ChildContent { get; set; } = default!;
[Parameter]
[DynamicallyAccessedMembers(Component)]
public Type Layout { get; set; } = default!;
/// <inheritdoc />
public void Attach(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}
/// <inheritdoc />
public Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
Render();
return Task.CompletedTask;
}
[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Layout components are preserved because the LayoutAttribute constructor parameter is correctly annotated.")]
private void Render()
{
// In the middle goes the supplied content
var fragment = ChildContent ?? EmptyRenderFragment;
// Then repeatedly wrap that in each layer of nested layout until we get
// to a layout that has no parent
var layoutType = Layout;
while (layoutType != null)
{
fragment = WrapInLayout(layoutType, fragment);
layoutType = GetParentLayoutType(layoutType);
}
_renderHandle.Render(fragment);
}
private static RenderFragment WrapInLayout([DynamicallyAccessedMembers(Component)] Type layoutType, RenderFragment bodyParam)
{
void Render(RenderTreeBuilder builder)
{
builder.OpenComponent(0, layoutType);
builder.AddAttribute(1, LayoutComponentBase.BodyPropertyName, bodyParam);
builder.CloseComponent();
};
return Render;
}
private static Type? GetParentLayoutType(Type type)
=> type.GetCustomAttribute<LayoutAttribute>()?.LayoutType;
}
首先,从参数上来说,它确实只定义了两个组件参数,就是我们上面提到过的ChildContent
和Layout
。其次,它的核心代码其实就在Render()
方法中,核心就是那个while
循环,如果我们夹杂一点伪代码,把那个WrapInLayout
再写的直观一点,那么核心代码其实可以写成下面这样:
var fragment = ChildContent ?? EmptyRenderFragment;
var layoutType = Layout;
while (layoutType != null)
{
fragment = @<layoutType Body=fragment></layoutType>;
layoutType = GetParentLayoutType(layoutType);
}
现在,我们回到我们的项目中,再看一眼App.razor
中调用RouteView
组件的地方:
...
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
...
不难发现
@typeof(MainLayout)
,是传递给RouteView.DefaultLayout
的值,最终会被传递给LayoutView.Layout
身上routeData
,是传递给RouteView.RouteData
的值,里面的PageType
,即匹配成功的页面组件类。用这个组件类+路由模板参数+查询字符串参数三者一起渲染出的渲染结果,最终会被传递给LayoutView.ChildContent
Pages/Index.razor
,并且Index.razor
中也没定义组件参数,所以不需要解析路由模板参数,也不需要从查询字符串扒参数,那么传递给LayoutView.ChildContent
的,其实就是<Index />
的渲染结果我们再来看#1中的字面量@typeof(MainLayout)
到底是个什么东西:它其实就是Client项目根目录下的MainLayout.razor
,官方生成的这个文件内容非常简单,内容如下:
@inherits LayoutComponentBase
<main>
@Body
</main>
它,就是一个最简单的,所谓的“布局组件”,而它用短短的四行代码,展示了布局组件的特点:
ComponentBase
的子类,布局组件是LayoutComponentBase
的子类RenderFragment?
,名为Body
的组件参数现在,将上面的知识杂糅起来,从<Router>
组件,到<RouterView>
组件,再到<LayoutView>
组件,终于我们要接触到核心的渲染逻辑了,我们将上面的核心while
循环代入实际传递的各种参数值,就能得到程序真正的运行逻辑
var fragment = @<Index></Index>;
var layoutType = typeof(MainLayout);
while (layoutType != null)
{
fragment = @<MainLayout Body=fragment></MainLayout>;
layoutType = layoutType.GetCustomAttribute<LayoutAttribute>()?.LayoutType;
}
而显然,MainLayout.razor
是没有额外被@layout
指令,或者LayoutAttribute
修饰的,所以上面的while
循环一次就退出了。所以最终的渲染结果其实就是:
<MainLayout>
<Body>
<Index></Index>
</Body>
</MainLayout>
再结合MainLayout.razor
的源代码,以及Pages/Index.razor
的源代码(我们把Pages/Index.razor
恢复到项目刚创建时最初的样子的话),不难得出,最终的,渲染成HTML的结果,如下:
<main>
<h1>Hello, world!</h1>
</main>
所以如果你跟着我的思路捋清楚了上面的渲染逻辑,那么你其实已经知识所谓的布局组件是什么东西了:其实就是一个定义了RenderFragment? Body
组件参数的普通组件而已。
只不过呢,在实际开发过程中:布局组件应当写成LayoutComponentBase
的子类,这样写的话,Body
参数的定义已经由父类声明了
从功能使用的角度来讲,普通组件是将页面中可重复的UI片段总结起来,函数化它,以便我们下次使用的时候不需要重复劳动。而布局组件则是把网页设计中属于“视觉框架”的部分总结起来,比如大多数网站在所有页面都共用的页眉、页脚、导航栏、搜索框等东西,把这些东西固化起来。
而框架生成的App.razor
在调用<RouteView>
组件的时候,需要的那个DefaultLayout
参数的语义则是:既然网站的大多数页面都要使用到一套“视觉框架”,那么作为程序员,你就先给我一套“默认视觉框架”,在例子中,这个东西就是MainLayout
。
框架在运行时,对于所有的“页面组件”,都会用MainLayout
把页面组件的渲染结果包起来,当成最终的渲染结果。而如果某个特殊的页面需要特殊处理,则根据我们查看RouteView
源代码时以下语句,就会知道:此时只需要页面组件自己显式使用@layout
指令为自己指定一个特定的布局组件即可。
// ...
protected virtual void Render(RenderTreeBuilder builder)
{
var pageLayoutType = RouteData.PageType.GetCustomAttribute<LayoutAttribute>()?.LayoutType
?? DefaultLayout;
// ...
这也是所谓的“默认”的含义:在页面组件没有指定自己的布局组件时,框架会使用传递给RouteView.DefaultLayout
作为布局组件。
我们上面创建的示例代码是使用dotnet new blazorwasm-empty
模板创建出来的,这个模板创建出来的布局组件太过简单了,接下来我们使用另外一个框架再创建一个Hosted WASM项目,来看一看稍微复杂一点的布局组件是长什么样子的:
HelloRouter/Client> cd ../../
> dotnet new blazorwasm --hosted o HelloLayout
> cd HelloLayout
HelloLayout> ./HelloLayout.sln
打开这个解决方案,会发现它比blazorwasm-empty
模板丰富了不少,运行起来后,我们能看到一个基于bootstrap样式库写出来的简单网站:侧边栏导航,共有三个页面,Home页面展示静态内容,Counter页面展示事件处理,Fetch Data页面展示前后端API互动。可以看出这个模板的设计是用过心的,这么一个简单的网站,基本把Blazor的基础能力都展示到了。
内容丰富了不少,但实际查看App.razor
的话,你会发现,跟blazorwasm-empty
模板生成的App.razor
是完全一样的:传递给RouteView
组件的DefaultLayout
参数的值也叫@typeof(MainLayout)
,但是这次啊,这个MainLayout
是有点东西的。
首先是目录结构上,会发现Client项目下多了一个叫Shared的目录,而MainLayout.razor
就在里面,这个Shared其实就是用来存放布局组件以及非页面组件的目录。
查看代码的话,会发现这次的MainLayout.razor
不光多了很多内容,其内部其实还调用了NavMenu
组件。
接下来我们来玩个有意思的东西。
在上面创建的HelloLayout
项目中,三个页面都是默认渲染在MainLayout
布局下的。三个页面分别对应着:静态内容、本地交互、API交互三个场景。
我现在提个需求:既要这三个页面保持原有的样式基本不变,还要给三个页面分别写上它们对应的场景:static content scenario, interactive scenario和api calling scenario。
而且假如说后续我还要开发300个页面组件,每个页面都要标记上它们所属的场景是哪一个。怎么办?
这个解决办法有很多,不过为了强行向我们的知识点:布局组件上靠拢,我们选择开发三套布局组件。
不过现有的MainLayout
其实已经做了99%的工作了,我们其实是要在MainLayout
的基础上,扩展出三个不同的分支而已。
这时,我们就能用到一个Blazor中用来扩展布局组件的特性:嵌套布局组件了。简单来说,就是布局组件本身,也可以有@layout
指令修饰。
再回想起我们上面看LayoutView
渲染时的核心代码:
var fragment = ChildContent ?? EmptyRenderFragment;
var layoutType = Layout;
while (layoutType != null)
{
fragment = @<layoutType Body=fragment></layoutType>;
layoutType = GetParentLayoutType(layoutType);
}
如果布局组件A
脑门上写着@layout B
,而组件B的脑门上写着@layout C
的话,实际的渲染逻辑是:
A
先与被渲染的页面一起,渲染出A+Body
,即<A><Body>...</Body></A>
Body
,被B
包裹起来,渲染出<B><A><Body>...</Body></A></B>
C
包裹起来,渲染出<C><B><A><Body>...</Body></A></B></C>
换个角度来说:如果我们想扩展某个已存在的布局组件的内容,只需要写一个新的布局组件,然后在新布局组件中使用@layout xxx
引用原有布局组件即可。。以此为指导,我们就可以在Shared
目录中新加一个StaticPageLayout.razor
,代码如下:
@inherits LayoutComponentBase
@layout MainLayout
<h1>This is a static content page</h1>
@Body
照猫画虎的,我们还能写出InteractivePageLayout.razor
和APICallingPageLayout.razor
,代码分别如下:
@inherits LayoutComponentBase
@layout MainLayout
<h1>This is an interactive page</h1>
@Body
@inherits LayoutComponentBase
@layout MainLayout
<h1>This is a page with API calling behind</h1>
@Body
然后我们只需要在对应三个页面上,使用@layout
指令显式的指定对应的布局组件即可:
+@layout StaticPageLayout
@page "/"
...
+@layout APICallingPageLayout
@page "/fetchdata"
...
+@layout InteractivePageLayout
@page "/counter"
...
运行效果如下:
其实呢,上面的例子虽然蹩脚,但已经能讲清楚“布局嵌套”这个知识点了。只不过在介绍完“布局嵌套”之后,我们不得不承认,为每个页面都手动添加一条@layout
指令,真的太麻烦了。假如真的在工作中遇到一个项目有上百个页面,而布局组件要做出如上调整的时候,一个个的去给上百个页面组件每个脑门上都加一个@layout
指令,实在是一个非常窒息的解决办法。
这时候,我们就要把目光转回App.razor
里了,我们可以把所有@layout
指令从页面组件上拿掉,然后将App.razor
改写为如下的模样,来实现之前的效果:
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
- <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
+ <RouteView RouteData="@routeData" DefaultLayout=@GetLayoutComponentBasedOnRouteData(routeData) />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
+
+@code {
+ public static Type GetLayoutComponentBasedOnRouteData(RouteData routeData)
+ {
+ if(routeData.PageType == typeof(Pages.Counter))
+ {
+ return typeof(InteractivePageLayout);
+ }
+
+ if (routeData.PageType == typeof(Pages.FetchData))
+ {
+ return typeof(APICallingPageLayout);
+ }
+
+ if(routeData.PageType == typeof(Pages.Index))
+ {
+ return typeof(StaticPageLayout);
+ }
+
+ return typeof(MainLayout);
+ }
+}
以上的改动虽然看着简单,但能写出这样的代码来解决问题的前提包括:
<Router>, <RouterView>, <LayoutView>
的工作原理,明白RouteData
的本质。这其实就是要理解路由机制背后的工作机制。所以啊,你不要嫌我啰嗦,一个烂路由的知识点讲了这么久。你光看着很多tutorial和快速入门,路由就讲三分钟,上手很迅速,但这知识点上欠下的债啊,迟早会在某天,以“以愚蠢的方式解决问题”为代价让你偿还的。
FocusOnNavigate
组件与PageTitle
组件截止目前为止,我们几乎快把Router.Found
参数讲完了,其实也快把NotFound
也讲完了。除了最后两个小知识点:FocusOnNavigate
组件与PageTitle
组件
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
这两个组件很有意思。我们到现在为止接触过三种组件:普通组件,页面组件与布局组件,这三种组件无论是哪种,在被渲染时,都会在页面上渲染点东西出来让用户看见。当然你也可以抬杠说,我可以写个空组件内部不渲染任何东西,而去做一些不可见的工作。这个杠咱就先不硬抬了。
而这两个框架提供的组件,并没有向页面上渲染任何可见的HTML元素,而是执行了一些后台工作。
<FocusOnNavigate>
组件的功能,是将当前页面的焦点定位在指定元素处。它本身是个空组件,就像你抬杠时说的那种空组件一样,它只是在OnAfterRenderAsync
生命周期函数中,调用JS代码实现了这一功能。这一点直接看它的源代码就能窥探一二:
// ...
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_focusAfterRender)
{
_focusAfterRender = false;
await JSRuntime.InvokeVoidAsync(DomWrapperInterop.FocusBySelector, Selector);
}
}
// ...
我们目前还未触及Blazor框架如何与JS代码交互这块知识,知道有这么个事就行了。唯一需要提一嘴的是,这个组件不光需要一个参数来让调用方说明焦点应该在哪里,即Selector
参数,还需要传入routeData
,这是出于什么考量呢?
从这个组件的名字上你应该能猜得出来,这个组件的设计目的,并不是“提供了一个小工具来定位页面焦点”,而是在“页面跳转后来重新定位页面焦点”,它的设计目的是用在页面跳转相关场合的。在实际开发过程中,这个组件也很少用在其它场合,基本就只出现了App.razor
中,所以知道这么个事就行了。
另外值得说一嘴的就是Selector
参数:它是用来指定“焦点应当在哪里”的一个参数,它的值应当是一个标准的CSS选择器,这个选择器最终会传入框架附带的JS代码中,由JS去执行这个选择器,最终焦点会落在页面上第一个匹配选择器的DOM元素上。
不过,如果选择器书写有误的话,是不会有任何日志提示,或者错误提示的。这个时候就需要知道如何查看当前页面上的焦点到底位于哪个DOM元素身上:简单来说,在浏览器调试窗口,查看document.activeElement
即可。下面就是一种简单的方式:
有关FocusOnNavigate
的知识就介绍这么多。接下来看位于NotFound
组件内部的PageTitle
组件。
PageTitle
组件同样是一个空组件,但它的内部实现要比FocusOnNavigate
复杂很多,不过它的实际作用却十分简单:更改当前页面的标题。
或者你可以理解为,PageTitle
并不是一个所谓的“空组件”:因为当我们谈及空组件的时候,说的是这个组件不去修改、添加页面DOM树,而PageTitle
组件实际会修改页面的<head>.<title>
元素中的值。
在blazorwasm
模板生成的HelloLayout
项目中,<PageTitle>
组件不光用在了App.razor
中,将路由匹配失败后的页面标题更改为"Not Found"
,还用在了Pages
目录下的所有三个页面组件中:
@page "/counter"
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
...
@page "/fetchdata"
@using HelloLayout.Shared
@inject HttpClient Http
<PageTitle>Weather forecast</PageTitle>
<h1>Weather forecast</h1>
...
@page "/"
<PageTitle>Index</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?" />
...
一个很自然的问题是:如果在渲染一个页面的时候,<PageTitle>
组件出现了多次,比如在页面中重复调用这个组件,或者在子组件某个犄角旮旯处调用了它,会有什么行为?
这个问题的答案是:不要这样做。研究<PageTitle>
组件被调用多次后的行为是一件没有意义的事情,真正有意义的是,应当在开发过程中避免多次调用<PageTitle>
,一个良好的最佳实践是:
<PageTitle>
组件。不在布局组件、普通组件中使用它到此为止,我们已经完全摸清楚了App.razor
中每一行代码的作用,及部分背后原理。是时候为这个章节做个总结了
这一章节的内容还是比较多的,我们的主线是介绍Blazor框架中的路由机制,辅线是介绍了布局组件,以及其它一些零散的知识点。
值得在小结部分再次总结并强调的两个大知识点是:路由机制的运行原理、三种组件的最佳实践
我将路由机制,其实就是App.razor
再梳理一遍:
<Router>
组件
AppAssembly
,让<Router>
组件在初始化过程中,扫描整个assembly,建立“路由模板”与“页面组件类”的映射表Router.Found
用以在路由匹配成功时渲染,Router.NotFound
用以在匹配失败时渲染Router.Found
的类型是RenderFragment<RouteData>
,其中routeData
是路由匹配成功后,在Router
组件内部构造出的一个对象,这个对象存储了两部分信息:
PageType
:路由匹配成功的页面组件类RouteValues
: 路由匹配成功后,框架解析出来的路由模板参数Router.Found
内部调用了<RouteView>
组件,该组件捕获了routeData
,另外传入了默认渲染模板组件,即为MainLayout
<RouteView>
主要负责将框架解析出来的routeData.RouteValues
,即路由模板参数,与解析查询字符串得来的参数,传递给匹配成功的页面组件类以供进一步渲染<RouteView>
内部调用了<LayoutView>
组件来负责布局组件与页面组件的融合渲染,在<LayoutView>
组件中,最核心的逻辑是用一个while
循环去处理布局组件的嵌套渲染<FocusOnNavigate>
是一个特殊组件:不渲染任何可视内容,只是去改写页面焦点定位Router.NotFound
的类型是RenderFragment
Router.NotFound
内部同样调用了<LayoutView>
组件,上面说过了,<LayoutView>
组件最核心的功能是处理布局组件的嵌套渲染。<PageTitle>
组件也不渲染可视内容,但从DOM角度来说,它还是有渲染成果的:它只是改写了<head>.<title>
元素的内容
<PageTitle>
,最佳实践是:
<PageTitle>
从使用场景上来分类的话,目前我们接触了三种组件:普通组件、页面组件、布局组件。我知道你准备说模板组件,但那个是技术细节角度的分类。
在开发工作中,为了避免一些奇怪的问题,我们一定要分清三类组件的功能与用途:
Pages
中,并按路由模板路径划分子目录,尽量保持页面组件的路由模板路径,与razor
文件的路径一致@page
指令,它就是用来参与路由机制运行的。它只应当被路由机制调用,而不应当被人为的当成普通组件去随意调用[SupplyParameterFromQuery]
去从查询字符串中去取值Layouts
。布局组件一般不会太多,所以不太需要再划分子目录精细管理。LayoutComponentBase
的子类,需要显式的写@inherits LayoutComponentBase
指令。因为Razor引擎默认会生成一个ComponentBase
的子类。Components
,并按功能领域划分子目录。[Parameter]
以及[CascadingParameter]
,不应当出现其它类型的组件参数。<RouteView>
的源代码就明白了。