Loading

第九章-单页应用程序和路由

单页应用程序

SPA 是一种 Web 应用程序,它可以替换 UI 的某些部分,而无需重新加载整个页面。 SPA 使用 JavaScript 来实现对浏览器控制树的这种操作,也称为文档对象模型 (DOM),其中大多数由固定的 UI 和占位符元素组成,其中内容根据用户单击的位置被覆盖。 使用 SPA 的主要优点之一是您可以使 SPA 有状态。 这意味着您可以将应用程序加载的信息保存在内存中,就像构建桌面应用程序一样。 在本章中,您将看到一个使用 Blazor 构建的 SPA 示例。

布局组件

让我们从 SPA 的固定部分开始。 每个 Web 应用程序都包含您可以在每个页面上找到的 UI 元素,例如页眉、页脚、版权、菜单等。将这些元素复制粘贴到每个页面将需要大量工作,并且需要更新每个页面,如果其中一个 这些要素需要改变。 开发人员不喜欢这样做,所以每个构建网站的框架都有一个解决方案。 例如,ASP.NET WebForms 使用母版页,而 ASP.NET MVC 具有布局页。 Blazor 还为此提供了一种称为布局组件的机制。

使用 Blazor 布局组件

布局组件是 Blazor 组件。 任何你可以用常规组件做的事情,你都可以用布局组件做,比如依赖注入、数据绑定和嵌套其他组件。 唯一的区别是它们必须继承自 LayoutComponentBase 类。

LayoutComponentBase 类向 ComponentBase 添加了一个 Body 属性,如清单 9-1 所示。

清单 9-1 LayoutComponentBase 类(简体)

namespace Microsoft.AspNetCore.Components
{ 
    public abstract class LayoutComponentBase : ComponentBase
    {
        [Parameter]
        public RenderFragment? Body { get; set; }
    }
}

从清单 9-1 可以看出,LayoutComponentBase 类继承自 ComponentBase 类。 这就是为什么您可以做与普通组件相同的事情的原因。
让我们看一个布局组件的示例。 从本章提供的代码中打开 SinglePageApplications 解决方案。 现在,查看 SPA.ClientShared 文件夹中的 MainLayout.razor 组件,如清单 9-2 所示。 由于布局组件由多个组件使用,因此将布局组件放在共享文件夹中是有意义的,尽管这样做没有技术要求。

清单 9-2 模板中的 MainLayout.razor

@inherits LayoutComponentBase
<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>
    <div class="main">
        <div class="top-row px-4">
            <a href="http://blazor.net" target="_blank"
               class="ml-md-auto">About</a>
        </div>
        <div class="content px-4">
            @Body
        </div>
    </div>
</div>

在第一行,MainLayout 组件声明它继承自 LayoutComponentBase。 然后你会看到一个侧边栏和主 <div> 元素,主元素数据绑定到继承的 Body 属性。 任何使用此布局组件的组件都将在 @Body 属性所在的位置结束,因此在 <div class="content px-4"> 内部。

在下图中,您可以看到左侧的侧边栏(包含指向此单页应用程序不同组件的链接)和右侧的主要区域,其中 @Body 用黑色矩形突出显示(我添加了 如图)。 单击侧边栏中的 Home、Counter 或 Fetch Data 链接将用所选组件替换 Body 属性,更新 UI 而无需重新加载整个页面。

image

您可以在 MainLayout.razor.css 文件中找到此布局组件使用的 CSS 样式。

配置默认布局组件

那么组件如何知道使用哪个布局组件呢? 组件可以为自己更改布局组件,应用程序可以设置默认布局组件,该组件将用于所有未明确设置其布局的组件。 让我们从应用程序开始。 打开 App.razor 文件,如清单 9-3 所示。 这里首先要注意的是 RouteView 组件,它有一个 Type 类型的 DefaultLayout 属性。 这是设置此应用程序的默认布局的位置。 默认情况下,此 RouteView 组件选择的任何组件都将使用 MainLayout。 如果找不到合适的组件来显示,App 组件使用 LayoutView 来显示错误消息。 同样,此 LayoutView 使用 MainLayout,但当然您可以将其更改为您喜欢的任何布局。

清单 9-3 App.razor 组件

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData"
                   DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

在内部,RouteView 组件使用 LayoutView 组件来选择合适的布局组件。 LayoutView 允许您更改组件的任何部分的布局组件。
让我们创建一个简单的错误布局组件,它将水平居中显示错误。 首先将带有清单 9-4 中标记的名为 ErrorLayout 的新剃须刀组件添加到 Shared 文件夹。

清单 9-4 ErrorLayout组件

@inherits LayoutComponentBase
<div class="error">
@Body
</div>

现在将清单 9-5 中名为 ErrorLayout.razor.cssCSS 文件添加到 Shared 文件夹中。这告诉错误布局将正文放置在浏览器窗口的中心。

清单 9-5 ErrorLayout样式

.error {
    position: relative;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
}

现在将 App.razor 中的 LayoutViewLayout 属性替换为清单 9-6。

清单 9-6 更新的 App.razor 文件

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData"
                   DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(ErrorLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

运行 Blazor 应用程序并通过附加 /x 手动更改浏览器的 URL。由于此 URL 没有任何关联,因此将使用错误布局来显示错误,如图 9-2 所示。

选择布局组件

每个组件都可以通过使用 @layout razor 指令声明布局组件的名称来选择要使用的布局。 例如,首先将 MainLayout.razor 文件复制到 MainLayoutRight.razor(这也应该制作 CSS 文件的副本)。 这将生成一个名为 MainLayoutRight 的新布局组件,从文件名推断(您可能需要重建项目以强制执行此操作)。 在该组件的 CSS 文件中,将两个 flex-direction 属性更改为它们的反向对应物,如清单所示。

image

清单 9-7 第二个布局组件

.page {
    position: relative;
    display: flex;
    flex-direction: column-reverse;
}
...
@media (min-width: 641px) {
    .page {
        flex-direction: row-reverse;
    }
    ...
}

现在打开 Counter 组件并添加一个 @layout razor 指令,如清单 9-8 所示。

清单 9-8 使用 @layout 选择不同的布局

@page "/counter"
@layout MainLayoutRight
<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
    private int currentCount = 0;
    private void IncrementCount()
    {
    	currentCount++;
    }
}

运行应用程序并在 HomeCounter 之间交替时观察布局变化。

大多数组件将使用相同的布局。 除了将相同的 @layout razor 指令复制到每个页面之外,您还可以将 _Imports.razor 文件添加到与组件相同的文件夹中。 从 SPA.Client 项目中打开 Pages 文件夹并添加一个新的 _Imports.razor 文件。 将其内容替换为示例 9-9。

清单 9-9 _Imports.razor

@layout MainLayoutRight

此文件夹(或子文件夹)中未显式声明 @layout 组件的任何组件都将使用 MainLayoutRight 布局组件。

嵌套布局

布局组件也可以嵌套。 您可以定义 MainLayout 以包含所有组件之间共享的所有 UI,然后定义嵌套布局以供这些组件的子集使用。 例如,将一个名为 NestedLayout.razor 的新 razor 视图添加到 Shared 文件夹,并将其内容替换为清单 9-10。

清单 9-10 一个简单的嵌套布局

@inherits LayoutComponentBase
@layout MainLayout
<div class="paper">
	@Body
</div>

要构建嵌套布局,请从 LayoutComponentBase`` @inherit 并将其 @layout 设置为另一个布局,例如 MainLayout。 我们的嵌套布局使用了一个纸类,所以在组件旁边添加一个 NestedLayout.razor.css 文件并添加清单 9-11。

清单 9-11 NestedLayout 组件的样式

.paper {
    background-image: url("images/paper.jpg");
    padding: 1em;
}

此样式使用 images 文件夹中的 paper.jpg 背景。
现在向 Pages 文件夹中的 _Imports.razer 文件添加一个布局指令,如清单 9-12 所示。

清单 9-12 嵌套布局

@layout NestedLayout

运行您的应用程序; 现在您在主布局内的嵌套布局内有了 Index 组件,如图所示。

image

Blazor 路由

单页应用程序使用路由来选择选择哪个组件来填充布局组件的 Body 属性。 路由是将浏览器的 URI 与路由模板集合匹配的过程,用于选择要在屏幕上显示的组件。 这就是为什么 Blazor SPA 中的每个组件都使用 @page 指令来定义路由模板以告诉路由器选择哪个组件。

安装路由器

从头开始创建 Blazor 解决方案时,路由器已安装,但让我们看看这是如何完成的。 打开 App.razor。 这个 App 组件只有一个组件,Router 组件,如清单 9-13 所示。

清单 9-13 包含路由器的 App 组件

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData"
                   DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(ErrorLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

Router 组件是具有两个模板的模板化组件。 Found 模板用于已知路由,当 URI 不匹配任何已知路由时显示 NotFound。 您可以替换最后的内容以向用户显示一个漂亮的错误页面。

Found 模板使用 RouteView 组件,该组件将使用其布局(或默认布局)呈现所选组件。 当 Router 组件被实例化时,它将在其 AppAssembly 属性中搜索所有具有 RouteAttribute 的组件(@page razor 指令被编译为RouteAttribute)并选择与当前浏览器的 URI 匹配的组件。 例如 Counter 组件有 @page "/counter" razor 指令,当浏览器中的 URL 匹配 /counter 时,会在 MainLayout 组件中显示 Counter 组件。

导航菜单组件

查看清单 9-2 中的 MainLayout 组件。 在第四行,您将看到 NavMenu 组件。 该组件包含在组件之间导航的链接。 该组件自带模板; 随意使用另一个组件进行导航。 我们将在这里使用这个组件来探索一些概念。 打开 SPA.Client 项目并在 Shared 文件夹中查找 NavMenu 组件,如清单 9-14 所示。

清单 9-14 导航菜单组件

<div class="top-row pl-4 navbar navbar-dark">
    <a class="navbar-brand" href="">SPA</a>
    <button class="navbar-toggler" @onclick="ToggleNavMenu">
        <span class="navbar-toggler-icon"></span>
    </button>
</div>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
    <ul class="nav flex-column">
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span>
                Home
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="counter">
                <span class="oi oi-plus" aria-hidden="true"></span>
                Counter
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span>
                Fetch data
            </NavLink>
        </li>
    </ul>
</div>

@code {
    private bool collapseNavMenu = true;
    private string NavMenuCssClass
    => collapseNavMenu ? "collapse" : null;
    private void ToggleNavMenu()
    {
    	collapseNavMenu = !collapseNavMenu;
    }
}

清单 9-14 的第一部分包含 Toggle 按钮,它允许您隐藏和显示导航菜单。 此按钮仅在宽度较窄的显示器(例如移动显示器)上可见。 如果您想查看它,运行您的应用程序并缩小浏览器宽度,直到在右上角看到汉堡包按钮,如图 9-4 所示。 单击按钮可显示导航菜单,再次单击可再次隐藏菜单。

image

剩下的标记包含导航菜单,它由 NavLink 组件组成。 让我们看看 NavLink 组件。
NavLink 组件是锚元素 <a/> 的特殊版本,用于创建导航链接,也称为超链接。 当浏览器的 URI 与 NavLinkhref 属性匹配时,它会将 CSS 样式(如果您想自定义它,则为活动的 CSS 类)应用到自身,让您知道它是当前路由。 例如,查看示例 9-15。

清单 9-15 Counter Route 的 NavLink

<NavLink class="nav-link" href="counter">
    <span class="oi oi-plus" aria-hidden="true"></span> Counter
</NavLink>

当浏览器的 URI 以 /counter 结尾(忽略查询字符串之类的内容)时,此 NavLink 将应用活动样式。 让我们看一下示例 9-16 中的另一个。

清单 9-16 默认路由的 NavLink

<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
    <span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>

当浏览器的 URI 为空(站点的 URL 除外)时,示例 9-16 中的 NavLink 将处于活动状态。 但是这里有一个特殊情况。 通常,NavLink 组件只匹配 URI 的结尾。 例如,/counter 匹配示例 9-15 中的 NavLink。 但是对于空 URI,这将匹配所有内容! 这就是为什么在空 URI 的特殊情况下,您需要告诉 NavLink 匹配整个 URI。 您可以使用 Match 属性执行此操作,默认情况下该属性设置为 NavLinkMatch.Prefix。 如果要匹配整个 URI,请使用 NavLinkMatch.All,如清单 9-16 所示。

设置路由模板

Blazor 的 Routing 组件检查浏览器的 URI 并搜索要匹配的组件的路由模板。 但是如何设置组件的路由模板呢? 打开清单 9-8 所示的 Counter 组件。 这个文件的顶部是 @page "/counter" razor 指令。 它定义了路由模板。 路由模板是与 URI 匹配的字符串,可以包含参数,然后您可以在组件中使用这些参数。

您可以通过在路由中传递参数来更改组件中显示的内容。 您可以传递产品的 id,使用 id 查找产品的详细信息,并使用它来显示产品的详细信息。 让我们看一个例子。 通过添加另一个将设置 CurrentCount 参数的路由模板,将 Counter 组件更改为如清单 9-17 所示。 这个清单说明了几件事。 首先,您可以有多个 @page razor 指令,因此 /counter 和 /counter/55 都将路由到 Counter 组件。 第二个@page 指令将从路由中设置CurrentCount 参数属性,并且参数名称在@page 指令中不区分大小写。 当然,参数需要用大括号括起来,这样路由器才能识别它。

清单 9-17 使用参数定义路由模板

@page "/counter"
@page "/counter/{currentCount:int?}"
@layout MainLayoutRight
<h1>Counter</h1>
<p>Current count: @CurrentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
    [Parameter]
    public int CurrentCount { get; set; }
    private void IncrementCount()
    {
    	CurrentCount++;
    }
}

就像 ASP.NET MVC Core 中的路由一样,您可以使用路由约束来限制要匹配的参数类型。 例如,如果您要使用 /counter/Blazor URI,则路由模板将不匹配,因为参数不包含整数值,并且路由器不会找到任何要匹配的组件。

如果您不使用字符串类型的参数,约束甚至是强制性的;否则,路由器不会将参数转换为正确的类型。 您可以通过使用冒号附加约束来指定约束,例如,@page "/counter/{currentCount:int}"。 您还可以通过在约束后附加一个问号来使参数可选,如清单 9-17 所示。

表 9-1 中列出了其他路由约束。 这些中的每一个都映射到相应的 .NET 类型。

路由约束

bool
datetime
decimal
double
float
guid
int
long

如果您将组件构建为纯 C# 组件,请将 RouteAttribute 应用到您的类,并将路由模板作为参数。 这就是 @page 指令被编译成的内容。

重定向到其他页面

如何使用路由导航到另一个组件? 您有三个选择:使用标准锚元素、使用 NavLink 组件和使用代码。 让我们从普通的锚标签开始。

如果使用相对 href,则使用锚点(<a/> HTML 元素)很容易。 例如,在示例 9-17 的按钮下方添加示例 9-18。

清单 9-18 使用锚标记导航

<a class="btn btn-primary" href="/">Home</a>

此链接已使用 Bootstrap 4 设置为按钮。运行您的应用程序并导航到 Counter 组件。 点击 Home 按钮导航到路由模板匹配“/”的 Index 组件

NavLink 组件使用底层锚,因此其用法类似。 唯一的区别是 NavLink 组件在匹配路由时应用活动类。 通常,您只在 NavMenu 组件中使用 NavLink,但您可以随意使用它来代替锚点。

也可以在代码中导航,但您需要通过依赖注入创建 NavigationManager 类的实例。 此实例允许您检查页面的 URI,并具有有用的 NavigateTo 方法。 此方法接受一个字符串,该字符串将成为浏览器的新 URI。

让我们尝试一个例子。 修改 Counter 组件,使其类似于清单 9-19。

清单 9-19 使用导航管理器

@page "/counter"
@page "/counter/{currentCount:int?}"
@layout MainLayoutRight
@inject NavigationManager navigationManager
<h1>Counter</h1>
<p>Current count: @CurrentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
<a class="btn btn-primary" href="/">Home</a>
<button class="btn btn-primary" @onclick="StartFrom50">Start from 50</button>
@code {
    [Parameter]
    public int CurrentCount { get; set; }
    private void IncrementCount()
    {
   	 	CurrentCount++;
    }
    private void StartFrom50()
    {
    	navigationManager.NavigateTo("/counter/50");
    }
}

你用 @inject razor 指令告诉依赖注入给你一个 NavigationManager 的实例并将它放在 navigationManager 字段中。 NavigationManager 是 Blazor 通过依赖注入提供的现成类型之一。 然后添加一个在单击时调用 StartFrom50 方法的按钮。 此方法使用 NavigationManager 通过调用 NavigateTo 方法导航到另一个 URI。 运行您的应用程序并单击“从 50 开始”按钮。 您应该导航到 /counter/50

了解基本标签

导航时请不要使用绝对 URI。 为什么? 因为当您在 Internet 上部署应用程序时,基本 URI 会发生变化。 相反,Blazor 使用 <base/> HTML 元素,所有相关 URI 都将与此 <base/> 标记组合。 <base/> 标签在哪里? 使用 Blazor WebAssembly,打开 Blazor 项目的 wwwroot 文件夹并打开 index.html,如清单 9-20 所示。

清单 9-20 index.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0,
                                       maximum-scale=1.0, user-scalable=no" />
        <title>SPA</title>
        <base href="/" />
        <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
        <link href="css/app.css" rel="stylesheet" />
        <link href="SPA.Client.styles.css" rel="stylesheet" />
    </head>
    <body>
        <div id="app">Loading...</div>
        <div id="blazor-error-ui">
            An unhandled error has occurred.
            <a href="" class="reload">Reload</a>
            <a class="dismiss">🗙</a>
        </div>
        <script src="_framework/blazor.webassembly.js"></script>
    </body>
</html>

如果您使用的是 Blazor Server,则可以在 _Host.cshtml 中找到基本标记。

当您在生产中部署时,您需要做的就是更新基本标记。例如,您可能将应用程序部署到 https://online.u2u.be/selfassessment。 在这种情况下,您需要将基本元素更新为 <base href="/selfassessment" />。 那么为什么需要这样做呢? 如果您部署到 https://online.u2u.be/selfassement,则 Counter 组件的 URI 变为 https://online.u2u.be/selfassessment/counter。 路由将忽略基本 URI,因此它将按预期匹配计数器。 您只需要指定一次基本 URI,如清单 9-20 所示。

您还可以使用 NavigationManagerBaseUri 属性访问基本 URI(带有尾部斜杠)。 这对于将绝对 URI 传递给某些 JavaScript 库很有用。 我们将在下一章讨论 JavaScript 互操作性。

带有路由的延迟加载

Blazor 应用程序中的某些组件可能不经常使用。 但即便如此,Blazor 仍需要在运行应用程序之前将这些组件加载到浏览器中。 对于大型应用程序,这可能意味着您的应用程序将需要更长的时间来加载。 但是,使用 Blazor,我们可以在需要时加载组件。 这称为延迟加载。

延迟加载组件库

延迟加载通过将不常用的组件移动到一个或多个组件库中,然后在您需要它们之前立即下载。 我们在第 3 章和第 4 章讨论了构建组件库。但是让我们从一个项目开始,将这些组件及其依赖项移动到库中,然后延迟加载它们。 在本书的下载中,您应该找到一种称为延迟加载的解决方案。 打开它。 这个项目应该看起来很熟悉。 您应该能够构建和运行此应用程序。 现在,为了示例,假设 Counter 和 FetchData 组件是我们想要延迟加载的组件。

让我们从 Counter 组件开始。 创建一个名为 LazyLoading.Library 的 Razor 类库项目。 将 Counter 组件移动到此库。 现在在客户端项目中添加对该库的项目引用,并将@using 指令添加到_Imports.razor(客户端项目中的那个)。

构建并运行您的解决方案。 单击计数器链接。 唔。 没有找到计数器。 为什么?

当 Router 组件被初始化时,它会从其 AppAssembly 参数中搜索程序集以查找具有 @page razor 指令的组件。 在我们将 Counter 组件移动到 razor 库之前,Counter 是该程序集的一部分。 但现在我们已将其移至剃刀库。 所以我们需要告诉 Router 组件在这个库中搜索可路由的组件。 我们可以通过设置路由器的 AdditionalAssemblies 参数轻松做到这一点。 打开 App.razor 并更新它,如清单 9-21 所示。 在这里,我们将 AdditionalAssemblies 参数设置为 List<Assembly>,其中包含 Counter 组件的 Assembly。 现在应用程序应该显示 Counter 组件。

清单 9-21 使用附加程序集

@using System.Reflection
<Router AppAssembly="@typeof(Program).Assembly"
        AdditionalAssemblies="@additionalAssemblies">
    <Found Context="routeData">
        <RouteView RouteData="@routeData"
                   DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>
@code {
    private List<Assembly> additionalAssemblies =
    new List<Assembly>
    {
    	typeof(Counter).Assembly
    };
}

我们将 Counter 组件移到了 razor 库中,但是在加载应用程序时我们仍然会加载 Counter 组件。 是时候为 razor 库启用延迟加载了。
首先,我们会告诉运行时不要自动加载程序集,然后我们会在需要时加载它。

为延迟加载标记程序集

使用编辑器打开客户端项目文件并添加 BlazorWebAssemblyLazyLoad 元素,如清单 9-22 所示。 这告诉运行时不要自动加载 LazyLoading.Library.dll

清单 9-22 开启延迟加载

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
    <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
    </PropertyGroup>
    <ItemGroup>
        ...
    </ItemGroup>
    <ItemGroup>
        <BlazorWebAssemblyLazyLoad
                                   Include="LazyLoading.Library.dll" />
    </ItemGroup>
</Project>

如果您尝试运行该应用程序,您将收到运行时错误:

Could not load file or assembly 'LazyLoading.Library, Version=1.0.0.0,
Culture=neutral, PublicKeyToken=null' or one of its dependencies.

动态加载程序集

现在我们需要在需要时加载这个程序集。 我们什么时候加载这个程序集? 当我们导航到需要此程序集中的组件的组件时。 我们怎么知道我们正在导航? Router 组件对此有一个称为 OnNavigateAsync 的事件,我们将使用它来检测我们何时导航到使用延迟加载组件的组件。 然后我们将使用 LazyAssemblyLoader 下载程序集,以便它可以使用。

更新 App.razor,如清单 9-23 所示。 首先,我们使用依赖注入获取 LazyAssemblyLoader 的实例。 然后我们使用 OnNavigate 方法实现 OnNavigateAsync 事件。 这个方法接收一个 NavigationContext 实例,如果我们导航到 Counter 组件,我们会检查 Path。 如果是这样,我们为 Counter 组件 (LazyLoading.Library.dll) 加载程序集,并将其添加到 additionalAssemblies 集合中,以便路由器组件可以扫描它以查找路由模板。

清单 9-23 在需要时加载程序集

@using System.Reflection
@using Microsoft.AspNetCore.Components.WebAssembly.Services
@inject LazyAssemblyLoader assemblyLoader
<Router AppAssembly="@typeof(Program).Assembly"
        AdditionalAssemblies="@additionalAssemblies"
        OnNavigateAsync="OnNavigate">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof
                                                         (MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

@code {
    private List<Assembly> additionalAssemblies =
    new List<Assembly>
    {
    };
    private async Task OnNavigate(NavigationContext context)
    {
        if( context.Path == "counter")
        {
        	var assembliesToLoad = new List<string>
            {
            "LazyLoading.Library.dll"
            };
            var assemblies = await assemblyLoader.LoadAssembliesAsync
            (assembliesToLoad);
            additionalAssemblies.AddRange(assemblies);
        }
    }
}

在我们可以运行之前,我们需要配置依赖注入

builder.Services.AddScoped<LazyAssemblyLoader>();

构建并运行应用程序。 它应该会启动,当我们点击 Counter 时,浏览器会下载并渲染它。

如果我们的网络很慢怎么办? 也许我们想在程序集下载时显示一些加载 UI? 路由器有一个导航 RenderFragment,它在加载时显示。 所以再次更新 App.razor 文件,如清单 9-24 所示,添加导航 UI。

清单 9-24 显示导航 UI

<Router AppAssembly="@typeof(Program).Assembly"
        AdditionalAssemblies="@additionalAssemblies"
        OnNavigateAsync="OnNavigate">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof
                                                         (MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
    <Navigating>
        Loading additional components...
    </Navigating>
</Router>

延迟加载和依赖

现在让我们尝试延迟加载 FetchData 组件。 此组件使用由 WeatherService 类(Blazor 项目中的那个)实现的 IWeatherService 实例。 我们将把两者都移到组件库中。

开始将 FetchData 组件和 WeatherService 类移动到组件库中。 由于 WeatherService 使用共享项目的 IWeatherService,因此为共享项目添加对库项目的项目引用。

您的库项目现在应该可以编译了。

从 App.razor 更新 OnNavigate 方法以检查 FetchData URI,如清单 9-25 所示。

清单 9-25 FetchData 的 OnNavigate 方法

private async Task OnNavigate(NavigationContext context)
{
    if (context.Path == "counter" || context.Path == "fetchdata")
    {
        var assembliesToLoad = new List<string>
        {
            "LazyLoading.Library.dll"
        };
        var assemblies = await assemblyLoader
            .LoadAssembliesAsync(assembliesToLoad);
        additionalAssemblies.AddRange(assemblies);
    }
}

在 C# 中修复几个命名空间后,项目应该构建。 但是运行会失败。 为什么? 在 Program.cs 中,您正在从延迟加载的库中添加 WeatherService 类,但尚未加载(因为您告诉运行时不要加载它)。

也许我们可以推迟注册 WeatherService? 抱歉,这行不通。 初始化后,依赖注入变得不可变,因此您以后无法添加依赖项。 当然,我们可以将 WeatherService 保留在 Blazor 客户端项目中,但让我们假设延迟加载它是值得的。 是时候介绍一点层了。 我们将使用工厂方法来创建依赖项,我们将使用依赖注入来注入工厂方法。 这将需要进行一些更改。

注意 工厂是一个类,它有一个方法可以创建某个类的实例,隐藏了创建过程。 例如,工厂可以创建一个实例,其中实例的类取决于某些业务规则。 当然,所有返回的实例都应该有一些共同的基类或接口。 实际上,依赖注入使用的IServiceProvider也是一个工厂,但是我们这里不能使用它,因为它不知道WeatherService的存在。 使用您最喜欢的搜索引擎并搜索“C# 中的工厂模式”以了解更多信息。

组件库和 Blazor 客户端应用程序都需要共享工厂接口,因此将 IWeatherServiceFactory 添加到 Shared 项目中,如清单 9-26 所示。

清单 9-26 IWeatherServiceFactory 接口

namespace LazyLoading.Shared
{
    public interface IWeatherServiceFactory
    {
        IWeatherService Create();
    }
}

更新 FetchData 组件以使用 IWeatherService 工厂创建 IWeatherService 实例,如清单 9-27 所示。

清单 9-27 更新 FetchData 组件

@page "/fetchdata"
@using LazyLoading.Shared
@inject IWeatherServiceFactory weatherServiceFactory
...
@code {
    private IEnumerable<WeatherForecast> forecasts;
    protected override async Task OnInitializedAsync()
    {
        IWeatherService weatherService = weatherServiceFactory.Create();
        forecasts = await weatherService.GetForecasts();
    }
}

最后,我们将在客户端项目中实现 IWeatherServiceFactory 接口,如清单 9-28 所示,以创建实际的 WeatherService。 因为我们在使用工厂时只需要实现 WeatherService ,所以这将起作用,因为包含 WeatherService 的库将通过延迟加载来加载。 但是,WeatherService 有自己的依赖项,因此我们将在工厂中请求这些并将它们传递给实际的服务。 工厂是一个很小的类,当实际服务及其依赖项很大时,这种技术就变得很有趣。

清单 9-28 实现 IWeatherServiceFactory

using LazyLoading.Library.Services;
using LazyLoading.Shared;
using System.Net.Http;
namespace LazyLoading.Client
{
    public class WeatherServiceFactory : IWeatherServiceFactory
    {
        private readonly HttpClient httpClient;
        public WeatherServiceFactory(HttpClient httpClient)
        {
            this.httpClient = httpClient;
        }
        public IWeatherService Create() => new WeatherService(httpClient);
    }
}

将另一个页面添加到 PizzaPlace

让我们向 PizzaPlace 应用程序添加一个详细信息页面。 这将允许客户检查有关比萨饼的成分和营养信息。

当您使用路由在不同的 Blazor 组件之间导航时,您可能会遇到需要将信息从一个组件发送到另一个组件。实现此目的的一种方法是通过在 URI 中传递目标组件来设置一个参数。例如,您可以导航到 /pizzadetail/5 以告诉目标组件显示有关 id 为 5 的比萨饼的信息。然后目标组件可以使用服务加载有关比萨饼 #5 的信息,然后显示此信息。但在 Blazor 中,还有其他方法可以将信息从一个组件传递到另一个组件。如果两个组件共享一个共同的父组件,我们可以使用数据绑定。否则,我们可以使用一个 State 类(大多数开发人员称它为 State,但这只是一个约定,您可以随意调用它;State 很有意义),然后使用依赖注入为每个组件提供此类的相同实例.这个单一的 State 类包含组件需要的信息。我们之前在第 5 章中已经看到了这一点:这被称为单例模式。我们的 PizzaPlace 应用程序已经在使用 State 类,因此使用这种模式应该不会做太多工作。

首先打开 PizzaPlace 解决方案。 从 Pages 文件夹(在 PizzaPlace.Client 项目中)打开 Index 组件并查找私有 State 字段。 删除该字段(我已将其设为注释)并将其替换为 @inject 指令,如示例 9-29 所示。

清单 9-29 使用依赖注入获取状态单例实例

@page "/"
@inject IMenuService MenuService
@inject IOrderService orderService
@inject State State
...
@code {
    // private State State { get; } = new State();
    ...
}

现在在 Program.cs 中配置依赖注入,将 State 实例作为单例注入,如清单 9-30 所示。

清单 9-30 为状态单例配置依赖注入

using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using PizzaPlace.Client.Services;
using PizzaPlace.Shared;
using System;
using System.Net.Http;
using System.Threading.Tasks;
namespace PizzaPlace.Client
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("#app");
            builder.Services.AddScoped(sp => new HttpClient
                                       {
                                 BaseAddress = new Uri(builder.HostEnvironment
                                           .BaseAddress)
                                       });
            builder.Services.AddTransient<IMenuService,
            MenuService>();
            builder.Services.AddTransient<IOrderService,
            OrderService>();
            builder.Services.AddSingleton<State>();
            await builder.Build().RunAsync();
        }
    }
}

运行应用程序。 一切都应该仍然有效! 您所做的是使用单例模式将 State 单例注入到 Index 组件中。 让我们添加另一个使用相同 State 实例的组件。

您想使用新组件显示有关披萨的更多信息,但在此之前,您需要更新 State 类。 向 State 类添加一个名为 CurrentPizza 的新属性,如清单 9-31 所示。

清单 9-31 将 CurrentPizza 属性添加到状态类

using System.Linq;
namespace PizzaPlace.Shared
{
    public class State
    {
        public Menu Menu { get; } = new Menu();
        public ShoppingBasket Basket { get; } = new ShoppingBasket();
        public UI UI { get; set; } = new UI();
        public Pizza? CurrentPizza { get; set; }
        public decimal TotalPrice
            => Basket.Orders.Sum(id => Menu.GetPizza(id)!.Price);
    }
}

现在当有人点击菜单上的披萨时,它会显示披萨的信息。 通过将披萨名称包装在锚点中来更新 PizzaItem 组件,如示例 9-32 所示。 在示例 9-33 中的 PizzaItem 类中,我们添加了一个新的 ShowPizzaInformation 参数,如果它不为 null,我们将其包装在一个调用 ShowPizzaInformation 操作的锚点中。

清单 9-32 添加锚以显示披萨的信息

<div class="row">
    <div class="col">
        @if (ShowPizzaInformation is not null)
        {
            <a href=""
               @onclick="@(() => ShowPizzaInformation?.Invoke(Pizza))">
                @Pizza.Name
            </a>
        }
        else
        {
        	@Pizza.Name
        }
    </div>
    <div class="col text-right">
        @($"{Pizza.Price:0.00}")
    </div>
    <div class="col"></div>
    <div class="col">
        <img src="@SpicinessImage(Pizza.Spiciness)"
             alt="@Pizza.Spiciness" />
    </div>
    <div class="col">
        <button class="@ButtonClass"
                @onclick="@(() => Selected.InvokeAsync(Pizza))">
            @ButtonTitle
        </button>
    </div>
</div>

清单 9-33 添加 ShowPizzaInformation 参数

using Microsoft.AspNetCore.Components;
using PizzaPlace.Shared;
using System;
namespace PizzaPlace.Client.Pages
{
    public partial class PizzaItem
    {
        [Parameter]
        public Pizza Pizza { get; set; } = default!;
        [Parameter]
        public string ButtonTitle { get; set; } = default!;
        [Parameter]
        public string ButtonClass { get; set; } = default!;
        [Parameter]
        public EventCallback<Pizza> Selected { get; set; }
        [Parameter]
        public Action<Pizza>? ShowPizzaInformation { get; set; }
        private string SpicinessImage(Spiciness spiciness)
            => $"images/{spiciness.ToString().ToLower()}.png";
    }
}

更新 PizzaList 组件以设置 PizzaItem 组件的ShowPizzaInformation 参数如清单 9-34 和 9-35 所示。

当有人单击此链接时,它应该设置 State 实例的 CurrentPizza 属性。但是您无权访问 State 对象。 解决此问题的一种方法是在 PizzaItem 组件中注入 State 实例。 但是你不想让这个组件负担过重,所以你添加了一个 ShowPizzaInformation 回调委托来告诉包含 PizzaList 的组件你想显示更多关于披萨的信息。 单击比萨饼名称链接只会调用此回调,而不知道会发生什么。

您在此处应用了一种称为“哑元和智能组件(Dumb and Smart Components)”的模式。 哑组件是对应用程序的全局图一无所知的组件。 因为它对应用程序的其余部分一无所知,所以哑组件更容易重用。 智能组件知道应用程序的其他部分(例如使用哪个服务与数据库通信),并将使用哑组件来显示其信息。 在我们的示例中,PizzaList 和 PizzaItem 是哑组件,因为它们通过数据绑定接收所有数据,而 Index 组件是与服务对话的智能组件。

清单 9-34 向 PizzaList 组件添加 PizzaInformation 回调

<ItemList Items="@Items">
    <Loading>
        <div class="spinner-border text-danger" role="status">
            <span class="visually-hidden">Loading...</span>
        </div>
    </Loading>
    <Header>
        <h1>@Title</h1>
    </Header>
    <RowTemplate Context="pizza">
        <PizzaItem Pizza="@pizza"
                   ButtonClass="@ButtonClass"
                   ButtonTitle="@ButtonTitle"
                   Selected="@Selected"
                   ShowPizzaInformation="@ShowPizzaInformation"/>
    </RowTemplate>
</ItemList>

清单 9-35 添加 ShowPizzaInformation 回调参数

using Microsoft.AspNetCore.Components;
using PizzaPlace.Shared;
using System;
using System.Collections.Generic;
namespace PizzaPlace.Client.Pages
{
    public partial class PizzaList
    {
        [Parameter]
        public string Title { get; set; } = default!;
        [Parameter]
        public IEnumerable<Pizza> Items { get; set; } = default!;
        [Parameter]
        public string ButtonClass { get; set; } = default!;
        [Parameter]
        public string ButtonTitle { get; set; } = default!;
        [Parameter]
        public EventCallback<Pizza> Selected { get; set; }
        [Parameter]
        public Action<Pizza>? ShowPizzaInformation { get; set; }
    }
}

您向 PizzaList 组件添加了一个 ShowPizzaInformation 回调,并且您只需将其传递给 PizzaItem 组件。 Index 组件将设置此回调,PizzaList 会将其传递给 PizzaItem 组件。

更新 Index 组件以设置 State 实例的 CurrentPizza 并导航到 PizzaInfo 组件,如清单 9-36 所示。 Index 组件告诉 PizzaList 组件在有人单击 PizzaItem 组件中的信息链接时调用 ShowPizzaInformation 方法。 ShowPizzaInformation 方法然后设置 State 的 CurrentPizza 属性(我们在 PizzaInfo 组件中需要它)并使用 NavigationManager 的 NavigateTo 方法导航到 /PizzaInfo 路由。

如果将 NavigateTo 作为回调的一部分调用,Blazor 将返回到原始路由。 这就是我使用后台任务的原因,因此 Blazor 将在回调之后导航。

清单 9-36 Index 组件导航到 PizzaInfo 组件

@page "/"
@inject IMenuService MenuService
@inject IOrderService orderService
@inject State State
@inject NavigationManager NavigationManager
@if (State.Menu.Pizzas.Any())
{
    <!-- Menu -->
    <PizzaList Title="Our Selection of Pizzas"
    Items="@State.Menu.Pizzas"
    ButtonTitle="Order"
    ButtonClass="btn btn-success pl-4 pr-4"
    Selected="@AddToBasket"
    ShowPizzaInformation="@ShowPizzaInformation"/>
    <!-- End menu -->
<!-- Shopping Basket -->
...
@code {
    ...
    private void ShowPizzaInformation(Pizza selected)
    {
        this.State.CurrentPizza = selected;
        Task.Run(() => this.NavigationManager.NavigateTo("/pizzainfo"));
    }
}

右键单击 Pages 文件夹并添加一个名为 PizzaInfo 的新 razor 组件,如清单 9-37 和 9-38 所示(为了节省一些时间并保持简单,您可以复制大部分 PizzaItem 组件)。 PizzaInfo 组件显示有关州的 CurrentPizza 的信息。 这是有效的,因为您在这些组件之间共享相同的 State 实例。 Index 组件将在 State 中设置 CurrentPizza 属性,然后由 PizzaInfo 组件显示。 因为 State 的 CurrentPizza 属性可以为 null,所以我还在 PizzaInfo 组件中添加了一个辅助属性,该属性始终返回一个不可为 null 的 CurrentPizza(使用 null-forgiving 运算符)以避免编译器警告。

清单 9-37 添加 PizzaInfo 组件

@page "/PizzaInfo"
<h2>Pizza @CurrentPizza.Name Details</h2>
<div class="row">
    <div class="col">
        @CurrentPizza.Name
    </div>
    <div class="col">
        @CurrentPizza.Price
    </div>
    <div class="col">
        <img src="@SpicinessImage(CurrentPizza.Spiciness)"
             alt="@CurrentPizza.Spiciness" />
    </div>
</div>
<div class="row">
    <div class="col">
        <a class="btn btn-primary" href="/">Back to Menu</a>
    </div>
</div>

清单 9-38 PizzaInfo 类

using Microsoft.AspNetCore.Components;
using PizzaPlace.Shared;
namespace PizzaPlace.Client.Pages
{
    public partial class PizzaInfo
    {
        [Inject]
        public State State { get; set; } = default!;
        public Pizza CurrentPizza
            => State.CurrentPizza!;
        private string SpicinessImage(Spiciness spiciness)
            => $"images/{spiciness.ToString().ToLower()}.png";
    }
}

在标记的底部,您添加一个锚点(并使用引导样式使其看起来像一个按钮)以返回菜单。 这是一个用锚点改变路线的例子。 当然,在现实生活中的应用程序中,您会展示比萨的成分、精美的图片和营养信息。 我把这个作为练习留给你.

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