Blazor-Web-开发秘籍-全-
Blazor Web 开发秘籍(全)
原文:
zh.annas-archive.org/md5/38ffea350904c6ec73e784e15f5a46af译者:飞龙
前言
Blazor 是由微软引入的一个强大的 Web 框架,它允许开发者使用 C# 和 .NET 而不是依赖 JavaScript 来构建交互式和现代 Web 应用程序。Blazor 弥合了前端和后端开发的差距,提供了一个统一的编程模型,使得创建具有一致性和稳健的语言堆栈的全功能 Web 应用程序变得更加容易。使用 Blazor,您可以选择将应用程序静态托管在服务器上;通过 SignalR 实现服务器端交互;构建在浏览器中直接运行的客户端界面,使用 WebAssembly;或者根据您的需要混合所有方法。
虽然您可以轻松找到介绍 Blazor 基础和基本概念的资源,但这本书将带您深入了解构建企业级 Blazor 应用程序的高级技术和最佳实践。目标是为您提供构建稳健、安全、可扩展和可维护解决方案的工具。我们将关注实际、真实世界的示例,并指导您解决最常见的开发挑战所需的食谱,利用 Blazor 提供的强大功能。
这本烹饪书中的解决方案和技巧主要来源于两个主要来源:
-
我在多个行业、国家和领域开发 Blazor 应用程序多年的经验
-
Blazor 社区以及来自领先 Blazor 开发者和专家的见解
随着 Blazor 不断发展,它越来越被视为.NET 生态系统中的变革者,尤其是在全栈 C#开发趋势日益增长的情况下。随着越来越多的公司采用 Blazor 来满足他们的 Web 开发需求,对熟练的 Blazor 开发者的需求正在迅速增加。这本书不仅可以帮助您保持领先,还可以确保您能够自信地构建和维护符合现代 Web 标准的 Blazor 应用程序。
我希望您觉得这本书既富有洞察力又实用。您的反馈对我来说极其宝贵,我很乐意听到您的体验。如果您觉得内容有帮助,或者它对您的项目产生了影响,请考虑留下评论。此外,您可以在 LinkedIn 上与我联系,或通过社交媒体分享您的想法、建议,或者简单地讨论这本书如何影响您的工作。您的见解将有助于塑造未来的版本,并继续在开发者社区中展开对话。
本书面向的对象
本书旨在为熟练掌握 C#和.NET 并已在 Web 开发方面有所经验的开发人员和软件架构师编写。理想情况下,您必须对 Blazor 有基本的了解,因为我们只简要介绍基础知识,为高级主题做好准备。
将从本内容中受益的三个主要角色是:
-
希望利用他们的 C#和.NET 技能使用 Blazor 构建现代、交互式 Web 应用程序的 Web 开发者
-
寻求使用 Blazor 作为其技术栈的一部分来设计和实现可扩展、可维护的 Web 应用程序的软件架构师
-
所有对 Blazor 有基础了解并希望通过探索高级技术、最佳实践和实际场景来深化知识的 Blazor 爱好者
本书涵盖的内容
第一章 ,与 组件化架构 一起工作,介绍了 Blazor 的架构设计并解释了组件的概念。它还涵盖了组件参数化、可重用性和动态定制。
第二章 ,同步和异步数据绑定,是关于高级数据绑定技术的全面指南——应用交互性的基石。它探讨了双向绑定模式,并展示了异步绑定和输入节流的实际案例研究,这对于与外部源进行高效数据交换至关重要。
第三章 ,掌握事件处理,涵盖了 Blazor 中事件处理的所有知识。它指导如何使用事件委托、lambda 表达式、捕获事件参数以及利用 Blazor 原生的EventCallback。它展示了控制默认事件行为和传播以及构建完全自定义事件的技巧。同时,它还涵盖了在处理长时间运行的事件时,保持用户意识和界面响应性的 UX/UI 技巧。
第四章 ,使用网格增强数据展示,展示了如何使用网格增强数据表示,并将操作附加到行、列或单个单元格。它还包含了一个无限滚动实现的示例,以及使用分页和虚拟化有效处理大数据集的策略。
第五章 ,管理应用程序状态,探讨了状态管理的各个方面,从支持可书签状态的 REST-like 路由模式,到内存和可注入状态容器,再到在浏览器中持久化状态以防止在高延迟场景中丢失。它还涵盖了全局注入状态的实际实现以及组件动态响应状态变化的技巧。
第六章 ,构建交互式表单,专注于通过利用 Blazor 的内置组件来处理表单,以及通过实现抗伪造令牌和防止跨站请求伪造攻击来确保表单安全性的重要性,尤其是在面向公众的应用程序中。
第七章 ,验证用户输入表单,展示了表单验证策略。它涵盖了为简单和复杂对象类型实现内存和客户端验证,并指导通过集成异步服务器端验证来确保数据完整性。此外,它还包含 UI/UX 技巧,允许根据验证状态进行条件表单提交,并通过验证消息的托盘通知来增强用户体验。
第八章 ,保持应用程序安全,强调了处理身份验证和授权的技术,从 JWT 令牌支持开始,通过基于角色的授权,到实现细粒度控制的定制策略。它还涵盖了创建自定义身份验证提供者以管理独特身份场景以及构建特定租户的授权逻辑。
第九章 ,探索导航和路由,指导从支持外部组件的路由到使用类型约束保护路由,再到集中路由以增强应用程序的深度链接的导航和路由复杂性。它也是利用导航事件取消长时间运行请求以保留内存以及防止意外导航以保护未保存的用户输入的实用指南。
第十章 ,与 OpenAI 集成,探讨了将 Azure OpenAI 服务集成到 Blazor 应用程序中并利用 AI 模型的可能性。它展示了如何设置和管理 AI 服务,并指导创建智能支持工单生成器,利用智能粘贴和智能输入区域。它还包含将 AI 驱动的聊天机器人添加到应用程序的教程。
要充分利用这本书
您需要了解 Blazor 的基础知识以及网络开发的基本原理。在此基础上,您还需要一个支持.NET 和 Blazor 开发的 IDE 以及一个支持 WebAssembly 和现代 CSS 和 HTML 的浏览器(所有现代网络浏览器都支持)。您还需要在您的机器上安装.NET 9 SDK 和运行时。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| . NET 9 | Windows, Mac OS X, and Linux (Any) |
| Blazor | Visual Studio, Visual Studio Code, Rider (Any) |
| Azure |
如果您正在使用这本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Blazor-Web-Development-Cookbook 。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/ 获取。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们有意设计Provider以匹配Virtualize组件所需的ItemsProvider签名,确保兼容性和无缝集成。”
代码块设置如下:
<div class="ticket">
<div class="name">Adult</div>
<div class="price">10.00 $</div>
</div>
任何命令行输入或输出都应如下所示:
dotnet new blazor -o BlazorCookbook.App -int Auto --framework net9.0
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“启动 Visual Studio,并在欢迎窗口中选择创建新项目。”
小贴士或重要注意事项
看起来像这样。
章节
在本书中,您将找到一些频繁出现的标题(准备工作,如何操作...,它是如何工作的...,还有更多...,以及相关内容)。
为了清楚地说明如何完成食谱,请按以下方式使用这些章节:
准备工作
本节告诉您在食谱中可以期待什么,并描述如何设置任何必需的软件或初步设置。
如何操作…
本节包含遵循食谱所需的步骤。
它是如何工作的…
本节通常包含对上一节发生情况的详细解释。
还有更多…
本节包含有关食谱的附加信息,以便您对食谱有更深入的了解。
相关内容
本节提供了对其他有用信息的链接,以帮助您了解食谱。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并将邮件发送至 customercare@packtpub.com。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata ,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
您已经完成了《Blazor Web 开发食谱》的阅读,我们很乐意听听您的想法!如果您从亚马逊购买了本书,请点击此处直接进入本书的亚马逊评论页面并分享您的反馈或在该购买网站上留下评论。
您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在移动中阅读,但无法随身携带您的印刷书籍吗?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不仅限于此,您还可以获得独家折扣、时事通讯和每日免费内容的访问权限。
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接

download.packt.com/free-ebook/9781835460788
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件地址。
第一章:与基于组件的架构一起工作
欢迎使用Blazor Web Development Cookbook。本书将成为您在 Blazor 中构建动态和可扩展 Web 应用的全面指南。它提供了一系列实用解决方案和技术,用于应对 Web 开发中最常见的挑战。在每一章中,我们将深入研究应用开发的各个领域。本书包含详细的示例和可操作的建议。我们将探讨一系列主题——从优化组件,到管理应用程序状态,再到提高您应用程序的交互性和安全性。拥有这样的资源将使您能够提高开发速度,并专注于满足业务需求。
在本章中,您将了解 Blazor 中基于组件架构的核心原则。我们将从创建一个基本的组件开始,并逐步过渡到更复杂的方面,如参数化以提高可重用性和处理必需参数。我们还将探讨高级主题,例如使用可定制内容的组件构建、实现泛型组件以及通过DynamicComponent增加松耦合。
到本章结束时,您将能够实现和优化Blazor中的组件。理解基于组件的架构是构建更复杂、交互性和响应性 Web 应用的基础。它对于编写可扩展、可维护和可重用的代码也是必不可少的。
我们将在本章中介绍以下食谱:
-
初始化项目
-
创建您的第一个基本组件
-
在组件上声明参数
-
在运行时检测渲染模式
-
确保参数是必需的
-
使用CascadingParameter从父组件传递值
-
创建具有可定制内容的组件
-
使组件泛型
-
使用DynamicComponent解耦组件
技术要求
您不需要任何付费工具或附加组件来开始您的 Blazor 之旅。为了帮助您,我们决定限制本书中食谱的依赖项。您可以在需要时独立选择任何主题。
对于本章,您需要以下内容:
-
一个现代 IDE。我们将使用 Visual Studio 17.12.0,但任何其他支持.NET 9 开发的 IDE 也是可以的。
-
一个现代网络浏览器。
-
.NET 9 SDK。如果它不是您 IDE 安装的一部分,您可以从
dotnet.microsoft.com/en-us/download/dotnet/9.0获取。
您可以在 GitHub 上找到本章的所有代码示例:github.com/PacktPublishing/Blazor-Web-Development-Cookbook/tree/main/BlazorCookbook.App.Client/Chapters/Chapter01 。
初始化项目
使用 .NET 9,.NET 团队专注于提高 Blazor 应用程序的质量、稳定性和性能。幸运的是,在 .NET 8 和 .NET 9 之间没有破坏性更改,因此您可以安全地提高应用程序的目标框架。然而,在 .NET 8 中,Blazor 获得了全新的解决方案类型和渲染体验,因此我们将在此处回顾初始化新项目所需的步骤。
让我们初始化一个具有按组件渲染范围的 Blazor Web App。这是一个战略选择,因为它使我能够在探索不同领域的 Web 开发时突出显示各种渲染模式的注意事项。
准备工作
在本食谱中,我们将展示使用 Visual Studio 提供的图形用户界面初始化项目。因此,启动您的 IDE 并开始操作。
如果您在环境中使用 .NET CLI,我将在 更多... 部分提供等效的命令。
如何操作...
执行以下步骤以初始化 Blazor Web App 项目:
- 启动 Visual Studio 并从欢迎窗口中选择 创建新项目:

图 1.1:导航到项目创建面板
- 使用下一面板顶部的搜索栏缩小可用的项目类型列表,选择 Blazor Web App,并通过点击 下一步 确认您的选择:

图 1.2:从可用的项目类型中选择 Blazor Web App
- 在 配置您的新的项目 面板上,定义项目的名称和位置,并通过点击 下一步 确认这些详细信息:

图 1.3:设置新项目的名称和位置
-
在 附加信息 面板上,选择以下选项:
-
.NET 9.0(标准术语支持) 在 框架 下
-
自动(服务器和 WebAssembly) 在 交互 渲染模式 下
-
按页面/组件 在 交互位置 下
此外,勾选 包含示例页面 复选框。通过点击 创建 确认您的选择:
-

图 1.4:配置项目的框架和交互性
您的初始解决方案结构将如下所示:

图 1.5:初始项目结构
它是如何工作的...
在步骤 1中,我们启动了 Visual Studio,并从欢迎菜单中选择创建新项目选项。由于 Visual Studio 预装了许多项目模板,在步骤 2中,我们利用面板顶部的搜索栏,通过搜索blazor关键字,我们快速从结果列表中找到了并选择了Blazor Web App。然后,我们通过点击下一步按钮进入下一阶段。在步骤 3中,我们定义了项目名称和位置。对于这本书,我选择了BlazorCookbook.App和D:\packt。我们通过点击下一步按钮继续设置过程。
在步骤 4中,我们配置了项目。考虑到我们将专注于.NET 9 中的 Blazor,我们从框架下拉菜单中选择了.NET 9.0 (标准支持期)。然后,我们从交互式渲染模式下拉菜单中为我们的应用程序选择了一个渲染模式。使用无选项,我们有效地表明 Blazor 应使用服务器端渲染(SSR)模式。SSR 是渲染速度最快的模式,因为标记是在服务器上静态生成的,但提供的交互性有限或没有。当我们期望交互性时,我们必须从交互式模式中选择。在这里,服务器(在代码中表示为InteractiveServer)在服务器上渲染组件,并通过SignalR连接管理 UI 交互,允许动态内容更新,同时将组件逻辑保持在服务器端。或者,WebAssembly(InteractiveWebAssembly)直接在浏览器中使用WebAssembly渲染组件,便于实现无需服务器通信即可更新 UI 的完全交互式体验。最后,使用自动(服务器和 WebAssembly)选项(InteractiveAuto),我们让 Blazor 根据当前环境状态和网络条件选择最佳的渲染方法。我们想探索各种渲染模式的行为,因此自动(服务器和 WebAssembly)对我们来说是最好的选项。对于交互性位置,我们选择了每页/组件,这样我们就可以在组件级别定义渲染模式,而不是选择全局,这将在整个项目中设置渲染模式。我们还勾选了包含示例页面复选框以触发基本布局和 CSS 的生成。我们故意将身份验证类型设置为无,以避免不必要的复杂性,尽管我们计划在第八章中重新访问身份验证。我们通过点击创建按钮最终完成了项目创建过程。
在此阶段,您应该看到初始项目结构。如果您发现有两个项目,BlazorCookbook.App 和 BlazorCookbook.App.Client,这是正确的。在这里,BlazorCookbook.App 代表我们应用程序的服务器端组件,而 BlazorCookbook.App.Client 是客户端部分,它编译成 WebAssembly 代码。放置在 BlazorCookbook.App.Client 中的所有内容都将传输到用户的浏览器,因此您不应将其中的任何敏感或机密信息放置在那里。由于 BlazorCookbook.App 引用了 BlazorCookbook.App.Client,因此无论最初如何渲染,都不需要重复代码。
更多内容...
如果您的 IDE 没有类似于 Visual Studio 的 GUI,您可以使用跨平台的 .NET CLI。导航到您的工作目录,并运行以下命令以使用与 步骤 4 中概述的相同配置初始化一个 Blazor Web App 项目:
dotnet new blazor -o BlazorCookbook.App -int Auto --framework net9.0
创建第一个基本组件
组件是一个独立的用户界面(UI)块。在 Blazor 中,组件是一个具有标记的 .NET 类,以 Razor(.razor)文件的形式创建。在 Blazor 中,组件是任何应用程序的主要构建块,封装了标记、逻辑和样式。它们使代码可重用,并提高了代码的可维护性和可测试性。这种模块化方法极大地简化了开发过程。
对于我们的第一个组件,我们将创建一个 Ticket 组件,当用户导航到页面时,它会渲染一个运费名称和价格。
准备工作
在您开始创建第一个组件之前,在您的 Blazor 项目中创建一个 Recipe02 目录——这将成为您的工作目录。
如何操作...
按照以下步骤创建您的第一个组件:
-
导航到您刚刚创建的 Recipe02 目录。
-
使用 添加新项目 功能创建一个 Razor 组件:

图 1.6:添加新的 Razor 组件提示
-
在 Ticket 组件中,添加支持 HTML 标记:
<div class="ticket"> <div class="name">Adult</div> <div class="price">10.00 $</div> </div> -
添加一个新的 Offer 组件。使用 @page 指令使其可导航,并在其中渲染 Ticket 组件:
@page "/ch01r02" <Ticket />
它是如何工作的...
在步骤 1中,我们导航到Recipe02 – 我们的工作目录。在步骤 2中,我们利用内置的 Visual Studio 提示创建文件并创建了第一个组件:Ticket。在构建使用 Razor 标记语法组件的过程中,我们命名了组件文件为 Ticket.razor。在步骤 3中,我们在Ticket中添加了简单的标记 – 我们渲染了Adult和10.00 $,这些描述了一个给定的票。在步骤 4中,我们创建了我们的第一个页面 – Offer页面。在 Blazor 中,任何组件都可以通过@page指令的帮助成为一个页面,该指令需要一个以/开头的固定路径参数。@page "/ch01r02"指令允许导航到该组件。在Offer标记中,我们使用自闭合标签语法嵌入Ticket – 这是显式打开和关闭标签(
更多...
虽然 Blazor 中的组件化提供了许多好处,但了解何时以及如何使用它是至关重要的。组件是重用各种数据对象的表示标记的绝佳方式。它们显著提高了代码的可读性和可测试性。然而,需要谨慎 – 你可能会过度组件化。使用过多的组件会导致反射开销增加和管理渲染模式的不必要复杂性。当你重构网格或表单时,这尤其容易忽视。问问自己是否每个单元格都必须是一个组件,以及你是否需要封装该输入。始终权衡从更高的标记粒度中获得的好处与它带来的性能成本。
在组件上声明参数
在 Blazor 中,组件参数允许你将数据传递到组件中。这是使你的应用程序动态化的第一步。组件参数类似于传统编程中的方法参数。你可以利用相同的原始类型,以及引用和复杂类型。这导致代码灵活性、简化的 UI 结构和高度的标记重用性。
让我们创建一个参数化组件来表示票,这样我们就可以显示任何传入的运费和价格,而无需不必要的代码重复或标记不一致。
准备工作
在你深入组件参数化之前,请执行以下操作:
-
创建一个Recipe03目录 – 这将是你的工作目录
-
从创建你的第一个基本组件食谱复制Ticket组件,或者从本书 GitHub 存储库的Chapter01 / Recipe02目录中复制其实现
如何操作...
要在组件中声明参数,请从以下基础步骤开始:
-
在Ticket组件中,在@ code块中声明参数:
@code { [Parameter] public string Tariff { get; set; } [Parameter] public decimal Price { get; set; } [Parameter] public EventCallback OnAdded { get; set; } } -
修改Ticket标记,以便可以从参数渲染值:
<div class="ticket"> <div class="name">@Tariff</div> <div class="price"> @(Price.ToString("0.00 $")) </div> <div class="ticket-actions"> <button @onclick="@OnAdded"> Add to cart </button> </div> </div> -
创建一个Offer页面,并增强它以便以InteractiveWebAssembly模式渲染:
@page "/ch01r03" @rendermode InteractiveWebAssembly -
在Offer组件的功能指令下方,添加两个参数化的Ticket实例。实现一个Add()方法作为交互性的占位符:
<Ticket Tariff="Adult" Price="10.00m" OnAdded="@Add" /> <Ticket Tariff="Child" Price="5.00m" OnAdded="@Add" /> @code { private void Add() => Console.WriteLine("Added to cart!"); }
它是如何工作的...
在步骤 1中,我们通过一个@code块扩展了Ticket组件,Blazor 将其识别为 C#代码的容器。在这个@code块内部,我们使用了Parameter属性来标记可外部设置的属性,例如 C#中的方法参数。在我们的例子中,我们使用字符串作为票价,使用十进制表示价格。对于最后一个参数,我们使用了EventCallback类型。这是一个 Blazor 特定的struct,它携带一个可调用的操作,并具有额外的优势。当你更改 UI 状态时,你应该使用StateHasChanged()生命周期方法来通知 Blazor 发生了什么。按照设计,EventCallback会自动触发StateHasChanged(),所以你不会意外地省略它。在步骤 2中,我们根据使用@符号访问的参数值重新构建了Ticket标记。该符号向编译器发出信号,我们正在切换到动态 C#代码。如果你搭配圆括号,你还可以嵌入复杂的代码块,就像我们格式化价格时使用货币格式一样。
在步骤 3中,我们创建了一个可导航的Offer页面。这次,除了@page指令外,我们还声明了一个@rendermode指令,这允许我们控制组件的初始渲染方式。我们可以选择 Blazor Web App 支持的任何渲染模式,但由于我们预计页面会有一些交互性,我们选择了InteractiveWebAssembly模式。在步骤 4中,在Offer的@code块中,我们实现了一个Add()占位符方法,模拟将票添加到购物车。我们还实现了Offer标记,其中我们渲染了两个具有不同参数的Ticket实例。你传递参数的方式类似于标准 HTML 属性,如class或style。Blazor 会自动识别你正在调用的是一个组件,而不是一个 HTML 元素。最后,我们渲染了Adult和Child票,并将Add()方法附加到暴露的EventCallback参数上。
还有更多...
你必须意识到参数的数量可以直接影响渲染速度。这是因为渲染器使用反射来解析参数值。过度依赖反射会显著降低性能。你可以通过覆盖组件生命周期中的SetParametersAsync()方法来优化这个过程,尽管这是一个高级操作。相反,你应该专注于保持参数列表简洁或在必要时引入包装类。
在本章的早期部分,我们为组件声明了特定的渲染模式,当你的 Blazor 应用程序设置为在页面或组件级别期望交互性时。然而,当你全局启用交互性时,你仍然可以排除某些页面从交互路由中。你会发现这对于依赖于标准请求/响应周期或读取或写入 HTTP cookies 的页面很有用:
@attribute [ExcludeFromInteractiveRouting]
要在页面上强制执行静态服务器端渲染,你必须使用@attribute指令在页面的顶部添加ExcludeFromInteractiveRouting属性。在这种情况下,你不再添加@rendermode指令,因为它专门用于声明交互渲染模式。
在运行时检测渲染模式
了解你的组件在哪里以及如何渲染对于优化性能和定制用户体验至关重要。Blazor 允许你在运行时检测渲染位置、交互性和分配的渲染模式。你可以查询组件是否处于交互状态或只是预渲染。这些见解为调试、性能优化和构建能够动态适应其渲染上下文的组件开辟了新的可能性。
让我们隐藏Offer组件中的票据区域,以防止用户交互,例如将票据添加到购物车,直到组件准备就绪并具有交互性。
准备中
在你探索渲染模式检测之前,请执行以下操作:
-
创建一个Recipe04目录——这将是你的工作目录
-
从在组件上声明参数菜谱中复制Offer和Ticket组件,或者从本书 GitHub 存储库的Chapter01 / Recipe03目录中复制它们的实现
如何操作...
按照以下步骤操作:
-
导航到Offer组件并更新附加到@page指令的路径,以避免路由冲突:
@page "/ch01r04" @rendermode InteractiveWebAssembly -
在组件指令下方添加一些条件标记,以根据RendererInfo.IsInteractive属性的值指示组件正在准备中:
@if (!RendererInfo.IsInteractive) { <p>Getting ready...</p> return; } @* existing markup is obscured, but still down here *@
它是如何工作的...
在步骤 1中,我们导航到Offer组件并更新了分配给@page指令的路径。Blazor 不允许重复的路由,因此我们由于从在组件上声明参数菜谱中复制了Offer组件并带有路由而触发了冲突。
在步骤 2中,我们在组件指令下方引入了一个条件标记块。我们利用了ComponentBase类公开的RendererInfo属性,使我们能够跟踪组件的渲染状态。RendererInfo属性有两个属性:
-
RendererInfo.Name属性告诉我们组件当前正在哪里运行,并返回以下选项:
-
静态:这表示组件正在服务器上运行,没有任何交互性
-
服务器:这表示组件正在服务器上运行,并在完全加载后具有交互性
-
WebAssembly:这表示组件在客户端浏览器中运行,并在加载后变得交互式
-
WebView:这表示它是为.NET MAUI 和原生设备定制的
-
-
RendererInfo.IsInteractive属性显示组件是否处于交互状态(例如,在预渲染或静态 SSR 期间)
我们利用RendererInfo.IsInteractive属性来检测交互性是否就绪。如果没有就绪,我们显示正在准备...消息,通知用户他们应该等待。
确保参数是必需的
EditorRequired属性指示你的 IDE,向组件传递数据在功能上是关键的。此属性在编译时触发数据验证,创建一个快速反馈循环并提高代码质量。使用EditorRequired属性确保你或你的团队中的任何人都不会因为缺少参数而陷入错误。你可以通过跳过初始参数值验证来简化你的代码。使用EditorRequired属性可以使组件在整个应用程序中表现出稳健和可预测的行为。
让我们增强Ticket组件的参数,以便 Blazor 将它们视为必需的。你还将学习如何配置你的 IDE,以便你可以将任何缺少的必需参数标记为编译错误。
准备中
在设置必需参数之前,执行以下操作:
-
创建一个Recipe05目录 – 这将是你的工作目录
-
从上一个食谱复制Ticket和Offer组件,或者从本书 GitHub 仓库的Chapter01 / Recipe04目录中复制它们的实现
如何做到...
通过以下步骤确保组件中的参数是必需的:
-
导航到Ticket组件的@code块,并使用EditorRequired属性扩展参数的属性集合:
@code { [Parameter, EditorRequired] public string Tariff { get; set; } [Parameter, EditorRequired] public decimal Price { get; set; } [Parameter] public EventCallback OnAdded { get; set; } } -
现在,导航到包含你的组件的项目中的.csproj文件。
-
将RZ2012代码添加到WarningsAsErrors部分:
<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>net9.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <WarningsAsErrors>RZ2012</WarningsAsErrors> </PropertyGroup> <!-- ... --> </Project> -
在Offer标记中,通过从两个实例中移除OnAdded参数以及从第二个实例中移除Price参数来修改Ticket实例:
<Ticket Tariff="Adult" Price="10.00m" /> <Ticket Tariff="Child" /> -
编译你的应用程序,以便你可以看到你的 IDE 标记省略但必需的Price参数:

图 1.7:IDE 将缺少 Price 参数视为编译错误
它是如何工作的...
在步骤 1中,我们增强了票价和价格参数的票组件的EditorRequired属性。这会提示您的 IDE 在编译期间期望这些值,并将默认情况下缺失的值标记为警告。我建议您提高严重性。在步骤 2中,您导航到您项目的.csproj文件。在这里,如步骤 3中概述的,您要么找到了它,要么添加了WarningsAsErrors部分,并包含了RZ2012代码。在步骤 4中,我们稍微修改了报价标记。我们从两个票实例中移除了OnAdded参数,并从其中一个实例中移除了价格参数。现在,任何编译尝试都将以错误结束,类似于步骤 5中显示的错误。这使得实际上不可能错过所需的分配并遇到相关的渲染错误。请注意,由于我们没有将OnAdded参数标记为EditorRequired属性,编译器将将其视为可选的,并允许跳过它。
使用 CascadingParameter 从父组件传递值
在 Web 应用程序中,跨多个组件共享参数是一个常见场景。它可以提高性能,因为数据可以共享,而不是每个组件都从外部源请求。它还简化了代码,尤其是在父子场景中。在 Blazor 中,这就是级联参数概念发挥作用的地方。其对应物,级联值,允许您提供在组件树中级联的值。这对组合使子组件能够接收和使用这些共享数据或状态。这种方法解决了通过组件层次结构传递信息而不需要复杂的管道或紧密耦合通信的挑战。
让我们实现一个购物车服务,并以级联方式向下传递,这样我们就可以在由票组件表示的报价区域内拦截它。我们还将渲染购物车摘要——完全解耦于票行为。
准备工作
在我们开始探索如何传递级联值之前,请执行以下操作:
-
创建一个Recipe06目录——这将是你的工作目录
-
从确保参数是必需的食谱中复制票组件,或者从本书 GitHub 存储库的Chapter01 / Recipe05目录中复制其实现。
如何做...
按照以下步骤实现级联参数以实现值共享:
-
添加一个购物车类并声明支持内容和值属性。扩展购物车,以便可以通过要求具有主要构造函数的回退操作属性来通过通信状态变化,并实现基本的添加()方法,该方法触发此通知:
public class Cart(Action onStateHasChanged) { public List<string> Content { get; init; } = []; public decimal Value { get; private set; } public int Volume => Content.Count; public void Add(string tariff, decimal price) { Content.Add(tariff); Value += price; onStateHasChanged.Invoke(); } } -
创建一个SellingTickets组件,以便我们可以出售我们的票:

图 1.8:添加新的 SellingTickets 组件
-
使用 @rendermode 属性声明 SellingTickets 在 InteractiveWebAssembly 模式下运行,并使用 @page 指令启用路由:
@page "/ch01r06" @rendermode InteractiveWebAssembly -
在 SellingTickets 的 @code 块中,声明 Cart 对象并在 OnInitialized() 生命周期方法中初始化它:
@code { protected Cart Cart; protected override void OnInitialized() { Cart = new(() => InvokeAsync(StateHasChanged)); } } -
在 SellingTickets 标记中,添加带有 Cart 实例作为其值的 CascadingValue 包装器。在购物车操作范围内声明两个可售票,利用 Ticket 组件:
<CascadingValue Value="Cart"> <Ticket Tariff="Adult" Price="10.00m" /> <Ticket Tariff="Child" Price="5.00m" /> </CascadingValue> -
在 SellingTickets 标记的 Cart 区域下方,附加额外的标记以显示 Cart 摘要:
<div class="cart-summary"> <div class="cart-content"> Items: @Cart.Volume </div> <div class="cart-value">Price: @Cart.Value</div> </div> -
导航到 Ticket 组件。在 @code 块中,声明 CascadingParameter 以便拦截 Cart 实例,并将 OnAdded 参数替换为 Add() 方法:
@code { [CascadingParameter] public Cart Cart { get; set; } public void Add() => Cart.Add(Tariff, Price); } -
在 Ticket 标记中,替换 @onclick 按钮动作,以便执行新的 Add() 方法:
<div class="ticket-actions"> <button @onclick="@Add">Add to cart</button> </div>
它是如何工作的...
在 步骤 1 中,我们实现了 Cart 类。我们声明了一个 Value 属性来存储当前购物车价值,一个 Content 集合来存储添加的票价格。我们还实现了一个无参数的 Volume 方法来计算当前购物车中的票数量。然后,我们实现了一个 Add() 方法,除了正常的添加到购物车的逻辑外,还负责通过调用通过 primary constructor 模式传递的 onStateHasChanged 委托来将这些更改通知外部对象。这样,我们确保 Cart 初始化需要我们提供一个在状态更改时执行的操作。
在 步骤 2 中,我们创建了 SellingTickets 组件。在 步骤 3 中,我们将其声明为以 InteractiveWebAssembly 模式渲染,并利用 @page 指令启用路由。在 步骤 4 中,在 SellingTickets 的 @code 块中,我们声明了一个 Cart 实例。我们将 Cart 作为重写的 OnInitialized() 生命周期方法的一部分进行初始化,并且作为负责应用状态变化的可调用 Action 委托,我们传递了 StateHasChanged() 生命周期方法。这样一来,任何对 Cart 对象的更改都会促使 Blazor 在 SellingTicket 组件的级别重新计算 DOM 变更。为了避免任何线程或竞态条件问题,我们在 InvokeAsync() 组件基方法中包装了 StateHasChanged() 方法。在 步骤 5 中,我们实现了 SellingTickets 标记。我们使用了一个 CascadingValue 组件,并将其值设置为 Cart。我们还通过添加两个代表可供销售的票的 Ticket 实例来声明 CascadingValue 内容。在 步骤 6 中,我们通过添加一个包含购物车摘要的节来进一步扩展 SellingTickets 标记,显示其当前大小和价值。
在 步骤 7 中,我们导航到 Ticket 组件的 @code 块并声明了 CascadingParameter。Blazor 将拦截此参数的值,因为它从父组件传递下来。值得注意的是,我们在这里没有使用 EditorRequired - 因为 Blazor 在编译时解析级联值,所以它不会对编译产生影响。由于 Cart 在 Ticket 组件的作用域内可用,我们用直接调用 Cart.Add() 的 Add() 方法替换了现有的 OnAdded 参数。在 步骤 8 中,我们通过将现有按钮上的过时 @onclick 赋值替换为对新实现的 Add() 方法的引用来更新了 Ticket 标记。
更多内容...
那么,为什么 Cart 的实现需要一个 Action 委托才能工作?在这里,StateHasChanged() 是一个组件生命周期方法,因此它触发该组件及其嵌套子组件的 DOM 重新渲染。由于添加到购物车是在 Ticket 组件级别发生的,并调用 StateHasChanged(),因此它不会影响父 SellingTickets 组件,并且 Cart 摘要部分保持不变!拥有 Action 委托允许 Cart 对象持久化对原始组件的引用,从而在任何组件树级别触发 DOM 更新。
创建具有可定制内容的组件
在 Blazor 应用程序中创建具有可定制内容的组件是构建灵活且可重用 UI 元素的一个新层次。这种方法允许你设计功能组件,这些组件可以适应各种内容需求和数据类型。我们将利用 RenderFragment 功能来实现这一点。RenderFragment 功能表示一段 UI 内容。它允许组件接受任意 HTML 标记作为参数。这就是你如何实现更高灵活性的方法。你可以使用不同的内容重用单个组件结构,从而增强代码库的模块化和可重用性。
让我们创建一个具有可定制显示票务详情的 Ticket 组件,同时保留一个固定的按钮,以便你可以将票务添加到购物车中。
准备工作
在开始实现具有可定制内容的组件之前,请执行以下操作:
-
创建一个 Recipe07 目录 - 这将是你的工作目录
-
将 Chapter01 / Data 目录复制到工作目录旁边,该目录包含此食谱所需的 Samples 和 TicketViewModel 对象
如何操作...
按照以下步骤构建具有可定制内容的组件:
-
创建一个新的 Ticket 组件。我们将使用它来显示单个票务详情。
-
在 Ticket 的 @code 块中,添加 Id 和 ChildContent 参数以及一个简单的 Add() 占位符方法,该方法仅显示添加到购物车中的票务 ID 的控制台消息:
@code { [Parameter, EditorRequired] public Guid Id { get; set; } [Parameter, EditorRequired] public RenderFragment ChildContent { get; set; } public void Add() => Console.WriteLine($"Ticket {Id} added!"); } -
作为 Ticket 标记,渲染 ChildContent 值和一个按钮以触发 Add() 方法:
<div class="ticket"> <div class="ticket-info">@ChildContent</div> <div class="ticket-actions"> <button @onclick="@Add">Add to cart</button> </div> </div> -
创建一个可路由的Offer组件,以InteractiveWebAssembly模式渲染。添加一个@using指令,以便可以引用Samples对象:
@page "/ch01r07" @using BlazorCookbook.App.Client.Chapters.Chapter01.Data @rendermode InteractiveWebAssembly -
作为Offer组件的标记,在利用Ticket组件的同时,渲染一个Samples.Adult成人票运费和价格,以及一个仅包含运费名称的Samples.FreeAdmission免费入场票,因为这样做是免费的:
<Ticket Id="@Samples.Adult.Id"> @Samples.Adult.Tariff (@Samples.Adult.Price) </Ticket> <Ticket Id="@Samples.FreeAdmission.Id"> <div class="free-ticket"> @Samples.FreeAdmission.Tariff </div> </Ticket>
它是如何工作的...
在步骤 1中,我们创建了一个新的Ticket组件,并在步骤 2中实现了其@code块。然后,我们声明了一组必需的参数——Id用于将票添加到购物车,以及ChildContent,它是RenderFragment类型,用于保存Ticket实例的自定义标记。我们利用了EditorRequired属性,并使这两个参数成为必需的。在步骤 3中,我们实现了Ticket标记。我们将ChildContent值嵌入其中,通过将其放置在与其他参数相同的位置来渲染票详情。我们还添加了一个按钮,允许用户通过利用Add()方法将票添加到购物车。
在步骤 4中,我们创建了一个Offer组件。我们使用了@page指令使其可路由,并声明它以InteractiveWebAssembly模式渲染。在此基础上,我们添加了一个带有Samples对象命名空间的@using指令,以便我们可以在Offer组件中引用它(命名空间可能根据解决方案的结构和名称而变化)。在步骤 5中,我们实现了Offer标记,并看到了RenderFragment对象的实际应用。对于带有价格标签的成人票,我们渲染了其运费和价格。对于免费入场票,我们选择只渲染运费名称。Blazor 将在Ticket组件内将自定义标记注入到ChildContent参数中,同时保留并重用交互式按钮的实现,无论定制内容如何。
更多内容...
您可以使用RenderFragment对象来封装组件的公共部分。您的代码的测试和可维护性将大幅提升。利用它们的另一个原因是静态的RenderFragment实例对性能有积极影响。
您可能已经注意到,当RenderFragment参数命名为ChildContent时,编译器会自动识别并分配其值。您仍然可以选择显式声明
然而,你可能会遇到需要在一个组件内具有多个可自定义部分的场景。幸运的是,Blazor 允许你拥有多个RenderFragment参数。为了实现这一点,你必须在组件中显式声明两个RenderFragment值,使用标记元素语法。这种方法使你的 UI 具有更高的模块化和适应性。例如,你可以有详情和操作内容来结构化你的组件,具有多个可自定义区域。你可以在以下代码块中看到这一点。
这里是票据组件,它允许我们自定义详情和操作区域:
<div class="ticket">
<div class="ticket-info">@Details</div>
<div class="ticket-actions">@Actions</div>
</div>
@code {
[Parameter, EditorRequired]
public RenderFragment Details { get; set; }
[Parameter, EditorRequired]
public RenderFragment Actions { get; set; }
}
这是票据组件的实际应用,带有自定义的详情和操作区域:
<Ticket>
<Details>
@Samples.Adult.Tariff (@Samples.Adult.Price)
</Details>
<Actions>
<button @onclick="@(() => Add(Samples.Adult.Id))">
Add to cart
</button>
</Actions>
</Ticket>
使组件通用
C#中的通用类是一个使用占位符类型定义的类,允许它使用任何数据类型进行操作。这种灵活性使得可以创建一个可以适应多种数据类型的单个类,从而提高代码的可重用性和效率。Blazor 应用程序中的通用组件是一个类似的概念。这些组件在不同上下文和数据类型之间具有高度的可重用性。它们抽象出特定细节,允许在各种数据或功能上具有最小更改的高适应性。这种方法显著减少了代码重复。有了这种灵活性,你可以实现更高的交付速度。你会在最常见的情况下看到通用组件的亮点,尤其是在重复数据显示,尤其是网格中。
让我们创建一个通用的Grid组件,它可以通过提供的行模板渲染任何类型的对象。
准备工作
在开始实现通用网格之前,执行以下操作:
-
创建一个Recipe08目录——这将是你的工作目录
-
复制Chapter01/ 数据目录,该目录包含此食谱所需的样本和TicketViewModel对象,并将其放在工作目录旁边
如何做到这一点...
按照以下步骤构建和使用你的通用组件:
-
创建一个Grid组件。在文件顶部,使用@ typeparam属性将其声明为通用:
@typeparam T -
在Grid组件的@code块中,声明数据源和表格区域自定义的参数。源和行模板必须是通用的:
@code { [Parameter, EditorRequired] public IList<T> Data { get; set; } [Parameter, EditorRequired] public RenderFragment Header { get; set; } [Parameter, EditorRequired] public RenderFragment<T> Row { get; set; } } -
对于Grid标记,添加一个标准的 HTML 表格,将标题内容渲染在表格标题的位置。对于表格主体,遍历数据并渲染每个元素的行模板:
<table class="grid"> <thead> @Header </thead> <tbody> @foreach (var item in Data) @Row(item) </tbody> </table> -
创建一个可路由的报价组件,以交互式 WebAssembly模式渲染,并使用样本组件,以便以后可以引用样本:
@page "/ch01r08" @using BlazorCookbook.App.Client.Chapters.Chapter01.Data @rendermode InteractiveWebAssembly -
在Offer的@code块中,实现一个Add()占位符方法,将简单的操作确认写入控制台:
public void Add(TicketViewModel ticket) => Console.WriteLine($"Ticket {ticket.Id} added!"); -
在报价组件的标记中,使用Grid组件并将Samples.Tickets作为Grid的数据源:
<Grid Data="@Samples.Tickets"> @* you will add areas here *@ </Grid> -
在Offer标记中的Grid实例内实现所需的Header区域:
<Header> <tr> <td>Ticket code</td> <td>Tariff</td> <td>Price</td> <td></td> </tr> </Header> -
在Grid实例内,Offer标记中,实现所需的Row模板,以便可以渲染TicketViewModel类型的元素:
<Row> <tr> <td>@context.Id</td> <td>@context.Tariff</td> <td>@context.Price</td> <td @onclick="() => Add(context)"> Add to Cart </td> </tr> </Row>
它是如何工作的...
我们通过实现创建通用组件的基础开始这个食谱。在第 1 步中,我们创建了一个Grid组件,并在顶部添加了@typeparam属性。我们还指定了参数类型占位符的名称——就像在后端开发中做的那样。我们选择称之为T。Blazor 识别了@typeparam,现在允许我们在组件内部操作T。IDE 还将应用所有泛型模块所需的验证。在第 2 步中,我们通过添加一个将持有要渲染的元素的Data参数和两个RenderFragment参数来实现Grid组件的@code块,这些参数使Grid可定制。你可以在创建具有可定制内容的组件部分了解更多关于RenderFragment的信息。值得注意的是,Data集合并不是唯一的泛型对象。包含行模板的Row参数也是泛型的,这意味着它将期望一个类型为T的数据对象进行初始化。在第 3 步中,我们实现了Grid标记。我们在标签内渲染了Header值,这是表格标题通常出现的地方;对于表格主体,我们使用foreach循环遍历Data集合,并为每个元素渲染Row模板。
在第 4 步中,我们创建了一个可路由的Offer组件来测试我们的网格。正如我们所期望的交互性,我们声明Offer以InteractiveWebAssembly模式渲染。我们还利用了Samples对象,因此我们使用@using指令公开了所需的程序集。在第 5 步中,我们在Offer组件的@code块内实现了一个Add()占位符方法来测试Grid组件的交互性。在第 6 步中,我们开始实现Offer标记。我们嵌入Grid组件,并将Samples.Tickets数组作为Data参数的值传递。在第 7 步中,我们声明了Header的内容,在我们的案例中,它是一组表示TicketViewModel属性的列以及一个放置操作按钮的额外列。真正的渲染魔法发生在第 8 步。由于Row模板期望一个TicketViewModel对象,我们可以在标记中使用@context指令访问TicketViewModel属性,并将它们放置在与Header声明匹配的表格列中。
还有更多...
通用组件的力量在于其对数据类型的不可知性。它只知道如何构建模板,以及在哪里放置可定制的内容。定义用于呈现数据属性的标记取决于你。
您可能会发现自己需要嵌套多个泛型组件。为此,您必须定义所有必需的 RenderFragment 参数。然而,这里的挑战将是区分每个泛型上下文。在这种情况下,您必须使用 Context 参数为每个泛型组件的上下文分配自定义名称。此参数会自动继承,简化流程并提高代码的可读性。
尽管我们的示例不需要嵌套,我们仍然可以利用 Context 命名功能来提高代码的可读性:
<Grid Data="@Data.Tickets" Context="ticket">
...
<Row>
<tr>
<td>@ticket.Id</td>
... *
</tr>
</Row>
</Grid>
请记住,您的代码越直观,导航和更新就越容易,尤其是在团队环境中工作或一段时间后返回代码时。
使用 DynamicComponent 解耦组件
解耦 是一种设计原则,它增强了您应用程序的灵活性和可维护性。这涉及到减少代码各部分之间的直接依赖。Blazor 提供了一种优雅的动态渲染组件的解决方案。在本配方中,我们将探讨 DynamicComponent 的战略使用。它允许您根据某些条件或参数在运行时动态渲染组件。您不需要在编译时显式指定组件类型。请注意 - 大多数编译验证器在这里不适用。
让我们根据该票的可用性,实现用户将票添加到购物车时完全解耦和动态提示成功和失败通知。
准备工作
在您开始实现 DynamicComponent 之前,请执行以下操作:
-
创建一个 Recipe09 目录 - 这将是您的工作目录
-
从 制作组件泛型 配方复制 Offer 和 Grid 组件,或从本书 GitHub 存储库的 Chapter01 / Recipe08 目录复制它们的实现
-
将 Chapter01 / Data 目录复制到工作目录旁边,该目录包含本配方所需的 Samples 和 TicketViewModel 对象
如何做到...
按照以下步骤学习如何使用 DynamicComponent 创建更模块化和独立的组件:
-
在您的项目中添加一个新的 Alerts 目录。
-
在 Alerts 目录中创建 AddedToCart 和 SoldOut 组件:

图 1.9:包含新添加的警报组件和示例对象的工程结构
-
导航到 AddedToCart 组件并添加一个成功警报标记:
<div class="alert alert-success" role="alert"> Added to cart successfully. </div> -
导航到 SoldOut 组件。声明一个 Tariff 参数,并使用 Tariff 值添加一个危险警报标记:
<div class="alert alert-danger" role="alert"> Ticket @Tariff is sold out! </div> @code { [Parameter] public string Tariff { get; set; } } -
导航到 Offer 组件,在 @code 块中声明额外的 AlertType 和 AlertParams 变量:
protected Type AlertType; protected Dictionary<string, object> AlertParams; -
在 Offer 的 @code 块中,替换 Add() 方法的实现以验证票务可用性并显示指定的通知:
public void Add(TicketViewModel ticket) { AlertType = ticket.AvailableSeats == 0 ? typeof(Alerts.SoldOut) : typeof(Alerts.AddedToCart); AlertParams = new(); if (ticket.AvailableSeats == 0) { AlertParams.Add( nameof(ticket.Tariff), ticket.Tariff ); } } -
在 Offer 标记中,在现有的 Grid 实例下方,添加对 DynamicComponent 的条件渲染,同时利用已解析的 AlertType 和 AlertParams 变量的值:
@if (AlertType is null) return; <DynamicComponent Type="@AlertType" Parameters="@AlertParams" />
它是如何工作的...
在 步骤 1 中,我们添加了一个 Alerts 目录,我们可以将不同的警报组件放置在那里。在 步骤 2 中,我们创建了 AddedToCart 和 SoldOut 组件,分别代表将票务添加到购物车时的成功和失败通知。在 步骤 3 中,我们专注于实现 AddedToCart 组件,它渲染一个带有 Added to cart successfully 消息的 alert-success 类。在 步骤 4 中,我们实现了 SoldOut 组件,它渲染一个 alert-danger 类,并渲染已售罄的票务价格。
在 步骤 5 中,我们添加了两个对 DynamicComponent 至关重要的变量。第一个是 AlertType,它是 Type 类型,它决定了要渲染的组件类型。第二个是 AlertParams,这是一个字典,允许我们动态传递参数值到加载的组件。在 步骤 6 中,我们解析了请求的票务状态。我们检查了座位可用性,并决定是否使用 SoldOut 或 AddedToCart 组件。当座位不可用时,我们条件性地将 Tariff 参数添加到我们的动态参数集合中。最后,在 步骤 7 中,我们在 Offer 标记中嵌入了 DynamicComponent 组件。如果 AlertType 值未设置,我们跳过渲染。否则,我们附加动态解析的标记。
注意,我们使用了内置的 typeof() 和 nameof() 函数来声明当前通知的类型和参数。如果你想或需要进一步解耦,你可以完全从 string 变量中初始化它们。当你在一个如 micro-frontends 这样的架构中工作时,这尤其强大。
第二章:同步和异步数据绑定
在本章中,我们将探讨 Blazor 中数据绑定的各个方面。绑定是现代网络开发的一个基石。我们将从绑定值与 DOM 元素的基本原理开始。然后,我们将进一步探讨绑定特定的 DOM 事件,确保你的 Blazor 应用程序高度交互和响应。大多数商业应用程序都需要与外部数据提供者集成。因此,你必须执行异步操作。我们将探讨如何将它们与绑定配对。
此外,我们将介绍自定义获取器和设置器,以提供更大的数据处理灵活性。我们还将介绍bind-Value绑定模式,它应该简化你大多数的绑定场景。最后,我们将实现一个与外部数据提供者无缝绑定的商业场景。这在实现搜索模块或数据持久化机制时非常有用。
然而,我们将完全跳过构建表单或使用可以简化绑定的 Blazor 原生组件——我们将在第六章中介绍,该章节涵盖构建 交互式表单。
到本章结束时,你将深入理解 Blazor 中的数据绑定。这将使你能够构建更动态和用户友好的网络应用程序。你将要实现的全部交互性都将归结为绑定。你将手头有食谱来处理那些同步和异步场景。
下面是我们将在本章中介绍的食谱列表:
-
使用标记元素绑定值
-
绑定到特定的 DOM 事件
-
绑定后执行异步操作
-
自定义获取和设置绑定逻辑
-
使用bind-Value模式简化绑定
-
使用外部数据提供者进行绑定
技术要求
由于我们将探讨 Blazor 和 Web 开发的基本概念,你不需要任何付费插件或额外工具。然而,你需要以下内容:
-
一个现代 IDE(你选择的)
-
一个现代网络浏览器(支持WebAssembly)
-
一个 Blazor 项目
你接下来将看到的全部代码示例(和数据样本)可以在以下 GitHub 仓库中找到:
使用标记元素绑定值
在本食谱中,我们介绍了 Blazor 应用程序中数据绑定的基础概念。这个特性连接了用户界面与应用程序的数据或状态之间的差距。对这个概念有深入的理解将使你能够提升你项目的交互性。我们首先从掌握基础知识开始。
让我们绑定一个简单的文本字段到一个后端变量,以查看数据从用户界面流向后端。
准备工作
在你开始绑定之前,创建一个 Recipe01 目录 – 这将是你的工作目录。
如何做到这一点…
按照以下步骤绑定 C# 值与标记元素:
-
添加一个可路由的 IntroduceYourself 组件,以 InteractiveWebAssembly 模式渲染:
@page "/ch02r01" @rendermode InteractiveWebAssembly -
在 IntroduceYourself 的 @code 块中,初始化一个 User 变量以存储用户输入的值:
@code { protected string User = string.Empty; } -
在 IntroduceYourself 标记中,添加一个号召性用语和一个带有 @bind 属性的输入字段,该属性分配给 User 变量:
<h3>What's your name?</h3> <input class="form-control w-50" @bind="@User" /> -
作为 IntroduceYourself 标记的一部分,构建一些逻辑以在用户填写输入字段后动态显示问候语:
@if (string.IsNullOrWhiteSpace(User)) return; <hr /> <h1>Hello @User!</h1>
它是如何工作的…
我们以 Blazor 开发中的一个常规步骤开始这个食谱 – 创建一个新的组件。在 步骤 1 中,我们执行 Blazor 开发中的一个常规步骤 – 创建一个新的 IntroduceYourself 组件并声明其渲染模式。在我们的案例中,我们选择 InteractiveWebAssembly。在 步骤 2 中,我们跳转到 IntroduceYourself 的 @code 块并初始化一个 User 后备字段 – 这对于我们的数据绑定操作至关重要。
实际的绑定魔法从 步骤 3 开始。我们转向 IntroduceYourself 标记,并在号召性用语旁边嵌入一个 input 元素,并首次利用 Blazor 的原生 @bind 属性。@bind 属性启用双向数据绑定 – 不仅将输入值分配给 User 后备字段,还确保 UI 更新以反映这一变化。在 步骤 4 中,我们添加 IntroduceYourself 标记的另一个部分 – 当用户填写输入时,我们显示当前的 User 值。这将帮助我们可视化绑定的行为。
由 @bind 属性促进的双向绑定简化了用户交互的实现。对于大多数简单的绑定情况,这是一个首选方法,其中不需要执行额外的逻辑。值得注意的是,默认情况下,此绑定发生在用户退出输入框时,但这完全可定制。我们将在下一个食谱中探索和阐明这种行为及其背后的机制。
绑定到特定的 DOM 事件
现在,我们将深入探讨在 Blazor 应用程序中针对用户交互的精确和高效处理。虽然一般数据绑定至关重要,但将特定操作绑定到特定的 DOM 事件 可以将应用程序的交互性提升到下一个层次。这种方法允许更受控和响应的用户体验。你可以直接将事件(如点击或按键)链接到相应的 C# 方法或操作。你将学习如何识别和绑定到这些事件以及哪些事件是可以绑定的。
让我们实现一个简单的文本字段,但触发绑定是在用户输入时,而不是他们退出字段时,这是默认行为。
准备工作
在探索特定事件的绑定之前,请执行以下操作:
-
创建一个 Recipe02 目录 – 这将是你的工作目录
-
从绑定值与标记元素菜谱复制您的IntroduceYourself组件,或者从 GitHub 仓库的Chapter02 / Recipe01目录复制其实现。
如何实现...
要绑定到特定的 DOM 事件,请遵循以下说明:
-
导航到IntroduceYourself组件中的@code块。
-
除了现有的用户变量外,初始化一个新的问候变量。我们将使用它来保存对用户的问候:
protected string User = string.Empty, Greeting = string.Empty; -
实现了IsGreetingReady和IsUserFilled方法,这些方法允许您评估用户和问候变量的状态:
private bool IsGreetingReady => !string.IsNullOrWhiteSpace(Greeting); private bool IsUserFilled => !string.IsNullOrWhiteSpace(User); -
添加一个SayHello()方法来准备问候信息:
private void SayHello() => Greeting = $"Hello {User}"; -
在IntroduceYourself标记中,通过添加@ bind:event="oninput"来扩展输入字段绑定:
<input class="form-control w-50" @bind="@User" @bind:event="oninput" /> -
通过将SayHello()行为附加到@ onfocusout事件来进一步扩展输入字段绑定:
<input class="form-control w-50" @bind="@User" @bind:event="oninput" @onfocusout="@SayHello" /> -
从之前的实现中移除对用户值的任何现有检查,并基于状态检查方法实现条件问候渲染:
@if (IsGreetingReady) { <h1>@Greeting</h1> return; } @if (IsUserFilled) { <h1>Introducing @User...</h1> }
它是如何工作的...
在这个菜谱中,我们正在增强IntroduceYourself组件,使其更具交互性和吸引力。我们的目标是使组件能够问候用户并显示用户名输入的进度。
在第 1 步中,我们导航到IntroduceYourself组件,在第 2 步中,我们直接进入@code块并初始化一个额外的问候变量来存储生成的问候信息。这为我们的动态用户交互奠定了基础。在第 3 步中,我们引入了IsUserFilled和IsGreetingReady这两个无参方法,它们检查用户和问候变量的状态,并允许我们简化标记代码并使我们的逻辑更易于阅读。继续到第 4 步,我们添加了另一个方法——SayHello()。当用户完成他们的名字输入时,我们将调用SayHello()来生成问候并将其分配给问候变量。这为用户体验增添了个性化元素。
第 5 步是我们控制绑定逻辑的地方。您可以在适用于脚本框架的任何事件上触发绑定,例如oncopy、onpaste或onblur。我们选择了oninput事件作为此示例。我们使用@bind:event="oninput"覆盖默认绑定事件,并启用用户值在用户输入时实时更新。注意@bind:event属性的语法是区分大小写的,因为 Razor 是一个区分大小写的语言框架。在第 6 步中,我们将交互性提升到一个新的层次。我们使用原生的 Blazor 引用到@onfocusout事件,当用户从输入字段导航离开时,我们调用SayHello()方法,从而生成问候信息。
最后,在 步骤 7 中,我们实现了一些渲染逻辑,以确保只有当用户完成输入时,问候语才会显示。在此之前,将生成 Introducing... 消息。这样,我们提供了一个清晰的 UI 交互和进度指示器。
还有更多...
重要的是要知道,Blazor 允许您拦截所有事件参数。您可以通过添加一个与触发事件对应的事件参数类型匹配的参数来实现这一点。
例如,让我们考虑 DragEventArgs。当用户将元素拖入或拖动到您的应用程序中时,您可以拦截光标位置和任何活动的键盘组合。
拦截拖拽事件的简单方法如下:
public void OnDragging(DragEventArgs args) { /*...*/ }
类似地,使用 InputFileChangeEventArgs,您可以访问用户已上传的文件的详细信息。它还公开了 IBrowserFile 对象,您可以使用它将内容流式传输到您的服务器。
下面是如何拦截用户上传文件细节的方法:
public void OnFileInput(InputFileChangeEventArgs args)
{
var droppedFile = args.File;
// ...
}
拦截特定事件及其细节的能力为在 Blazor 应用程序中实现更高级和细致的事件处理提供了可能性。
绑定后执行异步操作
异步操作在现代网络应用中至关重要,尤其是在处理如从 API 或数据库获取数据等数据密集型操作时。同时,当您的应用程序正在执行长时间运行的任务时,保持其响应性也非常关键。在本食谱中,我将指导您在处理异步任务时进行数据绑定。您将学习如何集成异步操作,以实现非阻塞的 UI 更新和更流畅的用户体验。
让我们在一个简单的输入字段上启用 自动完成 功能,并在用户输入他们的名字时生成一个建议列表。
准备工作
在深入到自动完成实现之前,请执行以下操作:
-
创建一个 Recipe03 目录——这将作为您的工作目录。
-
从 绑定到特定的 DOM 事件 食谱复制您的 IntroduceYourself 组件,或者从 GitHub 仓库的 Chapter02/ Recipe02 目录复制其实现。
-
在 Recipe03 旁边,从 GitHub 仓库复制 Chapter02/ Data 目录,其中包含本食谱所需的 SuggestionsApi 类。
如何实现...
按照以下步骤在数据绑定后触发异步操作:
-
打开您的应用程序的 Program 文件,并注册 SuggestionsApi 服务以启用与 API 的通信:
builder.Services.AddTransient<SuggestionsApi>(); -
导航到 IntroduceYourself 组件的 @code 块,清理该部分,以确保只有 User 变量仍然存在:
@code { protected string User = string.Empty; } -
在 @code 块内部,注入 SuggestionsApi 服务并初始化一个 Suggestions 集合,该集合将保存自动完成的结果:
[Inject] private SuggestionsApi Api { get; init; } protected IList<string> Suggestions = []; -
仍然在@code块中,实现一个新的异步AutocompleteAsync()方法,负责调用 API 并根据用户输入更新建议集合:
private async Task AutocompleteAsync() { Suggestions = string.IsNullOrWhiteSpace(User) ? [] : await Api.FindAsync(User); await InvokeAsync(StateHasChanged); } -
在自我介绍标记中,找到输入字段,并将@onfocusout赋值替换为触发AutocompleteAsync()方法的@bind:after属性:
<input class="form-control w-50" @bind=@User @bind:event="oninput" @bind:after="@AutocompleteAsync" /> -
在输入下方,在
分隔符下,清除现有的问候和介绍部分,并在建议当前不可用时构建快速返回:<hr /> @if (!Suggestions.Any()) return; -
最后,在自我介绍标记的末尾,渲染从 API 响应中接收到的建议集合。
<h5>Did you mean?</h5> @foreach (var name in Suggestions) { <div>@name</div> }
它是如何工作的…
在步骤 1中,我们将SuggestionsApi服务添加到我们应用程序的依赖注入容器中。这使得SuggestionsApi在整个应用程序中都可以用于注入,确保我们可以在需要的地方使用它。类似于其他.NET Web 框架,Blazor 可以管理具有三种生命周期——单例、作用域和瞬态——的服务:
-
单例服务在每个应用程序中只创建一次,并在所有组件和请求之间共享。
-
瞬态服务每次请求时都会被重新创建,这使得它们非常适合轻量级、无状态的服务(例如 API 集成)。
-
作用域服务稍微复杂一些。在客户端应用程序中,作用域服务通常表现得像单例服务,因为没有连接上下文来区分会话。然而,当OwningComponentBase组件介入时,情况就改变了。OwningComponentBase是一个基类,确保组件及其依赖项在 Blazor 销毁组件实例时能够优雅地释放。
在步骤 2中,我们继续到自我介绍组件,并清除问候实现,因此只剩下用户变量。在步骤 3中,我们将SuggestionsApi注入到我们的自我介绍组件中,并初始化一个建议集合来存储我们从 API 调用中获取的结果。进入步骤 4,我们实现AutocompleteAsync()异步方法,在简化标记逻辑中起着至关重要的作用。AutocompleteAsync()检查用户是否已输入任何内容,并调用 API 的FindAsync()方法来获取建议;否则,它将短路操作,返回一个空数组。
在步骤 5中,我们增强了IntroduceYourself的标记。我们通过移除@onfocusout事件并替换为@bind:after指令来细化输入字段的绑定逻辑,该指令在绑定完成后立即调用AutocompleteAsync()方法。通过这种设置,我们确保每次用户修改输入时,都会从 API 请求一组新的自动完成建议。最后,在步骤 6和步骤 7中,我们引入了额外的标记来显示自动完成操作的结果。我们渲染一个找到的名称列表,以用户输入的相同字符集开始,如果 API 返回任何结果。
更多内容…
在我们的示例中,我们使用了[Inject]属性将SuggestionsApi注入到组件中。然而,Blazor 还提供了其他服务注入方法。让我们回顾一下所有这些方法:
-
当你在. razor文件的@code块中注入服务时,你通常会使用[Inject]属性:
@code { [Inject] private SuggestionsApi Api { get; init; } } -
如果你采用代码后置(code-behind)方法,你也会使用[Inject]属性,其中你将 Blazor 组件的逻辑分离到一个. cs文件中:
public partial class IntroduceYourself { [Inject] private SuggestionsApi Api { get; init; } } -
当以代码后置方式工作时,你还可以利用构造函数注入模式,并且根本不需要使用任何属性:
public partial class IntroduceYourself( SuggestionsApi Api) { } -
最后,Blazor 允许在组件的标记中直接注入服务 - 在这种情况下,你会使用@ inject指令:
@page "/ch02r01" @inject SuggestionsApi Api
所有这些方法都服务于相同的目的,但适应了不同的编码风格和偏好。没有哪一个比另一个更好,所以选择最适合你代码库结构和组织的选项。
自定义获取和设置绑定逻辑
数据绑定不仅仅是将 UI 元素连接到数据源。它还涉及到数据的检索和更新方式。自定义这些get和set操作可以使状态管理和数据流更加灵活。在本食谱中,我将指导你了解显式使用get和set的注意事项,以及在设置值时执行异步逻辑。这些机制将允许你简化代码,并更好地控制 Blazor 应用程序的交互性。
让我们实现一个简单的文本字段,具有显式的get和set操作,这样我们就可以在绑定开始时执行额外的异步逻辑。
准备工作
这次,我们将通过简化准备工作来为即将到来的食谱做准备:
-
创建一个Recipe04目录 - 这将是你的工作目录
-
从执行绑定后的异步操作食谱中复制你的IntroduceYourself组件,或者从 GitHub 仓库的Chapter02/ Recipe03目录中复制其实现。
-
在Recipe04旁边,从 GitHub 仓库复制Chapter02/ Data目录,其中包含本食谱所需的对象
如何做到这一点…
要自定义get和set绑定逻辑,请遵循以下说明:
-
在IntroduceYourself组件的标记中定位input字段。保留@bind:event指令,但将其他指令替换为自定义的@bind:get和@ bind:set逻辑:
<input class="form-control w-50" @bind:event="oninput" @bind:get="@User" @bind:set="@AutocompleteAsync" /> -
导航到IntroduceYourself的@code块,并调整AutocompleteAsync()方法以符合由@ bind:set指令强制执行的 setter 模式:
private async Task AutocompleteAsync(string value) { User = value; Suggestions = string.IsNullOrWhiteSpace(User) ? [] : await Api.FindAsync(User); await InvokeAsync(StateHasChanged); }
它是如何工作的…
在步骤 1中,我们更新了input字段的绑定逻辑。我们保留oninput事件,因为我们希望我们的方法在用户输入时执行,但我们用@bind:get和@bind:set替换了其他指令。我们使用@bind:get来指定 Blazor 应从哪里检索输入值——在我们的例子中,这只是对User变量的引用。通过@bind:set指令,我们定义了当 Blazor 将输入值绑定到组件状态时要执行的逻辑。那就是我们触发AutocompleteAsync()方法的时候。此时,你的 IDE 应该会突出显示一个编译错误。
通常,C#中的set方法默认接收一个value对象:
private string _userName;
public string UserName
{
get => _userName;
set => _userName = value;
}
在 Blazor 中,用于@bind:set的方法必须遵循类似的模式。区别在于我们可以定义传入的value对象的名称,从而提供更大的控制和清晰度。因此,在步骤 2中,我们通过添加value参数扩展AutocompleteAsync(),并保持与传统的get - set模式一致。请特别注意参数类型的一致性。由于我们将input绑定到类型为string的User对象,因此AutocompleteAsync()方法也期望一个字符串。例如,如果你将绑定到类型为int的Age变量,你的绑定方法需要接受一个int参数。
还有更多…
那么,为什么@bind:after和@bind:set都存在,尽管它们几乎做的是同一件事?每个事件的执行时间至关重要。当你需要使用默认的绑定操作@bind:after执行额外的逻辑,通常是异步的,你应该选择它,因为它可以避免你手动持久化传入值的复杂性。另一方面,@bind:set提供了更多的灵活性,因为它在设置绑定值时执行。它允许将验证逻辑(包括异步操作)集成到绑定过程中。关键的是,@bind:set允许你在任何验证结果的基础上评估传入的值,并在它成为组件状态的一部分之前决定是否丢弃或接受它。这两个功能对于确保数据完整性和在 Blazor 应用程序中实现复杂的验证机制都非常有价值。
使用 bind-Value 模式简化绑定
bind-Value模式是简化 UI 元素与数据属性链接过程的一个变革性工具。它通过减少与双向数据绑定相关的样板代码,提高了代码的清晰性和简洁性。在本食谱中,我将通过一个实际示例指导您,展示该模式在创建更易于维护和简单的代码中的实用性,从而最终提升您的开发工作流程。
让我们实现一个组件,它允许直接绑定到其参数,其结构类似于标准绑定到 HTML 元素。
准备工作
在开始实现启用bind-Value模式的组件之前,执行以下操作:
-
创建一个Recipe05目录——这将成为您的工作目录
-
在Recipe05旁边,从 GitHub 仓库复制Chapter02/ Data目录,其中包含SkillLevel和DataSeed,这是本食谱所需的。
如何做到这一点…
按照以下步骤应用bind-Value绑定模式:
-
创建一个IntroductionForm组件并在顶部添加所需的程序集引用:
@using BlazorCookbook.App.Client.Chapters.Chapter02.Data -
在IntroductionForm的@code块中,声明一个string参数用于Name。在其旁边,添加EventCallback
以将Name值的更改通知: [Parameter] public string Name { get; set; } [Parameter] public EventCallback<string> NameChanged { get; set; } -
类似地,在Name和NameChanged下方,添加一个Skill和一个EventCallback
参数来管理技能级别状态: [Parameter] public SkillLevel Skill { get; set; } [Parameter] public EventCallback<SkillLevel> SkillChanged { get; set; } -
仍然在@code块中,实现一个调用NameChanged回调并传播Name值更改的方法:
private Task OnNameChanged() => NameChanged.InvokeAsync(Name); -
最后,在@code块中添加另一个方法,处理Skill值的更改,该方法检索ChangeEventArgs参数并根据解析的值和底层的DataSeed.SkillLevels数据源设置技能级别:
private Task OnSkillChanged(ChangeEventArgs args) { var id = int.Parse(args.Value.ToString()); var skill = DataSeed.SkillLevels .SingleOrDefault (it => it.Id == id); return SkillChanged.InvokeAsync(skill); } -
在IntroductionForm标记中,在@using下方添加一个用户可以输入其姓名的部分。声明绑定将在oninput事件上发生,并在绑定完成后触发OnNameChanged方法:
<h5>What's your name?</h5> <input class="form-control w-50 mb-1" @bind="@Name" @bind:event="oninput" @bind:after=@OnNameChanged /> -
在下方添加另一个标记部分,允许您选择用户的技能级别。利用DataSeed.SkillLevels数据源来填充选择选项,并确保选择更改的绑定与onchange事件一起发生:
<h5>What's your skill level?</h5> <select class="form-control w-50 mb-1" @onchange="@OnSkillChanged"> <option value="0">-</option> @foreach (var level in DataSeed.SkillLevels) { <option value="@level.Id"> @level.Title </option> } </select> -
创建一个新的IntroduceYourself可路由组件,以InteractiveWebAssembly模式渲染,并引用数据对象程序集:
@using BlazorCookbook.App.Client.Chapters.Chapter02.Data @page "/ch02r05" @rendermode InteractiveWebAssembly -
在@code块中,初始化Name和Skill变量以捕获用户输入并生成问候语:
protected string Name { get; set; } protected SkillLevel Skill { get; set; } -
仍然在@code块中,实现一个IsGreetingReady方法,允许您检查问候语是否准备好渲染:
private bool IsGreetingReady => !string.IsNullOrWhiteSpace(Name) && Skill is not null; -
在IntroduceYourself标记中,嵌入IntroductionForm并利用bind-Value模式动态绑定Name和Skill参数到相应的目标变量:
<IntroductionForm @bind-Name="@Name" @bind-Skill="@Skill" /> -
通过添加一个部分分隔符和条件渲染用户问候,当它准备好时,完成IntroduceYourself标记:
<hr /> @if (!IsGreetingReady) return; <h5>Welcome @Name on level @Skill.Title!</h5>
它是如何工作的…
在第 1 步中,我们创建了一个IntroductionForm组件,并在顶部引用了包含样本数据的汇编。在第 2 步中,我们定义了一对类型为string和EventCallback
在逻辑设置到位后,在第 6 步中,我们继续进行到IntroductionForm标记。我们添加了一个简单的输入字段并将其绑定到Name参数。我们还连接了OnNameChanged()方法,以便在绑定完成后触发。在第 7 步中,我们构建了一个选择字段,允许用户选择他们的技能水平。我们渲染了一个中性的选项,显示-,以及来自DataSeed.SkillLevels集合的技能选项。我们将选择字段的@onchanged事件连接到OnSkillChanged()方法。
在第 8 步中,我们创建了一个可路由的IntroduceYourself组件,以InteractiveWebAssembly模式渲染。我们还引用了示例数据汇编,利用了@using指令。在第 9 步中,我们在IntroduceYourself组件内初始化一个@code块,并声明Name和Skill后端属性以进行绑定和生成用户的问候。在第 10 步中,我们实现了一个简单的IsGreetingReady方法,该方法检查Name和Skill是否具有有意义的值,并且可以安全地生成问候。
在步骤 11中,我们跳转到IntroduceYourself标记并见证bind-Value模式的作用。由于IntroductionForm公开了具有模式匹配事件回调的Name和Skill参数,我们可以使用@bind-Name和@bind-Skill动态绑定它们。您的 IDE 将自动识别此模式,甚至可能建议这些指令。我们在步骤 12中通过添加基于当前Name和Skill值的问候消息的条件渲染来最终确定标记。
bind-Value模式封装了组件内的绑定逻辑和验证,极大地简化了单元测试,并增强了父组件的整洁性和健壮性。它只需要父组件提供后端变量,从而简化了开发过程。它甚至更强大,因为我们所涵盖的所有绑定指令(@bind:after,@bind:get,@bind:set),都可以与bind-Value模式搭配使用。
与外部数据提供者绑定
当构建与外部数据源交互的 Web 应用程序时,通常会在用户输入时触发 API 调用。然而,这可能导致请求洪水,使 API 过载并降低用户体验。为了应对这一挑战,我们将实现输入节流——一种根据用户输入调节请求发送速率的技术。在本食谱中,我将指导您在 Blazor 组件中设置输入节流,确保高效且负责任地使用外部 API。您将创建更健壮、用户友好的应用程序,能够处理大量用户交互而不会压垮您的数据提供者。
让我们实现一个简单的文本字段,该字段使用节流来限制对外部 API 的调用,并无缝等待用户完成输入。
准备工作
在开始节流实现之前,请执行以下操作:
-
创建一个Recipe06目录——这将作为您的工作目录
-
在Recipe06旁边,从 GitHub 仓库复制Chapter02 / Data目录,其中包含本食谱所需的SuggestionsApi类
-
从 GitHub 仓库的Chapter02 / Recipe03目录复制IntroduceYourself组件
如何操作...
在调用外部 API 时实现节流,请按照以下步骤操作:
-
打开您应用程序的程序文件,并将SuggestionsApi服务注册以启用与 API 的通信:
builder.Services.AddTransient<SuggestionsApi>(); -
使用@implements指令在@ rendermode指令下方增强IntroduceYourself组件的实现IDisposable接口:
@rendermode InteractiveWebAssembly @implements IDisposable -
在IntroduceYourself组件的@code块内部,声明Timer变量和两个TimeSpan变量——用于节流和整体超时:
private Timer _debounceTimer; private readonly TimeSpan _throttle = TimeSpan.FromMilliseconds(500), _timeout = TimeSpan.FromMinutes(1); -
仍然在@code块内,实现一个使用Timer和TimeSpan变量进行节流的OnUserInput()方法,该方法使用代理逻辑来封装在AutocompleteAsync()方法内的 API 请求:
private void OnUserInput() { _debounceTimer?.Dispose(); _debounceTimer = new Timer( _ => InvokeAsync(AutocompleteAsync), null, _throttle, _timeout); } -
为了完成@code块,实现IDisposable接口所需的Dispose()生命周期方法,并显式地销毁_debounceTimer实例:
public void Dispose() => _debounceTimer?.Dispose(); -
在IntroduceYourself标记中,更新@bind:after指令以调用OnUserInput()方法,其中我们添加了节流逻辑:
<input class="form-control w-50" @bind=@User @bind:event="oninput" @bind:after="@OnUserInput" />
它是如何工作的…
在步骤 1中,我们在应用程序的依赖注入容器中注册了SuggestionsApi服务。如果你正在跟随整个章节,你可能已经有了SuggestionsApi。
在步骤 2中,借助@implements指令,我们使用IDisposable模式增强了IntroduceYourself组件。在 Blazor 中,IDisposable接口用于在销毁组件时释放未管理资源或断开事件处理器。IDisposable需要实现一个Dispose()方法,Blazor 将在组件从 UI 中移除时自动调用该方法,确保适当的清理并防止内存泄漏。如果没有实现Dispose()方法,你的 IDE 将突出显示编译错误。我们将在后续步骤中解决这个问题。
在步骤 3中,我们为节流逻辑打下基础。在IntroduceYourself组件的@code块中,我们初始化了两个关键变量:_throttle,它定义了用户交互和 API 调用之间的空闲时间(在我们的示例中为 500 毫秒),以及_timeout,它为外部通信设置了一个总超时时间。我们还声明了一个_debounceTimer变量,其类型为Timer,这是管理 API 调用频率的骨干。Timer类封装了一个调度器,它将方法的执行延迟指定的时间,这使得它非常适合节流。在步骤 4中,仍然在@code块内,我们实现了一个具有节流代理逻辑的OnUserInput()方法。首先,我们通过销毁_debounceTimer实例来停止当前计划的操作,以避免任何重叠执行。接下来,我们实例化一个新的Timer对象,将现有的AutocompleteAsync()方法包装在InvokeAsync()方法中以确保线程安全。由于我们不需要在定时器调用之间维护任何状态,我们传递null作为状态对象,并使用_throttle和_timeout变量完成_debounceTimer的初始化。在步骤 5中,我们通过实现缺失的Dispose()方法来完成@code块,以优雅地销毁_debounceTimer实例。现在应该没有编译错误了。
最后,在第 6 步中,我们稍微更新了IntroduceYourself标记中的输入字段。而不是在绑定完成后调用AutocompleteAsync(),我们将@bind:after属性更新为调用OnUserInput()方法,其中包含我们的节流逻辑。现在,每个按键都通过节流机制,优化应用程序的响应速度并减少对外部 API 的负载。
第三章:掌控事件处理
在本章中,我们将深入探讨 Blazor 应用程序中的事件处理世界。事件 是一个基本构建块,表示浏览器内的动作,如点击、输入或页面加载。事件允许开发者在用户交互时执行特定代码,从而创建一个交互性和动态的用户体验。
我们将首先探讨如何钩入事件委托,为事件管理打下基础。接下来,我们将讨论使用 EventCallback 和 lambda 表达式进行责任委托,这增加了事件处理中的灵活性。
我们还将涵盖控制事件传播和防止触发默认事件的必要策略。这些技能对于创建直观的用户界面,其中你可以完全控制用户交互至关重要。此外,我们还将介绍自定义事件的概念,扩展了 事件驱动 应用程序设计的可能性。
本章节的重点将在于理解事件如何在 Blazor 中触发渲染。这种理解对于优化应用程序性能和确保无缝的用户体验至关重要。到本章结束时,你将深入理解 Blazor 的事件处理,并掌握将这些概念有效地应用于你的 Web 开发项目的实践技能。
下面是我们将在本章中涵盖的菜谱列表:
-
钩入事件委托
-
使用 lambda 表达式进行委托
-
使用 EventCallback 进行委托
-
阻止默认事件行为
-
控制事件传播
-
介绍自定义事件
-
处理长时间运行的事件
技术要求
本章的目标是使示例保持简单,并专注于 Blazor 事件处理的原则。换句话说,你不需要任何额外的工具,只需这些基础知识:
-
一个支持 Blazor 开发的现代 IDE
-
在你的开发机器上安装 .NET 9
-
一个支持 WebAssembly 的现代网络浏览器
-
一个 Blazor 项目(你将在其中编写代码)
你将看到的全部代码示例(和数据样本)都可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Blazor-Web-Development-Cookbook/tree/main/BlazorCookbook.App.Client/Chapters/Chapter03 。在需要任何样本的每个菜谱中,我还会指导你找到它们的目录。
钩入事件委托
UI 事件是网络用户交互的基础——标记每一次点击、滚动或键盘按键,并使您能够构建一个交互式应用程序。事件委托充当浏览器和您的代码之间的桥梁。每次用户交互都会触发一个指定的处理程序,执行预定义的操作。在本例中,我们将深入了解事件委托的机制,展示它们如何在 Blazor 应用程序中被检测和管理。
让我们创建一个页面,用户可以通过点击按钮来显示和隐藏一系列票据。
准备工作
在实现可点击按钮之前,执行以下操作:
-
创建Chapter03 / Recipe01目录——这将成为您的工作目录
-
从 GitHub 仓库中的Chapter03 / Data目录复制Ticket和Tickets样本文件
如何实现...
按照以下步骤实现基本的事件委托钩子:
-
创建一个新的可路由的TicketManager组件,以InteractiveWebAssembly模式渲染:
@page "/ch03r01" @rendermode InteractiveWebAssembly -
在您的TicketManager组件中添加@code部分。声明一个类型为bool的ShowTickets属性,该属性将决定票据列表的可见性:
@code { protected bool ShowTickets { get; set; } } -
此外,仍然在@code块内部,实现一个方法来切换ShowTickets属性以改变票据列表的可见性:
private void ToggleTickets() => ShowTickets = !ShowTickets; -
在TicketManager组件的标记区域中,引入一个按钮,该按钮利用ToggleTickets()方法并允许用户相应地更新 UI:
<button class="btn btn-sm btn-success" @onclick="@ToggleTickets"> Toggle Tickets </button> -
在按钮下方,根据ShowTickets属性的当前值,有条件地跳过显示票据列表或渲染水平分隔符,指示票据区域开始的位置:
@if (!ShowTickets) return; <hr /> -
在水平分隔符下方,渲染票据列表,利用从复制的样本数据中获取的Tickets.All集合作为数据源:
@foreach (var ticket in Tickets.All) { <div class="d-flex justify-content-between mb-1" id="ticket-@ticket.Id"> <div>@ticket.Title</div> </div> }
它是如何工作的...
我们开始实现的过程是创建一个可路由的TicketManager组件,如步骤 1所述。我们使用@page指令声明可导航路径。我们还声明了一个交互式渲染模式,因为我们需要我们的按钮是可操作的。接下来,在步骤 2中,我们引入了一个后备的ShowTickets属性。此属性作为标志,指示票据列表当前的可见状态——要么显示,要么隐藏。然后,在步骤 3中,我们引入了一个ToggleTickets()方法,用于切换ShowTickets属性。
我们通过挂钩 Blazor 的事件回调机制,在步骤 4中实现了交互的核心。我们在组件的标记中添加了一个按钮,使用户能够控制票据列表的显示状态。使用@onclick,我们可以在发生onclick事件时触发我们的ToggleTickets()方法。
在 步骤 5 中,我们检查 ShowTickets 属性的值,并决定是否完全跳过渲染票据列表。在渲染列表的情况下,我们添加一个
标签,以清楚地指示票据区域开始的位置。在 步骤 6 中,我们遍历 Tickets.All 样本集合,并在灵活的 div 容器中渲染所有可用的票据标题。通过这种设置,TicketManager 会根据 ShowTickets 属性的当前值响应用户交互,并相应地渲染或隐藏票据列表。
更多内容...
Blazor 提供了与 onclick、ondrag、oncopy 以及您已经熟悉的其他 HTML 事件的无缝集成,允许进行动态和交互式 Web 应用程序开发。通过在事件名称前加上 @ 符号,您向 Blazor 表明您正在使用 Blazor 特定的事件而不是标准 HTML 事件。这种区别对于充分利用 Blazor 事件系统的全部功能至关重要。
使用 Blazor 事件的一个主要优势是它们能够实时更新 DOM。Blazor 使用一个本地的 diffing 算法,该算法精确地计算出 DOM 中哪些部分已更改,并仅更新这些部分。这导致与服务器通信时的负载显著减小,并且无论选择的渲染模式如何,渲染时间都会更快。
需要注意的是,Blazor 渲染通常仅在组件的初始渲染时触发,或者当您显式调用 StateHasChanged() 生命周期方法时。如果我们进一步探讨,Blazor 中的 HTML 事件对应项实际上是 EventCallback
public Task InvokeAsync(object? arg)
{
if (Receiver == null)
{
return EventCallbackWorkItem
.InvokeAsync<object?>(Delegate, arg);
}
return Receiver
.HandleEventAsync(
EventCallbackWorkItem(Delegate),arg);
}
在大多数情况下,我们的接收器继承自 ComponentBase。有趣的是,ComponentBase.HandleEventAsync() 方法会自动调用 StateHasChanged()。因此,组件的状态更新无需手动干预:
Task IHandleEvent.HandleEventAsync(
EventCallbackWorkItem callback, object? arg)
{
// ...
StateHasChanged();
// ...
}
使用 lambda 表达式进行委托
在这个菜谱中,我们将探讨 .NET 中 lambda 表达式的力量及其在 Blazor 事件处理中的关键作用。最简单的说法,lambda 表达式是一个遵循特定语法的匿名方法。这些表达式是 .NET 中函数式编程的基石,并提供了一种简化的方法来编写内联委托实现。当涉及到 Blazor 时,使用 lambda 表达式进行委托变得特别有利。它们在定义标记内的直接事件处理程序和回调时非常有用。它们还使您能够拦截传入的参数和当前的运行上下文。
让我们利用 lambda 表达式并给票据列表添加更多操作,以便我们能够修改指定票据的状态。
准备中
在深入使用 lambda 表达式进行委托之前,请执行以下操作:
-
创建一个Chapter03 / Recipe02目录——这将是你的工作目录
-
从钩入事件委托食谱或从 GitHub 仓库中的Chapter03 / Recipe01目录复制TicketManager组件
-
从 GitHub 仓库中的Chapter03 / Data目录复制Ticket和Tickets样本文件
如何做到这一点...
按照以下步骤查看使用 lambda 表达式进行委托的强大功能:
-
导航到TicketManager的@code代码块,并在现有代码下方初始化一个Ticket类型的对象来存储当前选中票的详细信息:
protected Ticket SelectedTicket; -
在下面的@code代码块中,实现一个Show()方法,以便设置当前选中票的值:
private void Show(Ticket ticket) => SelectedTicket = ticket; -
跳转到TicketManager的标记,通过在Title部分下方添加两个操作按钮并使用 lambda 表达式附加其操作来扩展票详情的渲染:
<div>@ticket.Title</div> <div> <button class="btn btn-sm btn-success" @onclick=@(() => Show(ticket))> Show details </button> <button class="btn btn-sm btn-success" @onclick=@(() => ticket.Stock += 5)> Top up </button> </div> -
在循环渲染票详情下方,检查用户是否已经设置了SelectedTicket值,并根据条件跳过特定票详情的渲染:
@if (SelectedTicket is null) return; <hr /> -
对于SelectedTicket变量有值的情况,渲染票标题、价格和可用性。确保此部分仅在SelectedTicket的值可用时可见:
<div>Title: @SelectedTicket.Title</div> <div>Price: @SelectedTicket.Price</div> <div>Stock: @SelectedTicket.Stock</div>
它是如何工作的…
在步骤 1中,我们导航到TicketManager的@code代码块,并初始化一个SelectedTicket变量,该变量将保存对当前选中票的引用。接下来,在步骤 2中,我们实现一个Show()方法,该方法接受Ticket作为参数。Show()方法的单一职责是更新SelectedTicket引用。
在步骤 3中,我们转向TicketManager的标记,遍历样本中的Tickets.All集合并渲染每个票标题。在Title部分下方,我们添加了两个按钮,允许执行管理操作,利用 lambda 表达式进行委托。使用第一个按钮,我们允许用户通过将Show()方法附加到按钮的@onclick事件并传递当前迭代的ticket对象来显示给定票的详细信息。在这里,lambda 表达式的使用允许实现精确和上下文相关的操作。第二个按钮允许用户补充票库存。这次,我们使用了一个匿名 lambda 表达式——一个封装操作本身而不是委托给现有方法的 lambda 表达式。我们在遍历Tickets.All样本集合时访问每个票的Stock属性,并在标记内直接将Stock值增加 5。
然而,lambda 表达式的灵活性和强大功能也带来了巨大的责任。在标记中尽量减少 C#代码的数量是一种良好的实践。使用强类型方法封装复杂和冗长的 lambda 表达式,以保持代码的清晰性。
在步骤 4中,我们进一步扩展了TicketManager的标记。类似于检查ShowTickets值并条件性地显示票务列表(我们在Hooking into event delegates菜谱中实现),我们检查用户是否设置了SelectedTicket的值,并条件性地跳过渲染特定的票务详情。我们在步骤 5中通过添加简单的标记来渲染用户选择的票务的标题、价格和库存属性。由于 Blazor 将 lambda 表达式转换为EventCallback对象,用户将在每次点击充值按钮后立即看到库存属性值的更新。
参见
如果你想要了解更多关于 lambda 表达式角色和功能的信息,请访问 Microsoft Learn 部分:
learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-expressions
使用 EventCallback 进行委托
在这个菜谱中,我们借助EventCallback来探索事件委托。Blazor 中的EventCallback是一种机制,它使组件能够监听并响应用户生成的事件或交互,与框架的架构设计紧密相连。这个 Blazor 原生特性通过无缝集成组件生命周期和整体应用程序状态,使开发者能够编写更干净、更高效的代码。事件回调的主要优势在于它们能够通过StateHasChanged()方法自动管理 UI 更新,确保用户界面与应用程序状态保持同步。EventCallback也是一个空安全对象——当它未被分配但被调用时,它会安全地跳过而不是抛出NullReferenceException。你将在本章的所有菜谱中看到EventCallback,因为它是 Blazor 中大多数交互的基础。
让我们实现一个组件,利用EventCallback参数封装管理票务操作。使用该组件,我们还将简化票务列表的标记。
准备工作
在我们实现使用EventCallback的委托之前,请执行以下操作:
-
创建一个Chapter03/Recipe03目录——这将是你的工作目录
-
从Delegating with lambda expressions菜谱或从 GitHub 仓库中的Chapter03/Recipe02目录复制TicketManager组件
-
从 GitHub 仓库中的Chapter03/Data目录复制Ticket和Tickets样本文件
如何做到这一点…
按照以下步骤使用EventCallback委托实现文章管理系统:
-
创建一个新的TicketOptions组件。
-
在TicketOptions中初始化@code块并声明三个必需的参数,每个参数的类型为EventCallback,对应不同的管理操作:
@code { [Parameter, EditorRequired] public EventCallback OnShow { get; set; } [Parameter, EditorRequired] public EventCallback OnTopUp { get; set; } [Parameter, EditorRequired] public EventCallback OnRemove { get; set; } } -
跳转到TicketOptions标记并构建允许用户调用OnShow、OnTopUp和OnRemove操作的按钮:
<button class="btn btn-sm btn-success" @onclick="@OnShow"> Show </button> <button class="btn btn-sm btn-info" @onclick="@OnTopUp "> Top up </button> <button class="btn btn-sm btn-danger" @onclick="@OnRemove"> Remove </button> -
导航到TicketManager组件。
-
在TicketManager的@code块中,实现两个新方法,允许你移除和补充Ticket对象的库存:
private void TopUp(Ticket ticket) => ticket.Stock += 5; private void Remove(Ticket ticket) => Tickets.All.Remove(ticket); -
在TicketManager标记中,将现有的操作按钮替换为TicketOptions实例:
<div>@ticket.Title</div> <div> <TicketOptions OnShow="@(() => Show(ticket))" OnTopUp="@(() => TopUp(ticket))" OnRemove="@(() => Remove(ticket))" /> </div>
它是如何工作的...
在步骤 1中,我们创建一个新的TicketOptions组件。在步骤 2中,我们在TicketOptions中初始化@code块并声明三个必需的EventCallback参数,这些参数将携带触发管理票务操作的必要的行为委托。接下来,在步骤 3中,我们使用三个按钮构建TicketOptions标记,每个按钮在用户点击时将调用OnShow、OnTopUp或OnRemove参数。请注意,我们将EventCallback参数直接附加到每个按钮的@onclick事件上。我们不需要添加额外的作为代理的方法。Blazor 将无缝地将 UI 交互与我们的预定义操作链接起来。
在步骤 4中,我们导航到TicketManager组件。在步骤 5中,我们在TicketManager的@code块中扩展两个额外的方法。首先,我们实现一个TopUp()方法,该方法将当前票的Stock属性值增加 5。接下来,我们实现一个Remove()方法,该方法简单地从Tickets.All集合中移除指定的票。在步骤 6中,我们定位到渲染每个票的原始操作按钮的TicketManager标记区域。我们将这些按钮替换为TicketOptions标记,并将相应的操作附加到每个必需的EventCallback参数上。
还有更多...
在TicketOptions组件就位后,我们已经显著简化了TicketsManager标记代码。我们以更组织化和可读的方式重构了与票务相关的操作,使整体代码库更干净且易于维护。
但是,由于TicketOptions仅作为操作代理,并不基于Ticket引用,因此每次渲染TicketOptions组件时,我们实际上都创建了新的委托实例,将可操作的方法包裹在其中。即使有所有 C#优化魔法,这种操作也可能带来性能损失。在简单应用程序中,性能影响可能微乎其微。然而,当与数据密集型或高度反应性系统一起工作时,你必须记住这一点。
防止默认事件操作
在这个菜谱中,我们探讨了浏览器自动执行特定操作以响应用户事件的机制。默认事件操作可能包括当按下回车键时表单提交或当点击链接时导航到链接的 URL。然而,在 Blazor 应用程序中,您可能需要拦截这些自动行为以控制用户体验。无论是管理表单验证、确认用户意图还是管理不刷新页面的动态内容更新,防止默认操作变得至关重要。我将指导您如何在应用程序中以编程方式停止这些默认行为。
让我们实现一个快速创建票据的功能,我们将拦截并应用每个用户创建的键的自定义逻辑,并存储。
准备工作
在探索如何拦截和防止默认事件操作之前,执行以下操作:
-
创建一个Chapter03 / Recipe04目录——这将成为您的工作目录
-
从事件回调中的委托菜谱或从 GitHub 仓库中的Chapter03 / Recipe03目录复制TicketManager和TicketOptions
-
从 GitHub 仓库中的Chapter03 / Data目录复制Ticket,Tickets和Extensions文件
如何做到这一点…
通过以下步骤防止默认事件操作:
-
导航到TicketManager的@code块,并在现有代码下方初始化一个新的Creator变量:
internal string Creator = string.Empty; -
在Creator变量下方,仍然在@code块中,实现一个MonitorCreation()方法,该方法拦截一个KeyboardEventArgs参数,解析其有效负载,并在用户在键盘上按下+符号时创建一个新的票据实例:
private void MonitorCreation(KeyboardEventArgs args) { if (args.Key == "+") { Tickets.All.Add(new() { Title = Creator }); Creator = string.Empty; return; } if (args.IsBackspace() && Creator.Length > 0) { Creator = Creator[..¹]; return; } if (args.IsLetter()) { Creator += args.Key; return; } } -
跳转到TicketManager标记。在顶部的渲染模式声明下方,通过添加一个部分标题和一个带有附加到其@onkeydown事件的MonitorCreation()方法的输入来构建一个票据创建区域,防止默认的@ onkeydown行为:
<h5>Quick creation</h5> <p> <input value="@Creator" @onkeydown="MonitorCreation" @onkeydown:preventDefault /> </p>
它是如何工作的…
在步骤 1中,我们导航到TicketManager的@code块,并初始化一个Creator变量,该变量将保存用户在快速创建票据字段中输入的当前文本。我们将在稍后构建创建字段本身。
在 第 2 步 中,在 Creator 变量旁边,我们实现了一个 MonitorCreation() 方法,我们将在这里放置 Blazor 执行的自定义 @onkeydown 逻辑,而不是默认的。MonitorCreation() 方法接收一个 KeyboardEventArgs 对象,它有一个 Key 属性,这是我们自定义创建逻辑所需要的。首先,我们检查点击的符号是否匹配 + 键,并将一个新的 Ticket 对象添加到 Tickets.All 集合中。接下来,我们利用数据样本提供的 Extensions 文件中的 IsBackspace() 扩展方法。如果用户点击了退格键,并且 Creator 的长度指示有字符可以删除,我们将使用 范围运算符 从 Creator 的值中删除最后一个字符。最后,我们利用 Extensions 文件中的另一个自定义扩展方法 – IsLetter() – 来检查用户按在键盘上的键是否实际上是一个字母,并将其附加到当前 Creator 值的末尾。通过这种实现,我们忽略了所有其他键盘操作。我强烈建议您进行实验,并自行添加数字支持!
在 第 3 步 中,我们跳转到 TicketManager 标记,并构建一个用户可以快速创建新票据的部分。我们添加了一个 快速创建 标题,以便清楚地了解下面输入的目的。最后,我们构建了 输入 字段,所有的事件预防都发生在这里。我们将 输入 的值设置为反映 Creator 的值。请注意,我们在这里没有利用任何绑定(更多关于绑定的内容请参阅 第二章)。接下来,我们将 MonitorCreation() 方法附加到输入的 @onkeydown 事件上,以便 Blazor 无缝地触发我们的自定义逻辑。但是 @onkeydown 有浏览器默认逻辑,与我们刚刚附加的逻辑冲突。在这里,我们使用 @onkeydown:preventDefault,指示 Blazor 跳过任何默认的键按下行为。
还有更多...
Blazor 中的所有事件在渲染模式方面表现几乎相同。然而,一些事件,如 @onkeydown,在预期的结果上本质上是客户端的 – 立即响应用户输入。当在 InteractiveServer 模式下使用 @onkeydown 时,你必须考虑到每个事件触发器在反映到 UI 之前将往返于服务器。在高延迟场景中,这种往返可能会导致 UI 的不稳定和不稳定行为。始终考虑你选择的事件的性质和适当的渲染模式,以确保你的应用程序保持用户友好。
当构建一个国际化的应用程序时,你可能需要支持特殊的地方字符,这些字符需要特定的键组合,例如使用 Alt + a 来生成波兰语的字母 ą。为了有效地处理这些情况,Blazor 提供了管理键盘组合事件的能力。
private void MonitorCreation(KeyboardEventArgs args)
{
if (args.IsComposing) return;
//rest of the processing logic obscured for simplicity
}
您可以使用 KeyboardEventArgs 中的 IsComposing 属性来跟踪输入的组成状态。当 IsComposing 设置为 true 时,表示用户正在输入一个复合字符。您应该在 IsComposing 返回到 false 之前延迟处理输入。
控制事件传播
在本食谱中,我们探讨了在 Blazor 应用程序中控制事件如何在 Document Object Model ( DOM ) 中传播的过程。当我们与嵌套组件或元素一起工作时,停止默认事件传播变得至关重要。您可以通过确保事件(如点击、悬停或键盘输入)具有局部影响来避免在 UI 中产生意外的涟漪效果或行为。通过掌握事件传播的控制,您可以在应用程序中微调交互模式,从而实现更流畅、更直观的用户体验。
让用户能够点击票记录的任何位置来显示其详细信息,同时确保点击嵌套的任何管理操作都不会无控制地传播。
准备工作
在深入控制事件传播之前,请执行以下操作:
-
创建一个 Chapter03 / Recipe05 目录 – 这将是您的工作目录
-
从 Preventing default event actions 食谱或从 GitHub 仓库中的 Chapter03 / Recipe04 目录复制 TicketManager 和 TicketOptions
-
从 GitHub 仓库中的 Chapter03 / Data 目录复制 Ticket、Tickets 和 Extensions 文件
如何实现...
要控制事件传播并查看 stopPropagation 属性的作用,请按照以下步骤操作:
-
导航到 TicketManager 标记,并找到我们为每个票渲染的容器标记。在 id 属性的分配旁边,将 Show() 方法附加到容器的 @ onclick 事件:
<div class="d-flex justify-content-between mb-1" id="ticket-@ticket.Id" @onclick="() => Show(ticket)"> @* here's still the ticket container body *@ </div> -
导航到 TicketOptions 标记,并将 stopPropagation 属性附加到每个管理操作按钮的 @onclick 事件:
<button class="btn btn-sm btn-success" @onclick="@OnShow" @onclick:stopPropagation> Show </button> <button class="btn btn-sm btn-info" @onclick="@OnTopUp" @onclick:stopPropagation> Top up </button> <button class="btn btn-sm btn-danger" @onclick="@OnRemove" @onclick:stopPropagation> Remove </button>
它是如何工作的...
在 步骤 1 中,我们导航到 TicketManager 标记,在那里我们在一个专门的容器中渲染每个票的详细信息。您将在 foreach 循环内找到容器标记,其 id 属性设置为对应当前票 ID。为了允许用户通过点击容器上的任何位置来显示票详情,我们在 id 属性旁边将我们的 Show() 方法附加到容器的 @onclick 事件。现在,无论用户点击 Show 按钮,还是点击票容器内的任何位置,Blazor 都将触发相同的操作并渲染给定票的详细信息。
现在,这里有个关键点。在票据容器内部,我们还嵌入了Top Up和Remove按钮——它们对@onclick事件的反应各不相同。然而,在同一区域内嵌套的@onclick事件默认情况下会同时触发。在我们的例子中,当用户点击Top Up按钮时,它将同时增加票据库存并渲染其详情。对于Remove按钮来说,情况更加复杂,因为用户可以在移除票据的同时显示其详情。这就是我们需要stopPropagation属性的地方。将stopPropagation属性附加到所需事件上,我们指示 Blazor 阻止事件传播到父 DOM 元素。
在步骤 2中,我们导航到TicketOptions标记,其中包含所有管理操作按钮。在每个三个按钮的@onclick属性旁边,我们附加了@onclick:stopPropagation属性。这就足够确保用户可以安全地增加票据库存或完全移除它,而不会遇到不希望的票据详情显示渲染。
还有更多...
虽然stopPropagation属性是 Blazor 应用程序中管理事件流的有力工具,但了解其作用域和限制是至关重要的。该属性专门设计用于与 Blazor 事件一起工作,并且不会直接影响标准 HTML 事件的行为。HTML 事件必须首先允许正常执行;然后 Blazor 才能拦截这些事件并就事件从子组件传播到父组件做出决策。
在我们的实现中,我们专注于控制@onclick事件,但在处理需要控制多个事件的复杂界面时,stopPropagation必须对每个事件都是明确的。
此外,当将外部库中的组件集成到 Blazor 应用程序中时,可能会遇到直接控制事件传播并不直接的情况。在这种情况下,一个实用的解决方案是将外部组件包裹在一个中立的 HTML 元素中,例如,一个span元素。通过将stopPropagation应用于span上的事件,你实际上创建了一个阻止事件传播的屏障,其中span充当最近的父元素。这种方法允许你在复杂的组件层次结构中管理事件流,确保预期的行为,而不会受到外部组件的意外副作用。
引入自定义事件
在这个菜谱中,我们探讨了丰富我们的 Blazor 应用程序的自定义事件的可能性,深入到稍微更高级的领域,其中 JavaScript 与 Blazor 交互。除了自定义事件外,自定义事件参数的概念也出现了,允许传递超出标准事件负载的定制数据。当预定义的事件不足时,自定义事件及其相应的参数变得非常有价值,提供了捕捉和精确响应特定用户操作或外部系统触发的灵活性。
让我们实现一个组件,该组件覆盖用户尝试从该组件保护区域复制的所有数据。
准备工作
在我们探索自定义事件的实现之前,请执行以下操作:
-
创建一个 Chapter03 / Recipe06 目录——这将是你的工作目录
-
从 控制事件传播 菜谱或从 GitHub 仓库的 Chapter03 / Recipe05 目录复制 TicketManager 和 TicketOptions
-
从 GitHub 仓库的 Chapter03 / Data 目录复制 Ticket、Tickets 和 Extensions 文件
如何操作…
按照以下步骤实现复制事件的自定义逻辑:
- 向应用程序的 wwwroot 目录添加一个新的 JavaScript(.js)文件。遵循命名约定 {ASSEMBLY NAME}.lib.module.js。此文件将包含我们自定义事件所需的函数。

图 3.1:添加包含 JavaScript 函数的 BlazorCookbook.App.Client.lib.module.js 文件
-
在你新创建的 .js 文件中,声明一个 afterWebStarted() 函数。使用 registerCustomEventType API 声明一个新的 preventcopy 事件。在事件中实现自定义逻辑以覆盖当前的剪贴板数据:
export function afterWebStarted(blazor) { blazor.registerCustomEventType('preventcopy', { browserEventName: 'copy', createEventArgs: event => { event.clipboardData.setData('text/plain', '-------'); event.preventDefault(); return { stamp: new Date() }; } }); } -
创建一个新的 CustomEvents.cs 文件,该文件将作为所有与自定义事件相关的详细信息的中枢存储库。
-
在 CustomEvents.cs 中,添加一个名为 PreventedCopyEventArgs 的类,该类扩展了 EventArgs。包括一个 Stamp 属性,以便在 Blazor 阻止复制操作时持久化:
public class PreventedCopyEventArgs : EventArgs { public DateTime Stamp { get; init; } } -
仍然在 CustomEvents.cs 文件中,声明一个名为 EventHandlers 的 public 和 static 类。向这个类添加一个自定义的 EventHandler 属性,并定义一个返回 PreventedCopyEventArgs 的 onpreventcopy 事件。
[EventHandler("onpreventcopy", typeof(PreventedCopyEventArgs))] public static class EventHandlers { } -
添加一个新的 PreventCopy 组件,用于调用你定义的自定义事件逻辑。
-
在 PreventCopy 组件的 @code 部分,声明一个必需的 ChildContent 参数,其类型为 RenderFragment。同时,实现一个 Log() 方法来拦截并记录 PreventedCopyEventArgs 带有的时间戳:
@code { [Parameter, EditorRequired] public RenderFragment ChildContent { get; set; } private void Log(PreventedCopyEventArgs args) => Console.WriteLine( $"Prevented data leak at {args.Stamp} UTC."); } -
在 PreventCopy 标记内部,构建一个包装容器,其中拦截自定义的 @onpreventcopy 事件并将其委托给 Log() 方法,同时在内部渲染 ChildContent:
<div @onpreventcopy="@Log"> @ChildContent </div> -
导航到TicketManager标记,找到我们渲染SelectedTicket详情的区域,并用PreventCopy标签包裹它:
<PreventCopy> <div>Title: @SelectedTicket.Title</div> <div>Price: @SelectedTicket.Price</div> <div>Stock: @SelectedTicket.Stock</div> </PreventCopy>
它是如何工作的...
我们通过在 Blazor 和 JavaScript 之间建立桥梁来启动自定义事件的集成。在第 1 步中,我们在wwwroot目录中添加了一个.js文件,遵循特定的命名约定({ASSEMBLY NAME}.lib.module.js或{PACKAGE ID}.lib.module.js)。这个约定至关重要,因为 Blazor 会自动搜索这些文件以支持在应用程序中定义的自定义事件。在第 2 步中,我们定义了一个afterWebStarted()函数,它接受一个blazor参数(有意使用小写字母以区分全局可用的Blazor对象),这是 Blazor 编译器期望的。使用registerCustomEventType API,我们声明了我们的preventcopy事件,该事件旨在拦截浏览器的copy事件并覆盖剪贴板数据。在此过程中,我们还必须使用preventDefault()方法取消浏览器默认的复制行为。我们返回一个标记事件触发的时间戳,我们将在以后使用。
在第 3 步过渡到 Blazor 时,我们引入了CustomEvents.cs文件来定义我们的 Blazor 端自定义事件处理。在第 4 步中,我们实现了PreventedCopyEventArgs类,它继承自EventArgs,并反映了我们的 JavaScript 函数的结构,包括一个Stamp属性。在第 5 步中,我们使用 Razor 编译器的功能注册了一个 Blazor 自定义事件。遵循代码生成器的约定,我们声明了一个public static class EventHandlers,并利用[EventHandler]属性通知 Razor 编译器我们的自定义onpreventcopy事件。Razor 编译器将自动将onpreventcopy与其 JavaScript 对应者preventcopy对齐。
接下来,在第 6 步中,我们添加了一个PreventCopy组件作为包装器,防止在指定内容内进行复制操作。在第 7 步中,在PreventCopy的@code块中,我们声明了一个ChildContent参数,其中我们可以提供要保护的内容和一个原始的Log()方法来记录被阻止的复制尝试的时间戳。在第 8 步中,我们构建了PreventCopy标记。我们添加了一个容器并拦截了@onpreventcopy事件,同时每次它被触发时都会调用Log()方法。在容器内部,我们渲染提供的ChildContent标记。现在,Blazor 将有效地防止数据泄露,同时维护任何数据复制尝试的审计记录。
还有更多...
虽然 afterWebStarted() 函数在集成 Blazor Web App 中的自定义事件时至关重要,但需要注意的是,它专门为这个环境设计。当在 Blazor Web App 上下文之外工作时,需要采用类似的方法,但需要在函数命名上进行轻微调整。对于普通的服务器或 WebAssembly 项目,你必须实现 afterStarted() 函数。这种命名区分使我们能够清楚地定义 Blazor 注册自定义事件的时间,并确保应用程序生命周期的清晰性。
处理长时间运行的事件
在这个菜谱中,我们解决 单页应用程序 ( SPA ) 开发的一个关键方面 - 确保用户意识到后台正在进行的操作。与传统 Web 应用程序不同,SPAs 并不自然地指示后台正在执行的过程。这种缺乏反馈可能导致用户盯着看似停滞或无响应的页面,从而导致挫败感和困惑。在应用程序中融入视觉动作指示器,如预加载器、加载旋转器或进度条,是至关重要的。这些元素作为视觉提示,告知用户正在发生的事情,通过提供活动感和进度感来提升用户体验。我将指导你如何在你的 SPA 中实现这些指示器,确保在长时间运行请求或操作期间,你的用户能够保持了解,保持对应用程序的参与和满意度。
让我们实现两种动作指示器 - 一个简单的加载指示器和一种原始的进度指示器。
准备工作
在开始实现用户友好的状态指示器之前,执行以下操作:
-
创建一个 Chapter03 / Recipe07 目录 - 这将是你的工作目录
-
从 介绍自定义事件 菜谱或从 GitHub 仓库中的 Chapter03 / Recipe07 目录复制 TicketManager、TicketOptions、PreventCopy 和 CustomEvents
-
从 GitHub 仓库中的 Chapter03 / Data 目录复制 Ticket、Tickets 和 Extensions 文件
如何操作...
要构建改进应用程序用户体验的加载和进度指示器,请按照以下步骤操作:
-
创建一个新的 LoadingIndicator 组件,该组件将向用户直观地传达操作正在加载。
-
在 LoadingIndicator 的 @code 块中,声明一个 Job 参数 - 表示用于显示加载状态的任务,以及一个 ChildContent 参数,允许在加载完成后传递要渲染的内容:
@code { [Parameter, EditorRequired] public Func<Task> Job { get; set; } [Parameter, EditorRequired] public RenderFragment ChildContent { get; set; } } -
在参数声明下方,仍然在 @code 块内,初始化一个 IsLoading 状态变量并实现一个 RunAsync() 方法,该方法封装了在执行 Job 委托时管理加载状态的逻辑:
internal bool IsLoading; private async Task RunAsync() { IsLoading = true; StateHasChanged(); await Job.Invoke(); IsLoading = false; } -
在LoadingIndicator标记中,添加一个按钮,用户可以通过将其@onclick事件附加到RunAsync()方法来启动加载过程,并在加载过程中条件性地禁用它:
<button class="btn btn-sm btn-success" @onclick="@RunAsync" disabled="@IsLoading"> Load </button> -
在加载按钮下方,根据IsLoading状态变量的值构建两个区域——当加载正在进行时和加载完成后:
@if (IsLoading) { <hr /> <p>Loading...</p> } else { @ChildContent } -
创建另一个组件——ProgressIndicator——将向用户直观地传达他们请求的操作的进度:
-
在ProgressIndicator的@code块内,声明两个必需的参数:一个Job参数——表示进度指示器应监控的抽象操作,以及一个Total参数——提供操作应运行的元素数量:
@code { [Parameter, EditorRequired] public Func<int, Task> Job { get; set; } [Parameter, EditorRequired] public int Total { get; set; } } -
在参数声明下方,初始化一个Progress状态变量以反映操作的进度:
internal double Progress = 0; -
仍然在@code块内,实现一个表达式体的Label属性,根据Progress值构建动作按钮的标签(我们将在稍后添加):
private string Label => Progress > 0 ? $"Processing {Progress:0%}" : "Process"; -
通过实现一个RunAsync()方法来完成@code块,该方法遍历总元素数量并为每个索引执行作业:
private async Task RunAsync() { for (int i = 0; i < Total; i++) { Progress = 1.0 * (1 + i) / Total; StateHasChanged(); await Job.Invoke(i); } Progress = 0; } -
在ProgressIndicator标记中,构建一个按钮,用户可以通过调用RunAsync()方法来调用处理。检查Progress变量的当前值以条件性地禁用动作按钮,并利用Label属性动态生成按钮标签:
<button class="btn btn-sm btn-success" @onclick="@RunAsync" disabled="@(Progress > 0)"> @Label </button> -
导航到TicketManager组件,到其@code块,并实现一个简单的SaveAsync()方法,利用提供的数据样本的Tickets.SaveAsync():
public Task SaveAsync(int index) => Tickets.SaveAsync(Tickets.All[index]); -
在TicketManager标记中,在渲染模式声明下方,使用SaveAsync()和Tickets.All.Count分别附加到Job和Total参数上嵌入ProgressIndicator:
<ProgressIndicator Job="SaveAsync" Total="@Tickets.All.Count" /> -
仍然在TicketManager标记中,移除允许切换票据的按钮和ShowTickets复选框。
-
现在,在TicketManager标记中找到foreach循环,其中渲染每个票据容器,并将其包裹在LoadingIndicator组件内部。将Tickets.GetAsync()方法附加到LoadingIndicator所需的Job参数:
<LoadingIndicator Job="@(() => Tickets.GetAsync())"> <hr /> @foreach (var ticket in Tickets.All) { @* here's still the ticket container markup *@ } </LoadingIndicator>
它是如何工作的…
在步骤 1中,我们创建一个新的LoadingIndicator组件。在步骤 2中,我们初始化LoadingIndicator组件的@code块并声明两个关键参数:Func
在第 6 步中,我们创建了一个具有不同类型指示器的组件 – ProgressIndicator。在第 7 步中,我们初始化@code块并定义一个Job参数 – 允许我们定义一个要运行的操作 – 以及一个Total参数 – 表示操作必须经历的迭代次数。Job签名有效地抽象了任何异步操作,同时也确保操作接受一个int参数,表示当前执行迭代的索引。在第 8 步中,我们初始化一个Progress变量,我们将用它来监控实际的执行进度,从 0%到 100%。在第 9 步中,我们实现了一个Label属性。通过简单的逻辑,基于当前的Progress值,我们生成一个Process调用操作或实际的进度处理。在第 10 步中,我们通过实现核心的RunAsync()方法来完成@code块。在RunAsync()中,我们从0循环到Total,并对每个索引调用Job委托,同时持续更新Progress值。当处理完成时,我们将Progress值重置为一个中性的0。在第 11 步中,我们构建ProgressIndicator标记。我们构建一个按钮,允许我们通过在@onclick事件上触发RunAsync()来启动处理。我们还根据Progress值禁用动作按钮,以防止正在运行的操作重新排队,类似于LoadingIndicator组件中的动作按钮。最后,为了提供实时的进度反馈,我们利用Label属性在按钮上渲染文本。现在,当操作正在运行时,Blazor 不仅会禁用按钮,还会在它上面渲染当前的进度。
在步骤 12中,我们导航到TicketManager组件的@code块并实现一个SaveAsync()方法。SaveAsync()方法只是一个代理方法,允许拦截当前迭代索引,在Tickets.All集合中找到相关的票据对象,并将其传递以保存。在步骤 13中,我们跳转到TicketManager标记,并在顶部嵌入ProgressIndicator。由于有SaveAsync()方法,我们可以将其附加到需要Job参数的ProgressIndicator组件。对于其他必需参数——Total——我们计算Tickets.All集合中的对象数量。有了这样的设置,ProgressIndicator允许用户触发每个票据的保存并看到操作进度。在步骤 14中,我们移除允许切换票据和相关ShowTickets复选框的按钮。我们将不再需要它们,因为我们将会将显示Tickets.All集合的控制权委托给LoadingIndicator。在步骤 15中,我们定位到渲染票据容器的循环。我们将整个区域包裹在LoadingIndicator组件内部。由于LoadingIndicator需要一个Job委托,我们利用 lambda 表达式并附加Tickets.GetAsync()方法。现在,当用户请求加载数据时,LoadingIndicator渲染Loading…消息并无缝触发Tickets.GetAsync()。当加载完成时,LoadingIndicator组件使用一组新的票据容器更新 UI。
还有更多...
你可能已经意识到加载和进度指示器适用于哪些场景,但让我给你一个简单的经验法则。
任何加载指示器都非常适合具有不可预测完成时间的操作,例如从 API 获取数据,其中结果的数量和到达时间都是未知的。
进度指示器,如进度条,非常适合已知结果的操作,例如提交数据更改或发送通知。
第四章:使用网格增强数据展示
在本章中,我们将通过实现高级网格功能来深入探讨 Blazor 应用程序中的数据展示。从将传统表格重构为更动态的网格组件的基本任务开始,我们将探讨将交互式操作附加到网格的各个部分(如单元格中的按钮或链接)的重要性,从而增强用户参与度和操作效率。
我们还将涵盖分页技术,以有效地管理大型数据集,并探讨无限滚动作为传统分页的现代替代方案。此外,我们将逐步创建一个可定制的网格,提供灵活性以适应特定应用程序的需求。最后,我们将讨论QuickGrid——一个具有预定义功能集、可立即使用的 Blazor 网格组件,这是您可以利用的最快、最简单的数据网格选项。
到本章结束时,您将具备增强 Blazor 应用程序中数据展示的知识,通过有效使用网格来提升数据展示的美观性和功能性。
本章我们将遵循以下食谱:
-
将表格重构为网格组件
-
将操作附加到网格的各个部分
-
实现分页
-
实现排序
-
实现无限滚动
-
利用 QuickGrid
技术要求
我们将在所有示例中保持简洁,以促进理解和学习。我们将使用相同的数据集来展示所有食谱,以便您可以看到不同技术方面与网格组件协同工作的影响。不需要外部工具,但以下是一些基本要求:
-
一个支持 Blazor 开发的现代 IDE
-
在您的开发机器上安装 .NET 9
-
一个支持 WebAssembly 的现代网络浏览器
-
一个 Blazor 项目(您将在其中边做边写代码)
您将看到的所有代码示例(和数据样本)都可以在专门的 GitHub 仓库中找到:github.com/PacktPublishing/Blazor-Web-Development-Cookbook/tree/main/BlazorCookbook.App.Client/Chapters/Chapter04 。在需要任何样本的每个食谱中,我还会指向您可以找到它们的目录。
将表格重构为网格组件
在这个食谱中,我们将探讨开发可重用网格组件的基本原理。网格是设计直观和组织良好的用户界面的基石,它能够实现结构化数据展示。从使用基本表格到实现可重用网格组件的转变,是朝着实现模块化、可维护和可扩展的前端架构的战略举措。这样的组件可以适应应用程序的不同部分,确保一致性并减少代码中的冗余。
让我们从基础知识开始,将现有的标准 HTML 表格重构为一个组件化的网格。
准备就绪
在我们深入探索网格和重构标记之前,让我们先准备好舞台:
-
创建Chapter04 / Recipe01目录——这将是你的工作目录
-
从 GitHub 仓库的Chapter04 / Data目录复制Samples和HtmlGrid文件
如何做到这一点…
按照以下步骤将标准 HTML 标记重构为模块化网格组件:
-
定位HtmlGrid组件。将HtmlGrid重命名为Grid,并通过在文件顶部添加@typeparam属性将其转换为通用版本:
@typeparam T -
在Grid组件内添加一个@code部分。声明三个关键参数:Header和通用的Data以及Row,允许动态内容渲染:
@code { [Parameter, EditorRequired] public List<T> Data { get; set; } [Parameter, EditorRequired] public RenderFragment Header { get; set; } [Parameter, EditorRequired] public RenderFragment<T> Row { get; set; } } -
修改表格标题单元格标记以利用Header参数,代表一个灵活的模板:
<thead> @Header </thead> -
修订负责渲染表格体的循环。而不是使用固定数据集,遍历通过参数提供的Data集合。同样,用Row模板参数替换静态行单元格:
<tbody> @foreach (var element in Data) { @Row(element) } </tbody> -
创建一个新的可路由的TicketManager组件,并将新模块化的Grid组件嵌入到标记区域。利用提供的Tickets.All样本数据作为Grid的数据源:
@page "/ch04r01" <Grid Data="@Tickets.All"> @* we will construct the grid body next @* </Grid> -
通过从原始原始表格中提取标题区域来声明嵌入网格的Header标记:
<Header> <tr> <td>Tariff</td> <td>Price</td> </tr> </Header> -
通过从原始原始表格中提取行标记来构建嵌入网格的Row标记:
<Row> <tr> <td>@context.Tariff</td> <td>@context.Price</td> </tr> </Row>
它是如何工作的…
在步骤 1中,我们通过将HtmlGrid重命名为Grid开始实现模块化网格。然后,我们在文件顶部添加@typeparam T属性,将Grid组件转换为通用组件。如果你之前没有见过通用组件,我们已经在第一章中的使组件通用配方中探讨了该主题。在步骤 2中,我们声明了三个必需的参数。通过通用的Data集合和通用的Row模板,我们能够动态渲染任何对象作为表格行。通过Header,我们可以动态提供表格标题设置,而不依赖于任何固定布局。在步骤 3中,我们利用Header参数对表格的thead内容进行模块化,从而使得表格标题完全可定制。在步骤 4中,我们配置表格体渲染。我们遍历Data集合,并利用类型感知的Row模板动态渲染提供的模板的表格行。
在步骤 5中,我们添加了一个新的可路由TicketManager组件。我们展示了新的模块化Grid组件,并将其嵌入到TicketManager的标记区域中。我们使用Tickets.All数据集样本作为Grid实例的数据源。在步骤 6中,我们通过重新利用原始表格标题来构建Header标记。在步骤 7中,我们对Row标记做同样的处理。然而,对于Row,这里不需要实现循环——Grid组件已经遍历了提供的数据集。
这种模块化方法不仅简化了实现,还确保了网格保持高度可定制性和适应各种数据类型。我们实际上只简化了渲染网格行的循环机制,但展示将 HTML 表格拆分为模块化部分背后的思考过程很重要。理解这一点使我们能够在接下来的菜谱中进一步扩展网格概念。
还有更多……
在 Blazor 应用程序中将网格模块化可以提高灵活性和可重用性,但你必须考虑这种做法可能带来的潜在渲染开销,尤其是在交互式网格中。每一次用户交互都可能激活差异算法(我们在第三章 中提到了差异算法)并触发重新渲染,这可能会根据你逻辑的复杂性显著影响性能。在组件化你的网格时找到平衡至关重要——实现足够的模块化以保持灵活性,同时不要过度复杂化你的组件。策略性地放置 API 调用和审慎使用静态RenderFragment实例可以帮助管理性能影响。
提高网格性能的有效策略是利用@key Blazor 属性。此属性帮助 Blazor 的差异算法更有效地识别元素,通过将每个网格行或组件与一个唯一标识符关联,减少不必要的 DOM 更新。如果我们假设我们期望网格中只有行级变化,那么我们可以利用Ticket对象的Id属性,并按以下方式附加@key:
<Grid Tickets="@Tickets.All">
@* ... *@
<Row>
<tr @key="context.Id">
<td>@context.Tariff</td>
<td>@context.Price</td>
</tr>
</Row>
</Grid>
当 Blazor 能够将 DOM 元素与支持数据对象相关联时,它可以智能地决定何时重新渲染实际上是必要的,何时可以跳过更新 DOM 的某些部分。通过使用@key属性,你不仅提高了网格的渲染性能,还确保了更流畅的用户体验,尤其是在数据密集型场景中,网格的内容经常发生变化。
将操作附加到网格的部分
交互式网格在增强前端应用程序的用户体验中起着关键作用,使用户能够以直观和高效的方式与数据交互和操作。通过将动作附加到网格的某些部分,你可以显著提高网格的功能性,为高级功能如排序、过滤和动态数据管理铺平道路。我们在第三章中探讨了动作和事件在 Blazor 中的相关性。在本食谱中,你将了解在网格组件中集成可操作元素的技巧和最佳实践。有效地将动作附加到网格部分不仅丰富了用户界面,还为用户提供了与应用程序交互时的无缝体验。
让我们实现一个表格,允许你为其列附加一个动作,当用户点击时 Blazor 将执行该动作,并重构网格以使其更灵活。
准备工作
在深入制作交互式列和行之前,请执行以下操作:
-
创建一个Chapter04 / Recipe02目录——这将是你的工作目录
-
从重构表格为网格组件食谱或从 GitHub 仓库中的Chapter04 / Recipe01目录复制Grid和TicketManager:
-
从 GitHub 仓库中的Chapter04 / Data目录复制Samples:
如何完成它...
要实现交互式表格列和行,请按照以下步骤操作:
-
创建一个新的、通用的ColumnViewModel类,具有Label、Template和OnSort属性:
public class ColumnViewModel<T> { public string Label { get; init; } public RenderFragment<T> Template { get; init; } public EventCallback OnSort { get; init; } } -
导航到Grid组件,并在@typeparam指令下方添加一个属性,指示Grid组件的泛型类型应级联到子组件:
@typeparam T @attribute [CascadingTypeParameter(nameof(T))] -
在Grid组件的@code块中,删除Row参数,并将Header参数重命名为ChildContent。你已经有Data集合,你还需要它:
@code { [Parameter, EditorRequired] public List<T> Data { get; set; } [Parameter, EditorRequired] public RenderFragment ChildContent { get; set; } } -
在参数下方,初始化一个Columns集合,包含类型为ColumnViewModel的对象,并实现一个AddColumn()方法,允许你向内部集合添加新的列:
protected List<ColumnViewModel<T>> Columns = []; public void AddColumn(ColumnViewModel<T> column) => Columns.Add(column); -
最后,在@code块的最后,重写OnAfterRender()生命周期方法,以确保在所有嵌套组件渲染完成后 Blazor 重新渲染Grid:
protected override void OnAfterRender( bool firstRender) { if (firstRender) StateHasChanged(); } -
移动到Grid标记,并用一个循环替换现有的表头,该循环基于Columns集合中的对象构建列头:
<thead> <tr> @foreach (var column in Columns) { <th @onclick="@column.OnSort"> @column.Label </th> } </tr> </thead> -
仍然在Grid标记内,在表格体区域,嵌套另一个foreach循环,在其中渲染Data集合中所有元素的每个列模板:
<tbody> @foreach (var element in Data) { <tr> @foreach (var column in Columns) { <td>@column.Template(element)</td> } </tr> } </tbody> -
为了完成标记,添加一个CascadingValue标记以与所有可能包含的嵌套组件共享当前的Grid实例:
<CascadingValue Value="this"> @ChildContent </CascadingValue> -
创建一个新的通用Column组件,其中包含一个@code块,在该块中拦截对Grid实例的级联引用,并允许传递Label、ChildContent和OnSort参数:
@typeparam T @code { [CascadingParameter] public Grid<T> Grid { get; set; } [Parameter, EditorRequired] public string Label { get; set; } [Parameter, EditorRequired] public RenderFragment<T> ChildContent { get; set; } [Parameter] public EventCallback OnSort { get; set; } } -
仍然在Column组件的@code块中,重写OnInitialized()生命周期方法以将Column参数转换为ColumnViewModel并将model实例传递给父Grid组件:
protected override void OnInitialized() { var model = new ColumnViewModel<T> { Label = Label, Template = ChildContent, OnSort = OnSort }; Grid.AddColumn(model); } -
导航到TicketManager组件并初始化一个@code块以实现一个Sort()占位符方法,你只需记录意图:
@code { private void Sort(string prop) => Console.WriteLine($"Sorted by {prop}!"); } -
在TicketManager标记中,用通过Column组件渲染的列替换不再兼容的Grid内容:
<Column OnSort="@(() => Sort(nameof(Ticket.Tariff)))" Label="Tariff"> @context.Tariff </Column> <Column OnSort="@(() => Sort(nameof(Ticket.Price)))" Label="Price"> @context.Price </Column> -
最后,增强TicketManager组件以在InteractiveWebAssembly模式下渲染:
@rendermode InteractiveWebAssembly
它是如何工作的……
在步骤 1中,我们创建了一个通用的ColumnViewModel类。ColumnViewModel包含三个属性:Label,表示列的标题;Template,表示列中每个数据点要渲染的标记;以及OnSort,当用户点击列标题时触发的回调。使用ColumnViewModel,你可以简化网格中列的定义,而无需显式传递所有列属性。
在步骤 2中,我们导航到Grid组件并执行一些重构以使其构建更加动态。虽然Grid组件已经是通用的,但我们接下来将处理级联值,并希望 Blazor 自动将这些值类型向下传递到组件树。为了实现这种后代共享,我们利用CascadingTypeParameter属性。CascadingTypeParameter允许在组件树中共享通用类型。我们不是将通用类型"T"作为字符串传递,而是使用nameof()方法,以实现相同的结果同时保持编译时验证。
在步骤 3中,我们更改了Grid所需的参数。我们移除了Row参数,因为我们将数据点模板移动到ColumnViewModel。由于Grid组件现在只需要一个RenderFragment参数,我们将Header重命名为ChildContent,以便稍后简化网格的构建。
在步骤 4中,我们添加了一个Columns集合,它将作为我们将要渲染的网格列的容器。为了填充这个集合,我们公开了一个AddColumn()方法,它接受一个ColumnViewModel对象并将其添加到Columns。
在步骤 5中,我们重写了Grid组件的OnAfterRender()生命周期方法。这指示 Blazor 在初始渲染完成后立即重新渲染Grid组件。现在这可能会显得有些反直觉,但当我们稍后实现Column组件时,它将更有意义。
在步骤 6中,我们调整网格标记以符合@code块中的更改。由于我们已移除标题参数,我们重建表格标题区域。我们通过显式嵌入tr标签并渲染Columns集合中列的标签属性来重构thead内容。我们还将每个列声明的排序操作附加到每个th元素的@onclick事件。您将操作附加到网格元素的方式与任何其他 HTML 元素相同。
在步骤 7中,我们重构表格主体。我们将(我们已移除的)行引用替换为在遍历数据集合的循环中显式的tr标签。在每个tr内部,我们嵌套另一个循环,指示 Blazor 使用当前列的模板属性中的模板渲染每个数据元素。
在步骤 8中,我们通过构建级联值区域来完成网格标记,在该区域中,我们与嵌套组件共享当前网格实例。我们还需要列组件来理解这部分,因此我们将在下一步实现它。
在步骤 9中,我们创建一个通用的列组件,它将成为网格的主要构建元素。列组件拦截其渲染的网格实例,并需要标签和子内容参数。标签参数定义列标题,而子内容代表属于该列的数据点的模板。此标记将为网格中数据集合的每个元素渲染。我们还声明了一个可选参数OnSort,允许通过点击列标题附加排序行为。
在步骤 10中,我们通过重写OnInitialized()生命周期方法来完成列的实现,在该方法中,我们将传入的参数转换为ColumnViewModel对象,然后使用之前实现的AddColumn()方法将其注册到网格组件中。列组件按设计是无标记的——它不会显式渲染任何标记。相反,它直接在网格实例中注册行模板和列定义,该实例知道如何根据这些详细信息构建表格标记。
在步骤 11中,我们导航到票务管理器组件。首先,我们初始化一个@code块,在其中实现一个Sort()方法——一个仅记录操作意图的行为占位符(我们将在单独的步骤中实现排序)。
在步骤 12中,我们利用Column组件重构网格内容。由于所有网格元素都是通用的,并且Grid组件将泛型参数类型向下传递,我们可以使用context引用访问Ticket属性。了解这一点后,我们构建了第一个带有Tariff标题的列,并声明对于每个数据点,我们想要渲染当前元素的Tariff属性的值。我们还声明Column暴露的OnSort回调将触发Tariff属性的Sort()方法。对于第二列,我们对Price属性重复这些步骤。
在步骤 13中,由于我们期望网格是交互式的,我们声明TicketManager组件将以InteractiveWebAssembly模式渲染。
现在,随着整个实现的完成,理解增强的Grid组件的渲染将更容易。如您所见,我们用于构建网格的Column组件不携带任何标记,因此在 DOM 中将完全透明。然而,Column仍然需要级联访问Grid实例,这就是为什么我们在步骤 8中将所有可定制的Grid内容放入CascadingValue标签中。有了这个,每个Column实例可以直接在Grid实例中注册它携带的渲染模板,因此它与Grid标记一起渲染。这也是为什么我们在步骤 5中覆盖了Grid组件的OnAfterRender()生命周期方法。我们必须在网格的初始渲染之后以及所有Column实例在Grid实例中注册其有效负载之后重新渲染表格标记。
还有更多...
在某些场景中,您可能需要将操作附加到整个网格行和特定的网格单元格。当一行与单元格重叠时,您将面临事件冒泡。当浏览器中发生事件,如鼠标点击或按键时,它从目标元素通过其祖先传播(或冒泡),导致父元素中监听相同事件的不当行为。使用 Blazor 的@onEvent:stopPropagation属性,您可以防止这种传播,确保只有预期的处理程序执行:
<tr @onclick="@ShowTicketDetails">
<td>@ticket.Tariff</td>
<td @onclick="@AddToCart"
@onclick:stopPropagation>
@ticket.Price
</td>
</tr>
在这个代码片段中,我们允许用户在点击表格行时显示票务详情,在点击带有价格的单元格时将票务添加到购物车。我们已经将所需的事件处理程序附加到tr和td元素上。此外,我们还将@onclick:stopPropagation属性附加到带有价格的td上。现在,我们阻止点击事件传播到父行。因此,当用户点击单元格时,Blazor 只执行AddToCart()处理程序,并省略ShowTicketDetails()处理程序。通过@onclick:stopPropagation,我们确保点击事件仅由单元格处理,不会影响周围的行元素。
实现分页
分页是指将内容分成单独的页面,这对于显示大量数据的表格和网格尤其重要。这种方法提高了数据的可读性和可导航性,并通过减少在任何给定时间内加载和渲染的数据量来显著提高性能。在表格和网格中,分页通常需要有效地管理大量数据,防止用户一次性接收过多信息而感到不知所措,并确保应用程序保持响应。
让我们给网格添加简单的分页。
准备工作
在我们深入之前,请确保你做了以下事情:
-
创建一个Chapter04/Recipe03目录——这将是你的工作目录
-
从将操作附加到网格的一部分配方或 GitHub 仓库中的Chapter04/Recipe02目录复制Column、ColumnViewModel、Grid和TicketManager:
-
从 GitHub 仓库中的Chapter04/Data目录复制Samples:
如何做到这一点…
要将分页添加到你的Grid组件中,请按照以下步骤操作:
-
创建一个新的带有Page和Size属性的PaginateEventArgs记录:
public record PaginateEventArgs(int Page, int Size); -
添加一个新的Paginator组件,并使用两个必需参数初始化一个@code块:一个带有PaginateEventArgs和DataSize的Paginate回调:
[Parameter, EditorRequired] public EventCallback<PaginateEventArgs> Paginate { get; set; } [Parameter, EditorRequired] public int DataSize { get; set; } -
在参数下方,初始化定义分页状态的变量:TotalPages、CurrentPage和PageSize,并使用默认的初始值:
protected int TotalPages, CurrentPage = 1, PageSize = 5; -
在状态变量旁边,重写OnInitialized()生命周期方法并计算TotalPages值:
protected override void OnInitialized() => TotalPages = (DataSize + PageSize - 1) / PageSize; -
仍然在@code块内,实现一个LoadAsync()方法,调用Paginate回调并传递当前的分页状态:
private Task LoadAsync() { var state = new PaginateEventArgs( CurrentPage, PageSize ); return Paginate.InvokeAsync(state); } -
在加载方法下方,定义一个NextAsync()方法以启用数据页面的前进导航:
private async Task NextAsync() { if (CurrentPage == TotalPages) return; CurrentPage++; await LoadAsync(); } -
类似地,在前进导航旁边,实现一个PreviousAsync()方法来处理数据页面的向后导航:
private async Task PreviousAsync() { if (CurrentPage == 1) return; CurrentPage--; await LoadAsync(); } -
通过重写OnAfterRenderAsync()生命周期方法并加载初始数据页面来完成@code块,在第一次渲染之后:
protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) await LoadAsync(); } -
移动到Paginator标记区域,并构建一个包含两个用于页面导航的button元素和一个用于显示CurrentPage值的input字段的容器:
<div class="d-flex"> <button @onclick="@PreviousAsync"> Previous </button> <input disabled class="text-center" value="@CurrentPage" /> <button @onclick="@NextAsync"> Next </button> </div> -
导航到Grid组件,并在@code块内初始化一个通用的Set集合以持久化当前显示的数据:
protected IEnumerable<T> Set = []; -
在Set初始化旁边,实现一个接受PaginationEventArgs参数的LoadAsync()方法,该方法根据传入的分页状态详细信息从Data集合中获取数据的一个片段:
public Task LoadAsync(PaginateEventArgs args) { Set = Data .Skip((args.Page - 1) * args.Size) .Take(args.Size); return Task.CompletedTask; } -
跳转到Grid组件的标记,并更新生成表格行的循环以遍历Set集合:
<tbody> @foreach (var element in Set) { @* nested loop through Columns *@ } </tbody> -
仍然在 Grid 标记的表格下方嵌入 Paginator 组件,将其 LoadAsync() 方法附加到其 Paginate 回调,并将 Data 集合的大小作为 DataSize 参数传递:
<hr /> <Paginator Paginate="@LoadAsync" DataSize="@Data.Count"/>
它是如何工作的…
在 步骤 1 中,我们创建了一个带有 Page 和 Size 属性的 PaginateEventArgs 记录,分别表示当前可见的页面和用户查看的每页大小。拥有这些详细信息使我们能够有效地按预期批次获取数据。由于我们期望 PaginateEventArgs 表示一个分页事件,因此使该对象不可变是有意义的,所以我们将其声明为 record 对象。为了简化 PaginateEventArgs 的初始化,我们还利用了主构造函数而不是传统的构造函数和显式属性声明。
在 步骤 2 中,我们引入了一个 Paginator 组件来封装网格分页逻辑。首先,我们在 Paginator 内初始化一个 @code 块。我们声明一个返回 PaginateEventArgs 的 Paginate 回调,以通信页面导航更改。我们还声明了一个 DataSize 参数。知道要分页的数据量使我们能够通过设置用户可以达到的最大页数来改进分页体验。
在 步骤 3 中,我们初始化了三个状态属性:TotalPages,表示分页导航器应该停止的位置;CurrentPage,表示用户正在查看的当前页面;以及 PageSize,定义我们允许每页加载的元素数量。对于 CurrentPage,我们将初始值设置为 1,因为我们自然从第一页开始。我们还固定 PageSize 为 5,以便我们能够专注于分页行为。
在 步骤 4 中,我们重写了 Paginator 的 OnInitialized() 生命周期方法,根据传入的 DataSize 参数和 PageSize 变量计算 TotalPages 值。我们实现了最简单的算术计算,该计算始终向上取整到下一个整数,当 DataSize 和 PageSize 的除法结果为奇数时,这表明最后一页不是满的。
在 步骤 5 中,我们实现了一个 LoadAsync() 方法,这是分页请求通信的核心。每次 Blazor 调用 LoadAsync() 时,我们都会从 CurrentPage 和 PageSize 变量的当前值创建一个 PaginationEventArgs 实例,并将其异步传递给 Paginate 回调,以便回调消费者进行解释。
在 步骤 6 中,我们通过实现一个 NextAsync() 方法构建了 Paginator 导航能力的第一部分。NextAsync() 允许用户获取下一页的数据 – 我们检查用户是否已经在最后一个可用的页面上,以防止进一步的导航;如果不是,我们增加 CurrentPage 并调用 LoadAsync() 方法。
在 步骤 7 中,我们构建 NextAsync() 的对应方法,PreviousAsync()。PreviousAsync() 方法允许用户向后导航并获取前一个数据集。为了防止用户导航得太远,我们检查 CurrentPage 是否已经是第一页。如果不是,我们减少 CurrentPage 并调用 LoadAsync()。
我们必须覆盖的最后一件事是数据的初始加载。在 步骤 8 中,我们重写了 Paginator 的 OnAfterRenderAsync() 生命周期方法。在第一次渲染后,我们调用 LoadAsync() 指示 Blazor 加载定义的初始页面,并带有指定的元素数量。
在 步骤 9 中,我们构建 Paginator 标记。我们构建了一个原始的条形,包含两个按钮,分别允许使用 PreviousAsync() 和 NextAsync() 进行前后导航。我们还添加了一个禁用的输入字段,显示基于 CurrentPage 变量的当前页。
在 步骤 10 中,我们转向 Grid 组件并增强它以符合 Paginator 和分页。首先,我们关注 @code 块并声明一个通用的 Set 集合来存储当前获取的数据集。
在 步骤 11 中,我们实现了一个 LoadAsync() 方法,该方法消费 PaginateEventArgs 并响应 Paginator 回调。在 LoadAsync() 内部,我们使用 LINQ 方法 从 Data 中仅加载所需元素。我们使用 Skip() 方法跳过用户已经看到的元素。由于 Paginator 组件从 1 开始计数页面,而集合索引从 0 开始,所以我们减去 args.Page 的值 1,然后乘以 args.Size 以获取从 Data 集合开始要跳过的元素数量。然后,我们使用 Take() 方法获取所需数量的元素。
在 步骤 12 中,我们跳转到 Grid 标记,找到迭代 Data 元素的循环,并将循环更新为使用 Set。
最后,在 步骤 13 中,我们将 Paginator 组件嵌入到 Grid 标记中。我们将 LoadAsync() 方法附加到 Paginate 回调,并计算 Data 元素以提供所需的 DataSize 参数。
仅用少量代码,我们就实现了完全功能和通用的分页功能。

图 4.1:带有功能分页条的网格加载
还有更多...
在分页实现中,我们有一些返回Task的方法,我们没有将其声明为async,而是返回了Task.CompletedTask对象。当我们在方法内部不执行异步操作但必须遵守异步方法签名时,这种方法是有益的。在这种情况下返回Task.CompletedTask更高效,因为我们避免了编译器为async方法生成的异步状态机的开销。通过不等待Task并简单地返回Task.CompletedTask,我们最小化了与任务调度和上下文切换相关的性能成本。
相关内容
在这个菜谱中,我们还看到了 LINQ 方法的应用。LINQ 方法本身就可以写成一本书,所以如果你想探索这个话题,请访问learn.microsoft.com/en-us/dotnet/csharp/linq/。
实现排序
在这个菜谱中,我们深入探讨如何在网格中组织数据,通过根据列值排列行来实现。排序允许用户根据相关标准(如字母顺序、数值、日期或自定义参数)优先排序数据,从而轻松地导航和分析数据。在处理大量数据集的应用程序中,这种能力变得越来越重要,如果没有有效的排序机制,查找特定信息或理解数据趋势可能会变得很麻烦。通过引入排序功能,开发者可以显著提升用户体验,提供直观的交互和数据洞察。
让我们增强网格,添加用户可以通过点击网格列标题触发的排序功能。
准备工作
在我们探索网格中的排序之前,请执行以下操作:
-
创建一个Chapter04 / Recipe04目录——这将是你的工作目录。
-
从Implementing pagination菜谱或从 GitHub 仓库的Chapter04 / Recipe04目录复制Column、ColumnViewModel、Grid、PaginateEventArgs、Paginator和TicketManager。
-
从 GitHub 仓库的Chapter04 / Data目录复制Samples。
如何实现...
按以下步骤向网格添加排序:
-
导航到ColumnViewModel类,并将OnSort回调替换为Property委托,封装从泛型模型中选择属性的逻辑:
public Func<T, object> Property { get; init; } -
前往Column组件的@code块,并将OnSort参数替换为Property委托参数,允许你从泛型模型传递属性选择器:
[Parameter] public Func<T, object> Property { get; set; } -
仍然在Column组件中,通过更新ColumnViewModel构造函数以利用Property参数来修复重写的OnInitialized()方法:
var model = new ColumnViewModel<T> { Label = Label, Template = ChildContent, Property = Property }; -
移动到Grid组件。在@code块的末尾,声明变量以持久化当前排序列和顺序:
private string _currentSortColumn; private bool _isAsc; -
在排序状态变量下方,添加一个 PaginatorRef 变量,以便在 Grid 代码中引用 Paginator 组件:
protected Paginator PaginatorRef; -
通过实现一个 SortAsync() 方法来完成 Grid 组件的 @code 块,允许你根据为每个 ColumnViewModel 列对象设置的 Property 选择器动态地对 Data 集合进行排序:
public Task SortAsync(ColumnViewModel<T> column) { if (_currentSortColumn == column.Label) _isAsc = !isAsc; else _isAsc = true; Comparison<T> comparer = (left, right) => { var result = Comparer<object>.Default .Compare(column.Property(left), column.Property(right)); return _isAsc ? result : -result; }; Data.Sort(comparer); _currentSortColumn = column.Label; return PaginatorRef.LoadAsync(); } -
在 Grid 标记中,将附加到表格列标题的委托替换为新实现的 SortAsync():
<th @onclick="@(() => SortAsync(column))"> @column.Label </th> -
仍然在 Grid 标记内,找到 Paginator 实例并将其引用附加到 PaginatorRef 变量:
<Paginator @ref="@PaginatorRef" Paginate="@LoadAsync" DataSize="@Data.Count" /> -
导航到 TicketManager 组件,并通过传递 Property 选择器和定义要排序的 Ticket 属性来修复 Column 实例:
<Column Property="@(it => it.Tariff)" Label="Tariff"> @context.Tariff </Column> <Column Property="@(it => it.Price)" Label="Price"> @context.Price </Column>
它是如何工作的…
在 步骤 1 中,我们更新 ColumnViewModel 类,并将 OnSort 回调替换为泛型 Func<T, object>。Func<T, object> 是一个表示从给定类型 T 返回对象的委托。我们使用 Func<T, object> 作为排序属性的选择器,并直观地命名为 Property。
在 步骤 2 中,我们跳转到 Column 组件,使用与 ColumnViewModel 相同的逻辑更新 Grid 构建块。在 Column 的 @code 块内部,我们将 OnSort 回调替换为 Func<T, object> 参数。在 步骤 3 中,我们在重写的 OnInitialized() 方法中修复映射,将排序属性选择器传递给 ColumnViewModel 构造函数,从而传递给 Grid 实例。
在 步骤 4 中,我们导航到 Grid 组件并实现排序功能的后端逻辑。首先,我们声明两个变量来表示排序的当前状态:_currentSortColumn,表示当前选中的排序属性,以及一个 _isAsc 标志,表示排序顺序是升序还是降序。
在 步骤 5 中,我们引入一个类型为 Paginator 的 PaginatorRef 变量。一开始可能会有些困惑。在 Blazor 的 C# 代码中使用组件作为变量允许你与组件的公共 API 进行交互。此外,使用 @ref 属性,你可以捕获渲染组件的引用并利用其方法和属性。但是 @ref 有一个主要限制——引用仅在组件渲染完成后才会填充。由于 Blazor 的渲染过程是异步的,在组件初始化后立即尝试使用引用可能会失败,因为引用可能尚未可用。因此,你必须确保仅在组件渲染周期完成后访问绑定到 @ref 的引用。
在步骤 6中,我们实现了一个SortAsync()方法,这是我们的排序逻辑的核心。SortAsync()方法需要一个ColumnViewModel对象来定义要执行的排序。首先,我们通过检查当前排序列标签是否与用户选择的标签匹配来确定排序顺序。如果它们匹配,这表明用户试图反转排序顺序,因此我们翻转当前_isAsc的值。否则,我们将其设置为升序,这是预期的初始行为。接下来,我们利用一个通用的Comparison C#对象。Comparison
在步骤 7中,我们跳转到Grid标记,定位到渲染每个表格列标题的表头区域,并将SortAsync()方法附加到带有当前column引用的@onclick事件处理器。在步骤 8中,我们向下滚动到构建Paginator实例的位置,并借助@ref属性,将Paginator实例附加到组件代码部分中的PaginatorRef变量。
在所有排序增强之后,TicketManager组件不再兼容。在步骤 9中,我们转向TicketManager标记,并通过为每个渲染的列声明带有 lambda 表达式的Property委托来更新Column实例。
实现无限滚动
在用户体验趋势中,从传统的分页方式转向了更动态和流畅的无缝滚动方式。虚拟化(Virtualize),集成到 Blazor 框架中,旨在通过在用户滚动页面时按需加载内容来增强用户界面。它通过仅渲染视口中的项目并在需要时获取额外内容来智能管理资源,显著提高了性能和用户体验,尤其是在处理大量数据集的应用程序中。通过使用Virtualize组件实现无限滚动,您可以提供一个更平滑、更吸引人的交互模式,消除手动页面导航的需求,使内容浏览变得轻松。
让我们构建一个简单的网格并实现无限滚动,利用 Virtualize 组件。
准备工作
为了简化网格本身并专注于无限滚动实现,我们不会利用之前食谱中内置的任何网格标记,而是从头开始。但在你深入之前,请执行以下操作:
-
创建一个 Chapter04 / Recipe05 目录 – 这将是你的工作目录
-
从 GitHub 仓库中的 Chapter04 / Data 目录复制 Samples
-
导航到应用程序的 Program 文件并注册来自 Samples 的 TicketsApi 服务,在应用程序依赖注入容器中:
builder.Services.AddScoped<TicketsApi>();
如何做到这一点…
按照以下步骤向网格添加无限滚动:
-
使用 typeparam 属性创建一个泛型 Grid 组件:
@typeparam T -
在 Grid 组件的 @code 块内部,声明三个必需的参数:
[Parameter, EditorRequired] public Func<int, int, CancellationToken, Task<(int, List<T>)>> Provider { get; set; } [Parameter, EditorRequired] public RenderFragment Header { get; set; } [Parameter, EditorRequired] public RenderFragment<T> Row { get; set; }三个参数是:
-
Provider – 封装数据获取的代理
-
Header – 表格表头模板的 RenderFragment
-
Row – 表格行模板的泛型 RenderFragment
-
-
在参数下方,实现一个 LoadAsync() 方法来处理动态数据加载;接受 ItemsProviderRequest 作为输入并返回一个泛型 ItemsProviderResult 对象:
private async ValueTask<ItemsProviderResult<T>> LoadAsync(ItemsProviderRequest request) { (var total, var data) = await Provider .Invoke(request.StartIndex, request.Count, request.CancellationToken); return new(data, total); } -
移动到 Grid 组件的标记区域并构建一个表格:将 Header 模板嵌入到 标签内,对于 部分,使用 Virtualize 组件,通过其 ItemsProvider 参数将其链接到 LoadAsync() 方法,并将 Row 模板作为其 ChildContent 参数传递:
<table class="table table-bordered"> <thead> @Header </thead> <tbody> <Virtualize ItemsProvider="@LoadAsync"> @Row(context) </Virtualize> </tbody> </table> -
创建一个可路由的 TicketManager 组件。将 TicketManager 设置为以 InteractiveWebAssembly 模式渲染并注入 TicketsApi :
@page "/ch04r05" @rendermode InteractiveWebAssembly @inject TicketsApi Tickets -
在 TicketManager 标记内,包含新创建的 Grid 组件。将 Tickets.GetAsync() 方法附加到 Provider 参数,并定义 Header 和 Row 模板以在网格中渲染 Ticket 属性:
<Grid Provider="@Service.GetAsync"> <Header> <tr> <td>Id</td> <td>Tariff</td> <td>Price</td> </tr> </Header> <Row> <tr> <td>@context.Id</td> <td>@context.Tariff</td> <td>@context.Price</td> </tr> </Row> </Grid>
它是如何工作的…
在 步骤 1 中,我们创建了一个泛型 Grid 组件,它作为动态以无限滚动能力显示表格格式的数据的基础。
在 步骤 2 中,我们在 Grid 组件内声明了一些必需的参数。Header 和 Row ,类型为 RenderFragment ,允许自定义表格的表头并简化表格行的动态渲染。此外,我们指定了一个 Provider 代理来封装获取数据的逻辑。我们特意设计 Provider 以匹配 Virtualize 组件所需的 ItemsProvider 签名,确保兼容性和无缝集成。
在 步骤 3 中,我们实现了一个 LoadAsync() 方法,它在响应用户滚动操作获取数据时起着关键作用。它接受一个 ItemsProviderRequest 参数,并返回一个 ItemsProviderResult
在 步骤 4 中,我们设置了 Grid 标记。我们添加了一个
结构,在其中我们将 Header 模板放置在 标签内,并在 标签内使用 Virtualize 组件。通过将 LoadAsync() 方法作为 ItemsProvider 参数附加,并将 Row 模板作为 Virtualize 组件的 ChildContent,网格动态渲染额外的数据行,创建无限滚动效果。为了演示 Grid 组件的使用,在 步骤 5 中,我们引入了一个可路由的 TicketManager 组件。我们将 TicketManager 设置为以 InteractiveWebAssembly 模式渲染,并注入 TicketsApi,因为我们将其作为数据源。在 步骤 6 中,我们将 Grid 组件集成到 TicketManager 标记中,其中 Tickets.GetAsync 作为数据提供者,并指定了 Header 和 Row 模板以显示 Ticket 对象的属性。
还有更多...
在您的 Blazor 应用程序中使用 Virtualize 组件可以带来许多好处,这些好处既提高了性能,也改善了用户体验:
-
首先,Virtualize 在处理大量数据集时显著提高了性能并减少了内存使用。这种效率的提高来自于其渲染方法,即在任何给定时间只渲染可见的项目子集,从而减少了浏览器上的总体负载。
-
其次,Virtualize 提供的简洁性不容小觑。您可以用最少的代码实现复杂的无限滚动功能,因为该组件抽象了项目虚拟化和自动事件处理的复杂性。
-
最后,Virtualize 提供了卓越的灵活性,使其能够无缝集成到各种数据源中。这种灵活性对于需要实时数据获取的应用程序特别有益,因为您可以根据特定的数据获取逻辑定制 ItemsProvider 委托,确保应用程序保持响应并更新到最新信息。
利用 QuickGrid
在这个菜谱中,我们将探索现在直接嵌入到 Blazor 框架中的强大组件 – QuickGrid。QuickGrid 简化了在 Blazor 应用程序中创建和管理动态、数据驱动的网格,提供开箱即用的功能,如排序、分页和过滤。该组件因其易于实现和通过内置虚拟化在呈现和操作大数据集时的高性能而脱颖而出。QuickGrid 消除了对额外 NuGet 包的需求,简化了开发过程并减少了项目复杂性。
让我们浏览 QuickGrid 的基本知识,并展示其实现是多么简单。
准备工作
在我们探索 QuickGrid 的实现之前,请执行以下操作:
-
创建一个 Chapter04 / Recipe06 目录 – 这将是你的工作目录
-
从 GitHub 仓库中的 Chapter04 / Data 目录复制 Samples
-
导航到你的应用程序的 Program 文件并注册来自 Samples 的 TicketsApi 服务到应用程序依赖注入容器中:
builder.Services.AddScoped<TicketsApi>();
如何操作…
要使用 QuickGrid 渲染网格,请按照以下步骤操作:
-
导航到你的项目的 .csproj 配置文件,并添加 Microsoft.AspNetCore.Components.QuickGrid 包到你的项目中:
<ItemGroup> <PackageReference Include= "Microsoft.AspNetCore.Components.QuickGrid" Version="8.0.2" /> </ItemGroup> -
创建一个新的可路由的 TicketManager 组件,引用 QuickGrid 包,以 InteractiveWebAssembly 模式渲染,并注入 TicketApi 服务:
@page "/ch04r06" @using Microsoft.AspNetCore.Components.QuickGrid @rendermode InteractiveWebAssembly @inject TicketsApi Tickets -
在 TicketManager 组件的 @code 块中,引入一个类型为 PaginationState 的 Pagination 变量来配置 QuickGrid 实例的数据分页:
@code { protected PaginationState Pagination = new() { ItemsPerPage = 5 }; } -
在 TicketManager 标记中,使用嵌套的 PropertyColumn 组件构建 QuickGrid 组件以定义数据列,并集成 Paginator 组件来管理数据分页:
<QuickGrid Class="w-100 table table-bordered" Items="@Tickets.Get()" Pagination="@Pagination"> <PropertyColumn Property="@(x => x.Tariff)" Sortable="true" /> <PropertyColumn Property="@(x => x.Price)" Sortable="true" Format="0.00" /> </QuickGrid> <Paginator State="@Pagination" />
它是如何工作的…
在 步骤 1 中,我们导航到项目的配置文件(具有 .csproj 扩展名的文件)并将 Microsoft.AspNetCore.Components.QuickGrid 包添加到项目中。QuickGrid 是 Blazor 生态系统的一部分,但默认情况下并不包含在 Blazor 项目中。
在 步骤 2 中,我们创建一个新的可路由的 TicketManager 组件,我们将在这里测试 QuickGrid。由于网格将是交互式的,我们声明 TicketManager 以 InteractiveWebAssembly 模式渲染。我们还包含一个 using 指令,引用 QuickGrid 命名空间。最后,我们注入 TicketsApi 服务以提供网格的数据源。
在 步骤 3 中,我们在 TicketManager 组件的 @code 块中初始化,其中我们构建一个 Pagination 变量的实例并设置其 ItemsPerPage 属性。QuickGrid 需要一个 PaginationState 对象来启用分页。
最后,在步骤 4中,我们将QuickGrid嵌入到TicketManager标记中。我们将Tickets.Get()方法和Pagination对象分别附加到QuickGrid的Items和Pagination参数上。接下来,我们使用PropertyColumn组件构建网格列。我们通过委托指定要渲染的属性,并通过设置Sortable参数来启用排序。对于价格列,我们还设置了Format参数。QuickGrid组件将自动将此格式应用于该列中的所有价格。最后,我们引入了一个Paginator组件,并将其链接到与QuickGrid实例相同的Pagination变量。Paginator向用户公开分页 UI,并直接在QuickGrid上执行导航请求。
最后,我们得到了一个功能齐全、优化良好且功能丰富的网格:

图 4.2:使用 QuickGrid 渲染的具有可排序列和分页的网格
第五章:管理应用程序状态
在本章中,我们将探讨维护和操作 Blazor 应用程序状态的关键方面。应用程序状态是运行时数据,它决定了应用程序的行为和外观,反映了用户的交互和决策。
我们将介绍各种状态管理策略,从将状态编码到 URL 以实现可书签状态和易于共享,到实现内存中的状态容器以实现快速访问。你将学习如何将应用程序状态作为服务注入,以实现不同组件之间的集中式状态管理,以及如何持久化状态以确保会话间的数据连续性。此外,我们将探讨在应用程序加载时解决持久化状态、从应用程序的任何地方调用状态变化以及使用专用监听组件监控这些变化的技巧。我们将特别关注在不同渲染模式边界之间共享状态。
到本章结束时,你将具备在状态管理实践方面的坚实基础,这将帮助你构建动态、响应和有状态的 Blazor 应用程序。
这里有一些将带我们到达那里的配方:
-
具有可书签状态
-
实现内存中的状态容器
-
将应用程序状态作为服务注入
-
从任何地方调用状态变化
-
持久化状态
-
解决持久化状态
-
在交互渲染模式边界之间共享状态
技术要求
那一章节的入门门槛不高。你需要以下工具:
-
一个支持 Blazor 开发的现代 IDE
-
在你的开发机器上安装了 .NET 9
-
一个支持 Web Assembly 和具有 DevTools 的现代网络浏览器
-
一个基本的 Blazor 项目(你将在其中编写代码)
你可以在以下 GitHub 仓库中找到以下配方中引用的所有示例和数据样本:github.com/PacktPublishing/Blazor-Web-Development-Cookbook/tree/main/BlazorCookbook.App.Client/Chapters/Chapter05。在需要任何样本的每个配方中,我还会指向你可以找到它们的目录。
具有可书签状态
在这个配方中,我们将介绍利用 URL 维护和共享应用程序状态的最简单但功能强大的模式。与更复杂的状态管理策略不同,直接在 URL 中嵌入状态标志不需要内存持久化。静态 URL 允许用户书签特定的应用程序状态,并便于与他人轻松共享该状态。我们将遵循众所周知的RESTful 路由模式,并将应用程序状态优雅地映射到可读和可共享的 URL。
让我们创建一个组件,允许我们书签并查看整个事件列表或特定事件信息。
准备工作
在开始实现具有可书签状态的组件之前,我们需要做以下事情:
-
创建一个Chapter05 / Recipe01目录——这将是我们的工作目录
-
从 GitHub 仓库中的Chapter05 / Data目录复制Api和Event文件
如何做到这一点…
按照以下步骤在应用程序中实现状态化 URL:
-
导航到应用程序的Program文件并注册Api服务,如应用程序的依赖注入容器中定义的:
builder.Services.AddScoped<Api>(); -
创建一个带有两个可导航路由的Store组件,以方便用户通过 URL 访问不同的应用程序状态:
@page "/ch05r01/events" @page "/ch05r01/events/{eventId:guid}" -
在Store组件的@code块内部,注入Api服务并声明一个我们将用于获取特定事件详细信息的EventId参数:
[Inject] private Api Api { get; init; } [Parameter] public Guid EventId { get; set; } -
仍然在@code块中,根据应用程序的当前状态初始化将保存获取数据的Collection和Event变量:
protected IList<Event> Collection = []; protected Event Event; -
覆盖OnParametersSetAsync()生命周期方法并实现基于传递到 URL 中的参数更新组件状态的逻辑:
protected override async Task OnParametersSetAsync() { if (EventId != Guid.Empty) { Event = await Api .GetEventAsync(EventId, default); return; } Collection = await Api.GetEventsAsync(default); } -
在Store组件的标记中,添加一个部分以条件性地渲染Event详细信息:
@if (Event is not null) { <p>Viewing: @Event.Id</p> return; } -
在Store标记中添加另一个部分以渲染Collection元素:
@foreach (var item in Collection) { <div class="w-100"> <a href="/ch05r01/events/@item.Id"> @item.Id </a> </div> }
它是如何工作的…
在步骤 1中,我们导航到应用程序的Program并注册 API 服务到应用程序的依赖注入容器中,以便在需要时可以注入它。
在步骤 2中,我们创建了一个具有略微增强路由的Store组件。我们声明了两个可路由路径——/ch05r01/events用于渲染所有可用的活动,以及/ch05r01/events/{eventId:guid}用于特定事件的详细信息。通过利用路径参数化和路径约束,我们在大括号内指定了EventId参数,将其期望值类型设置为Guid。
在步骤 3中,我们初始化@code块,其中我们声明了路由所期望的EventId参数。Blazor 会自动拦截并根据名称匹配分配路径参数值。我们还从提供的示例数据中注入了Api服务,使我们能够无缝地获取事件信息。在步骤 4中,我们初始化Collection和Event变量。这些变量对于支持Store组件的双重状态至关重要——一个用于展示可用的活动列表,另一个用于展示特定选中活动的详细信息。在步骤 5中,我们通过覆盖OnParametersSetAsync()生命周期方法来微调渲染逻辑。我们确定EventId是否正确解析,并使用注入的Api服务获取该特定事件的详细信息。否则,我们检索整个可用的活动集合。
从步骤 6开始,我们实现支持两种不同状态的Store标记。为了适应这一点,我们包括两个条件标记部分。如果已获取Event,表示特定事件的详细信息已准备好显示,我们将渲染其Id并快速返回以跳过任何进一步的逻辑。我们通过遍历Collection在步骤 7中覆盖组件的替代状态。我们渲染指向每个事件详细信息的链接,利用Store组件的参数化路径,并在eventId参数的位置提供item.Id。
还有更多...
您会发现参数化路径在CRUD(创建、读取、更新、删除)场景中非常有用。假设我们将在Store组件内实现一个表单,该表单旨在附加到Event对象上,我们可以巧妙地将Guid.Empty值用作触发创建过程的信号,并初始化一个新的、空的Event模型。相反,如果提供了一个有效的Guid,我们将从 API 获取现有的Event。我们有效地使用相同的表单覆盖了两种场景,避免了代码重复。
在路径中指定值的类型并不是一个严格的要求。默认情况下,Blazor 会将参数映射为字符串,这为您在稍后阶段将它们解析为所需类型提供了灵活性。然而,真正的力量在于有效地利用路由约束。虽然稍后解析参数提供了灵活性,但我强烈建议尽可能利用路由约束。在Store组件示例中,通过指定参数类型为Guid,如果由于路由约束导致路由不匹配,Blazor 将显示NotFound内容——预先过滤掉无效输入,从而增强应用程序的健壮性和安全性。我们将在第九章中探讨路由和NotFound内容。
实现内存状态容器
在现代 Web 开发中,有效地管理与外部 API 的交互至关重要。一个内存状态容器允许您在应用程序生命周期内持久化特定对象,除非另有配置。当您在初始调用中从 API 接收到一个完整的数据对象时,而不是在每次页面转换时重新获取这些数据,内存状态容器可以促进整个对象在应用程序的各个阶段和页面之间的平滑传输。此外,在多阶段设置过程中,内存状态容器非常有价值,它允许复杂设置对象的当前状态持久地向前推进,而不会丢失或重复外部调用。
让我们实现一个容器,我们将在此容器中持久化事件信息,并在将用户重定向到事件详情页面后显示这些信息。
准备工作
在深入实现内存容器之前,我们需要做以下几步:
-
创建一个Chapter05/Recipe02目录——这将成为您的工作目录
-
从 GitHub 仓库中Chapter05/Data目录复制Api和Event文件
-
将Api服务注册为在应用程序的依赖注入容器中作用域内(您可以通过查看Having a bookmarkable state菜谱来了解如何操作)
如何做到这一点…
按以下步骤实现内存状态容器:
-
创建一个泛型StateContainer
类来在内存中持有任何类型的对象: public class StateContainer<T> { } -
在StateContainer
中,初始化一个后端_container作为泛型Dictionary,您将在其中持久化状态对象: private readonly Dictionary<Guid, T> _container = []; -
向StateContainer
添加Persist()和Resolve()方法,使用Guid键从_container存储或检索对象: public void Persist(Guid key, T value) => _container.TryAdd(key, value); public T Resolve(Guid key) => _container[key]; -
导航到Program应用程序根目录并在依赖注入容器中注册StateContainer
: builder.Services.AddScoped<StateContainer<Event>>(); -
添加一个静态Config类并定义一个基于InteractiveWebAssembly但禁用了预渲染的定制PrerenderDisabled渲染模式:
internal static class Config { public static readonly IComponentRenderMode PrerenderDisabled = new InteractiveWebAssemblyRenderMode( prerender: false); } -
创建一个在PrerenderDisabled模式下渲染的可路由Store组件:
@page "/ch05r02" @rendermode Config.PrerenderDisabled -
在Store的@code部分,注入StateContainer
以持久化Event对象,NavigationManager以促进导航,以及Api从外部源播种数据: [Inject] private StateContainer<Event> Container { get; init; } [Inject] private NavigationManager Navigation { get; init; } [Inject] private Api Api { get; init; } -
仍然在@code块内部,初始化一个后端Data集合并覆盖OnInitializedAsync()生命周期方法以从Api获取Data对象:
protected IList<Event> Data = []; protected override async Task OnInitializedAsync() => Data = await Api.GetEventsAsync(default); -
最后,在@code块中,实现一个ShowDetails()方法,将请求的Event存储在内存中的StateContainer
中,并重定向到显示事件详情的页面: public void ShowDetails(Event @event) { Container.Persist(@event.Id, @event); Navigation.NavigateTo( $"/ch05r02/events/{@event.Id}" ); } -
在Store标记中,构建一个循环以渲染来自Data集合的所有元素的导航按钮:
@foreach (var item in Data) { <div class="row w-50 m-1"> <button @onclick="@(() => ShowDetails(item))"> @item.Id </button> </div> } -
创建一个与上一步中指定的路由匹配的EventDetails组件,并以PrerenderDisabled模式渲染:
@page "/ch05r02/events/{eventId:guid}" @rendermode Config.PrerenderDisabled -
在EventDetails的@code部分,注入StateContainer
并声明一个EventId参数以从 URL 捕获事件标识符: [Inject] private StateContainer<Event> Container { get; init; } [Parameter] public Guid EventId { get; set; } -
仍然在@code块内部,声明一个Model变量以维护当前组件状态,并覆盖OnParametersSet()生命周期方法以从注入的Container解析Model:
protected Event Model; protected override void OnParametersSet() => Model = Container.Resolve(EventId); -
在EventDetails标记中,对Model进行空值检查,并在Model成功解析时渲染底层事件的当前容量:
@if (Model is null) return; It has @Model.Capacity spots left!
它是如何工作的…
我们通过建立内存状态持久化的基础来开始实施。在步骤 1中,我们添加了一个通用类,StateContainer
在步骤 5中,我们引入了一个自定义渲染模式——PrerenderDisabled。我们将PrerenderDisabled放置在一个新的静态Config类中,以便它易于重用。为什么我们需要一个自定义渲染模式?当你为每个组件声明交互模式时,Blazor 默认通过预渲染内容提供服务,随后再水化组件状态。在我们的情况下,这会引发异常,因为内存状态容器在组件初始渲染期间不可访问。我们的基于InteractiveWebAssembly的PrerenderDisabled模式解决了这个挑战。
在步骤 6中,我们创建了一个可路由的Store组件,引用示例数据程序集,并利用Config中定义的PrerenderDisabled模式。在步骤 7中,我们注入了所需的服务——用于对象状态持久化的StateContainer
现在,我们还需要添加事件详情页面。在步骤 11中,我们创建了一个EventDetails组件,其路由与步骤 9中选择的路由相匹配。我们还将其渲染模式声明为PrerenderDisabled——与Store组件保持一致。在步骤 12中,我们注入了StateContainer
还有更多...
有趣的是,内存状态容器不仅用于保存数据。在管理多步骤表单或复杂配置过程时,它也非常方便,因为您可以有效地保存和检索进度。
我们故意省略的一个关键方面是清理状态容器的机制。根据您应用程序的要求,您可能需要以不同的时长持久化状态。通过遵循我们上面的实现,简单地注册StateContainer
在结束这个食谱之前,我要提醒大家——你们必须战略性地评估在您的场景中内存状态容器的可行性。持久化对象复杂性和持久化时长可能会对应用程序内存造成不必要的压力,并导致性能问题。
将应用程序状态作为服务注入
在这个食谱中,我们将通过引入应用程序状态服务和利用依赖注入来展示一个设计模式,以简化跨应用程序的状态管理。这种方法简化了组件之间的交互,使它们能够无缝地监听或通信应用程序状态的变化。通过依赖注入,您提高了应用程序的响应性,并通过避免组件之间的紧密耦合来保持清晰的架构。有了应用程序状态服务,您的应用程序保持敏捷、可维护和可扩展,适应着网络开发的不断变化需求。
让我们实现一个可注入的状态服务,以便我们可以发布和接收成功和失败的消息。
准备工作
在深入研究可注入的状态服务之前,请执行以下操作:
-
创建一个 Chapter05 / Recipe03 目录——这将是你的工作目录:
-
从 Implementing an in-memory state container 菜单或 GitHub 仓库中的 Chapter05 / Recipe03 目录复制 Config 类,并使用定制的 PrerenderDisabled 渲染模式:
如何操作…
按照此指南在您的应用程序中实现可注入的状态服务:
-
添加一个 StateArgs 基础记录,并定义从该基础记录派生的 SuccessArgs 和 FailureArgs 状态参数:
public abstract record StateArgs; public record SuccessArgs : StateArgs; public record FailureArgs : StateArgs; -
引入一个具有 event 代理可以订阅的 StoreState 类和一个接受 StateArgs 并触发 OnChanged 事件的 Notify() 方法的 StoreState 类:
public sealed class StoreState { public event Func<StateArgs, Task> OnChanged; public Task Notify(StateArgs args) => OnChanged?.Invoke(args); } -
导航到 Program 类并在依赖注入容器中注册 StoreState:
builder.Services.AddScoped<StoreState>(); -
创建一个可路由的 Store 组件,利用 PrerenderDisabled 渲染模式并实现 IDisposable 接口:
@page "/ch05r03" @rendermode Config.PrerenderDisabled @implements IDisposable -
在 Store 组件的 @code 块中,注入 StoreState 并初始化一个 Message 变量:
[Inject] private StoreState State { get; init; } protected string Message = string.Empty; -
仍然在 @code 块中,实现一个将 StateArgs 转换为用户友好的消息并应用 UI 变化的 ReactAsync() 方法:
private Task ReactAsync(StateArgs args) { Message = args is SuccessArgs ? "Success" : "Failure"; return InvokeAsync(StateHasChanged); } -
在 @code 块中,覆盖 OnInitialized() 生命周期方法,将 ReactAsync() 方法订阅到 StoreState 事件:
protected override void OnInitialized() => State.OnChanged += ReactAsync; -
最后,在 @code 块中,实现 Dispose() 方法,以满足 IDisposable 的要求,并从 StoreState 事件中取消订阅 ReactAsync():
public void Dispose() => State.OnChanged -= ReactAsync; -
在 Store 组件的标记中,添加两个按钮,点击后调用带有 SuccessArgs 或 FailureArgs 的 Notify() 方法。同时包含一个段落来显示 Message 变量的当前值:
<button @onclick="@(() => State.Notify(new SuccessArgs()))"> Buy! </button> <button @onclick="@(() => State.Notify(new FailureArgs()))"> Buy! </button> <p>@Message</p>
它是如何工作的…
在 步骤 1 中,我们定义了三种对象类型——StateArgs、SuccessArgs 和 FailureArgs——来表示我们应用程序中的状态。利用继承,并让 SuccessArgs 和 FailureArgs 从 StateArgs 继承,我们可以在状态处理逻辑中保持简单。在 步骤 2 中,我们实现了 StoreState 类,它充当应用程序状态服务。我们公开了一个事件——封装了一个带有 StateArgs 参数的异步方法调用和一个 Notify() 方法——允许任何组件通信状态变化。我们有效地将状态转换的复杂性封装在一个简单直观的接口后面。StoreState 准备就绪后,在 步骤 3 中,我们将它集成到 Program 类中应用程序的依赖注入容器中。
在步骤 4中,我们创建了一个可路由的Store组件来展示我们的应用程序状态服务的实际用途。我们选择自定义PrerenderDisabled渲染模式以避免潜在的渲染陷阱;您在实现内存状态容器菜谱中了解到这一点。我们还声明Store实现IDisposable接口,表示将执行自定义清理逻辑。在步骤 5中,我们将StoreState作为State注入,并初始化Message变量,我们将捕获应用程序状态的友好快照以供显示。在步骤 6中,我们实现了一个ReactAsync()方法,它充当StateArgs的动态解析器。我们根据args类型更新Message变量,在成功和失败状态之间转换。之后,我们调用StateHasChanged()来通知 UI 状态已更改,但我们将其包装在InvokeAsync()方法中,以确保我们的 UI 保持响应和线程安全。
在步骤 7中,我们使Store组件能够监听由StoreState广播的状态变化。我们重写了OnInitialized()方法,并将我们的ReactAsync()订阅到State.OnChanged。在步骤 8中,我们实现了由IDisposable接口强制执行的Dispose()方法。在这里,我们取消订阅ReactAsync()从State.OnChanged,以防止内存泄漏并确保组件的优雅销毁。
在步骤 9中,我们将Store标记放入适当的位置。我们添加了两个按钮——一个表示成功,另一个表示失败。两者都使用State.Notify()方法来协调状态变化。在这些按钮下方,我们添加了段落标签,并渲染Message以可视化按钮交互的影响。为了保持示例简单,我们使用同一组件内的按钮来触发状态变化,该组件监听这些状态变化。然而,您可以将这些按钮放置在应用程序的任何组件中,我们的Store仍然能够准确接收并响应状态通知。这正是具有可注入应用程序状态服务的真正强大和敏捷之处。
参见
在这个菜谱中,我们触及了.NET 中事件的主题。我们不会深入探讨那本书中的.NET 基础知识,但如果您想了解更多,请查看官方的 Microsoft Learn 资源:learn.microsoft.com/en-us/dotnet/csharp/programming-guide/events/。
从任何地方调用状态变化
在本食谱中,我们正在探索在您的 Blazor 应用程序中全局注入状态服务。状态服务可以涵盖从用户的应用个性化到用户会话详情或处理指示器等任何内容。在我们的示例中,我们实现了一个在长时间运行的任务期间覆盖我们界面的覆盖层。Overlay 作为视觉提示,向用户发出信号,表明他们的请求正在执行,并防止任何可能干扰正在进行的过程的用户交互。
准备工作
在我们探索全局注入状态服务和触发覆盖层的策略之前,请执行以下操作:
-
创建一个 Chapter05 / Recipe04 目录——这将成为您的工作目录
-
从 GitHub 仓库中的 Chapter05 / Data 目录复制 Api 和 Event 文件
-
从 GitHub 仓库中的 Chapter05 / Data 目录复制 Overlay.css 文件并将其重命名为 Overlay.razor.css;重命名后,您的 IDE 可能会显示编译错误——我们将在本食谱的末尾解释这种行为并修复错误
-
在应用程序的依赖注入容器中将 Api 服务注册为作用域内(查看 Having a bookmarkable state 食谱了解如何操作)
如何做到这一点...
按照以下说明添加全局注入的覆盖状态处理器:
-
创建一个具有订阅者可以监听的 OnChanged 事件和触发 OnChanged 的 ExecuteAsync() 方法的 OverlayState 类,该方法在执行传递给它的任何作业之前和之后都会触发:
public class OverlayState { public event Func<bool, Task> OnChanged; public async Task ExecuteAsync(Func<Task> job) { await OnChanged.Invoke(true); await job.Invoke(); await OnChanged.Invoke(false); } } -
导航到 Program 类并在依赖注入容器中注册 OverlayState。
builder.Services.AddScoped<OverlayState>(); -
导航到项目级别的 _Imports.razor 主文件并注入 OverlayState,使其在所有组件中可用。您可能还需要引用缺失的程序集:
@inject BlazorCookbook.App.Client.Chapters.Chapter05 .Recipe04.OverlayState OverlayState -
创建一个实现 IDisposable 接口的 Overlay 组件:
@implements IDisposable -
在 Overlay 的 @code 块中初始化一个 IsVisible 变量并定义一个 ReactAsync() 方法来根据状态更改更新 IsVisible:
protected bool IsVisible; public Task ReactAsync(bool isVisible) { IsVisible = isVisible; return InvokeAsync(StateHasChanged); } -
在 Overlay 的 @code 块中重写 OnInitialized() 生命周期方法,并将 ReactAsync() 方法订阅到 OverlayState.OnChanged 事件以接收状态变更通知:
protected override void OnInitialized() => OverlayState.OnChanged += ReactAsync; -
仍然在 @code 块中,实现一个 Dispose() 方法来取消订阅 ReactAsync() 从 OverlayState.OnChanged 事件:
public void Dispose() => OverlayState.OnChanged -= ReactAsync; -
在 Overlay 标记中,包含一个表示覆盖层的
部分,并使用 IsVisible 变量通过以下方式切换此部分的可见性: <overlay class="@(IsVisible ? "visible" : "")"> Loading... </overlay> -
创建一个在 InteractiveWebAssembly 模式下渲染的 Store 组件:
@page "/ch05r04" @rendermode InteractiveWebAssembly -
在 Store 组件的 @code 块中注入 Api 服务,并实现一个 SyncAsync() 方法来在执行 Api 服务请求时管理 OverlayState 的可见性:
[Inject] private Api Api { get; init; } private Task SyncAsync() => OverlayState.ExecuteAsync(() => Api.SynchronizeAsync(default)); -
在Store标记中,嵌入Overlay组件,并包含一个按钮来触发SyncAsync()方法:
<Overlay /> <button @onclick="@SyncAsync"> Synchronize data </button>
它是如何工作的……
在第 1 步,我们创建一个OverlayState服务。由于我们预计我们的覆盖层将具有二进制性质——可见或隐藏,我们构建了一个基于bool的逻辑。我们添加一个可订阅的事件类型Func<bool, Task>并实现了一个ExecuteAsync()方法,该方法接受一个异步作业作为参数。在ExecuteAsync()中,我们通过在作业执行前后调用OnChanged事件来切换覆盖层的可见性,有效地在处理期间显示覆盖层,并在完成后隐藏它。在第 2 步,我们将OverlayState集成到依赖注入容器中,在第 3 步,我们通过将其注入到_Imports.razor文件中来实现OverlayState的全局可访问性。Blazor 应用程序中的_Imports.razor文件充当添加命名空间和指令的封装器,使它们能够在不显式声明的情况下跨兄弟或嵌套 Razor 组件访问。
在第 4 步,我们创建一个Overlay组件,与OverlayState进行交互。由于我们将在Overlay中实现事件驱动逻辑,我们将其声明为实现IDisposable接口。在第 5 步,我们初始化一个IsVisible变量来跟踪覆盖层的可见状态,以及一个ReactAsync()方法来响应这些状态变化。现在,我们可以利用ReactAsync()方法来监听OverlayState事件。在第 6 步,我们重写OnInitialized()生命周期方法,使用ReactAsync()订阅OverlayState.OnChanged事件。现在,Blazor 将把覆盖层状态的变化通知给Overlay UI。在第 7 步,我们处理潜在的内存泄漏,并在Dispose()方法中取消订阅OnChanged事件。在第 8 步,我们实现Overlay标记。我们引入一个自定义的
在 步骤 9 中,我们转向展示 OverlayState 和 Overlay 的实际应用。我们创建一个 Store 组件,并将其设置为以 InteractiveWebAssembly 模式渲染。在 步骤 10 中,我们注入 Api 服务并实现一个 SyncAsync() 方法。在 SyncAsync() 方法中,我们利用 OverlayState.ExecuteAsync() 封装可能耗时的操作执行,并以覆盖的形式显示视觉提示,确保用户知道他们的请求正在处理。在 步骤 11 中,我们通过添加 Overlay 组件和 SyncAsync() 方法的触发按钮来引入 Store 标记。
还有更多…
由于我们已经将 OverlayState 注入到所有组件中,我们可以将 Overlay 的存在与任何组件状态解耦。我们可以通过在应用程序的布局文件中包含 Overlay 标签来实现这一点。有了这个,覆盖功能无处不在——您可以从任何应用程序区域轻松利用覆盖功能。
这就是布局可能的样子:
@inherits LayoutComponentBase
<Overlay />
<main>
@Body
</main>
持久化状态
在现代网络开发中,持久化应用程序和会话状态不再是奢侈品,而是必需品。无论是为了增强用户体验、保护用户进度,还是跨会话维护偏好设置,状态持久化在创建无缝和引人入胜的数字体验中发挥着关键作用。考虑在客户端保存本地应用程序配置的便利性,例如用户对深色模式的偏好或他们选择接收推送通知的选择。同样,持久化会话状态的一部分对于确保用户不会因为意外中断而丢失宝贵的进度至关重要——想象一下这会带来多大的挫败感。这些小细节可以显著提高任何应用程序的可用性和个性化。让我们看看如何在您的 Blazor 应用程序中持久化状态。
让我们实现一个选项来在浅色和深色模式之间切换,并在用户的浏览器中持久化一个合适的设置标志。
准备工作
在实现状态持久化之前,请执行以下操作:
- 创建一个 Chapter05 / Recipe05 目录——这将成为您的工作目录
如何做到这一点…
按照以下步骤实现状态持久化:
-
在您的客户端应用的 wwwroot 目录中添加一个 {ASSEMBLY_NAME}.lib.module.js 文件,并定义一个具有 set 函数的 browserStorage 对象,该函数能够根据 type 参数在会话存储或本地存储中存储一个 key - value 对:
window.browserStorage = { set: function (type, key, value) { if (type === 'sessionStorage') { sessionStorage.setItem(key, value); } if (type == 'localStorage') { localStorage.setItem(key, value); } } }; -
创建一个具有 Key 和 Value 属性的通用、抽象 StorageValue 类,与 browserStorage.set 函数期望的参数相匹配:
public abstract record StorageValue<T> { public string Key { get; init; } public T Value { get; init; } } -
创建 LocalStorageValue 和 SessionStorageValue 记录——针对不同浏览器存储类型的 StorageValue 的特定实现:
public record LocalStorageValue<T> : StorageValue<T>; public record SessionStorageValue<T> : StorageValue<T>; -
在您的应用程序中创建一个BrowserStorage类,并通过 Blazor 默认提供的IJSRuntime服务进行注入:
public class BrowserStorage { private readonly IJSRuntime _js; public BrowserStorage(IJSRuntime js) { _js = js; } } -
在BrowserStorage内部,定义const值以确保逻辑的一致性:
-
_setFunc用于存储 JavaScript 函数名
-
_local和_session用于引用存储类型:
private const string _setFunc = "browserStorage.set", _local = "localStorage", _session = "sessionStorage";
-
-
仍然在BrowserStorage中,实现一个PersistAsync()方法,接受一个StorageValue
参数。利用JsonSerializer将值转换为 JSON 并确定适当的存储位置,然后调用browserStorage.set函数: public ValueTask PersistAsync<T>( StorageValue<T> @object) { var json = JsonSerializer .Serialize(@object.Value); var storage = @object is LocalStorageValue<T> ? _local : _session; return _js.InvokeVoidAsync(_setFunc, storage, @object.Key, json); } -
导航到应用程序的Program类并在依赖注入容器中注册BrowserStorage服务:
builder.Services.AddTransient<BrowserStorage>(); -
创建一个可路由的Settings组件,以InteractiveWebAssembly模式渲染:
@page "/ch05r05" @rendermode InteractiveWebAssembly -
在Settings的@code块中注入BrowserStorage并声明用于管理视图模式持久化的专用常量键:
[Inject] private BrowserStorage Storage { get; init; } private const string _key = "viewMode", _light = "lightMode", _dark = "darkMode"; -
仍然在@code块中,实现一个SetViewModeAsync()方法,该方法接受一个mode参数,将其封装在一个LocalStorageValue
对象中,并使用BrowserStorage.PersistAsync()方法进行持久化: public async Task SetViewModeAsync(string mode) { var value = new LocalStorageValue<string> { Key = _key, Value = mode }; await Storage.PersistAsync(value); } -
在设置组件的标记中,引入两个按钮,它们使用SetViewModeAsync()方法来调整应用程序的视图模式——一个用于设置浅色模式,另一个用于深色模式:
<button @onclick="@(() => SetViewModeAsync(_light))"> Turn the light on! </button> <button @onclick="@(() => SetViewModeAsync(_dark))"> Turn the light off! </button>
它是如何工作的…
在这个菜谱中,我们需要一段自定义的JavaScript来为我们处理过程打下基础。我们使用位于客户端项目wwwroot文件夹中的{ASSEMBLY_NAME}.lib.module.js文件;如果您还没有,请创建一个。Blazor 将自动嵌入它,因此不需要显式注册。在步骤 1中,我们导航到该.js文件并定义一个browserStorage API。目前,我们只实现了一个set函数,它接受type、key和value参数,并根据指定的type,调用sessionStorage或localStorage实例的setItem函数。
在步骤 2中,我们创建了一个具有Key和Value属性的StorageValue泛型记录。通过将此记录标记为abstract,我们表明我们的意图是将其用作更具体存储值的基石。在步骤 3中,我们实现了这一点,添加了LocalStorageValue和SessionStorageValue,它们都继承自StorageValue。
在第 4 步中,我们初始化BrowserStorage服务。由于我们需要从我们的 C#代码中调用 JavaScript 函数,我们将 Blazor 内置的IJSRuntime注入到我们的服务中。在第 5 步中,我们引入了一些const值来锚定我们的持久化逻辑。通过_setFunc,我们封装了我们想要调用的 JavaScript 函数的命名,而_local和_session则标识了两种可用的浏览器存储类型。在第 6 步中,我们使用PersistAsync()泛型方法最终确定BrowserStorage的实现。然而,浏览器存储只允许我们存储string类型。我们通过利用JsonSerializer将我们的value对象转换为JSON格式来解决这个问题。然后,使用is运算符和我们的常量值,我们解决适当的浏览器存储类型。拥有所有所需的负载后,我们通过委托工作到browserStorage.set函数来结束PersistAsync()逻辑,借助IJSRuntime引用及其InvokeVoidAsync()方法。现在,我们需要使我们的BrowserStorage对组件可用。在第 7 步中,我们导航到Program类,并在依赖注入容器中注册BrowserStorage。鉴于BrowserStorage的无状态特性,我们选择Transient生命周期以避免不必要的内存使用。
在第 8 步中,我们创建了一个设置组件,并将其设置为以InteractiveWebAssembly模式渲染,确保组件的交互性。然后,在第 9 步中,我们将BrowserStorage注入到设置组件中,并声明了一些常量变量——_key,它存储存储值的键,以及_light和_dark,它们概述了可用的视图模式。在第 10 步中,我们实现了SetViewModeAsync()方法,其中我们使用我们的_key和mode参数初始化LocalStorageValue变量,并调用注入的BrowserStore服务的PersistAsync()方法。为了总结,在第 11 步中,我们在设置组件的标记中添加了两个按钮。通过这些按钮,用户可以调用SetViewModeAsync()方法,并在浏览器的本地存储中设置所选的视图模式。
步骤在不同浏览器之间可能略有不同,但以下是您如何使用 Chrome DevTools 查看viewMode键值的方法:

图 5.1:使用 Chrome DevTools 查看浏览器本地存储中持久化的值
还有更多...
我们开发了一个自定义 JavaScript 函数,以启用对浏览器存储的访问,因为这个实现可以在服务器端和客户端场景中保持功能,提供广泛的兼容性和灵活性。当将 JavaScript 集成到 Blazor 应用程序中时,你必须记住,依赖于IJSRuntime的服务不能注册为单例。IJSRuntime需要访问每个用户的浏览器会话,这使得它与单例初始化模式和生命周期模型在架构上不兼容。
然而,对于严格限制在服务器端 Blazor 的项目,你应该考虑利用内置的ProtectedBrowserStorage API。ProtectedBrowserStorage是 Blazor 原生机制,它通过一个不需要任何自定义 JavaScript 的数据加密层启用浏览器存储访问。
解决持久化状态
在上一个菜谱中,你探索了在浏览器存储中持久化应用程序状态。在这个基础上,在这个菜谱中,我们将关注同样重要的一个方面——恢复那个持久化的状态。这个功能在处理用户特定的本地应用程序个性化时非常有价值,例如在暗色或亮色模式之间的偏好或接收推送通知的同意。通过状态持久化和解决,你为用户提供了一种方便的方式,让他们能够从他们离开的地方重新加入会话。这种连续性在构建以用户为中心的应用程序和提供个性化体验方面是基本的。
在上一个菜谱中保持亮色或暗色模式的情况下,让我们实现一个选项,当组件渲染时解决持久化的视图模式值。
准备工作
在开始解决持久化状态之前,请执行以下操作:
-
创建一个Chapter05 / Recipe06目录——这将是你的工作目录
-
从持久化状态菜谱或 GitHub 仓库的Chapter05 / Recipe05目录中复制BrowserStorage、Settings和所有StorageValue记录
-
如果你不是从整个章节开始,而是从这个菜谱开始,请将 GitHub 仓库中BlazorCookbook.App.Client的wwwroot目录下的BlazorCookbook.App.Client.lib.module.js文件复制到你的项目的wwwroot中,并将其重命名为与你的项目程序集匹配
-
在你的应用程序的Program类中将BrowserStorage注册为瞬态(查看持久化状态菜谱了解如何操作)
如何操作...
按照以下说明实现持久化状态的解决:
-
打开{ASSEMBLY_NAME}.lib.module.js脚本文件,通过添加一个get函数来增强browserStorage,该函数从存储中检索由type参数指定的持久化值:
get: function (type, key) { if (type === 'sessionStorage') { return sessionStorage.getItem(key); } if (type === 'localStorage') { return localStorage.getItem(key); } return ''; }, -
在BrowserStorage类中,引入一个新的_getFunc变量来保存新创建的browserStorage.getJavaScript 函数的名称:
private const string _getFunc = "browserStorage.get"; -
在BrowserStorage中实现一个ResolveAsync()通用方法,接受一个StorageValue参数,从适当的浏览器存储中获取持久化的值。利用JsonSerializer将检索到的字符串转换为预期的对象类型:
public async ValueTask<T> ResolveAsync<T>( StorageValue<T> @object) { var storage = @object is LocalStorageValue<T> ? _local : _session; var value = await _js.InvokeAsync<string>( _getFunc, storage, @object.Key); return JsonSerializer.Deserialize<T>(value); } -
转向Settings组件。在@code部分扩展一个新的ViewMode变量,并重写OnAfterRenderAsync()生命周期方法,其中包含解析持久化的viewMode值到ViewMode中的逻辑:
protected string ViewMode = string.Empty; protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) return; var value = new LocalStorageValue<string> { Key = _key }; ViewMode = await Storage.ResolveAsync(value); StateHasChanged(); } -
通过在按钮下方添加一个段落来增强Settings组件的标记,以显示当前的ViewMode值:
<p>@ViewMode</p>
它是如何工作的…
我们首先增强我们的browserStorage API。在步骤 1中,我们导航到{ASSEMBLY_NAME}.lib.module.js脚本文件,并使用get函数扩展browserStorage功能。我们模仿了set函数的实现,利用type参数选择适当的存储类型,并获取与指定key关联的值。
在步骤 2中,我们通过引入一个_getFunc变量来存储我们新创建的browserStorage.get函数的名称,从而完善我们的BrowserStorage服务实现,以防止未来引用中可能出现的拼写错误。随后,在步骤 3中,我们实现了一个通用的ResolveAsync()方法,该方法与现有的PersistAsync()方法的逻辑相匹配。ResolveAsync()接受一个StorageValue参数,使用is运算符识别正确的存储,并调用由IJSRuntime提供的InvokeAsync()通用方法从浏览器存储中提取value。由于此值返回为 JSON 字符串,我们利用JsonSerializer API 将此字符串转换回所需的数据类型。
在步骤 4中,我们转向Settings组件,在其中初始化一个ViewMode变量来保存用户持久化视图模式选择的解析值。然后我们重写OnAfterRenderAsync()生命周期方法,在其中我们使用注入的Storage服务和新引入的ResolveAsync()方法在Settings组件首次渲染时解析ViewMode值。利用快速返回模式,我们确保在后续组件状态变化时方法立即退出。由于 Blazor 在渲染后执行我们的解析逻辑,我们必须调用StateHasChanged()方法以刷新带有更新值的 UI。最后,在步骤 5中,为了简化,我们在Settings标记中的操作按钮下方添加一个段落来显示当前的ViewMode值。如果您有相应的 CSS,还可以将 CSS 类应用于 DOM 元素,以实现浅色和深色模式。
还有更多…
我们使用OnAfterRenderAsync()方法根据架构原因解决ViewMode值。Blazor 在组件初始化的初始阶段阻止所有IJSRuntime交互。在 DOM 创建之前,组件基本上处于设置阶段,初始化和获取所需数据。一旦渲染完成并且 DOM 结构就绪,Blazor 允许我们调用IJSRuntime API 并交互加载的 JavaScript 函数。
在交互式渲染模式边界之间共享状态
当你从仅在一个渲染模式下运行切换到混合渲染模式或使用InteractiveAuto模式时,Blazor 应用中的状态管理变得复杂。由于服务器和客户端环境之间缺乏自动状态共享,每次渲染模式更改都会重新创建作用域状态,这导致了这种碎片化。你可以通过指定一个单一、一致的状态持久化源来解决这个问题。在这个配方中,我们将深入研究一个策略,其中客户端是真相的来源,我们将从浏览器存储中恢复状态。
让我们实现一个通用的组件基类,以便我们可以在交互式渲染模式边界之间共享状态。
准备工作
在你深入研究状态共享之前,请执行以下操作:
-
创建一个Chapter05 / Recipe07目录——这将是你的工作目录
-
从“解析持久化状态”配方或从 GitHub 仓库的Chapters05 / Recipe06目录中复制BrowserStorage和StorageValue对象
-
如果你不是从本章的整个内容开始,而是从这个配方开始,请从 GitHub 仓库中的BlazorCookbook.App.Client的wwwroot目录中复制BlazorCookbook.App.Client.lib.module.js文件到你的项目的wwwroot,并将其重命名为与你的项目程序集匹配
-
在你的应用程序的Program类中将BrowserStorage注册为瞬态(查看持久化状态配方了解如何操作)
如何操作...
按照以下步骤在交互式渲染模式边界之间实现状态共享:
-
创建一个具有UpdateTime属性和Add()方法的CartState类,该方法模拟添加到购物车并刷新UpdateTime:
public sealed class CartState { public DateTime UpdateTime { get; set; } public void Add() => UpdateTime = DateTime.UtcNow; } -
导航到Program类并注册一个全局的CascadingValue用于CartState:
builder.Services .AddCascadingValue(it => new CartState()); -
创建一个实现IAsyncDisposable接口的通用CrossingInteractiveBoundary组件:
@implements IAsyncDisposable @typeparam T -
在CrossingInteractiveBoundary组件的@code部分中,定义一个唯一的状态标识符_key,注入BrowserStorage,并使用CascadingParameter来拦截通用的State:
private const string _key = "state"; [Inject] private BrowserStorage Storage { get; init; } [CascadingParameter] public T State { get; set; } -
仍然在@code块中,通过覆盖OnAfterRenderAsync()生命周期方法,使用SessionStorageValue通过Storage的ResolveAsync()方法获取持久化值来恢复State:
protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) return; var value = new SessionStorageValue<T> { Key = $"{_key}_{State.GetType()}" }; try { State = await Storage.ResolveAsync(value); StateHasChanged(); } catch { } } -
最后,在@code块中,为了在销毁时保留状态,实现一个DisposeAsync()方法,这是由IAsyncDisposable契约要求的,并通过PersistAsync()方法将更新后的State发送回浏览器存储:
public ValueTask DisposeAsync() { var value = new SessionStorageValue<T> { Key = $"{_key}_{State.GetType()}", Value = State }; return Storage.PersistAsync(value); } -
创建一个在InteractiveAuto渲染模式下操作的Cart组件,并从CrossingInteractiveBoundary
类型继承: @page "/ch05r07" @rendermode InteractiveAuto @inherits CrossingInteractiveBoundary<CartState> -
在Cart组件的布局中,添加一个触发State的Add()方法的按钮和一个用于显示UpdateTime属性当前值的段落:
<button @onclick="@(() => State.Add())"> Add to cart </button> <p>Last cart change: @State.UpdateTime</p>
它是如何工作的……
在步骤 1中,我们创建了一个具有UpdateTime属性和刷新UpdateTime为当前 UTC 的Add()方法的CartState状态类,展示了动态状态交互。在步骤 2中,我们导航到Program类,并利用 Blazor 的AddCascadingValue()扩展方法,将CartState声明为全局可访问的CascadingValue,确保状态对象在整个应用程序中可用。我们声明购物车的初始状态由CartState对象的新实例表示。
在步骤 3中,我们引入了我们状态共享机制的核心——CrossingInteractiveBoundary组件。通过实现IAsyncDisposable接口,我们为CrossingInteractiveBoundary准备了一个异步销毁逻辑。在步骤 4中,我们定义了一个唯一的_key存储标识符,并将BrowserStorage注入以在传统的生命周期边界之外持久化状态。通过CascadingParameter属性,我们动态捕获State值,无论当前期望的类型如何。在步骤 5中,我们重写了CrossingInteractiveBoundary的OnAfterRenderAsync()生命周期方法,使用从浏览器会话存储中持久化的先前值重新激活State。我们利用try-catch结构优雅地处理用户首次初始化状态时没有值可以恢复的情况。在步骤 6中,我们通过DisposeAsync()方法完成实现,但不是释放资源,而是添加逻辑使用Storage的PersistAsync()方法持久化State值。这样,我们确保状态保持更新并可恢复,无论导航操作或渲染模式转换。
在步骤 7中,我们引入了在动态InteractiveAuto模式下渲染的Cart组件,并从CrossingInteractiveBoundary继承,状态由CartState对象表示。Blazor 将在服务器端和客户端渲染之间无缝切换,这使得它成为展示CrossingInteractiveBoundary自适应状态共享逻辑的完美环境。在步骤 8中,我们添加了Cart标记——一个调用CartState的Add()方法的按钮和一个显示当前UpdateTime值的段落。
还有更多...
在这个配方中我们探索的策略不仅限于级联参数。你还可以利用 BrowserStorage 来激活并持久化作为服务注入的状态(你已经在 将应用状态注入为服务 配方中学习了如何实现可注入状态)。根据你的架构需求,你可以利用 REST API 或 gRPC 服务,并在服务器上持久化状态。此外,随着状态对象复杂性和大小的增长,你会发现状态激活可能会在 UI 刷新正确数据之前造成明显的延迟。这就是我们在 从任何地方调用状态变化 配方中实现的 Overlay 组件派上用场的地方。通过暂时遮挡 UI 直到状态解析完成,我们确保用户体验到无缝且一致的用户界面。
第六章:构建交互式表单
在本章中,我们将关注构建 Blazor 中交互式表单所需的基本技能。表单是许多网络应用程序的关键组件,Blazor 提供了简化表单创建和处理的工具。
我们将首先学习如何将简单和嵌套模型绑定到表单以捕获和管理用户输入。接下来,我们将探索 Blazor 提供的内置输入组件。这些组件有助于在不同平台上标准化表单行为,确保一致性并减少所需的自定义代码量。我们还将介绍解释按键和使表单直观的技术。在本章结束时,我们将讨论表单处理的安全性方面以及反伪造令牌的作用。实施这些安全措施对于保护你的应用程序免受常见的网络威胁,如跨站请求伪造(CSRF)攻击至关重要。
到本章结束时,你将具备在 Blazor 应用程序中创建、管理和保护表单的实用知识——这对于开发可靠、交互式和用户友好的网络应用程序至关重要。
这是本章我们将涵盖的菜谱列表:
-
将简单模型绑定到表单
-
提交静态表单而无需完整页面刷新
-
将嵌套模型绑定到表单
-
利用内置输入组件
-
使用表单处理文件上传
技术要求
我们将保持示例简单,专注于展示在 Blazor 中设置表单的所有角度。在每个菜谱的开头,你将找到有关查找所需样本和创建哪些目录的说明。也就是说,你需要以下基本工具进行 Blazor 开发:
-
一个支持 Blazor 开发的现代 IDE
-
一个支持 WebAssembly 的现代网络浏览器
-
浏览器 DevTools(可能是现代浏览器的一部分)
-
一个 Blazor 项目(你将在其中编写代码)
在 使用表单处理文件上传 菜谱中,我们将使用一个 NuGet 包 – Microsoft.AspNetCore.Http.Features – 该包默认未安装,因此你现在可以将其添加到你的项目中。
你可以在 GitHub 上找到所有代码示例:github.com/PacktPublishing/Blazor-Web-Development-Cookbook/tree/main/BlazorCookbook.App.Client/Chapters/Chapter06
将简单模型绑定到表单
在现代网络应用程序的开发中,表单无处不在且至关重要。无论是注册用户详细信息、收集反馈还是输入信息,表单都是用户输入的主要界面。Blazor 支持传统的 HTML 标记,但通过其本地的 EditForm 组件提升了用户体验。EditForm 与 Blazor 的数据绑定功能无缝集成,并提供了一种简化和高效的表单管理方法。
让我们添加第一个小型表单,该表单绑定到简单的数据模型,并允许用户通过提供其名称来创建新事件。
准备工作
在我们开始创建表单并将其绑定到简单模型之前,请执行以下操作:
-
创建一个 Chapter06 / Recipe01 目录 – 这将是你的工作目录
-
从 GitHub 仓库中的 Chapter06 / Data 目录复制 Models.cs 文件
如何操作…
要实现支持简单数据模型的表单,请按照以下步骤操作:
-
创建一个可路由的 EventManager 组件:
@page "/ch06r01" -
在 EventManager 的 @code 块中,声明一个 Model 对象,并用 SupplyParameterFromForm 属性进行装饰:
[SupplyParameterFromForm] protected Event Model { get; set; } -
仍然在 @code 块中,如果未设置,则使用条件初始化重写 OnInitialized() 生命周期方法。此外,实现一个 Save() 方法作为占位符来模拟保存表单:
protected override void OnInitialized() => Model ??= new(); private void Save() => Console.WriteLine($"Saved {Model.Name}."); -
在 EventManager 的标记中嵌入一个 EditForm 组件,并将其绑定到 Model 参数。包括一个用于输入 Model.Name 的输入字段和一个触发 Save() 方法的提交按钮:
<EditForm FormName="event-form" Model="@Model" OnSubmit="@Save"> <label> Name: <InputText @bind-Value="@Model.Name" /> </label> <button type="submit">Save</button> </EditForm>
它是如何工作的…
在 步骤 1 中,我们创建一个可路由的 EventManager 组件,它将作为我们表单的容器。
在 步骤 2 中,在 EventManager 的 @code 块中,我们为我们的表单声明一个 Model 参数,并使用一个特定于表单的属性 – SupplyParameterFromForm – 使 Blazor 能够自动将关联表单的值填充到 Model 对象中。在 步骤 3 中,我们最终确定 EventManager 的 @code 块。我们重写 OnInitialized() 生命周期方法,以便无缝地将 Model 参数初始化为空对象,除非它已经包含值。此外,我们引入一个 Save() 方法,作为占位符来模拟保存对表单所做的更改。
在 步骤 4 中,我们处理 EventManager 标记的实现,利用 Blazor 内置的 EditForm 组件。我们将我们的 Model 对象分配给 EditForm 组件的 Model 参数,并将 Save() 方法分配给 OnSubmit 回调,以便在表单提交时自动调用 Save()。关键的是,我们为 EditForm 的 FormName 参数设置一个唯一值,允许 Blazor 正确解析表单数据。在表单中,我们包含一个简单的输入框,将其绑定到 Model.Name 属性,并包含一个提交按钮以方便表单提交。
我们有意不声明任何渲染模式,导致我们的页面在服务器端进行静态渲染。虽然这种方法确保了快速渲染和服务器上资源利用的最小化,但提交表单需要完整的页面刷新 – 类似于 MVC 或 Razor pages 应用程序。
还有更多
EditForm组件的每个参数都有一个对应的 Blazor 属性,与标准 HTML 兼容,这意味着您可以在不依赖于EditForm组件的情况下广泛自定义表单的行为。您可以保留标准的 HTML 标记,并根据需要对其进行自定义。
为了给您一个实际示例,以下是您可以使用 HTML 标记实现我们的表单的方法:
<form method="post"
@onsubmit="@Save"
@formname="event-form">
<AntiforgeryToken />
<label>
Name:
<InputText @bind-Value="@Model.Name" />
</label>
<button type="submit">Save</button>
</form>
我们构建了一个简单的表单,利用默认的 HTML form元素。我们声明了表单的唯一名称,提交时调用的方法,以及 Blazor 在提交数据时应执行post操作。然而,由于我们不再使用EditForm组件,Blazor 将强制我们提供防伪造令牌以进行安全原因。为此,我们利用内置的AntiforgeryToken组件,但我们将详细探讨该组件,在章节末尾的使用防伪造令牌保护表单配方中。
无需完整页面刷新提交静态表单
Blazor 利用差异算法(我们在第三章中的挂钩到事件委托配方中讨论过),提供了一个增强导航功能,通过减少不必要的重新渲染,仅更新 UI 中已更改的部分而不是重新加载整个页面来优化用户交互。交互式渲染模式默认启用差异算法,但以静态服务器端渲染(SSR)模式渲染的表单则不启用。在本配方中,我们将探讨如何使用Enhance参数在EditForm组件上启用增强导航。
让我们启用Event创建表单的增强导航,并在保持其 SSR 模式操作的同时,防止表单在提交时重新加载整个页面。
准备工作
在我们探索表单增强之前,请执行以下操作:
-
创建Chapter06 / Recipe02目录——这将成为您的工作目录
-
从将简单模型绑定到表单配方或从 GitHub 仓库中的Chapter06 / Recipe01目录复制EventManager组件
-
从 GitHub 仓库中的Chapter06 / Data目录复制Models.cs文件
如何操作…
- 要在您的表单上启用增强导航,请导航到EventManager组件,并设置EditForm组件的Enhance参数值:
<EditForm FormName="event-form"
Model="@Model"
OnSubmit="@Save"
Enhance>
@* form body *@
</EditForm>
它是如何工作的…
在本配方中,我们导航到EventManager组件,并在EditForm组件上设置Enhance参数的值。由于Enhance是bool类型,仅声明参数名称就相当于声明Enhance="true"。这个简单的调整就足以在您的表单上启用增强导航。如果您一直在开发 MVC 应用程序,您可以在表单未增强时使用Html.BeginForm,并在Enhance属性就位时使用Ajax.BeginForm来概念化增强。
尽管EventManager组件在服务器上继续以静态方式渲染,但激活了增强导航后,Blazor 现在更有效地监控 UI 变化。当用户提交表单时不再需要完整页面刷新,这导致用户体验更加流畅和响应,同时仍然利用了 SSR 的好处。
还有更多...
与EditForm组件的其他参数类似,Enhance参数有一个与纯 HTML 表单兼容的等效属性——data-enhance。
这就是如何将data-enhance附加到你的标签上的方法:
<form method="post"
@onsubmit="@Save"
@formname="event-form"
data-enhance>
@* form body *@
</form>
我们利用默认的 HTML form元素并声明表单的唯一名称、提交时调用的方法以及 Blazor 在提交数据时应执行post操作。在那些已经熟悉的属性旁边,我们附加了data-enhance属性。属性的顺序不会影响表单的功能。
将嵌套模型绑定到表单
在本食谱中,我们将探索 Blazor 中表单内嵌套模型的管理。嵌套模型是包含其他模型作为属性的复杂数据结构。当我们捕获详细或结构化信息时,它们很常见,例如具有多个地址的用户资料或具有多个项目的订单。然而,随着数据结构深度的增加,管理具有复杂和嵌套数据模型的表单可能会变得难以控制。跟踪每个输入字段并确保适当的绑定可能具有挑战性,这使得表单难以维护且更容易出错。Editor
让我们通过嵌套对象增强我们的活动创建表单,以便我们可以添加有关活动持续时间的详细信息。
准备工作
在开始实现嵌套表单之前,请执行以下操作:
-
创建Chapter06 / Recipe03目录——这将是你的工作目录
-
从Submitting static forms without full page reload食谱或从 GitHub 仓库的Chapter06 / Recipe02目录复制EventManager组件
-
从 GitHub 仓库中的Chapter06 / Data目录复制Models.cs文件
如何做到这一点...
按照以下步骤实现可维护的嵌套表单:
-
创建一个继承自Editor
的EventDurationForm组件: @inherits Editor<EventPeriod> -
在EventDurationForm组件的标记中,使用InputDate组件并添加两个字段来设置基础EventPeriod模型的Start和End属性:
<label> From: <InputDate @bind-Value="@Value.Start" /> </label> <label> To: <InputDate @bind-Value="@Value.End" /> </label> -
在EventManager组件中,导航到@code块并扩展Save()方法以将Model.Period的详细信息记录到控制台:
private void Save() { Console.WriteLine($"Saved {Model.Name}."); Console.WriteLine( $"{Model.Period.Start} - {Model.Period.End}" ); } -
在EventManager标记中,将EventDurationForm组件的实例集成到EditForm中,在现有的标签和submit按钮之间,并将其绑定到Model.Period嵌套属性:
<label> Name: <InputText @bind-Value="@Model.Name" /> </label> <EventDurationForm @bind-Value="@Model.Period" /> <button type="submit">Save</button>
它是如何工作的…
在步骤 1中,我们创建了EventDurationForm组件,专门用于处理事件的期间设置。为了正确引用必要的模型,我们包括一个@using指令用于EventPeriod模型的程序集,并使用@inherits指令从泛型Editor
在步骤 3中,我们将注意力转向EventManager组件。在@code块中,我们扩展了Save()占位符方法,将Model.Period的详细信息记录到控制台。这将使我们能够验证嵌套模型的绑定。最后,在步骤 4中,我们转向EventManager组件的标记,并将EventDurationForm组件集成到现有的EditForm标记中,就在提交按钮之前。通过使用 bind-Value 模式,我们直接将Model.Period对象绑定到EventDurationForm。
我们有效地封装了管理事件期间的标记和逻辑,而没有使主要的EditForm实例复杂化。
还有更多…
在探索EditForm功能的所有食谱中,我们一直使用静态服务器渲染。这是一个战略选择,允许你突出显示任何潜在边缘情况和 SSR 模式的特殊性,并提供对表单在不同渲染条件下行为的全面理解。
然而,如果你选择使用 Blazor 中可用的任何交互式渲染模式,表单将继续正常工作。通过向你的组件添加@renderMode指令,你可以根据应用程序的需求轻松地在渲染模式之间切换。无论你需要服务器端渲染的健壮性和安全性,还是客户端渲染的交互性和速度,EditForm都将平稳高效地运行。
利用内置输入组件
在本食谱中,我们将探讨如何快速设置简单和复杂表单,使用 Blazor 的本地表单支持和内置输入组件。使用 Blazor 的好处在于它能够处理在表单创建中涉及的大量繁重工作,如数据绑定、事件处理、维护状态或解析用户输入到预期值。然后你可以自由地关注用户界面的其他方面。
让我们通过创建一个全面的表单来展示 Blazor 的内置输入组件,在这个表单中,系统管理员可以详细定义他们计划举办的活动。
准备工作
在构建事件创建器之前,执行以下操作:
-
创建一个Chapter06 / Recipe04目录 – 这将是你的工作目录
-
从绑定嵌套模型到表单食谱或从 GitHub 仓库的Chapter06 / Recipe03目录复制EventManager和EventDurationForm组件,或复制它们的实现:
-
从 GitHub 仓库中的Chapter06 / Data目录复制Models.cs文件
如何操作...
按照以下步骤实现具有内置输入组件的事件创建器:
-
打开EventManager组件,并在@code块中更新Save()方法:
private void Save() => Console.WriteLine($"Saved: {Model.Json}"); -
导航到EventManager组件的标记部分,找到EditForm标记。所有后续步骤都将在这个表单内进行。
-
将现有的InputText实例、EventDurationForm实例和保存按钮分别包裹在单独的段落< p>标签中:
<p> Name: <InputText @bind-Value="@Model.Name" /> </p> <p> <EventDurationForm @bind-Value="@Model.Period" /> </p> <p> <button type="submit">Save</button> </p> -
在一个新的段落中,位于EventDurationForm段落下方,添加一个InputCheckbox组件并将其绑定到Model.IsActive属性:
<p> <InputCheckbox @bind-Value="@Model.IsActive" /> Active </p> -
添加另一个段落 – 这次使用InputNumber组件 – 并将其绑定到Model.Capacity属性:
<p> Capacity <InputNumber @bind-Value="@Model.Capacity" /> </p> -
创建另一个段落,并在其中嵌入InputSelect组件,将其绑定到Model.Type属性。使用EventType枚举的值来渲染选择选项:
<p> <InputSelect @bind-Value="@Model.Type"> @foreach (var type in Enum.GetValues<EventType>()) { <option value="@type">@type</option> } </InputSelect> </p> -
在另一个段落中,包含一个InputRadioGroup组件,并将其绑定到Model.Location属性。使用EventVenues.All值和一个InputRadio组件来渲染每个单选选项:
<p> <InputRadioGroup @bind-Value="@Model.Location"> @foreach (var venue in EventVenues.All) { <InputRadio Value="@venue" />@venue } </InputRadioGroup> </p> -
在一个单独的段落中,放置一个InputTextArea组件并将其绑定到Model.Description属性:
<p> <InputTextArea @bind-Value="@Model.Description" /> </p>
工作原理...
在本食谱中,我们增强了EventManager组件。在步骤 1中,我们导航到@code块并更新Save()方法,在这里,利用Model.Json自动属性,我们将整个Model对象转换为 JSON,并将其结果写入控制台,以便我们可以查看已保存的Model实例的状态。
在步骤 2中,我们将注意力转向EventManager组件标记中的EditForm标记。在步骤 3中,我们使用
标签将现有的字段组织成有结构的段落。我们使用InputText组件来渲染一个与Model.Name属性绑定的文本输入元素,使用户能够设置事件的名称。接下来,使用EventDurationForm组件,我们封装了事件期间的设置,利用InputDate组件进行日期输入。InputDate支持多种时间格式,并包含一个内置的日历选择器——即插即用。我们通过将提交按钮包裹在另一组
标签中,完成了表单的结构化。
在步骤 4中,我们在新段落中引入了一个InputCheckbox组件,并将其绑定到Model.IsActive属性。InputCheckbox渲染一个复选框输入类型,非常适合处理bool属性,因此我们允许用户切换事件的活动状态。
在步骤 5中,我们在另一个段落中添加了一个InputNumber组件,并将其链接到Model.Capacity属性。InputNumber接受任何原始数值类型,这使得它非常适合设置事件参与者的最大数量。在步骤 6中,我们在另一个段落中嵌入了一个InputSelect组件,并将其绑定到Model.Type属性,以方便选择事件类型。InputSelect是一个泛型组件,因此您可以轻松地覆盖多种对象。然而,请记住,select选项的value必须是原始类型。在我们的表单中,我们通过遍历样本Data目录中的EventType枚举来填充下拉菜单。
在步骤 7中,我们允许用户选择事件地点。我们渲染了一个InputRadioGroup组件,并将其绑定到Model.Location属性。我们还渲染了多个InputRadio组件,每个组件代表EventVenues.All样本集合中的一个地点。Blazor 自动将所有InputRadio组件的范围限定在最近的父元素,但将它们包裹在InputRadioGroup内可以暴露出复选框组的额外功能,并给我们更多的控制权。
在步骤 8中,我们在最后一段中添加了一个InputTextArea组件,为Model.Description属性提供了一个文本区域。InputTextArea生成一个类型为textarea的输入——非常适合较长的描述,尽管它不是一个富文本编辑器。
我们刚刚构建的表单看起来很简单,但它通过很少的编码工作就渲染了完全功能、安全和有组织的标记:

图 6.1:仅使用内置输入组件构建的功能性、安全性和结构化表单
使用表单处理文件上传
在本食谱中,我们深入探讨在 Blazor 应用程序中管理文件上传。对于任何需要用户上传文档或图像的现代网络应用程序来说,文件上传至关重要。InputFile 组件通过其简单但全面的 API 简化了文件上传的集成。此外,通过一些额外的编码,您可以启用文件上传的 拖放 行为。
让我们添加一个简单的表单,允许用户上传代表活动封面的文件。
准备工作
在我们实现带有文件上传的表单之前,执行以下操作:
-
创建一个 Chapter06 / Recipe05 目录——这将是你的工作目录
-
从 GitHub 仓库中的 Chapter06 / Data 目录复制 FileStorage
如何操作…
按照以下步骤在交互式表单中启用文件上传:
-
打开你的应用程序的 Program 文件,并将 FileStorage 服务添加到依赖注入容器中:
builder.Services.AddTransient<FileStorage>(); -
创建一个具有单个 File 属性的 EventCover 类,该属性的类型为 IBrowserFile :
public class EventCover { public IBrowserFile File { get; set; } } -
创建一个新的可路由的 CoverUploader 组件,以 InteractiveWebAssembly 模式渲染:
@page "/ch06r05" @rendermode InteractiveWebAssembly -
在 CoverUploader 的 @code 块中,注入 FileStorage 服务并初始化一个类型为 EventCover 的 Model 变量:
[Inject] private FileStorage Storage { get; init; } public EventCover Model = new(); -
仍然在 @code 块中,实现一个 FileChanged() 方法,该方法接受 InputFileChangeEventArgs 参数并将文件数据分配给 Model.File :
private void FileChanged(InputFileChangeEventArgs e) => Model.File = e.File; -
最后,在 @code 块中,实现一个 SaveAsync() 方法,该方法从 Model 实例初始化文件上传,使用 FileStorage 服务:
private Task SaveAsync() { using var stream = Model.File.OpenReadStream(); return Storage.UploadAsync(stream); } -
在 CoverUploader 标记中,添加一个 EditForm 组件,将其绑定到 Model 实例,并将 SaveAsync() 方法附加到 OnSubmit 表单回调中:
<EditForm FormName="cover-upload" Model="@Model" OnSubmit="@SaveAsync"> </EditForm> -
在 EditForm 标记内部,添加一个调用 FileChanged() 方法的 InputFile 组件,该组件使用其 OnChange 事件,并添加一个简单的提交按钮。将这两个元素包裹在段落中:
<p><InputFile OnChange="FileChanged" /></p> <p><button type="submit">Save</button></p>
它是如何工作的…
在 步骤 1 中,我们导航到应用程序的 Program 文件,并将 FileStorage 服务注册到依赖注入容器中。FileStorage 是一个假服务,它假装将文件上传到您选择的存储。在 步骤 2 中,我们创建了一个具有单个 File 属性的 EventCover 类,该属性的类型为 IBrowserFile 。IBrowserFile 接口表示从用户接收到的文件,封装了文件名、内容类型、大小等属性以及访问文件内容的各种方法。
在步骤 3中,我们创建了一个可路由的CoverUploader组件,并将其设置为以InteractiveWebAssembly模式渲染,以在我们的表单上启用交互性。在步骤 4中,在CoverUploader组件的@code块内,我们注入了FileStorage服务,以利用其 API 来管理传入的文件。然后我们初始化一个类型为EventCover的Model对象,这构成了我们表单的骨干。在步骤 5中,在同一个@code块内,我们实现了一个处理InputFileChangeEventArgs的FileChanged()方法。InputFileChangeEventArgs对象包含一个IBrowserFile有效负载,我们将其分配给我们的Model实例的File属性,捕获用户选择的文件。在步骤 6中,我们添加了一个SaveAsync()方法,其中我们将File值读入一个流,并使用FileStorage.UploadAsync()方法将文件字节上传到我们选择的存储。我们利用using关键字来确保有效的资源管理和没有内存泄漏。在方法内部,using关键字与一个IDisposable对象一起工作,创建一个临时、可丢弃的作用域,当方法执行完成后自动丢弃所附加的对象。
在步骤 7中,我们使用EditForm组件设置了CoverUploader的标记。我们给表单赋予一个唯一的名称,将其绑定到我们的Model实例,并将SaveAsync()方法作为提交的回退。最后,在步骤 8中,我们构建了EditForm组件的主体。我们包含了InputFile组件,并将其OnChange回调绑定到FileChanged()方法。OnChange事件与我们的@code块中的FileChanged()逻辑无缝集成,处理用户发起的文件选择。我们还添加了一个简单的提交按钮,该按钮激活表单的OnSubmit回调。
最后,您的表单应该看起来像我的一样:

图 6.2:包含 InputFile 组件和提交按钮的表单
关于拖放功能呢?实际上,InputFile组件本身支持拖放功能!尽管它看起来像一个按钮,但InputFile渲染了一个已经启用了拖放功能的input区域——您不需要添加任何额外的代码或任何额外的属性。您可能想要添加一些额外的样式,使InputFile看起来像一个拖放区域,但功能是现成的。
最后,我们没有为上传实现任何文件类型或大小的验证(我们将在第七章中探讨验证)。对于企业级应用程序,您必须考虑设置此类边界,以保护您的基础设施以及服务器资源。
还有更多...
如果你想支持文件上传并利用最新的 SSR 渲染模式呢?在 SSR 模式下,Blazor 在服务器端预渲染组件,并仅提供静态标记而不具备任何交互性,因此你无法拦截用户尝试上传的文件。然而,如果我们考虑启用增强导航并利用enctype属性,即使在 SSR 模式下,上传文件也能正常工作。enctypeHTML 属性指定浏览器在提交表单到服务器时应如何编码表单数据。
让我们修改现有的交互式表单,使其在 SSR 模式下渲染,同时允许用户上传文件:
-
通过将File属性类型从IBrowserFile更改为IFormFile来更新EventCover类。这是Microsoft.AspNetCore.Http.Features包的一部分,因此你可能需要事先将其添加到你的项目中:
public class EventCover { public IFormFile File { get; set; } } -
接下来,调整CoverUploader组件以在 SSR 模式下渲染,通过删除@ renderMode指令。
-
在CoverUploader组件的@code块中,将Model转换为属性,并用SupplyParameterFromForm进行装饰以启用表单数据的自动绑定:
[SupplyParameterFromForm] public EventCover Model { get; set; } -
仍然在@code块中,覆盖OnInitialized()生命周期方法以遵循 SSR 表单绑定模式,并移除FileChanged()方法,因为我们不再需要它了:
protected override void OnInitialized() => Model ??= new(); -
在CoverUploader标记内,通过添加Enhance属性来增强EditForm组件,该属性激活了增强导航,并包含具有值multipart/form-data的enctype属性:
<EditForm FormName="cover-upload" Model="@Model" OnSubmit="@SaveAsync" Enhance enctype="multipart/form-data"> @* ... *@ </EditForm> -
最后,将InputFile组件上的OnChange回调赋值替换为nameHTML 属性,并将其值设置为与Model.File属性匹配,这样 Blazor 就能直接从表单绑定选定的文件:
<p><InputFile name="Model.File" /></p>
使用反伪造令牌保护表单
在这个菜谱中,我们探讨了 Web 安全的一个基本方面——保护你的应用程序免受 CSRF 攻击。CSRF 攻击利用了我们的应用程序与用户浏览器之间的信任,使得浏览器使用用户的身份执行不希望的操作。反伪造令牌,也称为 CSRF 令牌,是一项重要的安全措施,你必须使用它来确保发送到服务器的请求是真实的,并且来自合法用户,而不是攻击者。在表单中嵌入反伪造令牌实际上创建了一个与每个 POST 请求一起发送的唯一密钥。服务器在收到请求时检查此令牌;如果令牌不存在或是不正确的,请求将被拒绝,从而防止未经授权的操作。
让我们使用 Blazor 提供的反伪造令牌实现来保护我们的事件创建表单。
准备工作
在我们探索使用反伪造令牌保护表单之前,请执行以下操作:
-
创建一个Chapter06 / Recipe06目录 – 这将是你的工作目录
-
从 GitHub 仓库中的Chapter06 / Data目录复制Models.cs文件
如何操作…
按照以下说明使用防伪造令牌保护您的表单:
-
在您解决方案的服务器端,导航到程序文件,并在中间件配置区域注册防伪造中间件:
var app = builder.Build(); //... app.UseStaticFiles(); app.UseAntiforgery(); //... app.Run(); -
创建一个可路由的EventManager组件:
@page "/ch06r06" -
在EventManager组件的@code块内部,声明一个类型为Event的Model对象,并用SupplyParameterFromForm属性进行装饰:
[SupplyParameterFromForm] protected Event Model { get; set; } -
仍然在EventManager的@code块中,重写OnInitialized()生命周期方法,以有条件地初始化尚未设置的Model实例,并实现一个Save()方法来模拟保存表单数据的过程:
protected override void OnInitialized() => Model ??= new(); private void Save() => Console.WriteLine($"Saved {Model.Name}."); -
在EventManager组件的标记部分,构建一个具有唯一名称的标准 HTML 表单,在提交时触发Save()方法:
<form method="post" @onsubmit="@Save" @formname="event-form"> </form> -
在区域中,包括一个与Model.Name属性链接的文本输入字段和一个提交按钮。最重要的是,在表单中嵌入AntiforgeryToken组件:
<AntiforgeryToken /> <label> Name: <InputText @bind-Value="@Model.Name" /> </label> <button type="submit">Save</button>
它是如何工作的…
在步骤 1中,我们导航到服务器端项目的Program文件以启用防伪造安全。我们在中间件区域使用app.UseAntiforgery()扩展方法。中间件注册的顺序至关重要;您必须根据其他正在使用的中间件深思熟虑地定位防伪造中间件。如果您的应用程序包含身份验证和授权,确保app.UseAntiforgery()放置在app.UseAuthentication()和app.UseAuthorization()之后。如果您已配置路由,则在注册端点中间件之前,将防伪造中间件放置在app.UseRouting()之后,但在app.UseEndpoints()之前。
在步骤 2中,我们创建一个可路由的EventManager组件,并使用@using指令包含必要的程序集引用以访问Event类型。在步骤 3中,在EventManager组件的@code块内部,我们声明一个类型为Event的Model属性以支持我们的表单。我们利用SupplyParameterFromForm属性来启用Model和表单字段之间的自动数据绑定。在步骤 4中,我们重写OnInitialized()方法以有条件地初始化如果它仍然是空的Model实例。我们还实现了一个Save()方法作为模拟保存表单更改的占位符。在步骤 5中,我们转向EventManager组件的标记。我们构建一个标准的 HTML 表单,使用标签,在提交时触发Save()方法。在 Blazor 中,每个表单都必须有一个唯一的名称,因此我们使用@formname属性并将我们的表单命名为event-form。在步骤 6中,我们实现表单主体。首先,我们嵌入AntiforgeryToken组件。接下来,我们添加一个文本输入字段供用户输入事件名称,并将其绑定到Model.Name属性。最后,我们包括一个Save按钮以启用表单提交。
在放置了 AntiforgeryToken 组件之后,Blazor 会生成一个包含反伪造令牌的隐藏表单字段。我们已经在表单的顶部嵌入了 AntiforgeryToken 实例,但由于它是一个隐藏字段,您可以将其放置在表单的任何位置,只要它仍然是表单的一部分即可。令牌本身是 DOM 的一部分,因此您可以使用浏览器的发展工具检查其值:

图 6.3:检查作为表单标记一部分生成的反伪造令牌
还有更多...
由于其原生的 Blazor 集成和广泛的 API,我建议您为所有表单使用 EditForm 组件。那么,为什么我们没有涵盖将反伪造令牌添加到 EditForm 标记中的内容呢?原因很简单:EditForm 内置了反伪造支持。Blazor 会自动保护 EditForm 实例,从而让您无需显式处理 CSRF 保护。
此外,我们完全跳过了客户端应用程序反伪造令牌的实现。Blazor WebAssembly 应用完全在浏览器中运行,没有服务器端处理管道,在那里您通常会配置中间件,例如 app.UseAntiforgery()。如果您的 Blazor WebAssembly 应用与服务器端 API 交互,您应该在 API 层面上管理反伪造。然而,如果您已经使用 基于令牌的认证 来保护通信,反伪造令牌通常是不必要的。基于令牌的认证由于其本质,减轻了与 CSRF 相关的风险,使得额外的反伪造令牌变得多余。我们将在 第八章 中进一步探讨身份验证和授权。
参见
如果您想了解更多关于基于令牌的身份验证的信息,您可以查看以下资源:
learn.microsoft.com/en-us/xandr/digital-platform-api/token-based-api-authentication
第七章:验证用户输入表单
在本章中,我们探讨了确保通过 Blazor 应用程序中的表单提交的数据准确性和完整性的基本方面。通过有效的验证,你可以防止错误的数据输入,并增强用户交互和应用程序安全性。在本章中,我们将探讨你在验证用户输入时可以采用的一系列技术和策略。
我们从向表单添加验证的基本流程开始,为更复杂的验证场景奠定基础。你将学习 Blazor 如何处理基本的验证场景,以及如何扩展它们以满足特定的领域需求。之后,我们将探讨使用数据注释进行表单验证的方法。你将发现如何使用内置注释简化表单验证,以及如何利用它们直接在数据模型上强制执行规则和约束,从而减少样板代码。紧接着,你将看到如何实现自定义验证属性,这些属性提供了处理独特业务需求的灵活性。然后,我们将讨论复杂数据模型的验证,确保即使在复杂场景中也能保持数据完整性。
在本章结束时,我们专注于提高表单的用户体验。我们涵盖了验证消息的风格化和验证摘要的现代化。良好的风格使验证消息清晰并与应用程序的设计更一致,而提示信息提供了一种动态的方式来提醒用户问题,而不会打断他们的工作流程。最后,我们探讨了如何根据验证结果动态控制表单操作,确保用户只能在有效状态下提交表单,从而避免不必要的提交和服务器负载。
到本章结束时,你将了解如何在 Blazor 应用程序中实现有效的验证策略,确保正确的用户输入,并提高应用程序的可用性和可靠性。
在本章中,我们将介绍以下食谱列表:
-
向表单添加验证
-
利用数据注释进行表单验证
-
实现自定义验证属性
-
验证复杂的数据模型
-
风格化验证消息
-
当验证失败时显示提示信息
-
根据表单状态启用提交选项
技术要求
本章中的食谱是相互关联的,因此你可以在同一目录中跟随整个旅程。然而,为了清晰和更容易的初始设置,在每个食谱的开始,你将找到有关创建哪个工作目录以及需要哪些文件来执行当前任务的说明。但在深入之前,请确保你拥有所有 Blazor 开发的基本工具:
-
一个支持 Blazor 开发的现代 IDE
-
一个支持 WebAssembly 的现代网络浏览器
-
浏览器开发工具(可能已经是现代浏览器的一部分)
-
一个 Blazor 项目(你将在其中编写代码)
在Validating complex data models食谱中,我们使用了Microsoft.AspNetCore.Components.DataAnnotations.Validation NuGet 包,该包默认未预安装,因此你现在可以将其添加到项目中。请注意,验证包仍在预览中,因此你必须在 IDE 中的 NuGet 源中包含预发布包。
你可以在 GitHub 上找到本章中编写的所有代码和代码示例:
向表单添加验证
在本食谱中,我们将探讨 Blazor 中用户输入验证的基础。验证对于防止错误和安全漏洞、维护数据一致性以及提升用户体验至关重要。Blazor 社区已经创建了各种 NuGet 包来处理输入验证,提供了一系列功能和配置。然而,Blazor 提供了广泛的内置支持,用于以用户友好的方式验证表单并显示验证结果。原生功能轻量级,并直接与 Blazor 的数据绑定和 UI 功能集成。
让我们实现一个小型事件创建表单,用户必须提供事件名称。我们还将显示当事件名称为空时的验证消息。
准备工作
在我们向表单添加第一个、基本的验证之前,创建一个Chapter07 / Recipe01目录——这将是你的工作目录。
如何实现...
按照以下步骤向表单添加简单的验证:
-
创建一个具有名称属性的Event类——我们将将其用作表单模型:
public class Event { public string Name { get; set; } } -
创建一个可路由的EventManager组件,该组件实现了IDisposable接口。你现在将看到编译错误,但我们将在稍后解决它们:
@page "/ch07r01" @implements IDisposable -
在EventManager的@code块中,声明一个Event模型作为表单的后备模型:
[SupplyParameterFromForm] public Event Model { get; set; } -
在模型参数声明下方,引入用于表单状态管理的上下文和存储变量:
protected EditContext Context; protected ValidationMessageStore Store; -
仍然在@code块中,实现一个Save()占位符方法来模拟表单提交:
private void Save() => Console.WriteLine($"Saved {Model.Name}."); -
在Save()旁边,实现一个ValidateForm()方法,其签名与EventHandler验证的响应相匹配,该验证检查Model.Name属性是否有有效的值:
private void ValidateForm(object sender, ValidationRequestedEventArgs args) { Store.Clear(); if (string.IsNullOrWhiteSpace(Model.Name)) Store.Add(() => Model.Name, "You must provide a name."); } -
在@code块中继续,重写OnInitialized()生命周期方法以初始化Model实例(如果需要)并设置表单上下文以及验证消息容器:
protected override void OnInitialized() { Model ??= new(); Context = new(Model); Context.OnValidationRequested += ValidateForm; Store = new(Context); } -
通过实现Dispose()方法来最终化@code块,以符合IDisposable要求并取消订阅验证事件处理程序:
public void Dispose() { if (Context is not null) Context.OnValidationRequested -= ValidateForm; } -
在 EventManager 标记中,包含一个 EditForm 组件,将 Context 附加到适当的参数,并将 Save() 方法链接到处理表单提交:
<EditForm EditContext="@Context" event-form="forEvent-form" OnValidSubmit="@Save"> </EditForm> -
在 EditForm 内部,添加一个 InputText 组件并将其绑定到 Model.Name 属性。与 InputText 一起,添加一个 ValidationMessage 组件来显示附加属性的验证错误:
<p>Name: <InputText @bind-Value="@Model.Name" /></p> <p><ValidationMessage For="() => Model.Name" /></p> -
最后,通过在表单字段下方添加提交按钮来完成 EditForm:
<button type="submit">Save</button>
它是如何工作的…
在 步骤 1 中,我们创建一个具有单个 Name 属性的简单 Event 类。我们将使用 Event 作为表单的模型。接下来,在 步骤 2 中,我们创建一个可路由的 EventManager 组件,不指定任何渲染模式,这导致 Blazor 默认使用静态服务器端模式。由于表单验证是事件驱动的,EventManager 必须实现 IDisposable 接口,我们使用 @implements 指令来实现。现在你会看到编译错误,但我们将在稍后解决它们。
转到 步骤 3,在 EventManager 的 @code 块中,我们使用 Event 类声明 Model 参数,并使用 SupplyParameterFromForm 属性标记它,以启用与表单的自动绑定。在 步骤 4 中,我们引入两个表单支持变量:EditContext 和 ValidationMessageStore。EditContext 实例跟踪表单输入的更改并管理验证状态,而 ValidationMessageStore 存储并显示验证消息,简化了验证过程。
进行到 步骤 5,我们实现一个 Save() 占位符方法。数据持久性不是本章的重点,所以我们向控制台记录一条简短的消息来模拟保存操作。在 步骤 6 中,我们实现一个与 EditForm 所需的验证处理程序签名匹配的 ValidateForm() 方法。每当 Blazor 调用 ValidateForm() 时,我们首先清除 Store 中的任何消息,以平滑处理多次验证尝试。然后,我们检查用户是否提供了 Model.Name 属性;如果没有,我们向 Store 添加一条 您必须提供一个名称。 消息,并使用委托 () => Model.Name 识别无效属性。在底层,Blazor 将此委托分解为对象(Model)和属性路径字符串(Name),以有效地跟踪和管理验证和错误关联。
在 步骤 7 中,我们重写了 OnInitialized() 生命周期方法来设置表单的底层逻辑。我们解析了 Model 值,支持带有 SupplyParameterFromForm 属性的参数模式。然后,我们使用 Model 对象初始化 Context 并将 ValidateForm() 订阅到 Context 提供的 OnValidationRequested 事件处理器。这样,Blazor 将会自动在用户提交表单时调用 ValidateForm()。最后,我们通过传递 Context 来初始化 Store,这样验证容器就可以访问 Model 的字段。在 步骤 8 中,我们通过实现 Dispose() 方法来结束 @code 块,遵循 IDisposable 模式。在 Dispose() 中,我们安全地取消订阅 ValidateForm() 从 Context 的验证触发器,以防止潜在的内存泄漏。
在后端逻辑就绪后,我们继续进行 EventManager 标记。在 步骤 9 中,我们添加了一个 EditForm 组件,但不是直接将后端 Model 实例附加到它,而是将 Context 附加到 EditContext 参数。Blazor 不允许同时附加 Model 和 EditContext,因为 Context 已经包含了一个 Model 的实例。我们还使用 OnValidSubmit 回调而不是标准的 OnSubmit。Blazor 仅在所有验证成功通过时调用 OnValidSumbit,这使得它非常适合我们的需求。在 步骤 10 中,在 EditForm 内部,我们放置了一个 InputText 组件并将其绑定到 Model.Name 属性,使用户能够提供所需的事件名称。在 InputText 旁边,我们放置了一个 ValidationMessage 组件,用于显示特定表单字段的验证信息。由于 ValidationMessage 需要一个委托来从容器中检索消息,我们利用在 ValidateForm() 中使用的同一个委托来将验证信息种子到 Store 中。最后,在 步骤 11 中,我们通过添加提交按钮来完成表单的实现。
当用户提交表单时,Blazor 首先触发 OnValidationRequested 事件处理器。如果验证结果有错误,则不会激活 Save() 方法,确保只处理有效数据。
下面是我们在表单中看到的验证错误的样子:

图 7.1:用户提交表单未提供名称时的验证信息
还有更多...
使用ValidationMessage组件,我们可以控制 Blazor 渲染每个字段的验证消息的位置,提供直接位于单个表单元素旁边的细粒度反馈。然而,你可能希望显示一个综合的验证摘要而不是分散的消息。这就是ValidationSummary组件派上用场的地方。ValidationSummary组件收集并显示一个容器内的所有验证消息。你可以在表单的顶部或底部看到这样的摘要,甚至可以作为验证弹出窗口的一部分。
在我们的形式中实现摘要,我们只需要将ValidationMessage替换为ValidationSummary,并在表单内添加一个DataAnnotationsValidator组件:
<EditForm EditContext="@Context"
FormName="forEvent-form"
OnValidSubmit="@Save">
<DataAnnotationsValidator />
<p>Name: <InputText @bind-Value="@Model.Name" /></p>
<p><ValidationSummary /></p>
<button type="submit">Save</button>
</EditForm>
我们必须嵌入DataAnnotationsValidator,因为它会触发ValidationSummary的填充和重新渲染。如果没有验证器,将会得到红色的输入样式,表示提供的值无效,但没有解释原因的消息。
我们将在下一道菜中进一步探讨DataAnnotationsValidator。
利用数据注释进行表单验证
在这道菜中,我们探讨了数据注释在简化并增强 Blazor 表单验证过程中的作用。数据注释是直接应用于模型属性的属性,它允许以声明式的方式指定验证规则。通过实现数据注释,你可以显著简化验证逻辑,并将其封装在模型中,而不是将其与任何特定的表单耦合。这种分离确保了验证在应用程序的不同部分中始终如一地强制执行,无论你在什么上下文中使用模型。Blazor 有一个内置的DataAnnotationsValidator组件,它无缝地将数据注释集成到表单中。DataAnnotationsValidator检查应用于模型的属性的数据注释,并产生验证结果,而无需额外的编码。
让我们将表单中的显式验证逻辑转换为数据注释,并利用 Blazor 的本地支持来高效地处理验证。
准备工作
在我们将验证逻辑封装到单独的组件之前:
-
创建Chapter07 / Recipe02目录——这将是你的工作目录
-
从添加验证到表单配方或从 GitHub 仓库中的Chapter07 / Recipe01目录复制EventManager和Event。
如何做到这一点…
按照以下步骤利用数据注释进行模型验证:
-
导航到Event类,并使用带有用户友好错误消息的Required属性装饰Name属性。你必须引用System.ComponentModel.DataAnnotations命名空间,但你的 IDE 可能会自动包含它:
[Required(ErrorMessage = "You must provide a name.")] public string Name { get; set; } -
移动到EventManager组件,并从文件顶部删除IDisposable声明。你应该只剩下一个路由声明。
-
在 EventManager 的标记中,找到 EditForm 并将 DataAnnotationsValidator 组件嵌入到提交按钮下方:
<EditForm EditContext="@Context" event-form="event-forEvent" OnValidSubmit="@Save"> @* ... existing form body ... *@ <button type="submit">Save</button> <DataAnnotationsValidator /> </EditForm> -
跳转到 EventManager 的 @code 块并做一些清理:
-
从 OnInitialized() 方法的实现中移除对 OnValidationRequested 事件处理器的订阅
-
完全移除 Dispose() 和 ValidateForm() 方法
-
它是如何工作的…
在 步骤 1 中,我们通过实现数据注释来增强 Event 类,以强制执行输入验证。我们用 Required 属性装饰 Name 属性,以确保用户始终提供该值。数据注释还接受 ErrorMessage 参数,我们可以传递一个用户友好的验证消息,因此我们扩展了 Required 属性,并添加了 您必须提供一个名称 的错误消息。
在 步骤 2 中,我们转向 EventManager 组件。由于已经设置了数据注释,我们不再需要显式的事件处理。因此,我们从 EventManager 文件的顶部移除 IDisposable 声明。在 步骤 3 中,我们增强了 EventManager 的标记,并在 EditForm 的末尾嵌入 DataAnnotationsValidator 组件,紧挨着提交按钮。DataAnnotationsValidator 在表单中无缝运行,不带任何特定的标记,并依赖于级联的 EditContext 进行验证操作。我们将 DataAnnotationsValidator 放在表单的末尾,但只要它在 EditForm 标签内,您可以将它放在任何位置。在 步骤 4 中,我们更新了 EventManager 的 @code 块。由于数据注释现在管理验证,我们可以通过移除大部分之前必要的验证逻辑来简化组件代码。我们在 OnInitialized() 方法中移除了 OnValidationRequested 订阅,因为 DataAnnotationsValidator 现在自动监控验证状态。随后,我们也消除了 ValidateForm() 方法,因为验证消息存储和错误消息的管理也已转移到 DataAnnotationsValidator。最后,我们移除了 Dispose() 方法,因为 EventManager 不再实现 IDisposable 接口或监听任何事件。
通过这些少量调整,我们达到了与 向表单添加验证 菜谱中相同的验证范围,但代码量显著减少!
还有更多...
在 DataAnnotationsValidator 的支持下,Blazor 可以执行两种类型的验证:
-
第一种类型是 全模型验证 – 当用户提交表单时,Blazor 执行。这种验证发生在您点击 EventManager 表单上的 保存 按钮时。它涉及检查模型中所有字段的每个验证规则,确保在处理表单之前所有数据都符合指定的标准。由于我们正在使用具有固有有限交互性的 SSR 渲染模式,因此只支持全模型验证。
-
然而,如果您选择以交互式模式渲染EventManager,DataAnnotationsValidator可以执行另一层验证——字段验证。当用户将焦点从单个表单字段移开时,Blazor 会触发字段验证,并立即显示该特定字段中提供的输入的反馈。
实现自定义验证属性
在这个菜谱中,我们将深入了解自定义验证属性的灵活性。虽然内置的数据注释简化了验证逻辑,但它们仅覆盖了最常用的验证规则。您可能会发现自己缺少针对特定需求的覆盖。幸运的是,您可以使用独特的规则实现自定义数据验证属性,这些规则超出了.NET 提供的标准验证。此外,Blazor 的本地DataAnnotationsValidator组件可以无缝集成任何自定义属性。
让我们实现一个事件名称验证属性,该属性检查用户是否提供了事件名称,并扫描任何禁止的关键字。
准备工作
在我们实现自定义验证属性之前,请执行以下操作:
-
创建一个Chapter07 / Recipe03目录——这将成为您的工作目录
-
从“利用数据注释进行表单验证”菜谱或从 GitHub 仓库中的Chapter07 / Recipe02目录复制EventManager和Event。
如何实现...
按照以下说明实现自定义验证属性:
-
创建一个新的EventNameValidationAttribute类,该类继承自ValidationAttribute类。您必须引用System.ComponentModel.DataAnnotations程序集,但您的 IDE 可能自动包含它:
using System.ComponentModel.DataAnnotations; public class EventNameValidationAttribute : ValidationAttribute { } -
在EventNameValidationAttribute类内部,声明一个私有变量_forbidden,并用event值初始化它:
private const string _forbidden = "event"; -
在_forbidden变量下方,实现一个Failure()方法,该方法接受message和member参数,并返回一个ValidationResult实例:
private static ValidationResult Failure( string message, string member) => new(message, [member]); -
通过重写IsValid()方法完成EventNameValidationAttribute的实现,该方法返回一个ValidationResult对象。如果传入的value未提供或包含_forbidden关键字,则返回Failure()调用的结果。否则,返回默认的ValidationResult.Success:
protected override ValidationResult IsValid( object value, ValidationContext validationContext) { var text = value?.ToString(); if (string.IsNullOrWhiteSpace(text)) return Failure("You must provide a name.", validationContext.MemberName); if (text.Contains(_forbidden, StringComparison.InvariantCultureIgnoreCase)) return Failure( "You mustn't use the 'event' keyword.", validationContext.MemberName); return ValidationResult.Success; } -
导航到Event类,并通过用新实现的EventNameValidation属性替换现有的Required属性来更新Name属性的装饰:
[EventNameValidation] public string Name { get; set; }
它是如何工作的...
在步骤 1中,我们创建了一个EventNameValidationAttribute类,继承自ValidationAttribute。ValidationAttribute类是验证属性的基类,为在.NET 应用程序中实现自定义验证规则提供了一个框架。它允许定义数据在进一步处理之前必须满足的特定条件。在步骤 2中,我们在自定义验证属性类中声明了一个_forbidden变量,用于存储要检查的禁止关键字。在步骤 3中,我们实现了一个接受message和member参数的Failure()方法。Failure()创建并返回一个ValidationResult实例,表示验证失败。member参数允许将错误消息与特定字段关联,从而增强提供给用户的反馈的清晰度。
在步骤 4中,我们通过重写来自ValidationAttribute类的IsValid()方法来实现自定义验证逻辑。当 Blazor 验证表单模型时,它会触发IsValid()方法。我们选择重写返回ValidationResult对象的重载,而不是简单的bool,因为我们希望提供有关验证问题的详细反馈。我们首先将传入的value转换为text变量。如果text不包含有意义的值,我们调用Failure()方法返回一个包含消息您必须提供名称。的验证错误。但Failure()还需要提供member名称。IsValid()方法接受另一个类型为ValidationContext的参数,它提供了有关验证操作的上下文信息,包括MemberName标识验证的字段。有了MemberName,我们可以符合Failure()方法的签名。然后我们检查text是否包含_forbidden关键字,忽略大小写和文化差异。如果找到禁止的关键字,我们再次调用Failure(),并带有消息您不能使用‘event’关键字。。最后,如果所有检查都成功通过,我们返回ValidationResult.Success - 一个封装在ValidationResult类中的成功指示符。
在步骤 5中,我们导航到Event类,并将Name属性上现有的Required属性替换为我们新创建的EventNameValidation属性。多亏了代码生成器和 C#以及 Blazor 编译器,我们可以使用类名而不是Attribute后缀来引用自定义属性。
现在,我们不仅验证用户是否提供了事件名称,还验证他们是否使用了禁止的关键字:

图 7.2:用户提交包含禁止关键字的值时的验证消息
还有更多...
在构建多语言应用程序时,你可能需要翻译用户友好的错误消息。此外,随着持续交付趋势的发展,你可能需要根据功能标志或应用程序设置有条件地启用验证规则。你需要访问依赖注入容器以支持这样的高级场景。在基于 ValidationAttribute 的类中,你可以通过 ValidationContext 参数访问依赖注入,该参数封装了 IServiceProvider 的行为,并公开了 .NET 中所有标准依赖注入方法。
例如,假设你在服务容器中注册了一个 Api 服务,你可以在以下方式中注入这个依赖项到你的属性中:
protected override ValidationResult IsValid(object value,
ValidationContext validationContext)
{
var api = validationContext.GetRequiredService<Api>();
//...
}
我们重写了从 ValidationAttribute 类继承而来的 IsValid() 方法,并获取了一个 ValidationContext 实例。由于 ValidationContext 实现了 IServiceProvider 接口,我们利用内置的泛型 GetRequiredService() 扩展方法来检索我们的 Api 服务实例。
需要注意的是,在 .NET 中自定义验证属性不支持异步验证。在设计验证策略时,考虑这一限制至关重要,以确保性能和用户体验不受负面影响。
验证复杂数据模型
在这个菜谱中,我们处理复杂表单和数据模型的验证。拥有结构良好和模块化的代码使得代码库更容易维护,并通过明确定义和隔离每个组件的责任来降低出错的可能性。在表单中,复杂模型将数据分割成可管理的部分,每个部分都有其验证逻辑,这使得维护整个表单的状态更容易,并确保每个部分都遵循特定的业务规则。虽然 Microsoft.AspNetCore.Components.DataAnnotations.Validation 包是实验性的,但它公开了 Blazor 原生的验证器,并提供了与复杂模型无缝集成的增强数据注释。
让我们扩展事件创建表单,以包含一个封装事件位置详细信息的嵌套对象。
准备工作
在我们设置嵌套的复杂模型验证之前,执行以下操作:
-
创建一个 Chapter07 / Recipe04 目录 – 这将是你的工作目录。
-
从 实现自定义验证属性 菜谱或从 GitHub 仓库中的 Chapter07 / Recipe03 目录复制 EventManager、Event 和 EventNameValidationAttribute。
如何做到这一点…
按照以下步骤启用嵌套模型的验证:
-
在你的项目文件中添加对 Microsoft.AspNetCore.Components.DataAnnotations.Validation 包的引用:
<ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Components .DataAnnotations.Validation" Version="3.2.0-rc1.20223.4" /> </ItemGroup> -
创建一个新的 EventLocation 类,并在其中定义两个属性 – Venue 和 Capacity :
public class EventLocation { public string Venue { get; set; } public int Capacity { get; set; } } -
将Required属性应用于Venue属性,并在用户留空字段时包含一个有意义的错误信息:
[Required(ErrorMessage = "You must provide a venue.")] public string Venue { get; set; } -
对于Capacity属性,使用Required和Range属性进行装饰,并提供一个有意义的错误信息,以确保用户只输入有效的容量值:
[Required, Range(1, 1000, ErrorMessage = "Capacity must be between 1 and 1000.")] public int Capacity { get; set; } -
导航到Event类,并添加一个新的Location属性。使用ValidateComplexType属性装饰Location:
[ValidateComplexType] public EventLocation Location { get; set; } = new(); -
在EventManager组件中,在标记中定位EditForm。
-
在EditForm中,在Name输入字段下方直接添加一个新段落,包含一个绑定到Model.Location.Venue属性的InputText组件:
<p> Venue: <InputText @bind-Value="@Model.Location.Venue" /> </p> -
在Venue输入字段下方,添加另一个包含绑定到Model.Location.Capacity属性的InputNumber组件的段落:
<p> Capacity: <InputNumber @bind-Value="@Model.Location.Capacity" /> </p> -
仍然在EditForm中,将现有的Model.Name属性的ValidationMessage替换为ValidationSummary:
<p><ValidationSummary /></p> -
最后,将DataAnnotationsValidator组件替换为ObjectGraphDataAnnotationsValidator:
<ObjectGraphDataAnnotationsValidator />
它是如何工作的…
在步骤 1中,我们打开我们项目的csproj文件,并添加对包含所有必需扩展以实现复杂、嵌套数据模型无缝验证的Microsoft.AspNetCore.Components.DataAnnotations.Validation包的引用。
接下来,在步骤 2中,我们创建一个新的EventLocation类,包含Venue和Capacity属性,表示活动位置详情。在步骤 3中,我们使用Required属性装饰Venue属性,以确保用户在提交表单前必须填写场地描述。如果他们忘记输入Venue值,他们将看到一个您必须提供场地的验证消息来引导他们。在步骤 4中,我们通过应用Required和Range属性对Capacity属性添加验证。我们强制用户填写容量值,并确保它位于指定的范围内(1 到 1,000)。如果用户输入超出声明范围的值,他们将收到容量必须在 1 到 1000 之间的错误消息。
对于步骤 5,我们转向Event类,并扩展它以添加一个新的属性——Location,类型为EventLocation。为了确保 Blazor 理解这个属性代表一个需要嵌套验证的复杂类型,我们使用ValidateComplexType属性进行装饰。ValidateComplexType包含在Microsoft.AspNetCore.Components.DataAnnotations.Validation包中。
在 步骤 6 中,我们进入 EventManager 组件,并在标记中找到现有的 EditForm。我们将扩展表单以包含输入事件位置详情的字段。在 步骤 7 中,我们在 Name 字段下方嵌入一个新的段落,在那里插入一个绑定到 Model.Location.Venue 的 InputText 组件,以允许用户输入场地详情。在 步骤 8 中,我们添加另一个段落,这次包含一个绑定到 Model.Location.Capacity 的 InputNumber 组件,以允许用户指定特定场地的可用位置。在 步骤 9 中,为了简化验证消息的显示,我们将之前专门用于 Name 属性的 ValidationMessage 组件替换为 ValidationSummary 实例。ValidationSummary 组件将所有表单验证消息合并到一个区域。最后,在 步骤 10 中,我们通过将标准的 DataAnnotationsValidator 替换为 ObjectGraphDataAnnotationsValidator 来增强我们的验证设置。ObjectGraphDataAnnotationsValidator 组件是一个高级组件,能够验证嵌套的对象图,允许 Blazor 在我们复杂的 Event 模型的每个部分触发验证。
还有更多…
当使用 Blazor 的内置输入组件时,你将获得额外的灵活性。任何默认输入组件,如我们在这个菜谱中使用的 InputNumber 组件,它继承自 InputBase 类,会自动拦截任何不匹配的参数,并将它们直接作为属性附加到底层的 HTML input 元素上。有了这个功能,你可以轻松地通过声明 min 和 max 属性并禁止用户手动增加或减少值超出指定范围来增强用于 Model.Location.Capacity 的 InputNumber 组件:
Capacity:
<InputNumber min="1" max="1000"
@bind-Value="@Model.Location.Capacity" />
通过在表单中的 InputNumber 组件上添加 min 和 max 属性,并将它们的值分别声明为 1 和 1000,我们确保用户无法将输入值降低到 1 以下或增加到 1000 以上。他们仍然可以手动输入一个无效的值,但它们会在模型属性上触发验证。按照这个例子,你可以利用你熟悉的任何其他 HTML 输入属性。
样式验证消息
在本菜谱中,我们探讨了 Blazor 中表单验证的样式。你可能已经注意到,在之前的菜谱中,Blazor 在验证过程中会自动将验证类应用到表单字段上。默认验证 CSS 类与默认 Bootstrap 样式相匹配,无效字段会得到红色强调,有效字段会得到绿色强调。虽然默认设置提高了交付速度,但在大多数情况下,你仍然需要自定义视觉反馈以适应你的应用程序品牌或功能需求。幸运的是,Blazor 允许自定义在验证字段时附加的样式和类。这种自定义保持了应用程序模块化和松散耦合架构的完整性,确保增强不会损害代码的可维护性。
让我们实现一个自定义验证类提供者,使 Blazor 在红色中标记缺失的标签,在黄色中标记缺失的位置容量。
准备工作
在实现自定义验证类提供者之前,请执行以下操作:
-
创建一个 Chapter07 / Recipe05 目录 - 这将是你的工作目录。
-
从 Validating complex data models 菜谱或从 GitHub 仓库中的 Chapter07 / Recipe04 目录复制 Event、EventLocation、EventManager 和 EventNameValidationAttribute。
如何操作...
执行以下步骤以添加自定义验证类提供者:
-
在工作目录中添加一个新的 EventManager.razor.css 文件。你的 IDE 可能会自动将此 CSS 文件嵌套在 EventManager.razor 之下。
-
在 EventManager.razor.css 中,定义一个 invalid-warning 样式类,为应用此类的任何元素添加橙色轮廓:
::deep .invalid-warning { outline: 1px solid orange; } -
创建一个新的 TypeValidationClassProvider 类,从 Microsoft.AspNetCore.Components.Forms 命名空间下的 FieldCssClassProvider 继承:
public class TypeValidationClassProvider : FieldCssClassProvider { } -
在 TypeValidationClassProvider 中,声明一个私有的 _capacity 字段,该字段包含来自 EventLocation 类的 Capacity 属性的名称:
private static readonly string _capacity = nameof(EventLocation.Capacity); -
要最终实现 TypeValidationClassProvider,重写 GetFieldCssClass() 方法并实现逻辑,当当前字段的值无效且对应于 _capacity 属性时返回 invalid-warning 类;否则,回退到 base 实现:
public override string GetFieldCssClass( EditContext editContext, in FieldIdentifier fieldIdentifier) { var isValid = editContext.IsValid(fieldIdentifier); var isCapacity = fieldIdentifier.FieldName == _capacity; if (!isValid && isCapacity) return "invalid-warning"; return base.GetFieldCssClass( editContext, fieldIdentifier); } -
导航到 EventManager 组件,找到 OnInitialized() 方法。在现有设置之后,使用 EditContext 的 SetFieldCssClassProvider() 扩展方法将 TypeValidationClassProvider 附加到 Context:
protected override void OnInitialized() { // ... existing form context building ... Context.SetFieldCssClassProvider( new TypeValidationClassProvider()); }
工作原理...
在步骤 1中,我们在工作目录中添加一个新的 CSS 文件,具体命名为EventManager.razor.css,以符合 CSS 隔离要求,并匹配将要为其添加样式的组件名称。在 Blazor 中,CSS 隔离允许在特定组件的 CSS 文件中定义的样式只影响该组件,防止样式泄露。如果你在 IDE 中启用了文件嵌套,你会看到隔离的 CSS 文件被包裹在父组件文件之下。在步骤 2中,在EventManager.razor.css中,我们引入了一个.invalid-warning类,它将橙色轮廓应用于我们附加到其上的字段。我们使用::deep组合符来确保样式能够穿透类似 DOM 的封装并影响嵌套组件。
在步骤 3中,我们通过创建一个新的TypeValidationClassProvider类来初始化我们的自定义验证类提供者,该类继承自FieldCssClassProvider。FieldCssClassProvider类提供了必要的 API 来自定义 Blazor 根据字段验证状态应用的 CSS 类。在步骤 4中,我们在TypeValidationClassProvider中持久化Capacity字段的名称到一个名为_capacity的变量中。通过将其声明为private和static,我们确保这个值在整个应用程序的生命周期中保持不变,并消耗最少的内存,从而有效地成为一个单例实例。在步骤 5中,我们通过重写GetFieldCssClass()方法来完成我们的自定义提供者,Blazor 在需要根据字段的验证状态确定适当的 CSS 类时调用此方法。在我们的实现中,我们首先检查字段的当前状态是否有效,并且其名称是否与_capacity值匹配。如果字段无效并且引用的是容量,我们返回invalid-warning,指示 Blazor 应用橙色轮廓以突出显示错误。否则,我们默认返回基实现,通过返回base.GetFieldCssClass()调用的结果,保留其他字段的常规行为。
最后,在步骤 6中,我们跳转到EventManager组件并定位到重写的OnInitialized()生命周期方法,在那里我们初始化Context变量。在初始配置之后,我们利用EditContext的SetFieldCssClassProvider()扩展方法来配置Context,使其使用我们的TypeValidationClassProvider来根据字段验证解决 CSS 类。我们的自定义样式逻辑现在已经就绪。
还有更多……
我们实现了一个自定义 CSS 验证类,并利用了 Blazor 提供的 CSS 隔离功能。然而,如果你已经将 CSS 框架集成到你的应用程序中,你可以简单地使用框架提供的验证类,而不是创建自定义的类。
Bootstrap 作为目前最常用的 CSS 框架,提供了 border 和 border-warning CSS 类,您可以使用这些类来突出显示无效的输入字段。导航到 TypeValidationClassProvider 并更新 GetFieldCssClass() 实现如下:
public override string GetFieldCssClass(
EditContext editContext,
in FieldIdentifier fieldIdentifier)
{
var isValid = editContext.IsValid(fieldIdentifier);
var isCapacity =
fieldIdentifier.FieldName == _capacity;
if (!isValid && isCapacity)
return "border border-warning";
return base.GetFieldCssClass(
editContext, fieldIdentifier);
}
自定义验证逻辑保持不变——我们仍然检查验证上下文是否有效以及验证的字段是否引用容量。然而,当自定义验证失败时,我们不是返回自定义警告类,而是利用 border border-warning 类的组合,并将样式委托给 Bootstrap。
验证失败时显示 toast
在本菜谱中,我们探讨了如何通过自定义显示验证错误来增强表单验证反馈。Blazor 的 ValidationSummary 组件提供了一个简单的方法来收集和显示表单中的所有验证消息,通常在一个简单的 div 中渲染。虽然功能性强,但这种默认的展示可能并不总是符合期望的用户体验或应用程序的美学标准。您可以用自定义实现替换标准 ValidationSummary 组件,使验证消息更具吸引力,并与应用程序更广泛的通知策略无缝结合。
让我们实现一个自定义组件,该组件在默认的 Bootstrap toast 中显示验证错误,从而创建一个更现代版本的 ValidationSummary。
准备工作
在深入实现自定义验证摘要之前,请执行以下操作:
-
创建一个 Chapter07 / Recipe06 目录——这将是你的工作目录
-
从 Styling validation messages 菜单或从 GitHub 仓库中的 Chapter07 / Recipe05 目录复制 Event、EventLocation、EventManager、EventNameValidationAttribute 和 TypeValidationClassProvider。
如何做到这一点...
按以下步骤引入自定义验证摘要:
-
创建一个实现 IDisposable 接口的 ValidationToast 组件:
@implements IDisposable -
在 ValidationToast 组件的 @code 块内部,声明一个类型为 EditContext 的 CascadingParameter 参数和一个 IsDisplayed 属性:
[CascadingParameter] public EditContext Context { get; set; } protected bool IsDisplayed { get; set; } -
仍然在 @code 块内部,实现一个 Rerender() 方法,该方法与 EventHandler
处理程序的订阅者签名相匹配: private void Rerender(object sender, ValidationStateChangedEventArgs args) { } -
在 Rerender() 内部,根据 Context 中是否有任何验证消息来设置 IsDisplayed 属性,并调用 StateHasChanged() 以触发 UI 刷新:
IsDisplayed = Context.GetValidationMessages().Any(); StateHasChanged(); -
在 Rerender() 下方,重写 OnInitialized() 生命周期方法并订阅 EditContext 的 OnValidationStateChanged 事件:
protected override void OnInitialized() => Context.OnValidationStateChanged += Rerender; -
通过实现 Dispose() 方法并在 OnValidationStateChanged 事件上取消订阅来完成 @code 块:
public void Dispose() => Context.OnValidationStateChanged -= Rerender; -
在ValidationToast标记中,在@implements指令下方包含一个快速返回语句,以防止根据IsDisplayed值进行任何标记渲染:
@if (!IsDisplayed) return; -
在快速返回语句下方,构建一个默认的 Bootstrap toast 通知的框架:
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1"> <div class="toast text-white bg-danger show"> @* toast area *@ </div> </div> -
在 toast 区域中,添加一个空标题以增强美观,并在 toast 正文中实现逻辑以动态渲染从EditContext检索到的验证消息列表:
<div class="toast-header" /> <div class="toast-body"> @foreach (var message in Context.GetValidationMessages()) { <div>@message</div> } </div> -
导航到表单提交按钮上方的EventManager组件,并移除现有的ValidationSummary段落。用新的ValidationToast组件实例替换它:
<ValidationToast />
它是如何工作的…
在步骤 1中,我们创建了一个新的ValidationToast组件,该组件实现了IDisposable接口以确保在处理事件处理程序时进行适当的资源清理。
从步骤 2开始,我们处理ValidationToast组件的@code块。我们声明一个类型为EditContext的CascadingParameter参数,以便访问父表单的上下文。我们还声明了一个IsDisplayed属性,这将帮助我们根据验证结果控制 toast 的可见性。在步骤 3中,我们初始化一个接受sender参数和args类型为ValidationStateChangedEventArgs的Rerender方法,以便我们稍后将其订阅到匹配的EventHandler。在步骤 4中,我们实现Rerender逻辑,其中我们确定Context实例中是否有任何验证消息,并设置IsDisplayed属性,表示有错误需要显示。然后我们调用StateHasChanged()来提示 Blazor 刷新 UI 并反映更新的状态。在步骤 5中,我们重写OnInitialized()生命周期方法,将Rerender()方法订阅到EditContext的OnValidationStateChanged事件。每当表单的验证状态发生变化时,Blazor 将执行Rerender(),允许我们的 toast 通知进行响应式更新。在步骤 6中,我们实现Dispose()方法,其中我们取消订阅OnValidationStateChanged事件,确保在从 UI 中移除ValidationToast后,它不会继续对事件做出反应,从而防止内存泄漏。
在步骤 7中,我们关注ValidationToast的标记。我们从@implements指令下方开始,使用基于IsDisplayed值的快速返回语句,该语句指示 Blazor 在没有验证信息时立即退出渲染过程。在步骤 8中,我们使用默认的 Bootstrap 类构建一个视觉框架,以创建 Toast 通知。由于这是标准的 Bootstrap 代码,我们不会深入分析它。简而言之,我们将框架固定在视口的底部末端,确保其可见但不会干扰。我们还使 Toast 显示为红色,以清楚地表明存在问题。在步骤 9中,我们实现了 Toast 区域。我们添加了一个空标题以实现视觉平衡,并在主体中迭代Context.GetValidationMessages()调用的结果,动态渲染每个验证信息。
最后,在步骤 10中,我们跳转到EventManager组件。在这里,我们移除了现有的ValidationSummary段落,并用新的ValidationToast组件替换它,现在它以更互动和视觉上吸引人的方式处理验证信息的显示。
我们得到了一个仍然简单但更现代的验证摘要,我们的用户会喜欢的:

图 7.3:Toast 通知替换标准验证摘要容器
根据表单状态启用提交选项
在这个菜谱中,我们深入探讨了一种通过动态控制表单提交按钮的状态来提升用户体验的策略。我们不仅在创建新对象时使用表单,在修改现有对象时也使用表单。当用户没有做出任何更改或某些输入无效时,阻止表单提交是有意义的。有了这个功能,我们提升了用户体验,节省了内存使用,并减少了不必要的服务器请求。
让我们通过一个机制增强表单,该机制只允许在表单数据发生变化且所有输入都有效时保存表单。
准备工作
在使表单提交按钮对表单状态做出反应之前,请执行以下操作:
-
创建一个Chapter07 / Recipe07目录 - 这将是你的工作目录
-
从显示验证失败时的 Toast菜谱或从 GitHub 仓库中的Chapter07 / Recipe06目录复制Event、EventLocation、EventManager、EventNameValidationAttribute、TypeValidationClassProvider和ValidationToast。
如何实现...
按照以下步骤使表单提交按钮对表单状态做出反应:
-
导航到EventManager组件,并更新它以实现IDisposable接口,并在InteractiveWebAssembly模式下渲染:
@rendermode InteractiveWebAssembly @implements IDisposable -
在EventManager的@code块中,引入一个类型为bool的IsSubmittable变量:
protected bool IsSubmittable; -
仍然在 @code 块中,添加一个符合 EventHandler
响应模式的 FieldChanged() 方法,并将表单的当前状态解析到 IsSubmittable 变量中: private void FieldChanged( object sender, FieldChangedEventArgs args) { IsSubmittable = Context.Validate() && Context.IsModified(); StateHasChanged(); } -
在 OnInitialized() 方法中,为 Model 实例初始化一个默认值以模拟数据编辑场景:
Model ??= new() { Name = "Packt Party", Location = new() { Venue = "Packt Room", Capacity = 150 } }; -
在 OnInitialized() 方法的末尾,将 FieldChanged() 方法订阅到由 EditContext API 提供的 OnFieldChanged 事件:
Context.OnFieldChanged += FieldChanged; -
通过在 @code 块的末尾添加一个 Dispose() 方法来完成 IDisposable 的实现,并从 OnFieldChanged 处理器中取消订阅 FieldChanged():
public void Dispose() => Context.OnFieldChanged -= FieldChanged; -
切换到 EventManager 标记,找到提交按钮。将按钮的 disabled 属性设置为 IsSubmittable 变量的否定值:
<button type="submit" disabled="@(!IsSubmittable)"> Save </button>
它是如何工作的…
在 步骤 1 中,我们首先增强位于 EventManager 中的表单的交互性。我们配置 EventManager 以 InteractiveWebAssembly 模式渲染,启用组件交互性,并声明它将实现 IDisposable 接口,允许自定义清理实现。
在 步骤 2 中,我们转向 EventManager 的 @code 块,并声明一个 IsSubmittable 变量,我们将利用它来管理表单提交按钮的状态。在 步骤 3 中,我们实现一个 FieldChanged() 方法,该方法接受类型为 FieldChangedEventArgs 的 sender 和 args 参数。在 FieldChanged() 内部,我们通过 Context 实例利用 EditContext API 动态评估表单的状态。我们通过检查所有表单字段是否有效,使用 Context.Validate() ,以及表单是否已修改,使用 Context.IsModified() 来设置 IsSubmittable 变量。鉴于此操作可能会影响表单提交按钮的状态,我们调用 StateHasChanged() 来通知 Blazor UI 可能需要更新。
在 步骤 4 中,我们调整了 EventManager 的初始化方式。我们不是将 Model 对象重置为新实例,而是通过设置初始属性来模拟编辑现有模型,这反映了典型的数据编辑场景。在 步骤 5 中,作为初始化过程的一部分,我们还订阅 FieldChanged() 到 Context.OnFieldChanged 事件处理器。每当表单字段值发生变化时,Blazor 会触发 OnFieldChanged,确保我们的表单对每次编辑做出响应。在 步骤 6 中,我们最终完成 IDisposable 的实现。我们实现 Dispose() 方法,其中我们取消订阅 FieldChanged() 从 OnFieldChanged 事件处理器,以防止内存泄漏并确保当不再需要时,EventManager 组件能够优雅地被销毁。
在步骤 7中,我们跳转到EventManager标记以在 UI 中反映我们的后端逻辑。我们定位到表单的提交按钮,并附加一个disabled属性,将其值设置为IsSubmittable的否定。每当用户在表单的字段之间更改焦点时,我们将重新计算IsSubmittable的值——由于IsSubmittable指示用户是否进行了更改以及表单是否处于有效状态,否定这个值决定了何时应禁用提交按钮,防止在满足有效和修改表单的所有条件之前进行不必要的提交。
还有更多...
在 Blazor 中,EditContext在管理表单状态和验证方面发挥着至关重要的作用,但有一些限制。一个重要的注意事项是它不跟踪模型属性的初始状态。EditContext监视输入字段的更改,当它们的值发生变化时将其标记为已修改。然而,如果用户将字段的值还原到其原始状态,EditContext仍然认为它已被修改。这种行为可能导致表单可能错误地允许提交或显示验证状态,因为它们没有认识到字段值已返回到初始状态。
为了解决这个限制并细化表单修改的行为,你必须实现一个等价比较器,从IEqualityComparer
第八章:保持应用程序安全
在本章中,我们重点关注 Blazor 应用程序的基本安全实践,因为保护用户数据和维持信任对于任何商业成功至关重要。
我们将首先搭建身份框架——利用.NET 团队提供的模板设置用户身份验证和管理所需的基础设施。我们将探讨防止未经授权的访问并保护您的组件免受不受欢迎的参与者的影响的策略。此外,我们将介绍一种更细粒度的方法,并保护标记区域以自定义组件行为并确保敏感信息仅对授权用户可访问。我们将探讨如何定义和执行角色和策略以集中和封装与您的安全要求相一致的操作级别。接下来,我们将学习如何确定用户的身份验证状态和他们的当前访问上下文,这将使我们能够保护和增强后端逻辑。我们还将讨论如何安全地更新用户身份。
到本章结束时,您将了解 Blazor 中的各种安全机制,并将掌握最佳安全实践。
下面是我们将要涵盖的食谱列表:
-
搭建身份框架
-
保护页面
-
保护标记区域
-
创建角色
-
修改用户的身份
-
支持角色和政策授权
-
在过程逻辑中解析身份验证状态
技术要求
在本章中,食谱相互关联,最终形成了一本指南,介绍了最常需要的身份功能。为了清晰起见,在每个食谱的开头,您将找到如何设置工作目录以及从哪里获取示例对象的说明。此外,本章要求您拥有一个工作结构化查询语言(SQL)数据库、数据库实例的连接字符串以及一个 SQL IDE,因为您需要运行一些自定义迁移。大多数表都将为您搭建,所以如果您对 SQL 本身没有太多经验,请不要担心。
所有代码示例均可在 GitHub 上找到:github.com/PacktPublishing/Blazor-Web-Development-Cookbook/tree/main/Chapter08/BlazorCookbook.Auth
搭建身份框架
.NET 团队提供了一个模板,可以快速将身份验证添加到 Blazor 应用程序中。这个模板不仅设置快速,而且高度可定制。您可以简化用户身份验证、注册和配置文件管理的实现,确保您的应用程序从一开始就是安全的。您将获得基本功能,如登录和注销功能、密码恢复和用户数据管理——所有这些都是任何身份验证系统所必需的。
让我们使用启用身份验证的 Blazor 项目模板来生成一个新的 Blazor 项目,并探索它提供的功能。到食谱结束时,你将拥有一个坚实的基础和对身份系统的理解。无论你是构建一个简单的应用程序还是一个复杂的企业解决方案,这种方法都将节省你的时间和精力,同时确保你的应用程序符合现代安全标准。
准备工作
我们将展示使用身份初始化项目,利用 Visual Studio 提供的 GUI,因此本食谱的唯一先决条件是启动你的 IDE。让我们开始吧。
如果你在环境中使用.NET CLI,可以参考食谱末尾的更多内容…部分,在那里我将提供等效的命令。
如何操作…
按照以下步骤构建一个新的具有身份的 Blazor 项目:
- 从欢迎窗口中选择创建新项目:

图 8.1:从欢迎窗口开始创建新项目
- 使用面板顶部的搜索栏找到Blazor Web App位置,并通过点击下一步按钮进行确认:

图 8.2:从可用的项目模板中选择 Blazor Web App
- 定义项目位置和名称,并通过点击下一步按钮进行确认:

图 8.3:设置项目位置和名称
- 将目标框架选择为.NET 9.0(标准条款支持),在身份验证类型部分,选择个人账户。确保勾选配置 HTTPS和包含示例页面复选框,并从交互性配置下拉菜单中选择自动(服务器和 WebAssembly)和按页面/组件。通过点击创建按钮进行确认:

图 8.4:配置项目的框架、交互性和身份验证
你将到达一个类似的项目设置,这可能会根据你的项目名称而有所不同:

图 8.5:初始解决方案结构
它是如何工作的…
整个过程几乎与我们探索的初始化项目食谱中的相同第一章。前往那里进行前三步。在这里,我们专注于第 4 步。
在步骤 4中,我们进入项目配置面板。首先,我们将目标框架选为.NET 9 (标准术语支持)。然后,我们有一个身份验证类型部分。在这里,我们选择个人账户选项,指示 Visual Studio 为我们的应用程序生成支持身份的代码。我们还启用 HTTPS 并通过勾选相应的复选框生成示例页面。最后,为了完成配置设置,我们定义了应用程序的交互性——我们将使用每页/组件的交互位置和服务器与 WebAssembly 渲染的混合。接下来,我们看到分层的结果——一个包含两个项目的解决方案,分别用于服务器和客户端。它看起来与标准的 Blazor 模板分层没有太大区别,所以让我们深入了解每个项目,了解它是如何支持身份验证的。
这里是分层项目结构的示例:

图 8.6:带有启用身份验证的服务器和客户端项目
让我们先解开客户端项目(如图 8.6 右侧所示),因为它显著更小。在组件方面,我们只得到一个与身份管理相关的组件——RedirectToLogin。正如其名所示,RedirectToLogin安全地将用户重定向到登录页面,并保留初始 URL,以便 Blazor 可以返回那里。我们还得到一个UserInfo类——一个包含我们希望在服务器和客户端通信之间共享的用户身份详细信息的模型,并且可以轻松扩展。在渲染模式边界之间共享身份验证状态的骨干是PersistentAuthenticationStateProvider服务,我们将在支持角色和策略授权配方中对其进行探讨。最后,我们在Program.cs文件中得到了一个最小设置。PersistentAuthenticationStateProvider服务在依赖注入容器(DI)中注册为单例,并且通过AddAuthorizationCore()扩展方法,所有用于在我们的应用程序中启用授权所需的服务都为我们注册了。我们还得到对AddCascadingAuthenticationState()扩展方法的调用,以将身份验证状态作为根级级联值添加,并使其在整个 WebAssembly 应用程序中可拦截。
服务器端项目(在图 8 .6 的左侧)包含数据目录,其中有一个ApplicationDbContext类,一个ApplicationUser类,以及一个迁移子目录,这表明服务器端项目负责持久化和管理用户及其身份。这意味着你必须提供一个有效的连接字符串,指向你想要存储身份数据的数据库。你会在appSettings.json文件中找到一个生成的占位符DefaultConnection节点,你必须将其替换为你的数据库资源的连接详情。在数据目录旁边,我们得到了一大块生成的组件,包括一个账户区域,包含页面和 UI,处理管理我们应用程序中身份所需的所有操作。这里有用于登录、登出、管理账户甚至启用双因素认证(2FA)的组件,它们都是 Razor 原生组件。你会注意到,无论在配置时声明了什么交互性,所有身份组件默认都以服务器端渲染(SSR)模式渲染。由于目前行业标准是服务器端应用程序利用 cookie 进行身份管理,我们还得到了一个自定义的IdentityRedirectManager包装器,它利用默认的 Blazor NavigationManager类,通过身份状态 cookie 和一些重定向解析器对其进行扩展。IdentityRedirectManager类还设计为在静态 SSR 之外使用时抛出InvalidOperationException异常。在 SSR 中,与其它渲染模式不同,我们可以访问每个请求的HttpContext实例。IdentityUserAccessor类是另一个包装类,允许我们从HttpContext实例中解析当前用户身份。在IdentityComponentsEndpointRouteBuilderExtensions类中,我们得到了三个额外的身份端点的映射,用于使用外部身份提供者(IdP)登录、下载个人用户数据和登出。这些在默认身份 API 实现中是缺失的,因为它们对于具有 UI 的应用程序来说是本地的。IdentityNoOpEmailSender类是一个用于发送与身份相关的电子邮件的占位符服务:确认用户电子邮件或重置密码。在上线之前,你必须实现自己的IEmailSender客户端。我们还得到了一个PersistingRevalidatingAuthenticationStateProvider类,Blazor 使用它来在服务器和客户端代码之间的渲染边界之间共享身份验证状态——我们也会在支持角色和策略的授权配方中探讨这一点。Program.cs文件变得更加复杂。在这里,我们会找到交互式服务器和 WebAssembly 组件的默认设置以及默认的中间件管道。然而,在此基础上,我们还在设置服务器端身份功能。我们注册了自定义的身份服务(在本节前面讨论过)并调用AddCascadingAuthenticationState()扩展方法来在根级别启用身份验证状态的级联。我们利用AddAuthentication()扩展方法配置身份验证。这里也是我们通过AddIdentityCookies()扩展方法通知 Blazor 使用 cookie 进行身份持久化的地方。在Program.cs中,我们还为我们的ApplicationDbContext类配置数据库访问。最后,并且最重要的是,我们利用AddIdentityCore()方法和IdentityBuilder API 来配置所需的身份服务。
现在你已经了解了每个项目的结构,让我们可视化一下身份验证工作流程是如何工作的:

图 8.7:Blazor Web 应用的客户端和服务器端之间的身份验证工作流程
当用户尝试访问应用程序时,他们的身份将被检查。身份验证状态提供者服务验证可用的身份验证 cookie 或其缺失。如果验证成功,用户将被重定向到他们打算访问的页面;否则,用户将落在登录页面上。在提交登录表单并从身份提供者(IdP)收到成功的身份验证响应后,Blazor 将在身份验证 cookie 中持久化用户的身份。这个 cookie 将被附加到服务器和客户端之间的每个请求上,允许PersistingRevalidatingAuthenticationStateProvider和PersistentAuthenticationStateProvider有效地监控和识别当前用户及其权限。
当你第一次运行应用程序并尝试创建账户时,你的应用程序将失败。但以开发者友好的方式,你会看到一个异常页面,告诉你你还没有运行初始迁移,因此你的后端数据库无法支持身份功能:

图 8.8:尝试在没有初始迁移的情况下创建账户时的异常页面
你还会得到一个简单的应用迁移按钮,允许你立即应用迁移!
所有这些代码和功能都准备好了,而你还没有编写一行自己的代码。利用解决方案模板和脚手架可以加快你应用程序交付的速度。
还有更多……
如果你没有使用 GUI 或 Visual Studio,你可以利用跨平台的.NET CLI,通过单条命令行来生成相同的模板。导航到你的工作目录,并运行以下命令:
dotnet new blazor -o BlazorCookbook.Auth -int Auto --framework net9.0 -au Individual
你将得到与我们在 Visual Studio 演练中创建的项目相同的格式,只有一个区别。使用.NET CLI 生成的项目使用 SQLite 数据库而不是 SQL Server。你可以通过导航到服务器端项目的Program.cs文件并更新ApplicationDbContext注册选项来使用 SQL Server:
builder.Services.AddDbContext<ApplicationDbContext>(
options => options.UseSqlServer(connectionString));
保护页面
保护未经授权的路由至关重要,因为恶意行为者可能会尝试抓取你的应用程序,绕过你的 UI 强制执行的导航路径。确保只有授权用户可以访问特定路由有助于保护敏感数据和功能。Blazor 内置了Authorize属性,用于在用户导航到页面时检查访问权限。
让我们添加一个可路由的组件,只有通过在正确位置应用 Authorize 属性,认证用户才能导航到它。
准备工作
在我们将安全组件添加到服务器端项目之前,创建一个 Components / Recipes / Recipe02 目录——这将是你的工作目录。
如何操作...
按照以下说明保护组件:
-
创建一个具有 / ch08r02 路径的可路由 Settings 组件:
@page "/ch08r02" -
引用 Microsoft.AspNetCore.Authorization 程序集并将 Authorize 属性附加到 Settings 组件:
@using Microsoft.AspNetCore.Authorization @attribute [Authorize] -
向 Settings 组件添加占位符标记,告知用户他们有权查看此内容:
<h3>Settings</h3> <p>You're authorized to see settings.</p>
它是如何工作的...
在 步骤 1 中,我们执行一个常规步骤并创建一个新的可路由 Settings 组件,利用 @page。接下来,在 步骤 2 中,我们使用 @using 指令在 @page 声明下方引用 Microsoft.AspNetCore.Authorization 程序集。然后,我们使用 @attribute 将 Authorize 属性附加到组件上。现在,只有认证用户可以访问 Settings 页面。然而,重要的是要注意,Blazor 只在路由过程中验证 Authorize 属性,并不将其应用于子组件的渲染流程。最后,在 步骤 3 中,我们添加一些占位符内容来告知用户他们有权查看此页面。在 Settings 标记中,我们渲染页面标题和 您有权查看 设置 的消息。
更多内容...
如果你正在构建无标记的组件或以代码后方式工作,你仍然可以利用 Authorize 属性。以下是实现无标记版本的 Settings 组件的方法:
[Route("/ch08r02")]
[Authorize]
public class Settings : ComponentBase
{
// ...
}
由于我们不再处于 Razor 文件中,我们使用 C# 属性的语法。通过用 [Route] 属性装饰 Settings 类,我们启用对 /ch08r02 路径的导航。此外,通过添加 [Authorize] 属性,我们确保 Blazor 只允许认证用户导航到该组件。我们有效地实现了与初始实现相同的逻辑行为。作为旁注,当你以 Razor 文件工作的时候,Razor 编译器将所有的专用 @directive 声明转换为属性——类似于我们在无标记组件中所做的。
保护标记区域
有时,限制对整个页面的访问可能过于限制。您可能希望向所有人公开着陆页,同时微调用户在导航菜单中看到的元素。例如,经过身份验证的用户可能可以访问标准用户看不到的后台办公功能,尽管他们查看的是同一页面。Blazor 支持使用AuthorizeView组件保护特定的标记区域。AuthorizeView组件允许您根据用户的身份验证状态控制内容的可见性。它支持各种状态,并与RenderFragment对象无缝协作,使其非常灵活和多功能。
让我们利用AuthorizeView组件并添加一个仅对经过身份验证的用户可见的状态消息。
准备工作
在我们向组件添加受保护状态消息之前,请执行以下操作:
-
在服务器端项目中,创建一个组件/配方/Recipe03目录——这将是你的工作目录。
-
从页面安全配方或从 GitHub 仓库中的组件/配方/Recipe02目录复制设置组件。
如何操作...
按照以下步骤在组件中添加受保护的标记区域:
-
导航到设置组件,并移除授权属性以及现有的@using指令。
-
在设置标记中定位授权状态消息,并用AuthorizeView组件标签包裹它:
<h3>Settings</h3> <AuthorizeView> <p>You're authorized to see settings.</p> </AuthorizeView>
它是如何工作的...
在步骤 1中,我们从设置组件中移除现有的授权属性和引用该属性的@using指令,允许所有用户访问页面。
在步骤 2中,我们在设置标记中定位您有权查看设置的授权状态消息。然后我们将此消息包裹在AuthorizeView组件标签内。AuthorizeView组件根据用户身份验证状态管理内容可见性,并接受ChildContent,这意味着 Blazor 只为经过身份验证的用户渲染状态消息。这种方法确保只有具有适当凭证的用户才能看到某些内容,从而增强应用程序的安全性和用户体验。您可以在第一章的创建具有可定制内容的组件配方中找到有关ChildContent模式的更多详细信息。
Blazor 将有效地隐藏AuthorizeView组件内部的所有内容,对未经授权的用户不可见。这意味着标记以及任何事件处理程序或方法调用。因此,您可以保护您的 UI 以及整个功能和功能,防止未经授权的用户知道它们的存在。
更多内容...
除了 ChildContent 外,AuthorizeView 支持显式提供 Authorized、Authorizing 和 NotAuthorized 片段。有了这个,你可以在同一组件内为认证用户和非认证用户定义不同的内容。你可以利用 Authorizing 片段来显示一个临时消息,表明正在解决用户的身份,因为你可能需要执行一些异步和长时间运行的逻辑。在我们的案例中,我们可以在 Settings 组件中采用以下标记:
<h3>Settings</h3>
<AuthorizeView>
<Authorized>
<p>You're authorized to see settings.</p>
</Authorized>
<Authorizing>
<p>Give us a few moments...</p>
</Authorizing>
<NotAuthorized>
<p>You can't be here, sorry.</p>
</NotAuthorized>
</AuthorizeView>
AuthorizeView 组件将正常评估用户的认证状态,但这次会给用户一种感受到每个阶段的感觉。在认证用户时,Blazor 将在 Authorizing 标签中渲染内容 – 一个 给我们一点时间… 消息。认证完成后,对于认证用户,Blazor 将在 Authorized 部分渲染标记并显示预期的 您有权查看设置。 消息。然而,与 Authorized 属性相反,匿名用户也会看到一些内容 – 一个在 NotAuthorized 标签内,说 您不能在这里,抱歉。 ,向未认证用户提供有意义的反馈。
创建角色
在网络应用程序中,角色是预定义的类别,分配给用户,以确定他们在应用程序中的访问权限和功能。通过将用户分类到角色中,你可以管理和控制每个用户可以查看和执行的操作,从而增强安全和用户体验。角色提供了一种清晰和结构化的方式来执行访问控制。你不需要为每个用户管理权限,而是可以分配角色并根据这些角色定义访问规则。这种方法简化了用户权限的管理,并确保了应用程序中安全策略的一致性。
让我们添加一个小表单,让认证用户可以在应用程序中创建新的角色。
准备工作
在我们实现角色创建表单之前,执行以下操作:
-
在服务器端项目中,创建一个 Components / Recipes / Recipe04 目录 – 这将是你的工作目录
-
从 Securing markup areas 菜单或从 GitHub 仓库中的 Components / Recipes / Recipe03 目录复制 Settings 组件
-
如果你还没有搭建项目,请将 GitHub 仓库中 Components / Account / Shared 目录下的 StatusMessage 组件复制到服务器端项目的相同路径
如何操作…
按照以下说明设置角色的支持和管理:
-
导航到服务器端项目的 Program.cs 文件。找到我们注册身份服务部分的区域,从 AddIdentityCore() 方法开始。
-
在AddIdentityCore()方法之后,调用AddRoles()方法,并利用默认的IdentityRole模型声明应用程序的角色模型。在AddEntityFrameworkStores()方法下方,使用AddRoleManager()构建方法并借助默认的RoleManager服务为IdentityRole模型注册一个角色管理器:
builder.Services.AddIdentityCore<ApplicationUser>() .AddRoles<IdentityRole>() .AddEntityFrameworkStores<ApplicationDbContext>() .AddRoleManager<RoleManager<IdentityRole>>() .AddSignInManager() .AddDefaultTokenProviders(); -
打开设置组件,在@page指令下方,添加一组@using指令,引用必要的程序集:
@using BlazorCookbook.Auth.Components.Account @using BlazorCookbook.Auth.Components.Account.Shared @using Microsoft.AspNetCore.Identity -
在@using部分下方,注入RoleManager和Navigation服务:
@inject RoleManager<IdentityRole> RoleManager @inject IdentityRedirectManager Navigation -
在设置组件中,初始化@code块,并构建一个具有单个RoleName属性的InputModel类:
@code { private sealed class InputModel { public string RoleName { get; set; } } } -
在服务注入下方,拦截HttpContext的级联值:
[CascadingParameter] private HttpContext HttpContext { get; set; } -
在HttpContext下方,声明一个由表单提供的Input参数,并覆盖OnInitialized()生命周期方法来完成表单初始化模式:
[SupplyParameterFromForm] private InputModel Input { get; set; } = new(); protected override void OnInitialized() => Input ??= new(); -
通过实现一个SaveAsync()方法来完善@code块,在该方法中初始化一个新的IdentityRole对象,并利用RoleManager服务来保存新的角色。使用Navigation服务执行自我重定向并显示操作状态:
private async Task SaveAsync() { var role = new IdentityRole(Input.RoleName); await RoleManager.CreateAsync(role); Navigation.RedirectToCurrentPageWithStatus( $"'{role.Name}' role has been created", HttpContext); } -
在设置组件的标记中,找到AuthorizeView标签,并为Context参数声明一个自定义名称。同时,将身份验证状态消息替换为StatusMessage组件:
<AuthorizeView Context="auth"> <StatusMessage /> </AuthorizeView> -
在StatusMessage下方,初始化一个EditForm组件,将Input模型和SaveAsync()方法绑定到Model和OnValidSubmit参数。记住也要声明一个唯一的EditForm名称:
<EditForm FormName="creator" OnValidSubmit="@SaveAsync" Model="@Input"> </EditForm> -
在EditForm组件内部,添加一个段落,并将可编辑的输入框绑定到Input.RoleName属性:
<p>Role name <InputText @bind-Value="@Input.RoleName" /> </p> -
通过在角色名称输入下方添加一个表单提交按钮来完善EditForm组件:
<p><button type="submit">Save</button></p>
它是如何工作的…
在步骤 1中,我们导航到服务器端项目的Program.cs文件,并找到注册身份服务的那部分。这是一个以AddIdentityCore()方法开始的段落,并生成一个IdentityBuilder对象。在步骤 2中,我们调用AddRoles()方法向身份系统添加角色管理功能。AddRoles()方法是一个泛型方法,需要一个身份角色模型类。我们利用随身份包提供的默认IdentityRole模型,这个模型足以满足我们的需求。接下来,在AddEntityFrameworkStores()方法下方,我们使用AddRoleManager()构建方法注册角色管理器,使用默认的RoleManager服务为IdentityRole模型。我们有效地在应用程序中启用了角色支持和角色管理。
在步骤 3中,我们移动到设置组件。首先,我们在@page下方添加一组@using指令来引用必要的程序集,以便访问生成的Account区域和内置的身份服务。在步骤 4中,我们注入RoleManager和Navigation服务,分别处理角色管理和导航。在步骤 5中,我们在设置组件中初始化@code块。在@code块内部,我们构建了一个具有单个RoleName属性的InputModel类。当用户填写表单时,InputModel类将保存新角色的详细信息。在步骤 6中,我们拦截HttpContext的级联值以访问当前的 HTTP 上下文——这对于稍后通信角色创建状态是必要的。HttpContext对象并非神奇地出现——当 Blazor 以 SSR 模式渲染时,它默认级联暴露HttpContext实例。在步骤 7中,在HttpContext下方,我们声明了一个由表单提供的Input参数,并覆盖了OnInitialized()生命周期方法以完成表单初始化模式。您可以在第六章中了解更多关于构建表单的信息。在步骤 8中,我们通过实现SaveAsync()方法来完成@code块。在SaveAsync()中,我们初始化一个新的IdentityRole对象,并利用RoleManager将新角色保存到数据库。我们使用Navigation服务和HttpContext执行自我重定向并向用户发送操作状态。
在步骤 9中,我们切换到设置标记。首先,我们定位到AuthorizeView标签。AuthorizeView组件是一个通用组件,因此它暴露了一个Context属性。同样,我们将用于我们的表单的EditForm也是一个具有Context属性的通用组件。我们将遇到冲突,并且应用程序将无法编译!为了解决这个问题,我们给AuthorizeView的Context属性赋予一个自定义名称。我们还用StatusMessage组件替换了现有的身份验证状态消息。StatusMessage组件拦截HttpContext并从指定的 cookie 中解析状态消息。这就是为什么我们需要在@code块中包含HttpContext的原因——以便正确地附加状态 cookie。在步骤 10中,我们在StatusMessage下方初始化一个EditForm组件,将Input模型和SaveAsync()方法分别附加到Model和OnValidSubmit参数上。我们还为EditForm声明了一个唯一的FormName类。在EditForm内部,在步骤 11中,我们添加了一个段落,其中包含一个可编辑的输入框绑定到Input.RoleName属性,允许用户输入新的角色名称。最后,在步骤 12中,我们通过在角色名称输入下方添加一个表单提交按钮来完成EditForm组件。
修改用户身份
修改用户的身份对于定制应用程序的功能和提升用户体验至关重要。拥有额外的身份属性,你可以启用更多个性化的交互并更好地管理特定于用户的信息。在许多应用程序中,用户名等同于用户的电子邮件,但这不足以显示个性化的问候、发送定制的通知或生成报告。但不用担心。在 Blazor 中,身份非常灵活。
让用户填写他们的名字和姓氏。
准备工作
在扩展用户的身份之前,执行以下操作:
-
在服务器端项目中创建一个Components / Recipes / Recipe05目录——这将是你的工作目录
-
从Creating roles配方或从 GitHub 仓库中的Components / Recipes / Recipe03目录复制Settings组件
-
在 GitHub 仓库中服务器端项目的Samples目录中找到seed-work.sql脚本并在你的数据库上运行它
-
如果你还没有生成项目,从 GitHub 仓库的Components / Account / Shared目录复制StatusMessage组件到服务器端项目的相同路径
如何操作…
按照以下步骤扩展默认的用户身份模型:
-
导航到服务器端项目Data目录中的ApplicationUser类,并扩展它以包含FirstName和LastName属性:
public class ApplicationUser : IdentityUser { public string FirstName { get; set; } public string LastName { get; set; } } -
使用Package Manager Console,调用 Entity Framework 命令生成新的AddedUserFullName数据库迁移:
add-migration AddedUserFullName或者,如果你使用.NET CLI,使用以下命令生成相同的迁移:
dotnet ef migrations add AddedUserFullName你将在Data / Migrations目录中获得一些新的文件:

图 8.9:向数据库中的 ApplicationUser 添加 FirstName 和 LastName 属性的迁移文件
-
通过在Package Manager Console中调用另一个命令将AddedUserFullName迁移应用到数据库:
update-database或者,如果你使用.NET CLI,使用以下命令更新数据库:
dotnet ef database update -
打开Settings组件,并在现有指令旁边添加一个额外的@using指令:
@using BlazorCookbook.Auth.Data -
在下面的注入部分,将RoleManager服务替换为IdentityUserAccessor、UserManager和SignInManager服务。保留已可用的Navigation服务:
@inject IdentityUserAccessor UserAccessor @inject UserManager<ApplicationUser> UserManager @inject SignInManager<ApplicationUser> SignInManager @inject IdentityRedirectManager Navigation -
在@code块中,通过用FirstName和LastName替换现有属性来更新InputModel类:
private sealed class InputModel { public string FirstName { get; set; } public string LastName { get; set; } } -
在现有的SaveAsync()方法上方,声明一个私有的ApplicationUser字段:
private ApplicationUser _user; -
在_user声明下方,重写OnInitializedAsync()生命周期方法。利用UserAccessor实例从数据库获取用户详情并填充Input模型:
protected override async Task OnInitializedAsync() { _user = await UserAccessor .GetRequiredUserAsync(HttpContext); Input.FirstName ??= _user.FirstName; Input.LastName ??= _user.LastName; } -
为了完成@code块,更新SaveAsync()方法,使其从填充的Input模型更新_user详细信息,借助UserManager持久化更改,并使用SignInManager刷新用户上下文。最后,更新返回给用户的提示信息:
private async Task SaveAsync() { _user.FirstName = Input.FirstName; _user.LastName = Input.LastName; await UserManager.UpdateAsync(_user); await SignInManager.RefreshSignInAsync(_user); Navigation.RedirectToCurrentPageWithStatus( "Your profile has been updated", HttpContext); } -
跳转到设置标记区域,找到现有EditForm组件的内容区域。
-
更新现有的输入标签为First Name,并将绑定修复到Input.FirstName属性:
<p>First Name <InputText @bind-Value="@Input.FirstName" /> </p> -
在姓名下方添加一个段落,显示另一个可编辑的输入绑定到Input.LastName属性:
<p>Last Name <InputText @bind-Value="@Input.LastName" /> </p>
它是如何工作的…
在步骤 1中,我们导航到服务器端项目的Data目录中的ApplicationUser类。ApplicationUser类代表我们应用程序的用户,目前从默认的IdentityUser类继承以兼容身份架构。现在,我们通过FirstName和LastName属性扩展我们的用户身份详细信息。在步骤 2中,我们使用数据库迁移扩展了身份数据库。数据库迁移是一种管理并随时间应用数据库模式增量更改的方法。它们允许开发者在代码中定义对数据库结构的更改,例如添加或修改表和列,确保数据库与应用程序保持同步。我们打开 Visual Studio 中可用的包管理器控制台,并生成一个新的AddedUserFullName数据库迁移。Entity Framework 工具将在Data/Migrations目录中生成两个新的文件。在步骤 3中,我们再次使用包管理器控制台将AddedUserFullName迁移应用到数据库中。我们不会探索生成的迁移或迁移命令,因为它们不在本书的范围内,但你可以在食谱末尾的参见部分找到更多资源。
接下来,在步骤 4中,我们打开设置组件,并将现有的@using指令集扩展到对BlazorCookbook.Auth.Data程序集的引用,其中包含ApplicationUser类。在步骤 5中,我们移除了RoleManager服务注入,因为我们不会使用角色。相反,我们添加了一些其他的身份服务。我们需要IdentityUserAccessor来从应用程序的HttpContext实例中解析用户上下文。借助UserManager和SignInManager,我们可以安全地操作和刷新用户详情。在步骤 6中,我们更新InputModel类以支持我们的新要求,并用FirstName和LastName属性替换所有现有属性,以匹配我们希望在新的表单中看到的详细信息。此时,你会看到一些 IDE 错误,因为现有的表单不再与更新的InputModel类兼容。我们将很快解决这个问题。在步骤 7中,我们声明一个后置字段——一个私有的ApplicationUser变量,用于存储表示当前登录用户的数据库对象的引用。我们将使用它来持久化用户提供的第一个和最后一个名字。在步骤 8中,我们重写OnInitializedAsync()生命周期方法。我们利用注入的UserAccessor服务从HttpContext解析ApplicationUser对象到_user实例,并用找到的详细信息填充Input模型。这样,我们确保在 UI 渲染之前,表单已经预先填充了当前用户的详细信息。为了完成@code块,在步骤 9中,我们更新SaveAsync()方法,使其支持更新的Input模型并保存用户身份详情。我们使用来自表单的数据更新持久化的_user对象,并借助UserManager将这些更改保存到数据库。更新后,我们使用SignInManager刷新用户上下文,并执行自我重定向以在 UI 上显示您的个人资料已更新的消息。
接下来,在步骤 10中,我们跳转到设置标记区域并定位现有的EditForm组件。我们将调整表单以支持填写用户的第一个和最后一个名字。在步骤 11中,我们通过将其绑定到Input.FirstName属性来修复不再兼容的输入框。我们还更新了标签为First name,以清楚地表明用户正在更新哪个字段。同样,在步骤 12中,我们添加了一个段落,包含另一个可编辑的输入框,带有Last name标签并将其绑定到Input.LastName属性。
在表单就绪后,你可以运行应用程序并更新你将使用的账户的第一个和最后一个名字。当你填写输入并保存更改时,你会收到一个友好的确认消息:

图 8.10:确认更改成功应用的状态消息
你也可以通过显示AspNetUsers表中的记录来检查数据库中的更改:

图 8.11:在数据库中查看姓名更新
参见
在这个菜谱中,我们提到了数据库迁移的概念。这是一个值得单独成书的话题,但如果你想了解更多,请访问微软团队准备的学习资源:learn.microsoft.com/en-us/ef/core/managing-schemas/migrations。
支持角色和策略的授权
保护你的应用程序可能不仅仅是关于拥有一个经过身份验证的用户;它通常需要更细粒度的控制。你可能需要根据用户的角色授予对特定功能或页面的访问权限。Blazor 的本地授权 API – Authorize属性和AuthorizeView组件 – 支持从 MVC 应用程序或 REST API 中熟悉的角色和策略。
让我们实现角色和策略,微调设置页面以显示管理员和标准用户的不同内容。
准备工作
在我们实施策略和角色之前,请执行以下操作:
-
在服务器端项目中,创建Components / Recipes / Recipe06目录 – 这将是你的工作目录。
-
从修改用户身份菜谱或从 GitHub 仓库中的Components / Recipes / Recipe05目录复制Settings组件。
-
如果你还没有运行迁移,请在服务器端项目的Samples目录中找到seed-work.sql脚本,并在你的数据库上运行它。
-
如果你没有跟随操作,请确保你的服务器端项目中启用了角色支持;你必须利用我们在创建 角色菜谱中讨论的AddRoles()构建器 API 方法。
如何操作...
要在服务器和客户端都添加角色和策略支持,请按照以下步骤操作:
-
导航到BlazorCookbook.Auth.Client项目中的Program.cs文件 – 客户端应用程序。
-
在Program.cs文件中,找到AddAuthorizationCore()方法调用,并使用options对其进行重载以配置检查用户电子邮件是否属于@ packt.com域的InternalEmployee策略:
builder.Services.AddAuthorizationCore(options => { options.AddPolicy("InternalEmployee", policy => policy.RequireAssertion(context => context.User?.Identity?.Name? .EndsWith("@packt.com") ?? false)); }); -
仍然在客户端,打开UserInfo类,并扩展它以包含Role属性:
public class UserInfo { //... existing properties ... public required string Role { get; set; } } -
接下来,导航到PersistentAuthenticationStateProvider类,并在构造函数中扩展claims数组以包括新添加的Role值:
Claim[] claims = [ // ... existing properties ... new Claim(ClaimTypes.Email, userInfo.Email), new Claim(ClaimTypes.Role, userInfo.Role), ]; -
切换到服务器端应用程序,并打开BlazorCookbook.Auth项目的Program.cs文件。
-
找到应用程序构建的位置,并在那里之前,使用授权构建器添加与客户端相同的InternalEmployee策略:
builder.Services .AddAuthorizationBuilder() .AddPolicy("InternalEmployee", policy => policy.RequireAssertion(context => context.User?.Identity?.Name? .EndsWith("@packt.com") ?? false)); var app = builder.Build(); -
导航到 PersistingRevalidatingAuthenticationStateProvider 类的 OnPersistingAsync 方法,并扩展为认证用户执行的逻辑,以便将角色追加到 Blazor 将发送到客户端的 UserInfo 类:
var userId = principal.FindFirst( options.ClaimsIdentity.UserIdClaimType)?.Value; var email = principal.FindFirst( options.ClaimsIdentity.EmailClaimType)?.Value; var role = principal.FindFirst( options.ClaimsIdentity.RoleClaimType)?.Value; state.PersistAsJson(nameof(UserInfo), new UserInfo { UserId = userId, Email = email, Role = role }); -
打开 Settings 组件,在 @page 指令下方,添加一个使用 InternalEmployee 策略的 Authorize 属性重载:
@using Microsoft.AspNetCore.Authorization @attribute [Authorize(Policy = "InternalEmployee")] -
在 Settings 标记中,找到现有的 AuthorizeView 开启标签,并将 Roles 参数设置为允许 Support 和 Admin 角色:
<AuthorizeView Context="user" Roles="Support,Admin"> @* here's still the existing EditForm *@ </AuthorizeView> -
在 EditForm 受保护区域下方,构建另一个 AuthorizeView 部分,保护一个 关闭应用程序 按钮,并且只为 Admin 角色的用户渲染内容:
<AuthorizeView Roles="Admin"> <p><button>Shut down the app</button></p> </AuthorizeView>
它是如何工作的…
我们从客户端应用程序开始,因此在第 1 步中,我们导航到 BlazorCookbook.Auth.Client 项目的 Program.cs 文件。在第 2 步中,我们通过找到 AddAuthorizationCore() 方法调用并使用配置 InternalEmployee 策略的选项来扩展授权注册。我们利用 AuthorizationPolicyBuilder 类(我们称之为 policy),来检查当前登录用户的电子邮件是否属于 @packt.com 域。AuthorizationPolicyBuilder 类支持自定义断言(我们使用了)以及检查声明、用户名或 .NET 原生的 IAuthorizationRequirement 对象。在第 3 步中,我们打开 UserInfo 类,并扩展它以包含一个 Role 属性。UserInfo 类是 Blazor 用于在渲染模式边界之间共享用户身份细节的模型。由于我们需要 WebAssembly 端正确解析用户角色,我们必须明确地将它们传递到那里。在第 4 步中,我们通过扩展 PersistentAuthenticationStateProvider 类的构造函数来完成客户端配置。Blazor 使用 PersistentAuthenticationStateProvider 来确定从服务器端到达的用户认证状态。在构造函数中,我们将状态反序列化为 UserInfo 对象,并将 claims 数组扩展以包含新添加的 Role 属性的值。现在,无论我们的应用程序在浏览器中以本地方式运行,用户的角色都将仍然可用以进行验证。
在步骤 5中,我们切换到服务器端应用程序并打开BlazorCookbook.Auth项目的Program.cs文件。在步骤 6中,我们定位到调用builder.Build()方法以构建应用程序的位置。在那之前,我们使用授权构建器添加与客户端相同的InternalEmployee策略。由于策略断言确实相同,因此配置授权的服务器 API 略有不同。我们调用AddAuthorizationBuilder()方法来访问AuthorizationBuilder实例,因为它公开了AddPolicy()构建方法。在步骤 7中,我们通过导航到PersistingRevalidatingAuthenticationStateProvider类的OnPersistingAsync方法来完成服务器端实现。这是 Blazor 在将用户的身份传递到浏览器时使用的服务。我们定位到认证用户的逻辑。它已经包含了共享用户的 ID 和电子邮件。我们通过从当前的principal值中获取RoleClaimType的值并将其传递给 Blazor 将作为 JSON 持久化到输出响应中的UserRole对象,遵循相同的实现模式。
现在,我们将所有这些授权实现付诸实践。在步骤 8中,我们打开Settings组件并添加Authorize属性。它需要一个对Microsoft.AspNetCore.Authorization程序集的引用,所以我们使用@using指令授予它。然后,我们利用Authorize属性的过载功能。我们可以设置Policy属性,以便用户必须满足它才能访问Settings页面。这就是我们最终使用InternalEmployee策略的地方。在步骤 9中,我们继续到Settings标记。我们找到现有的AuthorizeView打开标签,它包裹着用户可以填写他们的名字和姓氏的表单。我们将Roles参数设置为Support和Admin值,确保只有当前用户处于预期的任何角色时,表单才会渲染。Roles参数接受一个string对象,因此您可以提供一个或多个以逗号分隔的角色。您也可以在同一个组件中拥有所需数量的受保护标记区域。在步骤 10中,我们在已存在的AuthorizeView区域下方构建另一个区域。在内部,我们构建一个空闲的关闭应用程序按钮,但我们确保它只为Admin角色的用户渲染。
我们得到了一个完全功能、安全的视图,它可以动态地调整给任何查看它的人。
图 8.12显示了具有Admin角色的用户看到的存储设置:

图 8.12:显示给 admin@packt.com 用户的存储设置
图 8.13显示了具有Support角色的用户看到的存储设置:

图 8.13:支持@packt.com 用户看到的存储设置
如您所见,当我以admin@packt.com登录,该账户具有管理员角色,我可以看到编辑表单和最受限的关闭应用程序按钮。但当我切换到support@packt.com时,关闭应用程序按钮消失了!我鼓励您检查以user@packt.com登录时 UI 如何变化。
还有更多...
Authorize属性支持与AuthorizeView相同的授权 API。两者都可以使用Roles和Policy来验证用户的身份是否符合特定标准。您甚至可以同时使用Roles和Policy!
@attribute [Authorize(Roles = "User,Support,Admin")]
<h3>Settings</h3>
<AuthorizeView Context="user" Policy="InternalEmployee">
@* here's still the existing EditForm *@
</AuthorizeView>
<AuthorizeView Policy="InternalEmployee" Roles="Admin">
<p><button>Shutdown the app</button></p>
</AuthorizeView>
使用Authorize属性,我们现在验证登录用户是否有以下三个允许的角色之一:用户、支持或管理员。此外,我们还更新了用户详细信息编辑表单的渲染。现在,AuthorizeView将表单显示给任何符合内部员工策略并且拥有任何可用角色的用户。我们还更新了关闭应用程序按钮的限制——用户必须拥有管理员角色并且属于@packt.com域,这是由内部员工策略强制执行的。
Authorize和AuthorizeView的参数工作方式类似,但应用在不同的级别。问题仍然是何时使用属性,何时使用组件最为合适。当需要保护对给定资源或页面的导航,确保只有授权用户可以访问时,使用Authorize。另一方面,当您需要限制对标记中某些区域的访问而不影响整体路由时,使用AuthorizeView。这种方法为保护 Blazor 应用程序提供了一种全面的方式,确保只有授权用户可以访问特定的功能和内容。
在程序逻辑中解析认证状态
将身份验证和授权集成到应用程序的过程逻辑中通常是必要的。在这些场景中,仅仅操作标记可见性可能不够;您需要在代码中根据当前的认证状态做出明智的决定。这就是级联AuthenticationState类的作用所在。AuthenticationState类是一个内置的 Blazor 功能,它提供了有关用户认证状态和声明的信息。
让我们添加一个按钮,根据内部员工的角色将他们重定向到票务系统的不同区域。
准备工作
在我们利用程序逻辑中的认证状态之前,请执行以下操作:
-
在服务器端项目中,创建一个Components / Recipes / Recipe07目录——这将成为您的工作目录
-
从 GitHub 仓库的Components / Recipes / Recipe07目录复制FakePages目录
-
如果您还没有运行迁移,请在服务器端项目的Samples目录中找到seed-work.sql脚本,并在您的数据库上运行它
-
由于我们需要启用交互性,我们不能再使用任何现有的设置组件,因此我们将创建一个全新的组件
如何做到这一点…
按照以下说明在过程逻辑中利用身份验证状态:
-
创建一个新的具有服务器端交互和注入的导航服务的可路由设置组件:
@page "/ch08r07" @rendermode InteractiveServer @inject NavigationManager Navigation -
添加一个@code块来拦截级联身份验证状态:
@code { [CascadingParameter] private Task<AuthenticationState> AuthState { get; set; } } -
在AuthState参数下方,初始化一个GoToTicketsAsync()方法并解析用户上下文:
private async Task GoToTicketsAsync() { var user = (await AuthState).User; //we will continue building logic here } -
在用户上下文下方,检查用户的身份属性是否有值,如果缺失则重定向到登录页面:
if (user.Identity is null) { Navigation.NavigateTo("/Account/Login"); return; } -
在身份验证之后,检查用户是否正确认证,如果没有则重定向到登录页面:
if (!user.Identity.IsAuthenticated) { Navigation.NavigateTo("/Account/Login"); return; } -
在身份验证验证之后,检查用户名称属性的值是否属于@packt.com域名,如果不是,则将他们重定向到票务系统的着陆页:
if (!user.Identity.Name.EndsWith("@packt.com")) { Navigation.NavigateTo("/tickets"); return; } -
在用户域检查之后,检查用户是否在支持或管理员角色中,并将他们重定向到票务系统的管理员面板:
if (user.IsInRole("Support") || user.IsInRole("Admin")) { Navigation.NavigateTo("/tickets/admin"); return; } -
最后,如果用户的身份不匹配任何已处理的案例,则将他们重定向到访问拒绝页面:
Navigation.NavigateTo("/tickets/denied"); -
跳转到设置组件的标记并添加一个按钮以导航到票务系统:
<p> <button @onclick=@GoToTicketsAsync> Support tickets </button> </p>
它是如何工作的…
在步骤 1中,我们创建一个新的可路由设置组件,以交互式服务器模式渲染,因为我们希望用户通过点击按钮导航到票务系统。
如果你跟完了整个章节或者已经搭建了你的项目,那么你将已经注册了级联身份验证状态。但为了给你一个全面的概述,在服务器端和客户端项目中,在它们的Program.cs文件中,你将找到(或者如果缺失则添加)builder.Services.AddCascadingAuthenticationState()命令,该命令明确启用了应用程序中的级联身份验证状态。
在步骤 2中,我们在设置组件中初始化@code块。首先,我们拦截身份验证状态。Blazor 将AuthenticationState作为一个Task参数共享——与现代网络开发一致,所有操作本质上都是异步的,因为AuthenticationStateProvider实现可能包含构建身份验证状态的异步逻辑。我们还注入了一个NavigationManager服务来帮助我们重定向用户到目标位置。在接下来的几个步骤中,仍然在@code块内部,我们实现了一个GoToTicketsAsync()方法,根据用户的身份上下文解决重定向目标。在步骤 3中,我们通过等待AuthState并从结果中获取User属性来解析用户对象。在步骤 4中,我们检查当前用户是否设置了Identity值,如果用户尚未登录,该值可以是null。如果Identity值缺失,我们立即将用户重定向到登录页面。在步骤 5中,我们使用IsAuthenticated属性对Identity值进行额外检查,以验证用户是否已登录并正确认证。如果该检查失败,我们将用户重定向到登录页面以重新验证其身份验证状态。现在我们确信当前用户有一个有效的身份,在步骤 6中,我们检查用户是否实际上是内部员工。我们利用user对象的Name属性,代表用户在应用程序中的登录。由于在我们的案例中,Name属性等同于用户的电子邮件,我们验证当前检查的用户账户是否属于@packt.com域名。如果该检查失败,我们将用户重定向到/tickets页面,在那里他们可以作为标准应用程序用户创建新的支持票证。在步骤 7中,知道是内部员工在使用应用程序,我们检查他们是否有Admin或Support角色。如果有,我们将他们重定向到/tickets/admin页面,在那里他们可以访问票务系统的管理面板。在步骤 8中,我们关闭GoToTicketsAsync()方法的实现。当所有之前的身份验证和授权检查都失败时,我们假设用户的账户不完整,并将他们重定向到/tickets/denied页面,表明他们无法访问票务系统。
在步骤 9中,我们扩展了设置组件的标记。在现有的h3标题下方,我们添加了一个具有按钮属性的段落,该属性在点击时调用GoToTicketsAsync()方法,使用户能够导航到票务系统。有效的重定向取决于我们添加的程序逻辑的结果和用户的身份。
第九章:探索导航和路由
路由和导航是任何现代 Web 应用程序的基本功能。在 Blazor Web App 中,路由是将 URL 映射到 Razor 组件的过程,使用户能够在不同的视图之间导航。导航指的是涉及从一个路由移动到另一个路由的动作和过程,无论是通过用户交互、程序性命令还是其他方式。Blazor 提供了一个灵活的路由系统,支持静态和交互式路由,具体取决于如何配置应用程序。
静态路由在启用预渲染的静态服务器端渲染期间发生。在此模式下,由Routes.razor中的Router组件定义的 Blazor 路由器根据 HTTP 请求路径执行路由,直接将 URL 映射到组件。相反,当 Blazor 路由器设置为交互式渲染模式时,在服务器上的初始渲染完成后,它会自动从静态路由转换为交互式路由。交互式路由使用文档的 URL(浏览器地址栏中的 URL)来确定要动态渲染哪个组件,允许应用程序响应用户交互并导航,而无需执行完整的 HTTP 请求。这种方法使得动态内容更新和应用内的无缝导航成为可能。
当比较 Blazor 中的路由与 ASP.NET Core 中的路由时,需要考虑相似之处和关键差异。ASP.NET Core 主要使用控制器和操作进行路由,其中路由通常以集中化的方式定义,通常使用基于属性的或传统路由。相比之下,Blazor 的路由是基于组件的,直接将 URL 映射到 Razor 组件而不是控制器操作。Blazor 中的这种基于组件的方法允许更模块化和封装的路由体验,其中每个组件可以管理自己的导航逻辑。此外,Blazor 支持客户端浏览器内的导航,无需完整页面刷新,这与 ASP.NET Core 中的传统服务器端路由有显著差异。
在本章中,我们将介绍使用 .NET 9 在 Blazor 网络应用程序中路由和导航的各个方面。我们将从启用来自多个程序集的路由开始,这对于构建模块化应用程序和您将利用外部 NuGet 包的场景至关重要。然后,我们将探讨参数化路由,您将学习如何使用路由参数创建动态和灵活的 URL。接着,我们将讨论实现统一的 深度链接 以集中管理路由定义。我们还将涵盖处理错误的导航请求和控制导航历史以增强用户体验。接近尾声时,我们将解释如何在导航期间执行异步操作,并在用户导航离开时取消长时间运行的任务。最后,我们将探讨如何在用户离开表单之前提示用户关于未保存的更改,以防止意外数据丢失。
到本章结束时,您将了解 Blazor 中的路由工作原理以及如何实现各种路由和导航场景。
路由和导航是影响整个应用程序行为的关键组件。然而,本章中的食谱完全独立,互不依赖。这种方法还意味着您可以单独审查和实现每个食谱。食谱开始于关于您应该创建哪个工作目录以及您需要执行以下任务所需的示例文件说明。
在本章中,我们将介绍以下食谱:
-
启用来自多个程序集的路由
-
与参数化路由一起工作
-
与查询参数一起工作
-
实现统一的深度链接
-
处理错误的导航请求
-
在导航时执行异步操作
-
当用户导航离开时取消长时间运行的任务
-
控制导航历史
技术要求
在深入研究之前,请确保您有以下条件:
-
安装了 .NET 9 SDK
-
一个支持 Blazor 开发的现代 IDE(Integrated Development Environment)
-
一个支持 WebAssembly 的现代网络浏览器
-
Blazor 项目
我们将在 BlazorCookbook.App 项目中构建所有食谱,因此所有引用都将反映该程序集。请确保您调整程序集引用以匹配您的项目。
您可以在 GitHub 上找到本章中编写的所有代码和代码示例:github.com/PacktPublishing/Blazor-Web-Development-Cookbook/tree/main/BlazorCookbook.App.Client/Chapters/Chapter09 .
启用来自多个程序集的路由
您可能希望通过在多个程序集中分散路由来模块化您的 Blazor 应用程序。模块化是将应用程序分解成更小、更易于管理和独立的模块的实践,每个模块负责特定的功能。当在大团队或分布式团队中工作时,这是一个理想的开发方法,因为每个团队可以独立交付功能。模块化对大型应用程序也有益,因为您可以将不同的功能封装在单独的程序集中。Blazor 允许您通过 Router 组件的 API 发现来自额外程序集的可路由组件,用于交互式路由,以及用于静态路由设置的端点约定构建器。
让我们学习如何允许用户从不同于我们的基础项目的程序集中导航到组件。
准备工作
在我们扩展程序集之前,Blazor 扫描可路由组件的位置,将 BlazorCookbook.Library 项目从 GitHub 仓库复制到您的解决方案中。
如何操作…
按照以下步骤允许 Blazor 从不同的程序集中发现路由:
-
导航到您解决方案中的 BlazorCookbook.App 服务器端项目。
-
打开 BlazorCookbook.App.csproj 并添加对 BlazorCookbook.Library 项目的引用:
<ItemGroup> <ProjectReference Include="..\BlazorCookbook.App.Client\ BlazorCookbook.App.Client.csproj" /> <ProjectReference Include="..\BlazorCookbook.Library\ BlazorCookbook.Library.csproj" /> </ItemGroup> -
打开 Program.cs 并定位到组件映射部分。使用 AddAdditionalAssemblies() 方法映射来自 ExternalEventManager 程序集的路由:
using BlazorCookbook.Library.Chapter09.Recipe01; //other registrations and pipelines app.MapRazorComponents<App>() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies( typeof(_Imports).Assembly, typeof(ExternalEventManager).Assembly ); -
打开 Routes 组件,并将 AdditionalAssemblies 参数附加的数组扩展到包含 ExternalEventManager 程序集:
@using BlazorCookbook.Library.Chapter09.Recipe01 <Router AppAssembly="@typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly, typeof(ExternalEventManager).Assembly }"> @* router configuration *@ </Router>
它是如何工作的…
在这个菜谱中,我们在解决方案的服务器端项目中添加了所有可路由组件可发现性的配置。在 第 2 步 中,我们找到 BlazorCookbook.App 项目的配置文件,并引用您从 GitHub 仓库复制的 BlazorCookbook.Library 项目。BlazorCookbook.Library 包含一个 ExternalEventManager 组件,我们希望我们的用户能够导航到它。
在 第 3 步 中,我们设置了可能来自 BlazorCookbook.Library 的静态路由的可发现性。我们导航到 BlazorCookbook.App 项目的 Program.cs 文件,并定位到构建应用程序端点约定的位置。端点路由构建器从 MapRazorComponents() 方法调用开始。在构建器末尾,我们调用 AddAdditionalAssemblies() 方法来映射来自 BlazorCookbook.App.Client 项目的所有静态路由。现在,我们将 ExternalEventManager 程序集扩展到额外的程序集数组中。为了使注册类型安全,而不是简单的字符串,我们使用 typeof() 方法。.NET 中的 typeof() 方法允许我们获取给定类型名称的 Type 对象,从而在运行时启用反射和元数据访问。此外,它还允许检索包含类型的程序集,这完美地解决了我们的需求。
在 步骤 4 中,我们导航到服务器端项目中的 Routes 组件,以扩展交互式路由的可发现性。在这里,我们找到了我们的应用程序 Router 配置。通过指定具有 AppAssembly 和 AdditionalAssemblies 参数的程序集,Router 可以动态发现并将路由映射到这些程序集中定义的组件。在我们的情况下,我们再次发现客户端项目程序集已经附加到 Router 的 AdditionalAssemblies 参数。我们通过引用 ExternalEventManager 程序集扩展了 AdditionalAssemblies。
使用参数化路由进行工作
在 Blazor 中,参数化路由允许您通过 URL 传递参数,使您的应用程序更加动态和灵活。通过利用路由参数,您可以创建对特定 URL 段落做出响应的组件,并根据这些参数渲染内容。您还可以使用路由参数来持久化组件状态,并允许用户将其添加到书签(我们在 第五章 的开头进行了探索)。
让我们通过参数化路由扩展组件路由,强制参数约束。
准备工作
在探索参数化路由之前,执行以下操作:
-
创建一个 Chapter09 / Recipe02 目录 – 这将是你的工作目录
-
从 BlazorCookbook.Library 项目中的 Chapter09 / Recipe01 目录或 GitHub 仓库中的对应目录复制 ExternalEventManager
如何做这件事...
按照以下步骤实现具有参数的路由并拦截它们的值:
-
导航到 ExternalEventManager 组件,并使用参数化选项扩展其路由:
@page "/ch09r02" @page "/ch09r02/{eventId:guid}" @page "/ch09r02/{eventId:guid}/venues/{venue?}" -
在 ExternalEventManager 中,使用两个参数 – EventId 和 Venue 初始化 @code 块:
@code { [Parameter] public Guid EventId { get; set; } [Parameter] public string Venue { get; set; } } -
通过构建 Venue 和 EventId 值的条件显示来扩展 ExternalEventManager 标记:
@if (EventId == default) return; <p>Event ID: @EventId</p> <p>In @(Venue ?? "all venues")</p>
它是如何工作的...
在 步骤 1 中,我们导航到 ExternalEventManager 并在那里扩展路由。我们添加了一个新的路由,该路由期望一个 eventId 参数,通过将参数名称放在大括号中来放置参数名称。我们还声明它必须是 guid 类型。Blazor 还支持路由参数约束,这通过自动拒绝不符合指定约束的参数值来增强应用程序的安全性。提供不兼容值的用户将收到 404 错误状态码。虽然路由参数名称不区分大小写,但约束必须遵循配置的大小写。由于约束支持有限,在食谱的 另请参阅 部分中有一个链接到所有当前支持的数据类型。在最后添加的路由中,我们通过在末尾添加一个 ? 符号来声明 venue? 可选路由参数。有一个可选参数意味着用户无论是否提供 venue 的值都可以导航到该页面,并且我们可以相应地调整显示逻辑。
在步骤 2中,我们在ExternalEventManager中初始化一个@code块,并声明两个参数,EventId和Venue,与在路由中添加的参数名称匹配,但遵循 Pascal 大小写约定。这就是启用 Blazor 将路由参数绑定到组件属性所需的所有操作。
在步骤 3中,我们在ExternalEventManager中构建了一个简单的标记。在现有的h1元素下方,我们检查路由中是否设置了EventId,并在一个段落中渲染其值。最后,我们在EventId下方添加另一个段落,以显示Venue的当前值或表示用户正在查看所有场地的消息(如果路由中没有提供Venue)。通过这种设置,你可以测试不同的路由如何影响组件的行为。
还有更多...
或者,你可以实现一个“捕获所有”模式来拦截路由参数。你可以将整个路由段拦截为一个字符串参数:
@page "/ch09r02/{*path}"
@code {
[Parameter] public string Path {get; set; }
}
我们仍然在花括号中声明路由参数,并在@code块中声明一个匹配的string参数,类似于其他路由情况。然而,为了表示我们想要拦截整个路由段,我们在参数名称前加上一个符号。例如,当用户导航到/ch09r02/im/definitely/lost时,Blazor 将im/definitely/lost分配给Path*值。只要捕获所有路由段参数是路由路径中的最后一个,你仍然可以混合标准路由参数和约束。
相关内容
要查看 Blazor 支持作为路由参数约束的数据类型的完整列表,请参阅以下 Microsoft 文档链接:learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/routing?view=aspnetcore-9.0#route-constraints。
使用查询参数
查询字符串和参数是 URL 的一部分,允许你向 Web 应用程序传递可选数据。它们出现在 URL 中的?符号之后,由=分隔的键值对组成,并由&连接。查询参数的使用对于过滤数据、分页以及在不显著改变 URL 结构的情况下传递用户特定信息非常有用。
让我们通过允许条件传递一个将被动态加载的事件日期来增强路由。
准备工作
在探索查询参数之前,请执行以下操作:
-
创建一个Chapter09 / Recipe03目录——这将是你的工作目录
-
从使用参数化路由配方或 GitHub 仓库中的Chapter09 / Recipe02目录复制ExternalEventManager
如何操作...
按照以下说明从查询参数中拦截值:
-
导航到 ExternalEventManager 的 @code 块并引入一个 Date 参数,但使用 SupplyParameterFromQuery 属性来指示 Blazor 应从查询字符串中拦截它:
[SupplyParameterFromQuery] public DateTime Date { get; set; } -
在 ExternalEventManager 标记的末尾,检查 Date 是否可用并渲染另一个包含 Date 值的段落:
@if (Date == DateTime.MinValue) return; <p>On @Date</p>
它是如何工作的…
在 步骤 1 中,我们导航到 ExternalEventManager 并声明一个 Date 参数。我们不是使用标准的 Parameter 属性,而是利用 SupplyParameterFromQuery 变体,指示 Blazor 拦截查询字符串中的参数。无需操作路由;只需使用 SupplyParameterFromQuery 注释参数即可启用此功能。
在 步骤 2 中,我们扩展了 ExternalEventManager 标记。我们检查 Date 值是否与 DateTime 的默认值匹配,这表示参数不在查询字符串中。如果 Blazor 拦截了 Date 参数,我们将渲染一个显示其值的段落。然而,需要注意的是,查询字符串中提供的日期格式需要与应用程序的文化设置相匹配。默认情况下,Blazor 和 .NET 预期日期格式为 MM-DD-YYYY,这是 en-US 文化的默认格式,反映了美国标准。如果日期以不同的格式提供,例如 DD-MM-YYYY,则除非应用程序的文化设置适当,否则可能无法正确解析。这可以通过配置适当的文化设置来调整,无论是在全局范围内还是在特定组件中,以确保 Blazor 以所需格式解释日期。
还有更多…
当你的 C# 参数名称与查询字符串中提供的不一致,或者当查询字符串中存在多个相同的参数键并且你需要将所有值拦截到一个数组中时,你必须显式声明 SupplyParameterFromQuery 属性的 Name 属性:
[SupplyParameterFromQuery(Name = "seat")]
public string[] Seats { get; set; }
使用此代码,Blazor 将拦截附加到 seat 键的所有查询参数并将它们存储在 Seats 数组中。然后你可以使用该数组来突出显示或预定具有特定编号的座位。
参见
如果你想了解更多关于 Blazor 中的全球化和文化设置的详细信息,请查看 Microsoft Learn 资源:learn.microsoft.com/en-us/aspnet/core/blazor/globalization-localization#globalization .
实现统一的深度链接
在 Web 应用程序中实现 深度链接 是直接链接到特定内容或功能的能力。在应用程序中实现统一的深度链接服务可以集中管理路由,使应用程序更易于维护和扩展。在一个地方管理路由更改并避免所有路由的不一致性要容易得多。
让我们将一些路由移动到静态深度链接容器中,并更新组件路由以利用这些统一的深度链接。
准备工作
在我们将路由封装到专用容器之前,请执行以下操作:
-
创建Chapter03/Recipe04目录——这将是你的工作目录。
-
从“使用查询参数”配方或 GitHub 仓库中的Chapter09/Recipe03目录复制ExternalEventManager。
如何做到这一点…
按照以下步骤在你的应用程序中引入路由容器:
-
这次,创建一个DeepLinks静态类,而不是一个组件:
public static class DeepLinks { // you will define routes here } -
在DeepLinks类内部,定义三个与ExternalEventManager中相同的const路由:
public const string LandingPage = "/ch09r04", EventPage = "/ch09r04/{eventId:guid}", EventAtVenuePage = "/ch09r04/{eventId:guid}/venues/{venue?}"; -
导航到ExternalEventManager,将@page指令替换为@attribute和[Route]属性。而不是显式提供路由,利用DeepLinks常量:
@attribute [Route(DeepLinks.LandingPage)] @attribute [Route(DeepLinks.EventPage)] @attribute [Route(DeepLinks.EventAtVenuePage)]
它是如何工作的…
在步骤 1中,我们创建一个新的DeepLinks类。DeepLinks不是一个组件,而是一个静态类,因为它代表了一个在整个应用程序中应该易于访问的固定库,并且在整个应用程序的生命周期中不会改变。在步骤 2中,我们在DeepLinks内部声明了三个const路由,用于LandingPage、EventPage和EventAtVenuePage页面。这些路由与我们在ExternalEventManager中明确声明的路由相匹配,因此我们在这里复制了那些值。
在步骤 3中,我们导航到ExternalEventManager并将所有@page指令替换为@attribute。使用@attribute,我们可以利用[Route]属性,该属性接受一个路由作为参数。我们使用DeepLinks路由仓库来显式构建与@page指令相同的路由。尽管我们已经将路由封装在string变量中,但 Blazor 仍然尊重路由参数的约束和可选性。
你可以利用DeepLinks类在应用程序菜单或任何其他地方安全地设置导航链接。通过将路由作为命名对象,你可以避免输入错误并减少路由配置中的错误风险。
还有更多…
你可以通过扩展DeepLinks类来添加方法,这些方法允许你生成有状态的链接,并启用一种更灵活和动态的方式来创建带有路由参数的 URL。例如,你可以实现一个接受EventId并将其正确放置在EventPage路由模板中的方法:
public const string
EventPage = "/ch09r04/{eventId:guid}";
public static string GetPage(Guid eventId)
=> EventPage.Replace("{eventId:guid}", $"{eventId}");
当渲染包含事件的网格时,你可以利用GetPage()方法并安全地生成指向事件详情页面的链接。GetPage()接受eventId参数,并使用Replace()扩展方法将参数插入到EventPage路由模板中。
处理不正确的导航请求
在现代 Web 开发中,优雅地处理错误的导航请求是强制性的,以确保流畅且用户友好的体验。通过防止用户遇到令人困惑的错误消息或损坏的链接,你让你的应用程序看起来更专业、更可靠。虽然我们已经在 第八章 中介绍了未经授权的导航,但其他错误状态可能会意外发生。你如何处理损坏的链接或输入错误的 URL 定义了用户体验的质量。
让我们实现一个全局、安全的重定向,将用户重定向到一个友好的错误页面,当用户遇到意外的导航异常时。
准备工作
在实现安全重定向之前,你必须有一个可以重定向到的目标。如果你一直跟随整本书的内容,或者只是搭建了你的项目,你已经有了一个可路由的 Error 组件。否则,你可以从 GitHub 仓库中的 Modules 目录中获取它。
如何做到这一点…
按照以下步骤添加全局安全重定向,当用户导航失败时:
-
导航到服务器端项目的 Program 文件。
-
在所有现有的中间件注册之后,使用 WebApplication 的 UseStatusCodePagesWithRedirects() 扩展方法来注册一个错误重定向中间件,并将用户重定向到 / error 路由:
//... //other middleware registrations app.UseAntiforgery(); app.UseStatusCodePagesWithRedirects("/error"); //...
它是如何工作的…
在 Blazor 应用程序中,在 Blazor Web 应用程序引入之前,你会使用 Router 的 NotFound 参数来处理用户导航到不可用的路由。Blazor Web 应用程序仍然支持 NotFound 参数以实现向后兼容性,但利用服务器端中间件管道来解析状态码提供了更大的灵活性。
在 步骤 1 中,我们导航到服务器端项目的 Program 文件,在那里我们配置服务器端中间件管道。在 步骤 2 中,我们找到现有中间件注册的结束位置,并使用 UseStatusCodePagesWithRedirects() 方法扩展中间件管道。通过 UseStatusCodePagesWithRedirects(),我们定义了每当服务器请求导致未处理的错误状态码时,用户将被重定向到 /error 页面。通过 Error 组件,我们可以自定义用户看到的消息、详细信息以及下一步操作。
UseStatusCodePagesWithRedirects() 的附加好处是它涵盖了所有不成功的状态码,而不仅仅是 找不到路由 的情况。
参见
我们已经介绍了 UseStatusCodePagesWithRedirects() 方法,因为它是在基于 UI 的应用程序中最常用的方法。然而,它只是 UseStatusCodePages() 方法家族中的一个选项。它可以从简单的文本状态表示处理到完全定制的异常处理逻辑和重试。
你可以在 Microsoft 文档中找到所有可用选项及其使用示例:learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?view=aspnetcore-9.0#usestatuscodepages .
执行带有导航的异步操作
在现代网络应用中,在导航期间执行常见逻辑对于保持无缝的用户体验和收集有价值的见解至关重要。你可以实现导航事件记录,更好地理解用户行为,识别最常使用的功能,并相应地改进它们。你还可以实现定期的安全检查并无缝刷新用户的访问令牌。
让我们记录应用内所有的导航请求,以便更好地了解用户使用最多的功能,从而可以优先考虑它们。
准备工作
我们将在Routes组件内部工作,这是 Blazor 应用的一个基本组成部分。在这个菜谱中不需要进行任何准备。
如何操作…
按照以下说明在应用内的所有导航上触发操作:
-
导航到Routes组件并注入一个Logger实例:
@inject ILogger<Routes> Logger -
在Routes组件中初始化一个@code代码块,并实现一个接受NavigationContext并记录用户输入路径的LogNavigation()方法:
@code { private void LogNavigation( NavigationContext context) => Logger.LogInformation( "User entered: {Path}", context.Path); } -
在Routes标记中,找到Router并将LogNavigation()方法附加到其OnNavigateAsync回调:
<Router AppAssembly="@typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly, typeof(ExternalEventManager).Assembly }" OnNavigateAsync="@LogNavigation"> @* here's further router configuration *@ </Router>
它是如何工作的…
在步骤 1中,我们导航到Routes组件。你可以在从多个程序集启用路由菜谱中了解到Routes。首先,我们注入一个ILogger实例。ILogger接口简化了应用程序中的日志记录,并允许你使用不同的严重级别(如信息、警告或错误)记录消息,而不依赖于特定的日志实现。ILogger允许你提供一个日志类别,该类别随后会在日志中反映出来,意味着日志来源。在我们的案例中,我们将Routes声明为日志类别。你可以在菜谱末尾的另请参阅部分找到更多日志资源。
在步骤 2中,我们初始化一个@code代码块并实现一个LogNavigation()方法。LogNavigation()接受NavigationContext,它提供了关于导航事件的详细信息。通过访问NavigationContext的Path属性,我们可以将导航目标路径传递给Logger并记录用户导航到的路径。
在步骤 3中,我们转向Routes标记。在这里,我们找到Router构造。Router组件公开一个OnNavigateAsync回调,因此我们在那里附加了LogNavigation()方法。现在,随着每个导航请求,Router将调用OnNavigateAsync回调并触发LogNavigation()方法,有效地记录用户在应用程序中输入的每个路径。
默认情况下,你将注册一个Console记录器,但你可以自由扩展日志行为以覆盖你的业务用例。你可以实现自己的记录器或找到多个支持从不同托管模型进行日志记录的 NuGet 包。
参见
你可以通过查看 Microsoft 学习资源了解更多关于日志记录的信息:learn.microsoft.com/en-us/dotnet/core/extensions/logging。
当用户离开时取消长时间运行的任务
根据你应用程序的流量,如果你没有正确管理长时间运行的任务,它们可能会对性能和用户体验产生负面影响。在 SSR 模式下渲染的组件中,服务器为你处理取消,类似于在 Web API 项目中发生的情况。但在交互模式下,当用户从一个正在执行长时间运行任务的页面离开时,无论是在服务器端还是客户端持久化状态,优雅地取消任务以释放资源并防止不必要的处理是至关重要的。
让我们借助 Blazor 的NavigationManager和CancellationToken实现长时间运行任务的优雅取消。
准备工作
在我们探索当用户离开时优雅取消长时间运行的任务之前,请执行以下操作:
-
创建一个Chapter09/Recipe07目录 – 这将是你的工作目录
-
从实现统一深度链接食谱或 GitHub 仓库中的Chapter09/Recipe04目录复制ExternalEventManager和DeepLinks文件
-
由于DeepLinks包含特定于食谱的路由,并且应用程序中的路由必须是唯一的,因此将路径更新为ch09r07以反映当前食谱
-
从 GitHub 仓库中的Chapter09/Data目录复制Source文件
如何做到这一点…
按照以下步骤触发用户离开时的优雅取消:
-
导航到ExternalEventManager组件,然后增强它以在InteractiveWebAssembly模式下渲染并实现IDisposable。我们将很快解决由此产生的编译错误:
@rendermode InteractiveWebAssembly @implements IDisposable -
在ExternalEventManager的@code块顶部,注入NavigationManager:
[Inject] private NavigationManager Nav { get; init; } -
在Nav注入下方,声明一个类型为CancellationTokenSource的_cts变量:
private CancellationTokenSource _cts; -
在ExternalEventManager的@code块末尾,实现一个CancelTask()方法,匹配返回LocationChangedEventArgs的EventHandler对象的签名,该对象作为代理来调用_cts对象的Cancel()方法:
private void CancelTask(object sender, LocationChangedEventArgs args) => _cts?.Cancel(); -
在CancelTask()方法下方,重写OnInitialized()生命周期方法,并将CancelTask()方法订阅到注入的Nav暴露的LocationChanged事件:
protected override void OnInitialized() => Nav.LocationChanged += CancelTask; -
在OnInitialized()旁边,实现一个Dispose()方法,这是IDisposable接口要求的,在该方法中,我们安全地取消订阅LocationChanged事件,并优雅地处理_cts实例:
public void Dispose() { Nav.LocationChanged -= CancelTask; _cts?.Dispose(); } -
为了完成@code块,实现一个GetAsync()方法,其中从Source获取eventId,并将用户重定向到事件详情页面:
private async Task GetAsync() { _cts = new(); var eventId = await Source.LoadAsync(_cts.Token); if (_cts.IsCancellationRequested) return; Nav.NavigateTo($"/ch09r07/{eventId}"); } -
移动到ExternalEventManager的标记区域,在标题下方,用按钮的渲染替换当EventId未设置时的快速返回语句,允许用户在事件尚未加载时加载事件:
@if (EventId == default) { <button class="btn btn-primary" @onclick="@GetAsync"> Get event </button> return; }
它是如何工作的…
在步骤 1中,我们导航到ExternalEventManager组件,并声明它以InteractiveWebAssembly模式渲染,因为我们想监控长时间运行的任务的执行,以便在需要时取消它。我们还需要ExternalEventManager实现IDisposable。将组件声明为IDisposable会导致编译错误,但我们将在食谱结束前解决它。
在步骤 2中,我们在ExternalEventManager的@code块顶部注入一个NavigationManager实例。NavigationManager实例允许我们响应导航和位置变化。在步骤 3中,我们通过声明一个CancellationTokenSource变量来设置优雅任务取消的基础。使用CancellationTokenSource,我们可以发出和管理异步操作取消请求。
在 第 4 步 中,我们实现了一个签名与 EventHandler 对象返回的 LocationChangedEventArgs 匹配的 CancelTask() 方法。CancelTask() 方法的责任是调用 _cts 实例的 Cancel() 并取消所有基于该实例运行的操作。我们利用 ? 操作符,称为 空条件操作符,因为它允许我们仅在前面对象(在这种情况下为 _cts)不是 null 时调用方法或访问成员。在 第 5 步 中,我们重写 ExternalEventManager 的 OnInitialized() 生命周期方法,并将 CancelTask() 订阅到 Nav 暴露的 LocationChanged 事件。Blazor 在用户在应用程序内导航到新位置时触发 LocationChanged 事件。在 第 6 步 中,我们通过构建一个 Dispose() 方法来完成 IDisposable 的实现。在 Dispose() 方法中,我们安全地取消订阅 LocationChanged 事件以防止内存泄漏。我们还使用它暴露的 Dispose() 方法来处置 _cts 实例。在 第 7 步 中,我们通过实现一个用于测试长时间运行任务优雅取消的 GetAsync() 方法来完成 @code 块。作为 GetAsync() 的一部分,我们初始化一个新的 _cts 实例并调用 Source 类的 LoadAsync() 方法,传入来自 _cts 实例的 CancellationToken。CancellationToken 允许 LoadAsync() 在执行过程中意识到任何取消请求。由于我们期望加载取消,我们添加了一个 _cts 状态检查。使用 IsCancellationRequested 属性,我们可以验证是否请求了取消并短路代码执行。最后,如果 LoadAsync() 完成,我们将用户重定向到适当的事件详情页面。
在 第 8 步 中,我们转向 ExternalEventManager 标记并添加了一个简单的按钮,允许用户触发 GetAsync() 并对实现进行测试。您可以运行应用程序并点击 获取事件 按钮。Source 中的 LoadAsync() 方法有一个硬编码的五秒延迟,并将状态消息记录到您的浏览器控制台。如果您在计时器结束之前离开 ExternalEventManager 页面,您将看到您已排队的保存请求被优雅地取消。

图 9.1:浏览器控制台中的消息,指示优雅的任务取消
控制导航历史
Blazor 中的NavigationManager使用浏览器的History API来维护导航历史记录。操作导航历史记录在用外部身份提供者验证用户时特别有用,用户在验证后会被重定向回应用程序。当显示允许用户配置其应用程序的中间页面,但您希望限制他们在此过程中向后导航时,这也很有益。
让我们模拟从浏览器历史记录中删除中间页面,并在用户尝试返回到中间阶段时强制他们导航到最后一个稳定页面。
准备工作
在我们探索浏览器导航历史记录操作之前,请执行以下操作:
-
创建一个Chapter09 / Recipe08目录——这将是你的工作目录
-
从“当用户导航离开时取消长时间运行的任务”食谱或 GitHub 仓库中的Chapter09 / Recipe07目录复制ExternalEventManager和DeepLinks文件
-
由于DeepLinks包含特定于食谱的路由,并且应用程序中的路由必须是唯一的,因此使用ch09r08更新路径以反映当前食谱
-
从 GitHub 仓库中的Chapter09 / Data目录复制Source文件
如何操作…
按照以下步骤替换浏览器导航历史记录中的条目:
-
导航到ExternalEventManager的@code块并找到GetAsync()方法。
-
在GetAsync()内部,在调用注入的NavigationManager的NavigateTo()方法时,明确设置额外的replace参数为true:
private async Task GetAsync() { //... event getting logic Nav.NavigateTo($"/ch09r08/{eventId}", replace: true); }
它是如何工作的…
在步骤 1中,我们导航到ExternalEventManager。在这里,在@code块中,我们有一个GetAsync()方法,用户在从外部源检索事件详情时触发此方法。在步骤 2中,通过将true作为replace参数传递来扩展GetAsync()的导航逻辑。在NavigationManager.NavigateTo()中的replace参数确保浏览器历史记录中的当前条目被新的 URL 替换,而不是添加新的条目。
如果用户尚未加载任何事件,我们将显示一个带有按钮的页面,允许他们加载事件。点击按钮后,他们将被自动重定向到事件详情页面。从那里,当用户尝试导航回时,他们将到达在获取事件之前所在的任何页面。浏览器将不会意识到最初加载事件的中间步骤。
第十章:集成 OpenAI
在本章中,我们将探讨如何使用 OpenAI 强大的语言模型将高级 AI 功能集成到您的应用程序中。您将学习如何设置 Azure OpenAI 服务并部署模型,为将 AI 集成到您的应用程序中打下基础。通过在 Web 应用程序中实现 AI 增强功能,如智能粘贴和智能文本区域,您将利用智能数据处理和内容生成来提升用户体验。此外,您还将构建并集成一个类似 ChatGPT 的聊天机器人,用于交互式 AI 驱动对话。最后,您将通过将 Azure OpenAI 服务连接到现有的 Azure Search 服务数据索引,实现无缝数据分析。
在我们深入食谱之前,强调使用 AI 模型,尤其是关于用户数据的道德影响至关重要。在某些情况下,通过使用和部署 AI 模型,您同意在您的应用程序内容上训练这些模型。您必须对用户数据的隐私和安全保持警惕,并在您的应用程序中实施清晰的警告和接受表格,允许用户同意或退出数据共享。通过优先考虑透明度和用户自主权,您保护了用户信任并遵守了负责任的 AI 实践。
下面是本章我们将涵盖的食谱:
-
设置 Azure OpenAI 服务
-
实现智能粘贴
-
实现智能文本区域
-
添加聊天机器人
-
将 Azure OpenAI 服务连接到现有数据索引
技术要求
在本章中,我们将大量依赖 Azure 服务和一些可能仍在预览中的 NuGet 包。您将在每个受影响的食谱中找到详细描述的所有细节和警告,因此您无需担心。在您开始之前,请确保您有以下内容:
-
一个活跃的 Azure 账户,可以访问 Azure 门户(如果您还没有,您可以从
azure.microsoft.com/en-us/free提供的限时免费账户开始) -
Azure 中预创建的资源组
-
预装了 Npm 包管理器,拥有全局可用的npm命令
-
一个具有组件/页面渲染模式的 Blazor Web App 项目
您可以在以下食谱中找到所有代码示例(和数据样本),在专门的 GitHub 仓库github.com/PacktPublishing/Blazor-Web-Development-Cookbook/tree/main中。只需注意,我们将只实现一些食谱在服务器端 – 当您阅读本章时,您将理解为什么。
设置 Azure OpenAI 服务
Azure OpenAI 服务是来自 Microsoft Azure 的基于云的服务,提供对 OpenAI 强大语言模型的访问。大型语言模型(LLM)是一个在人类文本上训练的优化成本函数,可以生成类似人类的文本,使您能够将聊天机器人、内容生成或语言翻译提升到新的水平。通过利用 Azure OpenAI 服务,您可以将高级 AI 功能集成到您的 Blazor 应用程序中,而无需管理底层基础设施。您还将获得对现有 GPT 模型的访问权限。
让我们使用 Azure 门户在 Azure 云中设置一个 Azure OpenAI 服务,部署一个专用的 GPT-4 模型,并在应用端定位所需的集成访问详情。
准备工作
在这个菜谱中,我们暂时不会编写任何代码;相反,我们将专注于设置 Azure OpenAI 服务。要开始,以下是一些先决条件:
-
您需要一个 Azure 账户和对 Azure 门户的访问权限
-
您应该事先创建一个资源组;我们将使用一个名为blazor-cookbook的资源组,专门用于本章
在撰写本文时,设置 Azure OpenAI 服务的流程包括两个阶段。
-
在第一阶段,执行以下操作:
-
您必须完成一个请求表单才能访问 Azure OpenAI 服务。要找到请求表单,请遵循以下“如何做”部分中概述的前两步。
-
提交表单后,您需要等待Azure 认知服务团队的批准。一旦您的请求被批准,您将收到一封确认邮件,标志着第一阶段结束。
-
-
我们将在“如何做”部分中介绍第二阶段。
如何做…
按照以下步骤将 Azure OpenAI 服务添加到您的 Azure 资源中:
- 在 Azure 门户中打开您的资源组,并点击顶部导航栏中的创建按钮,导航到 Azure 市场。

图 10.1:从资源组概览导航到 Azure 市场
- 在 Azure 市场,使用顶部面板中的搜索栏查找Azure OpenAI服务,然后通过点击结果标签页上的创建按钮开始创建过程。

图 10.2:导航到 Azure OpenAI 服务创建面板
-
在创建 Azure OpenAI面板中,提供创建 Azure OpenAI 实例所需的必要详细信息:
-
在订阅字段中选择服务的订阅。
-
在资源****组字段中选择您想要创建服务的资源组。
-
在区域字段中选择托管区域。
-
在名称字段中为服务提供一个唯一的名称。
-
在定价****层级字段中选择定价计划。
-

图 10.3:Azure OpenAI 服务创建过程的第一步 - 定义实例详情
审阅条款和条件后,点击下一步继续。
- 在网络步骤中,选择最适合您需求的网络可用性并通过点击下一步确认。

图 10.4:Azure OpenAI 服务创建过程的第二步 - 配置网络
-
如果您的组织有标签策略要遵循,请保持标签步骤不变,然后通过点击下一步继续。
-
在审查 + 提交步骤中,审查服务摘要并通过点击创建按钮确认创建请求。

图 10.5:Azure OpenAI 创建过程的最后一步 - 审查实例详情
- 部署完成后,打开资源组概览并选择Azure OpenAI服务。

图 10.6:从资源组概览中选择 Azure OpenAI 实例
- 在左侧菜单的资源管理部分找到模型部署功能。通过点击管理 部署按钮打开 Azure OpenAI Studio。

图 10.7:导航到 Azure OpenAI Studio 以管理模型部署
- 在 Azure OpenAI Studio 中,在左侧菜单的管理部分找到部署功能,并通过点击创建新 部署按钮开始部署过程。

图 10.8:通过 Azure OpenAI Studio 启动模型部署
-
使用您打算使用的模型的详细信息填写部署模型表单:
-
在部署名称字段中命名部署。
-
从选择一个 模型下拉菜单中选择您想要部署的模型。
-
在模型版本下拉菜单中选择特定的模型版本或自动更新到默认选项。
-
在部署类型下拉菜单中选择部署类型。
-

图 10.9:填写模型部署详情并部署模型
在填写所有必填字段后,通过点击创建按钮确认部署。
它是如何工作的…
在步骤 1中,我们打开 Azure 门户,并找到我们想要部署 Azure OpenAI 服务的资源组。从概览面板的顶部栏,我们选择创建选项。Azure 将我们重定向到 Azure 市场 place,在那里我们可以选择要安装的服务。
在步骤 2中,我们使用 Azure 市场 place 顶部的搜索栏查找 Azure OpenAI。结果选项卡有一个创建按钮,我们使用它来启动创建过程。
在步骤 3中,我们到达 Azure OpenAI 创建的第一步——基础。在这一步中,我们填写即将创建的实例的所有基本详细信息。我们选择 Azure 订阅来定义服务的所有者。然后,我们从分配给订阅的列表中选择合适的资源组。接下来,我们定义实例的详细信息,例如托管区域和定价层。请注意,不同地区可用的 AI 模型不同。此外,根据您选择的定价层,您可能会产生服务使用费用。为了避免这种情况,选择免费定价层(它具有受限的可扩展性和请求限制,但对于本章中的配方来说已经足够了)。您可以通过点击查看完整定价详情链接来查看可用性和定价详情。然后,我们提供实例名称和定价层。一旦填写了所有必填字段,我们通过点击下一步来进入下一步。
在步骤 4中,我们到达 Azure OpenAI 创建的网络步骤。在网络选项卡中,我们定义服务的可发现性。我们可以完全禁用网络访问,配置私有端点,在 Azure 内部设置网络安全,或使实例公开可访问。为了简化,我们允许实例从任何网络访问,包括互联网,并通过点击下一步来确认继续。
在步骤 5中,我们到达 Azure OpenAI 创建的标签步骤。标签选项卡允许定义描述服务的自定义标签。除非您的组织中有基于标签的策略定义,否则标签不会产生任何功能影响。因此,我们保持标签面板不变,并通过点击下一步继续到最后一步。
在步骤 6中,我们到达审查 + 提交面板,在这里我们有最后一次机会审查即将创建的实例的详细信息。当一切检查无误时,我们通过点击创建来确认创建。
服务部署完成需要一些时间。完成之后,我们继续进行步骤 7。我们导航到资源组的概览面板,并选择 Azure OpenAI 实例。
在步骤 8中,我们找到资源管理子菜单,并导航到模型部署功能。在该面板中,我们点击管理部署按钮,并被重定向到 Azure OpenAI Studio 以进行下一步操作。
在 步骤 9 中,在 Azure OpenAI Studio 中,我们找到 管理 子菜单并导航到 部署 面板。在 部署 导航栏中,我们点击 创建新部署 按钮以初始化 Azure OpenAI 服务的模型部署。
在 步骤 10 中,我们到达 部署模型 提交表单,在那里我们必须配置部署详情。首先,我们定义部署名称——稍后我们将使用该名称来指定从 Blazor 应用程序执行请求时使用哪个模型。接下来,我们选择要部署的模型。根据 Azure OpenAI 实例所在的区域,我们可以从 Azure 提供的不同 AI 模型集中进行选择。为了简化,我们选择部署 GPT-4o。选择模型后,我们指定要使用的版本。从下拉菜单中,我们可以选择特定的 GPT 模型版本或选择 自动更新为默认 以使用最新稳定的模型。在部署表单中,我们可以微调请求的速率限制和内容过滤器,我们将它们保留为默认值。我们还可以启用 动态配额,允许 Azure 在流量较高时自动增加每分钟令牌限制。当我们填写完所有部署详情后,我们可以通过点击 创建 来开始该过程。
当部署完成后,您将在 Azure OpenAI Studio 的 部署 面板中看到该模型:

图 10.10:Azure OpenAI 模型部署概览,显示已部署的模型
还有更多...
要与 Azure OpenAI 实例和部署的 AI 模型进行通信,您需要模型部署名称(我们在 步骤 10 中设置)和 Azure OpenAI API 访问详情。
要找到这些详情,请导航到资源组以及创建的 Azure OpenAI 实例。在左侧菜单中,选择 资源 管理 部分的 密钥和端点 项。

图 10.11:导航到包含 Azure OpenAI 实例 API 访问详情的面板
您将到达包含指向 Azure OpenAI 实例的 端点、它所在的位置以及两个 API 密钥的 API 详情面板。拥有两个 API 密钥可以确保在需要重新生成其中一个时,服务可以持续可用。

图 10.12:API 访问详情面板,包含 API 密钥和 URI
对于所有即将到来的食谱,您将需要端点、API 密钥和部署的模型名称,因此请将它们安全地存储在您的密钥存储中,或者将它们保存在便签簿中以便快速访问,随着我们进入实现过程。
参见
我们只是简要介绍了 Azure OpenAI 服务,涵盖了将 OpenAI 集成到 Blazor 应用程序所需的范围。如果您想了解更多信息,请访问 Microsoft Learn 资源:
learn.microsoft.com/en-us/azure/ai-services/openai/overview
实现智能粘贴
网络开发中一个常见的挑战是处理非标准化数据,例如当你收到需要准确输入到索赔表中的电子邮件或其他数据时。这项任务很快就会变得繁琐和令人沮丧,因为手动将数据复制和粘贴到正确的字段既耗时又容易出错。
SmartComponents仓库是一个开源仓库,其中包含组件,可以帮助您快速将 AI 驱动功能添加到.NET 应用程序中,而无需深入了解提示工程。在众多功能中,SmartComponents可以增强非结构化数据的粘贴,以适应预期的表单。尽管SmartComponents不在Microsoft命名空间中,但它位于官方.NET 平台 GitHub 账户下,并由 Microsoft 团队官方支持、开发和维护。
让我们实现一个智能粘贴功能,允许用户直接将复制的文本粘贴到指定的字段中,无需任何预处理。
准备工作
在我们深入实现智能粘贴之前,请执行以下操作:
-
创建一个Chapter10 / Recipe02目录 - 这将是你的工作目录
-
将 GitHub 仓库中Chapter10 / Data目录下的Models文件复制到工作目录
-
将 GitHub 仓库中的SmartComponents文件夹复制到您的解决方案文件夹中,并将所有项目添加到解决方案中。SmartComponents文件夹包含SmartComponents仓库的副本,已更新以支持最新的 Azure OpenAI 更新
-
准备好 Azure OpenAI 的详细信息(你可以在“设置 Azure OpenAI 服务配方中的更多内容…部分中查看如何获取它们”)
如何做到这一点…
按照以下说明使用 AI 增强您的应用程序中的粘贴功能:
-
导航到服务器端项目的csproj文件,并包含两个必需的SmartComponents项目:
<ItemGroup> <ProjectReference Include= "..\SmartComponents\ SmartComponents.AspNetCore\ SmartComponents.AspNetCore.csproj" /> <ProjectReference Include= "..\SmartComponents\ SmartComponents.Inference.OpenAI\ SmartComponents.Inference.OpenAI.csproj" /> </ItemGroup> -
仍然在服务器端,打开Program.cs文件,并使用 OpenAI 后端注册SmartComponents:
using SmartComponents.Inference.OpenAI; //...other service registrations builder.Services .AddSmartComponents() .WithInferenceBackend<OpenAIInferenceBackend>(); -
定位到服务器端项目的appSettings文件,并在应用程序设置中扩展一个用于SmartComponents配置的区域:
{ "SmartComponents": { "ApiKey": "YOUR_API_KEY", "Endpoint": "YOUR_ENDPOINT", "DeploymentName": "YOUR_MODEL_DEPLOYMENT" } } -
导航到客户端项目的csproj文件,并包含 WebAssembly 渲染器所需的SmartComponents项目:
<ItemGroup> <ProjectReference Include="..\SmartComponents\ SmartComponents.AspNetCore.Components\ SmartComponents.AspNetCore.Components.csproj" /> </ItemGroup> -
创建一个新的可路由的FillClaim组件,引用SmartComponents程序集:
@page "/ch10r02" @using SmartComponents -
在FillClaim组件的@code块中,声明一个Claim表单参数,其类型为ClaimViewModel:
@code { [SupplyParameterFromForm] public ClaimViewModel Claim { get; set; } = new(); } -
在 FillClaim 标记中,构建一个绑定到 Claim 参数的 EditForm 框架。如果 EditForm 不是一个已识别的组件,请在 FillClaim 组件的顶部包含一个 @using Microsoft.AspNetCore.Components.Forms 引用:
<EditForm Model="@Claim" FormName="claim-form"> @* we will continue here *@ </EditForm> -
在 FillClaim 表单内,添加用于输入事件和客户详情的字段:
<p> Event name: <InputText @bind-Value="@Claim.Event" /> </p> <p>Date: <InputText @bind-Value="@Claim.Date" /></p> <p> Customer name: <InputText @bind-Value="@Claim.Customer.Name" /> </p> <p> Customer email: <InputText @bind-Value="@Claim.Customer.Email" /> </p> -
在表单内添加一个提交按钮以确认输入:
<button type="submit">Submit</button> -
最后,在提交按钮下方嵌入一个带有默认图标的 SmartPasteButton 组件:
<SmartPasteButton DefaultIcon />
它是如何工作的…
在 步骤 1 中,我们首先配置应用程序的服务器端。我们导航到项目配置文件并添加两个项目的引用,这两个项目是使 SmartComponents 在服务器上工作所必需的。SmartComponents.AspNetCore 项目包含由 AI 驱动的服务器组件,而 SmartComponents.Inference.OpenAI 项目包含与 OpenAI 后端通信的服务实现。
在 步骤 2 中,我们导航到服务器端项目的 Program.cs 文件并在依赖注入容器中注册 SmartComponents。我们还注册了一个 OpenAIInferenceBackend 实现作为 SmartComponents 的默认提示配置。当您利用 AI 生成文本时,自定义推理实现非常有用。我们将在 实现智能文本 区域 菜谱中稍后探讨这一点。
在 步骤 3 中,我们通过导航到服务器端的 appSettings.json 文件来完成 SmartComponents 的设置。由于 appSettings.json 是应用程序的配置源,我们通过添加一个 SmartComponents 部分和键节点来扩展 JSON,这些节点代表 API 密钥和端点以及 SmartComponents 组件必须使用的模型部署名称。
在 步骤 4 中,我们跳转到应用程序的客户端。与默认的 Blazor 组件包一致,SmartComponents 也提供了用于在 WebAssembly 模式下渲染的组件对应物。我们导航到客户端项目的配置文件并在其中添加一个 SmartComponents.AspNetCore.Components 项目引用。
在 步骤 5 中,我们创建了一个可路由的 FillClaim 组件并引用了 SmartComponents 程序集。接下来,我们构建了一个表单,支持团队可以在 AI 的帮助下填写索赔详情。
在 步骤 6 中,我们初始化一个 @code 块并声明一个 Claim 参数,该参数也将作为索赔表单的后备模型。如果您对在 Blazor 中创建表单不熟悉,我们已在 第六章 中详细介绍了这一点。
在 步骤 7 中,我们使用 EditForm 构建一个表单框架并将其绑定到 Claim 模型。
在 步骤 8 中,我们构建了一个简单的表单主体,允许用户填写事件名称、日期、客户名称和电子邮件 – 足以识别和处理索赔。
在 步骤 9 中,我们通过添加提交按钮来完成表单。
最后,在步骤 10中,我们通过在表单的主体中嵌入SmartPasteButton组件来增强表单的 AI 功能。我们还声明SmartPasteButton组件以默认图标进行渲染。有了这个简单的设置,你现在可以使用(智能)按钮将非结构化数据转换成可发送的表单。

图 10.13:将带有声明的电子邮件智能粘贴到表单中的结果
还有更多…
SmartComponents也可以与 OpenAI API 密钥一起工作。如果你已经有了 OpenAI 账户,请导航到以下 URL:
在这里,你可以创建一个 API 密钥,允许你访问 ChatGPT API:

图 10.14:创建 API 密钥以访问 OpenAI API
一旦你有了 API 密钥,打开服务器端应用程序的appSettings.json文件并更新SmartComponents部分:
{
"SmartComponents": {
"ApiKey": "YOUR_API_KEY",
"DeploymentName": "gpt-4o"
}
}
在前面的配置中,ApiKey节点仍然代表你的 API 密钥,而DeploymentName节点现在定义了你想要使用的 GPT 模型。请注意,Endpoint节点不再需要。当你没有明确提供Endpoint值时,SmartComponents将回退到默认的 OpenAI API URI。
实现智能文本区域
你可能已经看到了 AI 的生成能力在实际中的应用 – 你提供上下文,就会出现一堵有意义的文本墙。不再有写作障碍,对吧?生成 AI 是改变你应用程序中所有文本驱动功能的游戏改变者。你可以将商品描述或事件描述从列表中的项目点转换为高质量的副本只需几秒钟。通过SmartComponents,我们可以轻松连接到 AI 模型并利用其生成能力,使内容创作更快、更直观。
让我们实现一个文本区域,支持团队可以在回复客户的声明时填写消息。
准备工作
在我们探索 AI 驱动的文本区域实现之前,我们必须做以下事情:
-
创建一个Chapter10 / Recipe03目录 – 这将是我们的工作目录
-
从实现智能粘贴配方或从 GitHub 仓库中的Chapter10 / Recipe02目录复制FillClaim组件
-
从 GitHub 仓库中的Chapter10 / Data目录复制Models
-
如果你从这里开始,请回顾实现智能粘贴配方中的步骤 1到步骤 4的说明,以进行初始SmartComponents配置
如何做到这一点…
按照以下说明将智能文本区域添加到你的应用程序中:
-
导航到FillClaim组件的@code块并添加一个replier变量,该变量定义填写声明表单的人:
const string replier = "An event organizer support team member replying to a claim request."; -
在 FillClaim 标记的 EditForm 主体中跳转到,并通过嵌入一个位于提交按钮上方的 SmartTextArea 组件来扩展表单。将 replier 变量附加到 SmartTextArea 组件的 UserRole 参数,并将文本区域值绑定到 Claim 实例的 Message 属性:
<p> <SmartTextArea @bind-Value="@Claim.Message" rows="5" cols="50" UserRole="@replier" /> </p>
它是如何工作的…
在 步骤 1 中,我们直接跳转到 FillClaim 组件。首先,我们移动到 @code 块并声明一个 replier 变量,在那里我们放置一个简短但详细的描述,描述我们希望 AI 代表的角色。考虑到 AI 模型从人类编写的内容中学习,您应该努力使 replier 描述听起来像您与朋友交谈时一样自然。
在 步骤 2 中,我们在 FillClaim 标记中定位 EditForm 标记。在提交按钮上方嵌入一个 SmartTextArea 组件。SmartTextArea 组件支持绑定值绑定模式(您可以在 第三章 中了解更多关于绑定的信息),并允许定义标准 textarea 属性,如 rows 或 cols,代表文本框的默认大小。它还允许设置一个 UserRole 参数——这就是我们将存储在 replier 中的角色定义附加到的地方。
要将生成字段添加到您的应用程序中,只需这样做:

图 10.15:用户在输入消息时,AI 帮助撰写索赔响应
还有更多…
到目前为止,我们使用了 SmartComponents 包提供的默认推理配置。然而,您可以通过实现自定义的 SmartTextAreaInference 逻辑来自定义提示和 AI 行为。由于 AI 通信和处理仅在服务器上发生,您必须在服务器端项目中保留提示自定义。
让我们创建一个继承自 SmartTextAreaInference 的 ClaimReplyInference 类,并自定义来自 FillClaim 表单的建议:
public class ClaimReplyInference : SmartTextAreaInference
{
public override ChatParameters BuildPrompt(
SmartTextAreaConfig config,
string textBefore, string textAfter
)
{
var prompt = base.BuildPrompt(
config, textBefore, textAfter
);
var systemMessage = new ChatMessage(
ChatMessageRole.System,
"Make suggestions in a professional tone."
);
prompt.Messages.Add(systemMessage);
prompt.Temperature = 0.7f;
return prompt;
}
}
在 ClaimReplyInference 中,我们重写了 BuildPrompt() 方法。我们利用基本实现来构建提示,但之后进行自定义。首先,我们将一个额外的 ChatMessage 实例追加到 prompt 已经拥有的 Messages 集合中。我们定义这个新的 ChatMessage 角色为 System。System 消息设置了 AI 模型的整体行为,表明我们期望得到专业建议的语气。最后,我们自定义了提示的 Temperature 属性值。Temperature 设置控制 AI 响应的随机性,较低的值使输出更集中和确定,而较高的值则使其更具创造性和多样性。
在 ClaimReplyInference 就位后,我们必须将其添加到依赖注入容器中:
builder.Services.AddSingleton<SmartTextAreaInference,
ClaimReplyInference>()
在Program入口类中,我们将SmartTextAreaInference类注册为单例。SmartTextArea组件将自动发现新的实现。
现在,用户将获得更多官方口吻的建议:

图 10.16:AI 以专业口吻生成建议,帮助用户回复主张
相关内容
您可以自定义所有可用的SmartComponents组件,并根据应用程序需求微调 AI 行为。如果您想了解更多信息,请查看 GitHub 上的官方SmartComponents文档,网址为github.com/dotnet-smartcomponents/smartcomponents/tree/main。
添加聊天机器人
由 OpenAI 开发的 ChatGPT 是一个先进的对话 AI 模型,自发布以来受到了广泛关注。它旨在根据接收到的输入理解和生成类似人类的文本,使其与用户的互动感觉自然直观。GPT 模型的通用性使其能够应用于多种场景,从客户支持和个人助理到教育工具和娱乐。
让我们构建一个原始的聊天 UI 并将其连接到 Azure OpenAI 服务,以便在 Blazor 应用程序中嵌入类似 ChatGPT 的聊天功能。
准备工作
与我们在前几章中探讨的SmartComponents类似,聊天将需要 Azure OpenAI API 访问权限。为了避免泄露 API 访问详情,我们将转向应用程序的服务器端。
在我们深入构建 AI 聊天之前,我们必须做以下几件事:
-
创建Chapter10 / Recipe04目录——这将成为您的工作目录
-
从 GitHub 仓库中的Chapter10 / Data目录复制InputModel
-
准备 Azure OpenAI 服务连接详情(您可以在更多内容…部分查看如何获取它们,该部分位于设置 Azure OpenAI 服务配方中)
如何做到这一点…
按照以下步骤将 AI 聊天添加到应用程序中:
-
导航到服务器端项目的配置文件,并包含Azure.AI.OpenAI包的最新版本(截至编写时,它仍在预览中):
<ItemGroup> <PackageReference Include="Azure.AI.OpenAI" Version="2.0.0-beta.2" /> </ItemGroup> -
使用服务器项目配置打开appsettings.json文件,并添加一个包含所需节点的ChatBot部分:
{ "ChatBot": { "ApiKey": "YOUR_API_KEY", "Endpoint": "YOUR_ENDPOINT", "DeploymentName": "YOUR_MODEL_DEPLOYMENT" } } -
移动到服务器端项目的Program.cs入口文件,并在builder实例初始化后,将聊天配置拦截到变量中:
var endpoint = builder .Configuration["ChatBot:Endpoint"]; var apiKey = builder .Configuration["ChatBot:ApiKey"]; var deploymentName = builder .Configuration["ChatBot:DeploymentName"]; -
在配置变量下方,通过将endpoint和apiKey变量传递给服务构造函数,将AzureOpenAIClient服务注册为单例:
builder.Services.AddSingleton( new AzureOpenAIClient( new Uri(endpoint), new AzureKeyCredential(apiKey) )); -
在注册 AzureOpenAIClient 后,将一个 ChatClient 服务添加到依赖注入容器中作为作用域。利用 AzureOpenAIClient API 和 deploymentName 构建一个 ChatClient 实例:
builder.Services .AddScoped(services => { var openAI = services .GetRequiredService<AzureOpenAIClient>(); return openAI.GetChatClient(deploymentName); }); -
创建一个可路由的 ChatBot 组件,以 InteractiveServer 模式渲染并引用 OpenAI.Chat 程序集:
@page "/ch10r04" @rendermode InteractiveServer @using OpenAI.Chat -
在 ChatBot 组件中初始化 @code 块,并将 ChatClient 服务注入为 Chat:
@code { [Inject] private ChatClient Chat { get; init; } } -
在服务注入下方,初始化一个 Model 实例以绑定到输入表单和 Messages 集合以持久化聊天消息并在 UI 上显示:
protected InputModel Model = new(); protected List<string> Messages = []; -
在 Messages 集合下方,初始化一个 _messages 集合来存储可以传输到 Azure OpenAI 服务的消息。以系统提示开始 _messages 集合,定义聊天机器人的角色:
private List<ChatMessage> _messages = [ new SystemChatMessage( "Act as a friendly salesman for the Blazor Web Development Cookbook written by Pawel Bazyluk." ) ]; -
在支持变量旁边,实现一个 SendMessage() 方法。首先检查 Model 状态的有效性。如果输入有效,将其转换为 UserChatMessage 对象并将其添加到支持集合中:
private async Task SendMessage() { if (!Model.IsValid) return; var message = new UserChatMessage(Model.Value); Messages.Add($"You: {Model.Value}"); _messages.Add(message); //continue here... } -
仍然在 SendMessage() 方法中,通过将 _messages 集合传递给 Chat 服务的 CompleteChatAsync() 方法来请求聊天完成,并解析响应有效负载:
var chatResponse = await Chat .CompleteChatAsync(_messages); var response = chatResponse.Value.Content[0].Text; // continue here... -
通过在 Messages 集合和 _messages 集合中持久化接收到的响应作为 AssistantChatMessage 对象来完成 SendMessage() 方法。最后,重置 Model 对象的 Value:
_messages.Add(new AssistantChatMessage(response)); Messages.Add($"OpenAI: {response}"); Model.Value = string.Empty; -
转到 ChatBot 标记,构建一个简单的 EditForm 表单,其中包含一个绑定到 Model 变量的输入字段,提交时触发 SendMessage()。如果 EditForm 被识别为组件,则在 FillClaim 组件的顶部包含一个 @using Microsoft.AspNetCore.Components.Forms 引用:
<h3>What can I help you with?</h3> <EditForm Model="@Model" FormName="chat-input" OnSubmit="@SendMessage"> <InputText @bind-Value="@Model.Value" /> <button type="submit">Send</button> </EditForm> -
在输入表单下方,遍历 Messages 集合中的元素,并在单独的段落中渲染它们:
<hr /> @foreach (var message in Messages) { <p>@message</p> }
如何工作……
在 步骤 1 中,我们首先将 Azure.AI.OpenAI 包添加到应用程序的客户端。如果你一直在使用 NuGet 包管理器,你将不得不包含预发布版本的包,因为 Azure.AI.OpenAI 在写作时仍在预览中。
在 步骤 2 中,我们将聊天机器人配置部分添加到 appsettings.json 文件中。我们需要一个 ApiKey 节点、一个 API Endpoint 节点和 DeploymentName 节点,以指定我们想要使用的模型名称。
在 步骤 3 中,我们导航到服务器端项目的 Program.cs 文件,在该文件中注册必要的依赖注入容器中的服务。首先,通过访问 builder 实例的配置读取器,将聊天机器人配置值拦截到 endpoint、apiKey 和 deploymentName:
在第 4 步中,我们将AzureOpenAIClient服务注册为单例,传递endpoint值作为 Azure OpenAI URI,并使用apiKey值初始化AzureKeyCredentials。由于设计上它是线程和作用域安全的,我们可以有一个共享的AzureOpenAIClient服务实例,但在实现时请考虑内存影响。
在第 5 步中,我们将一个额外的服务添加到依赖注入容器中——我们将ChatClient服务注册为作用域内的服务。我们通过从服务集合中解析AzureOpenAIClient实例并使用deploymentName值调用其GetChatClient()方法来构建ChatClient对象。有了服务,我们构建 UI 部分。
在第 6 步中,我们创建了一个可路由的ChatBot组件,该组件引用OpenAI.Chat程序集,因为我们需要访问ChatClient类的定义。我们还需要ChatBot组件以InteractiveServer模式渲染,因为我们的用户将与聊天进行交互。
在第 7 步中,我们初始化@code块并从依赖注入中注入ChatClient服务。
在第 8 步中,我们初始化一个Model实例以绑定用户填写消息的输入表单,以及一个Messages集合,我们将聊天和用户消息的文本表示持久化到该集合中,以便在标记中渲染它们。
在第 9 步中,我们初始化另一个集合——_messages。在_messages中,我们将消息以ChatMessage对象的形式持久化。这样,我们可以在请求 Azure OpenAI 服务的新响应时轻松提供对话的完整上下文;如果没有消息的历史记录,我们将限制聊天上下文为用户发送的最后一条消息。我们还以预定义的SystemChatMessage对象开始_messages。SystemChatMessage对象允许我们注入提示,其中我们定义聊天机器人应该如何表现,但提示本身不是对话的一部分。
在第 9 步中,我们实现了一个SendMessage()方法,其中包含所有聊天逻辑。一开始,我们检查提交的Model值是否有效,如果没有要处理的内容则快速返回。然后,我们将用户输入包装成一个UserChatMessage对象。在发送用户输入时,我们必须使用UserChatMessage对象,以便 AI 能够相应地解释它们。接下来,我们将UserChatMessage实例添加到_messages上下文集合中,并将用户输入格式化为类似聊天的版本,以便将其添加到可渲染的Messages集合中。
在第 10 步中,我们利用Chat实例及其CompleteChatAsync()方法从 Azure OpenAI 请求新的聊天响应。请注意,我们将整个_messages集合作为请求的一部分发送,以便云中的 GPT 拥有对话的完整上下文。然后,我们从接收到的响应的Content属性中解包消息。
在 步骤 11 中,我们将解包后的有效载荷推送到后端变量。这次,我们在将其添加到 _messages 集合之前,将接收到的消息包装在一个 AssistantChatMessage 对象中。AssistantChatMessage 类型代表来自 AI 自身的响应。接下来,我们构建一个类似聊天的消息以添加到 Messages 集合,以便用户可以看到。最后,我们清除 Model 的值以接受来自用户的另一条消息。
在 步骤 12 中,我们实现了一个原始的标记,以便用户可以与聊天交互。我们在顶部添加了一个行动号召,并构建了一个 EditForm 表单。我们将表单绑定到 Model 实例,并将 SendMessage() 方法附加到其提交回调。在 EditForm 标记内,我们添加了一个单独的 InputText 字段,用户可以在其中提供他们的聊天请求,以及一个按钮,允许他们提交表单并触发聊天生成。
在 步骤 13 中,在 EditForm 组件下方,我们构建了一个简单的循环,遍历 Messages 集合中的类似聊天消息,并在单独的段落中渲染它们。
通过这个简单的实现,你已经得到了一个准备就绪的聊天原型:

图 10.17:具有强大 AI 后端的原始聊天 UI 在行动中
还有更多...
根据你期望通过聊天处理的会话或消息长度,你应该考虑定期清理对话上下文。这将有助于保持聊天功能的效率和有效性。管理聊天上下文的长度会影响聊天的成本和响应速度。较长的上下文可能导致由于 API 使用增加而成本更高,并且由于处理更多数据而可能响应时间变慢。
一种有效策略是实现一个固定大小的 循环缓冲区。在循环缓冲区中,新元素添加到缓冲区的末尾,而当缓冲区达到其容量时,最旧的元素将被覆盖。这种方法确保聊天上下文保持在一个可管理的范围内,使对话保持相关和高效。
参见
如果你想要进一步探索 Azure.AI.OpenAI 的可能性,请访问包文档 github.com/Azure/azure-sdk-for-net/blob/main/sdk/openai/Azure.AI.OpenAI/README.md。
将 Azure OpenAI 服务连接到现有的数据索引
在 Azure 中,您可以有多个现有的数据源,从 Azure Cosmos DB 到各种带有标记和索引数据的 Azure 认知服务。虽然 Azure OpenAI 服务与常见的 GPT 模型一起工作,但它还允许您将选定的模型连接到您的特定数据源。通过这种集成,您可以通过与应用程序的自然语言交互来更直观地分析和提取数据。
让我们将 Azure OpenAI 服务连接到现有的 Azure Search 服务数据索引。通过这样做,我们将利用 AI 的力量无缝地分析我们的内部数据。
准备工作
在探索将 Azure Search 数据连接到 Azure OpenAI 之前,我们必须执行以下操作:
-
在您的应用程序的服务器端,创建一个 Chapter10 / Recipe05 目录 – 这将是您的工作目录
-
从 Adding a ChatBot 菜谱或从 GitHub 仓库中的 Chapter10 / Recipe04 目录复制 ChatBot 组件
-
如果您从这里开始,请按照 GitHub 仓库中 Chapter10 / Recipe04 目录中的 Configure 文件所示注册所有 Azure 服务
如何操作…
按照以下说明将 Azure OpenAI 连接到 Azure Search 数据并启用数据分析:
-
在服务器端打开 appsettings.json 文件并添加一个新的 Search 部分,包含 ApiKey、Endpoint 和 Index:
"Search": { "ApiKey": "YOUR_API_KEY", "Endpoint": "YOUR_ENDPOINT", "Index": "YOUR_INDEX_NAME" } -
移动到服务器端项目的 Program.cs 文件。在构建器和 Azure OpenAI 服务初始化下方,将搜索数据访问详细信息拦截到 searchEndpoint、searchApiKey 和 searchIndex 变量:
var searchEndpoint = builder .Configuration["Search:Endpoint"]; var searchApiKey = builder .Configuration["Search:ApiKey"]; var searchIndex = builder .Configuration["Search:Index"]; -
在截获的搜索配置下方,将 ChatCompletionOptions 注册为单例。作为 ChatCompletionOptions 初始化的一部分,构建一个 AzureSearchChatDataSource 实例并将其附加到构建的完成选项:
builder.Services.AddSingleton(services => { var dataSource = new AzureSearchChatDataSource { Endpoint = new Uri(searchEndpoint), IndexName = searchIndex, Authentication = DataSourceAuthentication .FromApiKey(searchApiKey) }; ChatCompletionOptions completionOptions = new(); completionOptions.AddDataSource(dataSource); return completionOptions; }); -
在撰写本文时,Azure.AI.OpenAI 包处于预览阶段,并且您的 IDE 可能会将使用 ChatCompletionOptions 类的 AddDataSource() 方法解释为编译错误。为了抑制错误,在 Program.cs 文件的顶部添加所需的 #pragma 指令:
#pragma warning disable AOAI001 -
导航到 ChatBot 组件的 @code 块并在 Chat 客户端旁边注入 ChatCompletionOptions 实例:
[Inject] private ChatCompletionOptions ChatOptions { get; init; } -
仍然在 @code 块内,在 SendMessage() 方法中找到我们调用 Chat 服务的 CompleteChatAsync() 方法并传递 ChatOptions 作为第二个参数的位置:
var chatResponse = await Chat .CompleteChatAsync(_messages, ChatOptions);
它是如何工作的…
在 步骤 1 中,我们导航到服务器端项目的 appsettings.json 配置文件。我们通过添加一个 Search 部分扩展配置文件,其中需要 ApiKey、Endpoint 和 Index 值。
在步骤 2中,我们停留在服务器端,但移动到Program.cs项目入口文件。我们将搜索配置拦截到searchEndpoint、searchApiKey和searchIndex变量中,这样我们就可以使用它们将数据连接到 Azure OpenAI。
在步骤 3中,我们在应用程序的依赖注入容器中注册了一个单例ChatCompletionOptions对象。ChatCompletionOptions用于配置聊天完成的行为,允许我们自定义和扩展聊天服务的功能。作为ChatCompletionOptions初始化逻辑的一部分,我们构建了一个AzureSearchChatDataSource实例,它代表搜索数据连接细节,需要提供端点、API 密钥和索引名称。我们已经从appsettings.json文件中拦截了这些信息。我们使用ChatCompletionOptions实例的AddDataSource()方法来附加搜索数据访问。
由于Azure.AI.OpenAI在撰写本文时仍处于预览阶段,你的 IDE 可能会将AddDataSource()方法的用法标记为编译错误——这没有什么好担心的。Azure 团队将在发布稳定包之前进行调整。目前,我们可以通过在Program.cs文件顶部添加一个带有我们需要抑制的AOAI001验证代码的#pragma指令来抑制警告,就像我们在步骤 4中所做的那样。接下来,我们转向ChatBot组件,并将增强的完成选项附加到我们的聊天机器人上。
在步骤 5中,我们直接进入ChatBot组件的@code块,并将从依赖注入容器中注入的ChatCompletionOptions实例作为ChatOptions注入。
在步骤 6中,我们定位到SendMessage()方法,并找到我们调用Chat服务的CompleteChatAsync()方法以从 Azure OpenAI 获取响应的地方。我们已经在CompleteChatAsync()方法中传递了一个_messages集合,但它还接受一个ChatCompletionOptions类型的第二个参数——这就是我们传递带有访问 Azure Search 数据的注入ChatOptions实例的地方。
还有更多…
你不需要将数据源和 Azure OpenAI 放在同一个资源组中。实际上,你甚至不需要拥有数据源。只要提供有效的配置详细信息,Azure OpenAI 就能正确工作并生成上下文化的结果。这种灵活性允许你利用现有的数据源,并与 Azure OpenAI 无缝集成,增强你应用程序的功能,而无需合并或迁移资源。
参见
在配方实现中,我们使用了#pragma预处理器指令。预处理器指令有不同的用途,允许在较低级别调整你的代码行为。如果你想了解更多,请查看这个 Microsoft Learn 资源:


浙公网安备 33010602011771号