Blazor-学习指南-全-

Blazor 学习指南(全)

原文:zh.annas-archive.org/md5/93e847ca616433b7f85809e96bbd8068

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

在过去的 20 多年里,Web 开发一直是软件行业的主导特征,并且未来很多年可能仍然如此。行业巨头继续大力投资于扩展 Web 技术的能力和灵活性,使得越来越多先进的基于浏览器的软件得以开发。尽管原生移动应用和增强现实/虚拟现实应用在消费软件中找到了它们的位置,但 Web 仍然是商业应用的默认 UI。如果你只能选择一个应用平台进行投资,你应该选择 Web。

在这同样的 20 年中,.NET(首次发布于 2002 年)一直是微软的主要开发工具集。与 Web 一样,.NET 继续增强其实力。它在 2016 年被重新定义为云优先、跨平台和完全开源,如今被约 30%的专业软件开发人员使用。¹ C# 一直被认为是最高效的语言之一,在丰富的开发工具中处于领先地位,具有精确的代码完成和顶级的调试体验,而现在 ASP.NET Core 是最快的服务器端 Web 技术之一。²

Blazor 的目标是解锁 .NET 在基于浏览器的 UI 应用程序中的全部潜力。这是 .NET 团队为创建单页面应用程序类型应用的最高效和自然方式所做出的努力。这包括 Blazor 的组件化编程模型,它汲取了许多现代 UI 框架的优点,并将它们统一到了.NET 的自然环境中,具有强大的类型支持。除此之外,它还意味着与 .NET 生态系统的其他部分连接,包括行业领先的 IDE 和一流的调试、测试和热重载功能。Blazor 最大的创新可能在于其灵活的执行模型,可以在服务器端通过 WebSocket 向浏览器实时流式传输 UI,在 WebAssembly 中直接在浏览器内部运行,或者作为移动和桌面应用程序中的本机代码运行。

学习 Blazor 深入广泛地探讨了 Blazor 应用程序开发。与许多其他书籍不同,它不仅关注 C# 编程的简单部分,并将真实世界的复杂性作为读者的练习。相反,David 在这本书中全面介绍了 Web 开发的各种关注点,包括身份验证、安全性、性能、本地化和部署(CI/CD),并从头开始为您展示。通过一些专注,您将能够吸收 David 的广泛专业知识,并准备好独立处理实际工作。

David 不仅能够解释当今事物的运作方式,还能说明它们如何演变到现在的状态,甚至可能在未来如何变化。多年来,他一直是 Blazor 社区中知名人物,与 Microsoft 的工程领导人有着良好的联系,作为 Microsoft 最有价值专家(MVP)和 Google 开发者专家(GDE)在 Web 技术领域也有着更为悠久的历史。在这本书中,你将找到许多历史细节和轶事,揭示了塑造 Web 开发和.NET 技术的挑战、决策和人物。David 的热情将帮助你穿越这个复杂的领域。

当我与 Dan Roth 和 Ryan Nowak 一起创建 Blazor 的首个版本时,我最大的动机之一是帮助摆脱 Web UI 的单一文化。我欣赏 JavaScript,并且我的职业生涯中有很多基于它的建设,但是还有许多其他编程语言、范式和社区,它们可以为浏览器带来自己的丰富性。我知道你会找到自己创新的方式来使用你创建的软件,造福于用户。我祝愿你在 Blazor 项目中一切顺利,并且确信你会在这些页面中找到灵感。

Steve Sanderson

Microsoft 软件工程师/架构师,Blazor 的原创者

英国布里斯托

2022 年 8 月

¹ “最受欢迎的技术”,2022 年 Stack Overflow 开发者调查,访问日期为 2022 年 8 月 18 日,https://oreil.ly/7UTEc

² “Web 框架基准测试:第 21 轮,2022 年 7 月 19 日”,TechEmpower,https://oreil.ly/EBawf

前言

欢迎来到学习 Blazor。你可能来到这里是因为听说过 Blazor 的一些很酷的东西,想要试试看。那么,它是什么?Blazor 是一个开源的 Web 框架,用于使用 C#(读作“C Sharp”)、HTML 和层叠样式表(CSS)构建交互式客户端 Web UI 组件。¹ 作为 ASP.NET Core 的一项功能,Blazor 通过工具和库扩展了.NET 开发平台,用于构建 Web 应用程序。

WebAssembly 使得许多非 JavaScript 编程语言能够在浏览器上运行。Blazor 充分利用了 WebAssembly,并允许 C#开发者使用.NET 构建 UI 组件和客户端体验。Blazor 是一个单页面应用程序(SPA)框架,类似于 Angular、React、VueJS 和 Svelte,但它基于 C#而不是 JavaScript。

好吧,它是一个 Web 框架,但它与任何其他用于构建 Web UI 的客户端框架有何不同?

为什么选择 Blazor?

Blazor 对.NET 开发者和 Web 开发者都是一个改变游戏规则的工具!在这本书中,你将学习如何使用 Blazor WebAssembly 托管模型创建引人入胜的实时 Web 体验。选择 Blazor 作为你下一个 Web 应用程序开发框架的原因似乎不计其数。让我们从它为 Web 开发做了什么开始。

早在 90 年代初期,浏览网络就像阅读一系列链接文本文档一样——基本的 HTML。这种体验并不沉浸或连贯。当 CSS 和 JavaScript 出现时,动态响应用户交互的能力为网络体验增添了更多风采。尽管网页开始变得更有趣,但加载速度非常缓慢,人们期望的用户体验也较差,页面的渲染/缓冲过程是显而易见的。当时,通过拨号连接以 HTTP 协议向浏览器缓冲底层图像数据,观看图像分段渲染是完全可以接受的。然而,这种耐心并没有持久。人类天性使然,我们总是希望即刻得到想要的东西,对吧?如果你在浏览器上等待几秒钟以上,你会开始感到有些不安。随着网页内容变得更加复杂,开发框架出现以应对这种复杂性。

在这些颠覆性框架中,Blazor 与 WebAssembly 并驾齐驱。通过 Blazor,您可以在客户端和服务器场景上共享 C#代码,同时利用 Visual Studio 系列产品、强大的.NET CLI 以及其他流行的.NET 集成开发环境的工具。.NET 生态系统蓬勃发展,采用率飙升,长期支持(LTS)的吸引力继续推动企业开发。与 Angular 和 React 等其他 SPA 框架的 LTS 相比,.NET 因其每个 LTS 版本的三年支持政策而脱颖而出。保持与每个发布版本的更新非常有利。有关更多信息,请参阅.NET 支持政策

就像任何其他 Web 应用程序一样,Blazor Web 应用程序可以创建为渐进式 Web 应用程序(PWA),以支持离线体验。它们也可以托管在本地桌面应用程序中,并安装在用户设备上。您的 Blazor WebAssembly 应用程序可以定义本地依赖项,例如来自 C 和 C++的依赖项。任何使用Emscripten编译的内容都可以在 Blazor 中使用。在我看来,几乎没有什么需要权衡的地方;Web 开发平台需求旺盛且编程乐趣无穷。

当 WebAssembly 首次引入时,它最初只受到了适度的开发者社区关注和期待。2017 年,WebAssembly 被公开标准化,这使开发者能够探索超越仅有 JavaScript 的互动和功能的新可能性。这对 Web 开发者很重要,因为他们可以更轻松地与利润丰厚的应用商店开发平台竞争。JavaScript 继续发展,添加超越 ECMAScript 标准的功能。通过.NET 创建 Blazor 后,C#成为了 JavaScript 的真正竞争对手。

作为一名拥有超过十年实际网页应用程序开发经验的开发者,我可以证明,我一再地使用.NET 进行企业级生产应用程序的开发。仅.NET 的 API 表面积就非常庞大,并且已被全球数十亿台计算机系统使用。多年来,我使用各种技术构建了许多网页应用,包括 ASP.NET WebForms、AngularJS、Angular、VueJS、Svelte,甚至包括 React,然后是 ASP.NET Core 的 Model View Controller、Razor Pages 和 Blazor。Blazor 将一个成熟的生态系统的优势与 Web 的灵活性和优雅结合在一起,对.NET 和 Web 开发者都有很多提供。

谁应该阅读本书

这本书适用于具有 HTML、CSS、文档对象模型和 JavaScript 基本理解,并具有一定.NET 应用开发经验的.NET 开发者和 Web 开发者。这本书不适合完全初学编程的人。比如,当我告诉我妈妈我在写一本书时,她问我书的内容和她是否会喜欢读。我说,“不会。”她既不是.NET 开发者也不是 Web 开发者,所以我不认为她会在这本书中找到太多价值。但如果你是.NET 开发者或 Web 开发者,那么你会有所收获。

对于.NET 开发者

如果你是一名对 Web 应用开发有兴趣的.NET 开发者,本书将详细介绍如何利用你现有的.NET 技能并将它们应用到 Blazor 开发中。Web 应用平台对.NET 开发者来说是一个重要的机遇。所有流行的 JavaScript SPA 框架,如 Angular、React、VueJS 和 Svelte,在 Blazor 面前都有一个真正的竞争对手。由于 Blazor 基于.NET 和 C#,因此 Blazor 应用开发对你来说应该是很熟悉的。你可以在客户端和服务器之间共享库,使开发变得非常愉快。

对于 Web 开发者

如果你是之前曾经用过.NET 的 Web 开发者,这本书将扩展你掌握的两套编程技能。你所有的.NET 经验都可以延续,以及你对 Web 基础知识的了解。如果你是 SPA 开发者,这本书将向你展示比你习惯的更好的工具集。我们还介绍了许多新的 C#特性。如果你对 C#不熟悉,这本书将为你提供一个惯用的 C#视角和一个强烈的主观经验。

提示

如果你自问,“什么是惯用的 C#?”那么,像所有编程语言一样,C#有一套编程习惯。编程习惯是一种编写更智能、更好的代码以完成任务的方式。惯用的 C#是一组习惯用法,用于使你的代码更易读和可维护。

你的 JavaScript 和客户端路由的开发经验,以及对 HTTP、微服务架构、依赖注入和基于组件的应用程序思维的深刻理解——所有这些东西都直接适用于 Blazor 开发。应用程序开发不应该如此困难,我确实相信 Blazor 使其变得更容易。具有丰富的数据绑定、强类型模板化、组件层次结构事件处理、日志记录、本地化、认证、PWA 支持和托管功能,你拥有所有构建引人入胜的 Web 体验的基础。

为什么写这本书

当有人问我:“你为什么想要写一本书?”我会停顿片刻,假装深思,然后回答:“O’Reilly 让我写的。”就这么简单。但说真的,当我收到一封来自 O’Reilly 的收购编辑的友好邮件,询问我是否有兴趣写一本关于 Blazor 的书时,我认真考虑了很多。首先,被邀请写书真的很酷!但我也知道接下这样的项目意味着要搁置一些事情。我需要暂时停止参加演讲活动,而这些活动在过去几年里是我生活的重要部分。然而,我乐于帮助他人,所以写书将以不同的方式帮助人们。写书也意味着要抽出时间离开我年幼的家庭。我的家人,尤其是我的妻子,一直非常温暖和支持。她相信我有能力帮助他人,并分享我的激情。最终,我决定,“是的!我想写一本书!”

对我来说,帮助开发者社区也有助于加强我对特定技术的理解。我热爱 Blazor!Blazor 对于 .NET 和 ASP.NET 微软开发团队来说是(并一直是)一项重大投资。他们不断推动创新,扩展了 C# 和整个 .NET 生态系统的影响力。这本书是每位开发者必备,也是我回馈我深爱的开发者社区的方式。我全身心投入到这本书中,我知道我对 Blazor 的热情透过书页流露出来。

如何使用本书

这不是你典型的“介绍 X 的”类型的书籍。这是一本技术书,将介绍如何使用 Blazor 构建带有 WebAssembly 和 C# 的单页应用程序。市面上有很多采用逐步方法的书籍,但本书并非如此。

当你阅读本书时,我希望你能有类似加入新团队时的体验。你将经历一些入职过程,了解一个现有应用程序,以及在此过程中学习各种领域知识。“Learning Blazor” 示例应用程序是一个相当大的解决方案,有超过十几个不同规模的项目。每个项目都包含或贡献给 Learning Blazor 应用程序中的特定功能。我们将这些项目作为 Blazor 中做事情的示例进行详细讲解。当我带你深入了解应用程序的内部工作时,你将学到 Blazor 应用程序开发的技巧。最终,你将获得开发 Blazor 应用程序所需的经验,并理解为什么会做出某些开发决策,并且你将获得如何完成任务的实际示例。你将读完本书,并获得启发来开发你的应用程序。

本书中的所有示例都是使用 Learning Blazor 应用程序(或模型应用程序)展示的。来自模型应用程序的源代码,连同本书,成为一个极好的学习资源和未来的参考点。源代码仓库可在 GitHub 上找到,并在 “代码必须活下去” 中分享。

本书的路线图和目标

本书结构如下:

  • 第一章,“迈向 Blazor”,介绍了用于 Web 应用程序开发的 Blazor 的核心概念和基础知识。还介绍了本书的示例应用程序并讨论其架构。

  • 第二章,“执行应用程序”,深入探讨了从第一个客户端请求到静态网站 URL 开始的应用程序执行方式。您将了解 HTML 如何呈现,如何调用其他资源的后续请求以及 Blazor 如何引导自身。

  • 第三章,“组件化”,探讨了用户在应用程序中的表示方式。您将学习如何使用第三方身份验证提供程序验证用户身份。您将了解如何自定义身份验证状态的用户体验,以及使用 Razor 控制结构进行各种数据绑定的方法。

  • 第四章,“定制用户登录体验”,详细介绍了为依赖注入注册客户端服务的过程。您将了解组件化的概念,以及如何使用 RenderFragment 方法定制组件。您还将学习如何编写和使用参数化的客户端本地语音合成,在 Blazor WebAssembly 中完全实现和配置。

  • 第五章,“本地化应用程序”,演示了如何使用免费的基于 AI 的自动化持续交付流水线来支持本地化。您将学习如何使用框架提供的 IStringLocalizer<T> 类型及其相应的服务。

  • 第六章,“演示实时 Web 功能”,介绍了实时 Web 功能,并展示了通知系统、实时推文流页面和警报功能。此外,您还将学习如何使用 ASP.NET Core SignalR 构建聊天应用程序。

  • 第七章,“使用源生成器”,提出了使用源生成器改进 Blazor JavaScript 互操作性(interop)体验的案例。您将了解为何 C# 源生成器在应用程序开发中非常有用,以及它们如何节省大量时间。

  • 第八章,“接受带验证的表单输入”,探讨了表单的工作原理。我们将介绍高级 <form> 输入验证。我们还将看看如何将本地语音识别整合到表单中,以提供用户另一种输入选项。您将学习如何使用 EditContext 和表单模型绑定。第八章还演示了一种接收使用 Reactive Extensions for .NET 实时更新的自定义状态验证模式。

  • 第九章,“测试所有内容”,教您如何编写单元测试、组件测试甚至端到端测试,以确保您的应用程序正常工作。这些测试可以自动化运行,每次将应用程序推送到 GitHub 存储库时都会运行,使用 GitHub Actions。

本书使用约定

本书使用以下排版约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名

Constant width

用于程序清单,以及在段落中引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字

提示

此元素表示一个提示或建议。

注释

此元素表示一般备注。

警告

此元素表示警告或注意事项。

使用代码示例

附加材料(代码示例、练习等)可在https://oreil.ly/learning-blazor-code下载。

如果您有技术问题或者在使用代码示例时遇到问题,请发送邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它,无需征得我们的许可。除非您复制了代码的大部分内容,否则无需联系我们以获得许可。例如,编写一个使用本书多个代码块的程序不需要许可。销售或分发 O’Reilly 书籍中的示例代码需要许可。引用本书并引用示例代码回答问题无需许可。将本书的大量示例代码整合到您产品的文档中需要许可。

我们感谢但通常不需要署名。署名通常包括书名、作者、出版商和 ISBN。例如:“学习 Blazor,作者 David Pine(O’Reilly)。版权所有 2023 年 David Pine,978-1-098-11324-7。”

如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com联系我们。

O’Reilly 在线学习

注释

超过 40 年来,O’Reilly Media已为企业提供技术和商业培训、知识和洞见,帮助它们取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台让您随需应变地访问现场培训课程、深度学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问https://oreilly.com

如何联系我们

请将关于本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • 加利福尼亚州北格雷文斯坦大道 1005 号

  • 加利福尼亚州塞巴斯托波尔,95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或当地)

  • 707-829-0104(传真)

我们有一本书的网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/learning-blazor

发送电子邮件至bookquestions@oreilly.com,对本书进行评论或提出技术问题。

要获取有关我们的图书和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上关注我们:https://www.youtube.com/oreillymedia

致谢

我曾作为 ITkonekt 开发者大会的一部分前往塞尔维亚。我与三位令人惊叹的人士共乘一辆旅行面包车。其中一位是当时的.NET 基金会执行董事 Jon Galloway。第二位是 Jonathan LeBlanc,他是我唯一认识的因技术赢得艾美奖的人(现在也是 O’Reilly 的作者之一)。第三位是 Håkon Wium Lie,他因创建 CSS 而闻名,并曾担任 Opera 的 CTO。从他们身上学到了很多,这是一个很好的机会。

无论如何,在旅途中,事实表明,在我们乘坐的面包车里,我是唯一没有写书的人。他们立即鼓励我纠正这一点。他们告诉我要与世界分享我的知识,写一本书。听到我的尊敬的朋友和同事们相信我,我感动不已。我并没有立刻写书,但我仔细考虑了很多,等待时机成熟。现在是时候了!我想感谢 Jon、Jonathan 和 Håkon 对我的信任,以及他们对开发者社区的启发。

请允许我感谢一些贡献者,他们为本书引用的部分源代码做出了贡献。感谢 Ben Felda,为模型应用的主页瓷砖组件提供 SVG 和样式更新。感谢 Max Schmitt,帮助我简化了模型应用的构建验证工作流中 Playwright 测试框架的使用。感谢 Billy Mumby,在支持模型应用的任务列表功能中对底层数据存储的工作。您在我们维护的Azure Cosmos DB Repository .NET SDK中所做的工作,对开发者社区是巨大的财富。感谢 Weihan Li,为模型应用消费 Blazor 源生成器的贡献,特别是Blazorators。感谢 Vsevolod Šliachtenko,在Azure 资源转换器 GitHub Action上与我合作和工作。您优美地实现了请求批处理。感谢 GitHub 机器人,截至 2022 年 7 月,自动化了超过 73,000 行代码到 Learning Blazor 项目。

我想感谢我的导师和好朋友 David Fowler。David 长时间以来一直在指导我,他的宝贵教训我都视若珍宝。David 为我的“Have I Been Pwned” .NET HTTP Client 开源项目贡献了代码,简化了 Minimal API 示例。我们的交流经常是我一周的亮点;我与他分享代码、经验、职业挑战和思考,他则以他的智慧回应。他对我和许多其他人都是一个激励,并且我非常感激能从他身上学到东西。

感谢 O’Reilly 团队的支持与鼓励。我要正式感谢这本书的所有审阅人员:Rita Fernando、Carol Tumey、Erik Hopf、Gerald Versluis、John Kennedy、Chad Olson 和 Egil Hansen。没有他们不知疲倦的工作和彻底的审查——从编辑审查关注每个字词到深入的技术审查确保每行代码尽可能简单和优雅——这本书就不会变得如此深刻和有帮助。这种质量得益于数十年的专业实践经验,我对结果感到非常高兴。

我要感谢 Steve Sanderson 创建 Blazor。我非常喜欢使用这项技术编写应用程序。我还要感谢全球开源和.NET 社区的众多贡献者。你们是我的灵感源泉——谢谢你们!

最后,我要感谢我的家人。没有我了不起的妻子 Jennifer 的支持,这一切都不可能实现。她鼓励我成为最好的自己。她比我更早地相信我自己。我要感谢我的三个儿子 Lyric、Londyn 和 Lennyx。他们是未来的不断提醒,是我们在世界上找到的美好。每个孩子都独特地拥有探索精神、好奇心和快乐的一小部分。没有他们的火花和支持,你现在可能不会读到这些。谢谢!

¹ “使用 C#构建客户端 Web 应用程序:Blazor: .NET”,Microsoft,https://oreil.ly/iIaWE

第一章:闪耀进入 Blazor

Node.js 重塑了现代 Web 应用程序开发的世界。它的成功部分归功于 JavaScript 的流行,当然还有 JavaScript 现在在客户端和服务器上的运行,这都要归功于 Node。这也是为什么 Blazor 会如此成功——现在使用 WebAssembly,C#可以在浏览器中运行。对于.NET 开发者来说,这是一个巨大的潜力,因为今天已经存在许多 C#服务器应用程序。对于.NET 开发者来说,有很多机会使用 Blazor 创建令人惊叹的用户体验。

第一次,.NET 开发者可以利用他们现有的 C#技能来构建各种 Web 应用程序。这模糊了后端和前端开发者之间的界限,并扩展了 Web 应用程序的开发。在现代 Web 应用程序开发中,您希望您的应用程序在桌面和移动浏览器上都具有响应性。现代 Web 应用程序比其前身更加复杂和内容丰富,并拥有实时 Web 功能、渐进式 Web 应用(PWA)功能以及精美编排的用户交互。

在本章中,您将了解.NET Web 应用程序开发的起源以及 Blazor 的诞生。您将探索单页应用程序(SPA)框架的变体,并了解.NET 如何在 Web 生态系统中巩固其地位。我将回答您可能会有的许多关于为什么Blazor 是一个可行选项的问题,并讨论其托管模型。最后,您将首次了解 Learning Blazor 示例应用程序。本书将使用此示例应用程序,在每章中演示 Blazor 的各种功能并使用该应用程序进行跟随学习。

Blazor 的起源

1996 年,微软的 Active Server Pages(ASP)首次提供了用于动态 Web 页面的服务器端脚本语言和引擎。随着.NET Framework 的发展,诞生了 ASP.NET,并随之出现了 ASP.NET Web Forms(WebForms)。WebForms 被许多人使用,他们享受.NET 所能实现的一切。

当 ASP.NET 模型视图控制器(MVC)于 2006 年首次发布时,它使 WebForms 显得迟缓。MVC 使 ASP.NET 开发者更接近少抽象的 Web 开发。通过更接近 Web 标准,MVC 引入了 ASP.NET 的模型-视图-控制器模式,帮助解决了管理 ASP.NET 后台状态的问题。当时,这是开发者社区的一大痛点。开发者不喜欢 WebForms 为页面上所有控件以及<form>提交数据带来的额外状态。WebForms 通过 ViewState 和其他状态机制制造了状态保持,这与 HTTP 的本质相矛盾。MVC 专注于可测试性,强调开发的可持续性。这是从 WebForms 到 MVC 的范式转变。

2010 年,Razor 视图引擎被引入作为 ASP.NET MVC 中多种视图引擎选项之一。Razor 是一种将 HTML 和 C#融合的标记语法,用于模板化。作为 MVC 的附带产品,ASP.NET Web API 因其强大的功能而受到欢迎,开发者们也开始接受将其作为构建基于.NET 的 HTTP 服务的标准。与此同时,Razor 视图引擎在演化、加强和成熟。

最终,基于 MVC 作为基础的 Razor 视图引擎进入了舞台。ASP.NET Core 的创新使这一切成为可能。团队对性能作为特性的迫切推动在TechEmpower 基准结果中可见,ASP.NET Core 继续领先。Kestrel 是跨平台的 Web 服务器,已默认包含在 ASP.NET Core 项目模板中。截至 2022 年,它是目前速度最快的 Web 服务器之一,能够每秒处理超过 400 万次请求。

ASP.NET Core 为现代开发中所期望的所有基本要素(但不限于依赖注入、强类型配置、功能丰富的日志记录、本地化、身份验证、授权和托管)提供了一流的支持。Razor 页面更加倾向于真正的组件,并构建在 Web API 基础设施之上。

在 Razor 页面之后,出现了 Blazor,这个名称是通过结合“browser”和“Razor”得到灵感而起。Blazor(是不是个聪明的名字?)是.NET 的首个单页面应用(SPA)框架。Blazor 利用 WebAssembly(Wasm),这是一种面向堆栈的虚拟机的二进制指令格式。WebAssembly被设计为编程语言的可移植编译目标,能够在客户端和服务器应用程序中部署。WebAssembly 使得.NET Web 应用能够与基于 JavaScript 的 SPA 框架真正竞争。这是 C#在客户端浏览器中运行,利用 WebAssembly 和 Mono .NET 运行时。

根据 Steve Sanderson 的说法,他创建 Blazor 是因为他受到在 WebAssembly 上运行.NET 的启发。当他发现了 Dot Net Anywhere (DNA),一个可轻松编译为 Web­Assembly 的替代.NET 运行时,并且使用了Emscripten,这是一个专注于速度、体积和 Web 平台的完整编译器工具链时,他有了突破。

这就是引导.NET 在浏览器中运行而无需插件的首个工作原型之一的路径。在 Steve Sanderson 展示了这个在浏览器中运行的令人惊叹的.NET 应用程序后,其他微软利益相关者开始支持这个想法。这将.NET 推向生态系统的一个更高级别,并使其更接近我们今天所知的 Blazor。

现在我们已经讨论了 Blazor 的产生过程,让我们谈谈它如何使应用程序焕发生机以及它们可以被托管的不同方式。

Blazor 的托管方式

Blazor 有三种主要的托管模型:Blazor Server、Blazor WebAssembly 和 Blazor Hybrid。虽然本书涵盖了 Blazor WebAssembly,但 Blazor Server 和 Blazor Hybrid 也是其有效的替代方法。

Blazor Server

使用 Blazor Server 时,当客户端浏览器向 Web 服务器发出初始请求时,服务器执行.NET 代码以动态生成 HTML 响应。返回 HTML 并根据 HTML 文档中指定的内容进行后续请求以获取 CSS 和 JavaScript。一旦脚本加载并运行,使用 ASP.NET Core SignalR 连接实现客户端路由和其他 UI 更新。ASP.NET Core SignalR 提供客户端和服务器之间的双向通信,实时发送消息。此技术用于在客户端浏览器上更新文档对象模型(DOM)而无需页面刷新。

使用 Blazor Server 作为主机模型的优势远远超过 Blazor WebAssembly:

  • 由于应用程序在服务器上呈现,下载大小比 Blazor WebAssembly 小。

  • 组件代码不会提供给客户端,只提供生成的 HTML 和一些用于与服务器通信的 JavaScript。

  • 在 Blazor Server 主机模型中存在服务器功能,因为应用程序在技术上在服务器上运行。

对于有关 Blazor Server 的更多信息,请参阅微软的“ASP.NET Core Blazor Hosting Models”文档

图 1-1 显示了服务器和客户端。服务器是 Blazor 代码运行的地方,由运行在.NET 上的 Razor 组件组成。客户端负责呈现 HTML。客户端 JavaScript 向服务器通信用户交互,服务器执行逻辑后发送一系列 HTML 更改(增量)列表返回客户端以更新其视图。

lblz 0101

图 1-1. Blazor Server 主机模型

Blazor WebAssembly

使用 Blazor WebAssembly 时,当客户端浏览器向 Web 服务器发出初始请求时,服务器返回一个静态 HTML 视图,显示应用程序如果已运行时将显示给用户的内容;这使用户能更快地看到首次渲染,并允许搜索引擎爬取应用程序的内容。当用户查看静态渲染的内容时,用于在客户端内运行应用程序所需的资源会在后台下载。作为 Blazor WebAssembly 应用程序的 HTML 的一部分,会有一个请求blazor.webassembly.js文件的<link>元素。该文件执行并开始加载 WebAssembly,充当从服务器请求.NET 二进制文件的引导程序。一旦您的应用程序在本地下载并在浏览器内运行,对 DOM 的更改(例如从 API 调用检索新数据值)会导致页面更新。这在“App Startup and Bootstrapping”中有详细描述。

警告

注意托管模型是很重要的。使用 Blazor WebAssembly 托管时,所有的 C#代码都在客户端执行。这意味着应避免使用任何需要服务器端功能的代码,也应避免使用密码、API 密钥或其他机密信息。

使用 Blazor WebAssembly 托管模型时,可以选择创建 Blazor ASP.NET Core 托管应用程序或作为一组静态文件发布的独立应用程序(显然,这不支持服务器端预渲染以及改进的用户体验)。使用 ASP.NET Core 托管 解决方案,ASP.NET Core 负责为应用程序提供服务,并在客户端/服务器架构中提供 Web API。本书的应用程序采用独立模型,并部署到 Azure Static Web Apps。换句话说,该应用程序作为一组静态文件提供服务。用于驱动应用程序的数据可作为几个 Web API 端点使用,这些端点可以部署为容器或具有监控的简单容错传递 API。我们还使用 Azure Functions 作为本地、当前和最新天气数据的无服务器架构。

图 1-2 仅显示客户端。在这种情况下,客户端负责所有内容,站点可以静态提供服务。

lblz 0102

图 1-2. Blazor WebAssembly 托管模型

使用独立方法,利用 Azure Functions 的无服务器云功能非常有帮助。微服务功能,例如 ASP.NET Core Web API 和 Blazor WebAssembly 独立场景,能够很好地协同工作,并一起作为 Azure Static Web Apps 部署的理想目标。静态网页服务器提供静态文件,这比计算请求并动态渲染 HTML 再返回响应要少计算成本。

注意

虽然本书专注于开发以静态文件托管的 Blazor WebAssembly 应用程序,但重要的是要注意这并不是唯一的选择。我更倾向于开发以静态方式托管的 Blazor WebAssembly 应用程序。有关托管模型的更多信息,请参阅微软的“ASP.NET Core Blazor Hosting Models”文档

使用 Blazor WebAssembly 托管模型,您可以编写在其中运行的 C# 代码。使用 WebAssembly,“二进制指令格式”意味着我们在讨论字节码。WebAssembly 位于“基于堆栈的虚拟机”之上。指令被添加(推入)到堆栈中,而结果则从堆栈中移除(弹出)。WebAssembly 是一个“可移植的编译目标”。这意味着可以使用 C、C++、Rust、C# 和其他非传统的 web 编程语言,将它们编译为 WebAssembly。这导致了基于 WebAssembly 的二进制文件,这些文件符合开放标准,但来自于 JavaScript 以外的编程语言。

Blazor 混合模式

Blazor 混合模式超出了本书的范围。它的目的是为桌面和移动设备创建本地客户端体验,并且与 .NET 多平台应用程序 UI(MAUI)兼容良好。有关 Blazor 混合模式的更多信息,请参阅 Microsoft 的“ASP.NET Core Blazor 混合”文档

重新定义单页面应用程序

Blazor 是目前唯一基于 .NET 的单页面应用程序框架。不能过分强调可以使用 .NET 来编写单页面应用程序的事实。还有许多流行的 JavaScript 单页面应用程序框架,包括(但不限于)以下内容:

所有这些基于 JavaScript,而 Blazor 则不是。这个列表是不详尽的——还有许多基于 JavaScript 的单页面应用程序框架,甚至还有更多非单页面应用程序的 JavaScript 框架!JavaScript 已经统治着浏览器作为 Web 的唯一编程语言超过 20 年了。它是一种非常灵活的编程语言,也是世界上最受欢迎的语言之一。在其初期,这种语言是由 Brendan Eich 在几周内原型化的——自那以后,它的进展令人惊讶。

Stack Overflow 进行了一项专业开发者年度调查,在 2021 年,超过 58,000 名专业开发者和超过 83,000 名总开发者投票,JavaScript 被选为最常用的编程语言。这标志着 JavaScript 连续第九年成为最常用的编程语言。¹ 紧随其后的是 HTML/CSS。如果将这些总数结合起来,Web 应用平台有着坚实的未来。

JavaScript 的一个认为的劣势是缺乏明确的类型,开发人员必须要么进行防御性编码,要么面对运行时错误的潜在后果。帮助解决这个问题的一种方法是使用 TypeScript。

TypeScript 是由 Anders Hejlsberg 创建的(他也是 C# 的首席架构师、Turbo Pascal 的首席工程师和 Delphi 的首席架构师——他是一位编程语言天才!)。TypeScript 提供了一种类型系统,使语言服务能够推断出您代码的意图。

使用 TypeScript,您可以使用所有最新的 ECMAScript 标准和原型功能编写通用类型安全代码。最棒的部分是,您的代码向后兼容到 ES3。TypeScript 是 JavaScript 的超集,这意味着任何有效的 JavaScript 也是有效的 TypeScript。TypeScript 提供静态类型(类型系统)和强大的语言服务,为您喜爱的 IDE 提供功能。这使得使用 JavaScript 编程更少出错,这一点不能被低估。TypeScript 更像是一种开发工具,而不是一种编程语言,但它拥有令人难以置信的语言特性。当它编译时,所有的类型都消失了,你只剩下 JavaScript。试着把 TypeScript 看作是一种使调试和重构变得更加轻松和可靠的方式。使用 TypeScript,您拥有全球最先进的流分析工具之一,以及比单独使用 JavaScript 更先进的语言特性。所有的 Web 开发人员都知道,Angular 在基于 JavaScript 的单页面应用的流行度上与 React 不相上下,这一点不足为奇。我认为 Angular 的许多竞争优势直接与更早采用 TypeScript 有关,比 React 更早。

Blazor 不同于基于 JavaScript 的单页面应用(SPA),它建立在 .NET 之上。尽管 TypeScript 可以帮助开发人员更高效地使用 JavaScript,但 Blazor 未来光明的一个主要原因是其与 C# 的互操作性。长期以来,C# 提供了大部分 TypeScript 提供给 JavaScript 开发的优点,并且更多。C# 不仅拥有出色的类型系统,而且在编译时更擅长捕获错误。TypeScript 的静态类型系统是“鸭子类型”的(如果看起来像鸭子、听起来像鸭子,那就像鸭子一样对待),而 C# 则拥有严格的类型系统,确保传递的对象是鸭子类型的实例。C# 一直优先考虑开发者体验,包括流分析、语句完成、功能丰富的生态系统和可靠的重构。C# 是一种现代的、面向对象的、类型安全的编程语言,不断发展和成熟,进一步扩展其能力。它是开源的,新功能经常受到开发者社区的启发和影响,有时甚至是由其开发。

话虽如此,Blazor 也能与 JavaScript 进行互操作。您可以从 Blazor 代码调用 JavaScript,也可以从 JavaScript 代码调用 .NET 代码。这是一个利用现有 JavaScript 实用功能和 JavaScript API 的有用特性。

为什么选择 Blazor

有一些有趣的新场景是特定于 WebAssembly 的,这些场景仅仅依靠 JavaScript 是无法实现的。很容易想象,通过 WebAssembly,应用程序可以通过网络传输到您的浏览器,用于更复杂和资源密集型的使用场景。如果您之前没有听说过 AutoCAD,它是一款建筑师、工程师和建筑专业人士依赖的计算机辅助设计软件,用于创建二维和三维图纸。它是一个桌面应用程序,但想象一下能够在网页浏览器中本地运行这样的程序。想象一下音频和视频编辑,在浏览器中运行或播放强大且资源消耗大的游戏。WebAssembly 确实让我们重新想象了一下网络。整体上,Web 应用程序平台可能是下一代软件开发的交付机制。Web 应用程序开发平台在互联网数据处理和摄入系统方面表现出色,因为它们与世界的连接性。Web 应用程序开发平台作为一种媒介,连接了开发者的想象力和用户的需求。

开发者可以继续将他们的 C# 和 Razor 技能扩展到单页应用程序开发中,而不必学习额外的语言和渲染框架。之前不太愿意编写单页应用程序的 C# 开发者现在转而从 MVC 到单页应用程序,仅仅是因为“这更像是 C#”。此外,代码共享的潜力也很大。与其确保您的 C# API 合同在服务器上与您的 TypeScript 定义手动保持同步,不如简单地使用同一个合同文件,以及所有的 DataAnnotation 验证器。

在未来几年,我相信我们将会看到越来越多基于 WebAssembly 的应用程序。Blazor WebAssembly 将成为 .NET 的首选解决方案。

.NET 在浏览器中的潜力

在大学毕业后的第一份开发者工作中,我是一个团队中最初级的开发者,其他团队成员都是开发主管或架构师。我清楚地记得自己独自坐在一个立方体农场中,周围的立方体都是空的,但周围的办公室都是满的。

我曾在汽车行业工作,我们正在实施一种称为车载诊断(OBD)协议的低级通信标准。我们使用 .NET 的 SerialPort 类来完成这项工作。我们正在编写应用程序,用于对车辆排放进行状态测试。在美国,大多数州要求一定年龄的车辆进行年度排放测试,以确保其能够注册。其核心思想相当简单:评估车辆的各种状态。例如,车辆可能具有硬件触发状态变化,这些状态通过固件传播,每根导线传输信息随其发生。OBD 系统位于车载计算机中,可以将此信息传递给相关方。例如,您的“发动机故障”指示灯就是来自 OBD 系统的诊断代码。

这些应用程序主要是作为 Windows Forms(WinForms)应用程序构建的,也有一些 Web 服务应用程序。但这意味着该应用程序仅限于.NET Framework 和 Windows 系统,换句话说,它不是跨平台的。当时,应用程序必须与各种 Web 服务通信以持久化数据并获取查找数据点。在当时,想象将这样的应用程序编写并部署为 Web 应用程序是不可想象的;它必须是 Windows 上的 WinForms 应用程序。

现在,然而,很容易想象这个应用程序会被重写为一个使用 Blazor WebAssembly 的 Web 应用程序。Mono .NET 运行时使得编写跨平台的.NET 应用程序成为可能。

尝试想象一下,如何可能在 Blazor WebAssembly 中实现与我们在 WinForms 中使用的相同的.NET SerialPort对象。相应的实现可以假设依赖于 WebAssembly 与本地 JavaScript Web Serial API 的交互操作。这种跨平台功能已经在其他实现中存在,比如在 Blazor WebAssembly 中的.NET HttpClient。通过 Blazor WebAssembly,我们的编译目标是 WebAssembly,而 Mono 运行时的实现是fetch Web API。您看,.NET 现在将整个 Web 作为其游乐场。

.NET 就在这里

WebAssembly 受到所有主要浏览器的支持,覆盖了近 95%的用户,根据“Can I Use WebAssembly?”网页。这是 Web 的未来,您将继续看到开发者使用这项技术构建应用程序。

.NET 并没有消失。微软继续以令人惊讶的速度前进,发布节奏可预测且深远。Web 开发者社区非常强大,整个软件开发行业都认为 ASP.NET Core 是现代和企业友好的 Web 应用程序开发平台之一。JavaScript 仍然是必不可少的,但从您的角度来看,它被弱化了,因为 WebAssembly 今天依赖于它们并且它们之间的配合非常好。WebAssembly 网站声明:“预计 JavaScript 和 WebAssembly 将以多种配置一起使用。”

熟悉度

如果您是 C#开发者,太好了!如果您是 JavaScript 开发者,太棒了!把这些现有技能带到桌面上,Blazor 将与这两组镜头感觉非常熟悉。这样,您可以继续使用您的 HTML 和 CSS 技能以及您喜爱的 CSS 库,并且可以自由地与现有的 JavaScript 包顺畅工作。但是 JavaScript 开发在您的视角中被弱化了,因为您将会用 C#编码。C#来自微软,并且深受.NET 开发者社区的影响。在我看来,C#是最好的编程语言之一。

如果你来自 Web 开发背景,你很可能已经习惯了客户端路由、事件处理、某种形式的 HTML 模板化以及组件编写。你所热爱的关于 Web 开发的一切依然是 Blazor 开发的重点。Blazor 开发是简单且直观的。此外,Blazor 为 JavaScript 和 CSS 提供了各种隔离模型。你可以将 JavaScript 和 CSS 限定到单独的组件中。你也可以继续使用你喜爱的 CSS 预处理器。你完全可以选择任何你喜欢的 CSS 框架。

安全和可靠

在 WebAssembly 出现之前很久以前,还有另一种基于 Web 的技术我不得不提及。Microsoft Silverlight 是由 .NET Framework 提供支持的插件。Silverlight 是一种用于编写和运行丰富 Web 应用程序的应用程序框架。Silverlight 依赖于已经废弃的 Netscape 插件应用程序接口(NPAPI)。插件架构证明是一个安全问题,所有主流浏览器开始逐步停止支持 NPAPI。这导致了 Silverlight 的衰落,但请放心:WebAssembly 不是 基于插件的架构。

注意

WebAssembly 的安全性与 JavaScript 一样可靠。WebAssembly 在与所有基于浏览器的 JavaScript 执行环境相同的安全沙箱中运行。因此,WebAssembly 的安全上下文与 JavaScript 完全相同。

代码重用

SPA 开发者多年来一直在与困难作斗争。这些开发者使用定义特定形状负载的 Web API 端点。消费端代码(SPA 应用程序)必须模拟相同的形状;然而,由于 API 可以随时更改响应的形状,这是容易出错的。客户端需要知道这些更改何时发生,并做出调整,这是一件很繁琐的事情!Blazor 可以通过将 .NET Web API 中的模型与 Blazor 客户端应用程序共享来缓解这一问题。我无法过分强调这一点的重要性。通过从类库中与服务器和客户端共享模型,就像是一举两得。

作为一个既构建 API 又在客户端应用程序中使用它们的开发者,我认为同步模型定义的行为带来了极大的烦恼。我称之为“同步疲劳”。同步疲劳严重影响开发者,他们对手动映射服务器和客户端模型感到沮丧。当你不得不映射不同语言的类型系统时,这一点尤为明显——那绝对不是一件有趣的事情。这个问题也存在于后端开发中,从存储介质(如文件系统或数据库)读取数据。将数据库中存储的内容的形状映射为与 .NET 对象匹配的形状是一个已解决的问题;对象关系映射器(ORM)为我们做到了这一点。

多年来,我依赖工具帮助捕捉常见错误,例如服务器改变 API 端点数据结构的形式导致客户端应用程序崩溃。当然,你可以尝试使用 API 版本控制,但坦率地说,这也有其自身的复杂性。工具支持显然是不够的,防止同步疲劳非常困难。偶尔会出现一些奇思妙想来应对这些问题,但你必须问自己:“有没有更好的方法?”答案是:“有,在 Blazor 中有!”

整个 .NET 库可以在服务器端和客户端场景中共享和使用。利用现有的逻辑、功能和能力使开发者能够更专注于创新,因为他们不需要重新发明轮子。开发者也不必浪费时间在服务器和客户端浏览器之间手动映射模型。你可以利用通用的扩展方法、模型和实用函数,这些都可以轻松封装、测试和共享。单单这一点就有一个隐含的、可能不太明显的优点。你看,一个团队可以同时编写客户端、服务器和抽象层。这使得应用开发过程中的快速创新成为可能,因为有如此多的通用代码可以重复使用和共享。可以把这看作是全球各地多个团队编写大量应用的情况,其中至少一个团队依赖另一个团队的输出。这是一个常见的开发问题领域,在这个领域中一个团队依赖于另一个团队的输出是不必要的,因为 Blazor 就是全部由 C# 编写的!

工具支持

作为开发者,在选择工具时我们有很多选择。选择正确的工具与完成工作本身同样重要。你不会用螺丝刀去敲钉子,对吧?开发团队的生产力始终是应用开发中的一个重要关注点。如果你的团队在完成常见编程任务时手忙脚乱或者遇到困难,整个项目最终可能会失败。在 Blazor 开发中,你可以使用如下经过验证的开发工具:

  • Visual Studio

  • Visual Studio for Mac

  • Visual Studio Code

依据你的操作系统可能会有所不同。在 Windows 上,Visual Studio 是非常好的。在 macOS 上,使用 Visual Studio Code 可能会更容易些。JetBrains 的 Rider 是另一个出色的 .NET 开发环境。关键在于作为开发者,你有很多非常好的选择。无论你选择哪个集成开发环境(IDE),它都需要与 .NET 生态系统良好配合。现代化的集成开发环境(IDE)提升了开发者的生产力。C# 由 Roslyn(.NET 编译平台)支持,虽然这些对开发者来说是不透明的,但我们享受到了诸如以下特性的便利:

语句完成(IntelliSense)

当您输入时,IDE 显示所有适用且上下文相关的成员的选择列表,提供语义指南和更快速的代码发现能力。通过三斜杠注释启用的开发者文档进一步促进代码理解和可读性。

AI 辅助的 IntelliSense(AI、IntelliCode)

当您输入时,IDE 根据从 GitHub 上所有 100+ 星级开源代码库学习到的模型驱动预测为您提供代码完成建议。

GitHub Copilot(AI 配对程序员)

当您输入时,IDE 建议整行或函数,其训练基于数十亿行公共代码。

重构

快速可靠地确保消费引用的更新,在解决方案中更改方法签名、成员名称和类型,以及添加增强源代码执行、性能、可读性和最新 C# 特性的 C# 现代化工作。

内置和可扩展的代码分析器

检测源代码中的常见陷阱或错误,并迅速通过警告、建议甚至错误点亮开发者体验。换句话说,写出优秀的代码。

代码生成器

一个代码生成器的例子是使用记录类型自动生成相等性实现;这项技术使得重新想象可能性成为了现实。

您还可以利用 .NET CLI,这是一个用于开发 .NET 工作负载的跨平台工具链。它公开了许多命令,如 new(模板化)、buildrestorepublishruntestpackmigrate

开源软件

Blazor 完全是在开放中开发的,作为 ASP.NET Core GitHub 仓库 的一部分。

开源软件开发是现代软件工程的未来。事实上,这并不是真的 全新;只是自 2014 年 3 月起对 .NET 新鲜。随着 .NET 基金会的诞生,开发者们在公开协商的开放标准和最佳实践下进行协作。创新是唯一前进的道路,尤其是在项目经历公众审查和自然秩序的情况下。

对我来说,仅仅将 .NET 描述为开源是不够的。让我与你分享更多关于真正价值主张的见解,以及为什么这一点如此重要。我见证了 .NET API 的开发,从最初的构想到最终成果——这个过程非常成熟且已被充分确立。这同样适用于 Blazor,因为它是 .NET 开源项目族的一部分。

与典型项目不同,开源项目完全在公开环境中开发供公众查看。在 .NET 中,它始于早期讨论,然后一个想法浮出水面。使用 GitHub 问题起草一个 ASP.NET Core api-suggestion label。从建议开始,在经过讨论和审核后,它转变为提议。包含提议的问题过渡到 ASP.NET Core api-ready-for-review label。该问题记录了提议所期望的一切内容:问题陈述、使用案例、参考语法、建议的 API 表面区域、示例用法,甚至链接到原始讨论和想法的评论。

潜在的 API 通常包括讨价还价、推理和谈判。在每个人都认为这是一个好提议之后,一份草案将与参加公共 API 设计审查会议的一组人员最终确定。官方的 .NET API 设计审查会议按照每周的日程安排进行直播,同时邀请开发者社区成员分享他们的想法。作为审查的一部分,会记录笔记并应用 GitHub 标签,假设它获得通过,相关的 .NET API 将被编码为代码片段。最后,它移动到 ASP.NET Core api-approved label

从这里开始,这个问题作为满足提议的拉取请求的参考点。一个开发者拿起这个问题,实现 API,编写单元测试,并创建一个拉取请求(PR)。PR 经过审查后合并,API 必须进行文档化、传播、捕捉和报告破坏性更改、推广、分享、分析等。

所有这些都是为了单个 .NET API,而 .NET API 的数量达到数万个。凭借所有构建现代应用程序开发最佳平台的 .NET 贡献者的力量,您可以放心!

软件开发行业对开源软件开发非常青睐。对我来说,能够看到一个功能是如何被架构化、设计和实现的是一个改变游戏规则的能力。发布问题、提议功能、进行开放讨论、维护自动状态更新的看板式项目、与开发团队和其他人合作,以及创建拉取请求等能力,使这款软件变得以社区为中心。这最终无疑会使产品更加出色!

使用 .NET CLI 创建你的第一个 Blazor 应用程序

言归正传,让我们跳入并让你使用 .NET CLI 制作你的第一个 Blazor 应用程序。.NET CLI 跨平台,在 Windows、Linux 和 macOS 上均可使用。安装 .NET SDK,其中包括 .NET CLI 和运行时 - 可以免费下载。安装 .NET 6.0,因为这是一个 LTS 版本。使用 .NET CLI,你可以创建许多 .NET 工作负载。要创建新的 Blazor WebAssembly 应用程序,请打开终端并运行以下命令:

dotnet new blazorwasm -o FirstApp

dotnet new命令将基于模板创建一个新的 Blazor WebAssembly 应用程序。

提示

您可以使用许多其他模板。.NET 是免费的、开源的,并且非常棒。有关其他模板,请参阅 Microsoft 的.NET 默认模板列表 dotnet new

它将输出项目到新创建的FirstApp目录。您应该会看到类似以下的命令输出:

The template "Blazor WebAssembly App" was created successfully.
This template contains technologies from parties other than Microsoft,
see https://aka.ms/aspnetcore/6.0-third-party-notices for details.

该模板应用程序包括一个单独的 C#文件、几个 Razor 文件、CSS 文件和一个 index.html 文件。此应用程序具有一些页面、基本导航、数据绑定、事件处理和典型 Blazor 应用程序开发的其他常见方面。接下来,您需要更改目录。使用cd命令并传递目录名称:

cd FirstApp

构建应用程序

一旦您进入新应用程序的目录,模板可以使用以下命令编译:

dotnet build

在应用程序编译完成(成功执行build)后,您应该会看到类似以下的命令输出:

Microsoft (R) Build Engine version 17.0.0+c9eb9dd64 for .NET
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  All projects are up-to-date for restore.
  FirstApp -> ..\FirstApp\bin\Debug\net6.0\FirstApp.dll
  FirstApp (Blazor output) -> ..\FirstApp\bin\Debug\net6.0\wwwroot

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:04.20

安装开发者证书

如果这是您第一次构建和运行 ASP.NET Core 应用程序,您需要信任localhost的开发者自签名证书。可以通过运行以下命令来完成此操作:

dotnet dev-certs https --trust

当提示时,回答“是”来安装证书。

提示

如果您没有安装和信任开发者证书,您将收到一个警告,因为该站点未受保护而必须接受。如果您在 macOS 上运行,您可能需要输入密码(两次)来接受该证书。

运行应用程序

要运行模板应用程序,请使用以下命令:

dotnet run

命令输出将类似于以下内容,并且其中一行输出将显示应用程序托管的位置:

..\FirstApp> dotnet run
Building...
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7024
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5090
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: ../repos/FirstApp

localhost URL 是当前设备主机名加上一个随机可用的端口号。使用https://协议导航到该 URL:在我的示例中,https://localhost:7024(您的可能会有所不同)。应用程序将启动,并且您将能够与完全功能的 Blazor WebAssembly 应用程序模板进行交互,如图 1-3 所示。

lblz 0103

图 1-3. 第一个 Blazor 模板应用程序

要停止应用程序运行,请结束终端会话。在停止应用程序运行后,您可以关闭您的 IDE。这个 Blazor WebAssembly 模板是非常完善文档化,并且展示了其所示功能的局限性。

现在您知道如何开始创建您的应用程序了,您可能会问:“我应该把我的代码放在哪里?” 我很高兴您问到了。

代码必须存活下去

代码只有在存储位置良好时才能发挥作用。如果您的代码只存在于您的计算机上,那么它将永远留在那里。它不会去任何其他地方,这真是一件遗憾的事情。GitHub 提供了使用 Git 进行版本控制的托管解决方案,它是其类别中的最佳选择。请原谅我有所偏见。

本书的所有源代码都可以在GitHub找到。如果您想在代码中跟随阅读,可以使用以下git CLI命令在本地机器上克隆存储库:

git clone https://github.com/IEvangelist/learning-blazor.git
提示

此命令将克隆存储库到名为learning-blazor的新目录中。新目录是从执行此命令的根目录开始的。有关克隆存储库的更多信息,请参阅 Git 的git clone文档

克隆仓库后,您可以在您喜欢的 IDE 中打开解决方案文件或根目录。您可以在开始阅读书籍之前本地运行应用程序以便进行探索。您需要阅读入门 Markdown 文件。

或者,您可以访问实时站点以探索其功能。使用您喜欢的 Web 浏览器导航到https://webassemblyof.net。如果您有 Twitter、Google 或 GitHub 帐户,您可以登录到站点并探索应用程序。如果您没有这些帐户之一,或者您不愿意使用它们登录,您可以注册一个帐户。唯一的要求是您提供一个可以验证的有效电子邮件地址。将发送验证电子邮件到您提供的地址,并创建一个在登录时使用的密码。在下一节中,您将了解如何对这些代码进行版本控制。

为了代码的持续存在,我们需要版本控制。我们的 Blazor 应用可以使用 GitHub Actions 构建、测试、分析、源代码生成、打包和部署我们需要的任何内容。GitHub Actions 在第五章和第九章中更详细地探讨。GitHub Actions 每月免费提供高达 2,000 分钟和 500 MB 的存储空间。GitHub Actions 的创建非常有趣,对于自动化流程非常强大。通过 GitHub Action Marketplace,您可以发现发布的操作,可以在工作流中使用。GitHub Action 工作流定义为包含运行组合 GitHub Actions 的指令的 YAML 文件。例如,每当代码推送到我的 GitHub 仓库的main分支时,将触发构建验证。构建验证定义在名为.github/workflows/build-validation.yml的 YAML 文件中:

name: Build Validation

on:
  push:
    branches: [ main ]
    paths-ignore:
    - '**.md'
  pull_request:
    types: [opened, synchronize, reopened, closed]
    branches:
      - main  # only ran on the main branch

env:
  TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
  TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

jobs:
  build:
    name: build
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2

    - name: Setup .NET 6.0
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 6.0.x

    - name: Install dependencies
      run: dotnet restore

    - name: Build
      run: |
        dotnet build --configuration Release --no-restore

    - uses: actions/setup-node@v1
      name: 'Setup Node'
      with:
        node-version: 18
        cache: 'npm'
        cache-dependency-path: subdir/package-lock.json

    - name: 'Install Playwright browser dependencies'
      run: |
        npx playwright install-deps

    - name: Test
      run: |
        dotnet test --no-restore --verbosity normal

从持续集成和持续部署(CI/CD)的角度来看,这非常强大。

上述 GitHub 工作流具有以下特点:

  • name为“Build”。

  • 它在将任何文件更改集中的文件以.cs.css.json.razor.csproj结尾时触发main分支上的push事件。

  • 它定义了一个单一的build作业,运行在最新版本的 Ubuntu 上。build作业定义了几个steps

    • 检查触发运行的特定提交的存储库。

    • 在执行环境的上下文中设置.NET 6.0。

    • 通过dotnet restore安装依赖项。

    • 使用dotnet build编译代码。

    • 使用dotnet test测试代码。

能看到一个简单的 Blazor 应用程序运行是很酷的,但如果我告诉你,你可以使用Telerik Blazor REPL来更深入地了解 Blazor,你会怎么想呢?Blazor REPL(读取-求值-打印-循环)是一个在线程序,允许你在浏览器中编写 Blazor 代码并立即编译和运行它。这是学习 Blazor 的一个很好的方式,因为它提供了一个交互式的方式来探索代码,并加快了快速开发的反馈循环。

这只是应用程序 GitHub 存储库中的几个示例之一。作为与示例应用程序同步的开发人员,了解涉及的所有移动部件非常重要。您将了解源代码的所有内容。在此过程中,您还将学习代码如何部署和托管以及数据流的一般流程。接下来,我们将高层次地概述应用程序的架构。

浏览“学习 Blazor”示例应用程序

在本书中,我们将与学习 Blazor 模型应用程序一起工作。学习的最佳方法是亲自动手并实际操作。该应用程序将通过提供解决各种问题的示例来进行教学。学习 Blazor 模型应用程序利用微服务架构。如果没有某种有意义或实际数据,该应用程序将毫无吸引力。虽然讨论所有尖端技术非常令人兴奋,但如果示例源代码缺乏现实世界的吸引力,则远不及此。

正如我所说的,我们将在接下来的章节中逐个项目进行介绍,但是让我们先大致了解一下这些项目是如何运作和组合在一起的。这也应该让你对 Blazor 的各种可能有个概念,并激发你编写自己的应用程序。

如图 1-4 所示,该应用程序的架构设计如下:所有客户端必须通过认证提供程序请求访问所有 API。一旦认证成功,客户端就可以访问 Web.Api 和 Web.PwnedApi。这些 API 依赖于其他服务和 API,如 Twitter、ASP.NET Core SignalR、逻辑应用和内存缓存。它们都是共享资源组的一部分,还包括 Azure 静态 Web 应用程序。作为开发人员,当您将更改推送到 GitHub 存储库时,将有条件触发各种 GitHub Actions,这些操作将最新代码部署到相应的 Azure 资源中。有关各项目的更多信息,请参阅附录。示例应用程序针对 .NET 6 并使用 C# 10。

lblz 0104

图 1-4. 架构图

摘要

我们在本章涵盖了大量内容。我们讨论了 Blazor 和.NET Web 应用开发的起源。从语言的角度来看,我们比较了 JavaScript 单页应用和.NET 的差异。我解释了为什么你会选择 Blazor 而不是其他任何单页应用。你从模板创建了你的第一个 Blazor 应用,并且介绍了本书中 Learning Blazor 模型应用的整体架构。在下一章中,我们将深入探讨该应用的源代码,并开始讨论 Blazor 应用的启动过程。

¹ “Stack Overflow Developer Survey 2021,” Stack Overflow, https://oreil.ly/bngvt.

第二章:执行应用程序

在本章中,你将学习 Blazor WebAssembly 应用程序的启动执行过程——从静态 HTML 的渲染到调用引导 Blazor 的 JavaScript,你将探索应用程序的解剖结构。这包括 Program 入口点和启动约定。你将了解路由器、客户端导航、共享组件和布局。你还将学习应用程序中的顶级导航和自定义组件。所有这些都将通过 Learning Blazor 示例应用程序的源代码进行讲解。

尝试拥抱作为新开发者加入现有应用程序的心态——就像在现实世界中一样。设想你正在开始一个新的旅程,在这个旅程中,你将迅速了解现有代码库。我的角色是你的导师;我将仔细地遍历代码,向你展示并解释它正在做什么及如何做到这一点。你将了解到为什么做出了某些决策以及应该考虑哪些替代方法。你应该掌握这个模型应用程序的工作原理,并准备在未来的章节中使用它。

在上一章中,你学到了一些关于 Web 应用开发平台 ASP.NET Core 的知识,作为一个框架、开源开发、Web 的编程语言以及开发环境。现在让我们来谈谈代码。正如 Linux 的创建者 Linus Torvalds 所说,“谈话很廉价。给我看代码。”这个模型应用程序是整本书的基础,你将学习 Blazor 的所有主要功能以及如何使用其他令人惊叹的特性。我们将一起查看代码,你将阅读代码并让它告诉你它自己的故事。在接下来的几节中,你将学习 Blazor 框架如何初始化应用程序以及应用程序如何开始执行。建议你访问 https://webassemblyof.net 查看最终的 Web 应用程序是什么样子。随意点击并尝试各种功能,以熟悉应用程序。

请求初始页面

让我们从评估客户端浏览器访问我们应用程序时发生的情况开始。它请求初始页面(根据其 URL),服务器返回 HTML。在 HTML 中,有 <link><script> 元素。这些定义了我们的 Blazor 应用程序启动时需要的额外资源的引用。这些资源包括但不限于 CSS、JavaScript、图像、Wasm 文件和 .NET 动态链接库(.dll 文件)。这些额外的资源会作为初始页面加载的一部分被请求,这时无法与应用程序进行交互。根据外围资源的大小和客户端的连接速度,应用程序变得交互的时间会有所不同。

交互时间(TTI)是网站准备接受用户输入之前所需的时间的测量。使用 Blazor WebAssembly 的一个权衡是应用程序的初始加载时间比 Blazor Server 的要长一些。应用程序必须在运行之前下载到浏览器中,而在 Blazor Server 中,应用程序是动态呈现在 Web 主机上的。这需要 .NET 运行时和配置的 Web 服务器。

提示

使用 Blazor WebAssembly 的一个优势是应用可以作为静态 Web 应用程序托管。提供静态文件比提供动态内容要快得多,也更不容易出错。但这是有代价的。应用将被下载到客户端浏览器,并且客户端浏览器将不得不下载整个应用。这可能是一个很大的下载量,并且可能比在服务器上运行的应用慢一些。

Blazor WebAssembly 的 TTI 可能比 Blazor Server 的要长一些。假设,如果 TTI 超过几秒钟,用户会期望某种视觉指示,比如一个动画旋转的齿轮来显示应用正在加载。

使用 Blazor WebAssembly,您可以延迟加载完整的 .NET 程序集。这很像在 JavaScript 中做等效的事情——各种组件由 JavaScript 表示——但我们可以使用 C#。这个功能可以通过仅在需要时按需获取依赖程序集来使您的应用程序更有效。然而,在向您展示如何延迟加载程序集之前,您将学习 Blazor WebAssembly 应用程序启动如何加载程序集。

让我们从检查初始页面的 HTML 内容的部分开始。

应用程序启动和引导

以下 HTML 被提供给客户端,了解客户端浏览器在呈现时会做什么很重要。让我们跳进来看看 Web.Client 项目中的 wwwroot/index.html 文件。我知道这很多,但先阅读一遍,然后我们会逐步解释它:

<!DOCTYPE html>
<html class="has-navbar-fixed-top">

<head>
    <meta charset="utf-8" />
    <meta name="viewport"
        content="
 width=device-width, initial-scale=1.0,
 maximum-scale=1.0, user-scalable=no" />

    <title>Learning Blazor</title>

    <link href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"
          rel="stylesheet">

    <!-- Bulma: micro extensions -->
    <link href="https://cdn.jsdelivr.net/npm/
 bulma-slider@2.0.4/dist/css/bulma-slider.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link href="https://cdn.jsdelivr.net/npm/
 bulma-quickview@2.0.0/dist/css/bulma-quickview.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link href="https://cdn.jsdelivr.net/npm/
 @creativebulma/bulma-tooltip@1.2.0/dist/bulma-tooltip.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link href="https://cdn.jsdelivr.net/npm/
 bulma-badge@3.0.1/dist/css/bulma-badge.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link href="https://cdn.jsdelivr.net/npm/
 @creativebulma/bulma-badge@1.0.1/dist/bulma-badge.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link type="text/css" href="https://unpkg.com/bulma-prefers-dark"
          rel="preload" as="style" onload="this.rel='stylesheet'">

    <link href="/css/app.css" rel="stylesheet" />
    <link href="Web.Client.styles.css" rel="stylesheet" />
    <link href="/_content/Web.TwitterComponents/twitter-component.css"
          rel="stylesheet" />

    <link rel="manifest" href="/manifest.json" />
    <link rel="apple-touch-icon" sizes="512x512" href="/icon-512.png" />
    <link rel="apple-touch-icon" sizes="192x192" href="/icon-192.png" />
    <link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="/icon-16.png">

    <base href="/" />

    <script src="https://kit.fontawesome.com/b5bcf1e25a.js"
            crossorigin="anonymous"></script>
    <script src="/js/app.js"></script>
</head>

<body>
    <div id="app">
        <section id="splash" class="hero is-fullheight-with-navbar">
            <div class="hero-body">
                <div class="container has-text-centered">
                    <img src="media/blazor-logo.png"
                         class="blazor-logo mb-5" />
                    <div class="fa-3x is-family-code">
                        <span class="has-text-weight-bold">
                        Blazor WebAssembly:</span> Loading...
                        <i class="fas fa-sync fa-spin"></i>
                    </div>
                </div>
            </div>
        </section>
    </div>

    <div id="blazor-error-ui">
        <div class="modal is-active">
            <div class="modal-background"></div>
            <div class="modal-content">
                <article class="message is-warning is-medium">
                    <div class="message-header">
                        <p>
                            <span class="icon">
                                <i class="fas fa-exclamation-circle"></i>
                            </span>
                            <span>Error</span>
                        </p>
                    </div>
                    <div class="message-body">
                        An unhandled error has occurred.
                        <button class="button is-danger is-pulled-right"
                                onClick="
 window.location.assign(window.location.origin)">
                            <span class="icon">
                                <i class="fas fa-redo"></i>
                            </span>
                            <span>Reload</span>
                        </button>
                    </div>
                </article>
            </div>
            <button class="modal-close is-large" aria-label="close"></button>
        </div>
    </div>

    <script src="/_content/Microsoft.Authentication.WebAssembly.Msal/
 AuthenticationService.js"></script>
    <script src="/_framework/blazor.webassembly.js"></script>
    <script>navigator.serviceWorker.register('service-worker.js');</script>
</body>

</html>

让我们逐个解释每个主要部分。我们将从阅读<head>标签的子元素开始:

<head>
    <meta charset="utf-8" />
    <meta name="viewport"
        content="
 width=device-width, initial-scale=1.0,
 maximum-scale=1.0, user-scalable=no" />

    <title>Learning Blazor</title>

    <link href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"
          rel="stylesheet">

    <!-- Bulma: micro extensions -->
    <link href="https://cdn.jsdelivr.net/npm/
 bulma-slider@2.0.4/dist/css/bulma-slider.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link href="https://cdn.jsdelivr.net/npm/
 bulma-quickview@2.0.0/dist/css/bulma-quickview.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link href="https://cdn.jsdelivr.net/npm/
 @creativebulma/bulma-tooltip@1.2.0/dist/bulma-tooltip.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link href="https://cdn.jsdelivr.net/npm/
 bulma-badge@3.0.1/dist/css/bulma-badge.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link href="https://cdn.jsdelivr.net/npm/
 @creativebulma/bulma-badge@1.0.1/dist/bulma-badge.min.css"
          rel="preload" as="style" onload="this.rel='stylesheet'">
    <link type="text/css" href="https://unpkg.com/bulma-prefers-dark"
          rel="preload" as="style" onload="this.rel='stylesheet'">

    <link href="/css/app.css" rel="stylesheet" />
    <link href="Web.Client.styles.css" rel="stylesheet" />
    <link href="/_content/Web.TwitterComponents/twitter-component.css"
          rel="stylesheet" />

    <link rel="manifest" href="/manifest.json" />
    <link rel="apple-touch-icon" sizes="512x512" href="/icon-512.png" />
    <link rel="apple-touch-icon" sizes="192x192" href="/icon-192.png" />
    <link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="/icon-16.png">

    <base href="/" />

    <script src="https://kit.fontawesome.com/b5bcf1e25a.js"
            crossorigin="anonymous"></script>
    <script src="/js/app.js"></script>
</head>

应用程序使用 Web 标准 UTF-8 字符集,并且还有一个viewport规范,这两者都是 HTML 中非常常见的<meta>标签。我们将页面的初始<title>设置为"学习 Blazor"。标题后是一组<link>元素。如果您花时间评估模板中默认的 Bootstrap CSS 的替代选项,您可能会考虑一个不依赖 JavaScript 的 CSS 框架。

在这种情况下,选择了 Bulma 作为 CSS 框架,因为它非常简单且干净。这与 Blazor 完美匹配,因为我们可以使用 C# 而不是 JavaScript 随意更改样式。正如 Bulma 文档 中所述,“Bulma 是一个 CSS 库。这意味着它提供了 CSS 类来帮助您样式化 HTML 代码。要使用 Bulma,您可以使用预编译的 .css 文件或安装 .sass 文件,以便根据需要自定义。” Bulma 提供了所有需要的内容来样式化网站;考虑到可扩展性,它具有现代实用程序、助手、元素、组件、表单和布局样式。Bulma 还拥有庞大的开发者社区追随者,分享扩展内容。这些额外的 CSS 包依赖于 Bulma 本身;它们只是覆盖或扩展现有的类定义。这与任何 Web 应用开发中的方法相同,不仅适用于 Blazor。

当我们看到 <link> 元素的 rel 属性设置为 "preload" 时,表示这些请求将异步进行。这是通过添加 as="style" onload="this.rel='stylesheet'" 属性实现的。这让浏览器知道 <link> 是用于样式表的。它最终会加载资源,并在加载时将 rel 设置为 "stylesheet"。我们可以将其视为 加载时热替换策略。我们将引入一些额外的 CSS 引用,用于滑块、快速视图、工具提示以及媒体查询中心的 @media (prefers-color-scheme: dark) { /* styles */ } 功能。这暴露了检测客户端首选颜色方案并应用适当样式的能力。例如,与默认的 white 相比,另一种颜色方案是 dark。这两种颜色方案涵盖了大多数 Web 用户体验。

然后,我们定义另一个 <link>,其 href 是到 Web 服务器上 /css/app.css 路径的。

Bulma 中的重要样式没有使用 加载时热替换策略。在应用程序加载时,它会适当地进行样式设置,以传达应用程序正在工作的信息(见 图 2-1) 。应用程序还预先声明了 <link rel="manifest" href="/​man⁠ifest.json" />,并附带相应的 <link> 图标。这是为了显露图标和 PWA 的能力。根据 MDN 的 HTML 参考指南,“HTML <base> 元素指定文档中所有相对 URL 使用的基础 URL。文档中只能有一个 <base> 元素。”

在可能的情况下,所有应用程序都应考虑使用图标来提供更易访问的网络体验。正确使用图标可以立即传达信息和意图,通常只需很少的文本。我自豪地使用 Font Awesome;它们提供免费的服务,并可以在 Blazor 标记中无缝集成。一个<script>指向我 Font Awesome 的套件已经注册到我的应用。紧随 Font Awesome 源之后的下一行是应用程序的 JavaScript 部分。Web 应用程序开发的三个主要关注点分别位于 /js/css/_content 目录中。在熟悉了 <head> 节点的子元素之后,我们可以继续。接下来,我们将查看 <body> 节点的内容:

<body>
    <div id="app">
        <section id="splash" class="hero is-fullheight-with-navbar">
            <div class="hero-body">
                <div class="container has-text-centered">
                    <img src="media/blazor-logo.png"
                         class="blazor-logo mb-5" />
                    <div class="fa-3x is-family-code">
                        <span class="has-text-weight-bold">
                        Blazor WebAssembly:</span> Loading...
                        <i class="fas fa-sync fa-spin"></i>
                    </div>
                </div>
            </div>
        </section>
    </div>

    <div id="blazor-error-ui">
        <div class="modal is-active">
            <div class="modal-background"></div>
            <div class="modal-content">
                <article class="message is-warning is-medium">
                    <div class="message-header">
                        <p>
                            <span class="icon">
                                <i class="fas fa-exclamation-circle"></i>
                            </span>
                            <span>Error</span>
                        </p>
                    </div>
                    <div class="message-body">
                        An unhandled error has occurred.
                        <button class="button is-danger is-pulled-right"
                                onClick="
 window.location.assign(window.location.origin)">
                            <span class="icon">
                                <i class="fas fa-redo"></i>
                            </span>
                            <span>Reload</span>
                        </button>
                    </div>
                </article>
            </div>
            <button class="modal-close is-large" aria-label="close"></button>
        </div>
    </div>

    <script src="/_content/Microsoft.Authentication.WebAssembly.Msal/
 AuthenticationService.js"></script>
    <script src="/_framework/blazor.webassembly.js"></script>
    <script>navigator.serviceWorker.register('service-worker.js');</script>
</body>

<body> 元素中的第一个标签是 <div id="app">...</div>。这是 Blazor 应用程序的根,真正的单页应用程序。非常重要的一点是理解,此目标元素的内容将自动和动态地更改以表示 Wasm 应用程序对 DOM 的操作。大多数单页应用程序开发者会让用户体验成为一个巨大的白色墙壁,使用默认字体大小为10pt,黑色文本显示“加载中……”。这种用户体验是不可接受的。理想情况下,我们希望向用户提供视觉提示,表明应用程序正在响应和加载中。一种方法是让一个 <div> 最初代表一个基本的闪屏屏幕。在这种情况下,模型应用程序将包括 Blazor 标志图像和一个消息,显示为"Blazor WebAssembly: Loading...",还会显示一个动画加载旋转图标。

<section id="splash">...</section> 作为加载标记的功能。当 Blazor 准备就绪时,它将被替换。这个标记不是 Blazor,而是 HTML 和 CSS。这个标记将类似于图 2-1 中显示的内容。如果没有这个标记,默认的加载体验将会是黑色文本并显示“加载中”。这使您能够自定义闪屏(或加载)屏幕的用户体验。

小贴士

在编写 Blazor 应用程序时,您应考虑向应用程序添加加载指示器。这是一种很好的方法,可以让用户感知到进度,并在应用程序首次加载时避免“白屏死机”的现象。

图 2-1. 指示器让用户知道应用程序正在加载中

index.html 文件中,跟随 app 节点之后,有一个“blazor-error-ui” <div> 元素。这与模板不同,适合我们应用程序的样式。当 Blazor 自己启动时,此特定元素标识符将被使用。如果有任何无法恢复的错误,它将显示此元素。如果一切顺利,您不应该看到此元素。

在错误元素之后是几个剩余的 <script> 标签。这些是我们引用的组件(如身份验证和 Twitter 组件库)的 JavaScript 引用。最后两个 <script> 标签非常重要:

<script src="/_framework/blazor.webassembly.js"></script>
<script>navigator.serviceWorker.register('service-worker.js');</script>

第一个 <script> 标签引用的 JavaScript(blazor.webassembly.js 文件)是启动 Blazor WebAssembly 执行的来源。没有这一行,此应用程序除了一个永远加载的页面外将不会渲染任何内容。这个 JavaScript 文件启动了 Blazor 的启动子例程,在这里 WebAssembly 生效,JavaScript 交互开始,乐趣开始!各种 .NET 可执行文件,即 *.dll 文件,被获取,并准备好 Mono 运行时。作为 Blazor 启动子例程的一部分,文档中的 app 元素被发现。应用程序的入口点被调用。这是 .NET 应用程序在 WebAssembly 上下文中开始执行的地方。

第二个 <script> 标签注册了应用程序的服务工作线程 JavaScript 代码。这使得我们的应用程序成为 PWA,这是一个很好的功能。这是一种使您的应用程序离线可用和服务工作线程功能的方式。有关 PWA 的更多信息,请参阅 Microsoft 的 “渐进式 Web 应用程序 (PWAs) 概述” 文档

Blazor WebAssembly 应用程序内部

每个应用程序都有一个必需的入口点。在 Web 客户端应用程序中,这是对托管应用程序的 Web 服务器的初始请求。当运行 _frame⁠work/​blazor.webassembly.js 文件时,它开始请求 .dll 文件,运行时启动 Blazor 应用程序的可执行文件。与大多数其他 .NET 应用程序一样,在 Blazor WebAssembly 中,Program.cs 是入口点。示例 2-1 是模型应用程序的 Web.Client 项目的 Program.cs C# 文件。

示例 2-1. Web.Client/Program.cs
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

if (builder.HostEnvironment.IsDevelopment())
{
    builder.Logging.SetMinimumLevel(LogLevel.Debug);
}

builder.ConfigureServices();

await using var host = builder.Build();

host.TrySetDefaultCulture();
await host.RunAsync();

Blazor 依赖于依赖注入作为其核心架构的一部分。

注意

依赖注入(DI)被定义为一个对象声明其他对象作为依赖项,并且一种机制将这些依赖项注入到依赖对象中。一个基本的例子是 ServiceOne 需要 ServiceTwo,并且服务提供者在给定 ServiceTwo 依赖项时实例化 ServiceOne。在这个构造的示例中,ServiceOneServiceTwo 都必须在服务提供者中注册。每当使用 ServiceOne 时,服务提供者会实例化 ServiceTwo 并将其作为依赖项传递给 ServiceOne

入口点简洁,并利用了 C# 的顶级程序语法,这需要更少的样板,如省略了 class Program 对象。我们从应用程序的 args 创建了一个默认的 WebAssemblyHostBuilderbuilder 实例添加了两个根组件:首先是 App 组件,配对了 #app 选择器,它将解析我们在之前讨论的 index.html 文件中的 <div id="app"></div> 元素。其次,我们在 <head> 内容之后添加了一个 HeadOutlet 组件。这个 Head​Out⁠let 是由 Blazor 提供的,它使得能够动态附加或更新 HTML 文档的 <meta> 标签或相关的 <head> 内容。

当应用程序在开发环境中运行时,最小的日志记录级别会适当地设置为调试。builder调用了ConfigureServices,这是一个封装了客户端应用程序所需的各种服务注册的扩展方法。已注册的服务包括以下内容:

ApiAccessAuthorizationMessageHandler

用于使用访问令牌授权出站 HTTP 请求的自定义处理程序

CultureService

一个中间的自定义服务,专门用于封装与客户端CultureInfo相关的常见逻辑

HttpClient

一个由框架提供的 HTTP 客户端,配置了两字母 ISO 语言名称作为默认请求头的文化服务

MsalAuthentication

框架提供的 Azure 企业对消费者 (B2C) 和 Microsoft 身份验证库 (MSAL),这些都是绑定并为应用程序的租户配置的

SharedHubConnection

一个自定义服务,将单个 SignalR HubConnection 与多个组件共享

AppInMemoryState

用于公开内存中应用程序状态的自定义服务

CoalescingStringLocalizer<T>

一个通用的自定义服务,利用了首先组件的本地化尝试,然后回退到共享的方法

GeoLocationService

用于查询给定经度和纬度的地理信息的自定义客户端服务

在所有服务注册之后,我们调用builder.Build(),这会返回一个WebAssemblyHost对象,这个类型实现了IAsyncDisposable接口。因此,我们要谨慎地使用await using这个host实例。这样异步使用host,并在不再需要时隐式地释放它。

在启动时检测客户端文化

你可能已经注意到host还使用了另一个扩展方法。host.TrySetDefaultCulture方法会尝试设置默认文化。在这个上下文中,cultureCultureInfo .NET 对象表示,并且充当浏览器的区域设置,例如 en-US。该扩展方法在 Web.Client 项目的 WebAssemblyHostExtensions.cs C# 文件中定义:

namespace Learning.Blazor.Extensions;

internal static class WebAssemblyHostExtensions
{
    internal static void TrySetDefaultCulture(this WebAssemblyHost host)
    {
        try
        {
            var localStorage =
                host.Services.GetRequiredService<ILocalStorageService>();
            var clientCulture =
                localStorage.GetItem<string>(StorageKeys.ClientCulture);
            clientCulture ??= "en-US";

            CultureInfo culture = new(clientCulture);
            CultureInfo.DefaultThreadCurrentCulture = culture;
            CultureInfo.DefaultThreadCurrentUICulture = culture;
        }
        catch (Exception ex) when (Debugger.IsAttached)
        {
            _ = ex;
            Debugger.Break();
        }
    }
}

host实例中,它的Services属性以IServiceProvider类型可用。这被暴露为host.Services,我们使用它来从 DI 容器中解析服务。这被称为服务定位器模式,因为服务是从提供程序手动定位的。

提示

你不需要在其他地方使用这种模式,因为 .NET 会处理这些事情。在“最佳实践”的精神下,你应该始终优先选择框架提供的 DI(或第三方)容器实现。我们只是在这里使用它,因为我们想要以特定文化加载应用程序,这需要早期开始。

我们在应用程序的其他地方不需要使用这种模式,因为框架将通过构造函数或属性注入自动解析我们需要的服务。我们首先调用 ILocalStorageService,这在 第七章 中有描述。然后,我们要求它检索与 StorageKeys.ClientCulture 键对应的 string 值。StorageKeys 是一个静态类,用于保持应用程序一致性的各种字面值、常量和逐字值。如果 clientCulture 值为 null,我们将分配一个合理的默认值 "en-US"

由于这些 culture 值来自客户端,我们不能信任它们 —— 这就是为什么我们用 try/catch 块来尝试创建 CultureInfo。最后,我们运行与上下文 host 实例相关联的应用程序。从这个入口点开始,App 组件是第一个启动的 Blazor 组件。

布局、共享组件和导航

App.razor 文件是所有 Blazor 组件中的第一个,它包含 <Router>,用于提供与导航状态对应的数据。考虑 Web.Client 项目中 App.razor 文件标记的以下内容:

<CascadingAuthenticationState>
    <Error>
        <Router AppAssembly="@typeof(App).Assembly" Context="routeData">
            <Found>
                <AuthorizeRouteView RouteData="@routeData"
                                    DefaultLayout="@typeof(MainLayout)">
                    <NotAuthorized>
                        @if (context.User?.Identity?.IsAuthenticated ?? false)
                        {
                            <p>
                            You are not authorized to access this resource.
                            </p>
                        }
                        else
                        {
                            <RedirectToLogin />
                        }
                    </NotAuthorized>
                </AuthorizeRouteView>
            </Found>
            <NotFound>
                <LayoutView Layout="@typeof(MainLayout)">
                    <NotFoundPage />
                </LayoutView>
            </NotFound>
        </Router>
    </Error>
</CascadingAuthenticationState>

这是应用程序本身的顶层 Blazor 组件,恰当地命名为 AppApp 组件是第一个被渲染的组件。它是应用程序的根组件,所有应用程序的子组件都在这个组件内部渲染。

Blazor 导航要点

让我们深入评估 App 组件的标记,并理解其各个部分。

<CascadingAuthenticationState> 组件是我们应用程序中最外层的组件。顾名思义,它将认证状态级联到感兴趣的子组件。

注意

通过组件层次结构传播状态的方法由于易用性和与相关模式(如 CSS 类似)的相似性而变得非常常见。同样的概念在操作系统层面也适用,比如轻量级目录访问协议(LDAP)权限系统。尝试在图形中考虑这个思路,因为这是软件中有图形/树状结构需要级联的常见模式。这就是级联状态背后的理念。

以一个祖先组件为例,可以定义一个 <CascadingValue> 组件,并赋予任意值。这个值可以沿着组件层次结构流向任意数量的后代组件。消费组件使用 CascadingParameter 属性从父组件接收这个值。随着我们继续探索应用程序,这个概念将会被更详细地覆盖。让我们继续下降到组件层次结构中。

第一个嵌套子组件是 Error 组件。它是一个自定义组件,定义在 Error.razor 文件中:

@inject ILogger<Error> Logger ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

<CascadingValue Value=this> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png) @ChildContent </CascadingValue> @code { ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png) [Parameter]
    public RenderFragment? ChildContent { get; set; } = null!; ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png) public void ProcessError(Exception ex) ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png) {
        Logger.LogError("Error:ProcessError - Type: {Type} Message: {Message}",
            ex.GetType(), ex.Message);
    }
}

1

@inject 语法是 Razor 指令。

2

Error 组件使用了 cascades

3

@code 指令是向组件添加 C# 类范围成员的一种方式。

4

ChildContent 属性是一个参数。

5

ProcessError 方法对所有消费组件都是可访问的。

作为 Blazor 开发的一部分,有几个常见的指令。这个特定的指令指示 Razor 视图引擎从服务提供程序 注入 ILogger<Error> 服务。这是 Blazor 组件使用 DI 的方式,通过属性注入而不是构造函数注入。

<CascadingValue> 标记包括 @Child​Con⁠tent 的模板渲染。ChildContent 同时是 [Parameter]RenderFragment。这允许 Error 组件呈现任何子内容,包括 Blazor 组件。在模板化组件的单个 RenderFragment 定义时,其子内容可以表示为纯 Razor 标记。

RenderFragment 是一个返回 voiddelegate 类型,接受 RenderTreeBuilder。它表示 UI 内容的一个片段。RenderTreeBuilder 类型是一个低级 Blazor 类,公开了用于构建 DOM 的 C# 表示的方法。

<CascadingValue> 是一个 Blazor(或框架提供的)组件,为所有后代组件提供级联值。CascadingValue.Value 被分配为 this,即 Error 组件实例本身。这意味着所有后代组件如果选择消耗它,将能够访问 ProcessError 方法。后代组件需要定义一个 [CascadingParameter] 属性,类型为 Error,才能使其流入其中。

Parameter 属性由 Blazor 提供,用于表示组件的属性是参数。这些作为绑定目标从消费组件作为 Razor 标记中的属性分配可用。

ProcessError 方法期望一个 Exception 实例,用于记录为错误。Error 组件的子内容是 RouterRouter 组件是我们 SPA 的客户端路由的实现,这意味着路由发生在客户端,页面不需要刷新。

路由器

Router是框架提供的组件,用于src/Web.Client/App.razor文件。它指定了一个AppAssembly参数,按照惯例将其赋给typeof(App).Assembly。此外,Context参数允许我们指定参数的名称。我们分配了routeData的名称,覆盖了默认名称contextRouter还定义了多个命名的RenderFragment组件;因为有多个,我们必须明确指定子内容。这就是对应的RenderFragment名称的用处。例如,当路由器无法找到匹配的路由时,我们定义页面应该渲染NotFound内容。考虑以下Router标记中的NotFound内容部分:

<NotFound>
    <LayoutView Layout="@typeof(MainLayout)">
        <NotFoundPage />
    </LayoutView>
</NotFound>

此布局基于MainLayout组件,并将其子项设置为NotFoundPage组件。假设用户导航到不存在的路由,他们将会进入我们自定义的 HTTP 404 页面,该页面与我们的应用程序一致地本地化和样式化。我们将在下一节处理 HTTP 状态码 401。但是,当路由器确实匹配预期的路由时,将渲染Found内容。考虑以下Router标记中的Found内容部分:

<Found>
    <AuthorizeRouteView RouteData="@routeData"
        DefaultLayout="@typeof(MainLayout)">
        <NotAuthorized>
            @if (context.User?.Identity?.IsAuthenticated ?? false)
            {
                <p>HTTP 401</p>
            }
            else
            {
                <RedirectToLogin />
            }
        </NotAuthorized>
    </AuthorizeRouteView>
</Found>

未经授权时重定向到登录页

如果您还记得之前的内容,Found内容只是一个RenderFragment。在这种情况下,子内容是AuthorizeRouteView组件。此路由视图仅在用户被授权查看时显示。它遵循MainLayout作为其默认值。RouteData从上下文的routeData分配。路由数据本身定义了路由器将渲染的组件以及来自路由的相应参数。

当用户未经授权时,我们使用RedirectToLogin组件将其重定向到登录屏幕:

@inject NavigationManager Navigation ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

@code {
    protected override void OnInitialized() ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        string returnUrl = Uri.EscapeDataString(Navigation.Uri);
        Navigation.NavigateTo(
            $"authentication/login?returnUrl={returnUrl}");
    }
}

1

RedirectToLogin组件需要NavigationManager

2

OnInitialized方法导航到身份验证登录页面。

RedirectToLogin组件注入了NavigationManager,并且在初始化时导航到authentication/login路由,带有转义的returnUrl查询字符串。当用户被授权时,路由视图将渲染MainLayout,它是 Blazor 的LayoutComponentBase的子类。虽然标记定义了应用程序的所有布局,但它也在适当的位置打散@Body。这是另一个从LayoutComponentBase继承的RenderFragment。正如用户在站点上导航时,路由器动态更新 DOM,渲染 Blazor 组件在@Body段内。

我们 overrideOnInitialized 方法。这是我们首次查看重写 ComponentBase 类功能,但在 Blazor 中非常常见。ComponentBase 类中有几个 virtual 方法(可以重写的方法),大多数代表组件生命周期的不同阶段。

Blazor 组件生命周期

继续从上述 RedirectToLogin.razor. 文件开始,存在一个名为 OnInitializedoverride 方法。这个方法是组件生命周期中特定点之一的几个生命周期方法之一。Blazor 组件继承了 Microsoft.AspNetCore.Components.ComponentBase 类。请参考 Table 2-1 以供参考。

表 2-1. ComponentBase 生命周期方法

顺序 方法名 描述
1 SetParametersAsync 设置组件在渲染树中由其父组件提供的参数
2 OnInitialized OnInitializedAsync 当组件准备就绪时调用的方法,从其父组件在渲染树中接收到其初始参数
3 OnParametersSet OnParametersSetAsync 当组件从其父组件接收到渲染树中的参数并且传入值已分配给属性时调用的方法
4 OnAfterRender OnAfterRenderAsync 在每次组件渲染后调用的方法

MainLayout 组件

MainLayout.razor 文件,顾名思义,表示主要布局。在此标记中,导航栏(navbar)、头部、底部和内容区域都被组织和结构化了:

@inherits LayoutComponentBase ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png) @inject IStringLocalizer<MainLayout> Localizer <section class="hero is-fullheight-with-navbar"> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    <div class="hero-head">
        <header class="navbar is-size-5 is-fixed-top">
            <div class="container">
                <div class="navbar-brand">
                    <NavLink class="navbar-item" href="" ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
                             Match="NavLinkMatch.All">
                        <span class="pr-2">
                            <img src="media/blazor-logo.png"
                                 height="128" alt="Logo">
                        </span>
                        <span>@Localizer["Home"]</span>
                    </NavLink>

                    <a role="button" class="navbar-burger" aria-label="menu"
                       aria-expanded="false" data-target="navbar">
                        <span aria-hidden="true"></span>
                        <span aria-hidden="true"></span>
                        <span aria-hidden="true"></span>
                    </a>
                </div>
                <div id="navbar" class="navbar-menu"> ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
                    <div class="navbar-start">
                        <AuthorizeView>
                            <Authorized>
                                <NavBar />
                            </Authorized>
                        </AuthorizeView>
                    </div>
                    <div class="navbar-end">
                        <AuthorizeView>
                            <Authorized>
                                <ThemeIndicatorComponent />
                                <AudioDescriptionComponent />
                                <LanguageSelectionComponent />
                                <NotificationComponent />
                            </Authorized>
                        </AuthorizeView>
                        <LoginDisplay />
                    </div>
                </div>
            </div>
        </header>
    </div>

    <div class="hero-body">
        <div class="container has-text-centered is-fluid mx-5"> @Body ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        </div>
    </div>

    <footer class="footer" style="padding-bottom: 4rem;"> ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
        <PageFooter />
    </footer>
</section>

1

此布局 Razor 文件的前两行是两个 C# 表达式,由其前导 @ 符号指示。

2

<section> 是一个原生 HTML 元素,它在 Razor 语法中是完全有效的。

3

<section> 元素的语义头部和导航栏中,引用了 <NavLink>

4

下一个导航栏部分是由自定义 NavBar 组件构建而成。

5

在 DOM 的中心定义了 @Body 渲染片段。

6

原生 HTML footer 元素是自定义 PageFooter 组件的父元素,负责渲染页面底部。

这两个指令代表了此组件内部所需的各种行为。两者中的第一个是@inherits指令,它指示组件从LayoutComponentBase类继承。这意味着它是框架的LayoutComponentBase类的子类。此布局基类实现了IComponent并公开了一个Body渲染片段。这使得我们可以将内容设置为应用程序的Router输出的任何内容。主要布局组件使用@inject指令请求服务提供者来解析IString​Lo⁠calizer<MainLayout>,并将其分配给一个名为Localizer的组件可访问成员。我们将在第五章中详细讨论本地化。

<section>是一个原生 HTML 元素,它是完全有效的 Razor 语法。注意我们如何可以无缝地在 C#和 HTML 之间进行过渡。我们定义了一些标准的 HTML,带有一些语义化的标记。你对 HTML 和 CSS 有所熟悉,我们不会过多强调这一点。因为这是一个如此大的项目,我们很可能会由我们想象中的 UX 部门提供这些 HTML 和 CSS。

<section>元素的语义化标题和导航栏中,引用了<NavLink>。这是一个由框架提供的组件。NavLink组件用于暴露组件逻辑的用户交互部分。它处理 Blazor 应用程序的路由,并依赖于浏览器 URL 栏中的值。这代表了应用程序的“主页”导航路由,并带有 Blazor 标志。

导航栏的下一个部分是使用自定义的NavBar组件构建的。这里有一些熟悉的保护性标记,当AuthorizerView有授权内容在浏览器中渲染时,应用程序才会显示它。前面提到的组件要么左对齐要么居中,而下一个组件则分组并推到导航栏的末尾或最右侧。这组件组合的右侧是一个LoginDisplay。让我们深入了解LoginDisplay组件(另请参见“理解 LoginDisplay 组件”)。这些元素组是主题感知的,这意味着它将以两种方式之一进行渲染,即dark主题或light主题(请参见“头部和尾部组件”的视觉示例)。

@Body渲染片段被定义在 DOM 的中心位置。@Body是 Blazor 导航的主要组成部分,也是路由器的输出目标。换句话说,当用户导航时,客户端路由将 HTML 渲染在@Body占位符内。

自定义的 NavBar 组件

诚然,从该布局组件标记中吸取的信息很多,但当您花时间逐个理解每个部分时,它将会变得清晰明了。其中有几个自定义组件,其中之一是<NavBar />。它引用了NavBar.razor文件:

@inherits LocalizableComponentBase<NavBar> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

<NavLink class="navbar-item" href="/chat" Match="NavLinkMatch.Prefix"> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    <span class="icon pr-2">
        <i class="chat fas fa-comments"></i>
    </span>
    <span>@Localizer["Chat"]</span>
</NavLink>
<NavLink class="navbar-item" href="/tweets" Match="NavLinkMatch.Prefix"> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    <span class="icon pr-2">
        <i class="twitter fab fa-twitter"></i>
    </span>
    <span>@Localizer["Tweets"]</span>
</NavLink>
<NavLink class="navbar-item" href="/pwned" Match="NavLinkMatch.Prefix"> ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    <span class="icon pr-2">
        <i class="pwned fas fa-user-shield"></i>
    </span>
    <span translate="no">Pwned?</span>
</NavLink>

1

继承自 LocalizableComponentBase 以利用基本功能。

2

<NavLink> 组件由框架提供,并与路由器一起使用。

3

第二个路由是用于推文,对应 /tweets 路由。

4

第三个路由是用于 Pwned?对应 /pwned 路由。

像大多数自定义组件一样,这个组件也继承自 LocalizableComponentBase 以利用基本功能。基本功能在 第五章 中有详细描述。框架提供的 <NavLink> 组件与路由器一起工作。第一个路由是聊天室,对应 /chat 路由。虽然之前的每个路由名称都是使用 @Localizer 索引器检索的,但“Pwned?”路由不是,因为它是一个品牌名。

标题和页脚组件

应用程序的标题包含指向主页、聊天、推文、Pwned 和联系页面的链接。这些都是 Router 将识别的可导航路由。右侧的图标用于主题、音频描述、语言选择、任务列表、通知和登出。登出功能依赖于应用程序的导航以导航到路由,但其他按钮可以视为实用按钮。它们打开用于全局功能的模态框并公开用户首选项。标题本身支持 darklight 主题,如图 2-2 和 2-3 所示。

图 2-2. 具有 dark 主题的示例导航标题

图 2-3. 具有 light 主题的示例导航标题

让我们首先看看定义在 PageFooter.razor 文件中的 PageFooter 组件:

@inherits LocalizableComponentBase<PageFooter> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

<div class="columns has-text-centered">
    <p class="column"> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        <strong translate="no"> Learning Blazor </strong> by <a href="@DavidPineUrl" target="_blank"> David Pine. </a>
    </p>
    <p class="column"> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png) The <a href="@CodeUrl" target="_blank">
            <i class="fab fa-github"></i> source code </a> is licensed <a href="@LicenseUrl"> MIT. </a>
    </p>
    <p class="column"> ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        <a href="/privacy">@Localizer["Privacy"]</a> &bull;
        <a href="/termsandconditions">@Localizer["Terms"]</a>
    </p>
    <p class="column"> ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png) @_frameworkDescription </p>
</div>

1

组件继承自 LocalizableComponentBase 类。

2

第一列读取 "Learning Blazor by David Pine"

3

在第二列,有两个链接:一个是源代码的 MIT 许可证,另一个是 GitHub 源代码链接。

4

第三列包含 隐私条款和条件 页面的链接。

5

最后一列包含客户端浏览器正在运行的 .NET 运行时版本。

我们正在建立一种模式,通过使自定义组件从LocalizableComponentBase通用基类继承。自定义的PageFooter组件通过定义一个四列布局并居中文本来编写。从左到右从第一列开始,显示应用程序名称和一个标题为"Learning Blazor by David Pine"的非可翻译粗体短语。第二列链接到源代码的 MIT 许可证和 GitHub 源代码链接。第三列包含隐私条款与条件页面的链接,并进行了本地化处理。Blazor 应用程序的本地化在第五章中有详细介绍。.NET 运行时版本非常有用,因为它立即告诉开发人员正在使用的框架版本。

多数情况下,我更喜欢我的 Razor 标记文件配有代码后端文件。这样,分离的文件有助于在 Razor 中存在标记和在 C#中存在逻辑的问题。对于简单组件、具有少量参数和标记元素的组件,仅在 Razor 文件中使用@code指令也是可以的。但是在使用代码后端方法时,您可以将其视为组件阴影,因为组件的标记被 Visual Studio 编辑器中的 C#文件所遮蔽,如图 2-4 所示。

组件阴影是指创建一个与现有 Razor 文件同名但附加.cs文件扩展名的 C#文件。例如,PageFooter.razorPageFooter.razor.cs文件就展示了组件阴影,因为它们在 Visual Studio 编辑器中嵌套,并且共同代表public partial PageFooter class

图 2-4. Visual Studio 解决方案资源管理器中的组件阴影

考虑PageFooter.razor.cs组件阴影文件:

namespace Learning.Blazor.Shared
{
    public partial class PageFooter
    {
        const string CodeUrl = ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
            "https://github.com/IEvangelist/learning-blazor";
        const string LicenseUrl =
            "https://github.com/IEvangelist/learning-blazor/blob/main/LICENSE";
        const string DavidPineUrl =
            "https://davidpine.net";

        private string? _frameworkDescription;

        protected override void OnInitialized() => ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            _frameworkDescription = AppState.FrameworkDescription;
    }
}

1

定义了几个常量。

2

OnInitialized生命周期方法分配框架描述。

定义了几个包含 URL 文本的const string字段。这些字段用于与 Razor 标记绑定。我们overrideOnInitialized生命周期方法,并从继承的LocalizableComponentBase.AppState变量分配了_frameDescription值。

组件还能够根据客户端浏览器偏好的lightdark主题进行自动适配。例如,请参见 2-5 和 2-6 图。

图 2-5. 具有dark主题的示例页脚

图 2-6. 具有light主题的示例页脚

页脚并不追求过多,而是故意保持简单,仅提供一些应用程序相关信息的链接。

MainLayout组件不仅仅是 Razor 标记;它还具有一个带有阴影的组件。考虑MainLayout.razor.cs文件:

using System.Runtime.InteropServices;
using Learning.Blazor.Services;
using Microsoft.AspNetCore.Components;

namespace Learning.Blazor.Shared
{
    public sealed partial class MainLayout : IDisposable ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
 [Inject]
        public AppInMemoryState? AppState { get; set; } ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)

        protected override void OnInitialized() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        {
            if (AppState is not null)
            {
                AppState.StateChanged += StateHasChanged;
                AppState.FrameworkDescription =
                    RuntimeInformation.FrameworkDescription;
            }

            base.OnInitialized();
        }

        void IDisposable.Dispose() ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        {
            if (AppState is not null)
            {
                AppState.StateChanged -= StateHasChanged;
            }
        }
    }
}

1

MainLayout 是一个 sealed partial class

2

AppInMemoryState 实例被注入到组件中。

3

OnInitialized 方法被重写,以允许订阅 AppInMemoryState.StateChanged 事件。

4

Dispose 方法取消订阅 AppInMemoryState.StateChanged 事件。

您会注意到 MainLayout 是一个 sealed partial class;它是 partial 的,以便作为 Razor 标记的代码后台,它是 sealed 的,因此不能被其他组件继承。它实现了 IDisposable 接口以执行必要的清理工作。让我们确保我们遵循 组件阴影组件继承 的概念。

AppInMemoryState 实例被注入到组件中。此应用状态对象仅存在于内存中;如果用户刷新页面,则状态将丢失。

OnInitialized 方法从基类重写,并用于订阅 AppInMemoryState.StateChanged 事件。事件处理程序是框架提供的 ComponentBase.StateHasChanged 方法。事件处理是 C# 的常见习语,非常有用。StateHasChanged 方法通知组件其状态已更改。在适用的情况下,这将导致组件重新渲染。AppState.FrameworkDescription 是从 Runtime​Informa⁠tion.FrameworkDescription 赋值的。这是在页脚右侧列显示的值,例如 “.NET 6”。

提示

只有在必要时调用 StateHasChanged 方法,以避免可能不必要地强制组件重新渲染。在异步上下文中调用此方法时,请将其包装在 await 语句中,并将其传递给 InvokeAsync 方法。这将在关联的渲染器同步上下文中执行提供的工作项,确保它在适当的线程上执行。

在以下情况下,您可能需要显式调用 StateHasChanged

  • 异步处理程序涉及多个异步阶段。

  • Blazor 渲染和事件处理系统接收来自外部的调用。

  • 您需要在不重新渲染特定事件的子树之外呈现组件。

有关触发渲染的更多信息,请参阅 Microsoft 的 “ASP.NET Core Razor 组件渲染”文档

Dispose 方法确保如果 AppState 实例 不为 null,它将取消订阅 AppInMemoryState.StateChanged 事件。这种显式清理有助于确保组件不会因未取消订阅事件处理程序而导致内存泄漏。

一个内存中的应用状态模型

Blazor 应用程序可以使用内存方法存储其状态。在此方法中,您将注册您的应用状态容器作为单例服务,这意味着应用程序共享的只有一个实例。服务本身公开了一个事件,订阅StateHasChanged方法,并且在状态对象的属性更新时触发该事件。考虑C#文件 AppInMemoryState.cs

using Learning.Blazor.BrowserModels;

namespace Learning.Blazor.Services;

public sealed class AppInMemoryState
{
    private readonly ILocalStorageService _localStorage; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    private string? _frameworkDescription;
    private ClientVoicePreference? _clientVoicePreference;
    private bool? _isDarkTheme;

    public AppInMemoryState(ILocalStorageService localStorage) =>
        _localStorage = localStorage;

    public string? FrameworkDescription ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        get => _frameworkDescription;
        set
        {
            _frameworkDescription = value;
            AppStateChanged(); ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        }
    }

    public ClientVoicePreference ClientVoicePreference
    {
        get => _clientVoicePreference ??=
            _localStorage.GetItem<ClientVoicePreference>(
                StorageKeys.ClientVoice)
            ?? new("Auto", 1);
        set
        {
            _localStorage.SetItem(
                StorageKeys.ClientVoice,
                _clientVoicePreference = value ?? new("Auto", 1));

            AppStateChanged();
        }
    }

    public bool IsDarkTheme
    {
        get => _isDarkTheme ??=
            _localStorage.GetItem<bool>(StorageKeys.PrefersDarkTheme);
        set
        {
            _localStorage.SetItem(
                StorageKeys.PrefersDarkTheme,
                _isDarkTheme = value);

            AppStateChanged();
        }
    }

    public Action<IList<Alert>>? WeatherAlertReceived { get; set; }
    public Action<ContactComponentModel>? ContactPageSubmitted { get; set; }

    public event Action? StateChanged; ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)

    private void AppStateChanged() => StateChanged?.Invoke(); ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
}

1

这些字段和属性表示了各种应用程序状态。

2

我们将呈现FrameworkDescription属性。

3

调用了AppStateChanged方法。

4

有一个名为StateChangedAction字段。

5

AppStateChanged方法调用了StateChanged事件。

声明了几个备份字段,用于存储表示各种应用程序状态的各个公共可访问属性的值。

以通信应用程序状态更改模式为例,考虑FrameworkDescription属性。其get访问器访问备份字段,而set访问器则将其分配给备份字段。

在将value分配给备份字段之后,将调用AppStateChanged方法。所有属性及其对应的备份字段都遵循此模式。

该类公开了一个名为StateChanged的可空Action作为事件。感兴趣的方可以订阅此事件以获取更改通知。

AppStateChanged方法表示为调用StateChanged事件。当事件为null时,它条件上是 NOOP(或“无操作”)。

此内存状态管理机制用于公开客户端语音偏好,客户端是否偏爱暗黑主题以及框架描述的值。要使应用程序状态在浏览器会话之间持久化,您可以使用另一种方法,如本地存储。每种方法都有权衡;使用内存中的应用程序状态少了工作量,但不会在浏览器会话之间持久存在。要持久存在浏览器会话之间,您依赖 JavaScript 交互操作来使用本地存储机制。

注意

如果您是 JavaScript SPA 开发人员,则可能熟悉Flux 模式。它由 Facebook 引入,以提供明确的关注点分离。该模式随着 React Redux 项目的流行而增长,后者是在 React 中使用的 Flux 模式的 JavaScript 实现。在 Blazor 中有一个名为Fluxor的实现,由 Peter Morris 开发。虽然超出本书的范围,但作为潜在的内存状态管理选项值得探讨。

理解LoginDisplay组件

LoginDisplay组件仅向 HTML 渲染少量内容,但需要理解一些代码:

@inherits LocalizableComponentBase<LoginDisplay> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png) @inject SignOutSessionStateManager SignOutManager <span class="navbar-item">
    <AuthorizeView> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        <Authorizing>
            <button class="button is-rounded is-loading level-item" disabled> @Localizer["LoggingIn"] </button>
        </Authorizing>
        <Authorized> @{
                var user = context.User!;
                var userIdentity = user.Identity!;
                var userToolTip =
                    $"{userIdentity.Name} ({user.GetFirstEmailAddress()})";
            } <button class="
		button is-rounded level-item has-tooltip-right has-tooltip-info"
                data-tooltip=@(userToolTip) @onclick="OnLogOut">
                <span class="icon">
                    <i class="fas fa-sign-out-alt"></i>
                </span>
                <span>@Localizer["LogOut"]</span>
            </button>
        </Authorized>
        <NotAuthorized>
            <button class="button is-rounded level-item" @onclick="OnLogIn">
                <span class="icon">
                    <i class="fas fa-sign-in-alt"></i>
                </span>
                <span>@Localizer["LogIn"]</span>
            </button>
        </NotAuthorized>
    </AuthorizeView>
</span>

1

该组件定义了两个指令。

2

该组件的标记使用了框架提供的AuthorizeView组件。

该组件定义了两个指令:一个指定它从LocalizableComponentBase继承,另一个注入SignOutSessionStateManager服务。LocalizableComponentBase是一个自定义的基础组件,在第五章中有详细介绍。

该组件的标记使用AuthorizeView组件及其各种依赖于授权状态的模板来呈现内容,当用户正在授权、已经授权或未经授权时。每个状态都有独立的标记。

在进行授权时,“正在登录”消息被本地化并呈现到屏幕上。当用户经过授权时,context暴露了分配给user变量的ClaimsPrincipal对象。考虑前面标记中的Localizer对象。这种特定类型来自于自定义LocalizableComponentBase<LoginDisplay>类的继承。这个Localizer提供了基于 Microsoft 资源驱动的键/值对(KVPs)和框架的IStringLocalizer<T>类型的本地化功能。自定义的LocalizableComponentBase.cs类位于Components目录中。

代码创建了一个工具提示,它将呈现用户姓名和电子邮件地址的字符串连接。工具提示绑定到按钮元素的data-tooltip属性。这是 Bulma CSS 框架中工具提示的一部分。当悬停在注销按钮上时,将呈现消息。当用户未经授权时,我们会呈现一个带有本地化登录消息的按钮。

接下来,让我们来看一下它的阴影组件,LoginDisplay.cs文件:

using Microsoft.AspNetCore.Components.Web;

namespace Learning.Blazor.Shared
{
    public partial class LoginDisplay
    {
 [Inject]
        public NavigationManager Navigation { get; set; } = null!;

        void OnLogIn(MouseEventArgs args) =>
            Navigation.NavigateTo("authentication/login", true);

        async Task OnLogOut(MouseEventArgs args)
        {
            await SignOutManager.SetSignOutState();
            Navigation.NavigateTo("authentication/logout");
        }
    }
}

该组件提供了两个使用注入的Navigation服务的函数。Navigation属性由 DI 框架分配,并且在功能上等同于组件的@inject指令语法。每个方法导航到所需的认证路由。当调用OnLogOut时,在导航之前,SignOutManager将其登出状态设置。每个路由由应用程序相应的认证逻辑处理。用户在经过身份验证后将在注销按钮旁看到其姓名,但如果未经身份验证,则仅会看到登录按钮。用户可以通过提供和验证其电子邮件与应用程序注册。这由 Azure Active Directory(Azure AD)业务对消费者(B2C)管理。作为与应用程序注册的替代方案,您可以使用可用的第三方身份验证提供程序,如 Google、Twitter 和 GitHub。

原生主题感知

对于所有现代 Web 应用程序,强烈建议应用程序具备色彩方案感知能力。从 CSS 中,很容易指定针对媒体相关查询值的样式规则。考虑以下 CSS:

@media (prefers-color-scheme: dark) {
    /*
 Styles here are only applied when the browser
 has a specified color scheme of "dark".
 */
}

此媒体查询中的规则仅在浏览器设置为偏好 dark 主题时应用。这些媒体查询也可以通过 JavaScript 从程序中访问。使用 window.matchMedia 方法 检测客户端浏览器偏好的更改。首先让我们看一下 ThemeIndicator​Com⁠ponent.razor 文件:

@inherits LocalizableComponentBase<ThemeIndicatorComponent> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

<span class="navbar-item"> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    <button class="button is-@(_buttonClass)
		has-tooltip-left has-tooltip-info is-rounded level-item"
        data-tooltip=@Localizer
            [AppState.IsDarkTheme ? "DarkTheme" : "LightTheme"]>
        <span class="icon">
            <i class="fas fa-@(_iconClass)"></i>
        </span>
    </button>
</span>

<HeadContent> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    <meta name="twitter:widgets:theme"
          content='@(AppState.IsDarkTheme ? "dark" : "light")'>
</HeadContent>

1

继承自通用的 LocalizableComponentBase 类。

2

ThemeIndicatorComponent 的主要标记是按钮。

3

ThemeIndicatorComponent 使用 <HeadContent>

希望您已经注意到,许多组件都是从通用的 LocalizableComponentBase 类继承而来。我们将在 第五章 再次讨论这一点。只需知道它公开了一个 Localizer 成员,通过自由索引器可以让我们根据字符串键获取本地化字符串值。

ThemeIndicatorComponent 的主要标记是按钮。按钮的 class 属性混合了直接的类名和在运行时求值的 Razor 表达式。_buttonClass 成员是一个 C# 字符串字段,绑定到 "is-" 前缀。此按钮还有一个工具提示,其消息根据 _isDarkTheme 布尔值的三元表达式有条件地赋值。Font Awesome 类也绑定到 _iconClass 字段成员。

ThemeIndicatorComponent 使用 <HeadContent>。这是一个由框架提供的组件,允许我们动态更新 HTML 的 <head> 内容。它非常强大,用于在运行时更新 <meta> 元素非常有用。当主题为 dark 时,应用程序指定 Twitter 小部件也应相应地进行主题设置。

注意

虽然 HeadContent 组件可以更新 meta 标签,但在使用 Blazor WebAssembly 时,这对于搜索引擎优化(SEO)来说仍然不是理想的。这是因为 meta 标签是动态更新的。要实现静态的 meta 标签值,您必须使用 Blazor WebAssembly 预渲染。有关组件集成方案的更多信息,请参阅微软的 “预渲染和集成 ASP.NET Core Razor 组件” 文档

接下来,让我们看一下其对应的组件阴影,即 C# 文件 ThemeIndicator​Component.razor.cs

using Learning.Blazor.Extensions;
using Microsoft.JSInterop;

namespace Learning.Blazor.Components
{
    public partial class ThemeIndicatorComponent ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        private string _buttonClass => ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            AppState.IsDarkTheme ? "light" : "dark";
        private string _iconClass =>
            AppState.IsDarkTheme ? "moon" : "sun";

        protected override async Task OnInitializedAsync() => ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            AppState.IsDarkTheme =
                await JavaScript.GetCurrentDarkThemePreferenceAsync(
                    this, nameof(UpdateDarkThemePreference));
 [JSInvokable] ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        public Task UpdateDarkThemePreference(bool isDarkTheme) =>
            InvokeAsync(() =>
            {
                AppState.IsDarkTheme = isDarkTheme;

                StateHasChanged();
            });
    }
}

1

定义了 ThemeIndicatorComponent 组件的阴影。

2

有几个条件性的 CSS 类绑定到字段值。

3

组件重写了 OnInitializedAsync 方法,在此方法中执行一些应用程序状态主题逻辑。

4

名为 UpdateDarkThemePreference 的回调方法。

ThemeIndicatorComponent 是当前检测到的主题的只读指示器。应用程序仅支持两种类型:Light 和 Dark。有几个私有字段,但您会记得这些字段在需要时可以通过标记访问和绑定。这两个 string 字段是基于 AppState.IsDarkTheme 值的简单三元表达式。组件重写了 OnInitializedAsync 方法,在其中分配了 AppState.IsDarkTheme 变量的当前状态,并调用 Get​Cur⁠rentDarkThemePreference 方法,这是一个 IJSRuntime 扩展方法。该方法要求 ThemeIndicatorComponent 引用自身和回调方法的名称。C# 的 nameof 表达式生成其参数的名称,本例中为回调函数的名称。这意味着我们正在注册我们的 .NET 组件,以便在给定 .NET 对象引用时从 JavaScript 端接收回调。

名为 UpdateDarkThemePreference 的回调方法预期 isDarkTheme 值。必须使用 JSInvokable 属性装饰该方法,以便从 JavaScript 中调用。由于此回调可以在组件初始化后的任何时候调用,因此必须使用 InvokeAsyncStateHasChanged 的组合:

InvokeAsync

在关联的渲染器同步上下文中执行提供的工作项。

StateHasChanged

通知组件其状态已更改。在适用的情况下,这将导致组件重新渲染。

现在让我们考虑下面的 JSRuntimeExtensions.cs C# 文件,用于 GetCurrentDarkThemePreferenceAsync 扩展方法:

using Microsoft.JSInterop;

namespace Learning.Blazor.Extensions;

internal static class JSRuntimeExtensions
{
    internal static async ValueTask<bool> GetCurrentDarkThemePreferenceAsync<T>(
        this IJSRuntime javaScript,
        T dotnetObj, ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        string callbackMethodName) where T : class =>
        await javaScript.InvokeAsync<bool>( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            "app.getClientPrefersColorScheme", ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            "dark", ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            DotNetObjectReference.Create(dotnetObj), ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
            callbackMethodName); ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
}

1

dotnetObj 参数是泛型参数,被限制为 class

2

javaScript 运行时实例调用互操作方法。

3

调用 "app.getClientPrefersColorScheme" 方法。

4

将值为 "dark" 的参数传递给 "app.getClientPrefers​Co⁠lorScheme" 方法。

5

DotNetObjectReference.Create(dotnetObj) 创建一个 DotNet​Ob⁠jectReference<ThemeIndicatorComponent> 实例。

6

callbackMethodName 是调用方法的名称。

扩展方法定义了一个泛型类型参数 T,该参数被限制为 class。在本例中,对象实例是 ThemeIndicatorComponent,但它可以是任何 class

使用 javaScript 运行时实例调用返回 ValueTask<bool> 的互操作函数。"app.getClientPrefersColorScheme" 方法是一个可以在 window 范围内访问的 JavaScript 方法。

"dark"的硬编码值作为第一个参数传递给app.getClientPrefersColorScheme函数。它是硬编码的,因为我们知道我们正在尝试评估当前客户端浏览器是否喜欢暗主题。当他们喜欢时,这将返回true

DotNetObjectReference.Create(dotnetObj) 创建一个DotNetObject​Re⁠ference<ThemeIndicatorComponent>的实例,并将其作为第二个参数传递给相应的 JavaScript 函数。这被用作一个引用,以便 JavaScript 可以回调到 .NET 组件。

callbackMethodName 是调用的ThemeIndicatorComponent实例中的方法名,该方法装饰有JSInvokable属性。当需要时,此方法可以从 JavaScript 中调用。

考虑到这是你第一次接触 JavaScript 互操作,让我预见并回答你可能有的一些问题:

问题

这个 JavaScript 是从哪里来的,它是什么样子?

答案

这个 JavaScript 是index.html中引用的app.js文件的一部分。它位于wwwroot文件夹下。我们将在下一节中查看源代码。

问题

它具有哪些功能?

答案

这取决于你想要实现什么,但实际上,任何你可能想象到的东西。对于这个特定的用例,JavaScript 将公开一个实用的辅助函数,名为getClientPrefersColorScheme。在内部,JavaScript 依赖于window.matchMedia API。.NET 代码通过 JavaScript 进行交互调用,并传递一个组件引用。JavaScript 代码注册了一个事件处理程序,以监视用户是否更改了他们的颜色方案首选项。当前的首选项立即从 JavaScript 返回到 .NET,但事件处理程序仍然注册。如果用户偏好更改,JavaScript 代码将使用给定的组件引用向 .NET 发出交互调用,传递新的颜色方案首选项。这展示了双向互操作性。

问题

我什么时候需要编写 JavaScript 互操作代码?

答案

每当您需要对一系列 JavaScript API 进行有限控制时。一个很好的例子是当您需要与第三方库交互或调用原生 JavaScript API 时。在本书中,您将看到一些适合编写 JavaScript 互操作代码的良好示例。

警告

Blazor 负责操作 DOM。如果 Blazor 不支持您的应用程序所需的 DOM 操作,您可能需要编写 JavaScript 互操作代码来实现所需的行为。但这应该是少数情况。理想情况下,您应该避免在解决同一问题时使用两种不同的方法。

这个特定的 JavaScript API 使用了媒体查询 API,这些 API 是 JavaScript 的本机支持。考虑app.js JavaScript 文件:

const getClientPrefersColorScheme = ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    (color, dotnetObj, callbackMethodName) => {
    let media = window.matchMedia(`(prefers-color-scheme: ${color})`); ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    if (media) {
        media.onchange = args => { ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            dotnetObj.invokeMethodAsync( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
                callbackMethodName,
                args.matches);
        };
    }

    return media.matches; ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
}

// omitted for brevity... 
window.app = Object.assign({}, window.app, {
    getClientPrefersColorScheme, ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
    // omitted for brevity... });

1

考虑getClientPrefersColorScheme函数。

2

从调用window.matchMedia分配一个media实例。

3

media.onchange事件处理程序属性分配给一个内联函数。

4

media实例发生变化时,.NET 对象会调用其回调函数。

5

返回media.matches值。

6

getClientPrefersColorScheme被添加到window.app对象。

getClientPrefersColorScheme函数被定义为一个带有colordotnetObjcallbackMethodName参数的const函数。从调用window.matchMedia分配一个media实例,给定媒体查询字符串。将media.onchange事件处理程序属性分配给一个内联函数。

事件处理程序内联函数依赖于dotnetObj实例,该实例是对调用的 Blazor 组件的引用。这是 JavaScript 与.NET 的交互,换句话说,如果用户更改其偏好设置,则触发onchange事件,并调用 Blazor 组件的callbackMethodName

返回media.matches值,向调用方指示媒体查询字符串当前匹配的值。getClientPrefersColorScheme被添加到window.app对象。

将所有这些内容结合起来,您可以在任何 Blazor 组件中引用<ThemeIndicatorComponent />,并拥有一个自包含的、颜色方案感知的组件。随着客户端偏好的变化,组件会动态更新其当前呈现的 HTML 表示的颜色方案。该组件依赖于 JavaScript 互操作,并且从 C#无缝集成。

总结

在本章中,我指导您了解了 Blazor WebAssembly 启动的内部工作原理。从静态 HTML 的服务和处理到调用 JavaScript 引导 Blazor 的过程,您探索了应用程序的解剖学。这包括Program入口点和启动约定。您了解了路由器、客户端导航、共享组件和布局。您还了解了应用程序中顶级导航组件以及如何通过RenderFragment占位符渲染内容。示范应用程序展示了一个原生的颜色方案感知组件和一个 JavaScript 互操作的示例。在下一章中,您将看到如何编写自定义 Blazor 组件以及如何使用 JavaScript 互操作。您将进一步了解 Blazor 如何使用身份验证来验证用户的身份以及如何有条件地渲染标记。最后,您将看到如何使用各种数据绑定技术并依赖来自 HTTP 服务的数据。

第三章:组件化

我们的应用程序已经起飞并展开——太棒了!我们将继续通过审视代码来学习 Blazor。在本章中,您将学习如何编写 Blazor 组件以及各种数据绑定方法。现在您已经熟悉了应用程序的启动方式,我们将评估应用程序的默认路由。这恰好是为Index.razor文件提供服务,该文件是应用程序的主屏幕。您将学习如何通过声明性属性和安全语义层次保护组件,以限制用户访问的内容。您将看到本地 JavaScript geolocation 服务与 JavaScript 互操作一起使用。作为本章的一部分,您还将了解 Blazor 应用程序依赖的一些外围服务和支持架构,例如“我被泄露了”服务和 Open Weather Map APIs。

注重用户体验设计

所有基于图形的应用程序都有用户,但并非所有应用程序都将用户需求置于优先位置。往往,应用程序使用您的信息来驱动广告或将您的信息出售给其他公司。这些应用程序将(用户)视为销售机会或数据点。

学习 Blazor 应用程序是根据其用户需求设计的。因此,它会验证用户的身份以确定应用程序采取的操作(更多信息,请参阅“身份和认证”)。

当用户登录应用程序时,也就是说,一旦 Web 服务器使用 Azure AD B2C 租户对用户进行了身份验证,将返回 JSON Web Token(JWT;或仅持有者令牌)。该应用程序将重定向到第三方站点并提示输入凭据。模型应用的 UX 显示如图 3-1 所示。

Azure Active Directory(AAD)面向消费者(B2C)登录屏幕

图 3-1 Azure AD B2C 登录屏幕

身份验证令牌根据需要通过外围服务和资源流动。例如,当与 Web.Client 项目一起使用时,此令牌可以表示为客户端浏览器的 cookie。无论此令牌位于服务器还是客户端应用程序上,经过身份验证的用户信息都表示为一组键/值对(KVPs),称为声明。用户表示为Claim⁠s​Principal对象。 ClaimsPrincipal具有Identity属性,在运行时可使用ClaimsIdentity实例。当服务需要身份验证并且请求提供有效的身份验证令牌时,会提供请求的声明。此时,我们可以要求用户同意与我们的应用程序共享的各种属性(或声明)。从 Blazor 应用程序的登录 UX 可自定义,详细内容请参见第四章。

我们的应用程序使用这些声明来唯一标识已验证用户。这些声明是承载令牌的一部分,并传递给应用程序依赖的各种服务。这些声明流入“Pwned”服务,从而使用户能够自动检测其电子邮件的数据泄露。

利用“Pwned”功能

Learning Blazor 应用程序的功能之一是“Pwned”功能,它可以告诉用户他们的电子邮件是否已被泄露。此功能源自Troy Hunt 的“Have I Been Pwned” API。他是世界上最著名的安全专家之一,多年来一直在收集数据泄露。他花费时间整合、规范化和持久化所有这些数据到一个名为“Have I Been Pwned”(简称 HIBP)的服务中。该服务提供检查特定电子邮件地址是否曾在数据泄露中出现的功能——截至撰写本文时,该服务已记录了近 115 亿条记录。这个数字肯定会继续增长。该 API 的消费组件和客户端服务在第五章中有详细描述。

HIBP API 公开了三个主要类别:

泄露

用于安全受损账户的聚合数据泄露信息

密码

大规模集合的已哈希密码,这些密码已在数据泄露中出现,意味着它们已被泄露

粘贴

已发布到公开面向网站以共享内容设计的信息

Learning Blazor 应用程序还依赖于GitHub 上的 pwned-client 开源项目,这是一个 .NET HTTP 客户端库,用于以编程方式使用 C# 访问 HIBP API。

此库已准备好使用 DI;消费者只需一个 API 密钥和NuGet 包

pwned-client 库使消费者能够通过众所周知的配置方式配置其 API 密钥。例如,如果要使用环境变量,可以将其命名为 HibpOptions__ApiKey。双下划线 (__) 用作跨平台替代符号,用于分隔名称段,Linux 中无法使用冒号 (:)。HibpOptions__ApiKey 环境变量将映射到库的强类型 HibpOptions.ApiKey 属性值。

要将所有服务添加到 DI 容器(IServiceCollection),请调用 AddPwnedServices 扩展方法之一的重载:

// Pass an IConfiguration section that maps
// to an object that has configured an ApiKey.
services.AddPwnedServices(
    _configuration.GetSection(nameof(HibpOptions))
);

第一个 AddPwnedServices 重载使用 IConfiguration _configuration,并请求 "HibpOptions" 部分。ASP.NET Core 具有多种配置提供程序,包括 JSON、XML、环境变量、Azure Key Vault 等等。IConfiguration 对象可以代表所有这些提供程序。例如,如果使用环境变量,它将把该配置部分映射到库的依赖 HibpOptions。同样,JSON 提供程序能够从 JSON 文件(例如 appsettings.json)中获取配置:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "HibpOptions": { ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    "ApiKey": "<YourApiKey>",
    "UserAgent": "<YourUserAgent>"
  }
}

1

在这个示例文件中,"HibpOptions"对象将映射到库中的HibpOptions类型。

或者,您可以使用 lambda 表达式直接分配选项:

// Provide a lambda expression that assigns the ApiKey directly.
services.AddPwnedServices(options =>
{
    options.ApiKey =
        Environment.GetEnvironmentVariable(
            "HAVE_I_BEEN_PWNED_API_KEY");
});

AddPwnedServices重载允许您内联指定 API 密钥和其他选项。注册服务并设置适当的配置后,代码可以使用 DI 来使用任何可用的抽象。有几个可以使用的客户端,每个都具有特定的上下文:

IPwnedBreachesClient

一个客户端,用于访问 Breaches API

IPwnedPasswordsClient

访问密码 API 的客户端

IPwnedPastesClient

一个客户端,用于访问 Pastes API

IPwnedClient

一个客户端,用于访问所有 API,并将所有其他客户端聚合到一个方便的单个客户端中

如果你想在本地运行示例应用程序,可以选择为各种服务提供几个 API 密钥。例如,要获取“Have I Been Pwned” API 密钥,您可以在他们的网站上注册。这特定的 API 密钥是收费的;如果您想注册 API,可以使用以下 API 密钥启用演示模式:

"HibpOptions": {
    "ApiKey": "demo"
}

这可以在 Web.Client 项目的appsettings.json文件中配置。

使用.NET 6,强调以极简为先是非常普遍的,并且有其道理。其核心理念是从小开始,随着需求的增长逐步扩展代码。极简 API 侧重于简洁性、易用性、可扩展性,并且可以说是极简主义。

“Have I Been Pwned”客户端服务

让我们来看看.NET 6 Minimal API 项目,这是 Learning Blazor 应用程序的 Web.PwnedApi,即Web.PwnedApi.csproj文件:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <RootNamespace>Learning.Blazor.PwnedApi</RootNamespace>
    <TargetFramework>net6.0</TargetFramework> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    <PackageReference Version="2.0.0"
        Include="HaveIBeenPwned.Client" />
    <PackageReference Version="2.0.0"
        Include="HaveIBeenPwned.Client.PollyExtensions" />
    <PackageReference Version="6.0.0"
        Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
    <PackageReference Version="6.0.0"
        Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
    <PackageReference Version="1.21.0"
        Include="Microsoft.Identity.Web" />
  </ItemGroup>

  <ItemGroup> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    <ProjectReference
        Include="..\Web.Extensions\Web.Extensions.csproj" />
    <ProjectReference
        Include="..\Web.Http.Extensions\Web.Http.Extensions.csproj" />
  </ItemGroup>

</Project>

1

项目的目标框架标识(TFM)是net.6.0

2

有几个框架提供和第三方库的包引用。

3

有几个项目引用用于本地依赖。

项目的根命名空间定义为Learning.Blazor.PwnedApi,并且它针对net6.0 TFM。由于我们的目标是.NET 6,我们可以启用ImplicitUsings特性;这意味着默认情况下在项目的所有 C#文件中隐式添加了一组usings,这是一种方便,因为这些隐式添加的命名空间是常见的。该项目还定义了启用的Nullable。这意味着我们可以定义可空引用类型,并且 C#编译器平台(Roslyn)将提供警告,指出存在null值的潜在问题,通过确定赋值流分析。

项目添加了许多包引用。特别感兴趣的一个包是HaveIBeenPwned.Client。这是一个暴露“Have I Been Pwned” HTTP 客户端功能的包。该项目还定义了认证和身份包,用于帮助保护暴露的 API。

该项目定义了两个项目引用,Web.Extensions 和 Web.Http.Extensions。这些项目提供了共享的实用功能。扩展项目基于公共语言运行时(CLR)类型,而 HTTP 扩展项目则专门提供了共享的瞬态故障处理策略。

Program.cs是一个 C#顶级程序,看起来像这样:

var builder = WebApplication.CreateBuilder(args).AddPwnedEndpoints(); ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
await using var app = builder.Build().MapPwnedEndpoints(); ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
await app.RunAsync(); ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)

1

创建了builder并添加了端点。

2

builder被构建,并映射其端点,生成一个app对象。

3

运行app对象。

代码首先创建了一个WebApplicationBuilder类型的builder实例,它为我们的 Web 应用程序公开了builder pattern(如“Builder Pattern”中描述的)。从调用CreateBuilder开始,代码调用AddPwnedEndpoints。这是WebApplicationBuilder类型的一个扩展方法,用于添加所有所需的端点。用于调用CreateBuilderargs隐式可用,并表示用于启动应用程序的命令行参数。这些对于所有 C#顶级程序都是可用的。有了builder,我们可以访问几个关键成员:

  • Services属性是我们的IServiceCollection;我们可以使用它注册依赖注入的服务。

  • Configuration属性是一个ConfigurationManager,它是IConfiguration的实现。

  • Environment属性提供了关于托管环境本身的信息。

接下来调用了builder.Build()。这将返回一个WebApplication类型,并从返回的对象调用了另一个方法MapPwnedEndpoints。这又是一个扩展方法,它封装了将添加的端点映射到扩展的WebApplication的逻辑。WebApplication类型是IAsyncDisposable接口的实现。因此,代码可以异步地await using app实例。这是确保在运行完成后正确处理app的方法。

最后,代码调用了await app.RunAsync();。这运行应用程序并返回一个Task,当应用程序关闭时完成。

尽管这个Minimal API项目有一个只有三行代码的Program文件,但实际上它包含了相当多的内容。这个 API 暴露了一个非常重要的应用功能:评估用户的电子邮件是否曾经参与过数据泄露。这些信息对用户非常有帮助,并且需要得到适当的保护。API 本身要求经过身份验证的用户具有特定的 Azure AD B2C 范围。考虑一下C#文件WebApplicationBuilderExtensions.cs

namespace Learning.Blazor.PwnedApi;

static class WebApplicationBuilderExtensions
{
    internal static WebApplicationBuilder AddPwnedEndpoints(
       this WebApplicationBuilder builder)
    {
        ArgumentNullException.ThrowIfNull(builder); ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

        var webClientOrigin = builder.Configuration["WebClientOrigin"]; ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        builder.Services.AddCors(
            options =>
                options.AddDefaultPolicy(
                    policy =>
                        policy.WithOrigins(
                                webClientOrigin, "https://localhost:5001")
                            .AllowAnyHeader()
                            .AllowAnyMethod()
                            .AllowCredentials()));

        builder.Services.AddAuthentication( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            JwtBearerDefaults.AuthenticationScheme)
            .AddMicrosoftIdentityWebApi(
                builder.Configuration.GetSection("AzureAdB2C"));

        builder.Services.Configure<JwtBearerOptions>( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            JwtBearerDefaults.AuthenticationScheme,
            options =>
                options.TokenValidationParameters.NameClaimType = "name");

        builder.Services.AddPwnedServices( ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
            builder.Configuration.GetSection(nameof(HibpOptions)),
            HttpClientBuilderRetryPolicyExtensions.GetDefaultRetryPolicy);

        builder.Services.AddSingleton<PwnedServices>();

        return builder;
    }
}

1

扩展方法防御性地检查builder不为空。

2

提取了WebClientOrigin配置值。

3

配置了builder以使用 JWT 承载身份验证。

4

JWT 承载的名称声明类型设置为name

5

调用了AddPwnedServices,它添加了所需的服务。

.NET 6 引入了一个新的 API,该 API 在给定参数为 null 时将throw异常。此 API 返回void,因此不是流畅的,但仍然可以节省几行代码。

给定builder.Configuration实例,代码期望为"WebClientOrigin"键提供一个值。这是客户端 Blazor 应用程序的来源,用于配置跨源资源共享,简称 CORS。CORS 是一种策略,允许不同来源共享资源,即一个来源可以请求另一个来源的资源。默认情况下,浏览器执行“同源策略”作为标准,以确保浏览器可以向不同来源发出 API 调用。由于 Pwned API 托管在与 Blazor 客户端应用程序不同的来源上,因此必须配置 CORS 并指定可接受的客户端来源。

配置了 Azure AD B2C 租户。从app​settings.json文件中绑定了"AzureAdB2C"部分,设置了实例、客户端标识符、域、范围和策略 ID。

配置了JwtBearerOptions,指定了"name"声明作为令牌验证的名称声明类型。这控制了承载身份验证处理程序的行为。选项名称中的JwtBearer表示这些选项用于 JWT 承载设置。JWT 代表 JSON Web Token,这些令牌代表了身份验证的互联网标准。ASP.NET Core 使用这些令牌来实例化每个经过身份验证的请求的ClaimsPrincipal实例。

调用了AddPwnedServices扩展方法,给定配置的"Hib⁠p​Options"部分和默认的 HTTP 重试策略。此项目依赖于 Web.Http.Extensions 项目。这些扩展暴露了一组通用的基于 HTTP 的重试逻辑,依赖于 Polly 库。按照此模式,整个应用程序共享一个常见的瞬态故障处理策略,有助于保持一切运行顺利。另外,PwnedServices作为单例添加到了 DI 中。

AddPwnedEndpoints之后评估的下一个扩展方法是MapPwned​End⁠points。这发生在 WebApplicationExtensions.cs 的 Web​.Pwne⁠dApi 项目中。

namespace Learning.Blazor.PwnedApi;

static class WebApplicationExtensions
{
    /// <summary>
    /// Maps "pwned breach data" endpoints and "pwned passwords"
    /// endpoints, with Minimal APIs.
    /// </summary>
    /// <param name="app">The current <see cref="WebApplication"/>
    /// instance to map on.</param>
    /// <returns>The given <paramref name="app"/> as a fluent API.</returns>
    /// <exception cref="ArgumentNullException">When <paramref name="app"/>
    /// is <c>null</c>.</exception>
    internal static WebApplication MapPwnedEndpoints(this WebApplication app)
    {
        ArgumentNullException.ThrowIfNull(app);

        app.UseHttpsRedirection(); ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        app.UseCors();
        app.UseAuthentication();
        app.UseAuthorization();

        app.MapBreachEndpoints(); ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        app.MapPwnedPasswordsEndpoints();

        return app;
    }

    internal static WebApplication MapBreachEndpoints( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        this WebApplication app)
    {
        // Map "have i been pwned" breaches.
        app.MapGet("api/pwned/breaches/{email}",
            GetBreachHeadersForAccountAsync);
        app.MapGet("api/pwned/breach/{name}",
            GetBreachAsync);

        return app;
    }

    internal static WebApplication MapPwnedPasswordsEndpoints( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        this WebApplication app)
    {
        // Map "have i been pwned" passwords.
        app.MapGet("api/pwned/passwords/{password}",
            GetPwnedPasswordAsync);

        return app;
    }
 [Authorize, RequiredScope("User.ApiAccess"), EnableCors] ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
    internal static async Task<IResult> GetBreachHeadersForAccountAsync(
 [FromRoute] string email,
        PwnedServices pwnedServices)
    {
        var breaches = await pwnedServices.GetBreachHeadersAsync(email);
        return Results.Json(breaches, DefaultJsonSerialization.Options);
    }
 [Authorize, RequiredScope("User.ApiAccess"), EnableCors]
    internal static async Task<IResult> GetBreachAsync(
 [FromRoute] string name,
        PwnedServices pwnedServices)
    {
        var breach = await pwnedServices.GetBreachDetailsAsync(name);
        return Results.Json(breach, DefaultJsonSerialization.Options);
    }
 [Authorize, RequiredScope("User.ApiAccess"), EnableCors] ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
    internal static async Task<IResult> GetPwnedPasswordAsync(
 [FromRoute] string password,
        IPwnedPasswordsClient pwnedPasswordsClient)
    {
        var pwnedPassword =
            await pwnedPasswordsClient.GetPwnedPasswordAsync(password);
        return Results.Json(pwnedPassword, DefaultJsonSerialization.Options);
    }
}

1

确保app不为 null 后,添加了一些常见的中间件。

2

BreachPwnedPasswords端点都已映射。

3

依赖于框架提供的MapGet,将两个端点映射到两个处理程序。

4

同样地,端点被映射到处理程序,这次是针对 PwnedPasswords

5

处理程序方法可以使用框架提供的属性和 DI。

6

每个处理程序都是隔离的和声明式的。

代码使用了 HTTPS 重定向、CORS、身份验证和授权中间件。这些中间件在 ASP.NET Core web 应用程序开发中很常见,是框架的一部分。

app 映射了 breach 端点和 Pwned passwords 端点。这些完全是自定义的端点,定义在扩展方法中。这些方法调用后,返回了 app,实现了流畅的 API。这使得在 builder.Build() 后能够链式调用 Program

MapBreachEndpoints 方法映射了两个模式及其对应的 Delegate handler,然后返回。每个端点都有一个路由模式,以 "api/pwned" 开头。这些端点在框架确定请求具有匹配路由模式时才执行;例如,经过身份验证的用户可以执行以下操作:

  • 请求 https://example-domain.com/api/pwned/breaches/test@email.org 并运行 GetBreachHeadersForAccountAsync 委托

  • 请求 https://example-domain.com/api/pwned/breach/linkedin 并运行 GetBreachAsync 委托

MapPwnedPasswordsEndpoints 方法将密码端点映射到 GetPwnedPasswordAsync 处理程序。

GetBreachHeadersForAccountAsync 方法是一个 async Task<IResult> 返回的方法。它声明了一个 Authorize 属性,用于保护该处理程序免受未经授权的请求。此外,它声明了一个 "User.ApiAccess"RequiredScope,这是在 Azure AD B2C 租户中定义的作用域。换句话说,这个处理程序(或者说 API)只能被我们 Azure AD B2C 租户中经过身份验证且具有特定作用域的用户访问。Learning Blazor 应用程序的用户将拥有此作用域,因此他们可以访问此 API。该方法声明了 EnableCors 属性,以确保该处理程序使用配置的 CORS 策略。除此之外,这个方法就像任何其他 C# 方法一样。它需要几个参数:

[FromRoute] string email

参数上的 FromRoute 属性告诉框架该参数将从路由模式中的 {email} 占位符提供。

PwnedServices pwnedServices

服务实例从 DI 中注入,异步请求给定 email 的违规头。breaches 以 JSON 形式返回。

GetPwnedPasswordAsync 方法与之前类似,除了它从路由中期望一个 password,并且从 DI 容器中获取 IPwnedPasswordsClient 实例。

通过我们应用程序的视角,让用户方便地获取这些信息是很有帮助的。当用户进行登录时,我们将检查 HIBP API 并进行反馈。作为用户,我可以信任应用程序会按预期工作,而无需手动检查或等待邮件。当我使用应用程序时,它通过立即提供信息来帮助我,否则这将是不方便的。Learning Blazor 应用程序依赖于 HaveIBeenPwned.Client NuGet 包,并通过其 Web Pwned API 项目公开它。

限制对资源的访问

如果您还记得,到目前为止我们的标记利用了 Authorize 框架提供的组件来保护各种客户端渲染的自定义组件。我们可以继续有选择地使用这种方法来限制应用程序中功能的访问。这被称为 授权

在示例应用程序的 Index.razor 标记文件中,使用授权来在应用程序没有经过身份验证的用户时隐藏路由:

@page "/" ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png) @inherits LocalizableComponentBase<Index>

<PageTitle> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png) @Localizer["Home"] </PageTitle>

<AuthorizeView> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    <NotAuthorized>
        <RedirectToLogin /> ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    </NotAuthorized>
    <Authorized> ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        <div id="index" class="tile is-ancestor">
            <div class="tile is-vertical is-centered is-7">
                <div class="tile">
                    <div class="tile is-parent">
                        <IntroductionComponent />
                    </div>
                    <div class="tile is-parent">
                        <JokeComponent />
                    </div>
                </div>
                <div class="tile is-parent">
                    <WeatherComponent />
                </div>
            </div>
        </div>
    </Authorized>
</AuthorizeView>

1

默认页面是应用程序根目录下的 Index 页面。

2

PageTitle 组件用于显示页面标题。

3

AuthorizeView 组件用于有条件地显示页面内容。

4

NotAuthorized 将重定向到登录页面。

5

Authorized 将显示 IntroductionComponentJokeComponentWeatherComponent

这是第一次看到 @page 指令。这是您模板化应用程序导航和客户端路由的方式。在 Blazor 应用程序中定义页面的每个组件都将作为用户可导航的路由。路由被定义为 C# 字符串。这个字面量是用来定义路由模板、路由参数和路由约束的值。

PageTitle 是一个框架提供的组件,允许动态更新页面的 head > title,即 HTML DOM 的 <title> 元素。这个值将显示在浏览器选项卡的 UI 中。

AuthorizeView 模板组件公开了 NotAuthorizedAuthorized 渲染片段。这些是特定于当前用户状态的模板。

当用户未经授权时,我们将重定向用户。我们已经讨论过如何使用 RedirectToLogin 组件重定向未经身份验证的用户。参见 “未经授权时重定向到登录”。

当有认证用户时,他们会看到三个磁贴。第一个磁贴是向您这个应用程序用户和我的书籍消费者说的简单“谢谢”消息!它渲染了自定义的 IntroductionComponent。第二个磁贴是笑话组件。它由一个聚合笑话服务支持,随机尝试从多个来源提供开发者幽默。最后一个磁贴跨越整个行,位于介绍和笑话组件下方,显示 WeatherComponent。我们将讨论每个这些不同的自定义 Blazor 组件实现及其不同程度的数据绑定和事件处理。

介绍组件说“嗨”

Learning Blazor 应用程序的下一个组件是 IntroductionComponent,它向访问应用程序的人说“嗨”,如 图 3-2 所示。

图 3-2. IntroductionComponent 的示例渲染

看看 Web.Client 项目中 Components/IntroductionComponent.razor.cs 的 C# 文件:

using Microsoft.Extensions.Localization; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

namespace Learning.Blazor.Components
{
    public partial class IntroductionComponent
    {
        private LocalizedString _intro => Localizer["ThankYou"]; ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    }
}

1

该组件使用了 Microsoft.Extensions.Localization

2

它定义了一个属性。

class 使用了 LocalizedString 类型,这是一个特定于区域的 string。它来自 Microsoft.Extensions.Localization 命名空间。

class 定义了一个名为 _intro 的单一字段,它表示为调用 Localizer 给定 "ThankYou" 键。此键标识要从本地化器实例解析的资源。在 Blazor WebAssembly 中,诸如 .resx 文件中找到的本地化资源可使用提供的 IStringLocalizer 框架类型。然而,Localizer 类型是一个名为 CoalescingString​Local⁠izer 的自定义类型,将在第五章详细讨论。

Localizer 成员来自于 LocalizableComponentBase 类型。这是我们许多组件的子类。现在,让我们看看 Introduction​Compo⁠nent.razor 标记文件:

@inherits LocalizableComponentBase<IntroductionComponent>

<article class="blazor-tile-container"> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    <div class="gradient-bg welcome-gradient"></div>
    <div class="icon-overlay heart-svg"></div>
    <div class="blaze-content">
        <p class="title is-family-monospace">
            <span class="wave">&#x1F44B;&#x1F3FD;</span>
            <span class="has-text-light"> @Localizer["Hi"] ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            </span>
        </p>
        <AdditiveSpeechComponent Message=@_intro.Value /> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        <p class="has-text-black is-family-monospace welcome-text is-size-5"> @_intro ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        </p>
    </div>
</article>

1

该组件是一个样式精美的 <article> 元素。

2

有一个本地化的问候消息。

3

_intro 的值与 AdditiveSpeech​Compo⁠nentMessage 属性绑定。

4

_intro 值也会作为 <p> 元素内的文本渲染。

大部分 HTML 标记是纯 HTML。如果您仔细查看,应该只能注意到一些 Blazor 细节。

Razor 代码上下文从原始 HTML 切换到访问 class 中的 Localizer 实例。我想展示你可以在 class 中使用字段,或者访问其他成员来实现单向数据绑定。与 "Hi" 键对应的本地化消息在挥手表情后绑定。问候消息是:“嗨,我是大卫。”

有一个自定义的 AdditiveSpeechComponent,其 Message 参数绑定到 _intro.Value。该组件将在图块的右上角渲染一个按钮。当点击该按钮时,将向用户朗读给定的 Message 值。 AdditiveSpeechComponent 组件将在下一章节详细介绍。

_intro 本地化资源值被插入到 <p> 元素中。

按照惯例,本地化资源文件的命名与它们本地化的文件相对应。例如,Introduction​Component.razor.cs 文件有一个 Introduction​Component.razor.en.resx 的 XML 文件。以下是它的内容的简化示例:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Hi" xml:space="preserve">
    <value>Hi, I'm David</value>
  </data>
  <data name="ThankYou" xml:space="preserve">
    <value>
        I'm honored, humbled, and thrilled to invite you
        on a tour of my "Learning Blazor: Build Single-Page Apps
        with WebAssembly and C#" book.
    </value>
  </data>
</root>

在顶级 root 节点内,有 data 节点。每个 data 节点都有一个 name 属性,而这个名称是用于检索资源 value 的键。可以有任意数量的 data 节点。这个示例文件是英文的,而其他语言将在文件名中使用它们特定的区域标识符。例如,法语资源文件将命名为 IntroductionComponent.razor.fr.resx,它将包含相同的 root > data [name] 结构,但其 value 节点将包含法语翻译。对于应用程序打算提供资源的任何区域设置都是如此。

介绍组件展示了单向数据绑定和本地化内容。让我们进一步扩展这两个概念,探索 JokeComponent

笑话组件和服务

学习 Blazor 应用程序的笑话组件显示一个随机笑话。当笑话组件忙于从端点获取随机笑话时,它将呈现一个旋转的加载动画。成功获取笑话后,它将以类似 Figure 3-3 所示的随机笑话呈现。

小贴士

我喜欢互联网查克·诺里斯数据库(icndb)。我在编程演示中经常使用它。它不仅提供了书呆子般的幽默,而且我喜欢它的简单性。它讲述了一个引人入胜的故事。同样,笑话经常会进入我的家庭生活。作为三个儿子的父亲,我知道我的孩子们喜欢听“爸爸的笑话”,而能让他们开心也让我感到快乐。

Figure 3-3. JokeComponent的示例渲染

该组件向 api/jokes Web API 端点发出 HTTP 请求。笑话对象本身与 Web API 端点和客户端代码共享。这有助于确保数据结构没有错位,从而避免序列化错误或数据丢失。考虑 Joke​Compo⁠nent.razor 标记文件:

@inject IJokeFactory JokeFactory ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png) @inject ILogger<JokeComponent> Logger
@inject IStringLocalizer<JokeComponent> Localizer <article class="blazor-tile-container"> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    <div class="gradient-bg jokes-gradient"></div>
    <div class="icon-overlay circle-svg"></div>
    <div class="blaze-content">
        <p class="title">
            <span class="is-emoji">&#x1F913;</span>
            <span class="has-text-light">@Localizer["Jokes"]</span>
        </p>
        <AdditiveSpeechComponent Message=@_jokeText />
        <div class="content"> @if (_isLoadingJoke) ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png) { <SpinnerComponent /> }
            else if (_jokeText is not null)
            { <blockquote class="has-text-black">
                    <span class="pb-4">@_jokeText</span>
                    <br> @if (_sourceDetails is { Site: not null })
                    { <cite>
                            &mdash; @{
                                var (site, source) = _sourceDetails.Value;
                            } <a href="@(site.ToString())" target="_blank"> @(source.ToString()) </a>
                        </cite> } </blockquote> } </div>
    </div>
</article> @code { ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png) private string? _jokeText = null;
    private JokeSourceDetails? _sourceDetails;
    private bool _isLoadingJoke = false;

    protected override Task OnInitializedAsync() =>
        RefreshJokeAsync();

    private async Task RefreshJokeAsync() ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png) {
        _isLoadingJoke = true;

        try
        {
            (_jokeText, _sourceDetails) =
                await JokeFactory.GetRandomJokeAsync();
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, ex.Message);
        }
        finally
        {
            _isLoadingJoke = false;
        }
    }
}

1

IJokeFactory 被注入到组件中。

2

就像 Index 页面上的其它组件一样,JokeComponent 渲染为一个样式化的 article 元素。

3

加载时会显示一个加载动画。

4

使用 @code 指令来指定代码块。

5

RefreshJokeAsync 方法被调用以获取新笑话。

JokeComponent 的标记与大多数其他组件类似,通过声明各种指令开始。JokeComponent 使框架注入了 IJokeFactoryILogger<JokeComponent>IStringLocalizer<JokeComponent>。任何在 DI 容器中注册的服务都是有效的 @inject 指令目标类型。此组件利用了这些特定服务。

HTML 标记比介绍组件更冗长。组件复杂性是您应该评估和注意的事项。一个好的经验法则是将组件限制为单一职责。笑话组件的责任是以 HTML 形式呈现笑话。标记类似于介绍组件,提供了一个表情符号和本地化标题,以及绑定到 _jokeText 变量的 AdditiveSpeech​Com⁠ponent

此笑话组件的内容标记是有条件的,并支持使用 @if, else if, else, and @switch 表达式作为控制结构。这从 Razor 语法一开始就存在。当 _isLoadingJoke 的值评估为 true 时,将呈现样式化的 SpinnerComponent 标记。Spinner​Compo⁠nent 也是自定义的,并且是一小部分常见的 HTML。否则,当 _joke​Text 不为 null 时,将随机笑话文本呈现为 blockquote

笑话组件使用 @code { ... } 指令而不是阴影组件方法。作为开发者,理解作为一种选择很重要。大多数情况下,我更倾向于不使用 @code 指令。对我来说,将它们保持在单独的文件中更清晰。我喜欢看到一个 C#类,这样对我来说感觉更自然一些。但是,如果你是从 JavaScript SPA 世界过来的开发者,也许将文件放在一起会感觉更自然。关键在于确定最佳方法的唯一方式是从团队中获得共识,这和其他风格上的开发者决策类似。

RefreshJokeAsync 方法由 OnInitializedAsync 生命周期方法调用。这意味着作为组件初始化的一部分,将异步获取笑话。方法从设置 _isLoadingJoke 位为 true 开始;这将导致渲染旋转器标记,但仅是临时的。方法体尝试请求 IJokeFactory 实例以获取 JokeResponse 对象。当有有效的 response 时,将其解构为元组赋值,并设置 _jokeText_sourceDetails 字段。然后,这些将作为笑话内容呈现。

支持这些笑话的端点聚合了几个第三方 API。各种笑话端点具有不同的数据结构,并且已经有服务将它们汇聚成我们 Blazor 客户端代码所消耗的单个端点。

聚合笑话服务——欢笑不断

没有有意义的数据,任何应用都没有用处。我们的应用将包括客户特定的天气信息、随机的极客笑话、实时的网络功能、聊天、通知、实时的 Twitter 信息流、按需的 HIBP 安全功能等等。这将会很有趣!但是对于 Blazor 来说意味着什么?在深入进行 Blazor 前端开发之前,我们应该对驱动该应用程序的服务和数据设置更多的期望——我们的后端开发。

Blazor 应用程序可以自由地从任意数量的其他平台、服务或 Web 应用程序中检索和使用数据。存在许多良好的架构,为任何给定的问题领域提供许多可能的解决方案。毕竟,知道何时使用哪种模式或实践是成功的一部分。您应该尝试识别数据的流动和基本要求,数据来源以及如何访问这些数据。这些更好的问题应该是你自己在问的问题。答案几乎总是“这取决于情况”。

让我们看看笑话服务库如何提供随机笑话:

namespace Learning.Blazor.JokeServices;

internal interface IJokeService
{
    JokeSourceDetails SourceDetails { get; }

    Task<string?> GetJokeAsync();
}

在 C# 10 之前,namespace声明将其包含类型包裹在花括号中。在 C# 10 中,您可以使用文件范围的命名空间,这通过删除代码中的一级缩进增强了可读性。我喜欢这个特性;即使它有点微妙,但在阅读代码时确实减少了噪音。

IJokeService是一个internal interface类型,它公开了一个只读的JokeSour⁠ce​Details属性和异步请求笑话的能力。internal访问修饰符意味着笑话服务不会暴露给声明外部的程序集。

GetJokeAsync方法是无参数的,返回一个Task<string?>。在string类型声明的?标识表明返回的string可能为null(C#引用类型string的默认值)。

我们有三种不同的第三方笑话 Web 服务,全部都是免费的。笑话响应的形状因提供者而异,URL 也不同。我们有三个单独的配置、端点和笑话模型需要表示。

第一个IJokeService的实现是ProgrammingJokeService

namespace Learning.Blazor.JokeServices;

internal class ProgrammingJokeService : IJokeService ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<ProgrammingJokeService> _logger;

    public ProgrammingJokeService( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        HttpClient httpClient,
        ILogger<ProgrammingJokeService> logger) =>
        (_httpClient, _logger) = (httpClient, logger);

    JokeSourceDetails IJokeService.SourceDetails => ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        new(JokeSource.RandomProgrammingJokeApi,
            new Uri("https://karljoke.herokuapp.com/"));

    async Task<string?> IJokeService.GetJokeAsync() ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    {
        try
        {
            // An array with a single joke is returned
            var jokes = await _httpClient.GetFromJsonAsync<ProgrammingJoke[]>(
                "https://karljoke.herokuapp.com/jokes/programming/random",
                DefaultJsonSerialization.Options);

            return jokes?[0].Text;
        }
        catch (Exception ex)
        {
            _logger.LogError("Error getting something fun to say: {Error}", ex);
        }

        return null;
    }
}

1

ProgrammingJokeService类实现了IJokeService接口。

2

HttpClientILogger<T>实例被注入到构造函数中。

3

SourceDetails属性提供有关笑话来源的信息。

4

GetJokeAsync方法返回一个Task<string?>,解析为一个笑话或者如果无法检索到笑话则为null

这个服务以其命名空间声明开始,后跟internal class实现的IJokeService

这个类需要两个参数,一个HttpClient和一个ILogger<ProgrammingJokeService>的日志记录器实例。这两个参数使用元组字面量和其立即解构来分配到字段赋值中。这允许单行和表达式体构造函数。这只是一个样板 DI 方法。这些字段被安全地声明为private readonly,以防止类中的使用者误将其值错误赋值。这是 DI 容器的职责。

编程笑话服务通过一个隐式目标类型new表达式表达了SourceDetails成员的表示。我们根据底层 API 类型JokeSource.RandomProgrammingJokeApi的枚举值和.NET 中笑话 URL 的Uri对象来实例化JokeSourceDetails的一个实例。

GetJokeAsync的实际实现从trycatch块开始。使用_httpClient进行 HTTP GET 请求,请求 URI 和默认的 JSON 序列化选项。在出现错误时,记录Exception详情并返回null。当没有错误时,也就是“正常路径”,从请求的响应中反序列化为一个ProgrammingJoke数组对象。当存在笑话时,返回第一个笑话的文本。如果这是null,那也没关系,因为我们将让使用者处理它。我们需要向他们指出这一点——再次强调,它是一个string?。我称可空类型为“有疑问的”。例如,给定一个string?,你应该问自己这是否为null,并适当地防范。我经常将这种模式称为有疑问的字符串

其他两个服务实现遵循相同的模式,很明显我们需要一种方法来聚合它们,因为它们代表了同一个接口的多个实现。当.NET 遇到为同一类型注册的多个服务时,它们会包装在IEnumerable<TService>中,其中TService是给定的实现之一。

让我们继续看另外两个IJokeService的实现。考虑以下DadJokeService的实现:

namespace Learning.Blazor.JokeServices;

internal class DadJokeService : IJokeService
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<DadJokeService> _logger;

    public DadJokeService(
        IHttpClient httpClient,
        ILogger<DadJokeService> logger) =>
        (_httpClient, _logger) = (httpClient, logger);

    JokeSourceDetails IJokeService.SourceDetails =>
        new(JokeSource.ICanHazDadJoke,
            new Uri("https://icanhazdadjoke.com/"));

    async Task<string?> IJokeService.GetJokeAsync()
    {
        try
        {
            return await _httpClient.GetStringAsync(
                "https://icanhazdadjoke.com/");
        }
        catch (Exception ex)
        {
            _logger.LogError(
                "Error getting something fun to say: {Error}", ex);
        }

        return null;
    }
}

ChuckNorrisJokeService的实现:

namespace Learning.Blazor.JokeServices;

internal class ChuckNorrisJokeService : IJokeService
{
    private readonly ILogger<ChuckNorrisJokeService> _logger;
    private static readonly AsyncLazy<ChuckNorrisJoke[]?> s_embeddedJokes =
        new(async () =>
        {
            var @namespace = typeof(ChuckNorrisJokeService).Namespace;
            var resource = $"{@namespace}.Data.icndb-nerdy-jokes.json";

            var json = await ReadResourceFileAsync(resource);
            var jokes = json.FromJson<ChuckNorrisJoke[]>();

            return jokes;
        });

    public ChuckNorrisJokeService(
        ILogger<ChuckNorrisJokeService> logger) => _logger = logger;

    JokeSourceDetails IJokeService.SourceDetails =>
        new(JokeSource.InternetChuckNorrisDatabase,
            new Uri("https://www.icndb.com/"));

    async Task<string?> IJokeService.GetJokeAsync()
    {
        try
        {
            var jokes = await s_embeddedJokes;
            if (jokes is { Length: > 0 })
            {
                var randomIndex = Random.Shared.Next(jokes.Length);
                var random = jokes[randomIndex];

                return random.Joke;
            }

            return null;
        }
        catch (Exception ex)
        {
            _logger.LogError(
                "Error getting something fun to say: {Error}", ex);
        }

        return null;
    }

    private static async Task<string> ReadResourceFileAsync(string fileName)
    {
        using var stream =
            Assembly.GetExecutingAssembly()
                .GetManifestResourceStream(fileName);
        using var reader = new StreamReader(stream!);
        return await reader.ReadToEndAsync();
    }
}

为处理多个IJokeService实现,我们将创建一个工厂,它将聚合笑话——返回第一个成功的随机实现的笑话:

namespace Learning.Blazor.JokeServices;

public interface IJokeFactory
{
    Task<(string, JokeSourceDetails)> GetRandomJokeAsync();
}

这个接口定义了一个基于任务的异步方法,方法名表明它获取一个随机笑话。返回类型是一个Task<(string, JokeSourceDetails)>,其中Task的泛型约束是一个stringJokeSourceDetails的元组。JokeSourceDetails的形式如下:

using System;

namespace Learning.Blazor.Models;

public record JokeSourceDetails(
    JokeSource Source,
    Uri Site);

在 C# 中,位置记录是一种令人惊叹的类型。首先,它们是不可变的。可以使用 with 语法克隆实例,其中属性值被覆盖到复制的对象中。您还将获得自动相等性和基于值的比较语义。它们是声明性和简洁的编写方式。让我们接下来看看笑话工厂:

namespace Learning.Blazor.JokeServices;

internal class AggregateJokeFactory : IJokeFactory
{
    const string NotFunny = @"Did you hear the one about a joke service that " +
        @"failed to get jokes?" +
        "It's not very funny...";

    private readonly IList<IJokeService> _jokeServices;

    public AggregateJokeFactory( ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        IEnumerable<IJokeService> jokeServices) =>
        _jokeServices = jokeServices;

    async Task<(string, JokeSourceDetails)> IJokeFactory.GetRandomJokeAsync() ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        string? joke = null;
        JokeSourceDetails sourceDetails = default;

        foreach (var service in _jokeServices.RandomOrder())
        {
            joke = await service.GetJokeAsync();
            sourceDetails = service.SourceDetails;

            if (joke is not null && sourceDetails != default)
            {
                break;
            }
        }

        return (
            joke ?? NotFunny,
            sourceDetails);
    }
}

1

构造函数接受一个 IJokeService 实现的集合。

2

GetRandomJokeAsync 方法体使用了 RandomOrder 函数。

IJokeFactory 实现的适当命名为 AggregateJoke​Fac⁠tory,其构造函数(.ctor)接受 IEnumerable<IJokeService>。这些是笑话服务:爸爸笑话服务随机编程笑话 API 服务互联网查克·诺里斯数据库服务。这些值由 .NET DI 容器提供。

GetRandomJokeAsync 方法体正在利用名为 RandomOrder 的扩展方法,它在 IEnumerable<T> 类型上。此模式依赖于回退模式,其中服务尝试提供笑话,直到有一个能够提供笑话。如果没有实现能够提供笑话,则方法默认返回 null。随机的扩展方法在 Learning.Blazor.Extensions 命名空间的 Enumerable​Exten⁠sions.cs C# 文件中定义:

namespace Learning.Blazor.Extensions;

public static class EnumerableExtensions
{
    static readonly Random s_random = Random.Shared; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

    public static IEnumerable<T> RandomOrder<T>(this IList<T> incoming) ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        var used = new HashSet<int>();
        var count = incoming.Count;

        while (used.Count != count)
        {
            var index = s_random.Next(incoming.Count);
            if (!used.Add(index))
            {
                continue;
            }

            yield return incoming[index];
        }

        yield break;
    }
}

1

框架提供的 Random 类型。

2

随机化顺序的算法是 O(1) 时间,这意味着其计算时间是常数,无论集合大小如何。

框架提供的 Random.Shared 实例 表示一个伪随机数生成器,它是一个生成满足基本统计要求的随机数序列的算法。

随机元素函数作用于 incoming 集合实例。从 AggregateJokeFactory 实例中我们伪随机地确定,我们将等待其调用 GetJokeAsync 方法。如果返回的笑话是 null,我们将合并到 "There is nothing funny about this." 然后返回一个包含 string 笑话和相应服务来源详细信息的元组。

图书馆作者的 DI

笑话服务库的最后一部分涉及到我们所有笑话服务都是 DI 友好的事实,我们可以在 IServiceCollection 上添加一个扩展方法,将它们注册到 DI 容器中。这是我将为所有旨在消费的库遵循的常见策略。消费者将调用 AddJokeServices 来注册所有抽象到 DI 中。他们可以在类的构造函数中或通过 Blazor 组件的属性注入开始要求这些服务。InjectAttribute@inject 指令允许通过它们的 C# 属性将服务注入组件。

namespace Learning.Blazor.Extensions; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddJokeServices(
        this IServiceCollection services)
    {
        ArgumentNullException.ThrowIfNull(nameof(services));

        services.AddScoped<IJokeService, ProgrammingJokeService>(); ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        services.AddScoped<IJokeService, DadJokeService>();
        services.AddScoped<IJokeService, ChuckNorrisJokeService>();

        services.AddHttpClient<ProgrammingJokeService>() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            .AddDefaultTransientHttpErrorPolicy();
        services.AddHttpClient<DadJokeService>()
            .AddDefaultTransientHttpErrorPolicy();

        services.AddScoped<IJokeFactory, AggregateJokeFactory>(); ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)

        return services;
    }
}

1

类使用了Learning.Blazor.Http.Extensions命名空间。

2

所有三个服务实现都添加到了services集合中。

3

每个实现都有其对应的HttpClient

4

总体而言,每个实现都通过AggregateJokeFactory暴露出来。

Learning.Blazor.Http.Extensions命名空间代表一个共享库,其中包含默认的瞬态故障处理策略。这些默认值在解决方案中所有使用HttpClient的项目中共享。这些共享的故障处理策略施加了指数退避模式,帮助自动重试间歇性 HTTP 请求失败。它们生成的睡眠持续时间以指数退避的抖动方式进行,以确保减少任何相关性。例如 850ms、1455ms 和 3060ms。这是通过Polly.Contrib.WaitAndRetry库及其Backoff.Decorrelated​Jit⁠terBack⁠offV2实现可能的。

调用AddJokesServices会将所有相应的笑话服务注册到 DI 容器中。一旦注册到 DI 容器中,消费者可以请求IJokeFactory服务,并提供实现。所有这些功能对 Web.Client 都是可见的。JokeComponent使用IJokeFactory.GetRandomJokeAsync方法。客户端代码将在客户端浏览器上执行,使用每个服务根据需要向一些外部端点发起 HTTP 调用。

我们已经介绍了IntroductionComponentJokeComponent。在下一节中,我们将看一个逐渐复杂的示例。我将向您展示如何调用与 Azure 静态 Web 应用共同托管的 Azure 函数。这个 Azure 函数在 Web.Functions 项目中实现。

提示

Azure 函数是一个无服务器解决方案(类似于 AWS Lambda)。它们是使用 Azure PaaS(平台即服务)构建可扩展、可靠和安全应用程序的好方法。有关更多信息,请参阅微软的“Azure 函数简介”文档

预测本地天气

到目前为止,我们所涵盖的自定义组件都比较基础。IntroductionComponent有一个单一的本地化文本字段进行渲染。然后Joke​Compo⁠nent演示了如何通过条件控制结构和加载指示器从 HTTP 端点获取数据。WeatherComponentWeatherCurrentComponentWeatherDailyComponent的父组件。这些组件共同显示用户本地当前天气和本周的即时预报,如图 3-4 所示。

图 3-4. WeatherComponent的示例渲染

所有天气数据都可以免费从 Open Weather Map API 获取。WeatherComponent 依赖于一个 HttpClient 实例来获取天气数据。在这个组件中,我们还将讨论如何使用双向 JavaScript 互操作。让我们来看一下 WeatherComponent.razor 的标记:

@inherits LocalizableComponentBase<WeatherComponent>

<article class="blazor-tile-container"> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    <div class="gradient-bg weather-gradient"></div>
    <div class="icon-overlay zap-svg"></div>
    <div class="blaze-content">
        <p class="title" translate="no">
            <span class="is-emoji">&#x1F525;</span>
            <span class="has-text-light"> Blazor @Localizer["Weather"]</span>
        </p>
        <AdditiveSpeechComponent Message=@_model?.Message /> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        <div class="columns has-text-centered"> @switch (_state) ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png) {
            case ComponentState.Loaded: ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png) var weather = _model!; <div class="column is-one-third">
                <WeatherCurrentComponent Weather=weather
                    Localizer=Localizer />
            </div>
            <div class="column">
                <div class="level"> @foreach (DailyWeather daily in weather.DailyWeather)
                { <WeatherDailyComponent Daily="daily"
                        GetDailyImagePath=weather.GetDailyImagePath
                        GetDailyHigh=weather.GetDailyHigh
                        GetDailyLow=weather.GetDailyLow /> } </div>
            </div> break;
            case ComponentState.Loading: <div class="column is-full"> ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
                <SpinnerComponent />
            </div> break;
            default: <div class="column is-full"> ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png) @Localizer["WeatherUnavailable"] </div> break;
        } </div>
    </div>
</article>

1

最外层的 article 元素被设计为一个瓷砖。

2

与其他两个瓷砖一样,天气瓷砖也使用了 AdditiveSpeech​Compo⁠nent

3

除了简单的 @if 控制结构外,还可以使用 @switch 控制结构。

4

加载完成后,天气瓷砖显示当前天气和本周的天气预报。

5

当组件正在加载时,会显示 SpinnerComponent

6

default 情况下会呈现本地化消息,告知用户天气不可用。

这个组件的标记与另外两个瓷砖(IntroductionComponentJokeComponent)类似。WeatherComponent 是另外两个组件 WeatherCurrentComponentWeatherDailyComponent 的父组件。它的标题是“Blazor 天气”,其中的 weather 一词是本地化的。

与其他两个瓷砖一样,天气瓷砖也使用了 AdditiveSpeech​Compo⁠nent。渲染时,在其父元素的右上角可见一个语音按钮。关于 AdditiveSpeechComponent 的详细信息可在 “本地语音合成” 中找到。

在标记中,@switch 控制结构相当不错。天气组件使用自定义组件 _state 变量来帮助跟踪组件的状态。可能的状态包括未知、加载中、已加载或错误。

当组件加载时,会渲染当前天气(WeatherCurrentComponent)和每日天气预报(WeatherDailyComponent)。父组件依赖于可空的 _model 类型;当处于加载状态时,_model 不为 null,我们可以使用非空断言操作符 ! 来告诉编译器我们对此很有把握。类作用域下的 _model 变量被赋给本地作用域下的 weather 变量。这个变量通过帮助方法委托或参数赋值,被分配给其子组件 WeatherCurrentComponentWeatherDailyComponent

当组件正在加载时,会显示 SpinnerComponentdefault 情况下会呈现本地化消息,告知用户天气不可用。这应该只在出现错误时发生。

天气组件标记引用了当前天气 (WeatherCurrentComponent) 和每日天气预报 (WeatherDailyComponent) 组件。这两个组件不使用组件阴影,仅用于模板。每个组件都定义了一个 @code { ... } 指令和几个 Parameter 属性。它们不需要逻辑或功能;因此,它们只是绑定到指定值的标记。这是 WeatherCurrentComponent.razor 标记文件:

@using Learning.Blazor.Localization;

<div class="box dotnet-box-border is-alpha-bg-50">
    <article class="media">
        <div class="media-left">
            <figure class="image is-128x128">
                <img src=@(Weather.ImagePath)
                    class="has-img-shadow"
                    alt="@Localizer["CurrentWeatherVisual"]">
            </figure>
        </div>
        <div class="media-content">
            <div class="content has-text-right has-text-light">
                <div>
                    <span class="title has-text-light">
                        @Weather.Temperature
                    </span>
                    <span class="heading">
                        <i class="fas fa-arrow-up"></i>
                        @(Weather.HighTemp) |
                        <i class="fas fa-arrow-down"></i>
                        @(Weather.LowTemp)
                    </span>
                    <span class="heading">
                        @Weather.Description
                    </span>
                    <span class="heading">
                        <i class="fas fa-wind"></i>
                        @Weather.WindSpeed
                        <sup>
                        @(Localizer[Weather.WindDegree.PositionalCardinal])
                        </sup>
                    </span>
                </div>
            </div>
        </div>
    </article>
    <div class="has-text-centered has-text-light">
        @($"{Weather.City}, {Weather.State} ({Weather.Country})")
    </div>
</div>

@code {
    [Parameter]
    public WeatherComponentModel Weather
    {
        get;
        set;
    } = null!;

    [Parameter]
    public CoalescingStringLocalizer<WeatherComponent> Localizer
    {
        get;
        set;
    } = null!;
}

WeatherCurrentComponent 渲染了与当前天气对应的图像,例如云、雨云,或者甚至是代表美好天气的太阳图像。它还显示温度、高温和低温、天气描述、风速和风向,以及城市和州。例如,让我们看一下 WeatherDailyComponent.razor 标记文件:

<div class="level-item has-text-centered has-text-light">
    <div>
        <p class="heading is-size-6 is-underlined">
            @Daily.DateTime.ToString("ddd")
        </p>
        <p class="title">
            <figure class="image is-64x64">
                <img src=@GetDailyImagePath?.Invoke(Daily)
                    class="has-img-shadow"
                    alt="@Daily.Weather[0].Description">
            </figure>
        </p>
        <p class="heading">@Daily.Weather[0].Main</p>
        <p class="heading has-text-weight-bold">
            <i class="fas fa-arrow-up"></i>
            @GetDailyHigh?.Invoke(Daily)
        </p>
        <p class="heading has-text-weight-bold">
            <i class="fas fa-arrow-down"></i>
            @GetDailyLow?.Invoke(Daily)
        </p>
    </div>
</div>

@code {
    [Parameter]
    public DailyWeather Daily { get; set; } = null!;

    [Parameter]
    public Func<DailyWeather, string>? GetDailyImagePath { get; set; }

    [Parameter]
    public Func<DailyWeather, string>? GetDailyHigh { get; set; }

    [Parameter]
    public Func<DailyWeather, string>? GetDailyLow { get; set; }
}

WeatherDailyComponent 使用委托作为部分数据绑定的参数。它渲染了预报的日期和天气图标,以及天气描述、高温和低温。

WeatherComponent 依赖于几个服务,并使用定时器自动刷新天气,我们接下来会详细查看。这个组件展示了许多强大的功能。现在您已经探索了标记,请考虑阴影组件的 C# 文件 WeatherComponent.razor.cs(示例 3-1)。

示例 3-1. Web.Client/Components/WeatherComponent.razor.cs
namespace Learning.Blazor.Components
{
    public sealed partial class WeatherComponent : IDisposable
    {
        private Coordinates _coordinates = null!; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        private GeoCode? _geoCode = null!;
        private WeatherComponentModel<WeatherComponent>? _model = null!;
        private ComponentState _state = ComponentState.Loading;
        private bool _isActive = false;

        private readonly CancellationTokenSource _cancellation = new();
        private readonly PeriodicTimer _timer = new(TimeSpan.FromMinutes(10));
 [Inject]
        public IWeatherStringFormatterService<WeatherComponent> Formatter
        {
            get;
            set;
        } = null!;
 [Inject]
        public HttpClient Http { get; set; } = null!;
 [Inject]
        public GeoLocationService GeoLocationService { get; set; } = null!;

        protected override Task OnInitializedAsync() =>
            TryGetClientCoordinatesAsync();

        private async Task TryGetClientCoordinatesAsync() => ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            await JavaScript.GetCoordinatesAsync(
                this,
                nameof(OnCoordinatesPermittedAsync),
                nameof(OnErrorRequestingCoordinatesAsync));
 [JSInvokable] ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        public async Task OnCoordinatesPermittedAsync(
            decimal longitude, decimal latitude)
        {
            _isGeoLocationPermissionGranted = true;
            _coordinates = new(latitude, longitude);
            if (_isActive) return;

            do
            {
                _isActive = true;

                try
                {
                    var lang = Culture.CurrentCulture.TwoLetterISOLanguageName;
                    var unit = Culture.MeasurementSystem;

                    var weatherLanguages =
                        await Http.GetFromJsonAsync<WeatherLanguage[]>(
                            "api/weather/languages",
                            WeatherLanguagesJsonSerializerContext
                                .DefaultTypeInfo);

                    var requestLanguage =
                        weatherLanguages
                            ?.FirstOrDefault(
                                language => language.AzureCultureId == lang)
                            ?.WeatherLanguageId
                        ?? "en";

                    WeatherRequest weatherRequest = new()
                    {
                        Language = requestLanguage,
                        Latitude = latitude,
                        Longitude = longitude,
                        Units = (int)unit
                    };

                    using var response =
                        await Http.PostAsJsonAsync("api/weather/latest",
                            weatherRequest,
                            DefaultJsonSerialization.Options);

                    var weatherDetails =
                        await response.Content.ReadFromJsonAsync<WeatherDetails>(
                            DefaultJsonSerialization.Options);

                    await GetGeoCodeAsync(
                        longitude, latitude, requestLanguage);

                    if (weatherDetails is not null && _geoCode is not null)
                    {
                        _model = new WeatherComponentModel(
                            weatherDetails, _geoCode, Formatter);
                        _state = ComponentState.Loaded;
                    }
                    else
                    {
                        _state = ComponentState.Error;
                    }
                }
                catch (Exception ex)
                {
                    Logger.LogError(ex, ex.Message);
                    _state = ComponentState.Error;
                }
                finally
                {
                    await InvokeAsync(StateHasChanged);
                }
            }
            while (await _timer.WaitForNextTickAsync(_cancellation.Token));
        }

        private async Task GetGeoCodeAsync(
            decimal longitude, decimal latitude, string requestLanguage)
        {
            if (_geoCode is null)
            {
                GeoCodeRequest geoCodeRequest = new()
                {
                    Language = requestLanguage,
                    Latitude = latitude,
                    Longitude = longitude,
                };

                _geoCode =
                    await GeoLocationService.GetGeoCodeAsync(geoCodeRequest);
            }
        }
 [JSInvokable] ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        public async Task OnErrorRequestingCoordinatesAsync(
            int code, string message)
        {
            Logger.LogWarning(
                "The user did not grant permission to geolocation:" +
                "({Code}) {Msg}",
                code, message);

            // 1 is PERMISSION_DENIED, error codes greater than 1
            // are unrelated errors.
            if (code > 1)
            {
                _isGeoLocationPermissionGranted = false;
            }
            _state = ComponentState.Error;

            await InvokeAsync(StateHasChanged);
        }

        void IDisposable.Dispose() ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        {
            _cancellation.Cancel();
            _cancellation.Dispose();
            _timer.Dispose();
        }
    }
}

1

WeatherComponent 管理了几个字段和属性。

2

当组件初始化时,会调用 TryGetClientCoordinatesAsync

3

当用户授予地理位置权限时,会调用 OnCoordinatesPermittedAsync 方法。

4

当用户不授予地理位置权限时,会调用 OnErrorRequestingCoordinatesAsync 方法。

5

Dispose 方法执行对 CancellationTokenSourcePeriodicTimer 对象的清理。

天气组件依赖于浏览器的地理位置,这是原生保护的,并且需要用户授权。如果用户允许,组件有几个字段变量用于保存这些信息。Coordinates对象是一个 C#位置记录类型,具有纬度和经度属性。GeoCode对象包含城市、国家和其他类似信息。它是从 HTTP 调用实例化的Big Data Cloud API。此调用是有条件的,仅当用户授予浏览器地理位置服务访问权限时发生。除了这些变量之外,还有组件模型和状态。还有PeriodicTimerPeriod⁠ic​Timer是在.NET 6 中引入的,它提供了一个轻量级的异步定时器。它配置为每 10 分钟进行一次滴答。组件请求 DI 容器注入格式化程序、HTTP 客户端和地理位置服务。

初始化组件时,会等待调用TryGetClientCoordinatesAsync方法。该方法作为调用JavaScript.GetCoordinatesAsync给定this和两个方法名称的表达。这是.NET 中的 JavaScript 互操作调用,并且相应的扩展方法将在下一节中详细解释。只需知道调用TryGetClientCoordinatesAsync将导致调用两种方法之一,要么是OnCoordinatesPermittedAsync方法,要么是OnErrorRequestingCoordinatesAsync方法。

当用户授予应用程序权限(或者他们在某个时间点已经授予权限)时,将调用OnCoordinatesPermittedAsync方法,并给出表示为纬度经度对的地理位置。此方法从 JavaScript 调用,因此需要用JSInvokable属性装饰。调用时,将提供有效值的longitudelatitude。然后使用这些值来实例化组件的_coordinates对象。在此时,方法试图进行一系列 HTTP 调用,顺序依赖于先前的请求。天气服务 API 允许一组支持的语言。我们需要使用当前浏览器的语言,其由其首选的 ISO 639-1 两字母语言代码表示。通过语言代码,我们现在也可以推断出温度的默认测量单位,要么是Metric °C(摄氏度)或Imperial °F(华氏度)。我们需要了解天气 API 支持的语言,因此调用api/weather/languages HTTP 端点。这会返回一组WeatherLanguage对象。api/weather/latest HTTP 端点返回一个WeatherDetails对象,然后用于实例化天气组件的_model。在此同时,_geoCode对象正在从GeoLocationService.GetGeoCodeAsync获取。

当出现错误时,它们将被记录到浏览器的控制台,并将 _state 设置为 Error,导致标记为天气服务不可用的渲染。所有这些更改都通过调用 StateHasChanged 方法传递回组件。UI 在适用时将重新渲染。所有这些代码都包裹在 do/while 结构中。while 条件依赖于 _timer_cancella⁠tion​.Token。这是在需要定期更新值时使用的模式。它仅从回调函数中发生一次;之后每次调用都由 PeriodicTimer 控制和保护,该计时器将多个时钟周期合并为单个时钟周期,直到调用其 WaitForNextTickAsync 方法。

OnErrorRequestingCoordinatesAsync 方法仅在用户禁用或稍后通过更改浏览器设置为阻止位置权限时调用。当用户进行这些更改时,浏览器将提示用户刷新 Web 应用程序。本地浏览器权限 API 将更改应用程序渲染天气的能力。此回调方法和 OnCoordinatesPermittedAsync 方法是互斥的,并且将仅从客户端触发一次。但是,刷新将触发位置权限 API 的重新评估。

天气组件演示了如何使用 Blazor 数据绑定条件渲染各种 UI 元素,从显示用户的加载指示器 Spinner​Compo⁠nent,到鼓励用户启用位置权限的错误消息,再到为您的共享位置定制的天气。所有这些都是异步进行的,使用 DI 和强大的 C# 10 特性在一个周期性计时器上自动完成。周期性计时器通过天气组件实现其 IDisposable.Dispose 功能,因此在清理组件时,计时器的资源也会被清理。

从 C# 代码中,您会注意到 JavaScript.GetCoordinatesAsync 方法。这些坐标的到达是启动整个流程的原因。您会看到我试图传达的趋势;具体来说,我希望将所有 JavaScript 互操作函数封装为扩展方法。这将使单元测试和集成测试更加容易。有关测试的更多信息,请参见 第九章。考虑 JSRuntimeExtensions.cs C# 文件:

using Microsoft.JSInterop; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

namespace Learning.Blazor.Extensions;

internal static class JSRuntimeExtensions
{
    internal static ValueTask GetCoordinatesAsync<T>( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        this IJSRuntime jsRuntime,
        T dotnetObj,
        string successMethodName,
        string errorMethodName) where T : class => ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        jsRuntime.InvokeVoidAsync(
            "app.getClientCoordinates",
            DotNetObjectReference.Create(dotnetObj), ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            successMethodName,
            errorMethodName);

    // Additional methods omitted for brevity. }

1

JSRuntimeExtensions 类依赖于 Microsoft.JSInterop.IJSRuntime 类型。

2

GetCoordinatesAsync 扩展了 IJSRuntime 接口。

3

任何组件都可以调用此扩展方法,并将自身作为泛型类型参数传递。

4

DotNetObjectReference 是从给定的 dotnetObj 创建的,并传递给互操作调用。

Microsoft.JSInterop 是一个由框架提供的命名空间。有许多有用的类型,你应该熟悉它们:

DotNetObjectReference<TValue>

封装了一个 JS 互操作参数,指示值不应作为 JSON 序列化,而应作为引用传递。然后 JavaScript 使用此引用调用封装的 .NET 对象的方法。

IJSRuntime

代表一个可以分派调用的 JavaScript 运行时实例。这对于 Blazor Server 和 Blazor WebAssembly 都是通用的,只公开异步 API。

IJSInProcessRuntime

代表一个可以分派调用的 JavaScript 运行时实例。这对于 Blazor WebAssembly 是特定的,因为该进程是共享的,与 Blazor Server 不同。此接口继承了 IJSRuntime 并添加了一个同步的 TResult Invoke<TResult> 方法。

IJSUnmarshalledRuntime

代表一个可以分派调用的 JavaScript 运行时实例,无需进行 JSON 编组。目前仅在 WebAssembly 上受支持,并且出于安全原因,永远不会支持在运行在服务器上的 .NET 代码。这是一种高级机制,仅在性能关键的场景中使用。

此类扩展了 IJSRuntime 类型,GetCoordinatesAsync 方法返回 ValueTask 并接受单个泛型类型参数 T。该方法需要 T 实例和两个方法名,用于成功和错误回调。这些方法名从 JavaScript 使用,用于知道要调用哪些方法。

泛型类型参数 T 受限于 class;任何组件实例都可以。方法体是表达式体定义,不包含 asyncawait 关键字。这里不需要它们,因为这个扩展方法仅描述了预期的异步操作。使用此方法扩展的给定 jsRuntime 实例,调用 InvokeVoidAsync。这不应与 "async void" 混淆;虽然名称有点混乱,但它试图传达的是这个 JavaScript 互操作方法不希望返回结果。被调用的对应 JavaScript 函数是 app.getClientCoordinates

DotNetObjectReference.Create(dotnetObj) 封装了 dotnetObj,这是作为引用传递给 JavaScript 调用的内容。Blazor 的 JavaScript 双向互操作支持依赖于 DotNetObjectReference,并对这些类型有特殊理解。successMethodNameerrorMethodNamedotnetObj 实例上具有 JSInvokable 属性的实际方法名称。

在查看 Razor 标记、阴影组件 C# 和扩展方法功能后,让我们跟随调用到 JavaScript。考虑 app.js JavaScript 文件:

const getClientCoordinates = ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    (dotnetObj, successMethodName, errorMethodName) => {
        if (navigator && navigator.geolocation) { ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            navigator.geolocation.getCurrentPosition(
                (position) => { ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
                    const { longitude, latitude } = position.coords;
                    dotnetObj.invokeMethodAsync(
                        successMethodName, longitude, latitude);
                },
                (error) => { ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
                    const { code, message } = error;
                    dotnetObj.invokeMethodAsync(
                        errorMethodName, code, message);
                });
        }
    };

window.app = Object.assign({}, window.app, { ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
    getClientCoordinates,
    // omitted for brevity... });

1

getClientCoordinates 函数接受几个参数。

2

如果浏览器支持geolocation API,则调用getCurrentPosition方法。

3

一旦获取到position,对象引用会调用其成功方法。

4

当发生错误时,对象引用会调用其错误方法。

5

创建(或更新)window.app对象以包括getClient​Coordi⁠nates

JavaScript 文件定义了一个名为getClientCoordinatesconst函数,它声明了一个方法签名,期望一个dotnetObjsuccessMethodNameerrorMethodName

函数首先询问浏览器的navigatornavigator​.geo⁠loca⁠tion是否为真。如果是,将调用getCurrentPosition。此函数受浏览器位置权限保护。如果用户未提供权限,将会提示他们。如果他们拒绝此权限,API 将永远不会调用成功的回调。

当用户已允许访问位置服务时,此方法将立即调用第一个回调,并提供有效的positionposition对象具有latitudelongitude坐标。通过这些坐标,并引用dotnetObj与给定的successMethodName,它从 JavaScript 回调到.NET 代码。这将调用WeatherComponent.OnCoordinatesPermitte⁠d​Async方法,传递坐标。

如果出现任何原因导致错误,将调用第二个注册的回调,给定error对象。error对象有一个错误code值和一个message。可能的错误code值如下:

1: PERMISSION_DENIED

当页面无权限获取geolocation信息时

2: POSITION_UNAVAILABLE

当尝试获取geolocation信息时发生内部错误

3: TIMEOUT

当在获取geolocation信息之前到达允许的时间限制时

现在getClientCoordinates函数已完全定义,它将app对象添加到window作用域上。如果在您的应用程序中定义了多个 JavaScript 文件,并且这些文件使用相同的对象名称在window上,您可以使用 JavaScript 展开运算符将新函数附加到现有对象中,而不会完全覆盖它。

假设在提示时向应用程序授予权限,标记将在用户屏幕上呈现组件。

摘要

在本章中,应用程序开始起飞,您学会了如何通过使用经过身份验证的用户信息来更好地个性化我们应用程序的用户体验。在呈现以用户为中心的内容时,用户被提示允许使用其坐标的浏览器本地geolocation服务。利用这些个人信息,显示用户的本地当前天气和天气预报。您学会了如何通过各种控制结构(如@if@switch组件表达式)渲染组件变量。我们看到了如何在组件内使用服务,例如服务库,并使用HttpClient类型进行 HTTP 调用。您学会了一种模式,使用.NET中的PeriodicTimer定期自动更新值。除此之外,您还学会了如何通过两向 JavaScript 交互从 Blazor 使用浏览器的本地geolocation服务。应用程序通过消息向用户致以问候,一点笑声(或者如果笑话够糟糕的话就是翻白眼),以及个性化的天气预报。

在接下来的章节中,您将学习如何为 DI 注册客户端服务。您将了解如何通过组件定制和 Blazor 渲染分段来自定义各种授权状态。我将带您了解另一个 JavaScript 交互场景,在这里您将学习如何说服浏览器使用本地语音合成来发出自定义消息。在下一章中,您还将学习组件如何通过事件进行通信。

第四章:定制用户登录体验

在本章中,你将继续加深对如何在 Blazor WebAssembly 应用程序的上下文中认证用户以及定制认证体验的理解。你将看到一个熟悉的 Web 客户端启动配置模式,并继续探索应用程序的其他几个领域,如客户端服务的注册。从那里开始,我将进一步通过一个引人入胜的例子扩展你对 JavaScript 互操作性的知识,使用浏览器本地语音合成。你将了解应用程序头部功能,并看到在小型基础组件层次结构中实现模态对话框作为共享基础设施的模式。作为其中的一部分,你将学习如何编写和处理自定义事件。

关于 Blazor 认证的更多内容

使用应用程序时,你的身份用于唯一标识你作为应用程序用户。在大多数应用程序场景中都是如此,包括 Blazor 托管模型的默认配置。单个用户可以从多个客户端登录以使用 Learning Blazor 应用程序。然后用户经过身份验证,这意味着用户已经输入其凭据或通过身份验证工作流程被重定向。这些工作流程定义了必须准确和成功遵循的一系列顺序步骤,以生成经过身份验证的用户。以下是基本步骤:

  1. 获取授权码: 运行 /authorize 端点,提供请求的 scope,用户与框架提供的 UI 交互。

  2. 获取访问令牌: 成功后,从 /token 端点获取授权码令牌。

  3. 使用令牌: 使用访问令牌向各种 HTTP Web API 发送请求。

  4. 刷新令牌: 令牌通常会过期,过期时会自动刷新,并且已经通过身份验证的用户可以继续工作,而无需不断提示进行登录。

认证用户流程在图 4-1 中可视化。

图 4-1. 认证用户流程

我不会分享如何创建 Azure AD B2C 租户,因为这超出了本书的范围。此外,有很多关于这类事情的好资源。有关更多信息,请参阅微软的“创建 Azure Active Directory B2C 租户”教程。只需知道租户存在,并且包含两个应用程序注册。有一个配置为单页应用的 WebAssembly 客户端应用程序和一个配置为服务器的 API 应用程序。它非常功能丰富,可以定制客户端的 HTML 工作流程。作为管理员,我配置了存在的用户范围以及返回/请求的声明。

在认证过程中,可能的状态列在“定制客户端授权体验”章节中。

用户由一系列键/值对(KVP)表示,称为 claims。键是命名并且相当标准化。值存储在受信任的第三方实体中,并从其检索,也称为 authentication providers ——比如 Google、GitHub、Facebook、Microsoft 和 Twitter。

客户端自定义授权消息处理程序实现

Learning Blazor 应用程序定义了 Authorization​Mes⁠sageHandler 的自定义实现。在 Blazor WebAssembly 应用程序中,您可以使用框架提供的 AuthorizationMessageHandler 类型为传出的请求附加令牌。让我们看看 ApiAccessAuthorizationMessageHandler.cs 的 C# 文件以了解其实现:

namespace Learning.Blazor.Handlers;

public sealed class ApiAccessAuthorizationMessageHandler ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    : AuthorizationMessageHandler
{
    public ApiAccessAuthorizationMessageHandler( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        IAccessTokenProvider provider,
        NavigationManager navigation,      ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        IOptions<WebApiOptions> options) : base(provider, navigation) =>
        ConfigureHandler(
            authorizedUrls: new[]
            {
                options.Value.WebApiServerUrl,
                options.Value.PwnedWebApiServerUrl,
                "https://learningblazor.b2clogin.com"
            },
            scopes: new[] { AzureAuthenticationTenant.ScopeUrl }); ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
}

1

ApiAccessAuthorizationMessageHandler 是一个密封类。

2

其构造函数采用 IAccessTokenProviderNavigationManagerIOp⁠tions​<WebApiOptions> 参数。

3

base 构造函数采用 IAccessTokenProviderNavigationManager

4

构造函数调用 ConfigureHandler 方法,设置 authorizedUrlsscopes 属性。

框架公开了 AuthorizationMessageHandler。它可以注册为 HttpClient 实例的 HTTP 消息处理程序,确保访问令牌附加到传出的 HTTP 请求中。

实现将需要配置的 IOptions<WebApiOptions> 抽象。此代码请求 DI 服务提供程序解析强类型配置对象。

子类应使用基类的 ConfigureHandler 方法配置自身。给定 Web API 和 Pwned Web API 服务器的 URL,分配了 authorizedUrls 数组。该实现基本上将一些配置的 URL 设置为允许列表的 URL。它还配置了一个特定于应用程序的 scope URL,该 URL 设置为处理程序的 scopes 参数传递给 ConfigureHandler 函数。然后可以使用 AddHttpMessageHandler<ApiAccessAuthorizationMessageHandler> 流畅 API 调用将此处理程序添加到 IHttpClientBuilder 实例中,在此处映射和配置 HttpClient 用于 DI。稍后在 “Web.Client ConfigureServices 功能” 中展示了这一点。所有从配置的 HttpClient 实例发出的 HTTP 请求都将附加适当的 Authorization 标头与短期访问令牌。

使用 C# 10 的常量插值字符串,租户主机和公共应用标识符与请求 scope 的 API 格式化。这是一个在名为 Azure​Authen⁠ticationTenant.cs 的 C# 文件中定义的 const 值,位于 AzureAuthenticationTenant 类中:

namespace Learning.Blazor;

static class AzureAuthenticationTenant
{
    const string TenantHost =
        "https://learningblazor.onmicrosoft.com";

    const string TenantPublicAppId =
        "ee8868e7-73ad-41f1-88b4-dc698429c8d4";

    /// <summary>
    /// Gets a formatted string value
    /// that represents the scope URL:
    /// <c>{tenant-host}/{app-id}/User.ApiAccess</c>.
    /// </summary>
    internal const string ScopeUrl =
        $"{TenantHost}/{TenantPublicAppId}/User.ApiAccess";
}

该类被定义为static,因为我不打算让开发者创建对象实例。该对象公开一个名为ScopeUrlconst string值。第一个const stringTenantHost。第二个const string是公共应用程序标识符(App Id),或TenantPublicAppIdScopeUrl值格式为主机和 App Id,并以表示范围标识符"User.ApiAccess"的结尾段结束。

这只是一个实用的static class,是在源代码中硬编码 URL 的一个受欢迎的替代方案。这种方法更可取,因为完全限定的 URL 的每个部分都被指定为名称标识符。这些命名值用于表示 Learning Blazor Azure B2C 用户范围。此配置在部分 “Web.Client ConfigureServices 功能” 中处理。接下来,我们将介绍客户端授权 UX 的自定义。

自定义客户端的授权体验

客户端配置将处理设置客户端前端 Blazor 代码依赖于特定服务、客户端和经过身份验证的端点。用户体验了一个身份验证流程,而该流程的部分可从 Azure AD B2C 进行配置,我们也能够管理用户体验的各个方面,包括身份验证流程的各种状态前后的返回。这是通过 "/authentication/{action}" 页面路由模板实现的,属于 Authentication.razor 标记:

@page "/authentication/{action}"
@inherits LocalizableComponentBase<Authentication>

<div class="is-size-3">
    <RemoteAuthenticatorView ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        Action=@Action
        LogOut=@LocalizedLogOutFragment
        LogOutSucceeded=@LocalizedLoggedOutFragment
        LogOutFailed=@LocalizedLogOutFailedFragment
        LogInFailed=@LocalizedLogInFailedFragment>

        <LoggingIn> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            <LoadingIndicator Message=@Localizer["CheckingLoginState"]
                              HideLogo="true" />
        </LoggingIn>
        <CompletingLogOut>
            <LoadingIndicator Message=@Localizer["ProcessingLogoutCallback"]
                              HideLogo="true" />
        </CompletingLogOut>
        <CompletingLoggingIn>
            <LoadingIndicator Message=@Localizer["CompletingLogin"]
                              HideLogo="true" />
        </CompletingLoggingIn>

    </RemoteAuthenticatorView>
</div>

1

Authentication页面呈现一个RemoteAuthenticatorView组件。

2

存在几个组件模板,用于渲染身份验证流程的不同片段。

就像应用程序的大多数组件一样,Authentication页面是一个组件,也是 @inherits LocalizableComponentBase。它被认为是一个页面,因为它定义了一个 @page "/authentication/{action}" 指令。当客户端路由处理导航事件以响应浏览器 URL 请求 /authentication/{action} 路由时,该组件被渲染,其中 {action} 对应远程身份验证流程的状态。

组件标记将框架提供的RemoteAuthenticatorView组件与一个divclass属性包装在一起,以控制整体布局。

RemoteAuthenticatorView组件本身提供了自定义体验的能力。该组件公开了模板化的渲染片段参数。通过这种能力,您可以为以下身份验证流程状态提供自定义体验:

LogOut

在处理注销事件时显示的用户界面

LogOutSucceeded

在处理注销成功事件时显示的用户界面

LogOutFailed

在处理注销失败事件时显示的用户界面

LogInFailed

在处理登录失败事件时显示的用户界面

LoggingIn

在处理登录事件时显示的用户界面

CompletingLogOut

在处理完成登出事件时显示的用户界面

CompletingLoggingIn

在处理完成登录事件时显示的用户界面

由于这些都是框架提供的RenderFragment类型,我们可以自定义渲染内容。我们可以直接或使用多个模板化参数语法分配给RemoteAuthenticatorView组件的参数属性。LoggingInCompletingLogOutCompletingLoggingIn参数使用标记语法分配。

这三个参数使用自定义的LoadingIndicator组件分配。LoadingIndicator组件有条件地渲染 Blazor 标志以及加载指示器消息和动画/样式旋转图标。身份验证流的所有状态都隐藏了 Blazor 标志,但可以通过将LoadingIndicator.HideLogo参数设置为false来选择渲染它。每个状态都传递了本地化文本消息给加载指示器消息。这三个状态是过渡性的,因此在设计此方法时,我确定最好使用符合该期望的消息。

这并不意味着你不能使用幽默的胡说八道。认证流状态在你第一次学习时才会变得有趣——超过那个阶段,我们现在都是技术宅了,所以让我们有创意点吧!我们可以用随机的事实替换这些状态——谁不喜欢听一些有趣的事情呢?我留给你处理;给我发送一个拉取请求,也许我会创建一个社区支持的消息列表。关键是它是完全可定制的。以下列表包含我为应用程序配置的初始状态:

LoggingIn

依赖于带有以下值的"CheckingLoginState"本地化消息:"阅读关于了不起的阿达·洛夫莱斯(世界上第一个计算机程序员)的信息。"

CompletingLogOut

依赖于"ProcessingLogoutCallback"本地化消息:"事情并不总是表面看起来的。"

CompletingLogin

依赖于"CompletingLogin"本地化消息:"插入周围随机散落的电线。"

Authentication页面组件的阴影使用了略微不同的技术来满足RenderFragment委托。请记住,框架提供的RenderFragment是一个返回voiddelegate类型,并且它定义了一个RenderTreeBuilder参数。考虑到这一点,看看C#文件 Authentication.razor.cs

using Microsoft.AspNetCore.Components.Rendering; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

namespace Learning.Blazor.Pages
{
    public sealed partial class Authentication ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
 [Parameter] public string? Action { get; set; } = null!;

        private void LocalizedLogOutFragment( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            RenderTreeBuilder builder) =>
            ParagraphElementWithLocalizedContent(
                builder, Localizer, "ProcessingLogout");

        private void LocalizedLoggedOutFragment(
            RenderTreeBuilder builder) =>
            ParagraphElementWithLocalizedContent(
                builder, Localizer, "YouAreLoggedOut");

        private RenderFragment LocalizedLogInFailedFragment( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            string errorMessage) =>
            ParagraphElementWithLocalizedErrorContent(
                errorMessage, Localizer, "ErrorLoggingInFormat");

        private RenderFragment LocalizedLogOutFailedFragment(
            string errorMessage) =>
            ParagraphElementWithLocalizedErrorContent(
                errorMessage, Localizer, "ErrorLoggingOutFormat");

        private static void ParagraphElementWithLocalizedContent( ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
            RenderTreeBuilder builder,
            CoalescingStringLocalizer<Authentication> localizer,
            string resourceKey)
        {
            builder.OpenElement(0, "p");
            builder.AddContent(1, localizer[resourceKey]);
            builder.CloseElement();
        }

        private static RenderFragment ParagraphElementWithLocalizedErrorContent(
            string errorMessage, ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
            CoalescingStringLocalizer<Authentication> localizer,
            string resourceKey) =>
            builder =>
            {
                builder.OpenElement(0, "p");
                builder.AddContent(1, localizer[resourceKey, errorMessage]);
                builder.CloseElement();
            };
    }

1

组件使用Rendering命名空间来消耗RenderTreeBuilderRenderFragment类型。

2

Authentication页面有几种状态。

3

每个方法要么满足 RenderFragment 委托签名,要么返回 RenderFragment 类型。

4

当认证流程状态未能成功登录时,会呈现本地化消息。

5

ParagraphElementWithLocalizedContent 方法创建一个带有本地化消息的 p 元素。

6

ParagraphElementWithLocalizedErrorContent 方法通过接受可格式化的错误消息而不同。

RenderFragmentRenderFragment<T>RenderTreeBuilder 类型首次讨论在“Blazor 导航基础”,并且属于 Micro⁠soft​.AspNetCore.Components.Rendering 命名空间,而 Authentication 页面组件在 Learning.Blazor.Pages 中。

Authentication 页面组件是不透明的,因为它定义了一个名为 Actionstring 属性,并将其绑定到同名的框架提供的 RemoteAuthenticatorView.Action 属性。此组件还是一个部分类,作为带有代码后端的标记的影子。

LocalizedLogOutFragment 方法是 private 的;然而,部分类标记组件可以访问它。此方法在客户端浏览器完成 登出 认证流程处理后,被指定为渲染责任。它的参数是 RenderTreeBuilder builder 实例。该构建器立即与 Localizer 及常量字符串值 "ProcessingLogout" 一起传递给 ParagraphElementWithLocalizedContent 方法。该模式对于委托到相同辅助函数的 LocalizedLoggedOutFragment 方法重复,仅第三个参数变为 "YouAreLoggedOut"。这两种方法是 void 返回并接受 RenderTreeBuilder 参数的。这意味着它们匹配预期的 RenderFragment 委托签名。

出于教育目的,我将展示一些稍微不同的定制方法。注意 LocalizedLogInFailedFragment 不是 void 返回,也不接受 RenderTreeBuilder 参数。相反,该方法返回一个 RenderFragment 并接受一个 string。这是可能的,因为有两个 RenderFragment 委托:

  • delegate void RenderFragment(RenderTreeBuilder builder);

  • delegate RenderFragment RenderFragment<TValue>(TValue value);

ParagraphElementWithLocalizedContent 方法使用了 RenderTreeBuilder builderCoalescingStringLocalizer<Authentication> localizerstring resourceKey 参数。利用 builder,构建了一个开放的 <p> HTML 元素。根据 localizer[resourceKey] 的值添加了内容。最后,构建了闭合的 </p> HTML 元素。此方法被 log outlogged out 认证流程事件使用:

  • "ProcessingLogout" 渲染了“如果你没有改变世界,你就在原地踏步”的消息。

  • "YouAreLoggedOut" 渲染了“现在先告辞!”的消息。

ParagraphElementWithLocalizedErrorContent 方法与 ParagraphElementWithLocalizedContent 方法类似,定义了相同的参数,但返回的内容不同。在这种情况下,推断出了通用的 Render​Frag⁠ment<string> 委托类型,即使显式返回了 RenderFragment 委托类型。此方法被 log in failedlog out failed 认证流程事件使用:

  • 登录失败时,显示格式化消息 "There was an error trying to log you in: '{0}'"

  • 当登出失败时,显示格式化消息 "There was an error trying to log you out: '{0}'"

消息格式中的 {0} 值被用作未经处理的错误消息的占位符。

Web.Client ConfigureServices 功能

你应该回忆起 WebAssembly 应用的顶层命名约定,一个 C# 的顶层程序。这最初展示在 示例 2-1 中,并涵盖了 ConfigureServices 扩展方法。我们没有讨论客户端服务注册的具体细节。大部分工作都发生在 WebAssembly​HostBuilderExtensions.cs 的 C# 文件中:

namespace Learning.Blazor.Extensions;

internal static class WebAssemblyHostBuilderExtensions
{
    internal static WebAssemblyHostBuilder ConfigureServices(
        this WebAssemblyHostBuilder builder)
    {
        var (services, configuration) = ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
            (builder.Services, builder.Configuration);

        services.AddMemoryCache();
        services.AddScoped<ApiAccessAuthorizationMessageHandler>();
        services.Configure<WebApiOptions>(
            configuration.GetSection(nameof(WebApiOptions)));

        static WebApiOptions? GetWebApiOptions(
            IServiceProvider serviceProvider) =>
            serviceProvider.GetService<IOptions<WebApiOptions>>()
                ?.Value;

        var addHttpClient = ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            static IHttpClientBuilder (
                IServiceCollection services, string httpClientName,
                Func<WebApiOptions?, string?> webApiOptionsUrlFactory) =>
                services.AddHttpClient(
                    httpClientName, (serviceProvider, client) =>
            {
                var options = GetWebApiOptions(serviceProvider);
                var apiUrl = webApiOptionsUrlFactory(options);
                if (apiUrl is { Length: > 0 })
                    client.BaseAddress = new Uri(apiUrl);

                var cultureService =
                    serviceProvider.GetRequiredService<CultureService>();

                client.DefaultRequestHeaders.AcceptLanguage.ParseAdd(
                    cultureService.CurrentCulture.TwoLetterISOLanguageName);
            })
            .AddHttpMessageHandler<ApiAccessAuthorizationMessageHandler>();

        _ = addHttpClient(
            services, HttpClientNames.ServerApi,
            options => options?.WebApiServerUrl);
        _ = addHttpClient(
            services, HttpClientNames.PwnedServerApi,
            options => options?.PwnedWebApiServerUrl);
        _ = addHttpClient(
            services, HttpClientNames.WebFunctionsApi,
            options => options?.WebFunctionsUrl ??
                builder.HostEnvironment.BaseAddress);

        services.AddScoped<WeatherFunctionsClientService>();
        services.AddScoped( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            sp => sp.GetRequiredService<IHttpClientFactory>()
                .CreateClient(HttpClientNames.ServerApi));
        services.AddLocalization();
        services.AddMsalAuthentication(
            options =>
            {
                configuration.Bind(
                    "AzureAdB2C", options.ProviderOptions.Authentication);
                options.ProviderOptions.LoginMode = "redirect";
                var add = options.ProviderOptions.DefaultAccessTokenScopes.Add;

                add("openid");
                add("offline_access");
                add(AzureAuthenticationTenant.ScopeUrl);
            });
        services.AddOptions();
        services.AddAuthorizationCore();
        services.AddSingleton<SharedHubConnection>();
        services.AddSingleton<AppInMemoryState>();
        services.AddSingleton<CultureService>();
        services.AddSingleton(typeof(CoalescingStringLocalizer<>));
        services.AddScoped
            <IWeatherStringFormatterService, WeatherStringFormatterService>();
        services.AddScoped<GeoLocationService>();
        services.AddHttpClient<GeoLocationService>(client =>
        {
            var apiHost = "https://api.bigdatacloud.net"; ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            var reverseGeocodeClientRoute = "data/reverse-geocode-client";
            client.BaseAddress =
                new Uri($"{apiHost}/{reverseGeocodeClientRoute}");
            client.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip");
        });
        services.AddJokeServices();
        services.AddLocalStorageServices();
        services.AddSpeechRecognitionServices();

        return builder;
    }
}

1

(IServiceCollection services, IConfiguration configuration) 元组被用来捕获 servicesconfiguration 作为局部变量。

2

定义了一个静态的局部函数 addHttpClient

3

IHttpClientFactory 被添加为单例。

4

地理位置 API 已经配置了它的 HttpClient

文件作用域的命名空间是 Learning.Blazor.Extensions,它为客户端代码共享所有扩展功能。扩展类是 internal 的,并且像所有扩展类一样,必须是 static 的。ConfigureServices 方法以这种方式命名是因为它可能对习惯于启动约定的 ASP.NET Core 开发者来说很熟悉,但不必非得这样命名。为了允许方法链式调用,这个扩展方法返回它扩展的 WebAssemblyHostBuilder 对象。

builder 中声明并分配了 servicesconfiguration 对象。然后我们将上述的 ApiAccessAuthorizationMessageHandler 添加为作用域服务。配置了 WebApiOptions 实例,从解析的 configuration 实例的 WebApiOptions 对象中实现绑定。有一个名为 GetWebApiOptions 的静态本地函数,它返回一个有问题的 WebApiOptions 对象,给定一个 IServiceProvider 实例。

为了避免重复代码,addHttpClient 是一个静态的本地函数,封装了添加和配置 HTTP 客户端的过程。它接收 services、一个 httpClientName 和一个充当工厂的函数,并返回一个 IHttpClientBuilder 实例。该函数名为 webApiOptionsUrlFactory,它根据配置的选项对象返回一个可空的字符串。Lambda 表达式委托给 IServiceCollection 类型上的 AddHttpClient 扩展方法。这样配置 HTTP 客户端的基地址就是配置的 URL。还将 "Accept-Language" 默认请求头设置为当前配置的 Culture​Ser⁠vice 实例的 ISO 639-1 两字母代码。有两次调用这个 addHttp​Client 表达式:设置 Web API 服务器端点和 "Have I Been Pwned" 服务器端点。

添加了一些额外的服务,并配置了 Microsoft Authentication Library (MSAL) 服务,并绑定到 configuration 实例的 "AzureAdB2C" 部分。将 LoginMode 分配为 "redirect",导致应用程序重定向用户到 Azure AD B2C 完成登录。Lambda 表达式改进的另一个示例是声明并分配一个名为 add 的变量,它委托给集合方法上的 DefaultAccessTokenScopes.Add 功能。它期望一个字符串并返回 void。然后,add 变量被调用三次,添加 "openid""offline_access"ScopeUrl 范围。然后注册了剩余的许多服务。

添加并配置了 HttpClient,当 DI 解析 Geo​Lo⁠cationService 时将使用它。大数据云、API 主机和路由被用作客户端的基地址。然后注册了额外的依赖项,包括 Joke Services 和 Local Storage 包。IJSInProcessRuntime 被注册为单一实例,通过从 IJSRuntime 进行转换来解析。这只有在 Blazor WebAssembly 中才可能。这在 第七章 中有更详细的讨论。最后,返回了 builder,完成了流畅的 ConfigureServices API。

此单个扩展方法是负责配置客户端应用程序的 DI 的代码。您会注意到,已为将代表客户端转发令牌的 HttpClient 实例配置了 HTTP 消息处理程序,该处理程序来自 ApiAccessAuthorizationMessageHandler。这很重要,因为并非所有 API 端点都需要经过身份验证的用户,但只有在正确配置的情况下,这些端点才能访问。

本机语音合成

您已经看到如何为 DI 注册所有客户端服务,并在组件中消费注册的服务。在前一章中,您看到了主页如何呈现其磁贴内容。如果您还记得,每个磁贴都包含一些包括 Additive​S⁠peechComponent 的标记。虽然我向您展示了如何消费此组件,但我还没有展开其工作原理。任何连接到 Additive​S⁠peechComponent 的组件都将能够使用本机语音合成服务。点击在 图 4-2 中显示的音频按钮将触发语音合成服务来朗读磁贴的文本。

图 4-2. 主页磁贴

AdditiveSpeechComponent 公开一个 Message 参数。消费组件引用此组件并分配消息。考虑 Additive​S⁠peechComponent.razor 标记文件:

@inherits LocalizableComponentBase<AdditiveSpeechComponent>

<div class="is-top-right-overlay">
    <button class="button is-rounded is-theme-aware-button p-4 @_dynamicCSS"
        disabled=@_isSpeaking @onclick=OnSpeakButtonClickAsync>
        <span class="icon is-small">
            <i class="fas fa-volume-up"></i>
        </span>
    </button>
</div>

AdditiveSpeechComponent 继承了 LocalizableComponentBase 以使用注入到基类的三个常见服务。AppInMemoryStateCultureServiceIJSRuntime 服务足以保证此继承。

标记是一个带有描述性 class 属性的 div 元素,它覆盖了消费组件的右上角。div 元素是一个圆角和主题感知的 button 的父元素,带有一些动态 CSS。当 _isSpeaking 位评估为 true 时,按钮本身是 disabled 的。这是我们涵盖的第一个显示 Blazor 事件处理的组件标记。当用户点击按钮时,将调用 OnSpeakButtonClickAsync 事件处理程序。

您可以为所有有效的 DOM 事件指定事件处理程序。语法遵循一个非常具体的模式:@on{EventName}={EventHandler}。此语法作为元素属性应用,其中:

  • {EventName}DOM 事件名称

  • {EventHandler} 是将处理事件的方法的名称。

例如,@onclick=OnSpeakButtonClickAsyncOnSpeakButtonClickAsync 事件处理程序分配给元素的 click 事件;换句话说,当点击时,它调用 OnSpeakButtonClickAsync

OnSpeakButtonClickAsync 方法定义在组件的阴影中,并返回 Task。这意味着除了同步事件处理程序外,还完全支持异步事件处理程序。在 Blazor 事件处理程序中,UI 的更改会自动触发,因此您无需手动调用 StateHasChanged 以触发重新渲染。AdditiveSpeechComponent.razor.cs C# 文件如下所示:

namespace Learning.Blazor.Components
{
    public partial class AdditiveSpeechComponent ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        private bool _isSpeaking = false;
        private string _dynamicCSS
        {
            get
            {
                return string.Join(" ", GetStyles()).Trim();

                IEnumerable<string> GetStyles()
                {
                    if (string.IsNullOrWhiteSpace(Message))
                        yield return "is-hidden";

                    if (_isSpeaking)
                        yield return "is-flashing";
                };
            }
        }
 [Parameter]
        public string? Message { get; set; } = null!;

        async Task OnSpeakButtonClickAsync() ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        {
            if (Message is null or { Length: 0 })
            {
                return;
            }

            var (voice, voiceSpeed) = AppState.ClientVoicePreference;
            var bcp47Tag = Culture.CurrentCulture.Name;

            _isSpeaking = true;

            await JavaScript.SpeakMessageAsync(
                this,
                nameof(OnSpokenAsync),
                Message,
                voice,
                voiceSpeed,
                bcp47Tag);
        }
 [JSInvokable]
        public Task OnSpokenAsync(double elapsedTimeInMilliseconds) => ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            InvokeAsync(() =>
            {
                _isSpeaking = false;

                Logger.LogInformation(
                    "Spoke utterance in {ElapsedTime} milliseconds",
                    elapsedTimeInMilliseconds);

                StateHasChanged();
            });
    }
}

1

AdditiveSpeechComponent 维护几个组件状态位。

2

OnSpeakButtonClickAsync 方法有条件地发言消息。

3

OnSpokenAsync 方法在消息发言后被调用。

类中有一个 _isSpeaking 字段,默认为 false。此值用于确定如何渲染 <button>_dynamicCSS 属性仅具有 get 访问器,这使它成为只读。它确定应用于 <button> 的样式。Message 属性是一个 Parameter,允许从消费组件中进行赋值。

分配给按钮 click 事件处理程序的事件处理程序是 OnSpeakButtonClickAsync 方法。当从 Message 获得有意义的值时,此处理程序从内存中的应用程序状态服务获取 voicevoiceSpeed,以及当前文化的 最佳当前实践 (BCP 47) 语言标签 值。_isSpeaking 位设置为 true,并且等待调用 JavaScript.SpeakMessageAsync,给定 this 组件,OnSpokenAsync 回调的名称,MessagevoicevoiceSpeedbcp47Tag。这种模式可能看起来有些熟悉;无论您的应用程序需要多少依赖于浏览器的本机功能,都可以使用 JavaScript 互操作。

OnSpokenAsync 方法声明为 JSInvokable。由于此回调是异步且在不确定的时间发生,组件无法知道何时重新渲染,因此必须使用 StateHasChanged 明确告知它。

提示

每当定义 JSInvokable 方法改变组件状态时,必须调用 StateHasChanged 以信号重新渲染。

OnSpokenAsync 处理程序表达为 InvokeAsync,在呈现同步上下文中执行给定的工作项。它将 _isSpeaking 设置为 false,记录消息发言的总时间,然后通知组件其状态已更改。

标记是最小化的,代码背后简洁而强大。让我们深入了解 JSRuntimeExtensions.cs C# 文件,看看 SpeakMessageAsync 是什么样子的:

namespace Learning.Blazor.Extensions;

internal static partial class JSRuntimeExtensions
{
    internal static ValueTask SpeakMessageAsync<T>(
        this IJSRuntime jsRuntime,
        T dotnetObj,
        string callbackMethodName,
        string message,
        string defaultVoice,
        double voiceSpeed,
        string lang) where T : class =>
        jsRuntime.InvokeVoidAsync(
            "app.speak",
            DotNetObjectReference.Create(dotnetObj),
            callbackMethodName, message, defaultVoice, voiceSpeed, lang);
}

使用有意义的名称扩展 IJSRuntime 功能让我感到满足。我在这些小胜利中找到了快乐,但在阅读代码时也带来了更愉快的体验。将其读作 JavaScript.SpeakMessageAsync 是自我描述的。此扩展方法委托给 IJSRuntime.InvokeVoidAsync 方法,调用 "app.speak" 给定 DotNetObjectReference、回调方法名、messagevoice、语速和语言。我本可以直接从组件调用 InvokeVoidAsync,但我更喜欢扩展方法的描述性方法名。这是我推荐的模式,因为它有助于封装逻辑,并且更容易从多个调用点消费。此扩展方法依赖的 JavaScript 代码位于 wwwroot/js/app.js 文件中:

const cancelPendingSpeech = () => { ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    if (window.speechSynthesis
    && window.speechSynthesis.pending === true) {
        window.speechSynthesis.cancel();
    }
};

const speak = (dotnetObj, callbackMethodName, message, ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
               defaultVoice, voiceSpeed, lang) => {
    const utterance = new SpeechSynthesisUtterance(message);
    utterance.onend = e => {
        if (dotnetObj) {
            dotnetObj.invokeMethodAsync(callbackMethodName, e.elapsedTime)
        }
    };

    const voices = window.speechSynthesis.getVoices(); ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    try {
        utterance.voice =
            !!defaultVoice && defaultVoice !== 'Auto'
                ? voices.find(v => v.name === defaultVoice)
                : voices.find(v => !!lang &&
                    v.lang.startsWith(lang)) || voices[0];
    } catch { }
    utterance.volume = 1;
    utterance.rate = voiceSpeed || 1;

    window.speechSynthesis.speak(utterance); ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
};

window.app = Object.assign({}, window.app, {
    speak,
    // omitted for brevity... });

// Prevent the client from speaking when the user closes the tab or window. window.addEventListener('beforeunload', _ => { ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
    cancelPendingSpeech();
});

1

作为一种安全措施,防止用户关闭标签页或窗口时浏览器发声,定义了 cancelPendingSpeech 方法。

2

speak 函数创建并准备一个 utterance 实例以便使用。

3

utterance.voice 属性设置为根据 defaultVoicelang 参数过滤的 voices 数组。

4

utterance 被传递给 speechSynthesis.speak 方法。

5

beforeunload 事件处理程序被定义为取消任何待定的语音。

cancelPendingSpeech 函数检查 window.speechSynthesis 对象是否为真值(在此情况下,表示不为 nullundefined)。如果队列中有任何待定的话语,将调用 window.speechSynthesis.cancel() 方法,从队列中移除所有话语。

"app.speak" 方法被定义为名为 speak 的函数。它有六个参数,感觉有点多。如果你愿意,可以将其参数化为单个顶层对象,但这将需要一个新的模型和额外的序列化。我可能会限制参数列表不超过六个,但编程中一切都是权衡取舍。speak 方法的主体通过给定的 message 实例化 SpeechSynthesisUtterance。此对象公开了在话语结束时触发的 end/onend 事件。分配了内联事件处理程序,依赖于给定的 dotnetObj 实例和 callbackMethodName。话语结束时,事件触发并回调到调用组件的给定方法。

尝试为 utterance 分配所需的语音。这可能会存在问题且容易出错,因此它的尝试是脆弱的,并用try/catch保护起来。如果成功,那很好,如果不成功,因为浏览器将选择默认语音,所以这并不是什么大问题。音量设置为1,并设置 utterance 的朗读速度。

准备好utterance实例后,调用window.speechSynthe⁠sis​.speak(utterance)。这将把 utterance 加入本地语音合成队列。当utterance到达队列末尾时,它会被朗读出来。"app.speak"名称来自于speak函数const添加到一个新实例或现有实例中的方式。

如果正在朗读长文本,并且用户关闭了应用程序的浏览器选项卡或窗口,但保持浏览器开启,则 utterance 将继续朗读。为避免此行为,我们将在窗口卸载时调用cancelPendingSpeech

AdditiveSpeechComponent可以捆绑到一个单独的 Razor 组件项目中,并分发给消费应用程序。这种方法非常有益,因为它暴露功能并与消费者共享。此组件的所有功能都封装在一起,并且可以通过 NuGet 分享。在撰写本文时,该组件仍作为 Web.Client 项目的一部分,但这并不意味着它不能轻松地在复杂性上发展或添加新功能。一旦在 NuGet 上,它可以被其他.NET 开发人员用于消费开源项目。

“学习 Blazor”示例应用程序演示了如何创建 Razor 项目并从 Blazor Web 客户端消费它们。Web.Client 项目依赖于 Web.TwitterComponents Razor 类库。Web.TwitterComponents 项目封装了一些特定于 Twitter 的组件。Web.Client 消费这些组件并向 Blazor Web 客户端公开它们。

分享和消费自定义组件

要使用组件,您需要在消费组件的标记中引用它。Blazor 提供了许多开箱即用的组件,从布局到导航,从标准表单控件到错误边界,从页面标题到头部输出等等。参见微软的“ASP.NET Core 内置 Razor 组件”文档以获取可用组件的列表。

当内置组件不足够时,您可以转向自定义组件。还有许多其他供应商提供的组件。此外,还有一个庞大的开源社区构建组件库。作为开发人员,在构建 Blazor 应用程序时,很有可能会从所有供应商提供的组件库中找到所需的内容。请考虑以下供应商资源列表:

GitHub 上有一个社区策划的列表,称为Blazor 精选,这是另一个很好的资源。有时,您可能需要框架、供应商甚至整个社区中不可用的功能。发生这种情况时,您可以编写自己的组件库。

由于 Blazor 建立在 Razor 之上,所有组件都是 Razor 组件。它们可以通过它们的.razor文件扩展名轻松识别。

Chrome:重载的术语

对于 GUI 应用程序,有一个经过多年重载的老术语。术语chrome指的是 UI 的一个元素,用于显示用户可用的各种命令或功能。例如,Learning Blazor 示例应用程序的chrome是顶部栏。其中包括应用程序的顶级导航、主题显示图标以及各种弹出模态组件的按钮,例如通知切换、任务列表切换和登录/登出按钮。这在第二章的图 2-2 和 2-3 中有展示。当我提到 chrome 时,我不是在说网络浏览器。我们已经稍微讨论了导航和路由,所以让我们专注于模态模块化。

模态模块化与 Blazor 组件层次结构

大多数应用程序需要与用户进行交互并提示他们输入。应用程序的导航是一种用户体验,用户输入的一个例子是用户点击链接以访问他们想要访问的路由,然后应用程序执行一个操作。有时,我们需要提示用户使用键盘而不是鼠标。我们询问用户的问题主要根据领域而异,例如,“您的电子邮件地址是什么?”或“您要发送的消息是什么?”答案根据控件类型而异,意味着自由形式文本行或文本区域,或复选框、选择列表或按钮。Blazor 完全支持所有这些。您可以订阅本机 HTML 元素事件并在 Razor C#组件逻辑中处理它们。还有本机集成和模态/输入绑定验证、模板化和组件层次结构。

其中一个控件是名为ModalComponent的自定义控件。该组件将用于应用程序的各种用例中。它将具有一个继承的组件来示例化组件子类模式,这在 C#中很常见,但作为 JavaScript SPAs 的编程模式却不常用。考虑ModalComponent.razor的标记文件:

<div class="modal has-text-left @_isActiveClass"> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    <div class="modal-background" @onclick=@CancelAsync></div>
    <div class="modal-card">
        <header class="modal-card-head">
            <p class="modal-card-title"> @TitleContent ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            </p>
            <button class="delete" aria-label="close" @onclick=@CancelAsync>
            </button>
        </header>

        <section class="modal-card-body"> @BodyContent ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        </section>

        <footer class="modal-card-foot is-justify-content-flex-end">
            <div> @ButtonContent ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            </div>
        </footer>
    </div>
</div>

1

最外层元素是带有modal类的div

2

标题被表示为带有modal-card-title类的header元素。

3

主体是一个带有modal-card-body类的section

4

footer使用modal-card-foot类进行样式设置。

HTML 是一个模态框样式的div,其class属性绑定到_isActiveClass值,这意味着模态框的状态,无论是激活(显示)还是不激活,都依赖于组件变量。它具有应用覆盖层的背景样式,使得这个元素作为模态对话框弹出显示给用户。背景div元素本身通过调用CancelAsync来处理用户点击,并覆盖整个页面。

HTML 语义准确,代表着行业标准化的三部分头部/主体/页脚布局。第一个模板占位符是@TitleContent。这是一个必需的RenderFragment,允许消费组件提供自定义的标题标记。header还包含一个button,当点击时将调用CancelAsync

BodyContent适当地样式化为模态框的主体,它是一个sectionHTML 元素,在header之下和footer之上语义化地定位。

模态框的footer包含必需的ButtonContent标记。总体来说,这个模态框表示一个常见的对话框组件,消费者可以插入他们定制的标记和相应的提示。

组件阴影定义了组件的参数属性、事件、组件状态和功能。考虑C#文件 ModalComponent.razor.cs

namespace Learning.Blazor.Components; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

public partial class ModalComponent
{
    private string _isActiveClass => IsActive ? "is-active" : "";
 [Parameter]
    public EventCallback<DismissalReason> Dismissed { get; set; } ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
 [Parameter]
    public bool IsActive { get; set; }
 [Parameter, EditorRequired]
    public RenderFragment TitleContent { get; set; } = null!;
 [Parameter, EditorRequired]
    public RenderFragment BodyContent { get; set; } = null!;
 [Parameter, EditorRequired]
    public RenderFragment ButtonContent { get; set; } = null!;

    /// <summary>
    /// Gets the reason that the <see cref="ModalComponent"/> was dismissed.
    /// </summary>
    public DismissalReason Reason { get; private set; }

    /// <summary>
    /// Sets the <see cref="ModalComponent"/> instance's
    /// <see cref="IsActive"/> value to <c>true</c> and
    /// <see cref="Reason"/> value as <c>default</c>.
    /// It then signals for a change of state; this rerender will
    /// show the modal.
    /// </summary>
    public Task ShowAsync() => ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        InvokeAsync(() => (IsActive, Reason) = (true, default));

    /// <summary>
    /// Sets the <see cref="ModalComponent"/> instance's
    /// <see cref="IsActive"/> value to <c>false</c> and
    /// <see cref="Reason"/> value as given <paramref name="reason"/>
    /// value. It then signals for a change of state;
    /// this rerender will cause the modal to be dismissed.
    /// </summary>
    public Task DismissAsync(DismissalReason reason) =>
        InvokeAsync(async () =>
        {
            (IsActive, Reason) = (false, reason);
            if (Dismissed.HasDelegate)
            {
                await Dismissed.InvokeAsync(Reason);
            }
        });

    /// <summary>
    /// Dismisses the shown modal; the <see cref="Reason"/>
    /// will be set to <see cref="DismissalReason.Confirmed"/>.
    /// </summary>
    public Task ConfirmAsync() => DismissAsync(DismissalReason.Confirmed);

    /// <summary>
    /// Dismisses the shown modal; the <see cref="Reason"/>
    /// will be set to <see cref="DismissalReason.Cancelled"/>.
    /// </summary>
    public Task CancelAsync() => DismissAsync(DismissalReason.Cancelled);

    /// <summary>
    /// Dismisses the shown modal; the <see cref="Reason"/>
    /// will be set to <see cref="DismissalReason.Verified"/>.
    /// </summary>
    public Task VerifyAsync() => DismissAsync(DismissalReason.Verified);
}

public enum DismissalReason ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
{
    Unknown, Confirmed, Cancelled, Verified
};

1

ModalComponent类属于Learning.Blazor.Components命名空间。

2

几个属性共同代表了必需的组件参数、事件、模板和组件状态值的示例。

3

至于功能性和模块化,模态框组件可以显示和同样容易地解除显示。

4

enum DismissalReason类型在相同的文件范围命名空间内定义。

提示

在 Blazor 中,当您定义一个作为Parameter使用的属性,并且希望该参数是必需的时,可以使用框架提供的EditorRequired属性。这指定了组件参数在编辑器中必须由用户提供。如果未提供此参数的值,编辑器或构建工具可能会提供警告,提示用户指定一个值。

ModalComponent类定义了几个属性:

_isActiveClass

作为计算属性的私有字符串,评估 IsActive 属性并在 true 时返回 "is-active"。这与模态框的标记绑定,divclass 属性包含一些静态类和动态绑定的值。

Dismissed

一个组件参数,类型为EventCallback<DismissalReason>。事件回调接受消费者的委托分配,其中事件从此组件流向感兴趣的接收者。

IsActive

一个 bool 值,表示模态框当前是否正在向用户显示。此参数通常通过调用 DismissAsync 隐式设置, 是必需的。

TitleContent

表示标题头部模板占位符的命名 RenderFragment 类型。

BodyContent

表示正文内容的命名 RenderFragment 类型。

ButtonContent

表示页脚控件模板占位符的命名 RenderFragment 类型。

Reason

模态解雇的原因是“未知”,“确认”,“取消”或“验证”。

ModalComponent 通过模板化功能暴露了模块化,消费者可以钩入组件。消费者可以调用这些返回 public Task 的异步操作方法中的任何一个:

ShowAsync

立即向用户显示模态框。此方法通过调用 InvokeAsync 表达,给定一个 Lambda 表达式,将 IsActive 的值设置为 true,并将 Reason 分配为 default(或 DismissalReason.Unknown)。此时不需要调用 State​Ha⁠sChanged。异步操作支持将根据需要自动重新渲染 UI 组件。

DismissAsync

给定解雇原因,立即解雇模态框。IsActive 状态设置为 false,这将有效隐藏组件。

ConfirmAsync

将解雇原因设置为Confirmed,并委托给DismissAsync

CancelAsync

将解雇原因设置为Cancelled,并委托给DismissAsync

VerifyAsync

将解雇原因设置为Verified,并委托给DismissAsync

enum DismissalReason 类型定义了四种状态:Unknown(默认),ConfirmedCancelled(可以由用户在模态框外点击隐式发生),以及Verified

探索 Blazor 事件绑定

ModalComponentVerificationModalComponent 消费。让我们看看在 VerificationModalComponent.razor 标记文件中如何实现这一点:

@inherits LocalizableComponentBase<VerificationModalComponent>

<ModalComponent @ref="_modal" Dismissed=@OnDismissed> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    <TitleContent> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        <span class="icon pr-2">
            <i class="fas fa-robot"></i>
        </span>
        <span>@Localizer["AreYouHuman"]</span>
    </TitleContent>
    <BodyContent> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        <form>
            <div class="field">
                <label class="label">@_math.HumanizeQuestion()</label>
                <div class="field-body">
                    <div class="field">
                        <p class="control is-expanded has-icons-left"> @{
                                var inputValidityClass =
                                    _answeredCorrectly is false
                                        ? " invalid"
                                        : "";

                                var inputClasses = $"input{inputValidityClass}";
                            } <input @bind="_attemptedAnswer" ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
                                class=@inputClasses
                                type="text"
                                placeholder="@Localizer["AnswerFormat",
                                    _math.GetQuestion()]" />
                            <span class="icon is-small is-left">
                                <i class="fas fa-info-circle"></i>
                            </span>
                        </p>
                    </div>
                </div>
            </div>
        </form>
    </BodyContent>
    <ButtonContent> ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        <button class="button is-info is-large is-pulled-left" @onclick=Refresh>
            <span class="icon">
                <i class="fas fa-redo"></i>
            </span>
            <span>@Localizer["Refresh"]</span>
        </button>
        <button class="button is-success is-large" @onclick=AttemptToVerify>
            <span class="icon">
                <i class="fas fa-check"></i>
            </span>
            <span>@Localizer["Verify"]</span>
        </button>
    </ButtonContent>
</ModalComponent>

1

_modal 引用连接了 OnDismissed 事件处理程序。

2

TitleContent 渲染了本地化的提示消息和机器人图标。

3

BodyContent呈现一个带有单个输入字段的表单。

4

_attemptedAnswer属性绑定到输入字段的value属性。

5

按钮在ButtonContent模板中呈现。

VerificationModalComponent标记依赖于ModalComponent,并使用@ref="_modal"语法捕获模态的引用。Blazor 将自动从引用组件标记的实例值中分配_modal字段。在VerificationModalComponent内部,依赖的ModalComponent.Dismissed事件由OnDismissed处理程序处理。换句话说,ModalComponent.Dismissed是一个必需的参数,它是组件将触发的事件。VerificationModalComponent.OnDismissed事件处理程序被分配用来处理它。这是自定义事件绑定,其中消费组件处理依赖组件的暴露参数化事件。

验证模态框的标题内容(TitleContent)提示用户“您是人类吗?”。

BodyContent标记包含一个本地 HTML form元素。在这个标记中有一个简单的label和相应的文本input元素。label从评估的_math.GetQuestion()调用中将问题插入标记中(稍后会详细介绍_math对象)。尝试的答案input元素根据是否正确回答问题动态绑定了 CSS 类。

input元素的value绑定到_attemptedAnswer变量。它还具有从本地化的答案格式中绑定的placeholder,这将为用户提供关于预期输入的线索。

ButtonContent标记具有两个按钮,一个用于通过Refresh方法刷新问题,另一个用于尝试验证答案(通过AttemptToVerify方法)。这是本地事件绑定的示例,其中button元素将它们的click事件绑定到相应的事件处理程序。

ModalComponent本身是一个基本模态,而VerificationModalComponent使用基本模态并使用非常特定的验证提示。VerificationModalComponent将如图 4-3 所示呈现。

图 4-3. VerificationModalComponent的示例渲染

VerificationModalComponent的组件影子位于VerificationModalComponent.cs文件中:

namespace Learning.Blazor.Components
{
    public sealed partial class VerificationModalComponent ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        private AreYouHumanMath _math = AreYouHumanMath.CreateNew();
        private ModalComponent _modal = null!;
        private bool? _answeredCorrectly = null!;
        private string? _attemptedAnswer = null!;
        private object? _state = null;
 [Parameter, EditorRequired] ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        public EventCallback<(bool IsVerified, object? State)>
            OnVerificationAttempted
            {
                get;
                set;
            }

        public Task PromptAsync(object? state = null) ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        {
            _state = state;
            return _modal.ShowAsync();
        }

        private void Refresh() => ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            (_math, _attemptedAnswer) = (AreYouHumanMath.CreateNew(), null);

        private async Task OnDismissed(DismissalReason reason) ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        {
            if (OnVerificationAttempted.HasDelegate)
            {
                await OnVerificationAttempted.InvokeAsync(
                    (reason is DismissalReason.Verified, _state));
            }
        }

        private async Task AttemptToVerify() ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
        {
            if (int.TryParse(_attemptedAnswer, out var attemptedAnswer))
            {
                _answeredCorrectly = _math.IsCorrect(attemptedAnswer);
                if (_answeredCorrectly is true)
                {
                    await _modal.DismissAsync(DismissalReason.Verified);
                }
            }
            else
            {
                _answeredCorrectly = false;
            }
        }
    }
}

1

VerificationModalComponent包装了ModalComponent以添加验证层。

2

事件回调显示验证尝试是否成功。

3

Prompt 方法委托给 ModalComponent.ShowAsync 方法。

4

Refresh 方法重置 _math_attemptedAnswer 字段。

5

当模态框被解除时,OnDismissed 事件处理程序被调用。

6

AttemptToVerify 方法如果答案正确则解除模态框。

VerificationModalComponent 类定义以下字段:

_math

数学对象是 AreYouHumanMath 类型,并从 AreYouHumanMath.CreateNew() 工厂方法分配。这是一种自定义类型,用于表示一个人类可能能在脑海中解决的简单数学问题。

_modal

代表相应标记的 ModalComponent 实例字段。将在此实例上调用方法,例如 ShowAsync 以向用户显示模态。

_answeredCorrectly

三态 bool 用于确定用户是否正确回答了问题。

_attemptedAnswer

可空的 string 绑定到 input 元素,用于存储用户输入的值。

_state

一个表示不透明值的状态对象,代表消费者的身份。当消费组件调用 PromptAsync 时,如果他们传递 state,它将分配给 _state 变量,然后在调用 OnVerificationAttempted 事件回调时返回给调用者。

OnVerificationAttempted 是一个必需的参数。回调签名传递一个元组对象,其第一个值表示验证尝试是否成功。当用户正确输入正确答案时为 true;否则为 false。第二个值是一个可选的状态对象。

PromptAsync 方法用于显示模态对话框,并接受一个可选的状态对象。

Refresh 方法绑定到刷新按钮,并用于重新随机化正在提问的问题。AreYouHumanMath.CreateNew() 工厂方法被重新分配给 _math 字段,并将 _attemptedAnswer 设置为 null

OnDismissed 方法是 ModalComponent.Dismissed 事件回调的处理程序。当基础模态框被解除时,它将具有 DismissalReason。与 reason 一起,并且当 OnVerificationAttempted 有委托时,传递它以传递是否验证通过以及在提示时保存的任何状态。

AttemptToVerify 方法绑定到验证按钮。调用时,它将尝试将 _attemptedAnswer 解析为 int,并询问 _math 对象答案是否正确。如果为真,_modal 将被解除为 Verified。这将间接调用 Dismissed

我打赌你一定想知道 AreYouHumanMath 对象是什么样子的——编写这个可爱小对象确实很有趣。看看 AreYouHumanMath.cs C# 文件吧:

namespace Learning.Blazor.Models;

public readonly record struct AreYouHumanMath( ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    byte LeftOperand,
    byte RightOperand,
    MathOperator Operator = MathOperator.Addition)
{
    private static readonly Random s_random = Random.Shared;

    /// <summary>
    /// Determines if the given <paramref name="guess"/> value is correct.
    /// </summary>
    /// <param name="guess">The value being evaluated for correctness.</param>
    /// <returns>
    /// <c>true</c> when the given <paramref name="guess"/> is correct,
    /// otherwise <c>false</c>.
    /// </returns>
    /// <exception cref="ArgumentException">
    /// An <see cref="ArgumentException"/> is thrown when
    /// the current <see cref="Operator"/> value is not defined.
    /// </exception>
    public bool IsCorrect(int guess) => guess == Operator switch ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        MathOperator.Addition => LeftOperand + RightOperand,
        MathOperator.Subtraction => LeftOperand - RightOperand,
        MathOperator.Multiplication => LeftOperand * RightOperand,

        _ => throw new ArgumentException(
            $"The operator is not supported: {Operator}")
    };

    /// <summary>
    /// The string representation of the <see cref="AreYouHumanMath"/> instance.
    /// <code language="cs">
    /// <![CDATA[
    /// var math = new AreYouHumanMath(7, 3);
    /// math.ToString(); // "7 + 3 ="
    /// ]]>
    /// </code>
    /// </summary>
    /// <exception cref="ArgumentException">
    /// An <see cref="ArgumentException"/> is thrown when
    /// the current <see cref="Operator"/> value is not defined.
    /// </exception>
    public override string ToString() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    {
        var operatorStr = Operator switch
        {
            MathOperator.Addition => "+",
            MathOperator.Subtraction => "-",
            MathOperator.Multiplication => "*",

            _ => throw new ArgumentException(
                $"The operator is not supported: {Operator}")
        };

        return $"{LeftOperand} {operatorStr} {RightOperand} =";
    }

    public string GetQuestion() => $"{this} ?";

    public static AreYouHumanMath CreateNew( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        MathOperator? mathOperator = null)
    {
        var mathOp =
            mathOperator.GetValueOrDefault(RandomOperator());

        var (left, right) = mathOp switch
        {
            MathOperator.Addition => (Next(), Next()),
            MathOperator.Subtraction => (Next(120), Next(120)),
            _ => (Next(30), Next(30)),
        };

        (left, right) = (Math.Max(left, right), Math.Min(left, right));

        return new AreYouHumanMath(
            (byte)left,
            (byte)right,
            mathOp);

        static MathOperator RandomOperator()
        {
            var values = Enum.GetValues<MathOperator>();
            return values[s_random.Next(values.Length)];
        };

        static int Next(byte? maxValue = null) =>
            s_random.Next(1, maxValue ?? byte.MaxValue);
    }
}

public enum MathOperator { Addition, Subtraction, Multiplication }; ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)

1

AreYouHumanMath是一个位置记录,定义了一个简单的数学问题。

2

通过IsCorrect方法来测试guess是否是正确答案。

3

ToString方法用于显示数学问题。

4

CreateNew方法用于创建一个新的随机数学问题。

5

MathOperator枚举定义了问题是加法、减法还是乘法。

AreYouHumanMath对象是一个readonly record struct。因此,它是不可变的,但允许使用with表达式来创建一个克隆。它是一个位置记录,意味着只能使用所需参数构造函数实例化。需要leftright操作数的值,但数学运算符是可选的,默认为加法。

Random.Shared是在.NET 6 中引入的,用于分配static readonly Random实例。

IsCorrect方法接受一个guess。仅当给定的guess等于左右操作数的数学运算结果时,该方法才会返回true。例如,new AreYouHumanMath(7, 3).IsCorrect(10)会评估为true,因为七加三等于十。该方法使用Operator上的 switch 表达式表示。每个操作符的情况都表达为相应的数学运算。

ToStringGetQuestion方法返回应用操作符和两个操作数的数学表示。例如,new AreYouHumanMath(7, 3).ToString()将评估为"7 + 3 =",而new AreYouHumanMath(7, 3).GetQuestion()将是"7 + 3 = ?"

CreateNew方法大量依赖于Random类,以确保每次调用时都会提出一个新问题。当提供可选的mathOperator时,将使用它;否则,会确定一个随机的运算符。有了运算符,操作数是随机确定的;左操作数是最大值,右操作数是最小值。

对于enum MathOperator,我特意决定避免除法。使用随机数会更加复杂,涉及到除以0和精度的问题。相反,我希望进行的数学运算更多是你能够在脑海中完成的。

VerificationModalComponent用作Contact.razor页面上的垃圾邮件阻止器,我们将在第 8 章中详细讨论。ModalComponent还被Audio​De⁠scriptionComponentLanguageSelectionComponent使用。这两个组件紧挨着讨论的ThemeIndicatorComponent,在“本地主题感知”中讨论。

概要

你已经学到了关于 Blazor 应用程序开发的广泛和可配置性的很多知识。在 Blazor WebAssembly 应用程序的背景下,你对如何认证用户有了更好的理解。我展示了一个熟悉的 web 客户端启动配置模式,其中注册了所有客户端服务。我们定制了授权用户体验。我们探讨了浏览器本地语音合成的实现。最后,我们阅读了应用程序头部和模态对话框层次能力内的所有标记和 C# 代码。现在我们对 Blazor 事件管理、触发和消费有了更好的理解。

在下一章中,我将向你展示一个将应用程序本地化为 40 种不同语言的模式。我将展示如何使用完全免费的 GitHub Action 结合 Azure Cognitive Services 来机器翻译资源文件。你将学到如何使用框架提供的 IStrin⁠g​Localizer<T> 类型以及静态资源文件来实现本地化。你还将学习各种格式化细节。

第五章:本地化应用程序

在本章中,我将向您展示如何本地化 Blazor WebAssembly 应用程序。以学习 Blazor 应用程序为例,我将展示应用程序如何自动本地化为数十种语言。您将了解到 Blazor WebAssembly 如何识别客户端浏览器对应的静态资源文件的语言。您还将学习如何使用框架提供的 IStringLocalizer<T> 接口类型。此外,我还将向您展示使用 Azure Cognitive Services Translator 在静态文件上使用 GitHub Action 进行机器翻译的一种可能方法。

我们生活在一个全球化社会,一款只面向某一群体的应用程序会让人失望。这不仅会极大地影响不会讲该应用程序语言的用户的用户体验,而且如果该应用程序参与在线购物体验,也会对销售产生不利影响。这就是本地化的用武之地。

什么是本地化?

本地化 是将静态资源(例如资源文件中的资源)翻译为应用程序计划支持的特定语言的行为。当您的应用程序支持多种语言时,它将为每个支持的区域设置有各种资源文件。在 .NET 中,本地化使用 XML 格式维护特定区域的资源文件,并使用 .resx 文件扩展名。

注意

本地化并不等同于全球化。全球化是指您在编写应用程序时使其易于本地化。有关全球化的概述,请参阅微软的 “全球化” .NET 文档

学习 Blazor 应用程序支持大约 40 种语言。借助 AI 的帮助,支持这些语言是可能的。作为一名讲英语的开发者,我用英语编写我的资源文件。这意味着资源文件名以 .en.resx 结尾,其他支持的区域设置则是通过自动化的拉取请求进行机器翻译。您将在本章后面学习如何在您的应用程序中使用这一功能。

作为 .NET 的一部分,Blazor WebAssembly 可以动态确定从哪个翻译版本的文件中提取资源。浏览器将确定其使用的语言,并且此信息在 Web.Client 应用程序中是可用的。使用适当的资源文件,应用程序将根据各种数字和日期格式规则呈现正确的内容。支持应用程序的多种语言即是 本地化。有关在 .NET 中的本地化的更多信息,请参阅微软的 “.NET 中的本地化” 文档

警告

仅使用机器翻译文本进行应用本地化并不理想。相反,开发人员应聘请专业翻译人员来帮助维护机器翻译后的文件。这种方法可以提供更可靠的翻译。虽然不是免费的,但你得到了你付出的代价。机器翻译并不总是准确的,但它们力求自然,并能满足有限文本的简单用户需求。

本地化主要通过应用程序的资源文件完成。资源(.resx)文件将其语言编码为子扩展名.{lang-id}.resx,其中{lang-id}占位符是浏览器指定的语言。该应用程序通过LanguageSelectionComponent暴露语言配置,后者使用ModalComponent提示用户从应用程序支持的语言列表中进行选择。这些语言可通过"api/cultures/all"端点访问应用程序。

本地化过程

让我们准备将我们的 Learning Blazor 示例应用本地化。要本地化任何 Blazor WebAssembly 应用程序,您需要以下内容:

  • 一个客户端对Microsoft.Extensions.Localization NuGet 包的引用

  • 在注册服务进行 DI 时调用AddLocalization()的能力

  • 根据用户偏好和应用程序启动时更新文化的能力,如在“在启动时检测客户端文化”中所示

  • Web.Client 项目可用的资源文件

  • IStringLocalizer<T>实例注入到使用本地化的组件中

  • 通过它们的索引器方法 API 调用本地化实例的机会

Blazor 依赖于CultureInfo.DefaultThreadCurrentCultureCultureInfo.DefaultThreadCurrentUICulture值来确定使用哪个资源文件。

让我们花点时间了解这个过程是如何组合在一起的。Blazor 应用程序需要注册本地化服务。当 Web.Client 项目启动时,所有它依赖的服务都注册为可以通过框架提供的 DI 服务提供程序发现。每个客户端应用实例都使用内部 HTTP 客户端和业务逻辑服务,其中一个特别来自Microsoft.Extensions.Localization NuGet 包。该包含了使用本地化所需的服务。回想一下“Web.Client ConfigureServices 功能”中的设置IServiceCollection时,我们调用了AddLocalization()。该方法来自本地化 NuGet 包,将IStringLocalizer<T>服务类型添加到客户端应用程序的 DI 容器中。

使用IStringLocalizer<T>类型,组件可以使用翻译文件中的资源。每个 Blazor 组件可能对应多个资源文件。IStringLocalizer<T>的一个实例对应于单个T类型,其中T可以是任何可能有资源的类型。

您可以使用包含这些常见值资源的共享对象(SharedResource)。当您使用IStringLocalizer<T>IStringLocal⁠izer​<SharedResource>时,反复注入这两种类型会变得冗余。为了解决这种冗余,存在一个自定义的CoalescingStringLocalizer<T>服务,用于合并这些多个本地化类型,偏向于T类型,并在找不到值时合并到SharedResource类型。常见文本示例包括 UI 上以命令为中心的按钮的文本,如“确定”或“取消”。这种方法可以在其他 Blazor 应用程序中使用,或者任何本地化的 .NET 应用程序中使用。请考虑以下C​​oalescingStringLocalizer.cs C# 文件:

namespace Learning.Blazor.Localization;

public sealed class CoalescingStringLocalizer<T> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
{
    private readonly IStringLocalizer<T> _localizer = null!;
    private readonly IStringLocalizer<SharedResource> _sharedLocalizer = null!;

    public CoalescingStringLocalizer( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        IStringLocalizer<T> localizer,
        IStringLocalizer<SharedResource> sharedLocalizer) =>
        (_localizer, _sharedLocalizer) = (localizer, sharedLocalizer);

    /// <summary>
    /// Gets the localized content for the current sub-component,
    /// relying on the contextually appropriate
    /// <see cref="IStringLocalizer{T}"/> implementation.
    /// </summary>
    internal LocalizedString this[string name] ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        => _localizer[name]
        ?? _sharedLocalizer[name]
        ?? new(name, name, false);

    /// <summary>
    /// Gets the localized content for the current sub-component,
    /// relying on the contextually appropriate
    /// <see cref="IStringLocalizer{T}"/> implementation.
    /// </summary>
    internal LocalizedString this[string name, params object[] arguments] ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        => _localizer[name, arguments]
        ?? _sharedLocalizer[name, arguments]
        ?? new(name, name, false);
}

1

CoalescingStringLocalizer<T>对象依赖于两个字段:

  • _localizerT 类型的本地化程序,其中T是一个组件

  • _sharedLocalizerSharedResource 类型的本地化程序

2

构造函数需要两个本地化实例,并分配给类范围的字段。

3

两个索引器中的第一个接受资源的name,并在两个本地化实例上合并。当找不到时,返回给定的name

4

第二个索引器接受资源的namearguments。当找不到资源时,它也会聚合两个本地化实例,并返回给定的name

CoalescingStringLocalizer<T> 在我们的 Learning Blazor 应用程序的 Web.Client 项目中广泛使用,并注入到LocalizableComponentBase<T>中。从LocalizableComponentBase<T>类型继承的组件将可以访问Localizer属性。LocalizableComponentBase<T>是框架提供的ComponentBase类的后代。LanguageSelectionComponent<T>为绑定到Localizer提供了一个很好的示例,该组件负责暴露客户端语言配置。在接下来的部分中,我们将探讨此组件如何绑定本地化内容并允许用户选择应用程序的语言。

语言选择组件

尽管允许用户选择应用程序的语言不是本地化过程的具体部分,但提供这一功能非常重要。在本地化应用程序时,应考虑包含这样的功能。

语言选择组件在用户选择顶级语言导航按钮时提示用户选择他们所需的语言。其标记引入了一个新的框架提供的组件用于处理错误,即 ErrorBoundary 组件。每当编写不处理错误的代码时,例如没有包装在 try/catch 块中的潜在错误代码,该代码有可能会对组件的正确渲染能力产生负面影响。因此,作为写入 try/catch 的替代方案,可以通过显示特定于错误的标记处理错误。 ErrorBoundary 组件允许消费者模板化成功逻辑的 ChildContent 和在抛出错误时的 ErrorContent。即使组件遇到错误也能有条件地渲染内容。例如,如果服务应用程序支持的语言的端点不可用,ErrorBoundary 组件可以渲染一个禁用的按钮。

假设没有错误存在,模态对话框充当用户提示。当显示 LanguageSelectionComponent 时,点击其 button 将显示类似于 图 5-1 的模态对话框。

图 5-1. 使用模态展示的 LanguageSelectionComponent 示例渲染

现在,让我们来看一下 LanguageSelectionComponent.razor 标记文件,负责渲染模态对话框:

@inherits LocalizableComponentBase<LanguageSelectionComponent>

<ErrorBoundary> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    <ChildContent>
    <span class="navbar-item">
        <button class="button level-item is-rounded is-warning"
            title=@Localizer["Language"] @onclick=ShowAsync>
            <span class="icon">
                <i class="fas fa-language"></i>
            </span>
        </button>
    </span>
    </ChildContent>
    <ErrorContent>
    <span class="navbar-item">
        <button class="button level-item is-rounded is-warning"
            disabled title=@Localizer["Language"]>
            <span class="icon">
                <i class="fas fa-language"></i>
            </span>
        </button>
    </span>
    </ErrorContent>
</ErrorBoundary>

<ModalComponent @ref="_modal"> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    <TitleContent>
        <span class="icon pr-2">
            <i class="fas fa-cogs"></i>
        </span>
        <span>@Localizer["ChangeLanguage"]</span>
    </TitleContent>

    <BodyContent> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        <form>
            <div class="field">
                <p class="control has-icons-left">
                    <span class="select is-medium is-fullwidth">
                        <select id="languages" class="has-dotnet-scrollbar"
                            @bind=_selectedCulture> @if (_supportedCultures?.Any() ?? false)
                    {
                        @foreach (var kvp
                            in _supportedCultures.OrderBy(c => c.Key.Name))
                        {
                            var (culture, _) = kvp; <option selected="@(lcid == culture.LCID)"
                                    value="@culture"> @(ToDisplayName(kvp)) </option> }
                    } </select>
                    </span>
                    <span class="icon is-small is-left">
                        <i class="fas fa-globe"></i>
                    </span>
                </p>
            </div>
        </form>
    </BodyContent>

    <ButtonContent> ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        <div class="buttons are-large">
            <button class="button is-success"
                @onclick="ConfirmAsync">
                <span class="icon">
                    <i class="fas fa-check"></i>
                </span>
                <span>@Localizer["Okay"]</span>
            </button>
            <button class="button is-danger"
                @onclick=@(() => _modal.CancelAsync())> <span class="icon">
                    <i class="fas fa-times"></i>
                </span>
                <span>@Localizer["Cancel"]</span>
            </button>
        </div>
    </ButtonContent>
</ModalComponent>

1

ErrorBoundary 组件用于包装潜在的错误组件。

2

ModalComponent 用于渲染模态对话框。

3

主体是 HTML form 元素。

4

ButtonContent 渲染了取消和确认按钮。

LanguageSelectionComponent 标记文件以 ErrorBoundary 组件开头。其 ChildContent 渲染一个 button,将其 onclick 事件处理程序绑定到 ShowAsync 方法。 ErrorContent 渲染一个禁用的 button。两个渲染片段使用相同的语法调用 LocalizableComponentBase.Localizer 实例。 @Localizer["Language"] 调用请求本地化器为 "Language" 键获取相应的值。这返回一个由框架提供的 LocalizedString 类型,表示特定于区域设置的 stringLocalizedString 类型定义了一个隐式操作符作为 string

本地化服务了解到对于 IStringLocalizer<LanguageSelectionComponent>,它们应该按命名约定查找资源。例如,LanguageSelectionComponent.razorLanguageSelectionCompo⁠nent​.razor.cs 文件是相关的,因为它们是同一个对象的两个 partial class 定义。这个组件的资源文件也有相同的关系。我为此定义了一个单独的 LanguageSelectionComponent.razor.en.resx 资源文件,稍后会在 Example 5-1 中显示。

使用 @ref="_modal" 语法将 ModalComponent 捕获为引用并分配给 _modal 字段。BodyContent 包含一个本地 HTML form 元素,并绑定到本地 HTML selection 元素。每个 option 节点从当前迭代中的 culture 绑定到 value 属性。当当前文化的语言代码标识符(或 LCID)与正在迭代的文化匹配时,它被 selected。使用 ToDisplayName 辅助方法将 cultureazureCulture 对象转换为它们的文本表示。

ButtonContent 定义了两个按钮。第一个按钮是 "Okay" 按钮,点击时调用 ConfirmAsync。另一个按钮是 "Cancel" 按钮,点击时调用 _modal.CancelAsync()

当用户展开所有支持的文化时,对话框将呈现类似于 Figure 5-2 中显示的内容。

图 5-2. 一个示例 LanguageSelectionComponent 渲染,带有打开的模态对话框和展开的文化选择。
提示

在编写本书时,关于 ASP.NET Core 在组件使用文件作用域命名空间时定位资源的能力存在 一个 bug。因此,不显示任何文本或用户输入的组件不需要本地化。因此,它们可以自由使用文件作用域命名空间。您将在代码中看到这两种命名空间格式,不必惊慌。

对应的组件部分代码反映在 LanguageSelection​Com⁠ponent.razor.cs 的 C# 文件中。让我们接下来看看它:

namespace Learning.Blazor.Components
{
    public partial class LanguageSelectionComponent
    {
        private IDictionary<CultureInfo, AzureCulture>? _supportedCultures; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        private CultureInfo _selectedCulture = null!;
        private ModalComponent _modal = null!;
 [Inject] HttpClient Http { get; set; } = null!;
 [Inject] public NavigationManager Navigation { get; set; } = null!;

        protected override async Task OnInitializedAsync() ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        {
            var azureCultures =
                await Http.GetFromJsonAsync<AzureTranslationCultures>(
                    "api/cultures/all",
                    DefaultJsonSerialization.Options);

            _supportedCultures =
                Culture.MapClientSupportedCultures(azureCultures?.Translation);
        }

        private static string ToDisplayName( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            KeyValuePair<CultureInfo, AzureCulture> culturePair)
        {
            var (culture, azureCulture) = culturePair;
            return $"{azureCulture.Name} ({culture.Name})";
        }

        private async Task ShowAsync() => await _modal.ShowAsync(); ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)

        private async Task ConfirmAsync()
        {
            var forceRefresh =
                _selectedCulture is not null &&
                _selectedCulture != Culture.CurrentCulture;

            if (forceRefresh)
            {
                JavaScript.SetItem(
                    StorageKeys.ClientCulture, _selectedCulture!.Name);
            }

            await _modal.ConfirmAsync();

            if (forceRefresh)
            {
                Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
            }
        }
    }
}

1

组件状态由私有字段管理。

2

OnInitializedAsync 方法用于从服务器获取支持的文化。

3

ToDisplayName 辅助方法用于将 cultureazure​Cul⁠ture 对象转换为它们的文本表示。

4

几种方法向组件公开了 _modal 功能。

LanguageSelectionComponent 定义了几个字段和几个注入的属性:

_supportedCultures

一个IDictionary<CultureInfo, AzureCulture>字段,表示支持的文化。字段的键是框架提供的CultureInfo,它们的值是自定义的AzureCulture位置记录类。

_selectedCulture

此值在 Razor 标记中绑定到select元素,并对应于用户选择的期望文化。

_modal

ModalComponent的引用。通过此引用,我们将调用ShowAsyncConfirmAsync来显示和确认模态框。

Http

由框架提供的HttpClient实例,用于获取支持的文化。

Navigation

由框架提供的NavigationManager,用于强制重新加载当前页面。在更改文化时,需要重新加载整个应用程序。

当组件初始化(OnInitializedAsync)时,调用"api/cultures/all"服务器端点。_supportedCultures映射从返回的值中分配,并计算交集的支持文化集。这些值反映了重叠客户端文化和服务器支持集的示例 Venn 图中的集合,在图 5-3 中显示,其中每个小圆表示一个两字母语言标识符。

图 5-3。支持的文化是客户端和服务器文化的交集。

剩余的方法依赖于_modal实例:

ShowAsync

委托给_modal.ShowAsync()

ConfirmAsync

如果用户选择了不同的文化,将强制重新加载,并将新值持久化到本地存储。模态框通过调用_modal.Confirm​A⁠sync()关闭。

LanguageSelectionComponent支持 41 种语言。从LanguageSelectionComponent.razor文件中显示的早期标记中,您可能已经注意到@Localizer使用给定的参数调用其索引器:

"Language"

绑定在<button title=@Localizer["Language"]></button>标记中

"ChangeLanguage"

绑定到TitleContent标记

"Okay"

绑定到ButtonContent确认按钮文本

"Cancel"

绑定到ButtonContent取消按钮文本

每个键(或名称)对应于Lan⁠guage​Selec⁠tionComponent的资源文件。考虑LanguageSelectionComponent.razor.en.resx资源文件,显示在示例 5-1 中。

示例 5-1。LanguageSelectionComponent的资源文件
<?xml version="1.0" encoding="utf-8"?>
<root>
    <!-- XML schema omitted for brevity -->

    <data name="ChangeLanguage" xml:space="preserve">
        <value>Change the current language?</value>
    </data>
    <data name="Language" xml:space="preserve">
        <value>Language</value>
    </data>
</root>

每个data节点具有一个name属性。此name与您在请求IStringLocalizer<T>相应值时使用的名称相匹配。返回的value对应于资源的英文版本。考虑LanguageSelection​Component.razor.es.resx资源文件,显示在示例 5-2 中。

示例 5-2。Web.Client/Components/LanguageSelectionComponent.razor.es.resx
<?xml version="1.0" encoding="utf-8"?>
<root>
    <!-- XML schema omitted for brevity -->

    <data name="ChangeLanguage" xml:space="preserve">
        <value>¿Cambiar el idioma actual?</value>
    </data>
    <data name="Language" xml:space="preserve">
        <value>Idioma</value>
    </data>
</root>

此资源文件的子扩展名为 .es.resx 而不是 .en.resx,每个 value 都是西班牙语。这些资源文件仅包含两个 data 节点。在标记中引用了两个额外的名称,这就是 CoalescingString​Lo⁠calizer<T> 的用武之地。"Okay""Cancel" 资源属于 SharedResource 对象资源文件的一部分。这种聚合方法确实会带来轻微的性能影响,但说它是轻微的是一种夸大。在我所有的测试中,这已被证明是不可测量的。

警告

此代码完全功能齐全且易读。虽然花时间尝试优化它可能看起来有利,但你应该牢记教授唐纳德·克努斯的著名警句。他警告开发者,“过早优化是所有邪恶之源”[¹]。

使用 GitHub Actions 自动化翻译

如果适合你的应用程序,你可能希望支持尽可能多的语言。这可以通过为应用程序支持的每种语言创建静态资源文件来手动完成,或者你可以考虑更自动化的方法。你如何管理创建和维护多个资源文件?如果你要手动完成这项工作,当单个翻译文件发生变化时,你必须手动更新每个相应的支持语言翻译文件。许多更大的应用程序将有团队负责翻译任务,监控翻译文件的变化,并创建拉取请求来进行适当的更改,这可能成本高昂。作为一种替代方案,你可以自动化这一过程。

你可以创建自己的 GitHub Action 来自动化翻译,或者你可以使用 GitHub Action 市场上提供的现有 GitHub Action 来完成同样的工作。如果这对你来说是新的,我建议使用一个现有的 GitHub Action,比如我为这本书制作的一个叫做Machine Translator的 GitHub Action。它依赖于Azure 的认知服务文本翻译器服务,并且是用 TypeScript 编写的。Learning Blazor 仓库中的 Machine Translator 工作流需要我的 Azure 加密订阅密钥,以便它可以访问基于云的神经机器翻译技术。这允许将静态的 .resx 资源文件作为输入进行源到文本的翻译,并为非英语语言写出翻译文本。在 GitHub 仓库中,作为管理员你可以访问设置 > 秘密 页面,在那里你将添加若干仓库密钥,这些密钥将在操作运行时被使用。

如果你正在跟随学习 Blazor 应用程序库的克隆版本,请查看微软的“快速入门:Azure 认知服务翻译器”文档。通过 Azure Translator 订阅密钥,你可以运行操作并在 GitHub Action 的输出中查看结果。你需要设置 AZURE_TRANSLATOR_SUBSCRIPTION_KEYAZURE_TRANSLATOR_ENDPOINTAZURE_TRANSLATOR_REGION 三个密钥。

要自动化翻译 Learning Blazor 应用程序,我们从以下 machine-translation.yml 工作流文件 开始:

name: Azure Translation ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

on:
  push:
    branches: [ main ]
    paths:
    - '**.en.resx'
    - '**.razor.en.resx'

env:
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

jobs:
  translate:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - name: Resource translator ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        id: translator
        uses: IEvangelist/resource-translator@main
        with:
          subscriptionKey: ${{ secrets.AZURE_TRANSLATOR_SUBSCRIPTION_KEY }}
          endpoint: ${{ secrets.AZURE_TRANSLATOR_ENDPOINT }}
          region: ${{ secrets.AZURE_TRANSLATOR_REGION }}
          sourceLocale: 'en'
          toLocales: |
          '["af","ar","az","bg","ca","cs","da","de","el","fa",' +
          '"fi","fr","he","hi","hr","hu","id","it","ja","ko",' +
          '"la","lt","mk","nb","nl","pl","pt","ro","ru","sv",' +
          '"sk","sl","es","sr-Cyrl","sr-Latn","th","tr","uk",' +
          '"vi","zh-Hans","zh-Hant"]'

      - name: Create pull request ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        uses: peter-evans/create-pull-request@v3.4.1
        if: ${{ steps.translator.outputs.has-new-translations }} == 'true'
        with:
          title: '${{ steps.translator.outputs.summary-title }}'
          body: '${{ steps.translator.outputs.summary-details }}'

1

machine-translation.yml 工作流的名称为 Azure Translation

2

此工作流中的主要步骤是运行 IEvangelist/resource-translator@main GitHub Action。

3

仅当 translator 步骤输出更改时才运行 create-pull-request 步骤。

GitHub Action 工作流文件将 name 描述为 "Azure Translation",稍后在 GitHub Action 实时状态屏幕中使用。on 语法用于描述操作将在何时运行;此操作在任何 .en.resx 文件更新并 pushedmain 分支时运行。托管环境将 secrets 上下文对象的 GitHub 令牌值映射为 GITHUB_TOKEN。工作流在 jobs 节点中定义了一个单一作业,其中命名为 translate 操作 runs-on: ubuntu-latest(Ubuntu 的最新支持版本)。与大多数其他 GitHub Action 工作流文件一样,它需要使用 action/checkout@v2 动作检出存储库的源代码。

steps 节点的第二步描述了我的 IEvangelist/resource-translator@main GitHub Action。此引用被标识为 translator,稍后允许工作流通过表达式按名称(或 id)引用它。with 语法允许此步骤提供所需的 GitHub Action 输入。with 节点中列出的键直接映射到 GitHub Action 发布为输入的名称:

subscriptionKey

从名为 AZURE_TRANSLATOR_SUBSCRIPTION_KEY 的存储库 secrets 上下文中使用表达式语法的字符串值。此值应来自 Azure Translator 资源的 Keys and Endpoint 页面,KEY 1 或 KEY 2 都是有效的。

endpoint

从名为 AZURE_TRANSLATOR_ENDPOINT 的存储库 secrets 上下文中使用表达式语法的字符串值。此值应来自 Azure Translator 资源的 Keys and Endpoint 页面,KEY 1 或 KEY 2 都是有效的。

region

从名为 AZURE_TRANSLA⁠TOR​_REGION 的存储库 secrets 上下文中使用表达式语法的字符串值。

sourceLocale

一个等于 'en' 字符串的文字值。

toLocales

一个字符串数组,使用文字语法指定要翻译为的本地化值。

现在,我们需要一个条件运行的操作。我们可以使用 GitHub Action Marketplace 中提供的另一个操作。GitHub 用户和社区成员 Peter Evans 提供了一个 create-pull-request 操作,我们可以使用它。当资源文件发生更改时,Create pull request 步骤将仅运行。这发生在 translator 步骤产生的输出指示创建了新的翻译时。这些拉取请求是自动化的,并显示为来自 github-actions 机器人的请求。拉取请求的描述(title)和 body 是根据上一步骤的输出动态确定的。如果你想看看 GitHub Action 机器人生成的实际拉取请求是什么样子,请查看 automated pull request #13,它在 Learning Blazor 示例应用的 GitHub 仓库中。

现在我们已经介绍了资源文件的使用及其生成的翻译文件,接下来我们将探索各种本地化格式的示例。

本地化实战

到目前为止,我们已经详细检查了 XML 资源文件,并看到了使用框架提供的 IStringLocalizer<T> 抽象访问这些文件中数据的机制。在本节中,您将了解 Learning Blazor 示例应用中的“Have I Been Pwned”(HIBP)服务的工作原理及其内容如何受到本地化的影响。您还将了解 LocalizableComponentBase<T>​.Local⁠izer 属性的作用。例如,此功能很好地配合了本地化和非本地化内容,如您所见。随着我们的深入,您将更多了解应用如何使用 HIBP 服务。该站点具有一个 Pwned?! 的顶级导航,点击此链接将用户导航到 https://webassemblyof.net/pwned 路由,如 Figure 5-4 所示。

图 5-4. 使用 Breaches 和 Passwords 子路由呈现 Pwned 页面

/pwned 路由呈现一个页面,其中有两个按钮,每个按钮链接到其相应的子路由。Breaches 按钮链接到 /pwned/breachesPasswords 按钮链接到 /pwned/passwords

Pwned.razor 页面的标记如下:

@page "/pwned"
@attribute [Authorize]
@inherits LocalizableComponentBase<Pwned>

<PageTitle> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png) Pwned </PageTitle>

<div class="tile is-ancestor">
    <div class="tile is-vertical is-centered is-7">
        <div class="tile">
            <div class="tile is-parent is-clickable"
                @onclick=@NavigateToBreaches>
                <article class="tile is-child notification is-warning">
                    <p class="title"><span class="is-emoji">&#x1F92C;</span> @Localizer["Breaches"] ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
                    </p>
                </article>
            </div>
            <div class="tile is-parent is-clickable"
                @onclick=@NavigateToPasswords>
                <article class="tile is-child notification is-danger">
                    <p class="title"><span class="is-emoji">&#128273;</span> @Localizer["Passwords"] ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
                    </p>
                </article>
            </div>
        </div>
    </div>
</div>

1

页面使用框架提供的 PageTitle 组件。这将浏览器标签标题设置为 Pwned

2

按钮文本使用 Localizer 实例和 "Breaches" 资源进行本地化。

3

按钮文本使用 Localizer 实例和 "Passwords" 资源进行本地化。

这是你第一次在本书中看到 @attribute 指令。此指令允许您向页面添加任何有效的类作用域属性。在这种情况下,Authorize 属性被添加到页面中。此属性由框架用于确定用户是否已登录。如果用户未登录,则会被重定向到登录页面。接下来,让我们看看组件的阴影。考虑 Pwned.razor.cs C# 文件:

namespace Learning.Blazor.Pages
{
    public partial class Pwned
    {
 [Inject]
        public NavigationManager Navigation { get; set; } = null!; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

        private void NavigateToBreaches() => ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            Navigation.NavigateTo("pwned/breaches");

        private void NavigateToPasswords() =>
            Navigation.NavigateTo("pwned/passwords");
    }
}

1

Pwned 页面依赖注入的 NavigationManager 实例,使用其导航功能。

2

页面有两种导航方法,分别在调用时导航到 BreachesPasswords 子路由。

Pwned 页面有以下英文 Pwned.razor.en.resx 资源文件:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <!--
    Schema omitted for brevity...
  -->

  <data name="Breaches" xml:space="preserve"> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    <value>Breaches</value>
  </data>
  <data name="Passwords" xml:space="preserve"> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    <value>Passwords</value>
  </data>
</root>

1

第一个 data 节点命名为 "Breaches",并有一个名为 Breaches 的子 value 节点。

2

最后一个 data 节点命名为 "Passwords",并有一个名为 Passwords 的子 value 节点。

你可能想知道为什么我们不只使用 name 属性。因为在本地化时,name 不会被翻译,只有 value。这基于资源文件 XML 的架构,适用于所有 .NET 应用程序。

Breaches 页面允许用户自由输入任何电子邮件地址,并检查其是否曾经泄露过数据。页面显示如 图 5-5 所示。

图 5-5. Breaches 页面渲染

当应用程序的语言设置为(es-ES)时,页面显示如 图 5-6 所示。

图 5-6. Breaches 页面的西班牙语渲染

在输入电子邮件地址之前,屏幕上显示了几个文本值,如 图 5-5 所示:

';--have i been pwned?

此值未被翻译,并且在标记中硬编码,因为它是一个名称,不应该被翻译。

pwned

同样,此值也不会被翻译,因为它是一个在互联网上广为人知的术语,无需翻译。

Email address

此值已被翻译,并在 Localizer 中命名为 "EmailAddress"

Breaches

此值已被翻译,并在 Localizer 中命名为 "Breaches"

Apply filter

此值已被翻译,并在 Localizer 中命名为 "ApplyFilter"

不显示整个标记文件,我将专注于与本地化相关的标记的特定部分。考虑来自 Breaches.razor 标记文件的以下片段,重点是电子邮件地址输入字段:

<InputText @bind-Value=_model.EmailAddress
    @ref=_emailInput class="input is-large"
    autocomplete="hidden"
    placeholder=@Localizer["EmailAddress"] />

这是用于电子邮件地址输入的标记。框架提供的InputText用于呈现电子邮件地址的文本输入。它的placeholder显示了用户的提示,表达了给定 HTML input元素的期望值。在这种情况下,渲染了本地化字符串"电子邮件地址"

想象一下,用户开始搜索数据泄露。当电子邮件地址未在任何数据泄露记录中找到(例如fake-email@not-real.com),结果将使用带有参数重载的IStringLocalizer<T>索引器进行格式化。考虑来自Breaches.razor标记文件的以下片段:

<a class="panel-block is-size-5" disabled>
    <span class="panel-icon">
        <i class="fas fa-check" aria-hidden="true"></i>
    </span>
    @Localizer["NoBreachesFormat", _model.EmailAddress!]
</a>

在这种情况下,Localizer实例调用其索引器,并传递"NoBreachesFormat"资源名称和模型的EmailAddress。这将呈现如图 5-7 所示。

缺乏数据泄露确实是一种解脱;但这并非完全现实。您的电子邮件地址很可能已经在数据泄露中受到影响。例如,当用户搜索test@user.org时,Breaches页面会查询 Web.Api 服务的/api/pwned/breaches端点。返回结果后,组件更新以显示数据泄露列表。要验证Breaches页面是否能成功与 Web.PwnedApi 项目的端点进行通信,我们可以使用已知已被泄露七次的测试用户电子邮件地址。如果您访问学习 Blazor 示例应用程序的Breaches页面并输入test@user.org电子邮件地址,您将看到它确实已经被pwned了七次,如图 5-8 所示。Breaches页面使用自定义共享的ModalComponent,在单击结果行时显示每次泄露的详细信息。

图 5-7. 在没有结果时呈现的Breaches页面

图 5-8. 对于 test@user.org 的Breaches页面呈现

假设您对了解更多关于 Dropbox 数据泄露感兴趣。您可以单击泄露以获取更多信息。此操作显示模态框,并将所选的数据泄露记录作为组件参数传递,如图 5-9 所示。

图 5-9. Dropbox 数据泄露模态框

为了帮助进一步理解本地化的工作原理,我们将查看Breaches.razor.en.resx XML 的翻译资源文件:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <!--
 Schema omitted for brevity...
 -->
  <data name="Breaches" xml:space="preserve">
    <value>Breaches</value>
  </data>
  <data name="EmailAddress" xml:space="preserve">
    <value>Email address</value>
  </data>
  <data name="Filter" xml:space="preserve">
    <value>Apply filter</value>
  </data>
  <data name="InvalidEmailAddress" xml:space="preserve">
    <value>This email is invalid</value>
  </data>
  <data name="NoBreachesFormat" xml:space="preserve">
    <value>No breaches found for {0}.</value>
  </data>
</root>

在此资源文件中,有几对英文值的名称-值对。其他语言将有其翻译值。大多数组件都继承自自定义的LocalizableComponentBase或框架提供的IStringLocalizer。然后,每个组件定义资源文件,并在运行时使用本地化实例检索资源。

接下来,让我们看看Passwords页面及其Passwords.razor标记的几个片段:

<div class="field has-addons">
    <p class="is-fullwidth control has-icons-left @(loadingClass)">
        <InputText id="password" @ref=_passwordInput
            type="password" autocomplete="hidden"
            @bind-Value=_model.PlainTextPassword
            class="input is-large"
            DisplayName=@Localizer["Password"] ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
            placeholder=@Localizer["Password"] />
        <span class="icon is-small is-left">
            <i class="fas fa-key"></i>
        </span>
    </p>
    <div class="control">
        <button type="submit" disabled="@(_isFormInvalid)"
                class="button is-danger is-large @(loadingClass)">
            <span class="icon">
                <i class="fas fa-question"></i>
            </span>
            <span>pwned</span>
        </button>
    </div>
</div>

1

密码 InputText 组件的 placeholderDisplayName 属性来自本地化的 "Password" 资源。

当用户首次进入此页面时,结果为空,但标题文本和消息提示均为本地化资源。如 Figure 5-10 所示渲染。

图 5-10. Passwords 页面

现在我们将看到 Passwords.razor 标记的以下部分,负责渲染结果内容:

<article class="panel is-info">
    <p class="panel-heading has-text-left">
        <span> @Localizer["Results"] ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        </span>
        <span class="is-pulled-right"> @if (_pwnedPassword?.IsPwned ?? false) ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png) { <span class="field is-grouped is-grouped-multiline">
                    <span class="control">
                        <span class="tags are-medium has-addons">
                            <span class="tag is-danger">pwned</span>
                            <span class="tag is-dark"> @(_pwnedPassword.PwnedCount.ToString( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png) "N0", Culture.CurrentCulture)) </span>
                        </span>
                    </span>
                    <span class="control">
                        <span class="tags is-clickable
                            are-medium has-addons" @onclick=Reset>
                            <span class="tag is-primary">reset</span>
                            <span class="tag is-dark">
                                <i class="fas fa-redo-alt"
                                    aria-hidden="true">
                                </i>
                            </span>
                        </span>
                    </span>
                </span> } </span>
    </p>

    <!-- The remaining markup is discussed later -->
</article>

1

本地化器获取与 "Results" 名称匹配的资源值,并将其绘制到 article 元素标题中。

2

使用控制结构时,当组件的 _pwnedPassword 对象不为 null 且具有 IsPwned 值为 true 时,添加了两个信息位。

3

给定密码已被泄露的次数将使用标准的 C# 数字格式化和当前区域设置进行格式化为字符串。

想象一下用户在输入字段中输入 "password" 并搜索以查看其是否曾经被泄露。很容易想象这个密码已经被使用了很多次,你并没有错。参见 Figure 5-11 以查看 "password" 被泄露多少次的渲染示例。哎呀!

图 5-11. 具有泄露密码的 Passwords 页面

Passwords 页面内还有几个额外的控制结构。考虑剩余的 Passwords.razor 标记:

@if (_pwnedPassword?.IsPwned ?? false)
{ <a class="panel-block is-size-5">
        <span class="panel-icon">
            <i class="fas has-text-danger
                fa-exclamation-circle" aria-hidden="true">
            </i>
        </span> @Localizer["OhNoFormat", _pwnedPassword.PwnedCount] ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    </a> }
else if (_state is ComponentState.Loaded)
{ <a class="panel-block is-size-5" disabled>
        <span class="panel-icon">
            <i class="fas has-text-success
                fa-check" aria-hidden="true"></i>
        </span> @Localizer["NotPwned"] ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    </a> }
else
{ <a class="panel-block is-size-5" disabled>
        <span class="panel-icon">
            <i class="fas fa-question-circle"
                aria-hidden="true"></i>
        </span> @Localizer["EnterPassword"] ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    </a> }

1

如果密码已经被泄露,将使用 OhNoFormat 资源来格式化本地化消息。

2

显示消息指示密码未被泄露。

3

否则,显示本地化提示消息。

根据 _pwnedPassword 对象是否为 null 以及其具有 IsPwned 值为 true 的情况,进行条件渲染。这将显示带有格式化资源的感叹号图标,匹配 "OhNoFormat" 名称,并给出密码被泄露的次数。这依赖于接受 params object[] argumentsLocalizer 索引器重载。当 _state 对象设置为加载状态,但 _pwnedPassword 对象为 null 或具有非泄露结果时,将渲染 "NotPwned" 资源。当页面首次渲染时,既不设置 _pwnedPassword 对象也不设置 _state 对象;在这种情况下,将渲染 "EnterPassword" 资源。提示用户输入密码。

在下面的 XML 资源中注意,每个 data 节点都有一个 name 属性和一个单独的 value 子节点。考虑 Passwords.razor.en.resx 文件:

<?xml version="1.0" encoding="utf-8"?>
<root>
  <!--
 Schema omitted for brevity...
 -->
  <data name="EnterPassword" xml:space="preserve">
    <value>Please enter a password to check if it's been "pwned".</value>
  </data>
  <data name="NotPwned" xml:space="preserve">
    <value>Great news, this password has not been "pwned"!</value>
  </data>
  <data name="OhNoFormat" xml:space="preserve">
    <value>Sorry, this password has been "pwned" {0:N0} times!</value>
  </data>
  <data name="Password" xml:space="preserve">
    <value>Password</value>
  </data>
  <data name="Passwords" xml:space="preserve">
    <value>Passwords</value>
  </data>
  <data name="Results" xml:space="preserve">
    <value>Results</value>
  </data>
</root>

摘要

在本章中,我向你展示了如何本地化 Blazor WebAssembly 应用程序。你学到了在 .NET 应用程序中什么是本地化,以及本地化应用程序的含义。我向你展示了如何使用依赖于 Azure Cognitive Services 的 GitHub Action 将应用程序本地化到数十种语言。我解释了 Blazor WebAssembly 如何使用熟悉的资源管理器识别资源文件。我还介绍了如何使用IStringLocalizer<T>接口消耗资源。

在接下来的章节中,你将学习如何在 Blazor WebAssembly 中使用 ASP.NET Core SignalR。你将学习一种模式,可以在整个应用中使用实时 web 功能,包括自定义通知系统、消息页面和实时推特流页面。

¹ Donald Knuth,“带有 go to 语句的结构化编程”,ACM Computing Surveys 6, no. 4(1974 年 12 月):261–301,https://doi.org/10.1145/356635.356640

第六章:实时 Web 功能的示例

没有网络用户希望不断刷新以获取最新信息。他们希望立即自动获取所有内容。实时 Web 功能非常普遍,大多数现代应用程序都需要。许多应用程序依赖实时数据,以便在其变为可用时向用户提供相关信息。在本章中,您将学习如何使用 ASP.NET Core SignalR(或仅 SignalR)实现实时 Web 功能。然后,您将了解如何创建一个服务器端(Hub),该服务器端将公开许多实时数据点,例如实时警报和通知,用于实时用户交互的消息系统,以及可加入的活动 Twitter 流。最后,您将学习如何从我们的 Blazor WebAssembly 应用程序中消耗这些数据,并以引人注目的方式响应和与这些实时数据点交互。

定义服务器端事件

要使您的 Blazor 应用程序具有实时 Web 功能,您需要一种方式让其接收实时数据。这就是 SignalR 的用武之地。实时浏览器到服务器的协议,例如 WebSockets 或服务器端事件,可能很难实现。SignalR 通过简洁的 API 提供了这些协议的抽象层,并减少了复杂性。为了处理单个服务器上的多个客户端,SignalR 引入了 hub 作为客户端和服务器之间的代理。在 hub 中,您可以定义可以直接从客户端调用的方法。同样,服务器可以在任何连接的客户端上调用方法。有了 hub,您可以定义从客户端到服务器或服务器到客户端的方法——这是双向(双工)通信。还有一个名为Azure SignalR Service的云就绪实现。此服务消除了管理处理可扩展性和客户端连接性的后端处理的需要。

这样做的目的是允许您的应用程序具有实时警报,用于实时用户交互的消息系统和可加入的活动 Twitter 流。SignalR 使所有这些成为可能。

一个机器调用另一个机器的概念称为远程过程调用(RPC)。所有发送到服务器的通信都需要认证令牌。没有有效的认证令牌,连接将无法建立或维持。有了有效的令牌,Web.Client 应用程序与其依赖的 HTTP 端点之间的通信将建立一个开放的线路,可以实时发送和接收消息,而不论任何一个过程跨越网络边界。最佳情况是,这两个过程协商并同意使用 WebSockets 作为通信传输方式。

公开 Twitter 流和聊天功能

以下示例突出显示了推文的实时流和一个有在场感知的聊天实现,如图 6-1 和 6-2 所示。

图 6-1。Tweets页面渲染

图 6-2. Chat 页面渲染

Learning Blazor 模型应用程序使用单个管理所有实时功能的通知中心。 在模型应用程序中,Web.Api 项目包含 SignalR 中心定义。 它定义了一个名为 NotificationHub 的单个类,但总共有三个文件。 每个文件代表 NotificationHub 对象的 partial 实现。 领域特定的段在其文件中封装,例如 NotificationHub.Chat.csNotificationHub.Tweets.cs。 让我们首先检查 NotificationHub.cs C# 文件:

namespace Learning.Blazor.Api.Hubs;
 [Authorize, RequiredScope(new[] { "User.ApiAccess" })] ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
public partial class NotificationHub : Hub
{
    private readonly ITwitterService _twitterService;
    private readonly IStringLocalizer<Shared> _localizer;

    private string _userName => Context.User?.Identity?.Name ?? "Unknown";
    private string[]? _userEmail => Context.User?.GetEmailAddresses();

    public NotificationHub(
        ITwitterService twitterService,
        IStringLocalizer<Shared> localizer) =>
        (_twitterService, _localizer) = (twitterService, localizer);

    public override Task OnConnectedAsync() => ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        Clients.All.SendAsync(
            HubServerEventNames.UserLoggedIn,
            Notification<Actor>.FromAlert(
                new(UserName: _userName,
                    Emails: _userEmail)));

    public override Task OnDisconnectedAsync(Exception? ex) => ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        Clients.All.SendAsync(
            HubServerEventNames.UserLoggedOut,
            Notification<Actor>.FromAlert(
                new(UserName: _userName)));
}

1

NotificationHub 受应用程序的 Azure AD B2C 租户保护。

2

override Task OnConnectedAsync 方法被实现为将事件 HubServerEventNames.UserLoggedIn 发送给所有连接的客户端的表达式。

3

override Task OnDisconnectedAsync 方法预期出现错误。

必须从配置的第三方身份验证提供程序之一提供有效的身份验证令牌,并且请求的声明必须是 "User.ApiAccess" 范围的一部分。 NotificationHub 是框架提供的 Hub 类的后代。 这是 SignalR 服务器的要求:它们必须公开一个中心端点。 该文件的主要功能是构造函数(.ctor)定义和处理连接和断开连接事件的覆盖。 其他中心局部是特定于领域的。 该类定义了几个字段:

ITwitterService _twitterService

此服务依赖于 TweetInvi NuGet package。 它管理流式传输的 Twitter API,并处理特定标签和句柄的流。

IStringLocalizer<Shared> _localizer

Shared 类包含本地化的 NotificationHub 资源。 某些通用消息已翻译为警报和通知系统。

string _userName

中心上下文中只有一个用户。 该用户是来自认证连接的反序列化令牌的表示—换句话说,当前与中心交互的用户。

string[]? _userEmail

该中心的用户还具有一个或多个电子邮件地址。

事件是一个Notification<Actor>。泛型通知对象是一个record class,包含用户名和电子邮件地址数组。这些事件有些通用,因此可以由客户端上的不同利益相关者共享。模型应用程序还需要一些附加功能,以提供丰富的聊天室体验。在本章中,您将学习一种干净的方法来实现“用户正在输入”指示器,创建和共享自定义房间,编辑发送的消息等等。通过使用类似的代码,这些相同的功能可以在您的 Blazor 应用程序中重复使用。让我们探索C#文件 NotificationHub.Chat.cs ,因为它展示了聊天功能的中心的实现:

namespace Learning.Blazor.Api.Hubs;

public partial class NotificationHub
{
    public Task ToggleUserTyping(bool isTyping) => ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        Clients.Others.SendAsync(
            HubServerEventNames.UserTyping,
            Notification<ActorAction>.FromAlert(
                new(UserName: _userName ?? "Unknown",
                    IsTyping: isTyping)));

    public Task PostOrUpdateMessage( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        string room, string message, Guid? id = default!) =>
        Clients.Groups(room).SendAsync(
            HubServerEventNames.MessageReceived,
            Notification<ActorMessage>.FromChat(
                new(Id: id ?? Guid.NewGuid(),
                   Text: message,
                   UserName: _userName ?? "Unknown",
                   IsEdit: id.HasValue)));

    public async Task JoinChat(string room) ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, room);

        await Clients.Caller.SendAsync(
            HubServerEventNames.MessageReceived,
            Notification<ActorMessage>.FromChat(
                new(Id: Guid.NewGuid(),
                    Text: _localizer["WelcomeToChatRoom", room],
                    UserName: UTF8.GetString(
                        new byte[] { 240, 159, 145, 139 }),
                    IsGreeting: true)));
    }

    public async Task LeaveChat(string room) ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, room);

        await Clients.Groups(room).SendAsync(
            HubServerEventNames.MessageReceived,
            Notification<ActorMessage>.FromChat(
                new(Id: Guid.NewGuid(),
                    Text: _localizer["HasLeftTheChatRoom", _userName ?? "?"],
                    UserName: UTF8.GetString(
                        new byte[] { 240, 159, 164, 150 }))));
    }
}

1

ToggleUserTyping方法改变客户端聊天用户的状态。

2

PostOrUpdateMessage方法将消息发布到聊天室中。

3

JoinChat方法将客户端添加到聊天室中。

4

LeaveChat方法将客户端从聊天室中移除。

ToggleUserTyping方法接受一个bool值,指示上下文用户是否正在聊天室中积极输入。这会触发HubServerEventNames.UserTyping事件,向外发送一个表示用户及其输入状态的Notification<ActorAction>对象作为消息。

PostOrUpdateMessage方法定义了roommessage string参数,以及一个可选的id。如果idnull,则将全局唯一标识符(GUID)分配给消息。消息包含消息文本、发送消息的用户以及消息是否被视为已编辑。这用于创建和更新用户聊天消息。

JoinChat方法需要一个room。调用时,当前连接将被添加到具有匹配房间名称的新建或现有 SignalR 组中。然后,该方法会告知当前调用者HubServerEventNames.MessageReceived事件已触发,向聊天室发送欢迎消息。此事件会发送一个Notification<ActorMessage>。所有客户端都可以访问此自定义泛型通知模型;它是 Web.Models 项目的一部分。这非常完美,因为客户端可以共享这些模型,并且序列化工作正常。这与您典型的 JavaScript 开发大不相同,后者需要努力维护 API 对象的不断变化的形状。

LeaveChat 方法是JoinChat功能的伴侣。这是有意的——一旦从客户端加入房间,您需要一种退出room的方式。这在LeaveChat方法中发生,其中从聊天发送HubServerEventNames.MessageReceived。当前上下文用户连接到 SignalR 中心实例中移除他们从聊天室。该特定组将使用机器人用户名和本地化消息发送自动化消息。

聊天功能正在形成。现在想象一下,你的应用程序需要访问实时 Twitter 动态。模型应用程序提供了如何实现这一点的示例。需要特定于 Twitter 的功能,通过实时通信来传达,考虑C#文件中的NotificationHub.Tweets.cs实现:

public partial class NotificationHub
{
    public async Task JoinTweets() ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        await Groups.AddToGroupAsync(
            Context.ConnectionId,
            HubGroupNames.Tweets);

        if (_twitterService.CurrentStatus is StreamingStatus status)
        {
            await Clients.Caller.SendAsync(
                HubServerEventNames.StatusUpdated,
                Notification<StreamingStatus>.FromStatus(status));
        }

        if (_twitterService.LastFiftyTweets is { Count: > 0 })
        {
            await Clients.Caller.SendAsync(
                HubServerEventNames.InitialTweetsLoaded,
                Notification<List<TweetContents>>.FromTweets(
                    _twitterService.LastFiftyTweets.ToList()));
        }
    }

    public Task LeaveTweets() => ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        Groups.RemoveFromGroupAsync(
            Context.ConnectionId,
            HubGroupNames.Tweets);

    public Task StartTweetStream() => ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        _twitterService.StartTweetStreamAsync();
}

1

JoinTweets 方法将客户端添加到 Tweets 组中。

2

LeaveTweets 方法从 Tweets 组中移除客户端。

3

StartTweetStream 方法启动推文流。

Tweets hub 中的 RPC 汇集了加入推文流的能力。当这发生时,当前连接加入HubGroupNames.Tweets组。局部作用域的_twitterService被问及一些问题,例如当前流状态是什么,内存中是否有推文:

  • 当当前 Twitter 流状态不为null并且具有值时,它被分配给status变量。这个status流向所有连接的客户端,因为他们被通知当前 TwitterStreamingStatus

  • 当内存中有推文时,所有连接的客户端都会被通知推文,作为List<TweetContents>集合。

LeaveTweets 方法从 HubGroupNames.Tweets 组中移除上下文连接。StartTweetStream 是幂等的,可以多次调用而不改变状态,第一次成功调用开始推文流。这是一个异步操作的表示。

你可能开始想知道实时推文来自哪里了。下一步我们将在后台服务中查看。

编写上下文 RPC 和进程内通信

我们模型应用程序的 Web.Api 项目负责暴露 HTTP API 表面,因此它的范围包括处理请求和提供响应。我们将探讨如何使用IHubContext,它允许我们的后台服务与NotificationHub实现通信。此外,模型应用程序显示了一个由所有partial NotificationHub class实现共同表示的 SignalR/notifications端点。关于此应用程序的实时流方面,我们依赖于 Twitter 服务,但我们需要一种监听事件的方法。在 ASP.NET Core 应用程序中,您可以使用Background​Ser⁠vice,它在同一进程中运行,但在请求和响应管道之外。SignalR 提供了一种通过IHub​Context接口访问NotificationHub的机制。所有这些都如图 6-3 所示。

图 6-3。Web.Api 服务器项目

接下来让我们看看C#文件TwitterWorkerService.cs

namespace Learning.Blazor.Api.Services;

public sealed class TwitterWorkerService : BackgroundService ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
{
    private readonly ITwitterService _twitterService;
    private readonly IHubContext<NotificationHub> _hubContext;

    public TwitterWorkerService( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        ITwitterService twitterService,
        IHubContext<NotificationHub> hubContext)
    {
        (_twitterService, _hubContext) = (twitterService, hubContext);

        _twitterService.StatusUpdated += OnStatusUpdated;
        _twitterService.TweetReceived += OnTweetReceived;
    }

    protected override async Task ExecuteAsync( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }

    private Task OnStatusUpdated(StreamingStatus status) => ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        _hubContext.Clients
            .Group(HubGroupNames.Tweets)
            .SendAsync(
                HubServerEventNames.StatusUpdated,
                Notification<StreamingStatus>.FromStatus(status));

    private Task OnTweetReceived(TweetContents tweet) => ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        _hubContext.Clients
            .Group(HubGroupNames.Tweets)
            .SendAsync(
                HubServerEventNames.TweetReceived,
                Notification<TweetContents>.FromTweet(tweet));
}

1

TwitterWorkerService实现了BackgroundService

2

构造函数接受ITwitterServiceIHubContext作为参数。

3

ExecuteAsync方法是服务的主要入口点。

4

_twitterService触发StatusUpdated事件时,将调用OnStatusUpdated方法。

5

OnTweetReceived处理TweetReceived事件,并通知HubGroupNames.Tweets组中的所有客户端。

TwitterWorkerServiceBackgroundService的后代。后台服务是长期运行的应用程序,它们在循环中执行,但可以通过其连接的客户端访问通知中心,并且可以通过它们发送消息。此类定义了两个字段:

_twitterServiceITwitterService

这项服务用于NotificationHub中的内存流状态和推文,现在也可以处理来自底层TweetInvi过滤流的事件。

_hubContextIHubContext<NotificationHub>

此对象用于向 SignalR 服务器中连接的客户端发送消息。

TwitterWorkerService构造函数将值声明为参数。依赖注入框架将提供服务和中心上下文对象。它们使用元组字面量的立即解构进行位置分配。_twitterService有其StatusUpdatedTweetReceived事件处理程序分配。Twitter 服务暴露了一个事件机制,并在接收到推文时触发事件。在 C#中,您可以订阅一个委托到事件,它将作为回调函数。不需要取消订阅事件,因为除非整个应用程序被关闭,否则应用程序不会停止。在这种情况下,我们没有持有任何未订阅的事件——整个过程正在终止。

ExecuteAsync方法被实现为应用程序可以执行其任务的信号。这只是在异步循环中延迟和监听停止令牌的取消请求。

_twitterService.OnStatusUpdated事件触发时,向所有订阅者发送当前流状态的更新。HubGroupNames.Tweets组中的所有上下文客户端都会收到HubServerEventNames.StatusUpdated事件。通知是StreamingStatus

处理_twitterService.OnTweetReceived事件时,当接收到新的TweetContents对象时。这些推文内容从HubServerEventNames.TweetReceived事件发送。它们也发送到名为HubGroupNames.Tweets的同一组中。

服务器功能已完成。通过此功能,我们可以通过协商的/notifications端点提供 SignalR 连接。每个客户端协商他们要使用的协议和传输方式。SignalR 传输是通信处理程序,例如 WebSockets、服务器发送事件和长轮询。客户端和服务器可以以各种方式进行通信。这通常遵循首选默认到次优的回退约定。好消息是,大多数现代浏览器环境支持高性能的 WebSockets。

配置 Hub 端点

使得中心功能作为可消费的路由公开,必须配置客户端如何与其通信。有几个需要配置的事项:

  • 所需的消息和传输协议(可能需要额外的 NuGet 包)

  • NotificationHub映射到/notifications端点

  • TwitterWorkerService注册为托管服务(BackgroundService

由于 Web.Api 项目目标为 net6.0 TFM,并指定 <Project Sdk="Microsoft.NET.Sdk.Web">,SignalR 隐式引用为 SDK 的元包。有关 .NET 中 SDK 的概述,请参阅 Microsoft 的 “.NET Project SDKs” 文档。默认的消息协议是 JSON(基于文本的协议),适合人类阅读和方便调试。但是,使用 MessagePack 更高效,它是一种二进制协议,消息通常大小减半。

Web.Api.csproj XML 文件包含以下内容,以及其他的包引用:

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

    <PropertyGroup>
        <RootNamespace>Learning.Blazor.Api</RootNamespace>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>true</ImplicitUsings>
        <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
        <DockerfileContext>..\..</DockerfileContext>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Version="6.0.1"
            Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" /> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        <!-- Additional package references omitted for brevity -->
    </ItemGroup>
    <ItemGroup>
        <!--
            Project references omitted for brevity:
                Abstractions, Cosmos DB, Distributed Caching,
                Extensions, Http.Extensions, LogicAppServices, TwitterServices
        -->
    </ItemGroup>

    <!-- Omitted for brevity -->
</Project>

1

包含了 Microsoft.AspNetCore.SignalR.Protocols.MessagePack NuGet 包引用。

这暴露了 MessagePack 二进制协议。客户端也必须为该协议配置 MessagePack,否则将回退到默认的基于文本的 JSON 协议。

在 Web.Api 项目的 Startup 类中,我们添加了 SignalR,并将 NotificationHub 映射到 "/notifications" 终端。考虑 Startup.cs C# 文件:

namespace Learning.Blazor.Api;

public sealed partial class Startup
{
    readonly IConfiguration _configuration;

    public Startup(IConfiguration configuration) =>
        _configuration = configuration;
}

Startup 类是 partial,仅定义了 _configuration 字段和接受配置的构造函数。按照约定,启动对象有两种方法:

ConfigureServices(IServiceCollection services)

此方法负责在服务集合上注册服务(通常使用帮助器的 Add{DomainService} 扩展方法来实现)。

Configure(IApplicationBuilder app, IWebHostEnvironment env)

此方法负责配置服务的使用(通常使用帮助器的 Use{DomainService} 扩展方法来实现)。

首先,在 Startup.ConfigureServices.cs C# 文件中添加 SignalR:

namespace Learning.Blazor.Api;

public sealed partial class Startup
{
    public void ConfigureServices(IServiceCollection services) ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            .AddMicrosoftIdentityWebApi(
                _configuration.GetSection("AzureAdB2C"));

        services.Configure<JwtBearerOptions>(
            JwtBearerDefaults.AuthenticationScheme,
            options =>
            options.TokenValidationParameters.NameClaimType = "name");

        services.AddApiServices(_configuration);

        var webClientOrigin = _configuration["WebClientOrigin"];
        services.AddCors(
            options => options.AddDefaultPolicy(
                builder => builder.WithOrigins(
                    "https://localhost:5001", webClientOrigin)
                    .AllowAnyMethod()
                    .AllowAnyHeader()
                    .AllowCredentials()));

        services.AddControllers();

        services.AddSignalR( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            options => options.EnableDetailedErrors = true)
                .AddMessagePackProtocol();
    }
}

1

IServiceCollection 中添加了服务。

2

配置了 JwtBearerOptions

3

配置了 SignalR 服务以显示详细错误,并添加了 MessagePack

添加了身份验证中间件,这应该看起来有点熟悉了——它使用了前几章中展示的相同 Azure AD B2C 租户进行配置。它配置为使用 "name" 作为名称声明类型。由于我们的 Blazor WebAssembly 应用程序向不同的来源发出请求,我们的 API 需要允许 CORS。

添加了 SignalR,使用 .AddSignalR 扩展方法。在此调用链上链式地调用了 AddMessagePackProtocol,正如其名称所示,这将添加 MessagePack 作为期望的 SignalR 消息协议。

将这些服务添加到启动例程后,现在我们可以对它们进行配置。让我们看看 Startup.Configure.cs C# 文件:

namespace Learning.Blazor.Api;

public sealed partial class Startup
{
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env) ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseHttpsRedirection();
        app.UseRouting();

        var webClientOrigin = _configuration["WebClientOrigin"];
        app.UseCors(options =>
            options.WithOrigins(
                    "https://localhost:5001", webClientOrigin)
                .AllowAnyHeader()
                .AllowAnyMethod()
                .AllowCredentials());

        var localizationOptions = new RequestLocalizationOptions() ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            .SetDefaultCulture(Cultures.Default)
            .AddSupportedCultures(Cultures.Supported)
            .AddSupportedUICultures(Cultures.Supported);

        app.UseRequestLocalization(localizationOptions);
        app.UseAuthentication();
        app.UseAuthorization();
        app.UseResponseCaching();
        app.UseEndpoints(endpoints => ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        {
            endpoints.MapControllers();
            endpoints.MapHub<NotificationHub>("/notifications");
        });
    }
}

1

Configure 方法是 ASP.NET Core web 应用程序的一个约定。它配置了依赖注入的服务。

2

Web.Api 项目支持请求本地化,类似于 第五章 中详细介绍的本地化,使用翻译资源文件和 IString​Local⁠izer<T> 抽象。

3

NotificationHub 映射到其终端点。

Configure 功能从根据当前运行时环境配置为 "Development" 条件性地使用开发者异常页面开始。使用 HTTPs 重定向,强制 API 使用 https:// 方案。使用路由功能启用终端点中间件服务。接下来,之前添加的模型应用程序的 CORS 现在正在使用。

在前一章中,我们探讨了本地化的概念。在 Web.Api 项目中,我们使用了相同方法的变体。虽然在本项目中所有资源文件都使用相同的机制,但是在 Web API 项目中,本地化的概念需要一个特定于请求的中间件,该中间件将根据 HTTP 请求自动设置适当的 Culture。配置过程指定了使用几个更多的中间件服务:

UseAuthentication

使用添加的 Azure AD B2C 租户

UseAuthorization

允许 API 使用 Authorize 属性进行装饰,要求经过身份验证的用户

ResponseCaching

允许 API 声明性地指定缓存行为

调用 UseEndpoints 对于 SignalR 是必需的,因为 NotificationHub 映射到 "/notifications" 终端点。有了这些设置,项目已准备好同时为多个连接的客户端提供服务。

在下一节中,我们将研究客户端应用程序如何接收这些数据。

在客户端消费实时数据

返回到 Web.Client 项目,本书的示范应用在多个组件和页面中使用实时数据。为了避免从单个客户端向服务器打开多个连接,使用了一种共享的方法来处理 hub 连接。每个客户端将具有一个 SharedHubConnection 实例。SharedHubConnection 类有几个实现,负责以线程安全的方式管理一个由框架提供的共享的 HubConnection。在使用 HubConnection 之前,我们必须首先配置客户端以支持此类型。SharedHubConnection 类共享一个 HubConnection 实例,并负责以线程安全的方式管理连接。

配置客户端

要在客户端配置 SignalR,我们的 Web.Client 项目必须包含两个 NuGet 包引用:

除了这些包之外,自定义的SharedHubConnection类被注册为客户端的服务提供程序中的单例,使其通过 DI 成为可解析的服务。这最初在“Web.Client ConfigureServices 功能”中讨论过。这个服务的生命周期内只会存在一个实例。这是一个重要的细节,因为它与所有消费组件和页面共享连接状态。接下来,我们将看一下SharedHubConnection的实现。

共享 Hub 连接

SharedHubConnection类被客户端应用程序中需要与 SignalR 服务器通信的任何组件或页面使用,无论组件是否需要向服务器推送数据,还是客户端订阅服务器事件或两者兼有。SharedHubConnection.cs C# 包含了共享单个框架提供的HubConnection的逻辑:

namespace Learning.Blazor;

public sealed partial class SharedHubConnection : IAsyncDisposable ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
{
    private readonly IServiceProvider _serviceProvider = null!; ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    private readonly ILogger<SharedHubConnection> _logger = null!;
    private readonly CultureService _cultureService = null!;
    private readonly HubConnection _hubConnection = null!;
    private readonly SemaphoreSlim _lock = new(1, 1);
    private readonly HashSet<ComponentBase> _activeComponents = new();

    /// <summary>
    /// Indicates the state of the <see cref="HubConnection"/> to the server.
    /// </summary>
    public HubConnectionState State =>
        _hubConnection?.State ?? HubConnectionState.Disconnected;

    public SharedHubConnection( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        IServiceProvider serviceProvider,
        IOptions<WebApiOptions> options,
        CultureService cultureService,
        ILogger<SharedHubConnection> logger)
    {
        (_serviceProvider, _cultureService, _logger) =
            (serviceProvider, cultureService, logger);

        var notificationHub =
            new Uri($"{options.Value.WebApiServerUrl}/notifications");

        _hubConnection = new HubConnectionBuilder()
            .WithUrl(notificationHub,
                 options =>
                 {
                     options.AccessTokenProvider = GetAccessTokenValueAsync;
                     options.Headers.Add(
                         "Accept-Language",
                         _cultureService.CurrentCulture
                             .TwoLetterISOLanguageName);
                 })
            .WithAutomaticReconnect()
            .AddMessagePackProtocol()
            .Build();

        _hubConnection.Closed += OnHubConnectionClosedAsync;
        _hubConnection.Reconnected += OnHubConnectionReconnectedAsync;
        _hubConnection.Reconnecting += OnHubConnectionReconnectingAsync;
    }

    Task OnHubConnectionClosedAsync(Exception? exception)
    {
        _logger.LogHubConnectionClosed(exception);
        return Task.CompletedTask;
    }

    Task OnHubConnectionReconnectedAsync(string? message)
    {
        _logger.LogHubConnectionReconnected(message);
        return Task.CompletedTask;
    }

    Task OnHubConnectionReconnectingAsync(Exception? exception)
    {
        _logger.LogHubConnectionReconnecting(exception);
        return Task.CompletedTask;
    }

    async ValueTask IAsyncDisposable.DisposeAsync() ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    {
        if (_hubConnection is not null)
        {
            _hubConnection.Closed -= OnHubConnectionClosedAsync;
            _hubConnection.Reconnected -= OnHubConnectionReconnectedAsync;
            _hubConnection.Reconnecting -= OnHubConnectionReconnectingAsync;

            await _hubConnection.StopAsync();
            await _hubConnection.DisposeAsync();
        }

        _lock?.Dispose();
    }
}

1

SharedHubConnection是一个sealed partial class

2

SharedHubConnection定义了几个字段,用于帮助管理共享的 hub 连接。

3

SharedHubConnection 构造函数从定义的参数初始化支持字段。

4

SharedHubConnection显式实现了IAsyncDisposable.Dispose​A⁠sync方法。

首先,请注意SharedHubConnectionIAsync​Dis⁠posable接口的实现。这使得SharedHubConnection类能够异步清理需要释放的任何托管资源。

然后类定义了几个在构造过程中(或内联)初始化的字段。它们的描述如下:

IServiceProvider _serviceProvider

客户端应用程序的服务提供程序。

ILogger<SharedHubConnection> _logger

一个特定于SharedHubConnection的日志记录器实例。

CultureService _cultureService

用于为从 hub 连接发出的请求填充“Accept-Language”HTTP 头。

HubConnection _hubConnection

客户端连接到服务器 hub 的框架提供的表示。

SemaphoreSlim _lock

用于线程安全并发访问的异步锁定机制。这个锁在后面详细介绍的共享StartAsync方法命令中使用。

_logger字段可以访问几个自定义的日志记录扩展方法。这些扩展方法调用从框架提供的LoggerMessage.Define工厂方法创建的缓存委托。这是一种性能优化,避免每次记录日志消息时都创建一个新的委托。

连接状态由底层 HubConnection.State 计算属性 State 表示。当 _hubConnectionnull 时,状态显示为 Disconnected

其他状态包括以下内容:

已连接

客户端和服务器已连接。

连接中

正在建立连接。

重新连接

正在重新连接。

接下来,SharedHubConnection 构造函数从构造函数的参数中分配了几个字段。从客户端配置的选项对象中,使用 Web API 服务器 URL 以及 "/notifications" 路由来实例化通知中心的 Uri。使用生成器模式和相应的 HubConnectionBuilder 对象实例化 _hubConnection 字段。

使用生成器实例和 Hub 连接,通过 WithUrl 方法重载配置了其选项。将 AccessTokenProvider 分配给用于异步获取上下文访问令牌的委托。更新默认的请求 HTTP 标头,添加 "Accept-Language" 标头,并使用当前配置的 ISO 两字母语言名称作为值。这确保 SignalR 服务器连接知道返回适当的本地化内容给连接的客户端。生成器在调用 Build 之前配置了自动重连和 MessagePack 协议。

使用 _hubConnection 实例,订阅了 ClosedReconnectedReconnecting 事件。通过这些事件传达了各种连接状态。它们各自的事件处理程序都非常相似。应用程序有条件地记录它们的发生。

最后,DisposeAsync功能取消订阅 _hubConnection 的事件,然后级联处理连接和用于同步的锁定机制的释放。

共享中心连接认证

SharedHubConnection 的使用是 partial 的,还有其他几个实现要考虑。在构建 _hubConnection 实例时分配了 GetAccessTokenValueAsync 委托,该功能在 SharedHubConnection.Tokens.cs C# 文件中实现:

namespace Learning.Blazor;

public sealed partial class SharedHubConnection
{
    private async Task<string?> GetAccessTokenValueAsync()
    {
        using (var scope = _serviceProvider.CreateScope())
        {
            var tokenProvider =
                scope.ServiceProvider
                    .GetRequiredService<IAccessTokenProvider>();
            var result =
                await tokenProvider.RequestAccessToken();

            if (result.TryGetToken(out var accessToken))
            {
                return accessToken.Value;
            }

            _logger.LogUnableToGetAccessToken(
                result.Status, result.RedirectUrl);

            return null;
        }
    }
}

SharedHubConnection 类被注册为单例,但框架提供的 IAccessTokenProvider 是一个作用域服务。这就是为什么构造函数不能直接要求 IAccessTokenProvider;相反,它需要 IServiceProvider。使用 _serviceProvider 实例,调用 CreateScope 创建一个作用域,以便解析 IAccessTokenProvider

提示

通常情况下,您不需要直接使用 IServiceProviderSharedHubConnection 类是一个单例,并且 IAccessTokenProvider 是一个作用域服务。当 SharedHubConnection 对象开始与服务器通信时,使用 IServiceProvider 解析 IAccessTokenProvider

通过 tokenProvider,我们调用 RequestAccessToken。如果 result 有一个访问令牌,则返回它。如果 GetAccessTokenValueAsync 无法获取 accessToken,则记录并返回 null。访问令牌用于对接的 Blazor 客户端与服务器 Hub 进行身份验证。

共享的 Hub 连接初始化

由于此类的共享性质,启动功能需要以线程安全的方式实现。任何消费者都可以安全地调用 StartAsync 以从客户端启动到服务器的连接。这发生在 SharedHubConnection​.Com⁠mands.cs C# 文件中:

namespace Learning.Blazor;

public sealed partial class SharedHubConnection
{
    public async Task StartAsync(CancellationToken token = default) ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        await _lock.WaitAsync(token);

        try
        {
            if (State is HubConnectionState.Disconnected)
            {
                await _hubConnection.StartAsync(token);
            }
            else
            {
                _logger.LogUnableToStartHubConnection(State);
            }
        }
        finally
        {
            _lock.Release();
        }
    }
}

1

StartAsync 方法定义了一个可选的取消 token

当调用 StartAsync 时,SemaphoreSlim _lock 变量调用其 WaitAsync 方法,该方法在进入信号量时完成。这是一个重要的细节,因为它通过确保所有调用者顺序执行来缓解多个组件并发调用 StartAsync 的问题。换句话说,想象三个组件同时调用 StartAsync。这种异步锁定机制确保第一个进入并启动 _hubConnection 的组件是唯一调用 _hubConnection.StartAsync 的组件。其他两个组件将记录它们无法启动与服务器 Hub 的连接,因为它已经启动。

共享的 Hub 连接聊天

接下来,让我们看看 SharedHubConnection 如何实现聊天功能。您可以在 SharedHubConnection.Chat.cs C# 文件中看到其定义:

namespace Learning.Blazor;

public sealed partial class SharedHubConnection
{
    /// <inheritdoc cref="HubClientMethodNames.JoinChat" />
    public Task JoinChatAsync(string room) => ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        _hubConnection.InvokeAsync(
            methodName: HubClientMethodNames.JoinChat, room);

    /// <inheritdoc cref="HubClientMethodNames.LeaveChat" />
    public Task LeaveChatAsync(string room) =>
        _hubConnection.InvokeAsync(
            methodName: HubClientMethodNames.LeaveChat, room);

    /// <inheritdoc cref="HubClientMethodNames.PostOrUpdateMessage" />
    public Task PostOrUpdateMessageAsync(
        string room, string message, Guid? id = default) =>
        _hubConnection.InvokeAsync(
            methodName: HubClientMethodNames.PostOrUpdateMessage,
            room, message, id);

    /// <inheritdoc cref="HubClientMethodNames.ToggleUserTyping" />
    public Task ToggleUserTypingAsync(bool isTyping) =>
        _hubConnection.InvokeAsync(
            methodName: HubClientMethodNames.ToggleUserTyping, isTyping);

    /// <inheritdoc cref="HubServerEventNames.UserLoggedIn" />
    public IDisposable SubscribeToUserLoggedIn( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        Func<Notification<Actor>, Task> onUserLoggedIn) =>
        _hubConnection.On(
            methodName: HubServerEventNames.UserLoggedIn,
            handler: onUserLoggedIn);

    /// <inheritdoc cref="HubServerEventNames.UserLoggedOut" />
    public IDisposable SubscribeToUserLoggedOut(
        Func<Notification<Actor>, Task> onUserLoggedOut) =>
        _hubConnection.On(
            methodName: HubServerEventNames.UserLoggedOut,
            handler: onUserLoggedOut);

    /// <inheritdoc cref="HubServerEventNames.UserTyping" />
    public IDisposable SubscribeToUserTyping(
        Func<Notification<ActorAction>, Task> onUserTyping) =>
        _hubConnection.On(
            methodName: HubServerEventNames.UserTyping,
            handler: onUserTyping);

    /// <inheritdoc cref="HubServerEventNames.MessageReceived" />
    public IDisposable SubscribeToMessageReceived(
        Func<Notification<ActorMessage>, Task> onMessageReceived) =>
        _hubConnection.On(
            methodName: HubServerEventNames.MessageReceived,
            handler: onMessageReceived);
}

1

JoinChatAsync 方法是一个示例,可从客户端调用,并在服务器上调用一个方法。

2

SubscribeToUserLoggedIn 方法是一个示例事件,从服务器触发,客户端可以通过订阅来监听它们。

聊天功能依赖于两个共享的辅助类:

HubClientMethodNames

定义了可以从连接的客户端上调用的方法名称

HubServerEventNames

定义了客户端可以订阅的来自 SignalR Hub 的事件名称(及其参数详情)

额外的功能是通过这些类来实现的。每个客户端方法都委托给 _hubConnection.Invoke​A⁠sync 方法的对应重载,传递适当的方法名称和参数。与此同时,每个服务器事件都订阅了一个分配的函数,作为其回调处理程序。这是通过适当的 _hubConnection.On 重载实现的。这些订阅被表示为返回的 IDisposable,调用者有责任通过调用 Dispose 在可能进行的任何订阅上取消订阅。消费组件将能够加入和离开聊天室,在这些聊天室中发布和更新消息,并共享他们当前是否正在输入。同样,这些组件将能够在另一个用户输入时接收通知,用户登录或退出时接收通知,以及收到消息时接收通知。

共享的 hub 连接推文

最后一个功能位于 SharedHubConnection 中实现,它在 SharedHubConnection.Tweets.cs 的 C# 文件中定义:

namespace Learning.Blazor;

public sealed partial class SharedHubConnection
{
    /// <inheritdoc cref="HubClientMethodNames.JoinTweets" />
    public Task JoinTweetsAsync() => ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        _hubConnection.InvokeAsync(
            methodName: HubClientMethodNames.JoinTweets);

    /// <inheritdoc cref="HubClientMethodNames.LeaveTweets" />
    public Task LeaveTweetsAsync() =>
        _hubConnection.InvokeAsync(
            methodName: HubClientMethodNames.LeaveTweets);

    /// <inheritdoc cref="HubClientMethodNames.StartTweetStream" />
    public Task StartTweetStreamAsync() =>
        _hubConnection.InvokeAsync(
            methodName: HubClientMethodNames.StartTweetStream);

    /// <inheritdoc cref="HubServerEventNames.StatusUpdated" />
    public IDisposable SubscribeToStatusUpdated( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        Func<Notification<StreamingStatus>, Task> onStatusUpdated) =>
        _hubConnection.On(
            methodName: HubServerEventNames.StatusUpdated,
            handler: onStatusUpdated);

    /// <inheritdoc cref="HubServerEventNames.TweetReceived" />
    public IDisposable SubscribeToTweetReceived(
        Func<Notification<TweetContents>, Task> onTweetReceived) =>
        _hubConnection.On(
            methodName: HubServerEventNames.TweetReceived,
            handler: onTweetReceived);

    /// <inheritdoc cref="HubServerEventNames.InitialTweetsLoaded" />
    public IDisposable SubscribeToTweetsLoaded(
        Func<Notification<List<TweetContents>>, Task> onTweetsLoaded) =>
        _hubConnection.On(
            methodName: HubServerEventNames.InitialTweetsLoaded,
            handler: onTweetsLoaded);
}

1

推特 实现依赖于 HubClientMethodNames 来调用 hub 连接方法,给定它们的名称和参数。

2

类似地,HubServerEventNames 用于从服务器订阅命名事件,给定处理程序。

通过封装每个特定于领域的功能的逻辑,SharedHubConnection 的相应 partial 实现向消费者公开了更有意义的方法。虽然在该类内部使用了框架提供的 HubConnection,但它已经被抽象掉了。因此,通过使用 SharedHubConnection,消费者可以调用更明确命名和有意义的方法。

在组件中消费实时数据

唯一剩下要做的就是在消费组件中使用共享的 hub 连接。每个特定于领域的功能,无论是小组件还是页面,都将依赖于 SharedHubConnection 来提供必要的功能。

SignalR 实时数据支持我们模型应用程序的三个组件:NotificationComponentTweetsChat 页面。通知系统能够接收以下事件的通知:

  • 当用户登录或退出应用程序时

  • 当你当前位置有重要的天气警报,如严重天气警告时

  • 如果你的电子邮件地址曾经参与过数据泄露(这指的是应用程序的“Have I Been Pwned”功能),如图 6-4 所示。

图 6-4. 一个 pwned 通知

所有通知都可以取消,但只有一些可以操作。例如,一个通知会告诉您是否涉及到数据泄露,并提供一个链接。如果您决定访问链接,它将带您到应用程序中的 /pwned 子路由,展示您的电子邮件参与的所有数据泄露。

应用程序有一个专门用于实时推特内容的 Tweets 页面。我们将深入研究其中一个消费组件。有了这些知识,您可以自行审查其他内容。让我们来看看聊天功能。

Chat 组件定义了 @page 指令,这意味着它是一个 页面。可以通过 /chat 路由导航到它。考虑 Chat.razor 文件:

@page "/chat/{room?}" ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png) @attribute [Authorize]
@inherits LocalizableComponentBase<Chat>

<PageTitle> @Localizer["Chat"] </PageTitle>

<AuthorizeView> @if (User is { Identity: { } } user)
    { <div class="is-hidden">@user.Identity.Name</div> } </AuthorizeView>

    <div class="columns">
        <section class="column is-10 is-offset-1">
            <div class="field has-addons">
                <div class="control is-fullwidth has-icons-left">
                    <input class="input is-large" spellcheck="true" ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
                       type="text" placeholder=@Localizer["ChatMessage"]
                       @ref="_messageInput"
                       @bind-value="@_message"
                       @oninput="@InitiateDebounceUserIsTypingAsync"
                       @onkeyup="@OnKeyUpAsync"
                       autocomplete="off">
                    <span class="icon is-small is-left">
                        <i class="fas">&#x1F4AD;</i>
                    </span>
                </div>
                <div class="control">
                    <a class="button is-info is-large"
                        @onclick="@SendMessageAsync"> @Localizer["Send"] </a>
                </div>
            </div>

            <article class="panel is-info has-dotnet-scrollbar">
                <p class="panel-heading has-text-left">
                    <span> @Localizer["Messages"] </span>
                    <span class="is-pulled-right"> @if (TryGetUsersTypingText(out var text)) ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png) {
                        MarkupString isTypingMarkup = new(text); <span class="has-text-grey-light is-strobing"> @isTypingMarkup </span> } </span>
            </p> @foreach (var (id, message) in _messages.Reverse()) ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png) { <ChatMessageComponent Message=@message
                    IsEditable=@(OwnsMessage(message.UserName))
                    EditMessage=@OnEditMessageAsync /> } </article>
    </section>
</div>

1

Chat 页面的路由模板为 "/chat/{room?}"

2

每个聊天室只有一个聊天室消息输入框和一个发送按钮。

3

当一个或多个用户正在输入时,我们会显示专门的消息,以向聊天室的参与者指示这一情况。

4

一组聊天室消息被迭代并传递给 ChatMessageComponent

Chat 页面的路由模板允许使用可选的房间参数。该值隐式绑定到组件对应的 Room 属性。路由模板非常强大,我们具有很大的灵活性。这使得我们客户端应用程序的用户可以共享和书签房间,邀请朋友实时互动。有关路由约束的更多信息,请参见微软的 “ASP.NET Core Blazor 路由和导航”文档

聊天室功能允许用户编辑他们发送的消息;这是一个很好的功能。它允许聊天用户纠正拼写错误或根据需要更新表达的内容。然而,消息不会被永久保存,这是有意为之的;每个交互都是实时的,如果您离开,消息也会消失。这强调了 要么专注当下,要么别浪费时间 的思维方式。从发送有拼写错误的消息到纠正并发送的过程是一个交互式的体验。要查看这一过程,请参见图 6-5、6-6 和 6-7。

图 6-5. 聊天室消息拼写错误

图 6-6. 聊天室消息编辑

图 6-7. 聊天室消息编辑

程序上来说,不持久化消息会使应用稍微简化。主要关注点在于用户通过创建或更新他们的聊天消息来与Chat房间互动。用户在<input type="text">中输入他们的消息,并使用<a class="button"> HTML 元素发送消息。input具有其本地的spellcheck属性设置为true。这使得元素能够为用户提供帮助,确保他们消息的拼写准确性。用户可以使用 Enter 键发送消息。发送按钮是明确的用户请求,而不是按 Enter 键的更被动或隐式的性质,但它们在功能上是等效的。

作为实时功能的一部分,当同一聊天室中的用户正在输入消息时,他们的客户端应用程序正在消除他们的输入。

当用户首次开始输入时,会触发一个通知,使用 SignalR 让感兴趣的聊天室参与者知道用户正在输入。每当他们键入一个非终止键后,经过大约 750 毫秒的特定时间,应用程序发送一个取消。用户体验是,您不仅可以看到聊天室中有人正在输入,还可以看到他们的名字。这在图 6-8 中描述。

图 6-8。抑制状态机图表

Chat页面维护了一个名为.NET Dictionary<Guid, ActorMessage>的集合_messages。这个集合通过来自服务器NotificationHub的 SignalR 事件接收到Notification where T : notnullT表示Payload属性的类型。当通信为NotificationType.Chat时,T类型为ActorMessage`。演员消息用于表示来自用户及其意图的消息。这些消息可以以多种方式反映消息,无论用户是否正在编辑消息,或者消息是否是一般的问候语。这些消息具有唯一标识且不可变。消息具有所有权的感觉,因为消息关联有用户名。考虑C#文件Actors.cs

namespace Learning.Blazor.Models;

public record class ActorMessage(
    Guid Id,
    string Text,
    string UserName,
    bool IsGreeting = false,
    bool IsEdit = false) : Actor(UserName);

public record class ActorAction(
    string UserName, bool IsTyping) : Actor(UserName);

public record class Actor(
    string UserName,
    string[]? Emails = null);

该文件包含三个record class定义:一个基本的Actor和两个后代,ActorActionActorMessage。在_messages集合中的每条消息按逆序迭代。这显示了消息按它们发布的时间的升序显示,这在所有聊天应用中都很常见。ActorAction类将用户的输入状态设置为truefalse。这些message对象被传递给自定义的<ChatMessageComponent>。这个组件在ChatMessageComponent.razor文件中定义。让我们先看看那个:

<a id="@Message.Id" ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    class="panel-block is-size-5 @_dynamicCss"
	@onclick=@StartEditAsync>
    <span> @Message.UserName </span>
    <span class="panel-icon px-4">
        <i class="fas fa-chevron-right" aria-hidden="true"></i>
    </span>
    <span class="pl-2"> @{ ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png) MarkupString messageMarkup = new(Message.Text); <span> @messageMarkup </span> @if (Message.IsEdit)
        { <span class="pl-2">
                <span class="tag is-success-dark">edited</span>
            </span> }
    } </span>
</a> @code {
    private string _dynamicCss ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png) {
        get
        {
            return string.Join(" ", GetStyles()).Trim();

            IEnumerable<string> GetStyles()
            {
                if (!IsEditable)
                    yield return "is-unselectable";

                if (Message.IsGreeting)
                    yield return "greeting";
            };
        }
    }

    [Parameter, EditorRequired]
    public bool IsEditable { get; set; }

    [Parameter, EditorRequired]
    public ActorMessage Message { get; set; } = null!;

    [Parameter, EditorRequired]
    public EventCallback<ActorMessage> EditMessage { get; set; }

    private async Task StartEditAsync() ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png) {
        if (IsEditable && EditMessage.HasDelegate)
        {
            await EditMessage.InvokeAsync(Message);
        }
    }
}

1

每条消息表示为<a id="@Message.Id">...</a>锚元素。

2

框架提供的 MarkupString 用于将 C# string 渲染为 HTML。

3

组件动态应用 _dynamicCss 计算属性中的样式。

4

StartEditAsync() 方法用于向父 Chat 页面发信号,表明此组件正在编辑消息。

ChatMessageComponent 用于表示单个聊天消息。如果创建该组件时将 IsEditable 设置为 true,则用户可以在该组件内编辑消息。如果消息之前已被编辑过,则会适当地应用样式来向聊天室用户指示。如果用户无权编辑消息,则应用 is-unselectable 样式。

接下来,让我们看看 Chat 页面的实现,它由几个 C# 部分类组成。考虑 Chat.razor.cs C# 文件:

namespace Learning.Blazor.Pages
{
    public sealed partial class Chat : IAsyncDisposable
    {
        private const string DefaultRoomName = "public";

        private readonly Stack<IDisposable> _subscriptions = new(); ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
 [Parameter]
        public string? Room { get; set; } = DefaultRoomName;
 [Inject]
        public SharedHubConnection HubConnection { get; set; } = null!; ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)

        protected override async Task OnInitializedAsync() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        {
            await base.OnInitializedAsync();

            _subscriptions.Push(
                HubConnection.SubscribeToMessageReceived(
                    OnMessageReceivedAsync));
            _subscriptions.Push(
                HubConnection.SubscribeToUserTyping(
                    OnUserTypingAsync));

            await HubConnection.StartAsync();
            await HubConnection.JoinChatAsync(
                Room ?? DefaultRoomName);
        }

        protected override async Task OnAfterRenderAsync(bool firstRender) ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        {
            if (firstRender)
            {
                await _messageInput.FocusAsync();
            }
        }

        async ValueTask IAsyncDisposable.DisposeAsync() ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        {
            if (HubConnection is not null)
            {
                await HubConnection.LeaveChatAsync(
                    Room ?? DefaultRoomName);
            }

            while (_subscriptions.TryPop(out var disposable))
            {
                disposable.Dispose();
            }
        }
    }
}

1

Chat 的实现维护了一个名为 _subscriptionsStack<IDisposable>

2

注入了 ShareHubConnection HubConnection 属性。

3

该类提供了对 OnInitializedAsync 方法的 override

4

使用 OnAfterRenderAsync 生命周期事件方法将焦点设置在消息输入框上。

5

IAsyncDisposable.DisposeAsync 的显式实现执行清理操作。

我们观察到的 Chat 组件类的第一个实现 partial 实现了 IAsyncDisposable。该组件公开了一个 [Parameter] public string? Room 属性。这是自动绑定的(意味着其值由框架从浏览器 URL 中相应的段提供)。换句话说,如果用户访问 /chat/MyCoolChatRoom,则此 Room 属性将具有值 "MyCoolChatRoom"。当没有指定房间名时,默认房间名为 "public"

当组件初始化时,它订阅以下事件:

HubConnection.SubscribeToMessageReceived

OnMessageReceivedAsync 方法是处理程序。

HubConnection.SubscribeToUserTyping

OnUserTypingAsync 方法是处理程序。

当组件被销毁时,它将离开当前聊天室,但会发出适当的 HubConnection.LeaveChatAsync 方法调用。还有一个 _subscriptions 栈将被取消订阅。下一个 Chat 实现的部分定义在 Chat.razor.Messages.cs C# 文件中:

namespace Learning.Blazor.Pages
{
    public sealed partial class Chat
    {
        private readonly Dictionary<Guid, ActorMessage> _messages = new(); ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

        private Guid? _messageId = null!;
        private string? _message = null!;
        private bool _isSending = false;
        private ElementReference _messageInput;

        bool OwnsMessage(string user) => User?.Identity?.Name == user;

        Task OnMessageReceivedAsync(Notification<ActorMessage> message) => ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            InvokeAsync(
                async () =>
                {
                    _messages[message.Payload.Id] = message;

                    StateHasChanged();

                    await JavaScript.ScrollIntoViewAsync(
                        $"[id='{message.Payload.Id}']");
                });

        Task OnKeyUpAsync(KeyboardEventArgs args) ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        {
            if (_isSending)
            {
                return Task.CompletedTask;
            }

            return args switch
            {
                { Key: "Enter" } => SendMessageAsync(),
                _ => Task.CompletedTask
            };
        }

        async Task SendMessageAsync() ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        {
            if (_isSending || string.IsNullOrWhiteSpace(_message))
            {
                return;
            }

            try
            {
                _isSending = true;

                await HubConnection.PostOrUpdateMessageAsync(
                    Room ?? DefaultRoomName, _message, _messageId);

                _message = null;
                _messageId = null;
            }
            finally
            {
                _isSending = false;
            }
        }

        async Task OnEditMessageAsync(ActorMessage message) ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        {
            if (!OwnsMessage(message.UserName))
            {
                return;
            }

            _messageId = message.Id;
            _message = message.Text;

            await _messageInput.FocusAsync();
        }
    }
}

1

_messages 被表示为键值对的集合。

2

OnMessageReceivedAsync 方法是从 hub 连接接收消息时的事件处理程序。

3

当用户正在输入并抬起键时,将触发 OnKeyUpAsync 方法。

4

要发送消息,使用 SendMessageAsync 方法。

5

当聊天室用户拥有消息时,他们可以使用 OnEditMessageAsync 方法开始编辑消息。

Chat/Messages 实现涵盖了消息管理的方方面面。从 _messages 的集合到单个 _message_messageId,该类包含了用于维护聊天消息状态的类作用域字段。 _isSending 值用于表示正在发送消息。 _messageInput 是框架提供的 ElementReference。当组件第一次渲染时,通过 FocusAsync 扩展方法聚焦于 _messageInput

Chat.OwnsMessage 方法接受一个 user 参数,并将其与上下文中的当前用户进行比较。这样可以防止任何人编辑他们没有所有权的消息。当接收到消息时,将调用 OnMessageReceivedAsync 方法。由于这可能发生在任何时候,该方法需要调用 StateHasChanged_messages 集合通过传入消息进行更新,并使用 JavaScript 调用 ScrollIntoViewAsync 方法,给定 message.PayloadId。这是使用命名扩展方法模式的 JavaScript 互操作调用。

当用户输入他们的聊天消息时,将调用 OnKeyUpAsync 方法。如果用户当前正在发送消息(由 _isSending 位确定),则为 NOOP(即不执行任何操作)。但是,当用户按下 Enter 键时,消息将被发送。如果消息已经在发送或根本没有消息,SendMessageAsync 方法将提前退出。当有消息要发送时,将调用 HubConnection.PostOrUpdateMessageAsync 方法。

如果用户决定编辑消息,OnEditMessageAsync 方法首先确保用户拥有该消息。 _message_messageId 被分配给正在编辑的消息,并将焦点返回到消息输入框。最后一部分 Chat 功能是 debounce 实现。要了解更多,请查看 Chat.razor.Debounce.cs C# 文件:

namespace Learning.Blazor.Pages
{
    public sealed partial class Chat
    {
        private readonly HashSet<Actor> _usersTyping = new(); ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        private readonly SystemTimerAlias _debounceTimer = new()
        {
            Interval = 750,
            AutoReset = false
        };

        private bool _isTyping = false;

        public Chat() => ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            _debounceTimer.Elapsed += OnDebounceElapsed;

        Task InitiateDebounceUserIsTypingAsync() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        {
            _debounceTimer.Stop();
            _debounceTimer.Start();

            return SetIsTypingAsync(true);
        }

        Task OnUserTypingAsync(Notification<ActorAction> actorAction) => ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            InvokeAsync(() =>
            {
                var (_, (user, isTyping)) = actorAction;
                _ = isTyping
                    ? _usersTyping.Add(new(user))
                    : _usersTyping.Remove(new(user));

                StateHasChanged();
            });

        Task SetIsTypingAsync(bool isTyping) ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        {
            if (_isTyping && isTyping)
            {
                return Task.CompletedTask;
            }

            return HubConnection.ToggleUserTypingAsync(
                _isTyping = isTyping);
        }

        bool TryGetUsersTypingText( ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
 [NotNullWhen(true)] out string? text)
        {
            var ut = _usersTyping
                ?.Select(a => a.UserName)
                ?.ToArray();

            text = ut?.Length switch
            {
                0 or null => null,
                1 => Localizer["UserIsTypingFormat", ut[0]],
                2 => Localizer["TwoUsersAreTypingFormat", ut[0], ut[1]],
                _ => Localizer["MultiplePeopleAreTyping"]
            };

            return text is not null;
        }

        async void OnDebounceElapsed(object? _, ElapsedEventArgs e) => ![7](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/7.png)
            await SetIsTypingAsync(false);
    }
}

1

debounce 实现维护 HashSet<Actor> _usersTyping

2

Chat 构造函数连接了 _debounceTimer.Elapsed 事件。

3

InitiateDebounceUserIsTypingAsync方法负责重新启动_debounceTimer并调用SetIsTypingAsync以设置为true

4

OnUserTypingAsync方法处理聊天室中有人输入时触发的事件。

5

SetIsTypingAsync方法有条件地切换一个表示用户是否正在输入状态的值。

6

TryGetUsersTypingText辅助方法获取显示用户正在输入时的消息。

7

在分配的抖动时间之后,将调用OnDebounceElapsed方法,从而清除输入状态。

Chat/Debounce实现管理_usersTyping集合、来自System.Timers.Timer命名空间的_debounceTimer以及表示用户是否正在输入的值。

当调用OnUserTypingAsync方法时,Notification<ActorAction>参数提供了用户是否正在输入的值。用户将被添加或从_usersTyping集合中移除。TryGetUsersTypingText辅助消息依赖于_usersTyping集合的当前状态和Localizer来格式化消息。例如,如果我的朋友 Carol 和 Chad 都在输入消息,UI 看起来类似于图 6-9。

图 6-9. 多人输入消息的聊天室

摘要

在本章中,您学习了如何使用 ASP.NET Core SignalR 实现实时 Web 功能。您看到了如何清晰地分离领域责任,充分利用 C#部分类。我们逐步介绍了功能丰富的服务器端 SignalR 实现的源代码,包括在BackgroundService中的HubIHub​Con⁠text<T>。您学习了创建实时警报和通知的可能方法,一个用于实时用户交互的消息系统以及一个可加入的活跃 Twitter 流的方法。最后,您学习了如何从我们的 Blazor WebAssembly 应用程序中消费这些数据,重点放在功能丰富的聊天应用上。

在下一章中,您将学习 C#源生成器的一个有效用例。您将看到如何使用众所周知的 JavaScript Web API 来生成扩展方法的实现,实现 JavaScript 互操作功能。您将学习如何将此特定应用于localStorage JavaScript API。

第七章:使用源生成器

在本章中,我们将探讨.NET 开发平台如何使你能够在 Blazor 应用程序中使用 C#源生成器。这是一个引人注目的功能,因为它提供了出色的开发者体验,并缓解了编写重复代码的顾虑,让你能够专注于更有趣的问题。事实上,你可以使用源生成器利用 JavaScript API,而无需自己编写任何 JavaScript 互操作代码。我们将通过一个示例源生成器来介绍如何使用一个定义良好的 JavaScript API 来生成代码。

什么是源生成器?

源生成器是 C#开发者可以编写的组件,允许你做两件事:

  1. 检索表示正在编译的所有用户代码的编译对象。

  2. 生成可在编译过程中添加到编译对象的 C#源文件。

基本上,你可以编写源生成器代码来生成更多代码。你为什么要这样做呢?作为开发者,你可能会注意到自己反复编写相同的代码。或者,你可能会编写大量的样板代码或重复的编程习语。当出现这种情况时,就是考虑自动化和使用源生成器代表你编写代码的时候了。这不仅会让你的工作更容易,而且会帮助减少代码中的人为错误。

这就是 C#源生成器的用武之地。C#源生成器作为分析器钩入到 C#编译上下文中,并可选择性地在相同上下文中发出源代码。生成的代码既是用户编写的代码的组合,也是自动生成的代码。

让我们考虑 JavaScript 互操作的代码。每次我必须编写 JavaScript 互操作代码时,我都必须执行以下步骤:

  1. 使用 API 参考文档来观察目标 JavaScript API,并推断正确消耗 JavaScript API 的方式。

  2. 创建一个扩展方法,扩展IJSRuntimeIJSInProcessJRuntime接口以公开 JavaScript API。

  3. 将互操作调用委托给我正在扩展的接口,将参数和返回值从 JavaScript API 映射到 C#方法。

  4. 使用扩展方法调用 JavaScript 互操作功能。

这变得重复了,因此很适合编写并使用源生成器。使用 Blazor WebAssembly,框架提供的IJSRuntime也是IJSInProcessRuntime类型的实现。该接口公开同步 JavaScript 互操作方法。这是因为 WebAssembly 在同一进程中有其相应的 JavaScript 实现,因此可以同步发生。这比使用async ValueTask替代方案的开销小,并且对于 Blazor WebAssembly 应用程序而言,这被认为是一种优化,而不是 Blazor Server 托管模型。

在本章后面,你将会了解到 blazorators,它提供了一个源代码生成器,可用于生成 Blazor 应用的 JavaScript 交互代码。它还生成了一些库,这些库是源代码生成的结果。该源代码生成器依赖于 C# 编译器平台 (Roslyn) 的 API。它有一个生成器,实现了 Microsoft.CodeAnalysis.ISourceGenerator 接口。这个接口被编译器用来生成源代码,我们可以根据需要自由实现它。在下一节中,你将看到一个例子,展示了一个源代码生成的 JavaScript API,它生成一个可重用的类库。

为源代码生成器构建案例

许多应用程序需要某种形式的持久性来保存用户状态。幸运的是,所有现代浏览器都支持存储,这是一种直接在浏览器中持久化用户状态的方式。Blazor.LocalStorage.WebAssembly NuGet 包是由 blazorators 源代码生成器创建的。它是一个类库,提供了一组强大的 API,它依赖于 JavaScript,但本身不包含任何 JavaScript。它仅仅是委托给浏览器的 localStorage API。

ECMAScript 标准指定了许多众所周知且被支持的 Web API,以及 DOM 和浏览器对象模型 (BOM) 的 API。

提示

Blazor 负责专门管理 DOM,因此建议避免生成特定于 DOM 的 JavaScript API。这是一个重要的细节,因为多段代码同时操作同一 API 可能会导致冲突和异常行为。

让我们专注于 Web API,这些 API 是暴露给 JavaScript 的。这里的 Web API 术语不应与 HTTP Web API 混淆,而是指原生于 JavaScript 的 API。其中一个这样的 API 是 window.localStorage。这是 Storage API 的一种实现。本地存储允许网站在浏览器会话之间持久化数据,非常适合用户偏好和类似用途。localStorage API 不需要安全上下文,并且内容存储在客户端浏览器上,用户可以通过浏览器的开发者工具看到这些内容。

window.localStorage 的 API 表面描述在 表 7-1 中。

表 7-1. 本地存储 API 表

方法名 参数 返回类型
clear none void
removeItem DOMString keyName void
getItem DOMString keyName DOMString &#124; null
setItem DOMString keyName, DOMString keyValue void
key number index DOMString &#124; null
length none number

Blazor JavaScript 互操作在localStorage JavaScript API 中有一个典型示例。在 Blazor 应用程序中看到这些不同的实现并不罕见。这段代码变得重复,维护起来可能既乏味又耗时,而且容易出错。在下一节中,我们将讨论blazorators源生成器如何使用 TypeScript 声明为localStorage API 创建适当的 JavaScript 互操作代码。要将此 JavaScript API 公开给 Razor 组件库或 Blazor WebAssembly 应用程序,需要引用IJSRuntimeIJSInProcessRuntime实现,并将 JavaScript 互操作调用委托给本地localStorage API 以提供其功能。

正如在“重新定义单页应用程序”中解释的那样,TypeScript 为 JavaScript 提供了静态类型系统。类型可以在类型声明文件中定义。blazorators源生成器依赖于 TypeScript 类型声明。对于常见的 JavaScript API,类型声明信息可以在 TypeScript GitHub 仓库上公开获取。源生成器获取并读取来自lib.dom.d.ts文件的类型声明。源生成器解析来自 JavaScript 的类型,并将其转换为相应的 C# 结构。

要帮助可视化这个过程,请考虑图 7-1。

图 7-1. 源生成器块图示

类型声明是从 HTTP GET 调用中请求的,源生成器确定输出的 C# 代码。lib.dom.d.ts 文件中的Storage接口类似于以下 TypeScript 代码,并用于生成相应的 C# 代码:

interface Storage {

    readonly length: number;

    clear(): void;
    getItem(key: string): string | null;
    key(index: number): string | null;
    removeItem(key: string): void;
    setItem(key: string, value: string): void;
}

此接口的实现将提供一个只读的length属性,返回Storage中项目的数量。实现还将提供cleargetItemkeyremoveItemsetItem的常见功能。源生成器将此接口解析为描述接口的 C# 对象。源生成器动态创建JSAutoGenericInterop属性。源生成器发现该属性,并根据属性值的元数据将其转换为生成器选项。源生成器将识别所需的TypeName及其对应的Implementation值的实现。

在编译时,当源生成器检测到JSAutoGenericInterop属性时,它将查找TypeNameImplementation的值。然后,源生成器将为Storage接口生成 JavaScript 互操作代码。源生成器解析 TypeScript 声明,并具有将这些方法转换为 JavaScript 互操作扩展方法的逻辑。在接下来的部分中,我将向您展示如何将localStorage API 实现为可重用的类库。

C#源生成器的实际应用

现在您知道 C#源生成器的工作原理,我将向您展示如何在 Blazor 应用程序开发中使用它们。在构建源生成器的用例时,我们看到 TypeScript 的类型声明定义了 API,并且源生成器可以使用此信息来生成适当的 JavaScript 互操作代码。您可以选择编写自己的源生成器,或者使用blazorators源生成器。

源生成localStorage API

如果我告诉您,C#源生成器可以用来生成具有相应 JavaScript 互操作代码的整个库,您会相信吗?这是真的!例如,我创建了Blazor.SourceGenerator项目,它正是如此。它是一个 C#源生成器,可以根据众所周知的 API 生成 JavaScript 互操作代码。

Blazor.LocalStorage.WebAssembly NuGet 包仅包含在C#文件 ILocalStorageService.cs中定义的以下代码:

namespace Microsoft.JSInterop;

[JSAutoGenericInterop(
 TypeName = "Storage",
 Implementation = "window.localStorage",
 Url = "https://developer.mozilla.org/docs/Web/API/Window/localStorage",
 GenericMethodDescriptors = new[]
    {
        "getItem",
        "setItem:value"
    })]
public partial interface ILocalStorageService
{
}

Blazor.SourceGenerator项目源生成了大量代码。此项目中唯一手写的代码是前面的 14 行。此代码将其指定为Microsoft.JSInterop命名空间,使得所有源生成功能对任何使用此命名空间的消费者都可用。该接口是partial,因为它将用户定义的代码与源生成的代码分开。它使用JSAutoGenericInteropAttribute来指定以下元数据:

TypeName = "Storage"

这将目标类型名称设置为Storage

Implementation = "window.localStorage"

这表达了如何从全局作用域的window对象中定位指定类型的实现;这是localStorage的实现。

Url

这将设置实现的 URL;源生成器将使用它来自动为生成的 API 创建代码注释。

GenericMethodDescriptors

这些描述符用于推断应使用泛型返回类型或泛型参数生成源代码的哪些方法。通过指定"getItem"方法,其返回类型将是泛型TValue类型。同样地,指定"setItem:value"将指示名为value的参数作为泛型TValue类型。

这里有很多描述性元数据,可以从这个装饰属性中推断出来。编译后,Blazor.SourceGenerators 项目将识别此文件并在 ILocalStorageService 上源生成相应的 localStorage JavaScript 互操作扩展方法。文件也需要是一个 public partial interface

生成的结果 C# 代码现在显示在 ILocalStorageService.g.cs C# 文件中:

using Blazor.Serialization.Extensions;
using System.Text.Json;

#nullable enable
namespace Microsoft.JSInterop;

/// <summary> /// Source generated interface definition of the <c>Storage</c> type. /// </summary> public partial interface ILocalStorageService
{
    /// <summary>
    /// Source generated implementation of
    /// <c>window.localStorage.length</c>.
    /// <a href=
    /// "https://developer.mozilla.org/docs/Web/API/Storage/length"></a>
    /// </summary>
    double Length { get; } ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

    /// <summary>
    /// Source generated implementation of
    /// <c>window.localStorage.clear</c>.
    /// <a href=
    /// "https://developer.mozilla.org/docs/Web/API/Storage/clear"></a>
    /// </summary>
    void Clear(); ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)

    /// <summary>
    /// Source generated implementation of
    /// <c>window.localStorage.getItem</c>.
    /// <a href=
    /// "https://developer.mozilla.org/docs/Web/API/Storage/getItem"></a>
    /// </summary> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    TValue? GetItem<TValue>(
        string key,
        JsonSerializerOptions? options = null);

    /// <summary>
    /// Source generated implementation of
    /// <c>window.localStorage.key</c>.
    /// <a href=
    /// "https://developer.mozilla.org/docs/Web/API/Storage/key"></a>
    /// </summary> ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    string? Key(double index);

    /// <summary>
    /// Source generated implementation of
    /// <c>window.localStorage.removeItem</c>.
    /// <a href=
    /// "https://developer.mozilla.org/docs/Web/API/Storage/removeItem"></a>
    /// </summary> ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
    void RemoveItem(string key);

    /// <summary>
    /// Source generated implementation of
    /// <c>window.localStorage.setItem</c>.
    /// <a href=
    /// "https://developer.mozilla.org/docs/Web/API/Storage/setItem"></a>
    /// </summary> ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
    void SetItem<TValue>(
        string key,
        TValue value,
        JsonSerializerOptions? options = null);
}

1

Length 方法返回 local​Stor⁠age 实现中底层数组的 length

2

Clear 方法清除 localStorage

3

GetItem 方法以期望的通用形状返回对应 key 的项目。

4

Key 方法返回 localStorage 中对应 indexkey

5

RemoveItem 方法移除 localStorage 中对应 key 的项目。

6

SetItem 方法为 localStorage 中对应的 key 设置项目。

由于这是一个部分接口,源生成器将生成 ILocalStorageService。相应的实现也是源生成的。生成代码的消费者使用 ILocalStorageService 类型上创建的方法来访问 localStorage API。此代码是异步代码的同步替代品,由 Blazor.LocalStorage.Server NuGet 包 生成。Blazor.LocalStorage.WebAssembly NuGet 包是依赖于 Blazor.SourceGenerators 项目的类库。生成此代码的优势是巨大的。通过少量声明式的手写 C#,可以源生成整个库,并且这些库可以被任何 Razor 项目或 Blazor WebAssembly 项目使用。

ILocalStorageService 将通过框架的 DI 系统公开。此接口是使用 TypeNameImplementation 属性的知识生成的。TypeName 是将暴露给生成代码的类型名称。Implementation 是用于实现 ILocalStorageService 接口的 JavaScript 类型的名称。这基于 localStorage Web API。以下是源生成的 LocalStorage 实现,定义在源生成的 LocalStorageService.g.cs C# 文件中:

#nullable enable

using Blazor.Serialization.Extensions;
using Microsoft.JSInterop;
using System.Text.Json;

namespace Microsoft.JSInterop;

/// <inheritdoc /> internal sealed class LocalStorageService : ILocalStorageService
{
    private readonly IJSInProcessRuntime _javaScript = null;

    /// <inheritdoc />
    double ILocalStorageService.Length => ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        _javaScript.Invoke<double>(
            "eval",
            new object[1]
            {
                "window.localStorage.length"
            });

    public LocalStorageService(IJSInProcessRuntime javaScript) ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        _javaScript = javaScript;
    }

    /// <inheritdoc />
    void ILocalStorageService.Clear() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    {
        _javaScript.InvokeVoid(
            "window.localStorage.clear");
    }

    /// <inheritdoc />
    TValue? ILocalStorageService.GetItem<TValue>( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        string key,
        JsonSerializerOptions? options)
    {
        return _javaScript.Invoke<string>(
            "window.localStorage.getItem",
            new object[1]
            {
                key
            })
            .FromJson<TValue>(options);
    }

    /// <inheritdoc />
    string? ILocalStorageService.Key(double index) ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
    {
        return _javaScript.Invoke<string>(
            "window.localStorage.key",
            new object[1]
            {
                index
            });
    }

    /// <inheritdoc />
    void ILocalStorageService.RemoveItem(string key) ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
    {
        _javaScript.InvokeVoid(
            "window.localStorage.removeItem",
            key);
    }

    /// <inheritdoc />
    void ILocalStorageService.SetItem<TValue>( ![7](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/7.png)
        string key,
        TValue value,
        JsonSerializerOptions? options)
    {
        _javaScript.InvokeVoid(
            "window.localStorage.setItem",
            key,
            value.ToJson<TValue>(options));
    }
}

1

Length 属性返回 localStorage 中的项目数。

2

LocalStorage 构造函数以 IJSInProcessRuntime 作为参数。

3

Clear 方法通过调用 clear JavaScript 方法来清空 localStorage

4

GetItem 方法返回 local​Stor⁠age 中对应 key 的项。

5

Key 方法返回 localStorage 中给定 index 处的 key

6

RemoveItem 方法通过调用 clear JavaScript 方法从 localStorage 中移除对应 key 的项。

7

SetItem 方法在 localStorage 中为对应的 key 设置项。

该接口支持泛型和可自定义的 Json​Seria⁠lizerOptions 序列化。JsonSerializerOptions 用于控制 GetItem 方法中 TValue 类型的序列化方式。如果未提供 options,则将使用默认序列化方式。

需要注意这是一个 internal sealed class,它是 ILocalStorageService 接口的显式实现。这样做是为了确保 LocalStorageService 实现不会直接暴露给生成代码的使用者,而是仅通过抽象进行访问。功能将通过本机 .NET DI 机制与使用者共享,并且该代码也是源生成的。

实现依赖于 IJSInProcessRuntime 类型进行 JavaScript 互操作。根据给定的 TypeName 和对应的 Implementation,还生成了以下代码:

ILocalStorageService.g.cs

对应 Storage Web API 表面的部分接口

LocalStorageService.g.cs

internal sealed 实现了 ILocalStorageService 接口

LocalStorageServiceCollectionExtensions.g.cs

扩展方法将 ILocalStorageService 服务添加到 DI 的 IServiceCollection 中。

以下是一个源生成的 LocalStorageServiceCollectionExtensions.g.cs C# 文件:

using Microsoft.JSInterop;

namespace Microsoft.Extensions.DependencyInjection;

/// <summary></summary> public static class LocalStorageServiceCollectionExtensions
{
    /// <summary>
    /// Adds the <see cref="ILocalStorageService" /> service to
    /// the service collection.
    /// </summary>
    public static IServiceCollection AddLocalStorageServices( ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        this IServiceCollection services) =>
        services.AddSingleton<IJSInProcessRuntime>(serviceProvider =>
            (IJSInProcessRuntime)serviceProvider.
            GetRequiredService<IJSRuntime>())
            .AddSingleton<ILocalStorageService, LocalStorageService>(); ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
}

1

AddLocalStorageServices 方法以 IServiceCollection 为参数。

2

AddLocalStorageServices 方法返回的 IServiceCollection 中添加了 ILocalStorageService 服务,并添加了依赖的框架提供的 IJSInProcessRuntime

在 Web.Client 的WebAssemblyHostBuilderExtensions类中调用此方法,以在 DI IServiceCollection 中注册ILocalStorageService服务。将这些部分结合起来,Blazor.LocalStorage.WebAssembly NuGet 包只需不到 15 行手写代码,其余部分都是生成的代码,提供了完全功能的 JavaScript 互操作实现,可以作为 DI-ready 服务进行注册。该服务注册为单例,ILocalStorageService接口暴露给生成代码的消费者。在下一节中,我将解释如何使用源代码生成器创建完全不同的库来处理Geolocation JavaScript API。

生成 Geolocation API 的源代码

地理位置信息可能非常有用,可以增强您的应用程序的用户体验。例如,您可以用它告诉用户最近商店的位置,或者可以使用它提供用户所在地区的天气情况。这非常方便,但您需要请求用户授权,以便与您的应用程序共享其地理位置信息。我在前一节介绍给您的源代码生成器项目还生成了Blazor.Geolocation.WebAssembly NuGet 包。此包用于在浏览器中访问Geolocation API。与localStorage API 不同,此 API 不需要泛型或自定义序列化,但需要双向 JavaScript 互操作,这是一个很好的学习示例。

Geolocation API 的 JavaScript API 通过window.navigator.geolocation JavaScript 对象公开。Geolocation API 需要安全上下文,这意味着浏览器会本地提示用户是否允许使用位置服务。用户有选择权,如果他们选择“否”,则无法使用此功能。如果用户选择“允许”,则浏览器将启用此功能。在安全上下文中,浏览器必须使用 HTTPS 协议。根据 TypeScript 接口声明,API 定义如下,同样可以在lib.dom.d.ts文件中找到:

interface Geolocation {
    clearWatch(watchId: number): void;

    getCurrentPosition(
        successCallback: PositionCallback,
        errorCallback?: PositionErrorCallback | null,
        options?: PositionOptions): void;

    watchPosition(
        successCallback: PositionCallback,
        errorCallback?: PositionErrorCallback | null,
        options?: PositionOptions): number;
}

所有这些类型都可以在lib.dom.d.ts文件中找到。Geolocation的定义是事情变得有趣的地方。当然,源代码生成器可以像处理本地存储部分那样生成此 API,但这次生成器需要做更多的工作。还需要评估并可能生成以下类型:

  • PositionCallback

  • PositionErrorCallback

  • PositionOptions

让我们首先从这两个回调函数开始。PositionCallback是在调用getCurrentPositionwatchPosition方法时调用的回调函数。这些回调函数在 TypeScript 中定义如下:

interface PositionCallback {
    (position: GeolocationPosition): void;
}

interface PositionErrorCallback {
    (positionError: GeolocationPositionError): void;
}

每个回调函数都是一个定义了回调方法签名的接口。源代码生成器还必须理解并生成GeolocationPositionGeolocationPositionError类型。这些类型在 TypeScript 中的定义如下:

interface GeolocationPosition {
    readonly coords: GeolocationCoordinates;
    readonly timestamp: DOMTimeStamp;
}

interface GeolocationPositionError {
    readonly code: number;
    readonly message: string;
    readonly PERMISSION_DENIED: number;
    readonly POSITION_UNAVAILABLE: number;
    readonly TIMEOUT: number;
}

GeolocationPosition类型有两个属性,coordstimestampcoords属性是一个定义了GeolocationCoordinates类型的接口。timestamp属性是一个DOMTimeStamp类型。DOMTimeStamp类型是一个number类型,其值是自 Unix 纪元(1970 年 1 月 1 日)以来经过的毫秒数,作为协调世界时(UTC)。源代码生成器将为DOMTimeStamp类型生成readonly属性,以 UTC 转换的.NETDateTime作为便利。GeolocationCoordinates类型定义如下:

interface GeolocationCoordinates {
    readonly accuracy: number;
    readonly altitude: number | null;
    readonly altitudeAccuracy: number | null;
    readonly heading: number | null;
    readonly latitude: number;
    readonly longitude: number;
    readonly speed: number | null;
}

最后,源代码生成器将识别在 TypeScript 中定义的PositionOptions类型,如下所示:

interface PositionOptions {
    enableHighAccuracy?: boolean;
    maximumAge?: number;
    timeout?: number;
}

源代码生成器有大量代码需要生成。让我们看看这是如何实现的。Blazor.Geolocation.WebAssembly NuGet 包包含两个手写文件。第一个是C#文件IGeolocationService.cs,我们现在来看一下,第二个是 JavaScript 文件,稍后再看:

namespace Microsoft.JSInterop;

[JSAutoInterop(
 TypeName = "Geolocation",
 Implementation = "window.navigator.geolocation",
 Url = "https://developer.mozilla.org/docs/Web/API/Geolocation")]
public partial interface IGeolocationService
{
}

同样,该库定义了一个partial interfaceTypeName设置为"Geolocation",这是 JavaScript API 的名称。Implementation设置为"window​.nav⁠iga⁠tor.geolocation",这是库公开的 JavaScript API。Url设置为 JavaScript API 文档的 URL。源代码生成器将生成以下C#接口IGeolocationService.g.cs

#nullable enable
namespace Microsoft.JSInterop;

/// <summary> /// Source generated interface definition of the <c>Geolocation</c> type. /// </summary> public partial interface IGeolocationService
{
    /// <summary>
    /// Source generated implementation of
    /// <c>window.navigator.geolocation.clearWatch</c>.
    /// <a href=
    /// "https://developer.mozilla.org/docs/Web/API/Geolocation/clearWatch">
    /// </a>
    /// </summary>
    void ClearWatch(double watchId); ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

    /// <summary>
    /// Source generated implementation of
    /// <c>window.navigator.geolocation.getCurrentPosition</c>.
    /// </summary>
    /// <param name="component">
    /// The calling Razor (or Blazor) component.
    /// </param>
    /// <param name="onSuccessCallbackMethodName">
    /// Expects the name of a <c>"JSInvokableAttribute"</c> C# method
    /// with the following <c>System.Action{GeolocationPosition}"</c>.
    /// </param>
    /// <param name="onErrorCallbackMethodName">
    /// Expects the name of a <c>"JSInvokableAttribute"</c> C# method
    /// with the following <c>System.Action{GeolocationPositionError}"</c>.
    /// </param>
    /// <param name="options">The <c>PositionOptions</c> value.</param>
    void GetCurrentPosition<TComponent>( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        TComponent component,
        string onSuccessCallbackMethodName,
        string? onErrorCallbackMethodName = null,
        PositionOptions? options = null)
        where TComponent : class;

    /// <summary>
    /// Source generated implementation of
    /// <c>window.navigator.geolocation.watchPosition</c>.
    /// </summary>
    /// <param name="component">
    /// The calling Razor (or Blazor) component.
    /// </param>
    /// <param name="onSuccessCallbackMethodName">
    /// Expects the name of a <c>"JSInvokableAttribute"</c> C# method
    /// with the following <c>System.Action{GeolocationPosition}"</c>.
    /// </param>
    /// <param name="onErrorCallbackMethodName">
    /// Expects the name of a <c>"JSInvokableAttribute"</c> C# method
    /// with the following <c>System.Action{GeolocationPositionError}"</c>.
    /// </param>
    /// <param name="options">The <c>PositionOptions</c> value.
    /// </param>
    double WatchPosition<TComponent>( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        TComponent component,
        string onSuccessCallbackMethodName,
        string? onErrorCallbackMethodName = null,
        PositionOptions? options = null)
        where TComponent : class;
}

1

ClearWatch方法接受一个double watchId值,该值由WatchPosition方法返回。

2

GetCurrentPosition方法接受一个TComponent组件,这是调用的 Razor(或 Blazor)组件。

3

WatchPosition方法接受一个TComponent组件,这是调用的 Razor(或 Blazor)组件。

TComponent参数用于调用onSuccessCallbackMethodNameonErrorCallbackMethodName方法。这些方法名需要是带有JSInvokableAttribute属性的方法。方法签名详细说明在生成的三斜线注释中。这对于使用这些 API 非常方便,因为源代码生成器将根据从相应 TypeScript 声明中解析的类型生成适当的 C#方法签名细节。

此接口的实现在C#文件GeolocationServices.g.cs中生成:

namespace Microsoft.JSInterop;

/// <inheritdoc /> internal sealed class GeolocationService : IGeolocationService
{
    private readonly IJSInProcessRuntime _javaScript = null;

    public GeolocationService(IJSInProcessRuntime javaScript)
    {
        _javaScript = javaScript; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    }

    /// <inheritdoc />
    void IGeolocationService.ClearWatch(double watchId) ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        _javaScript.InvokeVoid(
            "window.navigator.geolocation.clearWatch",
            watchId);
    }

    /// <inheritdoc />
    void IGeolocationService.GetCurrentPosition<TComponent>( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        TComponent component,
        string onSuccessCallbackMethodName,
        string? onErrorCallbackMethodName,
        PositionOptions? options)
    {
        _javaScript.InvokeVoid(
            "blazorators.getCurrentPosition",
            DotNetObjectReference.Create<TComponent>(component),
            onSuccessCallbackMethodName,
            onErrorCallbackMethodName,
            options);
    }

    /// <inheritdoc />
    double IGeolocationService.WatchPosition<TComponent>( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        TComponent component,
        string onSuccessCallbackMethodName,
        string? onErrorCallbackMethodName,
        PositionOptions? options)
    {
        return _javaScript.Invoke<double>(
            "blazorators.watchPosition",
            new object[4]
            {
                DotNetObjectReference.Create<TComponent>(component),
                onSuccessCallbackMethodName,
                onErrorCallbackMethodName,
                options
            });
    }
}

1

GeolocationService 构造函数接受一个IJSInProcessRuntime JavaScript,这是针对 Blazor WebAssembly 执行模型的特定 JavaScript 运行时。

2

IGeolocationService.ClearWatch方法接受一个double watchId,并委托给"window.navigator.geolocation.clearWatch" JavaScript 方法。

3

IGeolocationService.GetCurrentPosition 方法委托给 "blazorators.getCurrentPosition" JavaScript 方法。

4

IGeolocationService.WatchPosition 方法委托给 "blaz⁠ora⁠tors​.watchPosition" JavaScript 方法。

框架提供的 DotNetObjectReference 用于创建对组件的引用,该引用用于调用回调方法。 对于 GetCurrentPositionWatchPosition 方法,回调参数在委托的 JavaScript 内部与创建的组件引用一起使用。 在撰写本文时,blazorators 源代码生成器尚无法生成 "blazorators" 对象的 JavaScript 代码。 理论上这应该是可能的,但需要更多时间来开发。 相反,第二个手写文件是一个包含一些对应功能的 JavaScript 文件。 请考虑 blazorators.geolocation.js JavaScript 文件:

const onSuccess = (dotnetObj, successMethodName, position) => { ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    const result = {
        Timestamp: position.timestamp,
        Coords: {
            Accuracy: position.coords.accuracy,
            Altitude: position.coords.altitude,
            AltitudeAccuracy: position.coords.altitudeAccuracy,
            Heading: position.coords.heading,
            Latitude: position.coords.latitude,
            Longitude: position.coords.longitude,
            Speed: position.coords.speed
        }
    };
    dotnetObj.invokeMethod(successMethodName, result);
    dotnetObj.dispose();
};

const onError = (dotnetObj, errorMethodName, error) => { ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    const result = {
        Code: error.code,
        Message: error.message,
        PERMISSION_DENIED: error.PERMISSION_DENIED,
        POSITION_UNAVAILABLE: error.POSITION_UNAVAILABLE,
        TIMEOUT: error.TIMEOUT
    };
    dotnetObj.invokeMethod(errorMethodName, result);
    dotnetObj.dispose();
};

const getCurrentPosition = ( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    dotnetObj,
    successMethodName,
    errorMethodName,
    options) => {
    navigator.geolocation.getCurrentPosition(
        position => onSuccess(dotnetObj, successMethodName, position),
        error => onError(dotnetObj, errorMethodName, error),
        options);
}

const watchPosition = ( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    dotnetObj,
    successMethodName,
    errorMethodName,
    options) => {
    return navigator.geolocation.watchPosition(
        position => onSuccess(dotnetObj, successMethodName, position),
        error => onError(dotnetObj, errorMethodName, error),
        options);
}

window.blazorators = { ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
    getCurrentPosition,
    watchPosition
};

1

onSuccess 回调方法是一个辅助方法。 它被 getCurrentPosition 成功回调调用。

2

onError 回调方法是一个辅助方法。 它被 watch​Posi⁠tion 错误回调调用。

3

getCurrentPosition 方法接受一个 DotNetObjectReference dotnetObj,这是对组件的引用,以及一个 string successMethodName,这是要在组件上调用的方法的名称。 options 参数是一个 PositionOptions 对象,其中包含当前位置请求的选项。

4

watchPosition 方法接受一个 DotNetObjectReference dotnetObj,这是对组件的引用,以及一个 string successMethodName,这是要在组件上调用的方法的名称。 options 参数是一个 PositionOptions 对象,其中包含当前位置请求的选项。

5

blazorators 对象用于调用 getCurrentPositionwatch​Po⁠sition 方法。

下列类型都是由源代码生成器生成的:

  • GeolocationPosition

  • GeolocationPositionError

  • GeolocationCoordinates

  • PositionOptions

这意味着作为开发者,你会使用 Blazor.Geolocation.Web​Assem⁠bly NuGet 包,调用 AddGeolocationServices 扩展方法,然后使用 IGeolocationService。 这些回调的类型也都是可用的。 这是一个巨大的优势,并且提供了一个很好的示例,展示了 JavaScript 与 .NET 世界之间的绑定。

您可能还记得,在第三章中的 WeatherComponent 讨论中,我们讨论了 geolocation 的手动 JavaScript 互操作实现。虽然这是出于教育目的而故意这样做的,但您可以将手动实现重构出来,而不是使用 Blazor.Geolocation.WebAssembly 库。

在接下来的章节中,我们将看看如何使用 Blazor.LocalStorage.WebAssembly NuGet 包来访问应用程序代码中的 localStorage API。

ILocalStorageService 的示例用法

ILocalStorageService 类型的实现源代码已生成,因此让我们看看它的使用。本书的示例应用程序提供了几个依赖于应用程序状态能够在用户会话之外持久化的功能部分,例如用户的首选语言和音频描述设置,如语音速度和语音合成语音。这些值存储在 localStorage 中,并在用户重新访问站点时恢复。

在第四章中,我们简要讨论了 AudioDescriptionComponentAudioDescriptionComponent 是一个允许用户配置语音合成设置的组件。当用户配置音频描述设置时,AudioDescriptionComponent 依赖于 AppInMemoryState 类。 AppInMemoryState 被用作服务,并在第二章中进行了讨论。它公开了一个 ClientVoicePreference 属性,用于保存用户的首选语音设置,如图 7-2 所示。

图 7-2. 音频描述组件模态窗口

考虑以下 ClientVoicePreference.cs 记录类:

public record class ClientVoicePreference(
 [property: JsonPropertyName("voice")] string Voice,
 [property: JsonPropertyName("voiceSpeed")] double VoiceSpeed);

ClientVoicePreference 记录类有两个属性,VoiceVoiceSpeedVoice 属性是用户选择的语音名称。 VoiceSpeed 属性是语音播放的速度。此客户端偏好的值以 JSON string 的形式持久化在 localStorage 中。例如,以下 JSON string 表示用户的首选语音设置:

{
    "voice": "Microsoft Zira - English (United States)",
    "voiceSpeed": 1.5
}

当这个值存在于 localStorage 中时,AudioDescriptionComponent 将使用它来初始化 AppInMemoryStateClientVoicePreference 属性。考虑 AppInMemoryState.cs 类的简化版本,重点放在 ClientVoicePreference 属性上:

namespace Learning.Blazor.Services;

public sealed class AppInMemoryState
{
    private readonly ILocalStorageService _localStorage;
    private ClientVoicePreference? _clientVoicePreference;
    // Omitted for brevity... 
    public AppInMemoryState(ILocalStorageService localStorage) => ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        _localStorage = localStorage;

    public ClientVoicePreference ClientVoicePreference
    {
        get => _clientVoicePreference ??= ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            _localStorage.GetItem<ClientVoicePreference>(
                StorageKeys.ClientVoice) ?? new("Auto", 1);
        set
        {
            _localStorage.SetItem(
                StorageKeys.ClientVoice,
                _clientVoicePreference = value ?? new("Auto", 1));

            AppStateChanged();
        }
    }

    // Omitted for brevity... }

1

ILocalStorageService 类型被注入到 AppInMemoryState 类中。

2

如果 ClientVoicePreference 属性在 AppInMemoryState 实例中不存在,则从 _localStorage 中读取。

这个类公开了一个 ClientVoicePreference 属性,用于保存用户的首选语音设置。 ClientVoicePreference 属性是从 AudioDescriptionComponent 中读取以初始化自身的。

有了用户持久化首选项的知识,现在让我们看看AudioDescriptionComponent,它允许用户配置语音合成设置。 考虑下C#AudioDescriptionComponent.cs

namespace Learning.Blazor.Components
{
    public sealed partial class AudioDescriptionComponent
    {
        private readonly IList<double> _voiceSpeeds = ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
            Enumerable.Range(0, 12).Select(i => (i + 1) * .25).ToList();

        private IList<SpeechSynthesisVoice> _voices = null!;
        private string _voice = "Auto";
        private double _voiceSpeed = 1;
        private ModalComponent _modal = null!;

        protected override async Task OnAfterRenderAsync(bool firstRender)
        {
            if (firstRender)
            {
                (_voice, _voiceSpeed) = ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
                    AppState.ClientVoicePreference;

                _details = new AudioDescriptionDetails(
                    AppState,
                    _voiceSpeeds,
                    _voices,
                    _voice,
                    _voiceSpeed);

                await UpdateClientVoices(
                    await JavaScript.GetClientVoicesAsync(
                        this, nameof(UpdateClientVoices)));
            }
        }
 [JSInvokable]
        public Task UpdateClientVoices(string voicesJson) =>
            InvokeAsync(() =>
            {
                var voices =
                    voicesJson.FromJson<List<SpeechSynthesisVoice>>(); ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
                if (voices is { Count: > 0 })
                {
                    _voices = voices;

                    StateHasChanged();
                }
            });

        private async Task ShowAsync() => await _modal.ShowAsync();

        private void OnDetailsSaved(AudioDescriptionDetails details)
        {
            // Clone
            _details = details with { };

            AppState.ClientVoicePreference = ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
                new ClientVoicePreference(_details.Voice, _details.VoiceSpeed);

            Logger.LogInformation(
                "There are {Length} item in localStorage.", LocalStorage.Length);
        }
    }

    public readonly record struct AudioDescriptionDetails( ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        AppInMemoryState AppState,
        IList<double> VoiceSpeeds,
        IList<SpeechSynthesisVoice> Voices,
        string Voice,
        double VoiceSpeed);
}

1

_voiceSpeeds属性是一个双精度数组,用于填充语音速度滑块。

2

_voice_voiceSpeed字段从AppState.ClientVoicePreference中分配,该值来自localStorage

3

可用语音从在JavaScript.GetClientVoicesAsync调用中注册的回调中检索得到。

4

ClientVoicePreference属性更改时,将其写入localStorage

5

AudioDescriptionDetails结构体是一个readonly record类型,用于初始化AudioDescriptionComponent_details字段。

AudioDescriptionComponent代表依赖于应用状态能够在用户会话之外持久化的各种功能。 这是一个重要的细节,因为它与基于会话的存储不同。 JavaScript 的Storage接口有两种实现:localStoragesessionStorage。 会话存储实现仅在单个标签页生命周期内存在。 当标签页关闭时,会话存储将永远消失,包括用户的首选语言和音频描述设置,如语音速度和语音合成语音。 这些值被持久化在localStorage中,并在用户再次访问站点时恢复。 让我们来看看AudioDescriptionComponent.razor文件的标记:

@inherits LocalizableComponentBase<AudioDescriptionComponent> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

<span class="navbar-item">
    <button class="button is-info is-rounded level-item"
        title=@Localizer["Audio"] @onclick=ShowAsync> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        <span class="icon">
            <i class="fas fa-lg fa-audio-description"></i>
        </span>
    </button>
</span>

<AudioDescriptionModalComponent ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    @ref="_modal"
    Title=@Localizer["Settings"]
    Details=@_details
    OnDetailsSaved=@OnDetailsSaved/>

1

AudioDescriptionComponent使用LocalizableComponentBase类提供本地化支持。

2

大部分标记是导航栏中的按钮。

3

当用户点击音频描述按钮时,显示的模态是AudioDescriptionModalComponent

当用户点击音频描述按钮时,调用ShowAsync并显示AudioDescriptionModalComponentAudioDescriptionModalComponent是一个简单的模态框,允许用户配置语音合成设置。 使用@ref属性将对AudioDescriptionModalComponent的引用存储在_modal字段中。 _details字段使用来自AppState.ClientVoicePreference的当前值进行初始化,并传递给AudioDescriptionModalComponentAudioDescriptionModalComponent公开了一个OnDetailsSaved事件,由AudioDescriptionComponentOnDetailsSaved方法处理。

现在让我们看看C#AudioDescriptionModalComponent.cs

namespace Learning.Blazor.Components
{
    public sealed partial class AudioDescriptionModalComponent
    {
 [Parameter, EditorRequired]
        public AudioDescriptionDetails Details { get; set; } ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
 [Parameter, EditorRequired]
        public string Title { get; set; } = null!;
 [Parameter, EditorRequired]
        public EventCallback<AudioDescriptionDetails> OnDetailsSaved ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        {
            get;
            set;
        }

        private string _voice = null!;
        private ModalComponent _modal = null!;

        protected override void OnParametersSet() => _voice = Details.Voice; ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)

        private void OnVoiceSpeedChange(ChangeEventArgs args) => ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            Details = Details with
            {
                VoiceSpeed = double.TryParse(
                    args?.Value?.ToString() ?? "1", out var speed) ? speed : 1
            };

        internal async Task ShowAsync() => await _modal.ShowAsync();

        internal async Task ConfirmAsync() ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        {
            if (OnDetailsSaved.HasDelegate)
            {
                await OnDetailsSaved.InvokeAsync(
                    Details = Details with { Voice = _voice });
            }

            await _modal.ConfirmAsync();
        }
    }
}

1

Details 属性是一个轻量级 readonly record struct 类型。

2

OnDetailsSaved 事件是在用户点击 Confirm 按钮时调用的 EventCallback

3

_voice 字段在设置组件参数时从 Details 属性分配。

4

当用户在滑块中更改值时,将更新 VoiceSpeed 属性。

5

当用户点击 Confirm 按钮时,将调用 ConfirmAsync 方法。

AudioDescriptionModalComponent 取决于用户首选的 ClientVoice​Pre⁠ference 持久化。这是一个非常重要的细节,因为它不同于基于会话的存储。JavaScript Storage 接口有两种实现:localStoragesessionStorage。该应用只关注 localStorage 数据持久化。最后,我们看一下在 AudioDescriptionModal​Compo⁠nent.razor 文件中定义的 AudioDescriptionModal​Compo⁠nent Razor 标记:

@inherits LocalizableComponentBase<AudioDescriptionModalComponent>

<ModalComponent @ref="_modal"> ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    <TitleContent>
        <span class="icon pr-2">
            <i class="fas fa-cogs"></i>
        </span>
        <span>@Title</span>
    </TitleContent>

    <BodyContent>
        <form> ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            <div class="field">
                <label for="range"> Voice speed: @Details.VoiceSpeed </label>
                <input type="range" ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
                       min="@Details.VoiceSpeeds.Min()"
                       max="@Details.VoiceSpeeds.Max()"
                       step=".25" class="slider is-fullwidth is-info"
                       id="range" list="speeds"
                       value="@Details.VoiceSpeed"
                       @onchange=@OnVoiceSpeedChange>
                <datalist id="speeds"> @foreach (var speed in Details.VoiceSpeeds) ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png) { <option value="@speed">speed</option> } </datalist>
            </div>
            <div class="field">
                <p class="control has-icons-left">
                    <span class="select is-medium is-fullwidth">
                        <select id="voices" class="has-dotnet-scrollbar"
                            @bind=_voice> ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
                        <option selected>@Localizer["Auto"]</option> @if (Details.Voices is { Count: > 0 })
                        {
                            @foreach (var voice in Details.Voices) ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png) { <option selected="@voice.Default"
                                    value="@voice.Name"> @voice.Name </option> }
                        } </select>
                    </span>
                    <span class="icon is-small is-left">
                        <i class="fas fa-globe"></i>
                    </span>
                </p>
            </div>
        </form>
    </BodyContent>

    <ButtonContent>
        <button class="button is-success is-large"
            @onclick=ConfirmAsync> ![7](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/7.png)
            <span class="icon">
                <i class="fas fa-check"></i>
            </span>
            <span>@Localizer["Okay"]</span>
        </button>
    </ButtonContent>
</ModalComponent>

1

ModalComponent 是一个可重用的组件,用于显示模态框。

2

form 元素用于提供一个带有滑块和下拉列表的表单。滑块用于控制语音速度。下拉列表用于选择语音。

3

input 是一个 range 类型的元素,用于控制语音速度。

4

datalist 元素用于提供声音速度列表。

5

select 元素用于选择语音。

6

option 元素用于提供所有 Audio​Descrip⁠tionDetails.Voices 的声音列表。

7

"Okay" button 元素在用户点击时将调用 ConfirmAsync

form 是使用 Blazor 进行双向绑定的示例,而不使用 EditContext@bind 属性用于将 _voice 字段绑定到 Details 属性。@onchange 属性用于在用户更改滑块中的值或下拉列表中的值时更新 Details 属性。当用户修改这些值并关闭 _modal 时,将使用 ILocalStorageService 实现来持久化用户首选的 ClientVoicePreference 值。在下一章中,我们将介绍使用 EditContext 提供双向绑定的高级表单技术。

Summary

在本章中,你了解了为什么在开发 Blazor 应用程序时源生成器非常有用。源生成器可以节省开发时间,并有助于减少手写代码中固有的人为错误。你已经了解到源生成器可以生成整个可消耗的 JavaScript 互操作功能库的可能性。通过blazorators源生成器项目的示例,我向你展示了如何使用Blazor.LocalStorage.WebAssembly NuGet 包。

在下一章中,我将教你如何使用 Blazor 表单。我将向你展示如何验证用户输入以及如何定制用户体验。你将学习如何使用框架提供的EditForm组件。

第八章:使用验证接受表单输入

在本章中,您将学习如何使用框架提供的组件来接受表单输入,将自定义的 C#模型绑定到EditForm组件。我们将介绍在表单中使用本地语音识别。您还将学习如何在 Rx.NET 中使用响应式扩展。模型应用程序的联系页面表单将演示所有这些内容。

让我们从接受和验证用户输入的表单提交开始。您将看到如何将有效的用户输入发送到 HTTP 端点进行处理。

表单提交的基础知识

HTML form 元素的核心功能是接受和验证用户输入。当用户的输入无效时,应通知用户。当输入有效时,将其提交到 HTTP 端点进行处理。表单提交过程如下:

  1. 用户被呈现填写form

  2. 用户填写form并尝试提交。

  3. form进行验证。

    1. 如果form无效,则向用户显示验证消息或错误。

    2. 如果form有效,输入将被发送进行处理。

在这些步骤之间,用户以各种方式与form交互,有时通过键入,有时通过点击,有时通过选择单选按钮等。当表单无效时,表单的状态可以显示验证消息或错误给用户。表单可以接受多种不同类型的用户输入。我们可以对期望的输入元素应用动态 CSS 来指示用户输入无效。我们可以控制哪个元素具有焦点,并且可以将元素设置为disabled或使其readonly。这些样式包括动画以强调错误条件并引导用户注意特定区域。

表单的框架提供的组件

Blazor 提供了许多组件,这些组件在本地 HTML 元素之上提供了一层。其中一个组件是EditFormEditForm组件被设计为包装原生 HTMLform元素。这是书籍模型应用程序的Contact表单中使用的组件。还有其他框架提供的组件。在下一节中,您将看到可以与EditForm一起使用的各种框架提供的组件。

Table 8-1 显示可以与EditForm组件一起使用的各种框架提供的组件。¹

表 8-1。框架提供的表单组件

Blazor 组件 包装的 HTML 元素 组件的目的
EditForm <form> 提供对原生 HTML form 元素的包装
InputCheckbox <input type="checkbox"> 接受用户输入,要么true要么false
InputDate<TValue> <input type="date"> 接受DateTime值作为用户输入
InputFile <input type="file"> 接受文件上传
InputNumber<TValue> <input type="number"> 接受用户输入的数值
InputRadio<TValue> <input type="radio"> 接受一组互斥的值,代表单个选择
InputRadioGroup<TValue> 一个或多个 InputRadio<TValue> 组件的父组件 在语义上将 InputRadio<TValue> 组件包装在一起,使它们成为互斥的
InputSelect<TValue> <select> 接受TValue值作为用户输入,来自自定义选项列表
InputText <input type="text"> 接受一个字符串值作为用户输入
InputTextArea <textarea> 接受一个字符串值作为用户输入,但通常显示和期望的值比 InputText 组件更大

使用上述组件,您可以构建一个与您的应用程序需求一样丰富和复杂的表单。

在下一节中,我将向您展示如何构建一个模型,该模型将表示表单的状态以及用户与之交互的情况。该模型将使用元数据进行修饰,这些元数据将用于验证其绑定到的表单。

模型和数据注解

表单的一个常见用例是为最终用户提供一种在应用程序内部联系某人的方式,原因各不相同。《联系》表单的学习 Blazor 应用程序正是如此。用户可以填写表单并向我,应用程序的所有者,发送消息。在他们点击发送并确认他们是人类之后,消息将作为电子邮件发送给我。我们将在本章中详细介绍其工作原理。

让我们首先浏览一下表单的用户输入:

  1. 用户的电子邮件地址(应用程序的当前用户,如果用户已登录,则预填充)。

  2. 用户的名字和姓氏,作为一对。

  3. 消息的主题,或者说他们通过应用程序联系的原因。

  4. 输入消息使用一个 TextArea 组件和一些有趣的 JavaScript 互操作。消息输入提供了一个切换语音识别的麦克风按钮。

作为视觉参考点,请考虑图 8-1。

图 8-1.《联系》页面的示例呈现

定义组件模型

作为表单提交过程的一部分,EditForm 将验证用户的输入。EditForm 还将显示验证消息和错误。这一切都基于 EditContext 或一个模型。模型是一个用于绑定属性和表示相关值的 C# 类。在《联系》页面的情况下,它使用 EditContext 来管理表单的状态。而 EditContext 则依赖于一个相应的模型。让我们来看一下 ContactComponentModel.cs 文件中的 ContactComponentModel,它负责表示表单的状态:

namespace Learning.Blazor.ComponentModels;

public record ContactComponentModel() ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
{
 [Required] ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    public string? FirstName { get; set; } = null!;
 [Required]
    public string? LastName { get; set; } = null!;
 [RegexEmailAddress(IsRequired = true)] ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    public string? EmailAddress { get; set; } = null!;
 [Required]
    public string? Subject { get; set; } = null!;
 [RequiredAcceptance] ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    public bool AgreesToTerms { get; set; }
 [Required]
    public string? Message { get; set; } = null!;

    public AreYouHumanMath NotRobot { get; } =
        AreYouHumanMath.CreateNew(MathOperator.Subtraction); ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)

    public string RobotQuestion => NotRobot.GetQuestion();

    public static implicit operator ContactRequest( ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
        ContactComponentModel model) =>
        new(model.FirstName!,
            model.LastName!,
            model.EmailAddress!,
            model.Subject!,
            model.Message!);
}

1

该模型是一种 record 类型。

2

FirstNameLastName 属性是必填的,依据 Required 属性。

3

EmailAddress 属性是必需的,并且必须是有效的电子邮件地址。

4

AgreesToTerms 属性必须为 true

5

NotRobot 属性是一个 readonly 属性,其计算结果来自 AreYouHumanMath 类。

6

record 定义了一个操作符,将其转换为 ContactRequest

此模型公开用户应提供的值。用户的名和姓是必需的,以及一个有效的电子邮件地址。Required 属性是一个由框架提供的数据注释属性,用于指示用户必须为属性提供值。如果用户没有提供值,并且他们尝试提交表单或导航离开底层 HTML 元素,则 EditForm 将显示错误消息。C# 属性用于为其应用的对象提供额外信息。

定义和使用验证属性

RegexEmailAddress 属性是一个自定义属性,用于指示用户必须提供有效的电子邮件地址。在装饰模型属性时,此属性将验证其为电子邮件地址。RequiredAcceptance 属性是一个自定义属性,用于指示用户必须接受条款和条件。您可以使用各种属性来定义对象。Message 属性是必需的,Subject 属性也是如此。

让我们来看看 Regex​E⁠mailAddressAttribute.cs C# 文件中 RegexEmailAddress 属性的实现:

using System.Text.RegularExpressions;

namespace Learning.Blazor.DataAnnotations;
 
    AttributeUsage( ![1 AttributeTargets.Property |
        AttributeTargets.Field |
        AttributeTargets.Parameter,
        AllowMultiple = false)
]
public sealed class RegexEmailAddressAttribute : DataTypeAttribute
{
    internal static readonly Regex EmailExpression = ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        new("^([a-zA-Z0-9_\\-\\.]+)@" +
        "((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.)" +
        "|(([a-zA-Z0-9\\-]+\\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\\]?)$",
            RegexOptions.CultureInvariant | RegexOptions.Singleline);

    /// <summary>
    /// Gets or sets a value indicating if an email is required.
    /// </summary>
    /// <remarks>Defaults to <c>true</c>.</remarks>
    public bool IsRequired { get; set; } = true; ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)

    public RegexEmailAddressAttribute()
        : base(DataType.EmailAddress) ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    {
    }

    public override bool IsValid(object? value) ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
    {
        if (value is null)
        {
            return !IsRequired;
        }

        return value is string valueAsString
            && EmailExpression.IsMatch(valueAsString);
    }
}

1

AttributeUsage 装饰器指定了另一个属性类的用法,即 RegexEmailAddressAttribute,它仅适用于属性、字段和参数。

2

EmailExpression 是一个 readonly Regex 实例,用于验证电子邮件地址。

3

IsRequired 属性允许开发人员确定是否需要电子邮件地址。

4

构造函数使用 DataType.EmailAddress 值调用其基础构造函数。

5

IsValid 方法用于验证作为可空对象传递的电子邮件地址。

Blazor 开发人员可以编写自定义 DataTypeAttribute。如果用户输入的电子邮件地址不匹配正则表达式,EditForm 将显示错误消息。如果值为 null 并且 IsRequired 属性为 trueEditForm 将显示错误消息。另一个自定义属性是 RequireAcceptance​At⁠tribute。此属性用于指示用户必须接受条款和条件。

接下来,让我们看看 RequiredAcceptanceAttribute,它在 Require⁠d​AcceptanceAttribute.cs C# 文件中定义:

namespace Learning.Blazor.DataAnnotations;
 
    AttributeUsage( ![1 AttributeTargets.Property |
        AttributeTargets.Field |
        AttributeTargets.Parameter,
        AllowMultiple = false)
]
public sealed class RequiredAcceptanceAttribute : DataTypeAttribute
{
    public RequiredAcceptanceAttribute()
        : base(DataType.Custom) ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
    }

    public override bool IsValid(object? value) ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    {
        if (value is null)
        {
            return false;
        }

        return bool.TryParse(value.ToString(), out var isAccepted)
            && isAccepted;
    }
}

1

RequiredAcceptanceAttribute 类似于 RegexEmailAddressAttribute

2

构造函数使用 DataTypeAttribute 的基础构造函数,并传入 DataType.Custom 值。

3

IsValid 方法用于验证条款和条件的接受情况。

如果用户不接受条款和条件,EditForm 将显示错误消息。当表示 value 的对象为 null,或者 valuefalse 时,将触发错误条件。您可以自由地创建所需的任何自定义业务逻辑规则。每当需要接受用户输入时,您将从建模表示您需求的对象开始。您将为模型的属性添加自定义或框架提供的数据注释。在接下来的部分中,我们将实践这一点,并看到模型如何绑定到表单组件。

实现联系表单

Contact 页面的标记有点冗长,但包含了许多具有不同功能和验证要求的用户输入。当用户输入处于错误状态时,为了使控件动画化并提供适当的样式,表单需要比语义化表单更多的标记。页面的标记内容包含在 Contact.cshtml Razor 文件中:

@page "/contact" ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png) @attribute [AllowAnonymous]
@inherits LocalizableComponentBase<Contact>

<PageTitle>@Localizer["Contact"]</PageTitle>

<section class="section">
    <h1 class="is-size-3 pb-3">@Localizer["Contact"]</h1>

    <EditForm class="pb-4" Context="cxt" EditContext="_editContext" ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
              OnValidSubmit=@(async c => await OnValidSubmitAsync(c))>
        <DataAnnotationsValidator />

        <!-- Email address -->
        <FieldInput> ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
            <FieldLabelContent> @Localizer["Email"] <i class="pl-4 far fa-lg
                    @cxt.GetValidityCss(() => _model.EmailAddress)"></i>
            </FieldLabelContent>
            <FieldControlContent>
                <InputText @ref="_emailInput"
                    @bind-Value="_model.EmailAddress" class="input"
                    readonly=@_isEmailReadonly disabled=@_isEmailReadonly
                    placeholder="@Localizer["EmailPlaceholder"]" />
                <span class="icon is-small is-left">
                    <i class="fas fa-envelope"></i>
                </span>
            </FieldControlContent>
        </FieldInput>
        <!-- First and last name --> ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        <div class="field is-horizontal">
            <div class="field-label is-normal">
                <label class="label"> @Localizer["From"] <i class="pl-4 far fa-lg
                        @cxt.GetValidityCss(
                            () => _model.FirstName,
                            () => _model.LastName)"></i>
                </label>
            </div>
            <div class="field-body">
                <div class="field">
                    <p class="control is-expanded has-icons-left">
                        <InputText @ref="_firstNameInput"
                            @bind-Value="_model.FirstName" class="input"
                            placeholder="@Localizer["FirstName"]" />
                        <span class="icon is-small is-left">
                            <i class="fas fa-user"></i>
                        </span>
                    </p>
                </div>
                <div class="field">
                    <p class="control is-expanded has-icons-left">
                        <InputText @bind-Value="_model.LastName" class="input"
                                   placeholder="@Localizer["LastName"]" />
                        <span class="icon is-small is-left">
                            <i class="fas fa-user"></i>
                        </span>
                    </p>
                </div>
            </div>
        </div>
        <!-- Subject -->
        <FieldInput> ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
            <FieldLabelContent> @Localizer["Subject"] <i class="pl-4 far fa-lg
                    @cxt.GetValidityCss(() => _model.Subject)"></i>
            </FieldLabelContent>
            <FieldControlContent>
                <InputText @bind-Value="_model.Subject" class="input"
                    placeholder="@Localizer["SubjectPlaceholder"]" />
                <span class="icon is-small is-left">
                    <i class="fas fa-info-circle"></i>
                </span>
            </FieldControlContent>
        </FieldInput>
        <!-- Message -->
        <FieldInput ControlClasses=@(Array.Empty<string>())> <FieldLabelContent> @Localizer["Message"] <i class="pl-4 far fa-lg
                    @cxt.GetValidityCss(() => _model.Message)"></i>
            </FieldLabelContent>
            <FieldControlContent>
                <AdditiveSpeechRecognitionComponent ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
                    SpeechRecognitionStarted=OnRecognitionStarted
                    SpeechRecognitionStopped=OnRecognitionStopped
                    SpeechRecognized=OnSpeechRecognized />
                <InputTextArea @bind-Value="_model.Message" class="textarea"
                    readonly=@_isMessageReadonly disabled=@_isMessageReadonly
                    placeholder="@Localizer["MessagePlaceholder"]" />
            </FieldControlContent>
        </FieldInput>
        <!-- Agree to terms -->
        <FieldInput ControlClasses=@(Array.Empty<string>())> ![7](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/7.png)
            <FieldLabelContent> @Localizer["AgreeToTerms"] <i class="pl-4 far fa-lg
                    @cxt.GetValidityCss(() => _model.AgreesToTerms)"></i>
            </FieldLabelContent>
            <FieldControlContent>
                <label class="checkbox">
                    <InputCheckbox @bind-Value="_model.AgreesToTerms" /> @Localizer["TermsAndConditions"] <a href="/termsandconditions" target="_blank"
                        rel="noopener noreferrer">
                        <i class="fas fa-external-link-alt"></i>
                    </a>
                </label>
            </FieldControlContent>
        </FieldInput>
        <!-- Send button --> ![8](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/8.png)
        <div class="field is-horizontal">
            <div class="field-label">
                <!-- Left empty for spacing -->
            </div>
            <div class="field-body">
                <div class="field is-grouped">
                    <button class="button is-success is-large" type="submit">
                        <span class="icon">
                            <i class="fas fa-paper-plane"></i>
                        </span>
                        <span>@Localizer["Send"]</span>
                    </button>
                </div>
            </div>
        </div>
    </EditForm>
</section>

<VerificationModalComponent @ref="_modalComponent" ![9](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/9.png)
    VerificationAttempted=@OnVerificationAttempted />

1

Contact 页面允许匿名用户联系站点所有者。

2

EditForm 是一个由框架提供的组件,用于呈现一个 form

3

页面模型接受一个 EmailAddress 属性并呈现一个 <InputText> 元素。

4

页面模型接受 FirstNameLastName 属性,并呈现两个 <input type="text"> 元素。

5

页面模型接受一个 Subject 属性并呈现一个 <InputText> 元素。

6

页面模型接受一个Message属性,并呈现一个<InputTextArea>元素。这是添加性语音识别组件呈现的地方,后面章节详细介绍。

7

页面模型接受一个AgreesToTerms属性,并呈现一个<InputCheckbox>元素。

8

页面模型接受一个Send按钮,并呈现一个<button>元素。

9

该页面引用VerificationModalComponent以进行垃圾邮件过滤。

当请求/contact路由时显示的页面如图 8-2 所示。

图 8-2. 一个空白的Contact页面表单,只有电子邮件地址预填充

让我们总结一下这里发生的事情。Contact页面是一个带有几个字段的表单。页面模型是一个包含绑定到表单元素的属性的类。EditForm组件是一个由框架提供的组件,用于呈现 HTML form元素。它要求使用EditContextModel,但不能同时使用两者。在这种情况下,EditContext包装了ContactComponentModel。在Edit​Con⁠text中使用的模型可以是任何objectEditContext保存与数据编辑过程相关的元数据,例如指示哪些字段已被修改的标志和当前设置的验证消息。EditContext.Model将被EditForm用于呈现表单。EditContext.OnValidSubmit事件处理程序用于处理表单提交。当表单既有效又提交时,将调用Contact.OnValidSubmitAsync事件处理程序。使用Data​Anno⁠tations​Val⁠ida⁠tor框架提供的组件添加验证Data​Annota⁠tions属性支持,通知EditContext实例有关模型的元数据。

表单中的字段如下所示:

电子邮件

一个自定义的FieldInput组件,绑定到模型的EmailAddress属性。

发件人

以框架提供的InputText组件呈现的两个水平字段,分别绑定到模型的FirstNameLastName属性。这些值都是必需的,可以改变共享验证图标的验证状态。

主题

一个自定义的FieldInput组件,绑定到模型的Subject属性。

消息

一个自定义的FieldInput组件,绑定到模型的Message属性,但依赖于AdditiveSpeechRecognitionComponent来添加与InputTextArea组件绑定的语音识别支持。AdditiveSpeech​Rec⁠ogni⁠tionComponent在其父 HTML 元素的右上角呈现一个覆盖切换<button>

是否用户同意条款

一个自定义的FieldInput组件,绑定到模型的AgreesToTerms属性,以及框架提供的InputCheckbox组件,用于呈现复选框。

提交表单按钮

EditForm 标记的末尾有一个发送 <button type="submit"> 元素。当用户点击此按钮时,如果表单有效,则调用 EditContext.OnValidSubmit 事件处理程序。

模态对话框

当用户点击 Send 按钮时,会显示由 VerificationModalComponent 渲染的对话框。该对话框用作垃圾邮件过滤器,因为它要求提交表单的用户回答一个数学问题的字符串形式。

阴影组件执行此操作是因为其中包含大量 Razor 标记。它用于管理框架提供的 EditContext_model_emailInput_firstNameInput_modalComponent 以及两个布尔值,用于确定电子邮件或消息输入元素是否应为 readonly。这些详细信息将在后续部分中说明。由于联系页面标有 AllowAnonymous,非经身份验证的用户可以访问该页面;这是有意为之,以便潜在的应用程序用户可以通过提问与我们联系。

Razor 组件通常在评估模型属性时使用 Expression<Func<T>> 语义(或表达式树)。表达式树将代码表示为数据结构,其中每个节点都是一个表达式。表达式看起来像函数,但并不会被直接评估。而是被解析。例如,当我们传入 _model.EmailAddress 时,Blazor 库调用 FieldCssClass。然后解析表达式,提取如何评估我们的模型及其相应的属性值。

为了方便确定特定模型属性表达式状态下适用的 CSS 类,GetValidityCss 扩展方法计算了属性的适当 CSS 类。请参考 EditContext​Ex⁠tensions.cs C# 文件:

namespace Learning.Blazor.Extensions;

public static class EditContextExtensions
{
    /// <summary>
    /// Maps the given <paramref name="accessor"/>
    /// expression to the resulting CSS.
    /// </summary>
    public static string GetValidityCss<T>( ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        this EditContext context,
        Expression<Func<T?>> accessor)
    {
        var css = context?.FieldCssClass(accessor);
        return GetValidityCss(
            IsValid(css),
            IsInvalid(css),
            IsModified(css));
    }

    /// <summary>
    /// Maps the given <paramref name="accessorOne"/> and
    /// <paramref name="accessorTwo"/> expressions to
    /// the resulting CSS.
    /// </summary>
    public static string GetValidityCss<TOne, TTwo>( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        this EditContext context,
        Expression<Func<TOne?>> accessorOne,
        Expression<Func<TTwo?>> accessorTwo)
    {
        var cssOne = context?.FieldCssClass(accessorOne);
        var cssTwo = context?.FieldCssClass(accessorTwo);
        return GetValidityCss(
            IsValid(cssOne) && IsValid(cssTwo),
            IsInvalid(cssOne) || IsInvalid(cssTwo),
            IsModified(cssOne) && IsModified(cssTwo));
    }

    /// <summary>
    /// Maps the given validation states into corresponding CSS classes.
    /// </summary>
    public static string GetValidityCss( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        bool isValid, bool isInvalid, bool isModified) =>
        (isValid, isInvalid) switch
        {
            (true, false) when isModified => "fa-check-circle has-text-success",
            (false, true) when isModified => "fa-times-circle has-text-danger",

            _ => "fa-question-circle"
        };

    private static bool IsValid(string? css) => ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        IsContainingClass(css, "valid") && !IsInvalid(css);

    private static bool IsInvalid(string? css) => ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        IsContainingClass(css, "invalid");

    private static bool IsModified(string? css) => ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
        IsContainingClass(css, "modified");

    private static bool IsContainingClass(string? css, string name) =>
        css?.Contains(name, StringComparison.OrdinalIgnoreCase) ?? false;
}

1

Expression<Func<T>> 参数用于访问模型的属性。

2

Expression<Func<TOne>>Expression<Func<TTwo>> 参数用于访问模型的属性。

3

布尔参数用于确定要返回的 CSS 类。

4

IsValid 方法用于确定属性是否有效。

5

IsInvalid 方法用于确定属性是否无效。

6

IsModified 方法用于确定属性是否已修改。

EditContextExtensions类包含一些扩展方法,用于根据模型属性的状态确定要返回的 CSS 类。GetValidityCss方法及其重载用于根据模型属性的状态确定要返回的 CSS 类。使用框架提供的EditContextFieldClassExtensions.FieldCssClass扩展方法,我们可以根据相应表达式的状态评估当前的 CSS 类。Get​Vali⁠dityCss方法在整个标记中使用。

接下来,让我们看看C#文件Contact.razor.cs

namespace Learning.Blazor.Pages
{
    public sealed partial class Contact ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        private EditContext _editContext = null!;
        private ContactComponentModel _model = new();
        private InputText _emailInput = null!;
        private InputText _firstNameInput = null!;
        private VerificationModalComponent _modalComponent = null!;
        private bool _isEmailReadonly = false;
        private bool _isMessageReadonly = false;
 [Inject]
        public IHttpClientFactory HttpFactory { get; set; } = null!;

        protected override async Task OnInitializedAsync() ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        {
            // Initializes the "User" instance.
            await base.OnInitializedAsync();
            InitializeModelAndContext();
        }

        private void InitializeModelAndContext() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        {
            if (User is { Identity.IsAuthenticated: true })
            {
                _model = _model with
                {
                    EmailAddress = User.GetFirstEmailAddress()
                };
                _isEmailReadonly = _model.EmailAddress is not null
                    && RegexEmailAddressAttribute.EmailExpression.IsMatch(
                        _model.EmailAddress);
            }

            _editContext = new(_model);
        }

        protected override async Task OnAfterRenderAsync(bool firstRender) ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        {
            if (firstRender)
            {
                var input = _isEmailReadonly ? _firstNameInput : _emailInput;
                await (input?.Element?.FocusAsync(preventScroll: true)
                    ?? ValueTask.CompletedTask);
            }
        }

        private void OnRecognitionStarted() => _isMessageReadonly = true; ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)

        private void OnRecognitionStopped(
            SpeechRecognitionErrorEvent? error) =>
            _isMessageReadonly = false;

        private void OnSpeechRecognized(string transcript) ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
        {
            _model.Message = _model.Message switch
            {
                null => transcript,
                _ => $"{_model.Message.Trim()} {transcript}".Trim()
            };

            _editContext.NotifyFieldChanged(
                _editContext.Field(nameof(_model.Message)));
        }

        private Task OnValidSubmitAsync(EditContext context) => ![7](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/7.png)
            _modalComponent.PromptAsync(context);

        private async Task OnVerificationAttempted( ![8](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/8.png)
            (bool IsVerified, object? State) attempt)
        {
            if (attempt.IsVerified)
            {
                var client =
                    HttpFactory.CreateClient(HttpClientNames.ServerApi);

                using var response =
                    await client.PostAsJsonAsync<ContactRequest>(
                    "api/contact",
                    _model,
                    DefaultJsonSerialization.Options);

                if (response.IsSuccessStatusCode)
                {
                    AppState?.ContactPageSubmitted?.Invoke(_model);
                    _model = new();
                    InitializeModelAndContext();
                    await InvokeAsync(StateHasChanged);
                }
            }
        }
    }
}

1

EditContext实例包装ContactComponentModel

2

OnInitializedAsync方法调用base实现,初始化User实例并立即调用InitializeModelAndContext

3

InitializeModelAndContext方法从User实例初始化_model_edit​Con⁠text属性。

4

OnAfterRenderAsync方法确定页面渲染时应该具有焦点的输入元素。

5

OnRecognitionStarted方法将_isMessageReadonly属性设置为true

6

OnSpeechRecognized方法使用传递的文本更新_model.Message属性,并通知_editContext实例Message属性已更改。

7

当用户点击Send按钮时,将调用OnValidSubmitAsync方法。

8

OnVerificationAttempted方法在 Web.Api 项目的[HttpPost("api/contact")]端点抛出ContactRequest

当初始化Contact页面时,也会初始化base.User实例。如果存在已验证的用户,则将电子邮件地址设置为readonly,并使用用户的电子邮件。如果没有已验证的用户,则使用空的ContactComponentModel实例初始化_model实例。当首次渲染页面时,要么将焦点放在_emailInput元素上,要么放在_firstNameInput元素上。

有两个方法负责管理_messageInput元素是否为readonlyOnRecognitionStarted方法将_isMessageReadonly属性设置为trueOnRecognitionStopped方法将其设置为false。当识别到语音时,将使用传递的文本更新_model.Message属性,并通知_edit​Con⁠text实例Message属性已更改。

当用户提供了所有必需的输入时,表单被认为是“有效的”。此时,用户可以自由提交表单。提交表单时,将显示 _modalComponent 实例,提示用户回答一个问题。如果他们能够做到,表单信息将发送到 Web.Api 项目的 [HttpPost("api/contact")] 端点进行处理。

为了封装各种字段输入的一些常见代码,我编写了一个 Field​In⁠put 表单组件。这个组件在 Contact 页面中广泛使用。让我们来看一下 FieldInput.razor Razor 标记文件:

<div class="field is-horizontal">
    <div class="field-label @LabelSpecifierClass">
        <label class="label"> @FieldLabelContent ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        </label>
    </div>
    <div class="field-body">
        <div class="field @ControlSpecifierClass">
            <p class="control @ControlClasses.ToSpaceDelimitedString()"> @FieldControlContent ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            </p>
        </div>
    </div>
</div> @code { ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png) [Parameter]
    public string? LabelSpecifierClass { get; set; } = "is-normal";

    [Parameter]
    public string? ControlSpecifierClass { get; set; }

    [Parameter]
    public RenderFragment? FieldLabelContent { get; set; }

    [Parameter, EditorRequired]
    public RenderFragment? FieldControlContent { get; set; }

    [Parameter]
    public string[]? ControlClasses { get; set; } = new string[]
    {
        "is-expanded", "has-icons-left"
    };
}

1

FieldLabelContent 属性用于为字段渲染 label

2

FieldControlContent 属性用于为字段渲染 input

3

该组件接受多个可选和必需的参数。

由于 labelinput 元素被渲染为 RenderFragment,消费者可以自由渲染任何他们想要的内容。在 Contact 页面标记中,您可以看到使用以下组件的 FieldInput 组件的示例:

  • 单个由框架提供的 InputText 组件

  • 多个由框架提供的 InputText 组件

  • 自定义 AdditiveSpeechRecognitionComponent 组件

  • 单个由框架提供的 InputCheckbox 组件

让我们探索一些表单可以呈现的更多状态。

除了 label 外,图标也用于更清晰地传达验证错误。想象一下用户输入名字后忘记输入姓氏,然后提供一个主题。他们可以尝试点击 Send 按钮,但 _lastNameInput 元素将被红色边框标出,并且其有效性图标将变为红色叉号。 _subjectInput 元素的有效性图标将从问号变为绿色勾号,但 _messageInput 元素不会被突出显示,如 Figure 8-3 所示。

图 8-3. 一个无效 Contact 页面的示例放大渲染

用户可以为姓氏和消息提供值,从而清除验证错误,如 Figure 8-4 所示。

图 8-4. 一个有效 Contact 页面的示例放大渲染

在 Figure 8-3 和 Figure 8-4 中,您可能已经注意到了一个麦克风。消息输入元素的上右角边界框中呈现了一个按钮。用户单击按钮时,_messageInput 元素将暂时禁用。该元素接受语音识别作为输入形式。接下来的部分将向您展示如何将语音识别输入集成到您的表单中。

将语音识别作为用户输入实现

语音识别是现代应用程序中常用的输入机制,既用于可访问性,也方便整体使用。根据 “Can I Use Speech Recognition?” web page,超过 90% 的网络浏览器支持语音识别 API。语音识别 API 允许 web 开发人员从用户的语音输入中获取转录文本。该 API 受到所有现代浏览器的支持,包括 Chrome、Firefox、Safari 和 Edge。

要让用户可以使用语音识别在表单的消息字段中输入文本,您需要依赖于浏览器的原生语音识别 API。这需要 JavaScript 交互操作。要使用这个 API,您可以编写自己的实现来与原生 API 交互,或者使用一个包含这些逻辑的库。我维护了一个 Razor 类库,提供了一个发布在 NuGet 上的 ISpeech​Recog⁠nitionService 实现,称为 Blazor.Speech​Recog⁠nition.WebAssem⁠bly。这个库通过 DI 暴露了这种类型,允许消费者在 IServiceCollection 类型上调用 .AddSpeechRecognitionServices。一旦服务注册完毕,您就可以使用这个接口。这是对语音识别 API 的抽象,它使用了 Blazor 的 JavaScript 交互。这是一个如何创建可重用 Razor 类库的好例子。

Blazor 类库允许您编写组件,有效地共享通用的标记、逻辑,甚至静态资产。静态资产通常位于 ASP.NET Core 应用程序的 wwwroot 文件夹中。Blazor.SpeechRecognition.WebAssembly 库在 wwwroot 中定义了一些 JavaScript 代码。让我们来看看 blazorators.speech​Recog⁠nition.js JavaScript 文件,它暴露了 speechSynthesis 功能:

let _recognition = null; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

/**
 * Cancels any active speech recognition session,
 * considered best practice to properly clean up.
 * @param {boolean} isAborted
 */
export const cancelSpeechRecognition = (isAborted) => { ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    if (_recognition !== null) {
        if (isAborted) {
            _recognition.abort();
        } else {
            _recognition.stop();
        }
        _recognition = null;
    }
};

/**
 * Starts recognizing speech in the browser and registers
 * all the callbacks for the given dotnetObj in context.
 * @param {any} dotnetObj
 * @param {string} lang The BCP47 tag for the language.
 * @param {string} key Used for round-trip verification and callback-receipts.
 * @param {string} onResultMethodName Incremental recognition results callback.
 * @param {string | null} onErrorMethodName Recognition error callback.
 * @param {string | null} onStartMethodName Recognition started callback.
 * @param {string | null} onEndMethodName Recognition ended callback.
 */
export const recognizeSpeech = ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    (dotnetObj, lang, key, onResultMethodName,
        onErrorMethodName, onStartMethodName, onEndMethodName) => {
        if (!dotnetObj || !onResultMethodName) {
            return;
        }

        cancelSpeechRecognition(true);

        _recognition =
            new webkitSpeechRecognition() || new SpeechRecognition();
        _recognition.continuous = true;
        _recognition.interimResults = true;
        _recognition.lang = lang;

        if (onStartMethodName) {
            _recognition.onstart = () => { ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
                dotnetObj.invokeMethod(onStartMethodName, key);
            };
        }
        if (onEndMethodName) {
            _recognition.onend = () => {
                dotnetObj.invokeMethod(onEndMethodName, key);
            };
        }
        if (onErrorMethodName) {
            _recognition.onerror = (error) => {
                dotnetObj.invokeMethod(onErrorMethodName, key, error);
            };
        }
        _recognition.onresult = (result) => { ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
            let transcript = '';
            let isFinal = false;
            for (let i = result.resultIndex; i < result.results.length; ++i) {
                transcript += result.results[i][0].transcript;
                if (result.results[i].isFinal) {
                    isFinal = true;
                }
            }
            if (isFinal) {
                const punctuation = transcript.endsWith('.') ? '' : '.';
                const replaced =
                    transcript.replace(/\S/, str => str.toLocaleUpperCase());
                transcript =
                    `${replaced}${punctuation}`;
            }
            dotnetObj.invokeMethod(
                onResultMethodName, key, transcript, isFinal);
        };
        _recognition.start();
    };

window.addEventListener('beforeunload', _ => { ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
    cancelSpeechRecognition(true);
});

1

_recognition 变量用于全局存储当前的 SpeechRecognition 实例。

2

cancelSpeechRecognition 方法用于取消当前的语音识别会话。

3

recognizeSpeech 方法用于启动语音识别会话。

4

_recognition 实例有多个回调函数,每个都已注册。

5

_recognition.onresult 回调用于将结果发送回客户端。

6

window.addEventListener 方法中止任何活动的语音识别会话。

尽管我们在本书中已经使用 JavaScript 实现了其他功能,但这里的情况有所不同,因为这里定义的函数使用了export关键字。export JavaScript 关键字允许您将函数或变量作为可从另一个模块导入的代码进行导出。这是一个非常常见的 JavaScript 特性,用于使您的代码更可共享、可读和更易于维护。Blazor 可以通过 JavaScript 互操作调用来将这些函数导入到 .NET 中,使用import和指向 JavaScript 模块的路径。模块简单地将其所需的功能export,而其他模块则消费它。在 .NET 中,此模块表示为框架提供的IJSInProcessObjectReference类型。有关 JavaScript 隔离的更多信息,请参阅 Microsoft 的“在 ASP.NET Core Blazor 中从 .NET 方法调用 JavaScript 函数”文档

此 JavaScript 文件的两个函数是cancelSpeechRecognitionrecognizeSpeech。主要函数是recognizeSpeech,因为它在能够处理时条件注册所有提供的回调。它负责实例化SpeechRecognition实例,并将其分配给 JavaScript 代码的全局_recognition变量。接下来,我们将看一下ISpeech​Rec⁠ogni⁠tionService接口。它在C#文件ISpeechRecognitionService.cs中定义:

namespace Microsoft.JSInterop; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

public interface ISpeechRecognitionService : IAsyncDisposable ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
{
    Task InitializeModuleAsync(); ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)

    void CancelSpeechRecognition(bool isAborted); ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)

    IDisposable RecognizeSpeech( ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        string language,
        Action<string> onRecognized,
        Action<SpeechRecognitionErrorEvent>? onError = null,
        Action? onStarted = null,
        Action? onEnded = null);
}

1

接口声明自身位于Microsoft.JSInterop命名空间,以方便使用。

2

ISpeechRecognitionService接口用于定义公共Speech​Re⁠cognition API。

3

InitializeModuleAsync方法用于初始化语音识别模块。

4

CancelSpeechRecognition方法用于取消语音识别会话。

5

RecognizeSpeech方法用于启动语音识别会话。

警告

在他人命名空间(如Microsoft.JSInterop)中声明类型不应过度使用。通常不公开推荐这种做法,但在这里使用它是为了使库对开发者更加友好。这样一来,开发者选择使用此 NuGet 包时,如果他们的应用程序已经使用了Microsoft.JSInterop,他们也可以使用SpeechRecognition API。

该接口继承了 IAsyncDisposable,其 DisposeAsync 调用将执行必要的捕获模块引用的清理工作。ISpeechRecognitionService 接口很小,因此非常适合简单的单元测试,这在第九章中有所讨论。这使得可以轻松对围绕语音识别模块的逻辑执行单元测试。接下来,我们将看看 DefaultSpeech​Re⁠cognitionService 类。它在 DefaultSpeechRecognitionService.cs C# 文件中定义:

namespace Microsoft.JSInterop;

internal sealed class DefaultSpeechRecognitionService ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    : ISpeechRecognitionService
{
    const string ContentFolder = "_content"; ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    const string Package = "Blazor.SpeechRecognition.WebAssembly";
    const string Module = "blazorators.speechRecognition.js";

    readonly IJSInProcessRuntime _javaScript;
    readonly SpeechRecognitionCallbackRegistry _callbackRegistry = new();

    IJSInProcessObjectReference? _speechRecognitionModule;
    SpeechRecognitionSubject? _speechRecognition;

    public DefaultSpeechRecognitionService(
        IJSInProcessRuntime javaScript) => _javaScript = javaScript;

    void InitializeSpeechRecognitionSubject()
    {
        if (_speechRecognition is not null) ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        {
            CancelSpeechRecognition(false);
            _speechRecognition.Dispose();
        }

        _speechRecognition = SpeechRecognitionSubject.Factory(
            _callbackRegistry.InvokeOnRecognized);
    }

    /// <inheritdoc />
    public async Task InitializeModuleAsync() => ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        _speechRecognitionModule =
            await _javaScript.InvokeAsync<IJSInProcessObjectReference>(
                "import",
                $"./{ContentFolder}/{Package}/{Module}");

    /// <inheritdoc />
    public void CancelSpeechRecognition( ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        bool isAborted) =>
        _speechRecognitionModule?.InvokeVoid(
            "cancelSpeechRecognition",
            isAborted);

    /// <inheritdoc />
    public IDisposable RecognizeSpeech( ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
        string language,
        Action<string> onRecognized,
        Action<SpeechRecognitionErrorEvent>? onError,
        Action? onStarted,
        Action? onEnded)
    {
        InitializeSpeechRecognitionSubject();

        var key = Guid.NewGuid();
        _callbackRegistry.RegisterOnRecognized(key, onRecognized);
        if (onError is not null)
            _callbackRegistry.RegisterOnError(key, onError);
        if (onStarted is not null)
            _callbackRegistry.RegisterOnStarted(key, onStarted);
        if (onEnded is not null)
            _callbackRegistry.RegisterOnEnded(key, onEnded);

        _speechRecognitionModule?.InvokeVoid(
            "recognizeSpeech",
            DotNetObjectReference.Create(this),
            language,
            key,
            nameof(OnSpeechRecognized),
            nameof(OnRecognitionError),
            nameof(OnStarted),
            nameof(OnEnded));

        return _speechRecognition!;
    }
 [JSInvokable]
    public void OnStarted(string key) => ![7](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/7.png)
        _callbackRegistry.InvokeOnStarted(key);
 [JSInvokable]
    public void OnEnded(string key) =>
        _callbackRegistry.InvokeOnEnded(key);
 [JSInvokable]
    public void OnRecognitionError(
        string key, SpeechRecognitionErrorEvent errorEvent) =>
        _callbackRegistry.InvokeOnError(key, errorEvent);
 [JSInvokable]
    public void OnSpeechRecognized( ![8](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/8.png)
        string key, string transcript, bool isFinal) =>
        _speechRecognition?.RecognitionReceived(
            new SpeechRecognitionResult(key, transcript, isFinal));

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        _speechRecognition?.Dispose();
        _speechRecognition = null;

        if (_speechRecognitionModule is not null)
        {
            await _speechRecognitionModule.DisposeAsync();
            _speechRecognitionModule = null;
        }
    }
}

1

DefaultSpeechRecognitionService 类既是 sealed 又是 internal

2

除了 const string 字段外,此实现还需要几个字段——两个由框架提供的类型(IJSInProcessRuntimeIJSInProcessObjectReference)以及两个自定义类型(SpeechRecognitionCallbackRegistrySpeechRecognitionSubject)。

3

InitializeSpeechRecognitionSubject 创建语音识别主题。如果它已经存在,则取消现有的语音识别会话。

4

InitializeModuleAsync 方法用于初始化语音识别模块。

5

CancelSpeechRecognition 方法用于取消语音识别会话。

6

RecognizeSpeech 方法用于启动语音识别会话。

7

OnStarted 方法用于调用 onStarted 回调。

8

OnSpeechRecognized 方法用于调用 onRecognized 回调。

在调用任何其他调用之前,需要调用 InitializeModuleAsync 方法。这确保了 _speechRecognitionModule 字段的初始化。Cancel​Spee⁠chRecognition 方法用于取消语音识别会话。RecognizeSpeech 方法用于启动语音识别会话。当启动语音识别会话时,将初始化 _speechRecognition 字段。创建调用键(Guid.NewGuid()),并将其从 .NET 传递到 JavaScript 交互调用中。调用 JavaScript 使用给定的 key 来调用其回调函数。然后使用此方法确保一旦调用,就从 _callbackRegistry 中删除回调。OnStartedOnEndedOnRecognitionError 方法用于调用相应的回调函数。OnSpeechRecognized 不同,它将给定的 transcriptisFinal 值推送到 SpeechRecognitionResult 对象中,并在 _speechRecognition 字段上调用 Recognition​Received 方法。

_speechRecognition 字段是 SpeechRecognitionSubject 类型。这种自定义类型包装了一些响应式代码,并提供了封装的观察者和可观察者对。在下一节中,我将解释如何使用响应式扩展(Reactive Extensions)来创建 SpeechRecognitionSubject 类型。

使用观察者模式的响应式编程

OnStartedOnEndedOnRecognitionError 事件不同,OnSpeech​Recog⁠nized 事件会触发多次。这是因为 JavaScript 语音识别代码在启动语音识别会话时将 continuous 标志设置为 true。JavaScript 代码将多次调用 onRecognized 回调函数,对于每次调用,isFinal 标志设置为 false。当中间识别结果可用时,最终识别结果仍然是间歇性的。当最终识别时,它是一个完整的想法或句子。语音识别服务将继续监听,直到发生错误或请求取消。我们将使用响应式编程,依赖于异步编程逻辑来处理对静态内容的实时更新。当语音识别服务触发时,我们的应用程序将观察每个事件的发生并采取适当的操作。

响应式扩展(或响应式扩展) 是一个用于异步编程的可观察流的 API。响应式扩展是 观察者模式 的一种实现。

.NET 实现的响应式扩展被称为 Rx.NET。在这个库中,Subject 类型表示一个既是可观察序列又是观察者的对象。在语音识别中,SpeechRecognition​Sub⁠ject 类型观察一个流的 SpeechRecognitionResult 对象。考虑 Speech​Recogni⁠tionSubject.cs C# 文件:

namespace Microsoft.JSInterop;

internal sealed class SpeechRecognitionSubject : IDisposable ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
{
    readonly Subject<SpeechRecognitionResult> _speechRecognitionSubject = new();
    readonly IObservable<(string, string)> _speechRecognitionObservable;
    readonly IDisposable _speechRecognitionSubscription;
    readonly Action<string, string> _observer;

    private SpeechRecognitionSubject( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        Action<string, string> observer)
    {
        _observer = observer;
        _speechRecognitionObservable =
            _speechRecognitionSubject.AsObservable()
                .Where(r => r.IsFinal)
                .Select(r => (r.Key, r.Transcript));

        _speechRecognitionSubscription =
            _speechRecognitionObservable.Subscribe(
                ((string Key, string SpeechRecognition) tuple) =>
                    _observer(tuple.Key, tuple.SpeechRecognition));
    }

    internal static SpeechRecognitionSubject Factory( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        Action<string, string> observer) => new(observer);

    internal void RecognitionReceived( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        SpeechRecognitionResult recognition) =>
        _speechRecognitionSubject.OnNext(recognition);

    public void Dispose() => _speechRecognitionSubscription.Dispose(); ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
}

1

类型SpeechRecognitionSubject依赖于主题、观察者、可观察对象和订阅。

2

字段_observer用于调用onRecognized回调,并且构造函数是private

3

方法Factory用于创建SpeechRecognitionSubject类型。

4

方法RecognitionReceived用于将给定的recognition值推送到字段_speechRecognitionSubject中。

5

方法Dispose用于释放字段_speechRecognition​Sub⁠scrip⁠tion

SpeechRecognitionSubject允许消费者将SpeechRecognitionResult实例推送到其底层的Subject中。消费者还提供了一个Action<string, string>观察者函数,该函数在可观察对象的订阅中使用。当Subject充当可观察对象时,意味着它的间歇性结果流可以被过滤并有条件地分派给消费者。当最终的transcript准备就绪时,会通知消费者并提供keytranscript值。

自定义主题包装器仅定义了一个private构造函数,这意味着除非使用静态工厂方法,否则无法实例化此对象。Factory功能接受用于实例化SpeechRecognitionSubjectobserver。订阅实例被存储为字段,以便在主题被处置时可以显式清除。

使用注册表管理回调

由于服务暴露了多个回调,它通过自定义注册表管理交互回调。SpeechRecognitionCallbackRegistry对象允许注册回调,并通过其键调用相应的回调。让我们看看C#文件 SpeechRecognitionCallbackRegistry.cs

namespace Microsoft.JSInterop;

internal sealed class SpeechRecognitionCallbackRegistry ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
{
    readonly ConcurrentDictionary<Guid, Action<string>>
        _onResultCallbackRegister = new();
    readonly ConcurrentDictionary<Guid, Action<SpeechRecognitionErrorEvent>>
        _onErrorCallbackRegister = new();
    readonly ConcurrentDictionary<Guid, Action>
        _onStartedCallbackRegister = new();
    readonly ConcurrentDictionary<Guid, Action>
        _onEndedCallbackRegister = new();

    internal void RegisterOnRecognized( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        Guid key, Action<string> callback) =>
        _onResultCallbackRegister[key] = callback;

    internal void RegisterOnError( ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        Guid key, Action<SpeechRecognitionErrorEvent> callback) =>
        _onErrorCallbackRegister[key] = callback;

    internal void RegisterOnStarted(
        Guid key, Action callback) =>
        _onStartedCallbackRegister[key] = callback;

    internal void RegisterOnEnded(
        Guid key, Action callback) =>
        _onEndedCallbackRegister[key] = callback;

    internal void InvokeOnRecognized( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        string key, string transcript) =>
        OnInvokeCallback(
            key, _onResultCallbackRegister,
            callback => callback?.Invoke(transcript));

    internal void InvokeOnError( ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        string key, SpeechRecognitionErrorEvent error) =>
        OnInvokeCallback(
            key, _onErrorCallbackRegister,
            callback => callback?.Invoke(error));

    internal void InvokeOnStarted(string key) =>
        OnInvokeCallback(
            key, _onStartedCallbackRegister,
            callback => callback?.Invoke());

    internal void InvokeOnEnded(string key) =>
        OnInvokeCallback(
            key, _onEndedCallbackRegister,
            callback => callback?.Invoke());

    static void OnInvokeCallback<T>( ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
        string key,
        ConcurrentDictionary<Guid, T> callbackRegister,
        Action<T?> handleCallback)
    {
        if (key is null or { Length: 0 } ||
            callbackRegister is null or { Count: 0 })
        {
            return;
        }

        if (Guid.TryParse(key, out var guid) &&
            callbackRegister.TryRemove(guid, out var callback))
        {
            handleCallback?.Invoke(callback);
        }
    }
}

1

字段_onResultCallbackRegister用于存储onRecognized回调的注册。

2

方法RegisterOnRecognized注册了onRecognized回调,并且字段_onResultCallbackRegister用于存储回调。

3

方法RegisterOnError注册了onError回调函数,并且字段_onErrorCallbackRegister用于存储回调。

4

方法InvokeOnRecognized调用onRecognized回调,并且方法OnInvokeCallback调用回调。

5

方法InvokeOnError调用onError回调,并且方法OnInvokeCall⁠back调用回调。

6

OnInvokeCallback 方法在回调被移除后调用该注册。

ConcurrentDictionary 表示一个线程安全的 KVP 集合,可以被多个线程并发访问。有许多管理回调的替代方法,但 SpeechRecognitionCallbackRegistry 对象是最简单和最高效的。它是线程安全的,并使用全局唯一标识符管理回调——确保单个注册与回调的单次调用相对应。在这种浏览器中使用 C# 的优势之一是我们可以使用 .NET 生态系统提供的原生类型。具有诸如 ConcurrentDictionaryGuid、强类型委托 (Action<T> 例如) 甚至 Rx.NET 这样的基本类型是一个巨大的优势。

将语音识别服务应用于组件

SpeechRecognitionSubjectSpeechRecognitionCallbackRegistry 应用于公开 ISpeechRecognitionService 接口,我们现在可以创建一个自定义组件,将其添加到 HTML 元素中,并提供语音识别功能。让我们看看 AdditiveSpeechRecognitionComponent.cs C# 文件:

using RecognitionError = Microsoft.JSInterop.SpeechRecognitionErrorEvent;

namespace Learning.Blazor.Components
{
    public sealed partial class AdditiveSpeechRecognitionComponent
        : IAsyncDisposable ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        IDisposable? _recognitionSubscription;
        SpeechRecognitionErrorEvent? _error = null;
        bool _isRecognizing = false;

        string _dynamicCSS => _isRecognizing ? "is-flashing" : "";
 [Inject] ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        private ISpeechRecognitionService SpeechRecognition
        {
            get;
            set;
        } = null!;
 [Parameter] ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        public EventCallback SpeechRecognitionStarted { get; set; }
 [Parameter] ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        public EventCallback<RecognitionError?> SpeechRecognitionStopped
        {
            get;
            set;
        }
 [Parameter, EditorRequired] ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        public EventCallback<string> SpeechRecognized { get; set; }

        protected override async Task OnAfterRenderAsync(bool firstRender)
        {
            if (firstRender)
            {
                await SpeechRecognition.InitializeModuleAsync(); ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
            }
        }

        void OnRecognizeButtonClick() ![7](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/7.png)
        {
            if (_isRecognizing)
            {
                SpeechRecognition.CancelSpeechRecognition(false);
            }
            else
            {
                var bcp47Tag = Culture.CurrentCulture.Name;
                _recognitionSubscription?.Dispose();
                _recognitionSubscription = SpeechRecognition.RecognizeSpeech(
                    bcp47Tag,
                    OnRecognized,
                    OnError,
                    OnStarted,
                    OnEnded);
            }
        }

        void OnRecognized(string transcript) => ![8](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/8.png)
            _ = SpeechRecognized.TryInvokeAsync(transcript, this);

        void OnError(SpeechRecognitionErrorEvent recognitionError)
        {
            (_isRecognizing, _error) = (false, recognitionError);
            _ = SpeechRecognitionStopped.TryInvokeAsync(_error, this);
        }

        void OnStarted()
        {
            _isRecognizing = true;
            _ = SpeechRecognitionStarted.TryInvokeAsync(this);
        }

        public void OnEnded()
        {
            _isRecognizing = false;
            _ = SpeechRecognitionStopped.TryInvokeAsync(_error, this);
        }

        ValueTask IAsyncDisposable.DisposeAsync()
        {
            _recognitionSubscription?.Dispose();
            return SpeechRecognition.DisposeAsync();
        }
    }
}

1

AdditiveSpeechRecognitionComponent 实现了 IAsyncDisposable 接口,允许我们在组件从 DOM 中移除时处置语音识别模块。

2

SpeechRecognition 属性用于访问语音识别服务。

3

SpeechRecognitionStarted 属性是可选的,并用于通知父组件语音识别已启动。

4

SpeechRecognitionStopped 属性也是可选的,在语音识别停止时发信号。

5

SpeechRecognized 属性是一个 EditorRequired 参数,在典型会话中被多次调用。

6

OnAfterRenderAsync 方法用于初始化语音识别模块。

7

OnRecognizeButtonClick 方法用于启动或停止语音识别。

8

OnRecognized 方法用于通知父组件语音识别已完成。

当用户点击麦克风按钮时,将调用OnRecognizeButtonClick方法。消费者Contact页面将把相应的input元素标记为readonly。这有助于确保用户无法编辑输入字段中的文本,因为它是通过语音识别自动更新的。因此,你不能边说话边打字。EventCallback实例会向消费者发出任何更改的信号。TryInvokeAsync是一个扩展方法,条件性地调用EventCallback实例上的InvokeAsync方法,如果其HasDelegate值为true

表单提交验证和确认

将所有这些内容结合起来,我们构建了一个自定义的Contact页面,展示了一个漂亮样式的表单,支持一键语音识别功能。在用户提交表单之前,所有字段都必须通过验证。由于表单的主要功能是接受用户输入并将其传递给接收者,验证输入以确保信息正确传达是非常重要的。

表单模型绑定到各种表单字段,并在提交时进行验证。每个表单字段由使用 Blazor 组件的 HTML 元素表示。表单字段组件负责验证用户的输入。当框架提供的EditForm组件得到一个无效的 C# 模型时,它将使用适当的错误消息渲染表单。只有在表单提交有效时,EditForm组件才会提交表单。这意味着所有模型上的数据注解都得到验证,包括必填字段、自定义正则表达式模式和自定义验证方法。

一旦Contact表单被视为有效并提交,用户将收到一个模态框提示,充当基本的垃圾邮件阻止器。我们在第 4 章的图 4-3 中设置了这个VerificationModalComponent。该模态框提示用户回答随机的数学问题,并要求正确答案才能继续提交。

图 8-5 显示了这个模态框提示的一个示例。

图 8-5. VerificationModalComponent的示例渲染,放大后

如果答案不正确,模态框将不允许将用户的表单数据发送到 Web.Api 项目的端点进行处理。错误答案在图 8-6 中显示。

图 8-6. VerificationModalComponent的示例渲染,放大后,答案错误

一旦问题被正确回答,模态框将被关闭,联系表单将被处理。触发通知,说明联系尝试成功,如图 8-7 所示。

图 8-7. 确认通知的示例渲染

因为表单的主要功能是接收用户输入并传递给接收者,因此验证输入确保信息正确传递非常重要。模型绑定到各种表单字段,并在提交时进行验证。每个表单字段由使用 Blazor 组件的 HTML 元素表示。表单字段组件负责验证用户的输入。当框架提供的EditForm组件给定一个无效的 C# 模型时,它将渲染带有适当错误消息的表单。只有在表单提交有效时,EditForm 组件才会提交表单,这意味着模型上的所有数据注解都经过验证,包括必填字段、自定义正则表达式模式和自定义验证方法。

总结

在本章中,我向您展示了如何实现一个带有验证输入的表单。在此过程中,您学习了表单提交的基础知识,包括如何将自定义的 C# 模型绑定到EditForm,如何使用数据注解来装饰模型属性以及如何渲染带有验证错误的表单。我还向您介绍了一个语音识别库,该库可以接受用户口述的文字输入,并将其绑定到文本输入。

在下一章中,我将向您展示如何正确测试您的 Blazor 应用程序。从使用 xUnit 进行单元测试到使用 bUnit 进行组件测试,您将学习如何编写可靠的测试,用于验证应用程序的功能。

¹ “ASP.NET Core Blazor Forms and Input Components,” Microsoft .NET Documentation, August 16, 2022, https://oreil.ly/3qzqQ.

第九章:测试所有的东西

在本章中,我们将探讨作为 Blazor 开发人员可用的各种测试选项。了解您可以测试什么以及如何测试非常重要。我们将从适用于所有.NET 和 JavaScript 开发人员的最基本的测试用例开始。我将介绍测试并向您展示如何使用 xUnit、bUnit 和 Playwright 测试框架。然后我们将转向更高级的测试场景。最后,我们将以代码示例结束,展示如何使用 GitHub Action 工作流自动化测试,以及如何编写单元、组件和端到端测试。

为什么测试?

也许你会问,“如果你的代码本来就能工作,测试的意义何在?”这是个公平的问题。多年来,我也持相同观点——我不喜欢测试,因为它看起来没有必要。然而,多年来编写代码后,我改变了看法。测试是确保您的代码按预期工作并能根据需要重构的好方法。测试还有助于在核心业务规则更改时使事情正常运行。就像我曾说过良好的代码是给下一个开发者的情书一样,测试也是一种表达情感的方式。让我们从最小的测试类型——单元测试开始吧。

单元测试

单元测试是最基本的测试策略之一,用于测试小型、隔离的代码片段或单元。单元测试应接受已知输入并返回预期输出——最好避免在测试中使用随机化。通过自动化单元测试并避免人为错误,您更有可能在未来的重构中捕捉潜在问题。

注意

所有这里的单元测试都是用 C#编写的,但这并不意味着你不能为我们模型应用程序中使用的 JavaScript 代码编写单元测试。我选择不这样做是因为 Learning Blazor 应用程序几乎没有 JavaScript 代码,主要是包装现有的 API,因此非常可靠。换句话说,我对维护仅验证框架代码的测试不感兴趣。

单元测试是确保代码功能的最佳方式之一,但它不能替代手动功能测试,因为它侧重于单个单元。您可以使用测试框架,如 xUnit、MSTest 和 NUnit,为您的 Blazor 应用程序编写单元测试。所有这些框架都得到了很好的维护、文档化、支持和功能丰富。再加上 GitHub 仓库,您就可以运用如虎添翼。有了 GitHub 工作流文件,您可以调用dotnet test CLI 命令来运行单元测试。

提示

一个相当普遍采用的单元测试策略是在编写要测试的代码实现之前开发单元测试。这被称为测试驱动开发(TDD)。TDD 的好处在于在编写代码之前你被迫先考虑如何实现 API。这是确保你测试正确事物的好方法。

定义可单元测试代码

单元测试的一个好方法是使用扩展方法。我非常喜欢扩展方法。它们非常有用,以至于它们已经成为 C#开发的习惯用语。扩展方法是向现有类添加功能的好方法。长期以来有一个误解,即扩展方法很难进行单元测试。这是不正确的。这种观点来自于一个担忧,即无法对扩展方法进行模拟(无法控制或自定义其实现以进行单元测试),因此依赖扩展功能的其他逻辑无法进行控制。据信这使得测试变得困难。然而,实际上,你仍然可以测试扩展方法和消费功能。你不需要模拟所有东西来编写单元测试。再次强调,单元测试只关心一部分工作,给定已知输入并期望特定输出。

在本节中,我们将通过 Web.Extensions.Tests 项目来实现模型应用程序,该项目使用常见的安排-执行-断言测试模式。在这个模式中,我们将安排我们的输入,在被测试的系统上执行操作,并断言预期的输出是准确的。关于这种模式的更多信息,请参阅 Microsoft 的“.NET Core 和.NET Standard 单元测试最佳实践”文档。Web.Extensions.Tests 是一个 xUnit 测试项目,依赖于Microsoft.NET.Sdk,像这样的测试项目可以使用.NET CLI 创建:dotnet new xunit命令。xunit模板已经指定了所有依赖项,并准备好运行测试。有关更多信息,请参阅xUnit 网站

在本书的开发和模型应用程序的讨论中,你可以在整个系统中看到User属性。这个属性是一个ClaimsPrincipal实例,它展示了如何对扩展方法进行单元测试的良好示例。你可能还记得在第八章中从联系页面调用了User.GetFirstEmailAddress()方法。这个方法是一个扩展方法,用于从用户的“emails”声明中返回第一个电子邮件地址。首先让我们看一下扩展方法的功能,以了解它应该如何运作,并考虑 Web.Extensions 类库项目中的ClaimsPrincipalExtensions.cs文件:

namespace Learning.Blazor.Extensions;

public static class ClaimsPrincipalExtensions
{
    /// <summary>
    /// Gets the first email address (if available) from the "emails" claim.
    /// </summary>
    public static string? GetFirstEmailAddress(this ClaimsPrincipal? user) => ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
        user?.GetEmailAddresses()?.FirstOrDefault();

    /// <summary>
    /// Gets the email addresses (if available) from the "emails" claim.
    /// </summary>
    public static string[]? GetEmailAddresses(this ClaimsPrincipal? user) ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        if (user is null) return null;

        var emails = user.FindFirst("emails");
        if (emails is { ValueType: ClaimValueTypes.String }
            and { Value.Length: > 0 })
        {
            return emails.Value.StartsWith("[")
                ? emails.Value.FromJson<string[]>()
                : new[] { emails.Value };
        }

        return null;
    }
}

1

GetFirstEmailAddress方法从调用GetEmailAddresses获取第一个电子邮件地址。

2

GetEmailAddresses方法从给定用户的“emails”声明中获取所有电子邮件地址。

ClaimsPrincipalExtensions类可以从一些单元测试中受益,因为其功能具有几个不同的逻辑分支。当没有“emails”声明值时,逻辑是返回null。当有“emails”声明值时,我们希望从GetEmailAddresses返回一个电子邮件地址数组。此方法规范化声明值,有效地解析string值是否作为数组开始,如果是,则将其反序列化为string[]。否则,它被视为一个只有一个元素的单长度数组,其中包含唯一的电子邮件地址。换句话说,如果只有一个电子邮件地址,我们希望返回一个包含一个元素的数组。当有多个电子邮件地址时,我们只关心第一个。

编写扩展方法单元测试

要对ClaimsPrincipal扩展方法进行单元测试,我们需要能够创建具有已知声明的实例。考虑一个内部辅助类,用于构建自定义ClaimsPrincipal实例,就像C#文件 ClaimsPrincipalExtensionsTests​.Inter⁠nal.cs中的示例一样:

namespace Learning.Blazor.Extensions.Tests;

public sealed partial class ClaimsPrincipalExtensionsTests
{
    class ClaimsPrincipalBuilder ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        readonly Dictionary<string, string> _claims =
            new(StringComparer.OrdinalIgnoreCase);

        internal ClaimsPrincipalBuilder WithClaim( ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
            string claimType, string claimValue)
        {
            _claims[claimType] = claimValue ?? "";
            return this;
        }

        internal ClaimsPrincipal Build() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        {
            var claims = _claims.Select(
                kvp => new Claim(kvp.Key, kvp.Value));
            var identity = new ClaimsIdentity(claims, "TestIdentity");

            return new ClaimsPrincipal(identity);
        }
    }
}

1

ClaimsPrincipalBuilderClaimsPrincipal​Exten⁠sionsTests中的一个辅助类。

2

WithClaim方法向建造者实例添加声明类型和值。

3

Build方法返回一个ClaimsPrincipal实例,创建一个带有建造者中声明的身份。

建造者模式(如描述在“建造者模式”)对于这个辅助工具非常有用。因为我们正在创建特定于测试的ClaimsPrincipal类型,所以框架不会提供User实例。相反,我们将使用建造者的WithClaim方法添加声明,然后使用Build方法创建一个Claim⁠s​Principal实例。每个测试可以创建自己的实例(带有已知的输入)。我们可以通过查看来自 Web.Extensions.Tests 项目的ClaimsPrincipal​Exten⁠sionsTests.cs文件来看这个辅助工具/建造者的作用:

namespace Learning.Blazor.Extensions.Tests;

public sealed partial class ClaimsPrincipalExtensionsTests
{
 [Fact]
    public void GetFirstEmailAddressNull() ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    {
        var sut = new ClaimsPrincipalBuilder()
            .WithClaim(
               claimType: "emails",
               claimValue: null!)
            .Build();

        var actual = sut.GetFirstEmailAddress();
        Assert.Null(actual);
    }
 [Fact]
    public void GetFirstEmailAddressKeyMismatch() ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        var sut = new ClaimsPrincipalBuilder()
            .WithClaim(
               claimType: "email",
               claimValue: @"[""admin@email.org"",""test@email.org""]")
            .Build();

        var actual = sut.GetFirstEmailAddress();
        Assert.Null(actual);
    }
 [Fact]
    public void GetFirstEmailAddressArrayString() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    {
        var sut = new ClaimsPrincipalBuilder()
            .WithClaim(
               claimType: "emails",
               claimValue: @"[""admin@email.org"",""test@email.org""]")
            .Build();

        var expected = "admin@email.org";
        var actual = sut.GetFirstEmailAddress();
        Assert.Equal(expected, actual);
    }
 [Fact]
    public void GetFirstEmailAddressGetSimpleString() ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
    {
        var sut = new ClaimsPrincipalBuilder()
            .WithClaim("emails", "test@email.org")
            .Build();

        var expected = "test@email.org";
        var actual = sut.GetFirstEmailAddress();
        Assert.Equal(expected, actual);
    }
 [
        Theory,
        InlineData(
            "emails",
            "test@email.org",
            new[] { "test@email.org" }),
        InlineData(
            "emails",
            @"[""admin@email.org"",""test@email.org""]",
            new[] { "admin@email.org", "test@email.org" }),
        InlineData(
            "email",
            @"[""admin@email.org"",""test@email.org""]",
            null),
        InlineData(
            "emails", null, null),
    ]
    public void GetEmailAddressesCorrectlyGetsEmails( ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        string claimType, string claimValue, string[]? expected)
    {
        var sut = new ClaimsPrincipalBuilder()
            .WithClaim(claimType, claimValue)
            .Build();

        var actual = sut.GetEmailAddresses();
        Assert.Equal(expected, actual);
    }
}

1

GetFirstEmailAddressNull验证了没有“emails”声明值时,方法返回null

2

GetFirstEmailAddressKeyMismatch验证了当声明类型不匹配时(没有“emails”声明,而是“email”),方法返回null

3

GetFirstEmailAddressArrayString验证了当声明值中有一个“emails”数组时,返回第一个电子邮件地址。

4

GetFirstEmailAddressGetSimpleString验证了只要有一个“email”,它就会返回。

5

GetEmailAddressesCorrectlyGetsEmails验证了当给定声明类型和值对时,返回预期的电子邮件地址。

前四个测试使用Fact属性进行装饰。这向 xUnit 的可发现性机制表明,这些方法代表了单个单元测试。同样,最后一个测试使用TheoryInlineData属性进行装饰。这向 xUnit 表明这是一个参数化测试。InlineData属性接受一个电子邮件地址的字符串数组和预期结果。使用Theory装饰的单元测试会多次运行,每次运行一次InlineData或通过其他属性对各种数据集进行测试。

提示

在编写Theory测试时,重要的是注意可以使用多种类型的数据集属性。您可以使用 xUnit 执行一些强大的操作。我更喜欢它而不是其他选项,因为它带有分析器,帮助确保您的测试编写正确。有关 xUnit 分析器的更多信息,请参阅我的文章“xUnit Roslyn 分析器”

ClaimsPrincipalExtensionsTests 测试类是一组八个单元测试。单元测试的一些优点是测试通常运行速度快且具有良好的可读性。在撰写本文时,Web.Extensions.Tests 项目共有 31 个测试,所有测试运行时间为 30 毫秒。

组件测试

组件测试专注于功能的单个组件。与单元测试相比,组件测试需要处理更多的开销。这是因为组件通常引用多个其他组件,承担外部依赖项,并管理组件的状态,等等。随着这种增加的复杂性,需要一个可以帮助您测试组件的测试框架。

Blazor 组件无法自行呈现。这就是 bUnit 这个用于 Blazor 组件的测试库的用武之地。通过 bUnit,您可以执行以下操作:

  • 使用 C# 或 Razor 语法设置和定义正在测试的组件

  • 使用语义 HTML 比较器验证结果

  • 与组件进行交互并检查组件,以及触发事件处理程序

  • 传递参数、级联值和将服务注入到正在测试的组件中

  • 模拟IJSRuntime、Blazor 认证和授权等

为了演示组件测试,我们将查看模型应用程序中的 Web.Client.Tests 项目。Web.Client.Tests 项目是使用与我们在上一节中进行的 xUnit 测试项目相同的模板创建的。为了简化向组件传递参数和验证标记,bUnit 允许测试项目针对Microsoft.NET.Sdk.Razor SDK。这使其成为一个 Razor 项目,因此它可以呈现 Razor 标记。该项目还定义了一个 <Package​Refer⁠ence Include="bunit" Version="1.6.4" /> 元素,告诉项目使用 bUnit 包。与其他测试项目一样,我们向项目添加了一个 <Project​Reference>,指向我们将要编写测试的项目。Web.Client.Tests 项目引用了 Web.Client 项目。

在这个测试中,我们将定义一些输入,并看看如何编写一个测试,安排一个组件进行测试,对其进行操作,然后断言其正确渲染。 让我们直接进入组件测试。 考虑 ChatMessageComponentTests.razor Razor 测试文件:

@using Learning.Blazor.Components
@inherits TestContext ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
@code {
    public static IEnumerable<object[]> ChatMessageInput ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
    {
        get
        {
            yield return new object[]
            {
                Guid.Parse("f08b0096-5301-4f4d-8e19-6cb1514991ea"),
                "Test message... does this work?",
                "David Pine"
            };
            yield return new object[]
            {
                Guid.Parse("379b3861-0c04-49e9-8287-e5de3a40dcb3"),
                "...",
                "Fake"
            };
            yield return new object[]
            {
                Guid.Parse("f68386bb-e4d9-4fed-86b3-0fe539640b60"),
                "If a tree falls in the forest, does it make a sound?",
                null!
            };
            yield return new object[]
            {
                Guid.Parse("b19ab8b4-7819-438e-a281-56246cd3cda7"),
                null!,
                "User"
            };
            yield return new object[]
            {
                Guid.Parse("26ae3eae-b763-4ff1-8160-11aaad0cf078"),
                null!,
                null!
            };
        }
    }
 [Theory, MemberData(nameof(ChatMessageInput))] ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    public void ChatMessageComponentRendersUserAndText(
        Guid guid, string text, string user)
    {
        var message = new ActorMessage( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            Id: guid,
            Text: text,
            UserName: user);

        var cut = Render( ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
            @<ChatMessageComponent Message="message"
                IsEditable="true"
                EditMessage="() => {}" />);

        cut.MarkupMatches( ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
            @<a id=@guid class="panel-block is-size-5">
                <span>@user</span>
                <span class="panel-icon px-4">
                    <i class="fas fa-chevron-right" aria-hidden="true"></i>
                </span>
                <span class="pl-2">
                    <span>@text</span>
                </span>
            </a>);
    }
}

1

该类继承自 bUnitTestContext 类。

2

ChatMessageInput 属性中定义了多个测试输入。

3

测试方法是一个理论,这意味着它将多次运行,每次运行时都会为 ChatMessageInput 属性中的每个元素运行一次。

4

ActorMessage 是根据测试方法参数进行安排的。

5

ChatMessageComponent 根据其必需的参数进行渲染。

6

测试断言标记与预期标记匹配。

ActorMessage 类型是模型应用的 Web.Models 项目中的 record。 测试框架提供了 TestContext,用于渲染待测试的组件(或 cut)。 Render 方法返回 IRenderedFragmentMarkupMatches 方法是来自 bUnit 的许多扩展方法之一,用于验证来自标记片段的渲染标记与预期标记的匹配。

要运行这些测试,您可以使用 dotnet test 命令或您喜欢的 .NET IDE。 在 Visual Studio 中运行这些测试时,您可以在测试摘要详细信息中看到每个测试的唯一参数,如 图 9-1 所示。

图 9-1. Visual Studio:测试资源管理器 —— ChatMessageComponentTests 的测试详细信息摘要

现在您已经看到了单元测试和组件测试,我将展示如何实现端到端测试。 在下一节中,我将介绍 Microsoft 的 Playwright 如何进行端到端测试。

使用 Playwright 进行端到端测试

端到端测试是测试整个场景的一种方式。 它不仅测试应用程序几个部分的集成,而是从头到尾执行整个应用程序场景。 Playwright 是一个浏览器自动化库,可为现代 Web 应用程序提供可靠的端到端测试。 它类似于 Selenium,但从我的专业经验来看,它更加可靠,并且在易用性方面具有更简单的 API。 我们可以使用 Playwright 在多个浏览器(如 Chrome 和 Firefox)中测试我们的模型应用。

为了演示使用 Playwright 进行端到端测试,让我们看看模型应用的 Web.Client 项目中的登录测试。正如您可能已经意识到的那样,我喜欢编写 partial 类,并将每个 partial 分离到具有共享通用概念的单独文件中。在 Web.Client.EndToEndTests 项目的 LoginTests.Utilities.cs C# 文件中有一些实用代码:

namespace Web.Client.EndToEndTests;

public sealed partial class LoginTests ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
{
    const string LearningBlazorSite = "https://webassemblyof.net";
    const string LearningBlazorB2CSite = "https://learningblazor.b2clogin.com";

    static IBrowserType ToBrowser(BrowserType browser, IPlaywright pw) => ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        browser switch
        {
            BrowserType.Chromium => pw.Chromium,
            BrowserType.Firefox => pw.Firefox,
            _ => throw new ArgumentException($"Unknown browser: {browser}")
        };

    static Credentials GetTestCredentials() ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
    {
        var credentials = new Credentials(
            Username: GetEnvironmentVariable("TEST_USERNAME"),
            Password: GetEnvironmentVariable("TEST_PASSWORD"));

        Assert.NotNull(credentials.Username);
        Assert.NotNull(credentials.Password);

        return credentials;
    }

    readonly record struct Credentials( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
        string? Username,
        string? Password);

    public enum BrowserType ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
    {
        Unknown,
        Chromium,
        Firefox,
        WebKit
    }
}

1

该类声明了两个常量 string 值,分别是 Learning Blazor 站点的实时应用程序 URL 和认证 B2C 站点。

2

ToBrowser 方法返回一个 IBrowserType 实例,该实例是 Playwright 浏览器类型的包装器。

3

GetTestCredentials 方法返回一个 Credentials 对象,它是一个 readonly record struct 类型,包含用于测试的用户名和密码。

4

Credentials 是一个不可变对象,具有两个 readonly string? 值,表示用户名和密码对。

5

BrowserType 是支持的浏览器的枚举。

这些实用工具将在 Playwright 测试中使用。

警告

Credentials 类型使用环境变量进行填充。这是用于测试的安全替代方案,而不是在测试中硬编码这些值。环境变量用于测试。TEST_USERNAMETEST_PASSWORD 环境变量还需要存在于持续交付流水线中。幸运的是,如果您正在使用 GitHub 存储库,它将使用 GitHub Action 工作流来消耗加密的密钥并运行所有测试。这很好,因为这是一个安全的替代方案,用于测试中的硬编码值,并且测试会自动在 CI/CD 流水线中运行。

端到端测试在基于 Chromium 的浏览器(Chrome 和 Edge)和 Firefox 中运行。因为这些测试在多个浏览器中运行,您需要为每种浏览器类型指定输入。让我们首先看一下 Chromium 的测试输入,考虑以下 LoginTests.Chromium.cs 文件:

namespace Web.Client.EndToEndTests;

public sealed partial class LoginTests
{
    private static IEnumerable<object[]> ChromiumLoginInputs
    {
        get
        {
            yield return new object[]
            {
                BrowserType.Chromium, 43.04181f, -87.90684f,
                "Milwaukee, Wisconsin (US)"
            };
            yield return new object[]
            {
                BrowserType.Chromium, 48.864716f, 2.349014f,
                "Paris, Île-de-France (FR)", "fr-FR"
            };
            yield return new object[]
            {
                BrowserType.Chromium, 20.666222f, -103.35209f,
                "Guadalajara, Jalisco (MX)", "es-MX"
            };
        }
    }
}

xUnit 测试框架允许对测试输入进行参数化。ChromiumLoginInputs 属性是一个 object[] 对象的集合,每个对象包含浏览器类型、纬度、经度和计算出的位置。每个测试还有一个可选的 CultureInfo 参数。Firefox 的测试输入类似,但浏览器类型不同。考虑 LoginTests.Firefox.cs 文件:

namespace Web.Client.EndToEndTests;

public sealed partial class LoginTests
{
    private static IEnumerable<object[]> FirefoxLoginInputs
    {
        get
        {
            yield return new object[]
            {
                BrowserType.Firefox, 43.04181f, -87.90684f,
                "Milwaukee, Wisconsin (US)"
            };
            yield return new object[]
            {
                BrowserType.Firefox, 48.864716f, 2.349014f,
                "Paris, Île-de-France (FR)", "fr-FR"
            };
            yield return new object[]
            {
                BrowserType.Firefox, 20.666222f, -103.35209f,
                "Guadalajara, Jalisco (MX)", "es-MX"
            };
        }
    }
}

两者之间唯一的区别是浏览器类型。接下来,让我们考虑 LoginTests.cs 文件:

namespace Web.Client.EndToEndTests;

public sealed partial class LoginTests
{
    private static bool IsDebugging => Debugger.IsAttached; ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)
    private static bool IsHeadless => !IsDebugging;

    public static IEnumerable<object[]> AllLoginTestInput =>
        ChromiumLoginInputs.Concat(FirefoxLoginInputs);
 [
        Theory,
        MemberData(nameof(AllLoginTestInput))
    ]
    public async Task CanLoginWithVerifiedCredentials(
        BrowserType browserType,
        float lat,
        float lon,
        string? expectedText,
        string? locale = null) ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
        var (username, password) = GetTestCredentials();

        using var playwright = await Playwright.CreateAsync(); ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
        await using var browser = await ToBrowser(browserType, playwright)
            .LaunchAsync(new() { Headless = IsHeadless });

        await using var context = await browser.NewContextAsync( ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
            new BrowserTypeLaunchOptions()
            {
                Permissions = new[] { "geolocation" },
                Geolocation = new Geolocation() // Milwaukee, WI
                {
                    Latitude = lat,
                    Longitude = lon
                }
            });

        var loginPage = await context.NewPageAsync(); ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
        await loginPage.RunAndWaitForNavigationAsync(
            async () =>
            {
                await loginPage.GotoAsync(LearningBlazorSite);
                if (locale is not null)
                {
                    await loginPage.AddInitScriptAsync(@"(locale => {
    if (locale) {
        window.localStorage.setItem(
            'client-culture-preference', `""${locale}""`);
    }
})('" + locale + "')");
                }
            },
            new()
            {
                UrlString = $"{LearningBlazorB2CSite}/**",
                WaitUntil = WaitUntilState.NetworkIdle
            });

        // Enter the test credentials, and "sign in".
        await loginPage.FillAsync("#email", username ?? "fail"); ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
        await loginPage.FillAsync("#password", password ?? "?!?!");
        await loginPage.ClickAsync("#next" /* "Sign in" button */);

        // Ensure the real weather data loads.
        var actualText = await loginPage.Locator("#weather-city-state") ![7](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/7.png)
            .InnerTextAsync();

        Assert.Equal(expectedText, actualText);
    }
}

1

IsHeadless 属性在启动测试浏览器时使用,它确定浏览器是否以无头模式启动。

2

CanLoginWithVerifiedCredentials是一个在 Chromium 和 Firefox 浏览器上运行的Theory测试方法。

3

初始化了playwright对象并创建了浏览器实例。

4

浏览器配置了geolocation权限,并设置了latitudelongitude以测试数值。

5

从配置的browser中的context创建名为loginPage的新页面。

6

loginPage填写用户名和密码,然后点击“登录”按钮。

7

检索#weather-city-state元素的text内容。

CanLoginWithVerifiedCredentials测试是如何使用 Playwright 的良好示例。在这种情况下,测试被认为是一个Theory测试,并且一组参数作为测试输入集合的参数传递给测试。在使用Theory属性时,测试将针对测试输入集合中的每个出现运行,本例中为ChromiumFirefox浏览器。使用GetTestCredentials方法获取存储为环境变量的测试凭据。如果不存在,测试将失败。使用ToBrowser方法创建并启动测试浏览器实例。配置browser对象的geolocation权限,并设置latitudelongitude为测试值。创建context对象,并从context创建loginPage。这是等待调用NewPageAsync的结果。此方法在浏览器上下文中创建新页面。

我们验证已验证和注册的用户可以登录到 Learning Blazor 网站。我们指示context运行并等待loginPage导航到 Learning Blazor 网站。作为操作的一部分,我们有条件地添加一个初始化脚本,将根据给定的locale设置客户端文化。这非常强大,因为它允许测试翻译。它测试以下内容:

  • 用户可以使用正确的凭据登录。

  • 给定用户的 locale,天气数据以正确的语言显示。

使用loginPage等待浏览器的 URL 匹配登录站点的 URL。随着 URL 的更改,代码将等待页面呈现其 HTML,完全加载文档并处于空闲状态的网络。如果在可配置的时间内未能实现这一条件,则测试将失败。一旦满足此条件,我们填写用户名和密码,然后点击“登录”按钮。

如果我们无法与登录页面进行交互,或者找不到任何特定的元素或属性,测试将失败。测试提交登录测试凭据,并根据其 geolocation 权限,浏览器将能够确定当前位置。测试通过验证 #weather-city-state 元素是否包含正确的文本来结束。测试的 latitudelongitude 参数设置为测试的理论值。正确的字符串与已知的格式化城市、州和国家值进行匹配。

只有在已认证用户登录并知道其位置时,所有这些功能才可能。此端到端测试在两个浏览器上运行,并且每当您向应用的 GitHub 仓库的 main 分支 push 代码时触发。此自动化测试功能与模型应用中的其他测试完美配合!所有这些测试都以自动化方式运行,并且结果会自动发布到 CI 流水线。接下来我们来看看这一点。

自动化测试执行

有智慧的人曾告诉我,“把自己自动化掉”,¹ 我很高兴告诉你,这种理念会带来回报。作为开发者,你的目标是某种程度上的懒惰。每当你发现自己重复做同样的事情时,就是自动化的时候了。使用 GitHub Actions 是其中一种方法。我喜欢 GitHub Actions!它是一个强大且简单的工具,您可以使用它来自动化代码变更时的测试。我非常激动能够使用 GitHub Actions 来自动化我的代码测试。我相信,自动化部署我的代码是非常直接的。在我看来,GitHub 已经完美地掌握了自动化的艺术。只需几行代码,您就可以创建一个完全自动化的 CI/CD 流水线。

在本节中,我将向您展示如何使用 GitHub Action workflows 自动化测试,以 Learning Blazor 应用为例。首先,所有可识别的 GitHub Action workflow 文件都应位于项目的 GitHub 仓库的 .github/workflows 目录中。例如,在 Learning Blazor 仓库中,有一个 .github/workflows/build-validation.yml 文件用于构建和运行单元测试。如果任何测试失败,构建将失败。让我们看一下 build-validation.yml YAML 文件:

name: Build Validation ![1](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/1.png)

on: ![2](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/2.png)
  push:
    branches: [ main ]
    paths-ignore:
    - '**.md'
  pull_request:
    types: [opened, synchronize, reopened, closed]
    branches:
      - main  # only run on main branch

env: ![3](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/3.png)
  TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
  TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

jobs: ![4](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/4.png)
  build:
    name: build
    runs-on: ubuntu-latest

    - name: Setup .NET 6.0 ![5](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/5.png)
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 6.0.x

    - name: Build
      run: |
        dotnet build --configuration Release

    - uses: actions/setup-node@v1 ![6](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/6.png)
      name: 'Setup Node'
      with:
        node-version: 18
        cache: 'npm'
        cache-dependency-path: subdir/package-lock.json

    - name: 'Install Playwright browser dependencies'
      run: |
        npx playwright install-deps

    - name: Test ![7](https://gitee.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/lrn-blzr/img/7.png)
      run: |
        dotnet test --verbosity normal

1

name 就是显示在 GitHub README.md 文件状态徽章上的内容,以及最新运行的状态。

2

使用 on 属性,我们告诉 GitHub Action 在特定事件上运行。

3

env 属性用于设置环境变量。

4

每个 workflow 都有一个 jobs 属性,而一个 job 则包含多个 steps

5

在构建环境中准备 .NET CLI,或安装构建依赖项。

6

Playwright 需要 NodeJS 及其包管理器,即 Node Package Manager(NPM)。

7

最后,调用 dotnet test,运行所有三组测试。

在这种情况下,我们告诉工作流在 push 事件时运行,并且仅在 main 分支上运行。我们可以指定额外的逻辑以在 pull_request 事件上运行,或者甚至可以通过 GitHub UI 的 workflow_dispatch 事件手动运行它。通过这个自动化工作流,GitHub Actions 将根据您的代码更改自动运行测试,这样您就无需手动操作。

概要

在本章中,您了解到测试代码的重要性。您看到了三种不同的方法来测试您的 Blazor 应用程序。您可以使用单元测试来确保应用程序的最小组成部分是正确的,使用组件测试来确保一组组件正常工作,以及使用端到端测试来确保一切协同工作。您还了解了如何通过 GitHub Actions 自动化测试。

我们在整本书中覆盖了很多内容。我与您分享了一个包含超过 90,000 行代码的企业级应用程序的一些最重要的组成部分。

若要继续学习 Blazor,我鼓励您查看以下资源:

希望您喜欢这本书,并希望您作为开发人员继续学习和成长。总的来说,Blazor 是一个非常适合的 Web 应用程序框架。对我来说,.NET 在其他编程语言和平台上有很大优势,正如我在本书中展示的那样。希望我对这个技术栈的热爱能够通过。感谢您给我这个机会为您详细介绍 学习 Blazor

¹ Microsoft 的 Scott Hanselman。

附录。Learning Blazor 应用程序项目

在本书中,我们研究了 Learning Blazor,这是为本书创建的一个应用程序。该应用程序由几个项目组成,这些项目作为独立的功能部分。架构在“浏览“Learning Blazor”示例应用程序”中进行了讨论。源代码可以在GitHub上找到。

learning-blazor.sln解决方案文件包含了几个项目,这些项目共同构成了整个应用程序作为一个统一的单元。虽然解决方案中的每个项目负责其核心功能,但协调具有不同功能的项目以形成一个统一的整体是任何成功应用程序的要求。以下部分列出了解决方案中的主要项目,并提供了关于它们的主题详细信息。

Web 客户端

客户端应用程序,简称为 Web.Client,是一个 Blazor WebAssembly 项目,目标是使用Microsoft.NET.Sdk.BlazorWebAssembly软件开发工具包(SDK)。该 Web 项目负责所有用户交互和体验。通过页面、客户端路由、表单验证、模型绑定和基于组件的用户界面,Web.Client 项目展示了 Blazor 的大部分主要特性。该应用程序定义了一个Learning.Blazor命名空间。

Web API

如果没有数据,客户端应用程序会变得相当无聊。你可能会问,Web 应用程序如何获取数据?HTTP 是最常见的方法,但除此之外,我们的应用程序还将使用 ASP.NET Core SignalR 和 Web Sockets 来实现实时网络功能。

提示

ASP.NET Core SignalR 是一个开源库,简化了向应用程序添加实时网络功能的过程。它在示例源代码中用于展示实时功能。有关 SignalR 的概述,请参阅 Microsoft 的ASP.NET Core SignalR 概述

同样,示例应用程序使用 Blazor WebAssembly 托管模型,但展示实时网络功能仍然非常有价值。因此,ASP.NET Core SignalR 被使用,但使用方式与以前在 Blazor Server 托管模型中描述的方式不同。

有一个名为 Web.Api 的 ASP.NET Core Web API 项目,目标是使用Microsoft.NET.Sdk.Web。该项目将提供客户端应用程序依赖的各种端点。API 和 SignalR 端点将由 Azure Active Directory(Azure AD)的业务对消费者(B2C)认证保护。

Web API 项目使用内存缓存来确保响应迅速的体验。某些端点依赖于服务,这些服务会从缓存或原始的 HTTP 依赖端点中确定性地获取数据。

Pwned Web API

Pwned Web API 项目还依赖于Microsoft.NET.Sdk.Web SDK。该项目从 Troy Hunt 提供的“Have I Been Pwned”服务中公开功能。用户在同意允许应用程序使用其电子邮件地址后,将其发送到 Pwned 服务。API 提供的详细信息用于通知用户其电子邮件是否曾经参与数据泄露。

Web Abstractions

使用一个简单的 C#类库项目,目标为Microsoft.NET.Sdk,Web.Abstractions 项目定义了一些在客户端和服务器应用程序之间共享的抽象。这些契约将作为 SignalR 端点的粘合剂。从客户端的角度来看,这些抽象将提供一个可发现的 API 集合,客户端可以订阅事件和方法,并通过这些方法与服务器进行通信。从服务器的角度来看,这些抽象将巩固方法和事件名称,确保没有任何可能的不对齐。这非常重要,并且是所有基于 JavaScript 的单页面应用程序开发中的一个常见陷阱。

Web Extensions

在现代 C#应用程序开发中,将重复的子程序封装为扩展是很常见的。由于它们的重复性质,实用的扩展方法是共享类库风格项目的完美候选者。在我们的情况下,我们将使用目标为Microsoft.NET.Sdk的 Web.Extensions 项目。该项目提供的功能将在我们解决方案中的大多数其他项目中使用,特别是客户端和服务器应用程序场景。

Web HTTP Extensions

另一个扩展类库专注于定义HttpClient类型的默认值。有几个共享的类库,它们都在进行 HTTP 调用—​我希望所有失败的 HTTP 调用都有一个特定的重试策略来处理瞬态错误。这些策略在目标为Microsoft.NET.Sdk的 Web.Http.Extensions 项目中定义。

Web Functions

在过去的十年中,无服务器编程已经变得非常普遍。不可变基础设施、弹性和可扩展性始终是非常受欢迎的特性。Azure Functions 用于封装我的天气服务。我决定使用 Open Weather Map API,它是免费的,支持多种语言,并且相当准确。通过 Azure Function 应用程序,我可以封装我的配置,保护我的 API 密钥,使用依赖注入,并将调用委托给天气 API。该项目名为 Web.Functions,目标为Microsoft.NET.Sdk

Web Joke Services

生活太短暂,我们需要更多的笑声,微笑,不要那么严肃地对待自己。Web.JokeServices 库负责按照伪随机的时间表聚合笑话。在此项目中,有三个独立且免费的笑话 API 被聚合起来:

Web Models

Web.Models 项目是解决方案中许多其他项目共享的库。它包含用于表示各种领域实体的所有模型,例如服务和客户端共享的模型。任何与应用程序交互的内容都会指定一个形状,并具有帮助唯一标识自身的成员。这当然是面向对象编程的核心。

Web Twitter 组件

为了举例说明组件库的功能,我选择创建了一个名为 Web.TwitterComponents 的 Twitter 组件 Razor 库。这个项目依赖于Microsoft.NET.Sdk.Razor SDK。它提供了两个组件,一个代表一条推文,另一个代表推文集合。这个项目将演示组件如何被模板化;展示了父子层次关系。它展示了组件如何使用 JavaScript 互操作性并从异步事件中更新。

Web Twitter 服务

Web.Twit​terServices 项目被 Web.Api 项目所消费,而不是 Web.TwitterComponents 项目。Twitter 服务在后台服务的上下文中使用。后台服务提供了一种管理长时间运行操作的手段,其功能超出了请求和响应管道。就像推文流处理那样,当实时过滤的推文发生时,我们的服务将相应地传播它们。

posted @ 2024-06-18 17:53  绝不原创的飞龙  阅读(19)  评论(0编辑  收藏  举报