Blazor教程 第八课:组件的特级知识:模板组件、组件类库、静态资源、虚拟组件与动态组件

Blazor

前三篇文章已经基本把“组件”相关的基本知识介绍完毕了,坦白讲,这些基本知识已经足够应付日常开发了。今天这篇文章我们则是在基础知识之外,再介绍一些相较而言比较高级的知识点,不过请放心,我虽然在标题里把它们叫“特级”知识,但其实这些知识点都比较简单,没有学习难度。

这些所谓的“特级知识”其实更像是一个个的独立特性,互相之间关联性不大,所以这篇文章整体连贯性就不是很强。

1. 模板组件

我们早在第一篇文章里,即介绍Razor语法的时候,就提过“模板”这个东西的本质,这里再重复一遍:

其实仔细想,无论是“模板类”,还是“模板字符串”,还是“模板网页”,都有一个共通的特点:90%的内容是写死的,10%的内容需要动态插入。

  • 模板类/模板函数:90%死内容指的是“类型无关的程序逻辑”,10%待填充的内容是“类型信息”
  • 模板字符串:90%的死内容指的是“写死的字符串部分”,10%待填充的内容是“待求值的变量”
  • 服务端渲染领域的模板语言:90%的死内容指的是“写死的HTML”,10%待填充的内容是“杂糅在模板语言中的代码片段,这部分代码片段会在运行期动态的插入生成剩下的内容”

我们之前也曾把“组件”类比作程序设计语言中的“函数”,那么既然程序设计语言领域有所谓的“模板函数”,那在Blazor框架中,有没有所谓的“模板组件”呢?

答案是:有,但和你想的有一点不同。并且“模板组件”是没法和“模板函数”类比的,它俩逻辑上完全没有相似的地方。

不过呢,我还是准备以函数为例子,先告诉你,模板组件是什么东西。

一个不接受组件参数的组件,我们就把它叫Zoo吧,现在假设这个动物园就是一个内容写死的动物园,那么它可以被类比为如下的伪代码函数:

RenderFragment Zoo()
{
    return 
        <h1>Welcome to WILD WORLD!</h1>
        <p>WILD WORLD is a zoo contains various animals and plants!</p>
        <p>Enjoy your time!</p>
}

而一个接收参数的动物园,比如我们可以使调用方自定义动物园的名字,那么它可以被类比为下面的伪代码函数:

RenderFragment Zoo(string Name)
{
    return 
        <h1>Welcome to @Name</h1>
        <p>WILD WORLD is a zoo contains various animals and plants!</p>
        <p>Enjoy your time!</p>
}

进一步,我们想让调用方自定义动物园里的内容,那么附加了ChildContent组件参数的动物园,可以类比为如下的伪代码函数:

RenderFragment Zoo(string Name, RenderFragment ChildContent)
{
    return 
        <div>
            <h1>Welcome to @Name</h1>
            @ChildContent
        </div>
}

以上,均是我们前几篇文章提到的知识点,今天,我们来搞点新花样:比如在目前的基础上,我们想让调用方指定动物园里的动物列表,那么我们可以把动物列表做成一个组件参数,然后类比成如下的伪代码函数:

RenderFragment Zoo(string Name, List<string> Animals, RenderFragment ChildContent)
{
    return 
        <div>
            <h1>Welcome to @Name</h1>
            @ChildContent
            <h1>Animals you can see in here:</h1>
            <ol>
                @foreach(var animal in Animals)
                {
                    <li>@animal</li>
                }
            </ol>
        </div>
}

现在我们不用新加知识点,我们也可以通过上面的方式,让父组件传入动物列表,然后在子组件里把动物列表渲染成一个<ol>列表。没问题。

但如果,父组件想再灵活一点呢?比如有时候,父组件想以表格的形式展示动物列表,有时候又想以列表的形式去展示列表,但有时候只是想展示一下总共的动物种类数量,无需把所有动物种类一一列出呢?

按我们目前的知识储备,只能是把所有的可能性都列出来,然后做成参数,让父组件去传递,然后在子组件中,为每种可能都写出一套渲染结果,类比成伪代码函数的话,就会长下面这样:

RenderFragment Zoo(string Name, List<string> Animals, AnimalsRenderOption AnimalsRenderOption, RenderFragment ChildContent)
{
    return 
        <div>
            <h1>Welcome to @Name</h1>
            @ChildContent
            @if(AnimalsRenderOption == AnimalsRenderOption.List)
            {
                <h1>Animals you can see in here:</h1>
                <ol>
                    @foreach(var animal in Animalss)
                    {
                        <li>@animal</li>
                    }
                </ol>
            }
            @elif(AnimalsRenderOption == AnimalsRenderOption.Table)
            {
                <h1>Animals you can see in here:</h1>
                <table>
                    <thead>
                        <tr>
                            <td>Animal Name</td>
                        </tr>
                    </thead>
                    <tbody>
                        @foreach(var animal in Animals)
                        {
                            <tr>
                                <td>@animal</td>
                            </tr>
                        }
                    </tbody>
                </table>
            }
            @elif(AnimalsRenderOption == AnimalsRenderOption.Count)
            {
                <h1>You can see @Animalss.Count different kinds of animals in our zoo!</h1>
            }
            @...
            @...
        </div>
}

这样虽然能部分解决问题,但太不优雅了

  • 子组件没法把所有父组件可能出现的天马行空的可能性全列在自己的实现中
  • 子组件的代码太冗长了

怎么解决这个问题呢?正确答案就是:“模板组件”。模板组件其实也只是一种特殊的组件而已,它特殊的地方在于:

  • 组件本身接受一个数据,或数据集合。组件的任务是要渲染这个数据,或数据集合
  • 但对于如何渲染这个数据/数据集合,组件并不自己做决定,而是把这个决定写成一个组件参数,让调用方去做

换句话说,如果使用了组件参数,上面的动物园组件就可以被类比为如下伪代码:

RenderFragment Zoo(
    string Name, 
    List<string> Animals, 
    AnimalsRenderFunc<List<string>> AnimalsRenderFunc, 
    RenderFragment ChildContent)
{
    return 
        <div>
            <h1>Welcome to @Name</h1>
            @ChildContent
            @AnimalsRenderFunc(Animals)
        </div>
}

比如,父组件想把动物列表渲染成表格的话,就可以以如下伪代码的形式去调用子组件:

RenderFragment Index()
{
    List<string> animals = new List<string>{"cat", "dog", "tiger", "lion"};
    AnimalsRenderFunc<List<string>> func = (List<string> animals) =>
    (
        <text>
            <h1>Animalss you can see in here:</h1>
            <table>
                <thead>
                    <tr>
                        <td>Animal Name</td>
                    </tr>
                </thead>
                <tbody>
                    @foreach(var animal in animals)
                    {
                        <tr>
                            <td>@animal</td>
                        </tr>
                    }
                </tbody>
            </table>
        </text>
    );

    return 
        <Zoo Name="WILD WORLD" Animals=@animals AnimalsRenderFunc=@func>
            <p>No smoking</p>
        </Zoo>
}

现在概念就讲明白了,再回头看上面的AnimalsRenderFunc<List<string>>函数指针类型。虽然上面我们都是以伪代码举例,但不影响我们的讨论:你有没有发现,这个函数指针的实现func,是不是有点像一个东西,即我们之前说过的RenderFragment

不同的是,这个函数指针描述的是:如何去渲染指定类型的数据,所以我们在伪代码中使用模板类型参数<List<string>>来限定它。

再想想,RenderFragment是什么?就是UI片断,而这个函数指针是什么?也是UI片断,只不过有了“渲染指定类型数据”这个限制。

那么很自然的,这个函数指针的实际类型,在Blazor框架中,其实就是RenderFragment<T>,而RenderFragment<T>的语义就非常明确了:渲染类型为T的数据的UI片断。

接下来,我们要介绍的就是如何传递类型为RenderFragment<T>类型的组件参数,不过在介绍知识点之前,我们先回顾一下我们之前学习的一些知识点:

1.1 回顾RenderFragment组件参数的四种传参方式

我们之前在介绍到ChildContent知识点时,说过,给ChildContent赋值,只需要将UI片段写在组件XML内部即可,比如:

<SomeComponent>
    <div>
        <p>all things in this div, will be packed to ChildContent and pass to SomeComponent</p>
        <p>including this p either</p>
    </div>
</SomeComponent>

当时我们还介绍过一种传参方式,即为类型为RenderFragment,但参数名不为ChildContent的参数赋值的话,可以以如下方式,写一个RenderFragment的字面量,然后把这个字面量以属性传参的方式传递进去:

@page "/"

@using HelloComponents.Client.Components
@using Microsoft.AspNetCore.Components.Rendering;

@{
    RenderFragment p1 =@<p>MessageBox about paragraph</p>;
    RenderFragment p2 =
    @<text>
        <h1>MessageBox about an article</h1>
        <p>..</p>
        <p>..</p>
        <p>..</p>
        <p>..</p>
    </text>;
}

<MessageBox ChildContent=@p1/>
<MessageBox ChildContent=@p2/>
<MessageBox />

甚至我们还介绍过,手撸RenderFragment的本质去传参,如下:

@page "/"

@using HelloComponents.Client.Components
@using Microsoft.AspNetCore.Components.Rendering;


<MessageBox ChildContent=@this.p1/>
<MessageBox ChildContent=@this.p2/>
<MessageBox />

@code{
    void p1(RenderTreeBuilder builder)
    {
        builder.AddMarkupContent(1, "<p>MessageBox about paragraph</p>");
    }

    private void p2(RenderTreeBuilder builder)
    {
        builder.AddMarkupContent(1, "<h1>MessageBox about an article</h1>");
        builder.AddMarkupContent(2, "<p>..</p>");
        builder.AddMarkupContent(3, "<p>..</p>");
        builder.AddMarkupContent(4, "<p>..</p>");
        builder.AddMarkupContent(5, "<p>..</p>");
    }
}

最后,是通过具名的xml子元素去明确传参

<MessageBox>
    <ChildContent>
        <h1>MessageBox about an article</h1>
        <p>..</p>
        <p>..</p>
        <p>..</p>
        <p>..</p>
    </ChildContent>
</MessageBox>

总结一下,四种传参方式:

  1. 直接写在元素体内:只适用于ChildContent
  2. 写成RenderFragment字面量,通过属性赋值的方式传参
  3. 写成RenderFragment的本质:即方法/函数,再通过属性赋值的方式传参
  4. 以参数名为XML Tag元素,将参数包裹在一个子XML元素中

1.2 先实现一下我们上面提到的动物园组件

好,暂时复习了RenderFragment后,我们就直接写一个动物园组件的实现,新建文件Components/Zoo.razor,代码如下:

<div>
    <div>@this.Header</div>
    <div>@this.AnimalsFragment(this.Animals)</div>
    <div>@this.Footer</div>
</div>

@code {
    [Parameter]
    public RenderFragment Header { get; set; } = default!;

    [Parameter]
    public RenderFragment Footer { get; set; } = default!;

    [Parameter]
    public List<string> Animals { get; set; } = default!;

    [Parameter]
    public RenderFragment<List<string>> AnimalsFragment { get; set; } = default!;
}

我们声明了四个组件参数,其实两个组件参数是RenderFragment类型的,即FooterHeader。我们还声明了一个普通的组件参数,即List<string>类型的Animals。最后,我们声明了一个类型为RenderFragment<List<string>>类型的特殊组件参数,这是我们的重头戏。

而在调用方,我们在Pages/Index.razor中如下写:

@page "/"

@using HelloComponents.Client.Components

<Zoo Animals=@this.animals>
    <Header>
        <h1>Welcome to Wild World! The best zoo in the world!</h1>
    </Header>
    <AnimalsFragment>
        <h2>you'll see the follwing animals in our zoo</h2>
        <ol>
            @foreach(string animal in context) @* !!!!!!!! *@
            {
                <li>@animal</li>
            }
        </ol>
    </AnimalsFragment>
    <Footer>
        <h1>Wish you have a good day!</h1>
    </Footer>
</Zoo>

@code {
    private List<string> animals = default!;

    protected override void OnInitialized()
    {
        this.animals = new List<string> { "Pig", "Horse", "Cat", "Tiger", "Lion" };
    }
}

在调用方,我们通过子元素的方式来给HeaderFooter进行赋值 -- 刚才新学的热乎的知识点,没什么可再强调的。

我们通过属性赋值的方式给Animals赋值 -- 也没什么可说的,非常直观。

唯一值得说的是,我们通过子元素的方式给AnimalsFragment进行了赋值,和HeaderFooter比较类似,但有一点不同:

  • 我们在<AnimalsFragment>内部,通过一个叫context的变量,引用到了动物的列表!

还记得AnimalsFragment作为组件参数的实际类型吗?是RenderFragment<List<string>>,它的语义是:

  • 这是一个UI片段
  • 这个UI片段是渲染一特定类型数据的片段,类型是List<string>

所以,我们需要一个东西,在定义这个UI片段的时候,去指向那个被渲染的数据,即List<string>,而这个特殊的变量,默认情况下,它的变量名就是context

这就是模板组件的一种最常见的写法。所以回头看,如果你理解了RenderFragment的本质,理解了模板组件的功能本质,那么理解模板组件是非常容易的,你只有一个新知识点需要掌握:那就是如何传递类型为RenderFragment<T>类型的参数。

接下来,我们对比着RenderFragment的四种传参方式,来再介绍其它三种为RenderFragment<T>传值的方式。

1.3 RenderFragment<T>的几种传参方式

上面我们已经接触了“子元素”式传值,现在来一一对照着看其它三种传值方式。

作为ChildContent,将值写在子组件元素内部

对比起ChildContent的默认传值方式:即直接把参数写在组件的体内,这个RenderFragment就会被框架默认传递给ChildContent:这种方式显然是没法在RenderFragment<T>这边复刻的。。。。吗?

诶,你看,假如我们把AnimalsFragment参数名改成ChildContent的话,如下:

 <div>
     <div>@this.Header</div>
     <div>@this.AnimalsFragment(this.Animals)</div>
     <div>@this.Footer</div>
 </div>
 
 @code {
     [Parameter]
     public RenderFragment Header { get; set; } = default!;
 
     [Parameter]
     public RenderFragment Footer { get; set; } = default!;
 
     [Parameter]
     public List<string> Animals { get; set; } = default!;
 
     [Parameter]
-    public RenderFragment<List<string>> AnimalsFragment { get; set; } = default!;
+    public RenderFragment<List<string>> ChildContent { get; set; } = default!;
 }

然后在调用方,我们如下修改代码:

 @page "/"
 
 @using HelloComponents.Client.Components
 
-<Zoo Animals=@this.animals>
+<Zoo Animals=@this.animals Header=@this.header Footer=@this.footer>
-    <Header>
-        <h1>Welcome to Wild World! The best zoo in the world!</h1>
-    </Header>
-    <AnimalsFragment>
         <h2>you'll see the follwing animals in our zoo</h2>
         <ol>
             @foreach(string animal in context) @* !!!!!!!! *@
             {
                 <li>@animal</li>
             }
         </ol>
-    </AnimalsFragment>
-    <Footer>
-        <h1>Wish you have a good day!</h1>
-    </Footer>
 </Zoo>
 
 @code {
     private List<string> animals = default!;
+    private RenderFragment header = default!;
+    private RenderFragment footer = default!;

     protected override void OnInitialized()
     {
         this.animals = new List<string> { "Pig", "Horse", "Cat", "Tiger", "Lion" };
+        this.header = @<h1>Welcome to Wild World! The best zoo in the world</h1>;
+        this.footer = @<h1>Wish you have a good day!</h1>;
     }
 }

其实,它也能正常编译,正常运行!

很神奇吧?是不是有点不合理?但如果你简单的,把Razor引擎的行为理解为下面这样:

  • 默认情况下,Razor引擎总是会试图将子组件元素内部的东西,无论是RenderFragment字面量,还是RenderFragment<T>字面量,都直接传递给子组件的名为ChildContent的参数

这样就合理了。

而这,也是为什么如下的写法无法通过编译的原因:

<Zoo Animals=@this.animals Header=@this.header Footer=@this.footer>
    <h2>you'll see the follwing animals in our zoo</h2>
    <ol>
        @foreach(string animal in context) @* !!!!!!!! *@
        {
            <li>@animal</li>
        }
    </ol>
    <Header>
        <h1>Welcome to Wild World! The best zoo in the world!</h1>
    </Header>
    <Footer>
        <h1>Wish you have a good day!</h1>
    </Footer>
</Zoo>

我们不能混用子元素传参,与直接式的ChildContent传参,因为引擎没法分辨内部的<Header><Footer>到底应该算是ChildContent的一部分,还是对HeaderFooter的赋值。

RenderFragment<T>的本质

前面我们说了,RenderFragment的本质是一个delegate,那么我们去查RenderFragment<T>的话,会发现它的本质其实也是个delegate。这两个东西的定义在dotnet源代码中其实是挨在一起的,如下:

public delegate void RenderFragment(RenderTreeBuilder builder);
public delegate RenderFragment RenderFragment<TValue>(TValue value);

这就很有意思了:

  • RenderFragment是一个delegate类型,即函数指针,手撸的话,其实就是给builder中添加v-dom枝桠
  • RenderFragment<T>就复杂了:它也是一个delegate,但它的返回值是RenderFragment:意思是这玩意是个高阶函数,它的返回值是一个函数指针

我们这里把HeaderFooterAnimalsFragment都改写成函数形式,这种对比就会更强烈:

@page "/"

@using HelloComponents.Client.Components
@using Microsoft.AspNetCore.Components.Rendering;

<Zoo Animals=@this.animals Header=@this.header Footer=@this.footer AnimalsFragment=@this.animalFragment />

@code {
    private List<string> animals = default!;

    private void header(RenderTreeBuilder builder)
    {
        builder.AddMarkupContent(1, "<h1>Welcome to Wild World! The best zoo in the world!</h1>");
    }

    private void footer(RenderTreeBuilder builder)
    {
        builder.AddMarkupContent(1, "<h1>Wish you have a good day</h1>");
    }

    private RenderFragment animalFragment(List<string> animals)
    {
        RenderFragment res = (RenderTreeBuilder builder) =>
        {
            builder.AddMarkupContent(1, "<h2>You\'ll see the following animals in our zoo</h2>");
            builder.OpenElement(2, "ol");
            foreach(string animal in animals)
            {
                builder.OpenElement(3, "li");
                builder.AddContent(4, animal);
                builder.CloseElement();
            }
            builder.CloseElement();
        };

        return res;
    }

    protected override void OnInitialized()
    {
        this.animals = new List<string> { "Pig", "Horse", "Cat", "Tiger", "Lion" };
    }
}

RenderFragment<T>字面量

虽然从本质上来说,RenderFragment<T>RenderFragment是完全不同的两个东西,但在字面量的书写上,二者是十分相似的:无非就是我们需要在RenderFragment<T>字面量的书写中,通过content变量引用数据本身而已,下面就是个例子:

@page "/"

@using HelloComponents.Client.Components
@using Microsoft.AspNetCore.Components.Rendering;

<Zoo Animals=@this.animals Header=@this.header Footer=@this.footer AnimalsFragment=@this.animalsFragment />

@code {
    private List<string> animals = default!;
    private RenderFragment header =@<h1>Welcome to Wild World! The best zoo in the world!</h1>;
    private RenderFragment footer =@<h1>Wish you have a good day</h1>;
    private RenderFragment<List<string>> animalsFragment = 
    @<div>
        <ol>
            @foreach(var animal in context)
            {
                <li>@animal</li>
            }
        </ol>
    </div>;

    protected override void OnInitialized()
    {
        this.animals = new List<string> { "Pig", "Horse", "Cat", "Tiger", "Lion" };
    }
}

上面的写法对吗?其实并不对,因为Razor引擎只会分辨出RenderFragment字面量,并没有所谓的RenderFragment<T>字面量:引擎并不知道context是什么东西。

正确的写法是下面这样:使用Lambda表达式来书写RenderFragment<T>字面量:

 @page "/"
 
 @using HelloComponents.Client.Components
 @using Microsoft.AspNetCore.Components.Rendering;
 
 <Zoo Animals=@this.animals Header=@this.header Footer=@this.footer AnimalsFragment=@this.animalsFragment />
 
 @code {
     private List<string> animals = default!;
     private RenderFragment header =@<h1>Welcome to Wild World! The best zoo in the world!</h1>;
     private RenderFragment footer =@<h1>Wish you have a good day</h1>;
-    private RenderFragment<List<string>> animalsFragment = 
+    private RenderFragment<List<string>> animalsFragment = (context) => 
     @<div>
         <ol>
             @foreach(var animal in context)
             {
                 <li>@animal</li>
             }
         </ol>
     </div>;
 
     protected override void OnInitialized()
     {
         this.animals = new List<string> { "Pig", "Horse", "Cat", "Tiger", "Lion" };
     }
 }

1.4 模板类+模板组件=双重模板更加快乐

我们上面说到的模板组件,“模板”部分说的是“如何对数据进行渲染”,把对数据的渲染这部分权利让渡给父组件。

而我们通常意义上,在程序设计语言中说到的“模板”,多数指的是“把类型信息作为参数”的意思。比如模板类与模板函数/方法。

你现在想:组件本身也是个C#类,能来能把这个类,写成程序设计语言意义上的模板类呢?显然是可以的,我们在介绍Razor语法的时候就说到过一个指令,叫@typeparam,它就是用来给Razor类声明类型参数的。

那,我们给模板组件,再加个@typeparam,会变成什么呢?

会变成一个更灵活的二阶模板组件:现在不光数据如何渲染是由调用方决定,甚至数据本身是什么东西,也可以由调用方决定了!

这种组件广泛出现在用来做布局相关场合中。虽然我们现在还没有讲到Blazor中的Layout相关知识,但不妨碍我们感受这种二阶模板组件的威力。比如我们写一个“布局组件”:这个组件描述的是页面的概览布局:比如有页眉,有页脚,有内容区。但内容到底是什么东西,它完全不能决定,得由调用方自行决定。那么我们就可以写出如下的一个组件来(Components/Grid.razor):

@typeparam T

<style>
</style>

<div style="display:flex;flex-direction:column; justify-content: space-between; height: 90vh;">
    <div style="background-color: yellow">@this.Header</div>
    <div style="flex-grow: 1;display:flex;justify-content: space-between">
        <div style="background-color: cyan">@this.SideBarOptionsFragment(this.SideBarOptions)</div>
        <div style="flex-grow: 1;">@this.ChildContent</div>
    </div>

    <div style="background-color: yellow">@this.Footer</div>
</div>


@code {
    [Parameter]
    public RenderFragment Header { get; set; } = default!;

    [Parameter]
    public RenderFragment Footer { get; set; } = default!;

    [Parameter]
    public RenderFragment<IReadOnlyList<T>> SideBarOptionsFragment { get; set; } = default!;

    [Parameter]
    public IReadOnlyList<T> SideBarOptions { get; set; } = default!;

    [Parameter]
    public RenderFragment ChildContent { get; set; } = default!;
}

这个组件的意图是:调用方可以通过HeaderFooter传递页眉与页脚,然后通过SideBarOptions传递一系列的导航选项,再通过SideBarOptionsFragment来决定如何渲染这些导航选项。最后再给内容主体留一个ChildContent

如果我们在Pages/Index.razor中如下调用它

@page "/"

@using HelloComponents.Client.Components

<Grid T=@string SideBarOptions=@this.sideBarOptions>

    <Header>
        <p>Header...</p>
    </Header>

    <Footer>
        <p>Footer</p>
    </Footer>

    <SideBarOptionsFragment>
        <ul>
        @foreach(string o in context)
        {
            <li>@o</li>
        }
        </ul>
    </SideBarOptionsFragment>

    <ChildContent>
        <p>content</p>
    </ChildContent>
</Grid>

@code {
    private List<string> sideBarOptions = new List<string> { "Home", "Document", "About us" };
}

显示效果如下所示:

Grid

上面的调用比较简单,导航选项也只是字符串类型,下面我们可以使用一个复合类型去设置导航选项:除了导航文字本身,导航选项还可以设定背景颜色与前景颜色。

 @page "/"
 
 @using HelloComponents.Client.Components
 
-<Grid T=@string SideBarOptions=@this.sideBarOptions>
+<Grid T=@SideBarOption SideBarOptions=@this.sideBarOptions>
 
     <Header>
         <p>Header...</p>
     </Header>
 
     <Footer>
         <p>Footer</p>
     </Footer>
 
     <SideBarOptionsFragment>
-        <ul>
-        @foreach(string o in context)
-        {
-            <li>@o</li>
-        }
-        </ul>
+        @foreach(SideBarOption o in context)
+        {
+            <p style=@($"background-color: {o.BackgroundColor}; color: {o.ForegroundColor}")>@o.Content</p>
+        }
     </SideBarOptionsFragment>
 
     <ChildContent>
         <p>content</p>
     </ChildContent>
 </Grid>
 
 @code {
-    private List<string> sideBarOptions = new List<string> { "Home", "Document", "About us" };
+    private List<SideBarOption> sideBarOptions = new List<SideBarOption>
+    {
+        new SideBarOption("Home", "white", "black"),
+        new SideBarOption("Document", "red", "yellow"),
+        new SideBarOption("About us", "yellow", "black"),
+    };
+
+    private struct SideBarOption
+    {
+        public string Content;
+        public string ForegroundColor;
+        public string BackgroundColor;
+        public SideBarOption(string content, string fcolor, string bcolor)
+        {
+            this.Content = content;
+            this.ForegroundColor = fcolor;
+            this.BackgroundColor = bcolor;
+        }
+    }
 }

显示效果如下所示:

grid

当然这个例子很丑,但从代码上你应该能体会到这种双重模板组件是多么的灵活。总结起来就是

  • 一阶模板,调用方可以自行决定数据渲染方式
  • 二阶模板,调用方可以自行决定数据类型与数据渲染方式

最后再补充一点小知识:我们都知道模板类、模板函数中,模板类型参数是可以加限制的。在Razor中,也是类似的,可以如下加限制:

@typeparam TItem where TItem: IDisposable

1.5 一个烧脑的模板组件,与猪头语法

现在你已经明白,RenderFragment<T>是一个delegate类型,其入参是T,返回值是RenderFragment。通常我们用它来表示“如何渲染类型为T的数据”。

这里介绍一个比较冷门的知识点:T也可以是RenderFragment,即可以存在RenderFragment<RenderFragment>这种东西。

这个知识点在作为框架和库的使用者来说,不是很常见,但对于UI组件库的源代码来说,这个技巧还是比较普遍的。

我们假设这样一种场景:你要写一个组件去渲染一个列表数据,这里简单点,就说是List<string>吧。一般来说,设计有两个方向:

  1. 在组件中暴露一个类型为RenderFragment<string>的组件参数,可以让组件使用者来定义列表中的每个数据如何渲染。而关于“列表”本身是如何渲染的,由组件写死
  2. 在组件中暴露一个类型为RenderFragment<List<string>>的组件参数,完全让组件使用者来决定列表,及列表中的每个数据如何渲染

这两个做法其实都不是非常好。#1的缺点在于,组件的使用者无法灵活的选列表整体如何被渲染,比如添加背景色等。#2的缺点在于,调用者需要完全写明白列表列表中的每个数据如何渲染,需要把整个列表的渲染方式都事无巨细的写明白。

有没有一种折衷的方式呢?比如对使用者而言,只需要输入两个信息就能使用这个组件,这两个信息分别是:

  1. 这个数据列表作为一个整体,应当如何渲染。要不要背景色或背景图片,内外margin/padding应当为多少等。
  2. 每个数据项应当如何渲染。比如最简单的,用<li>还是<li><p><em>,还是简单的<p>

这时,RenderFragment<RenderFragment>就能帮上你的忙:

  1. 首先,子组件暴露一个类型为RenderFragment<string>的组件参数。调用方使用这个参数来决定每个数据如何被渲染
  2. 其次,子组件再暴露一个类型为RenderFragment<RenderFragment>的组件参数,这就有意思了:
    • 通过上个参数,每个数据都知道如何渲染了,在实际渲染过程中,就是把List<string>通过上个组件参数转换成了多个RenderFragment
    • 这多个RenderFragment合在一起,也算是一个RenderFragment。那么如何渲染这个RenderFragment呢?你看,绕回来了:RenderFragment<RenderFragment>

下面就是一个例子,我们新建组件Components/List.razor,代码如下:

@this.ItemsTemplate(
    @:@foreach(var item in this.Items)
    {
        @this.ItemTemplate(item);
    }
)

@code {
    [Parameter]
    public IReadOnlyList<string> Items { get; set; } = default!;

    [Parameter]
    public RenderFragment<string> ItemTemplate { get; set; } = default!;

    [Parameter]
    public RenderFragment<RenderFragment> ItemsTemplate { get; set; } = default!;

}

三个参数都是什么意思上面已经分析过了,这里有意思的是,如何在Razor文件中调用@this.ItemsTemplate

首先,如何我们只是要循环的把Items的数据渲染在页面上,我们只需要如下写一个代码块即可:

@foreach(var item in this.Items)
{
    @this.ItemTemplate(item);
}

注意这里有一个经常会犯的错误,就是写成下面这样:

 @foreach(var item in this.Items)
 {
-    @this.ItemTemplate(item);
+    this.ItemTemplate(item);
 }

如果去掉了this.ItemTemplate(item)前面的@的话,页面就不会渲染任何内容。这背后的原理知识点其实我们都讲过,今天这里再捋一遍:

  1. 首先,@foreach(xxx) { ... }@{ foreach (xxx) { ... } }的一个简写版。这属于是临时性的去插入C#代码块
  2. 在C#代码块内部,如果我们写的是this.ItemTemplate(item);的话,就纯粹是一句C#调用而已:把item作为参数传递给delegatethis.ItemTemplate,这个调用确实会返回一个RenderFragment,但这个返回值被丢掉了
  3. 而如果我们加上@变成@this.ItemTemplate(item);的话,Razor引擎其实是把这行当成标记语言中的表达式求值去处理的,它其实相当于<text>@this.ItemTemplate(item)</text>
    • 在C#模式下,把一整行用<text>包裹起来的语法,叫“Explicit delimited transitions”,我们早在Razor语法的时候就介绍过
    • 这样写,才会把返回的RenderFragment添加进v-dom中去

OK,现在的问题是:上面的代码块,在转译后,引擎会把它们翻译成BuildRenderTree方法中的一堆语句,类似于:

    BuildRenderTree(builder) {
        builder.AddMarkupContent(...)
        builder.AddMarkupContent(...)
        builder.AddMarkupContent(...)
    }

this.ItemsTemplate需要的是一个RenderFragment类型的参数,怎么办呢?有一个技巧,我们早在Razor语法时就讲过一个知识点:“在行首使用@:进行声明,那么该行剩余的所有内容会被当成HTML进行处理”。

上面那句话说的“会当成HTML进行处理”的意思是,引擎会把相关内容打包成一个整体,然后添加进v-dom中去。所以我们可以写出如下的代码:

@:@foreach(var item in this.Items)
{
    @this.ItemTemplate(item);
}

上面整个三行,其实可以看作一行代码:因为@foreach{}本身是个代码块。然后我们就可以把这整行所代表的“值”,即RenderFragment,作为参数,传入@this.ItemsTemplate了,于是就可以写出下面的代码:

@this.ItemsTemplate(
    @:@foreach(var item in this.Items)
    {
        @this.ItemTemplate(item);
    }
)

这个@:@看起来像猪鼻子,所以我们把这玩意也叫猪鼻子语法,简单来说,猪鼻子语法可以方便的让我们把Razor中的一段渲染内容包装成一个RenderFragment。猪鼻子语法其实并不是什么新特性,而是对现有Razor语法的组合运用而已,只不过组合后的功效太抽象了,所以实际上你也可以直接把它当成一个新语法知识点去掌握。

而上面这段代码被引擎转译后的结果其实长下面这样:

    public partial class List : ComponentBase
    {
        protected override void BuildRenderTree(RenderTreeBuilder __builder)
        {
            __builder.AddContent(
                0,
                this.ItemsTemplate(
                    (__builder2) => 
                    {
                        foreach(var item in this.Items)
                        {
                            __builder2.AddContent(1, this.ItemTemplate(item));
                        }
                    }));
        }

        [Parameter]
        public IReadOnlyList<string> Items { get; set; } = default!;

        [Parameter]
        public RenderFragment<string> ItemTemplate { get; set; } = default!;

        [Parameter]
        public RenderFragment<RenderFragment> ItemsTemplate { get; set; } = default!;
    }

下面则是在Pages/Index.razor中使用这个组件的一种写法:

@page "/"

@using HelloComponents.Client.Components

<List Items=@this.items ItemTemplate=@this.itemTemplate ItemsTemplate=@this.itemsTemplate></List>

@code {
    private List<string> items = new List<string> { "one", "two", "three" };
    private RenderFragment<string> itemTemplate = (item) => @<p style="color:red">@item</p>;
    private RenderFragment<RenderFragment> itemsTemplate = (fragment) => @<div style="background-color:cyan;">@fragment</div>;
}

下面是等价的第二种写法

 @page "/"
 
 @using HelloComponents.Client.Components
 
-<List Items=@this.items ItemTemplate=@this.itemTemplate ItemsTemplate=@this.itemsTemplate></List>
+<List Items=@this.items>
+    <ItemTemplate>
+        <p style="color:red">@context</p>
+    </ItemTemplate>
+    <ItemsTemplate>
+        <div style="background-color:cyan">@context</div>
+    </ItemsTemplate>
+</List>
 
 @code {
     private List<string> items = new List<string> { "one", "two", "three" };
-    private RenderFragment<string> itemTemplate = (item) => @<p style="color:red">@item</p>;
-    private RenderFragment<RenderFragment> itemsTemplate = (fragment) => @<div style="background-color:cyan;">@fragment</div>;
 }

2. 组件类库

我们前面已经用很长的篇幅去讲组件的相关知识了,在我们实践示例的时候,所有的组件都是写在Client项目的Components目录下的。这个做法本身对于开发一个项目来说,没有什么问题。但如果我们要开发多个项目,而这多个项目如果都用到一些相似的组件的话,将这些组件在每个项目的Client子项目中重写一遍,显然是个非常蠢的主意。显然此时一个比较好的做法就是:把组件本身写成一个类库。

我们之前就说过,Blazor技术栈比较特殊,最大的问题是要考虑到,在Blazor WASM模式下,代码是要跑在浏览器里那个残废的dotnet runtime中的,所以有关Blazor的类库也比较特殊,需要在编译时由工具链检查类库中使用到的API浏览器是否都支持。对于组件类库来说亦是如此。

我们以上小节讲的List组件为例,现在我们把这个组件从Client/Components/List.razor,挪到一个新的类库项目中去,首先我们先新建一个项目

HelloComponents> midir Components
HelloComponents> cd Components
HelloComponents\Components> 

然后在Components目录下新建名为HelloComponents.Components.csproj的项目文件,内容如下:

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>


  <ItemGroup>
    <SupportedPlatform Include="browser" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="7.0.14" />
  </ItemGroup>

</Project>

上面的<SupportedPlatform Include="browser" />就是在告诉工具链:这个类库会运行在浏览器上,请做必要的检查。

对包M.A.Components.Web的引用则是因为:像RenderFragment{<T>},以及ParameterAttribute之类的东西,都定义在这个包中。其它就没有什么神奇的地方了

然后直接在Components目录下把List.razor搬过来即可,一行代码都不用改。但注意要添加如下namespace的引用

@using Microsoft.AspNetCore.Components.Web

...

再然后,在Client项目中,添加对这个新的类库项目的依赖,改写Client/HelloComponents.Client.csproj的内容如下:

 <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
 
   <PropertyGroup>
     <TargetFramework>net7.0</TargetFramework>
     <Nullable>enable</Nullable>
     <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
   </PropertyGroup>
 
   <ItemGroup>
     <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.14" />
     <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.14" PrivateAssets="all" />
     <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
   </ItemGroup>
 
   <ItemGroup>
+    <ProjectReference Include="..\Components\HelloComponents.Components.csproj" />
     <ProjectReference Include="..\Shared\HelloComponents.Shared.csproj" />
   </ItemGroup>
 
   <ItemGroup>
     <Folder Include="Components\" />
   </ItemGroup>
 
 </Project>

就基本完活了。没什么稀奇的。不过有两个小知识点需要补充一下:

2.1 _Imports.razor

我们在Client项目中时,对于常用的,所有组件、页面等Razor文件可能用到的名称空间,都可以一股脑的放在_Imports.razor这个文件中,我们可以简单的认为,一个项目中的所有*.razor文件在引擎处理时,都会将_Imports.razor中的内容放置于自己脑门上,类似于C语言中的#include

这个特性其实在组件类库中也可以用到,比如上面我们说了,把List.razor搬到独立类库后,要注意引用M.A.Components.Web这个namespace,其实我们完全可以在项目中创建一个_Imports.razor,再将这句引用写进去。这样,其中所有内容都会被HelloComponents.Components项目中的所有组件源代码文件用到。

2.2 标记语言与C#语言分离书写

在我们之前所有示例代码中,无论是组件还是页面,我们都是只写一个*.razor文件,然后标记语言就直接写,类成员就写在@code {}代码块中。

有另外一种写法,以List.razor为例,即在同级目录中创建一个名为List.razor.cs的文件,然后把@code{}中的成员成员等内容挪到这个独立的文件中去,比如我们就可以把List.razor改写成如下形式:

 @this.ItemsTemplate(
     @:@foreach(var item in this.Items)
     {
         @this.ItemTemplate(item);
     }
 )
 
-@code {
-    [Parameter]
-    public IReadOnlyList<string> Items { get; set; } = default!;
-
-    [Parameter]
-    public RenderFragment<string> ItemTemplate { get; set; } = default!;
-
-    [Parameter]
-    public RenderFragment<RenderFragment> ItemsTemplate { get; set; } = default!;
-
 }

然后在List.razor.cs中如下书写:

using Microsoft.AspNetCore.Components;
using System.Collections.Generic;

namespace HelloComponents.Components;

public partial class List
{    
    [Parameter]
    public IReadOnlyList<string> Items { get; set; } = default!;

    [Parameter]
    public RenderFragment<string> ItemTemplate { get; set; } = default!;

    [Parameter]
    public RenderFragment<RenderFragment> ItemsTemplate { get; set; } = default!;

}

注意类声明时的partial关键字,这个非常重要,这意味着当前的源代码文件是对类List的补充定义,而不是完全定义。事实上,在编译时,是Razor转译List.razor生成的源代码文件,与我们自己写的List.razor.cs共同一起定义了这个类。如果你翻开此时Razor的转译结果,你会看到,转译的C#文件中,也带了partial关键字。

...
    public partial class List : global::Microsoft.AspNetCore.Components.ComponentBase
    {

...

到底是合并在一起写,还是分开写,这个看个人喜好。我个人觉得如此分离代码其实没什么特别显著的好处。

3. 静态资源引用

我们讲了那么多有关组件的知识,始终没有触碰的一个知识点就是:静态资源怎么处理。

如果没有组件类库,只有一个Client前端项目的话,静态资源的处理是比较简单的:只需要把用到的静态资源放在Client/wwwroot下即可。

比如我们把下面这个emoji图片放在Client/wwwroot/images/目录下,并起名为angry.png

angry

那么在Client项目中,无论是页面,还是组件,我们都可以直接以images/angry.png的路径去引用这张图片,比如我们可以在Pages/Index.razor中如下写:

@page "/"

<img src="images/angry.png" />

但我们无法写出如下的代码并正确执行:

@page "/"

<img src=@($"data:image/png;base64, {this.imgBase64Code}") />

@code {
    private string imgBase64Code = default!;

    protected override void OnInitialized()
    {
        byte[] imgBytes = System.IO.File.ReadAllBytes("images/angry.png");
        this.imgBase64Code = Convert.ToBase64String(imgBytes);
    }
}

背后的原因非常显而易见:因为Client项目是一个运行在浏览器上的程序,而实际上在部署后,Client项目中的angry.png文件是被部署在服务端web server中的。直接通过文件系统相关接口去读取images/angry.png是读不到这个文件的。

正确的写法是如下:

 @page "/"
+
+@inject HttpClient httpClient
 
+@if(this.imgBase64Code is null)
+{
+    <p>image is loading</p>
+}
+else
+{
     <img src=@($"data:image/png;base64, {this.imgBase64Code}") />
+}
 
 @code {
     private string imgBase64Code = default!;
 
-    protected override void OnInitialized()
-    {
-        byte[] imgBytes = System.IO.File.ReadAllBytes("images/angry.png");
-        this.imgBase64Code = Convert.ToBase64String(imgBytes);
-    }
+    protected override async Task OnInitializedAsync()
+    {
+        byte[] imgBytes = await httpClient.GetByteArrayAsync("images/angry.png");
+        this.imgBase64Code = Convert.ToBase64String(imgBytes);
+    }
 }

但,如果,我们独立的组件类库中也包含静态资源的话,怎么办?这其实是两个问题:

  1. 如果某个组件本身就是要引用一张图片,那么这个组件在组件类库中怎么写
  2. 如果Client项目要直接引用组件类库中的静态资源,引用路径怎么写?

首先的一个前置知识点就是:即使是在类库中,静态资源也应当放在类库项目的wwwroot目录中。比如我们想把下面这张图片打包进上面我们创建的类库中的话,就需要把它放在Components/wwwwroot/images目录下(注意不是Client项目的Components子目录,而是Components项目),起名叫smile.png

smile

然后我们在Components项目中新建一个组件,叫ShowSmile.razor,其内容应当如下:

<div>
    <h3>show smile!</h3>
    <img src="_content/HelloComponents.Components/images/smile.png"/>
</div>

注意看路径:这就是我们在引用一个位于类库中的静态资源时应该写的路径,它的固定格式是:_content/{LibraryProjectName}/{Path}。就这个路径其实就一次性回答了上面两个问题。

更重要的,需要注意的一点是:引用静态资源时,路径到底是直接写wwwroot的相对路径,还是_content/{LibraryProjectName}/{Path},取决于:

  • 这个静态资源到底是在WASM项目中,还是在类库项目中。
    • 如果静态资源是在WASM项目中,那么就直接使用wwwroot的相对路径就可以
    • 如果静态资源是在类库项目中,就需要使用_content/{LibraryProjectName}/{Path}这种路径
  • 而与当前书写的页面、组件本身属于哪个项目没有关系!

即要引用上面的smile.png图片,无论是在HelloComponents.Components项目内部,还是HelloComponents.Client项目内部,都需要使用路径_content/HelloComponents.Componnets/images/smile.png这个路径。

4. 虚拟组件

设想你有100条数据放在数据库,要把这些数据展示在网页页面上,怎么做?

都不用动脑,我直接写个API一次性返回所有数据即可。

但如果数据量是1000条呢?一次性返回所有数据,页面一次性渲染所有数据,好像问题也不大。1000条数据而已,远远触及不到浏览器或者任何框架的性能瓶颈。

但如果是1万条呢?10万条呢?好像一次性返回所有数据就不太明智了。至少在浏览器面前的那个人,是无法在一个合理的时间内用鼠标滚轮把这一万条或者十万条数据滚完的。

以往,面对这种问题,一个正常的工程师,没有脑血栓的工程师,都会去想办法在API这一层做分页逻辑:即前端在请求数据的时候带上页码,后端每次只返回几十或几百条数据,用户想查看更多数据,就需要在前端点击一下“下一页”按钮,触发一个前端事件,触发一个http请求。

不过呢,工程师是越来越懒了,10万条数据,每条数据平均100字节的话,总体大小也不过10M而已,这样的数据量在2023年其实一次性全部返回,对于后端写API的懒人来说,已经算是“我觉得还行啊”的级别了。用户网络条件良好,或者如果说这是一个企业内部项目的话,10M数据就算传输的时候不压缩,也就是一两秒的事情,网络加载耗时也算是勉强可以接受。

但这样的数据级别对于浏览器DOM系统,以及各种前端框架来说,就捅到肺管子了:10万个数据,即使一条数据只用一个<li>去渲染,也是十万个DOM元素。如果数据有5个字段,还要用<table>渲染的话,一条数据就是<tr>里嵌着五个<td>,五十万个DOM元素。就不用框架,纯用JS去对DOM进行操作,用户端也是肉眼可见的页面卡顿。

下面我们就写这么个例子:我们先在Shared项目中创建一个彩票类,如下:

using System;
using System.Linq;

namespace HelloComponents.Shared;

public class LotteryTicket
{
    public int[] RedBalls { get; }

    public int[] BlueBalls { get; }

    public LotteryTicket()
    {
        Random rand = new Random();
        this.RedBalls = Enumerable.Range(1, 2).Select(_ => rand.Next(1, 17)).OrderBy(n => n).ToArray();
        this.BlueBalls = Enumerable.Range(1, 6).Select(_ => rand.Next(1, 34)).OrderBy(n => n).ToArray();
    }
}

我们在Server项目中新建一个Controllers/LotteryForecastController.cs文件,里面写个接口,一次性返回十万条彩票预测号码,内容如下:

using HelloComponents.Shared;
using Microsoft.AspNetCore.Mvc;

namespace HelloComponents.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class LotteryForecastController : ControllerBase
    {
        [HttpGet]
        public IEnumerable<LotteryTicket> Get()
        {
            return Enumerable.Range(1, 10_000).Select(_ => new LotteryTicket());
        }
    }
}

然后我们在Components项目中新建一个组件ItemsBoard.razor,用来渲染多个彩票,这里用到了之前我们讲的模板组件的知识

@typeparam TypeOfItem

@if(!string.IsNullOrEmpty(this.Name) && this.Items is not null && this.ItemTemplate is not null && this.ItemsFragmentTemplate is not null)
{
    <div>
        <h3>@this.Name</h3>
        @this.ItemsFragmentTemplate(
            @:@foreach(TypeOfItem item in this.Items)
            {
                @this.ItemTemplate(item)
            }
        )
    </div>
}

@code {
    [Parameter]
    public string Name { get; set; } = default!;

    [Parameter]
    public IEnumerable<TypeOfItem> Items { get; set; } = default!;

    [Parameter]
    public RenderFragment<TypeOfItem> ItemTemplate { get; set; } = default!;

    [Parameter]
    public RenderFragment<RenderFragment> ItemsFragmentTemplate { get; set; } = default!;

    protected override void OnParametersSet()
    {
        if(this.ItemTemplate is null)
        {
            this.ItemTemplate = (TypeOfItem item) => @<li>@item</li>;
        }

        if(this.ItemsFragmentTemplate is null)
        {
            this.ItemsFragmentTemplate = (RenderFragment fragment) => @<ol>@fragment</ol>;
        }

        if(this.Items is null)
        {
            this.Items = new List<TypeOfItem>();
        }

        if(string.IsNullOrEmpty(this.Name))
        {
            this.Name = "Items board";
        }
    }
}

这里其实也是一个小知识点:在Client项目中,引用Shared类库是没什么问题的。但如果我们要把部分组件独立的抽出去写一个Components组件类库的话,这个组件类库应当尽量做到不引用其它类库,这就是我们在这里要把ItemsBoard写成双重模板组件的原因:既模板了数据类型,也是一个模板组件。

如果我们把ItemsBoard里的数据类型限定为LotteryTicket的话,这意味着其它想使用Components组件类库的项目也会间接的引用到当前解决方案下的Shared类库:而通常情况下,Shared类库存在的最大意义是共享Client与Server之间的数据结构,显然不适合拿去给其它项目使用。

然后我们在Client项目的Index.razor中如下写:

@page "/"

@using HelloComponents.Shared;
@inject HttpClient httpClient

@if(this.tickets is null)
{
    <p>Lottery forecast data is loading...</p>
}
else
{
    <style>
        table, th, td {
            border: 1px solid black;
            border-collapse: collapse;
        }
    </style>
    <HelloComponents.Components.ItemsBoard TypeOfItem=@LotteryTicket Items=@(this.tickets!)>
        <ItemTemplate>
            <tr>
                <td>@context.BlueBalls[0]</td>
                <td>@context.BlueBalls[1]</td>
                <td>@context.BlueBalls[2]</td>
                <td>@context.BlueBalls[3]</td>
                <td>@context.BlueBalls[4]</td>
                <td>@context.BlueBalls[5]</td>
                <td>@context.RedBalls[0]</td>
                <td>@context.RedBalls[1]</td>
            </tr>
        </ItemTemplate>
        <ItemsFragmentTemplate>
            <table>
                <thead>
                    <tr>
                        <td colspan="6">Blue balls</td>
                        <td colspan="2">Red balls</td>
                    </tr>
                </thead>
                <tbody>
                    @context
                </tbody>
            </table>
        </ItemsFragmentTemplate>

    </HelloComponents.Components.ItemsBoard>
}

@code {
    private IEnumerable<LotteryTicket>? tickets = default!;
    private System.Diagnostics.Stopwatch watch = default!;

    protected override async Task OnInitializedAsync()
    {
        watch = new System.Diagnostics.Stopwatch();
        watch.Start();
        this.tickets = await this.httpClient.GetFromJsonAsync<IEnumerable<LotteryTicket>>("api/lotteryForecast").ConfigureAwait(false);
        watch.Stop();
        Console.WriteLine($"calling api & parse response data cost {watch.ElapsedMilliseconds} ms");
        watch.Restart();
    }

    protected override void OnAfterRender(bool firstRender)
    {
        if(!firstRender && this.tickets is not null)
        {
            watch.Stop();
            Console.WriteLine($"render data cost {watch.ElapsedMilliseconds} ms");
        }
    }
}

上面代码中值得注意的是两个生命周期函数。

首先是由于我们覆写了OnInitializedAsync方法,但没有覆写OnParametersAsync方法,所以在初次加载的时候,这个组件会被渲染两次:

  1. 第一次渲染,发生在OnInitializedOnInitializedAsync调用返回后,但await OnInitializedAsync还未完成时
  2. 第二次渲染,发生在await OnInitializedAsync完成,且框架对OnParameters{Async}的调用完成后
  3. 由于我们没有覆写OnParametersAsync,所以框架没必要对其进行await,也就自然没有第三次渲染

有两次渲染意味着OnAfterRender{Async}会被框架调用两次。

在初次渲染时,调用API前,我们打开了计时器,并且在API返回后,掐下计时器,这时计时器里的时间就包括:

  1. 从API调用发起,收到HTTP Response的时间
  2. 框架内部将HTTP Response解析为IEnumerable<LotteryTicket>的时间
  3. 还包括组件初次渲染的时间:在第一次watch.Start()后,http response尚未返回且被解析前,第一次渲染已经发生且完成。在第一次watch.Stop()后,第二次渲染即将马上开始。

这两部分

然后在输出完这部分时间后,我们重启了计时器。在重启计时器之后,第二次渲染即将开始。在渲染完成后,我们在OnAfterRender中再掐住计时器。注意我们使用了一个判断条件,来确保第二次掐计时器的时候是在第二次渲染发生后。

通过上面的代码,我们就能大致粗略的统计出:

  1. 处理HTTP请求、解析回应到底花了多少时间
  2. 页面渲染这10万条数据花了多少时间(第二次渲染)

在我本地,以上代码的运行结果如下所示:

time_cost

可以看到处理HTTP请求解析数据花了1.7秒左右,页面渲染这10万条数据花了1.3秒左右。

而如果我们在样式上稍微整一点点花活,如下,就基本能把渲染时间增加到2.2秒左右

time_cost2

而我们整的花活的改动如下:


             <tr>
-                <td>@context.BlueBalls[0]</td>
-                <td>@context.BlueBalls[1]</td>
-                <td>@context.BlueBalls[2]</td>
-                <td>@context.BlueBalls[3]</td>
-                <td>@context.BlueBalls[4]</td>
-                <td>@context.BlueBalls[5]</td>
-                <td>@context.RedBalls[0]</td>
-                <td>@context.RedBalls[1]</td>
+                @foreach(var blueBall in context.BlueBalls)
+                {
+                    string foregroundColor = this.tdColors[random.Next(this.tdColors.Length)];
+                    string backgroundColor1 = this.tdColors[random.Next(this.tdColors.Length)];
+                    string backgroundColor2 = this.tdColors[random.Next(this.tdColors.Length)];
+                    string style = $"color: {foregroundColor}; background-image: linear-gradient({backgroundColor1}, {backgroundColor2})";
+                    <td style=@style>@blueBall</td>
+                }
+                @foreach(var redBall in context.RedBalls)
+                {
+                    string foregroundColor = this.tdColors[random.Next(this.tdColors.Length)];
+                    string backgroundColor1 = this.tdColors[random.Next(this.tdColors.Length)];
+                    string backgroundColor2 = this.tdColors[random.Next(this.tdColors.Length)];
+                    string style = $"color: {foregroundColor}; background-image: linear-gradient({backgroundColor1}, {backgroundColor2})";
+                    <td style=@style>@redBall</td>
+                }
             </tr>

 }
 
 @code {
     // ...
+    private string[] tdColors = new string[] { "red", "yellow", "brown", "cyan", "coral", "gold", "gray" };
+    private Random random = new Random();
     // ...
 }

就这么一点点改动,这么一点点样式,就能把渲染时间拉到原来的二倍。你就可想而知,如果我们是使用第三方Blazor组件库里的表格、列表去渲染10万条数据的话,渲染耗时少说拉到五六秒甚至十几秒都是不夸张的事。

我知道正在阅读这篇文章的你可能对这个例子嗤之以鼻,我也确实知道这个例子构造的不是很好,没有显著的突出“渲染耗时”,但咱这是教学系列文章,例子不可能与真实开发条件那么接近,有那么个意思就行了。

4.1 按需渲染

言归正传,在这种情况下,我们应该如何优化性能呢?显然最正确的做法是后端写分页API,前端只渲染几百条数据,这样最好。但假如我就是不想在API里写分页逻辑呢?

我们就得有一种方式,在前端方面取一点巧:页面上确实渲染了10万条数据,但显然的是,人眼一次也就看几十条数据顶天了,显然对于前端来说,应该“只渲染一小部分数据,甚至只渲染屏幕范围内能展示出来的数据”,然后在“用户滚动滚轮、拉下滚动条的时候,即时渲染其它数据”是个好办法。

这个思路没问题,其实按我们目前的知识积累,我们发挥一点想象力,也能写出这样一个组件出来,但问题是:这个组件太难写了,我们要计算当前屏幕能容纳多少内容,还要处理滚动条相关事件。

好消息是,Blazor框架内置了一个小工具,帮我们实现了上述功能。这个小工具也是一个组件,叫Virtualize。我们可以把我们的ItemsBoard组件改写成下面这样:

+@using Microsoft.AspNetCore.Components.Web.Virtualization
+
 @typeparam TypeOfItem
 
 @if(!string.IsNullOrEmpty(this.Name) && this.Items is not null && this.ItemTemplate is not null && this.ItemsFragmentTemplate is not null)
 {
     <div>
         <h3>@this.Name</h3>
         @this.ItemsFragmentTemplate(
-            @:@foreach(TypeOfItem item in this.Items)
-            {
-                @this.ItemTemplate(item)
-            }
+            @<Virtualize Items=@this.Items.ToList() TItem=@TypeOfItem>
+                    @this.ItemTemplate(context)
+            </Virtualize>
         )
     </div>
 }

 ...
 ...
 ...

这时再运行起来看效果:

virtualize

可以说效果十分拔群了,把两千多毫秒的渲染耗时直接压到了十几毫秒左右。不过缺陷就是,能明显看出来,WebAssembly间接操作dom还是有性能硬伤的,滚轮拉得快的情况下,能明显感受到页面是在“按需渲染”。

而从代码写法上来说,Virtualize能优化的场合要符合以下条件:

  1. 要渲染的数据是个集合,或者更准确的说,要是一个ICollection。这也是为什么我们给VirtualizeItems属性赋值的时候要把this.Items转换成ToList()的原因:因为IEnumerable接口并不兼容ICollection
  2. 渲染的过程就是用foreach循环逐一渲染数据集合中的一个个数据

此时我们就可以借助Virtualize来做前端优化,写法上如下:

  1. 先把整个foreach循环删掉,用<Virtualize>组件来替换foreach循环。作为使用者,你甚至可以把<Virtualize>组件当成是一种特殊的foreach循环
  2. 要在Items参数中说明数据集合是什么。。注意要转换成ICollection兼容类型
  3. 多数情况下,编译器可以通过Items参数的值来推断出数据集合中单个数据的类型,但在某些场合下,比如上面的泛型场合下,我们需要手动给TItem属性赋值,说明每个数据的类型
  4. 把循环体写在<Virtualize>内部作为ChildContent传递给它,但需要注意的是,默认情况下,对单个数据的引用变量名是context。。你可以简单的把它理解为,你在写一个@foreach(var context in Items){}的循环体

还有一个知识点需要说明一下:在上面的代码改写中,我们是将@:@foreach改写成了@<Virtualize>,这是因为我们并不是要就地渲染数据,而是要把数据集的渲染结果,作为一个RenderFragment传递给@this.ItemsFragmentTemplate

最后,如果你对context这个变量名不满的话,可以通过给<Virtualize>组件传递一个名为Context的组件参数,来自定义单个数据的变量名,如下:

-            @<Virtualize Items=@this.Items.ToList() TItem=@TypeOfItem>
+            @<Virtualize Items=@this.Items.ToList() TItem=@TypeOfItem Context="item">
-                    @this.ItemTemplate(context)
+                    @this.ItemTemplate(item)
             </Virtualize>

4.2 你以为这是个皮鞋,其实是个电吹风

Virtualize在通常情况下,用Items接收数据,用Context指定单个数据变量名,就足以满足多数需求了。但如果你和我一样有好奇心,会发现它还有以下几个有意思的组件参数:

  1. ItemSize参数,接受一个数值
  2. ItemsProvider参数,类型是一个函数指针,细节如下:
public delegate ValueTask<ItemsProviderResult<TItem>> ItemsProviderDelegate<TItem>(ItemsProviderRequest request);

其中函数指针里的入参与返回值类型的定义如下:

public readonly struct ItemsProviderRequest
{
    public int StartIndex { get; }
    public int Count { get; }
    public CancellationToken CancellationToken { get; }

    public ItemsProviderRequest(int startIndex, int count, CancellationToken cancellationToken)
    {
        this.StartIndex = startIndex;
        this.Count = count;
        this.CancellationToken = cancellationToken;
    }
}

public readonly struct ItemsProviderResult<TItem>
{
    public IEnumerable<TItems> Items { get; }
    public int TotalItemCount { get; }

    public ItemsProviderResult(IEnumerable<TItem> items, int totalItemCount)
    {
        Items = items;
        TotalItemCount = totalItemCount;
    }
}

定义也非常清晰。

从表面上看,怎么看都会觉得这个ItemSizeItemsProvider的组合,像是能帮助我们在前端页面上写分页逻辑:我们把数据获取的API或方法包装成ItemsProvider的样子,Virtualize组件会帮忙我们自动的传递分页参数,去获取一页一页的数据。

这话,对了三成,错了七成。对的那三成:在有ItemsProvider的情况下,确实不需要Items参数了,Virtualize组件确实会帮助我们把分页参数传递给ItemsProvider指向的方法去获取分页数据。错的那七成是:最终Virtualize的渲染结果还是一个长列表,而不是一个分页控件。Virtualize并不会帮你在页面上再写上“上一页”,“下一页”按钮。

你可能会问,那这个ItemSize参数是干嘛用的?不是用来指定每页容量的吗?

不是,这个参数特别有意思:

  • 默认情况下,Virtualize组件会自动检测渲染区域的高度,来决定每次的“按需渲染”到底需要渲染多少数据。
  • 问题来了:比如渲染区域有1000个像素的高度,要得知到底要渲染多少条数据,还有一个关键信息需要获得:那就是每条数据要占多少像素的高度。而这个尺寸,就是ItemSize的语义。如果你查看ItemSize参数的类型,也会看到它虽然是个数值类型,但并不是整数数值类型,而是float类型的

但问题来了:如果我在给Virtualize组件传递的ItemSize参数上写,每条数据需要占100个像素,但实际渲染的时候,DOM渲染结果上,每条数据只用了30像素,会发生什么?

答案是:会以实际渲染结果为准,即Virtualize会按照实际的渲染结果,看到每个数据条目只需要30个像素,那么就按30个像素去计算“即时渲染需要渲染多少个条目”。

ItemSize有什么卵用?

实际上,在初次渲染结束之前,Virtualize是无法得知渲染结果的,ItemSize主要是给Virtualize组件一个提醒:在初次渲染的时候,你先按照这个值去算。如果计算结果出现了偏差:比如本来需要渲染100个条目去铺满父元素,但初次渲染只渲染了20个,那么Virtualize组件就会立即进行重绘。在后续的渲染过程中,ItemSize的值就基本没什么存在意义了。

我们无法揣测这个组件参数的设计初衷,只是从使用者的角度来看,除非你仔细研究了Virtualize的内部实现,否则不要去手贱动这个值。提一句,框架默认实现中,这个组件参数的默认值是50像素。

ItemSize有异曲同工之妙的另外一个组件参数,叫OverscanCount。它的默认值是3。我举例来说明一下它的功效:比如,虽然经过严密的计算,Virtualize认为只需要渲染10条数据就能铺满父元素的空间,但为了保险起见,Virtualize会额外再多渲染几个元素,这个额外多的数量,就是OverscanCount

总之,作为框架使用者,除非有特别的理由,以及特别清楚Virtualize组件的内部实现,否则,我都不建议你们去更改ItemSizeOverscanCount这两个组件参数的值。

但,有一个知识点需要注意:当我们讨论Virtualize渲染了几个数据条目时,说的是浏览器DOM中出现了几个条目。但这些条目并不一定都要展示给用户。

比如,我们在Index.razor中如下实现:

@page "/"

@using HelloComponents.Shared;
@using Microsoft.AspNetCore.Components.Web.Virtualization;
@inject HttpClient httpClient

@if(this.tickets is null)
{
    <p>Lottery forecast data is loading...</p>
}
else
{
     <div style="height: 200px;">
        <Virtualize ItemsProvider=@this.LotteryTicketItemsProvider Context="lottery" TItem=@LotteryTicket>
            <p>
            <span style="color:blue">
                @foreach(int blueNum in lottery.BlueBalls)
                {
                    <text>@blueNum,</text>
                }
            </span>
            <span style="color:red">
                @foreach(int redNum in lottery.RedBalls)
                { 
                    <text>@redNum,</text>
                }
            </span>

            </p>
        </Virtualize>
     </div>
}

@code {
    private IEnumerable<LotteryTicket>? tickets = default!;

    protected override async Task OnInitializedAsync()
    {
        this.tickets = await this.httpClient.GetFromJsonAsync<IEnumerable<LotteryTicket>>("api/lotteryForecast").ConfigureAwait(false);
    }

    private async ValueTask<ItemsProviderResult<LotteryTicket>> LotteryTicketItemsProvider(ItemsProviderRequest request)
    {
        return new ItemsProviderResult<LotteryTicket>(
            this.tickets.Skip(request.StartIndex).Take(request.Count),
            this.tickets!.Count()
        );
    }
}

运行结果如下图所示:

overflow

虽然我们的div写了高度只有200px,但由于各种原因,导致Virtualize组件认为需要渲染二三十个条目。具体Virtualize是怎么算出这个智障值的,不重要。重要的是,我们如何把这个滚动框,限制在div之内。

即DOM元素中有多个小条目,不重要,重要的是让用户视觉上只认为这个滚动框只有200px大小。

这时,就需要overflow:auto样式出动了,给外层div套上一个overflow:auto样式,就可以达到下面的效果:

overflow2

4.3 有关Virtualize组件的其它有用的参数

PlaceholderItemContent

当你的ItemsProvider背后的实现确实是一个服务端的分页获取数据的API时,就会出现滚动过程中,Virtualize需要即时加载数据,但由于网络原因并没有那么迅速,会导致页面短暂的空白:没内容可渲染。

这时可以通过Placeholder来提升用户体验:它的意思是,当数据还未加载完成,即await ItemsProvider还未完成的情况下,用来显示 loading 信息字样。

与此同时,需要把原先传递给ChildContent的值传递给ItemCount,如下:

<Virtualize Context="employee" ItemsProvider="@LoadEmployees">
    <ItemContent>
        <p>
            @employee.FirstName @employee.LastName has the 
            job title of @employee.JobTitle.
        </p>
    </ItemContent>
    <Placeholder>
        <p>
            Loading&hellip;
        </p>
    </Placeholder>
</Virtualize>

EmptyContent

Placeholder类似,但EmptyContent处理的情形是:API成功返回了,但后端完全没有任何数据返回,一条都没有。这时就可以使用EmptyContent来展示一些提示信息,提升用户体验,用法如下:

<Virtualize Items="@stringList">
    <ItemContent>
        <p>
            @context
        </p>
    </ItemContent>
    <EmptyContent>
        <p>
            There are no strings to display.
        </p>
    </EmptyContent>
</Virtualize>

还有很多

有关Virtualize组件,可介绍的内容其实还有很多,还有很多花活值得说,但我认为没有必要过于研究这个东西,原因主要是我认为,我们现在学习的是Blazor框架的知识,目的是做全栈工程师。而后续接触实际开发的时候,作为全栈工程师,更多的精力会投入到“熟悉一个流行的Blazor UI组件库”中去,比如Antd Blazor或者MudBlazorVirtualize这个组件的设计还是偏向基础,我们在实际开发过程中如果有相关需求,只需要调用对应的Antd或Mud库中的组件就行了,比如它们中的一些数据展示控件,在内部封装了Virtualize,我们不必要过分追逐Virtualize的细节

再加上Virtualize组件内部很多细节其实很鬼扯,典型的就比如如何计算首屏加载的数据条目数,如何检测父元素的高度等。这些知识有用吗?其实从功能角度来讲,用处并不大。比如页面只需要5个条目就能塞满,但Virtualize总是给我塞10个,有问题吗?有。但从用户体验上来说,搞清楚这个问题,真的有必要吗?我看未必。

所以说,我们大致了解一下Virtualize的相关概念知识,就足够了。了解一点基础用法,能应付以后实际开发过程中的一些边角需求,也足够了,这个组件的内部实现还是挺复杂的,没必要钻牛角尖。

不过话说回来,如果未来某一天,你想做一些更“高尚”的工作,而不仅仅的建设一个网站,让它长成需求方的样子的话,Virtualize确实是值得你研究的,比如完成一个组件库,或者要对Antd或Mud中的某个数据展示组件在极端情况下做性能优化等。但这种场合已经超出了我这系列文章的范畴了。

5. 动态组件

所谓的动态组件,是一个叫DynamicComponent的组件,它接受两个组件参数:

  1. Type,传入一个类型信息
  2. Parameters,传入IDictionary<string, object>类型的一个字典

这玩意有什么用呢?简单来说,它可以根据传入的组件参数去动态的去渲染一些组件。有点反射那味了:它可以根据用户的输入,通过反射的方式,动态的去渲染指定组件。

这个东西你说它没用吧,显然不是,这就是UI框架里的反射功能,但你说它有用吧,我们又很难构造出一个符合实际的样例程序去演示它的功能。它的处境和程序设计语言中的反射也很像:几乎所有用到反射的代码,都可以用一个不使用反射特性的实现去替代,无非就是不用反射的版本缺乏一些“灵活性”与“扩展性”而已。

DynamicComponent对于组件库的作者来说还是比较有用的,但对于全栈工程师的日常项目开发来说,知道有这么回事,用到的时候知道有这么个东西就行了,不必要生套。