Loading

第四章-高级组件

使用模板化组件

组件是 Blazor 的重用构建块。 在 C# 中,泛型被大量用于重用; 想想你在泛型中使用的所有集合,比如 List<T>。 如果 Blazor 有类似通用组件的东西会不会很酷? 是的,Blazor 可以!

Blazor 支持模板化组件,您可以在其中指定一个或多个 UI 模板作为参数,从而使模板化组件更加可重用! 例如,您的应用程序可能会在各处使用网格。 您现在可以为 Grid 构建模板化组件,将网格中使用的类型作为参数(非常类似于您可以在 .NET 中构建泛型类型)并分别指定用于每个项目的 UI! 让我们看一个例子。

创建网格模板化组件

创建一个新的 Blazor 项目; 称之为 Components.Advanced。 现在向项目的 Pages 文件夹中添加一个新的 razor 组件,并将其命名为 Grid,如清单 4-1 和 4-2 所示。

这是一个模板化组件,因为它使用 razor 文件中的 @typeparam TItem 语法将 TItem 声明为类型参数。 查看清单 4-1 中的部分 Grid<TItem> 类。 这是 C# 中声明的泛型类型。 将此与类 List<T> 进行比较,其中 T 是类型参数。 您可以拥有任意数量的类型参数; 只需使用 @typeparam 语法列出每个类型参数,但对于这个 Grid<TITem> 组件,我们只需要一个。

清单 4-1 模板化网格组件的代码

using Microsoft.AspNetCore.Components;
using System.Collections.Generic;
namespace Components.Advanced.Pages
{
    public partial class Grid<TItem>
    {
        [Parameter]
        public RenderFragment Header { get; set; } = default!;
        [Parameter]
        public RenderFragment<TItem> Row { get; set; } = default!;
        [Parameter]
        public RenderFragment Footer { get; set; } = default!;
        [Parameter]
        public IReadOnlyList<TItem> Items { get; set; } = default!;
    }
}

清单 4-2 模板化网格组件的标记

@typeparam TItem
<table border="1">
    <thead>
        <tr>@Header</tr>
    </thead>
    <tbody>
        @foreach (var item in Items)
        {
        <tr>@Row(item)</tr>
        }
    </tbody>
    <tfoot>
        <tr>@Footer</tr>
    </tfoot>
</table>

Grid 组件有四个参数。 HeaderFooter 参数是 RenderFragment 类型,它表示我们可以在使用 Grid 组件时指定的一些标记(HTML、Blazor 组件)(我们将在进一步解释 Grid 组件后立即查看一个示例)。 在 Grid 组件中查找清单 4-2 中的 <thead> 元素。 在这里,我们使用 @Header razor 语法告诉 Grid 组件将 Header 参数的标记放在这里。 页脚也是如此。

Row 参数的类型为 RenderFragment<TItem>,它是 RenderFragment 的通用版本。 在这种情况下,您可以指定可以访问 TItem 实例的标记,从而允许您访问 TItem 的属性和方法。 这里的 Items 参数是一个 IReadOnlyList<TItem>,可以将数据绑定到具有 IReadOnlyList<TItem> 接口的任何类,例如 List<T>。 在清单 4-2 中查找 <tbody> 元素。 我们使用 foreach 循环遍历 IReadOnlyList<TItem> 的所有项目(TItem 类型),并使用 @Row(item) razor 语法应用 Row 参数,将当前项目作为参数传递。

使用网格模板化组件

现在让我们看一个使用 Grid 模板化组件的示例。 在 Components.Advanced 项目中打开 FetchData 组件。 将 <table> 替换为 Grid 组件,如清单 4-3 所示。

FetchData 组件使用 Grid 组件将 Items 参数指定为 WeatherForecast 实例的预测数组。 再次查看 Grid 组件中的 Items 类型:IReadOnlyList<TItem>。 编译器足够聪明,可以从中推断出 Grid 的类型参数 (TItem) 是 WeatherForecast 类型。 我喜欢类型推断!

清单 4-3 在 FetchData 组件中使用网格模板化组件

@page "/fetchdata"
@inject HttpClient Http
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from the server.</p>
@if (forecasts == null)
{
	<p><em>Loading...</em></p>
}
else
{
    <Grid Items="forecasts">
        <Header>
            <th>Date</th>
            <th>Temp. (C)</th>
            <th>Temp. (F)</th>
            <th>Summary</th>
        </Header>
        <Row>
            <!-- by default called context -->
            <td>@context.Date</td>
            <td>@context.TemperatureC</td>
            <td>@context.TemperatureF</td>
            <td>@context.Summary</td>
        </Row>
        <Footer>
            <td colspan="4">Spring is in the air!</td>
        </Footer>
    </Grid>
}

@code {
    private WeatherForecast[] forecasts;
    protected override async Task OnInitializedAsync()
    {
        forecasts =await Http.GetFromJsonAsync<WeatherForecast[]> 
            ("sample-data/weather.json");
    }
    public class WeatherForecast
    {
        public DateTime Date { get; set; }
        public int TemperatureC { get; set; }
        public string Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

现在看看清单 4-3 中 Grid 组件的 Header 参数。 此语法会将 <Header> 元素内的任何内容绑定到 RenderFragment 类型的 GridHeader 参数。 在本例中,我们指定了一些 HTML 表头 (<th>)。 网格会将这些放在清单 4-2 中的表格行 (<tr>) 元素中。 Footer 参数类似。

检查清单 4-3 中的 Row 参数。 在 <Row> 元素中,我们希望使用清单 4-2 中迭代中的当前项。 但是我们应该如何访问当前项目呢? 默认情况下,Blazor 会将项目作为上下文参数(TItem 类型)传递,因此您可以将预测实例的日期作为@context.Date 访问。
您可以覆盖参数的名称,如清单 4-4 所示。 这就是我们使用 <Row Context="forecast">Context 参数(由 Blazor 提供)执行的操作。 现在可以使用预测参数访问迭代中的项目。 你能猜出 Grid 的输出是什么吗?

清单 4-4 覆盖上下文参数

<Grid Items="forecasts">
    <Header>
        <th>Date</th>
        <th>Temp. (C)</th>
        <th>Temp. (F)</th>
        <th>Summary</th>
    </Header>
    <Row Context="forecast">
        <!-- by default called context, but now called forecast -->
        <td>@forecast.Date</td>
        <td>@forecast.TemperatureC</td>
        <td>@forecast.TemperatureF</td>
        <td>@forecast.Summary</td>
    </Row>
    <Footer>
        <td colspan="4">Spring is in the air!</td>
    </Footer>
</Grid>

运行您的解决方案并从导航菜单中选择获取数据链接。 欣赏您的新模板化组件,如图 4-1 所示!

image

现在我们有了一个可重用的 Grid 组件,我们可以使用它来显示任何项目列表,将列表传递给 Items 参数,并指定应该在 Header、Row 和 Footer 参数中显示的内容! 但还有更多!

显式指定类型参数的类型

通常,编译器可以推断 TItem 类型参数的类型,但如果这不能按预期工作,则可以显式指定类型。 请注意,这是类型参数的名称,与 List<TItem> 相同。 您可以使用任何有意义的名称。 使用示例 4-5 中的组件时,只需将类型参数的类型指定为 TItem(模板化组件中使用的类型参数的名称)即可。

清单 4-5 显式指定类型参数

<Grid Items="forecasts" TItem="WeatherForecast">
<Header>

使用泛型类型约束

清单 4-6 使用约束的泛型

public class DisposableList<T> where T : IDisposable

我们可以对模板化组件做同样的事情。 例如,我们可以声明 TItem 应该为 Grid 模板化组件实现 IDisposable,如清单 4-7 所示。

清单 4-7 对模板化组件使用约束

@typeparam TItem where TItem: IDisposable

Razor模板

在模板化组件中,您可以拥有 RenderFragment 类型的参数,然后可以使用标记为其赋值。 您还可以使用 Razor 模板为 RenderFragmentRenderFragment<TItem> 赋值。

Razor 模板是一种定义 UI 片段的方法,例如,@Hello!,然后您可以将其传递到 RenderFragment。 Razor 模板通常使用 @<element>...</element> 语法。 在示例的情况下,我们指定了一个不带任何参数的 RenderFragment,例如,在 Grid 的 Header 参数中使用。 但是,如果您需要将参数传递给 RenderFragment<TItem>,您可以使用看起来很像 lambda 函数的语法来创建 Razor 模板。

将 Razor 模板视为用于创建 RenderFragment 的特殊 C# 语法。
让我们看一个例子。 首先添加一个名为 ListView 的新组件,如清单 4-8 和 4-9 所示。 这将使用 <ul><li> HTML 元素显示项目的无序列表(TItem 类型)。

清单 4-8 模板 ListView 组件的代码

using Microsoft.AspNetCore.Components;
using System.Collections.Generic;
namespace Components.Advanced.Pages
{
    public partial class ListView<TItem>
    {
        [Parameter]
        public RenderFragment<TItem> ItemTemplate { get; set; }
        = default!;
        [Parameter]
        public IReadOnlyList<TItem> Items { get; set; }
        = default!;
    }
}

清单 4-9 模板化 ListView 组件的标记

@typeparam TItem
<ul>
    @foreach (var item in Items)
    {
        <li>
            @ItemTemplate(item)
        </li>
    }
</ul>

现在将 ListView 添加到 FetchData 组件中,如清单 4-10 所示(我省略了大部分未更改的部分)。 ItemTemplate 参数现在使用 @code 部分中指定的 forecastTemplate RenderFragment。 查看示例 4-10 中的 forecastTemplate。 这使用的语法非常类似于将预测作为参数的 C# lambda 函数,并使用 (forecast) => @<span>@forecast.Summary</span> razor语法返回 RenderFragment<TItem>

清单 4-10 使用带有 RenderFragmentListView 组件

@page "/fetchdata"
...
    <ListView Items="forecasts">
        <ItemTemplate>
            @forecastTemplate(context)
        </ItemTemplate>
    </ListView>
}

@code {
    private RenderFragment<WeatherForecast> forecastTemplate =
    (forecast) => @<span>@forecast.Summary</span>;
    ...

Wig-Pig 语法

让我们疯狂一下:我们可以有一个 RenderFragment<RenderFragment> 吗? 目前,我们的 ListView<TItem> 使用 <ul> 来包装项目,但是如果 ListView<TItem> 的用户想要使用 <ol> 或其他东西怎么办?

查看示例 4-9,这意味着我们希望能够用模板替换外部 (<ul>) 标记,循环遍历项目,并使用另一个模板来呈现每个项目。

创建一个名为 ListView2 的新组件,如清单 4-11 和 4-12 所示(ListView 的一种增强版本)。 请注意,在清单 4-11 中,ListTemplate 参数的类型是 RenderFragment<RenderFragment>。 我们为什么要这个? 因为我们想使用 ListTemplate 作为另一个 RenderFragment 的包装器,所以 RenderFragm ent<RenderFragment> 是有意义的!

清单 4-11 使用 RenderFragment<RenderFragment>

using Microsoft.AspNetCore.Components;
using System.Collections.Generic;
namespace Components.Advanced.Pages
{
    public partial class ListView2<TItem>
    {
        [Parameter]
        public RenderFragment<RenderFragment>? ListTemplate
        { get; set; }
        [Parameter]
        public RenderFragment<TItem> ItemTemplate
        { get; set; } = default!;
        [Parameter]
        public IReadOnlyList<TItem> Items
        { get; set; } = default!;
    }
}

清单 4-12 ListView2 组件

@typeparam TItem
@if(ListTemplate is null )
{
    <ul>
        @foreach (var item in Items)
        {
            <li>
                @ItemTemplate(item)
            </li>
        }
    </ul>
} else
{
}

ListView2 的标记当前将使用默认的 <ul> 列表,以防不使用 ListTemplate(这就是它设置为可为空的原因)。 但是现在我们需要谈谈使用 ListTemplate。 我们想要什么? 我们希望 ListTemplate 包装 foreach 循环,然后调用 ItemTemplate。 因此,我们需要将包含 foreach 循环的 RenderFragment 传递给它。 但是我们如何在我们的组件中做到这一点呢? 让我向您介绍一下 pig-wig 语法:@:@{。 之所以这样称呼它,是因为它看起来像一头戴着假发的脾气暴躁的猪(不是我的发明!)。

在 ListView2 组件中,我们将调用 ListTemplate,如清单 4-13 所示,它使用 pig-wig 语法传递一个循环遍历每个项目并调用 ItemTemplateRenderFragment。 pig-wig 语法由两部分组成。 @: 部分告诉 razor 切换到 C# 模式,@{ 告诉 C# 编译器创建 Razor 模板。

清单 4-13 使用 Pig-Wig 语法

@typeparam TItem
@if(ListTemplate is null )
{
    ...
} else
{
    @ListTemplate(
        @:@{
            foreach(var item in Items)
            {
                @ItemTemplate(item)
            }
        }
    )
}

是时候使用 ListView2 组件了,如清单 4-14 所示。 请将此添加到第一个 ListView 下方的 FetchData 组件中。 由于 ListTemplateRenderFragment 作为参数,因此我们在这里调用上下文(称为 innerTemplate),包装在列表的标记中。 这将调用将调用 ItemTemplateforeach 循环。 因此,作为 ListView2 组件的使用者,您提供了 ListTemplate,但也调用了 innerTemplate 以允许 ListView2 组件呈现它的 pig-wig 模板。 呸…

清单 4-14 使用带有 ListTemplate 的模板化组件

<ListView2 Items="forecasts">
    <ListTemplate Context="innerTemplate">
        <ol>
            @innerTemplate
        </ol>
    </ListTemplate>
    <ItemTemplate Context="forecast">
        <li>@forecast.Summary</li>
    </ItemTemplate>
</ListView2>

使用 Blazor 错误边界

使用模板化组件等可重用组件,您允许组件的用户注入他们自己的逻辑。 但是那个逻辑有缺陷并开始抛出异常是什么?

Blazor 错误边界允许您处理组件内的异常,并提供一些漂亮的 UI 来指示问题,而不会出现异常导致页面的其余部分下降。

让我们举个例子:首先更新类以在天气太冷时抛出异常,如示例 4-15 所示。

清单 4-15 模拟一些有缺陷的逻辑

public class WeatherForecast
{
    public DateTime Date { get; set; }
    public int TemperatureC { get; set; }
    public string? Summary { get; set; }
    public int TemperatureF
        => TemperatureC > 0 ? 32 + (int)(TemperatureC / 0.5556)
        : throw new DivideByZeroException();
}

运行应用程序并选择 FetchData 组件将使整个页面崩溃。 不是很好的用户体验。

更新 Grid 模板化组件以使用 ErrorBoundary 组件,如清单 4-16 所示。 如果内部元素抛出异常,为了保护您想要显示错误 UI 的任何地方,请使用 ErrorBoundary 包装它。

清单 4-16 使用错误边界

@typeparam TItem
<table border="1">
    <thead>
        <tr>@Header</tr>
    </thead>
    <tbody>
        @foreach (var item in Items)
        {
        <ErrorBoundary>
            <tr>@Row(item)</tr>
        </ErrorBoundary>
        }
    </tbody>
    <tfoot>
        <tr>@Footer</tr>
    </tfoot>
</table>

运行应用程序并选择 FetchData 组件现在将导致错误,如图 4-2 所示。

image-20220820211542720

默认情况下,ErrorBoundary 的错误 UI 使用带有 blazor-error-boundary CSS 类的空 div。 您可以自定义此 CSS 类来更改整个应用程序的错误 UI。

您还可以使用其 ErrorContent 参数自定义特定 ErrorBoundary 组件的错误 UI,示例如清单 4-17 所示。

清单 4-17 自定义错误边界

<ErrorBoundary>
    <ChildContent>
        <tr>@Row(item)</tr>
    </ChildContent>
    <ErrorContent>
        <div>Too cold!</div>
    </ErrorContent>
</ErrorBoundary>

构建组件库

组件应该是可重用的。 但是您不想通过在项目之间复制粘贴组件来在项目之间重用组件。 在这种情况下,构建一个组件库要好得多,正如您将看到的,这一点也不难! 通过将 Blazor 组件放入组件库中,您可以将其包含到不同的 Blazor 项目中,将其用于客户端 Blazor 和服务器端 Blazor,甚至将其发布为 NuGet 包!

我们现在要做的是将 GridListView2 组件移动到一个库中,然后我们将在 Blazor 项目中使用这个库。

创建组件库项目

根据您的开发环境,创建组件库是不同的。 我们将研究如何使用 Visual Studio 和 dotnet CLI(它与开发环境无关,因此无论您选择何种 IDE,它都有效)。
使用 Visual Studio,右键单击您的解决方案,然后选择添加新项目。 查找 Razor 类库项目模板,如图 4-3 所示。

image

点击下一步。 将此项目命名为 Components.Library,选择其他项目旁边的文件夹,然后单击 Next。 在下一个屏幕中,单击创建。

使用 dotnet CLI,打开命令提示符或使用 Visual Studio Code 中的集成终端(您可以使用 Ctrl-` 作为在 Code 中切换终端的快捷方式)。将当前目录更改为其他项目所在的文件夹。 输入以下命令:

dotnet new razorclasslib -n Components.Library

dotnet new 命令将基于 razorclasslib 模板创建一个新项目。 如果希望在子目录中创建项目,可以使用 -o <<subdirectory>> 参数指定它。

执行此命令应该会显示类似的输出

The template "Razor Class Library" was created successfully.

切换到解决方案的目录。 通过键入下一个命令将其添加到您的解决方案中(使用 <<path-to>> 占位符供您替换):

dotnet sln add <<path-to>>Components.Library

将组件添加到库

首先,打开 Components.Library 项目文件并添加对可空引用类型的支持:

<Project Sdk="Microsoft.NET.Sdk.Razor">
    <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
    </PropertyGroup>

同时删除库项目中的所有现有文件(_Imports.razor 文件和 wwwroot 文件夹除外)。

之前,我们构建了几个模板化组件。 其中一些是非常可重用的,所以我们将把它们移到我们的库项目中。 从网格开始。

Grid.razorGrid.razor.cs 文件从 Components.Advanced 项目移动(您可以使用 Shift-Drag-and-Drop)到 Components.Library 项目。

对 ListView2 组件执行相同的操作。 两个组件仍在使用客户端的命名空间,因此将它们的命名空间更新为 Components.Library。

构建库项目应该会成功。 构建解决方案仍然会从客户端项目中获得编译器错误,因为我们需要从客户端项目中添加对组件库的引用,我们将在下一部分中修复它。

从您的项目中引用库

使用 Visual Studio,首先右键单击客户端项目并选择 Add > Project Reference。 确保选中 Components.Library 并单击 OK。 Blazor 组件库只是另一种库/程序集

使用项目文件(例如,使用 Visual Studio Code),打开 Components.Advanced.csproj 文件并向其中添加 <ProjectReference> 元素,如清单 4-18 所示。

清单 4-18 添加对另一个项目的引用

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
    ...
    <ItemGroup>
        <ProjectReference Include="..\Components.Library\Components.Library.
                                   csproj" />
    </ItemGroup>
</Project>

使用库组件

现在您已经添加了对组件库的引用,您可以像使用任何其他组件一样使用这些组件,只是这些组件位于另一个命名空间中。 就像在 C# 中一样,您可以使用完全限定名称来引用示例 4-19 中的组件。

清单 4-19 使用完全限定的组件名称

<Components.Library.Grid>
...
</Components.Library.Grid>

就像在 C# 中一样,你可以添加一个 @using 语句,这样你就可以使用组件的名称,如清单 4-20 所示。 将 @using 语句添加到 razor 文件的顶部。

清单 4-20 在 Razor 中添加 @using 语句

@page "/fetchdata"
@inject HttpClient Http
@using Components.Library
...
<Grid Items="forecasts">
...

使用 razor,您可以将 @using 语句添加到 _Imports.razor 文件中,如清单 4-21 所示,这将使您能够在同一目录或子目录中的所有 .razor 文件中使用命名空间。 考虑这一点的最简单方法是 Blazor 会将 _Imports.razor 文件的内容复制到该目录和子目录中每个 .razor 文件的顶部。

清单 4-21。 将@using 添加到 _Imports.razor

@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Components.Advanced
@using Components.Advanced.Shared
@using Components.Library @* Added using *@

您的解决方案现在应该像以前一样编译并运行。
为什么我们将组件移动到组件库中? 使组件库中的组件可重用于其他项目。 只需添加对库的引用,其组件即可使用!

组件库中的静态资源

也许您想在组件库中使用图像(或其他一些静态文件,如 CSS 或 JavaScript)。 Blazor 运行时要求您将静态资源放在项目的 wwwroot 文件夹中。 如果您希望在应用程序中使用静态资源而不是库,则应将这些资源放在应用程序项目的 wwwroot 文件夹中。对于这两种情况,您都需要将它们放在 wwwroot 文件夹中; 唯一的区别是对于库项目,您需要使用不同的 URL。

我从 https://openclipart.org/ 下载了云的图像并将其复制到 wwwroot 文件夹中(任何图像都可以)。 然后,您可以使用使用资源内容路径的 URL 来引用此静态资源。 如果您的资源位于 Blazor 应用程序的项目中,则路径从 wwwroot 文件夹开始,但对于库项目,URL 应以 _content/{LibraryProjectName} 开头并引用库项目中的 wwwroot 文件夹。 例如,要引用 Components.Library 项目中的 cloud.png 文件,打开 Index.razor 并添加清单 4-22 中的图像。

清单 4-22 引用组件库中的静态资源

@page "/"
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?" />
<img src="_content/Components.Library/cloud.png" alt="Cloud"/>

运行你的项目。 你应该看到你的形象。
您还可以使用相同的 URL 从您的主项目中引用组件库中的此静态内容。

虚拟化

有时您需要显示大量数据,可能是数千行。 如果要使用简单的 foreach 循环为每一行创建 UI,则在加载数据和呈现数据之间会出现明显的延迟,因为 Blazor 运行时必须为每一行创建 UI。 在这里,我们将查看仅呈现可见行的内置虚拟化。

显示大量行

让我们首先为数据构建一个类和一个将生成该数据的大量实例的类。
在 Components.Advanced 项目中添加一个新的 Data 文件夹,并添加清单 4-23 中的 Measurement 类。 你也可以从本书的资源中复制这个类来节省一些打字。

清单 4-23 Measurement类

using System;
namespace Components.Advanced.Data
{
    public class Measurement
    {
        public Guid Guid { get; set; }
        public double Min { get; set; }
        public double Avg { get; set; }
        public double Max { get; set; }
    }
}

现在将清单 4-24 中的 MeasurementsService 类添加到 Data 文件夹中。 MeasurementsService 类有一个返回许多行的 GetMeasurements 方法。 您可以更改 nrOfRows 常量以使用行数。 所以为什么
GetMeasurements 方法是否返回 ValueTask<T>? 因为这使我以后可以改变主意并调用一些异步方法,例如,使用 REST 调用来检索数据。 将 ValueTask<T> 视为 T 和 Task<T> 的联合,可以选择是同步还是异步实现方法。 您可以在 https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/ 了解有关 ValueTask<T> 的更多信息。

清单 4-24 MeasurementsService类

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Components.Advanced.Data
{
    public class MeasurementsService
    {
        public ValueTask<List<Measurement>> GetMeasurements()
        {
            const int nrOfRows = 5000;
            var result = new List<Measurement>();
            var rnd = new Random();
            for (int i = 0; i < nrOfRows; i += 1)
            {
                result.Add(new Measurement()
                           {
                               Guid = Guid.NewGuid(),
                               Min = rnd.Next(0, 100),
                               Avg = rnd.Next(100, 300),
                               Max = rnd.Next(300, 400),
                           });
            }
            return new ValueTask<List<Measurement>>(result);
        }
    }
}

将清单 4-25 中名为 NonVirtualMeasurements 的razor组件添加到 Pages 文件夹中。 同样,您可以从提供的来源复制它。 这个组件看起来很像 FetchData 组件,我们在其中获取数据,然后使用 foreach 循环对其进行迭代。 NonVirtualMeasurements 组件还具有一些逻辑来显示使用 .NET Stopwatch 类呈现组件所花费的时间量。 此类具有 Start 和 Stop 方法,并将测量它们之间的时间量。

清单 4-25 显示多行的组件

@using Components.Advanced.Data
@using System.Diagnostics
@if (measurements is null)
{
	<p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Guid</th>
                <th>Min</th>
                <th>Avg</th>
                <th>Max</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var measurement in measurements)
            {
                <tr>
                    <td>@measurement.Guid.ToString()</td>
                    <td>@measurement.Min</td>
                    <td>@measurement.Avg</td>
                    <td>@measurement.Max</td>
                </tr>
            }
        </tbody>
    </table>
}
@code {
    private List<Measurement>? measurements;
    private Stopwatch timer = new Stopwatch();
    protected override async Task OnInitializedAsync()
    {
        MeasurementsService measurementService =
            new MeasurementsService();
        measurements = await measurementService.GetMeasurements();
        timer.Start();
    }
    protected override void OnAfterRender(bool firstRender)
    {
        timer.Stop();
        Console.WriteLine($"Full rendering took {timer.ElapsedMilliseconds} ms.");
    }
}

要完成演示的这一部分,请将 NonVirtualMeasurements 组件添加到您的 Index.razor 文件中,如清单 4-26 所示。

清单 4-26 使用 NonVirtualMeasurements 组件

@page "/"
<h1>Hello, world!</h1>
Welcome to your new app.
<NonVirtualMeasurements/>

构建并运行应用程序。 根据您的计算机速度,您会在 Blazor 构建 UI 时看到明显的延迟(您甚至可能会耗尽内存或使浏览器崩溃!)。 我们还可以查看浏览器的调试控制台,了解渲染过程。 在我的机器上,我得到以下输出:

Full rendering took 746 ms.

考虑到正在创建的行数,这并不是那么糟糕。

使用虚拟化组件

那么我们怎样才能减轻负担呢? Blazor 有一个 Virtualize 组件就是为了这个! Virtualize 组件只会为可见行创建 UI,并且根据屏幕的高度,此演示中渲染的行应该是大约 20 行。比 5000 行好得多!当您滚动时,Virtualize 组件将动态呈现可见的新行。这有一些限制。首先,所有行应该具有相同的高度;否则,Virtualize 组件无法在不渲染所有其他前面的行的情况下计算要渲染的行。仅当有许多不可见的行时才应使用此组件。是时候看看这个了。复制粘贴 NonVirtualMeasurements.razor 文件并将其命名为 VirtualMeasurements.razor。替换清单 4-27 中的 foreach 循环。 Virtualize 组件是一个模板化组件,它通过 Items 参数接收其项目,并使用 Virtualize.ItemContent 参数来呈现每个项目。将 <ItemContent> 视为 for 循环的主体。

清单 4-27 将 foreach 替换为 Virtualize 组件

<Virtualize Items="@measurements" Context="measurement">
    <ItemContent>
        <tr>
            <td>@measurement.Guid.ToString()</td>
            <td>@measurement.Min</td>
            <td>@measurement.Avg</td>
            <td>@measurement.Max</td>
        </tr>
    </ItemContent>
</Virtualize>

Index.razor 中的 NonVirtualMeasurements 组件替换为 VirtualMeasurements 组件。
构建并运行。 现在 UI 几乎立即呈现,当我查看浏览器的调试器控制台时,我看到

Full rendering took 28 ms.

这速度更快! 尝试滚动。 它滚动流畅! 使用 Virtualize 组件,您几乎无需任何工作即可获得许多功能。 但这还不是全部!

添加分页

我们能做的还有很多。 我们的组件正在从服务加载所有数据,而我们只显示一小部分行。 使用 Virtualize 组件,我们可以更改服务,因此它只返回正在显示的行。 我们通过在 Virtualize 组件上设置 ItemsProvider 参数来做到这一点,该组件是一个异步委托,接受 ItemsProviderRequest 并返回 ItemsProviderResult<T>

让我们改变我们的测量来做到这一点。 首先,在 MeasurementsService 类中实现 GetMeasurementsPage 方法,如清单 4-28 所示。 此方法返回一个元组,其中包含行段和总行数(所有行,而不仅仅是段大小)。

清单 4-28 向 MeasurementsService 添加分页

public ValueTask<(List<Measurement>, int)> GetMeasurementsPage
    (int from, int count, CancellationToken cancellationToken)
{
    const int maxMeasurements = 5000;
    var result = new List<Measurement>();
    var rnd = new Random();
    count = Math.Max(0, Math.Min(count, maxMeasurements - from));
    for (int i = 0; i < count; i += 1)
    {
        result.Add(new Measurement()
                   {
                       Guid = Guid.NewGuid(),
                       Min = rnd.Next(0, 100),
                       Avg = rnd.Next(100, 300),
                       Max = rnd.Next(300, 400),
                   });
    }
    return new ValueTask<(List<Measurement>, int)>((result, maxMeasurements));
}

复制粘贴 VirtualMeasurements.razor 文件并将其命名为 PagedVirtualMeasurements.razor。 使用 ItemsProvider 参数更新 Virtualize 组件,如清单 4-29 所示。 现在 Virtualize 组件将要求 ItemsProvider 获取几行。 当然,它必须估计屏幕上有多少行,这就是为什么我还提供了 ItemSize 参数。

ItemsProvider 是采用 ItemsProviderRequest 的异步方法,它具有三个属性:StartIndexCountCancellationToken。 我们使用这些属性调用 GetMeasurementPage 方法,该方法返回行集合和总行数。 然后将其作为 ItemsProviderResult 返回。

清单 4-29 使用 ItemsProvider

@using Components.Advanced.Data
@using System.Diagnostics
<table class="table">
    <thead>
        <tr>
            <th>Guid</th>
            <th>Min</th>
            <th>Avg</th>
            <th>Max</th>
        </tr>
    </thead>
    <tbody>
        <Virtualize ItemsProvider="@LoadMeasurements"
                    ItemSize="25"
                    Context="measurement">
            <ItemContent>
                <tr>
                    <td>@measurement.Guid.ToString()</td>
                    <td>@measurement.Min</td>
                    <td>@measurement.Avg</td>
                    <td>@measurement.Max</td>
                </tr>
            </ItemContent>
            <Placeholder>
                <tr><td colspan="4">Loading...</td></tr>
            </Placeholder>
        </Virtualize>
    </tbody>
</table>
@code {
    private async ValueTask<ItemsProviderResult<Measurement>>
        LoadMeasurements(ItemsProviderRequest request)
    {
        MeasurementsService measurementService =
            new MeasurementsService();
        var (measurements, totalItemCount) =
            await measurementService.GetMeasurementsPage
            (request.StartIndex, request.Count,
             request.CancellationToken);
        return new ItemsProviderResult<Measurement>(
            measurements, totalItemCount);
    }
    private Stopwatch timer = new Stopwatch();
    protected override void OnInitialized()
    {
        timer.Start();
    }
    protected override void OnAfterRender(bool firstRender)
    {
        timer.Stop();
        Console.WriteLine($"Full rendering took {timer.ElapsedMilliseconds} ms.");
    }
}

VirtualMeasurements 组件替换为 Index.razor 中的 PagedVirtualMeasurements 组件。 现在我们准备好运行了。 再次体验非常流畅。 UI 即时呈现,滚动速度非常快。 当然,有一点作弊。 如果我们要通过网络连接检索行,我们不会有延迟来获取我们将拥有的行。 让我们效仿一下。 通过添加示例 4-30 中的延迟来减慢 GetMeasurementsPage 方法。 在这里,我们添加对 Task.Delay 的调用来模拟延迟。 您可以使用延迟常数来使事情变得更慢。

清单 4-30 使用 Task.Delay 模拟慢速获取

public async ValueTask<(List<Measurement>, int)> GetMeasurementsPage
    (int from, int count, CancellationToken cancellationToken)
{
    const int maxMeasurements = 5000;
    // Start Add delay
    const int delay = 50;
    await Task.Delay(delay, cancellationToken);
    // End Add delay
    var result = new List<Measurement>();
    var rnd = new Random();
    count = Math.Max(0, Math.Min(count, maxMeasurements - from));
    for (int i = 0; i < count; i += 1)
    {
        result.Add(new Measurement()
                   {
                       Guid = Guid.NewGuid(),
                       Min = rnd.Next(0, 100),
                       Avg = rnd.Next(100, 300),
                       Max = rnd.Next(300, 400),
                   });
    }
    return (result, maxMeasurements);
}

运行它并开始滚动。 由于延迟,Virtualize 组件可能没有要渲染的行,因此有一个 Placeholder 参数显示在其位置。 当然,在加载行的那一刻,它会被 ItemContent 替换。

动态组件

有时您可能不知道渲染 UI 所需的组件。 也许您需要等待用户做出选择,然后根据用户的选择显示组件。 你会怎么做? 您可以为每个选择使用详细的 if 语句,但这很快就会成为维护的噩梦! 但是,Blazor 现在具有 DynamicComponent 组件,可以在运行时轻松选择组件。 想象一下,您想开一家宠物酒店,因此人们需要能够注册他们的宠物。 最初,您将搭乘猫和狗,但从长远来看,您可能想搭乘其他动物。 因此,您从清单 4-31 中的以下枚举开始。

清单 4-31 AnimalKind 枚举

namespace Components.Advanced.Data
{
    public enum AnimalKind
    {
        Unknown,
        Dog,
        Cat
    }
}

接下来,为每种动物添加清单 4-32 中的类,使用继承来更容易重用某些属性。

清单 4-32 不同种类的动物

namespace Components.Advanced.Data
{
    public class Animal
    {
        public string Name { get; set; } = string.Empty;
    }
    public class Dog : Animal
    {
        public bool IsAGoodDog { get; set; }
    }
    public class Cat : Animal
    {
        public bool Scratches { get; set; }
    }
}

您还需要一些组件,每种动物一个。 让我们从清单 4-33 中的 Animal 的基础组件开始。 是的,如果 Blazor 组件以某种方式从 ComponentBase 继承,您也可以使用继承!

清单 4-33 基础动物组件

using Components.Advanced.Data;
using Microsoft.AspNetCore.Components;
namespace Components.Advanced.Pages
{
    public partial class AnimalComponent : ComponentBase
    {
        [Parameter]
        public EventCallback ValidSubmit { get; set; }
    }
}

现在我们从中派生出 CatComponent,如清单 4-34 和 4-35 所示。 所有这些现在应该很熟悉了,除了在标记中您将看到从另一个组件继承的语法:@inherits AnimalComponent 告诉编译器从 AnimalComponent 而不是 ComponentBase 派生。

清单 4-34 CatComponent 代码

using Components.Advanced.Data;
using Microsoft.AspNetCore.Components;
namespace Components.Advanced.Pages
{
    public partial class CatComponent
    {
        [Parameter]
        public Cat Instance { get; set; } = default!;
    }
}

清单 4-35 CatComponent 标记

@inherits AnimalComponent
<EditForm Model="@Instance"
          OnValidSubmit="@ValidSubmit">
    <DataAnnotationsValidator />
    <fieldset>
        <div class="row mb-2">
            <label class="col-2" for="name">Name:</label>
            <InputText class="form-control col-6"
                       @bind-Value="@Instance.Name" />
        </div>
        <div class="row mb-2">
            <div class="col-6 offset-2">
                <ValidationMessage For="@(() => Instance.Name)" />
            </div>
        </div>
        <div class="row mb-2">
            <label class="col-2" for="scratches">Scratches</label>
            <div class="col-1 pl-0 w-auto">
                <InputCheckbox class="form-control col-6"
                               @bind-Value="@Instance.Scratches" />
            </div>
        </div>
        <div class="row mb-2">
            <div class="col-2">
                <button class="btn btn-success">Save</button>
            </div>
        </div>
    </fieldset>
</EditForm>

以非常相似的方式(意味着您可以复制粘贴其中的大部分内容),我们在清单 4-36 和 4-37 中有 DogComponent

清单 4-36 DogComponent 的代码

using Components.Advanced.Data;
using Microsoft.AspNetCore.Components;
namespace Components.Advanced.Pages
{
    public partial class DogComponent
    {
        [Parameter]
        public Dog Instance { get; set; } = default!;
    }
}

清单 4-37 DogComponent 的标记

@inherits AnimalComponent
<EditForm Model="@Instance"
          OnValidSubmit="@ValidSubmit">
    <DataAnnotationsValidator />
    <fieldset>
        <div class="row mb-2">
            <label class="col-2" for="name">Name:</label>
            <InputText class="form-control col-6"
                       @bind-Value="@Instance.Name" />
        </div>
        <div class="row mb-2">
            <div class="col-6 offset-2">
                <ValidationMessage For="@(() => Instance.Name)" />
            </div>
        </div>
        <div class="row mb-2">
            <label class="col-2" for="isagooddog">Is a good dog</label>
            <div class="col-1 pl-0 w-auto">
                <InputCheckbox class="form-control col-6"
                               @bind-Value="@Instance.IsAGoodDog" />
            </div>
        </div>
        <div class="row mb-2">
            <div class="col-2">
                <button class="btn btn-success">Save</button>
            </div>
        </div>
    </fieldset>
</EditForm>

现在添加一个名为 AnimalSelector 的新组件,如清单 4-38 所示。 这是我们将使用 DynamicComponent 的组件。 为什么? 因为我们会要求用户选择一种动物,然后我们会显示与该动物匹配的组件。

清单 4-38 AnimalSelector 标记

<div class="row">
    <div class="col-2">
        Please select:
    </div>
    <div class="col-6 pl-0 pr-0">
        <select class="form-control"
                @onchange="@((ChangeEventArgs e)
                           => AnimalSelected(e.Value))">
            @foreach (AnimalKind kind in Enum.GetValues(typeof(AnimalKind)))
            {
            	<option value="@kind">@kind.ToString()</option>
            }
        </select>
    </div>
</div>

现在当用户选择一种动物时,我们调用清单 4-39 中的 AnimalSelected 方法。 这个方法被传递了一个包含 AnimalKind 值的字符串实例,所以我们把这个字符串解析成一个 AnimalKind 并且我们使用这个值来选择一个 ComponentMetaData 类的实例。

清单 4-39 AnimalSelector 的代码

using Components.Advanced.Data;
using System;
namespace Components.Advanced.Pages
{
    public partial class AnimalSelector
    {
        ComponentMetaData? MetaData;
        private void AnimalSelected(object? value)
        {
            string? val = value?.ToString();
            if (Enum.TryParse<AnimalKind>(val, out AnimalKind kind))
            {
                MetaData = kind.ToMetaData();
            }
        }
    }
}

清单 4-40 中的 ComponentMetaData 包含什么? 它包含一个 Type 属性(是的,属于 Type 类型)和一个名为 Dictionary<string,object>Parameters 属性。DynamicComponent 使用这些属性来选择要显示的组件(例如,当 TypeCatComponent 时,DynamicComponent 将自己替换为 猫组件)。 现在 CatComponent 有一个 [Parameter] 属性(称为 Instance),所以 DynamicComponent 需要提供这个参数。 ComponentMetaData 的参数字典将包含一个名为 Instance 的键,并为 Instance 参数设置值。

清单 4-40 ComponentMetaData

using System;
using System.Collections.Generic;
namespace Components.Advanced.Data
{
    public class ComponentMetaData
    {
        public ComponentMetaData(Type type,
                                 Dictionary<string, object> parameters)
        {
            Type = type;
            Parameters = parameters;
        }
        public Type Type { get; set; }
        public Dictionary<string, object> Parameters { get; }
    }
}

完成此示例的另一件事是:查看清单 4-39 中的 AnimalSelected 方法。 我们如何将 AnimalKind 转换为 ComponentMetaData 实例? 为此,我在清单 4-41 中的 AnimalMetaData 类中有一个 ToMetaData 扩展方法。 此方法使用新的 C# 模式匹配 switch 语句,非常适合此类事情。 在这里,我们打开 AnimalKind 值。 如果它是一只狗,我们返回一只狗的 ComponentMetaData,类似于一只猫,而对于所有其余的(使用 _ 丢弃语法),我们返回一个空值。

清单 4-41 AnimalMetaData 类

using Components.Advanced.Pages;
using System.Collections.Generic;
namespace Components.Advanced.Data
{
    public static class AnimalMetaData
    {
        private static Dictionary<string, object> ToParameters
            (this object instance)
            => new Dictionary<string, object>
        {
            { "Instance", instance }
        };
        public static ComponentMetaData? ToMetaData
            (this AnimalKind animal)
            => animal switch
            {
                    AnimalKind.Dog =>
                        new ComponentMetaData(typeof(DogComponent),
                                              new Dog().ToParameters()),
                    AnimalKind.Cat =>
                        new ComponentMetaData(typeof(CatComponent),
                                              new Cat().ToParameters()),
                    _ => null
            };
    }
}

为了完成 AnimalSelector 组件,我们将查看 MetaData 属性的值(在清单 3-39 中)并使用DynamicComponent 为所选动物选择适当的组件并设置其参数,如清单 4-42 所示。

清单 4-42 完成 AnimalSelector 组件

<div class="row">
    <div class="col-2">
        Please select:
    </div>
    <div class="col-6 pl-0 pr-0">
        <select class="form-control" @onchange="@((ChangeEventArgs e)
                                                => AnimalSelected(e.Value))">
            @foreach (AnimalKind kind in Enum.GetValues(typeof(AnimalKind)))
            {
           	 	<option value="@kind">@kind.ToString()</option>
            }
        </select>
    </div>
</div>
@if (MetaData is not null)
{
    <div class="mt-2">
        <DynamicComponentType="@MetaData.Type" Parameters="@MetaData.Parameters" />
    </div>
}

将 AnimalSelector 添加到您的 Index 组件中(如清单 4-43 所示)。

清单 4-43 带有 AnimalSelector 的索引组件

@page "/"
<div>
	<AnimalSelector />
</div>

运行应用程序。 现在,当您选择一种动物时,相应的编辑器如图 4-4 所示。

image

组件重用和 PizzaPlace

让我们先刷新一下内存。 我们有一个 PizzaItem 组件来显示披萨的详细信息。 我们还有 PizzaList 组件来显示菜单中的比萨饼,我们还有 ShoppingBasket 组件来列出订单中的比萨饼。PizzaList 和 ShoppingBasket 都迭代一个列表,因此这里有重用的机会。 根据清单 4-44 和 4-45 创建一个名为 ItemList 的新组件。 在这里,我们有一个 RenderFragment 类型的页眉和页脚? 和 RenderFragment 类型的 RowTemplate 参数。 Header 和 Footer 参数是可选的,这就是我们使用@if 的原因。 还有 IEnumerable 类型的 Items 参数,该参数允许编译器在我们为其分配集合时推断 TItem 的类型。 我们使用@foreach 迭代这个参数,并调用RowTemplate RenderFragment。

清单 4-44 ItemList 组件的代码

using Microsoft.AspNetCore.Components;
using System.Collections.Generic;
namespace PizzaPlace.Client.Pages
{
    public partial class ItemList<TItem>
    {
        [Parameter]
        public RenderFragment? Header { get; set; }
        [Parameter]
        public RenderFragment<TItem> RowTemplate { get; set; } = default!;
        [Parameter]
        public RenderFragment? Footer { get; set; }
        [Parameter]
        public IEnumerable<TItem> Items { get; set; } = default!;
    }
}

清单 4-45 ItemList 组件的标记

@typeparam TItem
@if (Header is not null)
{
    @Header
}
@foreach (TItem item in Items)
{
    @RowTemplate(item)
}
@if (Footer is not null)
{
    @Footer
}

现在我们有了这个模板化组件,我们可以将它用于 PizzaList 和 ShoppingBasket 组件。
更新 PizzaList 组件的标记,如清单 4-46 所示。

清单 4-46 使用 ItemList 的 PizzaList 组件

<ItemList Items="@Items">
    <Header>
        <h1>@Title</h1>
    </Header>
    <RowTemplate Context="pizza">
        <PizzaItem Pizza="@pizza"
                   ButtonClass="@ButtonClass"
                   ButtonTitle="@ButtonTitle"
                   Selected="@Selected" />
    </RowTemplate>
</ItemList>

并将 ShoppingBasket 标记替换为清单 4-47。

清单 4-47 使用 ItemList 的 ShoppingBasket 组件

@if (Pizzas.Any())
{
    <ItemList Items="@Pizzas">
        <Header>
            <h1 class="">Your current order</h1>
        </Header>
        <RowTemplate Context="tuple">
            <PizzaItem Pizza="@tuple.pizza"
                       ButtonClass="btn btn-danger"
                       ButtonTitle="Remove"
                       Selected="@(() => Selected.InvokeAsync(tuple.pos))" />
        </RowTemplate>
        <Footer>
            <div class="row">
                <div class="col"></div>
                <div class="col"><hr /></div>
                <div class="col"> </div>
                <div class="col"> </div>
            </div>
            <div class="row">
                <div class="col"> Total:</div>
                <div class="col text-right font-weight-bold">
                    @($"{TotalPrice:0.00}")
                </div>
                <div class="col"> </div>
                <div class="col"> </div>
                <div class="col"> </div>
            </div>
        </Footer>
    </ItemList>
}

现在我们通过添加一个模板化的组件来增强我们的 PizzaPlace 应用程序,我们可以将它用于 PizzaList 和 ShoppingBasket 组件。 编译并运行。 PizzaPlace 应用程序应该像以前一样工作。

posted @ 2022-09-04 13:49  F(x)_King  阅读(251)  评论(0编辑  收藏  举报