Blazor教程 第九课:布局组件与路由机制

Blazor

在前面几篇文章中,我们耗费了很多笔墨去介绍“组件”的相关知识,一步一步的,从平地起大厦,一砖一瓦的几乎把所有Blazor框架中有关“组件”的知识都给大家介绍了个遍,但唯独遗漏了一个非常重要的知识点:布局组件。

我这样做是有原因的:我自己尽我可能的想给大家描绘一条舒缓的学习曲线,让系列文章有连贯性与前后因果,而不是列流水账一样的把框架所有知识点罗列出来。之所以把没有把布局组件这个知识点,放在“组件”这个大话题下去讲,是因为布局组件本身与另外一个重要知识体系是有关联的,就是标题中提到的路由。

这个关联并不是说这两个知识点之间有什么联系,而是因为路由组件本身经常是搭配着布局组件使用的,无论是在解读代码还是在解释路由相关的知识点的时候,布局组件都会掺和进来。所以我们直接在一篇文章中,把这两个东西都介绍掉。不过本篇文章主要还是以路由相关的知识点为主,布局组件是一个相对比较简单的知识点(前提是你已经掌握了我们前面系列文章介绍的内容)。

1. 我们来观察一个官方模板生成的Hosted WASM项目来做引子

之前的文章中,我们示例所使用的代码大多都是在我们手撸出的解决方案的基础上跑起来的,在介绍如何构建一个前后端一把梭的解决方案的那篇文章中,我们也是着重介绍了如何手撸出一个解决方案的骨架出来,而不是直接使用官方模板。当时也提到了这样做的原因:因为官方模板中多多少少都包含着一些我们有前期无法解释的知识点与概念。即便是在blazorwasm-empty中也包含着一些我们在早期不太好解释的知识点。

今天,时机成熟了,我们来清点一下blazorwasm-empty中剩下的知识点

如下所示,新建一个解决方案,下面这个解决方案将是后续示例代码的运行方案:

> dotnet new blazorwasm-empty -o HelloRouter --hosted
> cd HelloRouter

默认模板生成的解决方案中有以下几点需要注意:

  1. 所有项目文件,即三个*.csproj文件,天生都自带<ImplicitUsings>enable</ImplicitUsing>这个声明。这个特性是自dotnet 6.0版本后新加的一个特性,主要功能就是工具链自动的为所有代码文件引入一些常用的namespace。
    • 这个特性的实现方式也非常简单粗暴,如果你编译整个解决方案的话,会在三个项目的obj/Debug/net7.0目录下看到一个名为HelloRouter.[Client/Server/Shared].GlobalUsings.g.cs文件,里面通过global using ...的方式将常见常用的namespace都给整了进来
    • 这个要不要关掉,纯粹看个人爱好,一方面这个特性确实让代码文件清爽了不少,但另一方面会让某些程序员有一种不安全的感觉,要不要用,纯粹看个人爱好。
  2. 所有项目文件,都携带了<Nullable>enable</Nullable>的声明,表示提醒工具链做必要的空指针检查,并且在有风险的代码处报warning。这个建议保持原样。
  3. 所有项目文件,特别是Client文件,是不带<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>。这意味着默认情况下框架并不会把Razor转译后的C#代码文件放在编译产出目录中,我们也就没法观察Razor引擎的工作方式。我个人建议把这个特性打开,无论是在学习过程中,还是开发过程中,观察Razor引擎的转译结果对理解razor语法、v-dom、生命周期、组件、参数这些基础知识点都非常有帮助。
  4. Client项目下,默认模板会生成一个_Imports.razor组件,这个组件内部的实现是空的,只是写了很多@using指令。
    • 工具链会对项目目录下名为_Imports.razor的组件做特殊处理,简单来说,会把它内部写的@using指令以复制粘贴的形式贴给项目中的所有*.razor文件。
    • 这相当于是只针对*.razor文件的global using
    • 还记得Microsoft.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是这个组件下的一个组件参数,接受类型信息。

而像FoundNotFound两个东西,就非常像是Router组件的两个类型为RenderFragment的组件参数,通过XML子元素的方式进行参数传递。不过就我们目前的知识储备来说,还存在有一种可能,即FoundNotFound是两个另外的组件,它们的渲染结果拼在一起,传递给了Router.ChildContent

Found本身还接受了一个名为Context的参数这个写法来看,后者的可能性更大一些。不过查看源代码后,你就会发现,真相是,FoundNotFound都是组件参数,在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>是这样一个函数指针:

  1. 它接受一个类型为RouteData的参数
  2. 它返回一个RenderFragment

所以Found的字面量可以写成下面这样的Lambda表达式:

    var foundLiteral = (RouteData routeData) => {
        RenderFragment res = ...;
        return res;
    }

它描述的语义是:如何去渲染类型为RouteData的数据

另外,RenderFragment本身也是个函数指针类型:

  1. 它接受一个参数为RenderTreeBuilder的参数
  2. 它没有返回值,函数实现应该在内部调用参数来向v-dom添加枝桠

所以进一步的,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去取值,并把参数分别传给RouteViewFocusOnNavigate

但在外部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">到底是什么意思?现在应该能猜出来了,这也是我们之前讲模板组件时没有提到的一个边角知识点:

  • 在通过XML子元素为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>

是完全等价的。

2. 有关路由,即<Router>组件的基础知识

好,从细节里抬起头,调整一下视角,暂时性的总结一下,目前,我们所知道的内容如下:

  1. <Router>是一个框架自带的组件,它有三个参数:
    • AppAssembly,类型是Assembly,尚不得知有什么用
    • Found,类型是RenderFragment<RouteData>。尚不得知这个RouteData类型是啥玩意,也不知道执行时传递给这个FoundRouteData实例是从哪来的。
    • NotFound,类型是RenderFragment
  2. Found赋值的体内,连续渲染了两个组件:RouteViewFocusOnNavigate,它们都捕获了传递给FoundRouteData实例作为自己的RouteData组件参数的值。(有点绕,你品一下)
    • RouteView还接受一个名为DefaultLayout的组件参数,实际是把MainLayout.razor组件的类型信息传递给了它
    • FocusOnNavigate还接受了一个字符串组件参数,名为Selector,值是h1,不知道有什么软用
  3. NotFound赋值的体内,连续渲染了两个组件:
    • PageTitle组件看起来非常简单,将一个字符串传递给了它的ChildContent
    • LayoutView组件稍微复杂了一点:
      • 将一个HTML paragraph传递给了它的ChildContent组件参数
      • MainLayout.razor的类型信息传递给了它的Layout组件参数

简直有太多的疑问了,不过不要着急,我们一个个的看。

现在我们从写法上,已经搞明白了App.razor里的代码是在干什么,只是我们不知道的是诸如<Router><RouteView>, <LayoutView>等这些组件的功能是什么而已。我们来一个个的介绍,一层层的介绍,循序渐进的介绍。

2. 什么是路由

设想一下从用户在浏览器地址栏敲入网址,到页面上展示出内容的整个过程中,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,并把其进行渲染。

魔法是什么呢?魔法有两个关键:

  1. 在WASM程序开始运行时,即App.razor开始渲染的时候,框架一定以某种方式,获取到了用户的请求路径,知道了用户要请求的路径是/Counter
  2. App.razor内部,一定通过某种方式,扫描了整个Client.dll中所有脑门带@page指令的所有组件
  3. 二者一结合,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,在匹配请求路径的时候,显然会得到两种结果:要么用户的请求路径确实能与某个页面组件匹配上,要么并不存在这样一个页面组件。框架要为两种情况都做打算:

  1. 对于匹配成功的请求,那没得说,显然是要去渲染对应的页面组件
  2. 对于匹配不成功的请求,我们就需要在页面上显示一些提示信息

而这两个分支的具体行为,就是通过Router组件的FoundNotFound两个参数指定的。

所以现在再来看下面这块代码,是不是就又理解了那么一点点呢?

<Router AppAssembly="@typeof(App).Assembly">
    <Found>
        < ...>
    </Found>
    <NotFound>
        < ...>
    </NotFound>
</Router>

好了,到此为止,有关路径的基本概念就讲完了,关键点如下:

  1. 脑门上有@page指令的组件,我们为了描述方便,把它称为“页面组件”
  2. Router组件是负责路由的核心组件
    • 通过AppAssembly来指定一个assembly,Router组件内部在正式工作之前,会对这个assembly中的所有页面组件进行登记,记下它们所匹配的路径与组件类之间的映射关系
    • 当开始工作时,Router组件内部会去做路径匹配工作
      • 当匹配成功时,Router就会去渲染由Found参数传递进来的内容
      • 当匹配不成功时,Router就会去渲染由NotFound参数传递进来的内容

有关路径的“基本知识”,就是这么的简单。

接下来,在继续介绍FoundNotFound参数的细节之前,我们需要从用法层面上,再介绍几个知识点:

3. @page指令

3.1 @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呢?难道它也是模板?它模了什么板?

诶,你这么想,就对了

3.2 几种路由模板

在Blazor中,我们把传递给RouteAttribute构造函数的实参,也就是@page指令后面的内容,那个字符串,叫路由模板

我们的系列文章中已经好多次解释过“模板”这个词语在编程领域的语义:90%内容写死,10%信息待填充。路由模板也不例外。

死模板:写死的路径

我们之前接触的所有示例代码都没有展示它模板的那一面:因为我们只是把写死的路径传递给RouteAttribute的构造函数:100%的内容都写死了,没什么可模的板。这就是第一种路由模板:死模板。

这没什么可说的,就如它的名字一样:写死了。就比如@page "/"@page "/Counter"一样。

模板参数:将路径中的一部分转换为组件参数传递给组件

模板参数,或者叫参数模板,两个名字叫法不一样,侧重点不一样,但其实描述的是同一个东西,我直接举个例子你就明白了:

比如现在我们给某个页面的脑门上写上@page "/ProductDetail/{ProductId}",这时,传递给RouteAttribute构造函数的字符串将变成"/ProductDetail/{ProductId}"。这个字符串看起来不像是正经字符串,因为这个大括号里面括起来的东西太像一个占位符了。

你说的没错,这就是路由参数占位符,它有两层含义:

  1. 首先,在做路由匹配时,如果用户的请求路径是/ProductDetail/123,会被匹配到这个页面上,解析过程中,路由参数的值会被解析为123(显然路由参数的名是ProductId)。同理,如果用户的请求路径是/ProductDetail/Apple,路由参数的值会被解析为Apple
  2. 解析出来的“路由参数”,其值会被传递给同名的组件参数,如果同名的组件参数不存在,那么页面会抛出一个异常。

所以路由参数其实是组件参数赋值的一种方式,或者换句话来说,对于组件参数来说,就我们目前所知的,有三种赋值方式:

  1. 对于[Parameter],由其父组件在调用时赋值
  2. 对于[CascadingParameter],由其祖先组件使用<CascadingValue>进行赋值
  3. 对于[Parameter],还可以通过路由模板的设定,从用户的请求路径中解析值

有关模板参数的基础知识就介绍完毕了,这里有两个小知识点需要大家额外注意一下:

  1. 有关大小写的问题。
    • 首先是路由模板参数名,与组件参数名之间的映射,它们之间是不区分大小写的,什么意思呢?意思是@page /ProductDetail/{productId}[Parameter]public string ProductId{get; set;}是能匹配起来的。
    • 其次是模板参数值的大小写,这东西是区分大小写的,框架不会做什么特殊处理,这是什么意思呢?意思是用户请求的路径如果是/ProductDetail/aBcD,那么解析出来的模板参数值就是aBcD,最终传递给组件参数的值也会是"aBcD"
  2. 模板参数是要独占一个路径级别的。这是什么意思呢?有两层意思
    • 首先是,像@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!;
}

我们先猜一下运行结果:按照我们朴素的直觉经验,很容易做出如下猜想:

  1. 如果模板参数的值可以被转换为整数,那么框架应该为我们自动转换,比如用户请求/Product/233的时候,程序应当正常运行,组件参数ProductId应该被赋值为233
  2. 如果模板参数的值无法被转换为整数,那么有两种可能的情形
    • 框架在背后做数据类型转换的时候,用的是xxx.TryParse()不抛异常的版本,那么此时程序不会招异常,但ProductId的值会被赋值为default,对于整型来说,是0
    • 框架在背后做数据类型转换的时候,用的是xxx.Parse()招异常的版本,程序应该抛异常终止运行

那么,我们试运行一下吧,结果如下图:

cast_ex

很不幸,我们猜错了,框架并没有为我们做“符合直觉的自动类型转换”,而是直接抛出了一个异常: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文档中,你都找不到如何自定义类型转换器,让模板参数的值转换为复杂类型。这明显是框架设计者有意为之的。这背后的原因也非常显而易见:

  • URL也好,请求路径也好,说破天就是字符串,本身通过请求路径本身传递“参数”就是一个值得商榷的行为了,你把参数全放路径里,你让查询参数颜面何存?只不过/ProductDetail/{ProductId}这种设计太好用了,框架顺手用“模板参数”给你支持了而已。还想在这上面搞花活?对不起请不要太放飞自我。

但是呢,话又说回来,真是由于/ProductDetail/{ProductId}这种设计太流行了,Blazor也知道真的就只传个字符串进来,也确实在某些开发场合下不太方便,所以框架还是提供了一些,有限的,类型转换能力。

带类型限制的模板参数:限制参数必须是指定CLR基础类型

我们可以在声明路由模板的时候,给路径模板参数添加额外的类型约束信息,来告诉框架:嗨,这个参数应当是数值/布尔值/日期,请帮我做自动转换。

比如上面的示例代码,我们可以如下修改:

@page "/Product/{ProductId:int}"

<h1>ProductId == @this.ProductId</h1>

@code {
    [Parameter]
    public int ProductId { get; set; } = default!;
}

语法就如上所示:在模板参数名后面,加个冒号,再加上具体的类型名,框架就会自动的把模板参数的值,从字符串转向指定的类型。但这里有几个知识点需要注意:

  1. 框架所支持的类型转换的类型是比较有限的,只有八个:bool, datetime, decimal, double, float, guid, int, long。其它类型都不支持,想都不要想
  2. 使用类型限制的实现,与我们上面自行迂回的NumericProductId的实现,二者的区别主要在于,当用户请求的路径中的模板参数的值,无法被成功转换为指定类型时,程序的行为不一致
    • 使用类型限制的路由模板时,如果类型转换不成功,那么这个路由匹配就不会成功。比如上例中如果用户请求的是/Product/abc的话,路由的匹配结果会转向NotFound
    • 而我们自行实现的迂回版本,会直接抛异常。即便我们妥善处理了异常情况,路由匹配结果也是无法改写的:路由是成功匹配了的
  3. 框架支持的八个类型中,有两个类型需要注意:
    • 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}"两种格式都支持
  4. string本身并不在八种类型之间,所以有个很吊诡的事:{ProductId:string}会引起运行时异常。

对类型限制的扩展:可选参数

模板参数的类型限制支持八种CLR基础类型,这些基础类型都是值类型。框架在此基础上,还添加了一个特性:可以将路径模板参数声明为可选参数,有两个操作需要做:

  1. 在路由模板声明时,后面加上问号
  2. 在组件参数类型声明时,声明为Nullable类型

比如下面这个例子,我们把上面的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作为模板参数的值,传递给了组件参数。运行效果如下:

optional_param

而显然,与程序设计语言中函数领域的“可选参数”一样:路由模板中的可选参数,可以有多个,但可选参数之后不应当再有其它参数。什么意思呢?

@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_param

这种特殊的模板参数的官方名称叫catch all parameter。它有以下几个特点:

  1. 在请求路径为/Product/Product/时,框架为参数赋值为null
  2. 它不能同时作为可选参数,即@page "/Product/{*ProductDetails?}"这种写法是错误的,会抛运行时异常
  3. 它不能有类型限制,即@page "/Product/{*ProductDetails:guid}"这种写法也是错误的,也会抛运行时异常。它对应的组件参数类型只能是stringstring?

4. 将URL查询字符串转换为组件参数

我们上面提到过,在请求路径里传递参数其实是一件值得商榷的事,因为按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&param2=169&param3=false&date=2023-01-27%2013:23:49&numbers=1.7&numbers=2.5&numbers=3.2&numbers=4.9的话,运行结果如下所示:

query_string

基本的用法就如上所示,这里总结一下要点,再补充一些知识点:

  1. 和路由模板参数一样,SupplyParameterFromQuery依然支持上面提到的八种CLR基本类型和基本的string类型,但额外的
    • 还支持这九种类型的可空类型变体,比如int?DateTime?
    • 还支持数组,比如int[],和decimal?[]
  2. 框架在做数据类型转换的时候,依然走的是invariant culture的设定
  3. 对于数组,框架并不支持?arr=[1,2,3,4,5]?arr=1,2,3,4,5这种查询字符串的解析,而是?arr=1&arr=2&arr=3&arr=4&arr=5这种风格的查询字符串
  4. 可以额外给SupplyParameterFromQuery传递一个名为Name的参数,比如上例中的Param4Param5,它们在查询字符串中的参数名分别为datenumbers

5. 从Found入手,搞清楚App.razor中的每行代码,顺便讲一下什么是布局组件

现在我们介绍了<Router>组件的基础知识,也介绍了@page指令更多的细节,我们这里捋一下:

  1. 首先,程序员会在项目中书写很多页面组件,所谓页面组件,就是带@page xxx指令的组件
  2. 其次,在App.razor中,会调用<Router>组件,并为其AppAssembly属性赋值,那么在运行时,框架会去扫描AppAssembly指代的dll,以反射的方式搜索出所有页面组件,并记录下它们的路由模板与组件类之间的映射关系
  3. 当用户请求来临时,路由机制会去#2中记录的映射关系中去找一个与当前请求路径最匹配的路由模板:
    • 如果找到了,那么就去渲染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,这是有一定的用意的。我们现在要搞清楚三个问题:

  1. Router组件在渲染时,调用Found的时候,传入的RouteData的实例到底是个什么东西?
  2. <RouteView>组件是什么东西?连带着有两个问题:为什么它需要捕获routeData,以及那个DefaultLayout参数是怎么回事?
  3. <FodusOnNavigate>组件又是个什么东西?为什么它也需要捕获routeData?以及那个Selector组件是怎么回事?

我们一个个来分析

5.1 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的实例,来存储下面两个信息:

  1. 路由匹配的结果中,对应的页面组件的类是谁:即PageType属性
  2. 路由匹配完成后,如果参与匹配过程的路由模板中包含模板参数,那么把参数的名字和值存储在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的时候,框架依然会做路由匹配操作,并且得出操作结果:

  1. 匹配的页面组件类是RouteTest
  2. 对应的模板参数有一个,参数名为param1,字符串类型,值是abcdef

框架内部会把上面的信息打包,创建出一个RouteData实例,并用这个实例去调用Router组件下的Found参数。正常情况下,Found参数会触发RouteTest类最终被渲染,但在本例中,Found参数只是将框架生成的RouteData实例打印在了页面上而已。

以上程序的运行结果如下所示:

routeData

与我们猜的完全一样。

5.2 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.

翻译过来也非常直白:

  1. 接收框架构造的(其实是Router组件渲染过程中构造的)RouteData对象
  2. 在指定的布局组件内部,渲染对应的页面组件,如果布局组件有嵌套,那么也会递归渲染所有嵌套的布局组件

好,我们先抛开“布局组件”这个东西不谈的话,那这个介绍还算是清晰明了,如果我们翻开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的实现还是触及了我们的一些知识盲区,但忽略掉我们目前看不懂的代码后,整体来看,结构是非常清晰的

  1. RouteView接受两个参数:RouteDataDefaultLayout。前者我们已经明白了是怎么回事了,后者只是一个类型,不难推测它的值应当是某个组件类
  2. 在渲染时,渲染逻辑优先去寻找一个“布局组件类”,就是Render方法中的pageLayoutType变量指代的类型
    • 如果RouteData中的页面组件本身,被LayoutAttribute修饰,那么就取这个Layout里面的“布局组件类”
    • 如果RouteData中的页面组件本身,没有被LayoutAttribute修饰,那么就使用传入的DefaultLayout作为“布局组件类”
  3. 布局组件类找到后,我们把它暂时记为TLayout吧,另外把RouteData中路由匹配的页面组件称为TPage的话,其余的渲染逻辑,可以用类Razor标记语言的伪代码描述如下
    <LayoutView Layout=@TLayout>
        <TPage>
        <!-- 
            在调用 TPage 组件时,还额外做了两件事:
            1. 把RouteData中保存的模板参数,传递给TPage的组件参数
            2. 通过QueryParameterValueSupplier拿到请求URL的附加查询参数,并把查询参数也传递给TPage的对应组件参数
        -->
        </TPage>
    </LayoutView>

好烦呀,怎么又冒出来个LayoutView?

5.3 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;
}

首先,从参数上来说,它确实只定义了两个组件参数,就是我们上面提到过的ChildContentLayout。其次,它的核心代码其实就在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)" />
    ...

不难发现

  1. 字面量@typeof(MainLayout),是传递给RouteView.DefaultLayout的值,最终会被传递给LayoutView.Layout身上
  2. 路由匹配成功后的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>

它,就是一个最简单的,所谓的“布局组件”,而它用短短的四行代码,展示了布局组件的特点:

  1. 普通组件都是ComponentBase的子类,布局组件是LayoutComponentBase的子类
  2. 布局组件都有一个类型为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>

5.4 布局组件

所以如果你跟着我的思路捋清楚了上面的渲染逻辑,那么你其实已经知识所谓的布局组件是什么东西了:其实就是一个定义了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的基础能力都展示到了。

blazorwasm_template

内容丰富了不少,但实际查看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的话,实际的渲染逻辑是:

  1. A先与被渲染的页面一起,渲染出A+Body,即<A><Body>...</Body></A>
  2. 然后以上的结果整体,作为Body,被B包裹起来,渲染出<B><A><Body>...</Body></A></B>
  3. 再递归一次,以上的渲染结果,再被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.razorAPICallingPageLayout.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"
 ...

运行效果如下:

nested_layout

运行时动态选择布局组件

其实呢,上面的例子虽然蹩脚,但已经能讲清楚“布局嵌套”这个知识点了。只不过在介绍完“布局嵌套”之后,我们不得不承认,为每个页面都手动添加一条@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);
+    }
+}

以上的改动虽然看着简单,但能写出这样的代码来解决问题的前提包括:

  1. 理解<Router>, <RouterView>, <LayoutView>的工作原理,明白RouteData的本质。这其实就是要理解路由机制背后的工作机制。
  2. 理解布局组件的本质,理解布局组件的嵌套原理

所以啊,你不要嫌我啰嗦,一个烂路由的知识点讲了这么久。你光看着很多tutorial和快速入门,路由就讲三分钟,上手很迅速,但这知识点上欠下的债啊,迟早会在某天,以“以愚蠢的方式解决问题”为代价让你偿还的。

5.5 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即可。下面就是一种简单的方式:

focus

有关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>,一个良好的最佳实践是:

  1. 只在页面组件中使用一次<PageTitle>组件。不在布局组件、普通组件中使用它
  2. 不要以调用子组件的方式去调用某个页面组件

6. 小结

到此为止,我们已经完全摸清楚了App.razor中每一行代码的作用,及部分背后原理。是时候为这个章节做个总结了

这一章节的内容还是比较多的,我们的主线是介绍Blazor框架中的路由机制,辅线是介绍了布局组件,以及其它一些零散的知识点。

值得在小结部分再次总结并强调的两个大知识点是:路由机制的运行原理、三种组件的最佳实践

6.1 路由机制的运行原理

我将路由机制,其实就是App.razor再梳理一遍:

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>,最佳实践是:
      1. 只在页面组件中调用<PageTitle>
      2. 不要以调用子组件的方式去调用某个页面组件

6.2 有关三种组件的一些总结

从使用场景上来分类的话,目前我们接触了三种组件:普通组件、页面组件、布局组件。我知道你准备说模板组件,但那个是技术细节角度的分类。

在开发工作中,为了避免一些奇怪的问题,我们一定要分清三类组件的功能与用途:

  1. 页面组件
    • 建议放在独立的目录中,比如Pages中,并按路由模板路径划分子目录,尽量保持页面组件的路由模板路径,与razor文件的路径一致
    • 页面组件之所以是页面组件,就是因为它脑门上有@page指令,它就是用来参与路由机制运行的。它只应当被路由机制调用,而不应当被人为的当成普通组件去随意调用
    • 页面组件可以声明参数,但这些参数都只能由路由机制去赋值。即要么由路由模板参数去赋值,要么加上[SupplyParameterFromQuery]去从查询字符串中去取值
  2. 布局组件方面
    • 建议放在独立的目录中,比如Layouts。布局组件一般不会太多,所以不太需要再划分子目录精细管理。
    • 一定要记得要写成LayoutComponentBase的子类,需要显式的写@inherits LayoutComponentBase指令。因为Razor引擎默认会生成一个ComponentBase的子类。
    • 布局组件不应当设定任何组件参数去搞所谓的“灵活性”。
    • 如果多个布局组件共享了部分UI,那么可以把这部分UI抽象成普通组件以避免代码重复。
    • 如果需要基于某个布局组件去扩展出更多样式,还可以使用嵌套布局组件的特性去避免代码重复。
  3. 普通组件
    • 建议放在独立的目录中,比如Components,并按功能领域划分子目录。
    • 核心在于组件参数:普通组件中应当仅出现[Parameter]以及[CascadingParameter],不应当出现其它类型的组件参数。
    • 普通组件由于不参与路由机制的直接渲染,所以不能从路由模板参数,或查询字符串中获得参数值。不理解这一条的,回去翻一下<RouteView>的源代码就明白了。