Angular-设计模式和最佳实践-全-
Angular 设计模式和最佳实践(全)
原文:
zh.annas-archive.org/md5/45ce755fc65c79b26ac858559b9855ab译者:飞龙
前言
自 2009 年以来,Angular 框架一直在帮助开发团队,它拥有强大的结构,几乎包含了构建 Web 应用程序所需的一切。Angular 以其“包含电池”的哲学,提供了状态管理、路由管理和依赖注入等机制,以及其他工具,以帮助您为用户提供最令人难以置信的体验。
本书旨在帮助您导航这个令人难以置信的功能列表,并学习如何为您和您的团队编排这些功能,以充分利用 Angular 及其整个生态系统。
我们将发现框架中存在哪些类型的模式,以及我们可以从这些模式中学到什么教训,并将其应用到我们的应用程序中。
我们还将根据其文档和 Angular 生态系统周围的社区建议,探索 Angular 开发和架构的最佳实践。
Angular 被不同规模和行业的公司广泛使用。赞助这个开源框架的公司,谷歌,拥有数千个使用 Angular 的内部应用程序,保证了极高的稳定性,这也是使用它的最大原因之一。
对于掌握 Angular 的开发人员和能够组织和充分利用 Angular 的架构师,需求量很大,目前框架处于最佳状态,社区称之为 Angular 文艺复兴。
本书面向的对象
本书面向有 Angular 或任何其他 Web 框架经验的开发人员和架构师,他们希望深入了解 Angular 能提供的最佳功能。
本书的主要目标受众是以下人群:
-
已经使用 Angular 的开发人员,希望提高他们在完成任务时的生产率
-
技术领导者,希望将最佳实践带到他们的团队中,以提高交付的质量和生产率
-
希望探索 Angular 为应用程序提供可能性的软件架构师,并因此设计出弹性和安全的系统
本书涵盖的内容
第一章,正确开始项目,强化了 Angular 的基础知识、其原则以及如何配置项目和开发环境以尽可能提高生产效率。
第二章,组织您的应用程序,探讨了组织 Angular 项目的最佳实践以及如何通过懒加载 Angular 模块来优化应用程序的性能。
第三章,Angular 的 TypeScript 模式,深入框架的基础语言 TypeScript,并帮助您理解为什么 Angular 团队选择了它,以及我们如何将其应用到我们的项目中。
第四章,组件和页面,与框架的基本元素组件一起工作,以及我们如何构建项目以创建简洁高效的应用程序。
第五章, Angular 服务和单例模式,分析了 Angular 服务,以将业务逻辑与表示逻辑分离,并讨论了与后端通信的最佳实践。
第六章, 处理用户输入:表单,我们将研究用户与我们的应用程序交互的主要方式,即通过表单,以及我们如何创建响应式且易于维护的表单。
第七章, 路由和路由器,我们将与 Angular 的路由机制一起工作,以及如何以安全和优化的方式管理我们应用程序的路由。
第八章, 改进后端集成:拦截器模式,我们将在这里应用拦截器设计模式,以处理与后端通信的常见任务,例如令牌管理和用户通知。
第九章, 探索 RxJS 的反应性,深入探讨了 RxJS 库,以及我们如何充分利用它来管理项目中的信息流和交互。
第十章, 设计用于测试:最佳实践,讨论了自动化测试以及如何为这个过程准备我们的项目,以及使用 Jasmine 和 Karma 库进行单元测试,以及使用开源工具 Cypress 进行端到端测试。
第十一章, 使用 Angular Elements 的微前端,探讨了微前端架构,并讨论了何时使用它以及如何在 Angular 中使用 Angular Elements 库来实现它。
第十二章, 打包一切:部署最佳实践,探讨了构建和部署我们的 Angular 应用程序到云环境中的最佳实践。我们将通过一个示例项目来探索微软 Azure 云。
第十三章, Angular 的复兴,探讨了如何跟上 Angular 的持续发展,并查看令人惊叹的功能,如 Angular Signals、独立组件和通过 defer 指令的懒加载组件。
为了充分利用本书
您需要具备基本的 HTML、CSS 和 JavaScript 知识以及如何工作一个 Web 应用程序。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Angular 16 和 17 | Windows、macOS 或 Linux |
| TypeScript 5.2 | |
| RxJS 7 | |
| Azure |
下载示例代码文件
您可以从 GitHub(.)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在这个测试用例中,我们不需要担心登录,因为beforeEach函数执行了这个功能,我们直接在表上工作。”
代码块设置如下:
describe('My First Test', () => {
it('Visits the initial project page', () => {
cy.visit('/')
cy.contains('app is running!')
})
})
当我们希望将您的注意力引到代码块中的特定部分时,相关的行或项目将以粗体显示:
<button
type="submit"
class="w-full rounded bg-blue-500 px-4 py-2 text-white"
[disabled]="loginForm.invalid"
[class.opacity-50]="loginForm.invalid"
data-cy="submit"
>
Login
</button>
任何命令行输入或输出都应如下编写:
ng test
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“选择所需的浏览器,然后点击开始 E2E 测试,我们将获得测试执行界面。”
小贴士或重要注意事项
看起来是这样的。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过客户关怀@packtpub.com给我们发邮件,并在邮件主题中提及书名。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问 www.packtpub.com/support/errata 并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com 与我们联系,并提供材料的链接。
如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com。
分享您的想法
一旦您阅读了《从 PHP 到 Ruby on Rails》,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
请放心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此停止,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限。
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781837631971
-
提交您的购买证明
-
就这些了!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱
第一部分:巩固基础
在本部分中,您将更深入地了解 Angular 框架的基本原理及其基本概念,例如为什么使用 Angular,如何组织项目并设置高效的开发环境。此外,您还将学习组件创建和与后端通信方面的最佳实践。
本部分包含以下章节:
-
第一章**,以严谨的方式启动项目
-
第二章**,组织您的应用程序
-
第三章**,Angular 的 TypeScript 模式
-
第四章**,组件和页面
-
第五章**,Angular 服务和单例模式
第一章:正确开始项目
Angular 是一个以“一应俱全”作为开发理念的框架。这意味着您需要的所有前端应用程序资源在创建新项目时就已经全部可用。
在本章中,您将了解为什么为您的 Web 应用程序选择 Angular,它的主要特性和设计是什么,以及为什么公司,尤其是最大的公司,选择 Angular 作为开发单页应用程序的主要框架。
您将探索构成框架的技术,并在需要特定情况下的可能替代方案时,充分利用这些技术。您还将使用最佳工具设置您的办公空间,以帮助您和您的团队提高生产力。
在本章中,我们将涵盖以下主题:
-
为什么选择 Angular?
-
生态系统中有哪些技术?
-
配置您的开发环境
-
开始一个 Angular 项目
-
使用 Angular 命令行界面 (CLI) 提高您的生产力
到本章结束时,您将为在项目中使用 Angular 提供论据,并在您的开发工作区中更加高效。
技术要求
要遵循本章中的说明,您需要以下内容:
-
Visual Studio Code (VS Code) (
code.visualstudio.com/Download) -
Node.js 18 或更高版本 (
nodejs.org/en/download/)
本章的代码文件可在 此处 获取。
为什么选择 Angular?
在特定项目中选择要使用的技术对其成功至关重要。作为项目开发者或架构师,您必须通过选择最适合这项工作的工具来帮助您的团队完成这项任务。
Angular 框架是构建单页应用程序最常用的工具之一,与 React 和 Vue 一起。在选择适合这项工作的正确工具时,您需要回答“为什么”。
以下是一些选择 Angular 的论据。
一应俱全
Angular 是一个有观点的框架,这意味着 Angular 开发团队已经为每个可能出现在 Web 应用程序中的挑战做出了几个工具和解决方案的选择。这样,您和您的团队就不必研究应该使用哪个路由引擎或状态管理库;所有这些都已经包含并为您项目配置好了。
此功能还简化了您团队中新开发者的入职流程。遵循文档中提出的指南和使用最佳实践,Angular 项目通常具有相同的结构和开发方法。了解 Angular 后,您可以快速定位到任何正在进行的项目。
Google 支持
Angular 是由谷歌的 Angular 团队创建和维护的。尽管像 Vue.js 和 Svelte 这样的优秀框架仅由其社区维护,但有一个大型科技公司支持框架,这为技术选择带来了安全性,尤其是对于大型公司来说。
此外,Angular 被用于超过 300 个内部应用程序和谷歌产品中,这意味着稳定性和质量,因为,在发布框架的新版本之前,它已经在所有这些应用程序中得到验证。
自从版本 13 以来,Angular 团队一直努力通过发布路线图(angular.io/guide/roadmap)来增加社区内的透明度,详细说明所有正在进行中的改进以及未来框架的预期,让您放心,它将在未来几年内得到支持。
社区
技术的生命力取决于支持它的社区,Angular 拥有一个庞大的社区。聚会、播客、活动、文章和视频——Angular 社区拥有许多资源来帮助开发者。
构成这个社区的人们还对 Angular 做出了重要的贡献,即提供反馈、创建和修复 Angular 的问题。由于它是一个开源项目,每个人都受邀评估和贡献代码。
Angular 团队还通过请求评论(RFCs)的方式向社区寻求帮助,以做出重大的框架决策。
此外,社区还创建了众多库,扩展了框架的可能性,例如 NgRx(ngrx.io/)用于高级状态管理和 Transloco(ngneat.github.io/transloco/)以支持国际化等。
工具
与其竞争对手相比,Angular 的一个不同之处在于从一开始就专注于工具和开发者体验。Angular CLI 工具是一个强大的生产力工具,我们将在本章中探讨它,它不仅用于项目的简单创建和设置。
从测试的角度来看,Angular 已经配备了 Karma 作为测试运行器和 Jasmine 作为配置工具。Angular 的工具已经使用 webpack 配置了项目构建,并且已经有一个开发服务器。
该工具也是可扩展的,允许社区为配置和更新他们的库创建常规。
基于这些论点,您将能够根据您的项目选择 Angular;现在让我们看看构成框架生态系统的技术有哪些。
生态系统中有哪些技术?
当 Angular 团队为不断增长的 Web 应用程序开发复杂性寻找解决方案时,决定将最佳的工具和库联合在一个有见地的包中,并尽可能多地使用默认配置。
然后,我们有以下库构成了 Angular 的核心。
TypeScript
TypeScript 是 JavaScript 语言的超集,它向语言添加了类型检查和其他功能,确保了更好的开发体验和安全性,适用于 Web 开发。
它自 Angular 的第一个版本以来就存在,是框架的基石,它使得依赖注入、类型化表单和 Angular 的工具等功能成为可能。
TypeScript 目前是 Node.js 后端开发的首选工具,并且受到 React 和 Vue.js 等其他框架社区的鼓励。
RXJS
RXJS 是一个在 JavaScript 语言中实现响应式范式的库 (www.reactivemanifesto.org/)。
自 Angular 的第一个版本以来,响应性一直是框架想要实现的核心主题,因此它使用 RXJS 库来帮助实现这一目标。
HTTP 请求、路由、表单以及其他 Angular 元素使用可观察对象及其操作符的概念,为 Angular 开发者提供工具,以创建更流畅和动态的应用程序,同时减少样板代码。
RXJS 还提供了在前端应用程序中进行状态管理的方法,无需使用更复杂的模式,如 Redux。
Karma 和 Jasmine
质量在任何应用程序中都应该是首要任务,这对于前端应用程序尤为重要,因为对于用户来说,它就是 那个 应用程序。
质量的一个证明方式是通过测试,考虑到这一点,Angular 默认已经包含了 Jasmine 和 Karma 工具组合。
Jasmine 是一个用于对 JavaScript 和 TypeScript 应用程序进行单元测试的框架,它提供了多个断言和测试组装功能。
Karma 是测试运行器,即执行单元测试设置的运行环境,它借助 Jasmine 来执行。这个环境在其配置文件中配置,在浏览器中运行,与客户的日常生活相比,使测试更加真实。
由于测试执行的性能,社区中许多人将这两个工具切换到 Jest 框架,这是完全可以接受的,甚至 Angular CLI 还提供了便利;然而,应该注意的是,这个工具不在浏览器中运行,这确实提高了测试执行的性能,但可能会隐藏一些只有通过在浏览器中进行测试才能提供的特定性。
Webpack
在应用程序开发完成后,需要创建捆绑包以发送到生产环境,Webpack 就是 Angular 团队选择的这个任务的工具。
Webpack 是一个非常强大且通用的打包器,正是由于它,框架能够实现一些有趣的优化,如摇树优化和捆绑的懒加载。
然而,Webpack 的配置复杂,考虑到这一点,Angular 团队已经设置并创建了一些抽象,以便对工具进行微调,例如 angular.json 文件。
我们理解框架的各个组成部分以及它们如何与提供丰富和流畅的用户界面相关联。现在,我们将设置我们的开发环境。
配置您的开发环境
拥有正确工具的井然有序的环境是通往卓越和高效的第一步;现在,让我们在您的开发空间中设置此环境。
按照技术要求部分中的说明安装 Node.js 后,以下工具及其插件将帮助您在您的开发流程中。
VS Code
VS Code (code.visualstudio.com/) 目前是大多数开发者的默认工具,尤其是对于前端项目。
还有其他非常好的工具,例如 WebStorm (www.jetbrains.com/webstorm),但 VS Code,特别是针对 Angular 项目的插件,可以极大地提高生产力和人体工程学。
要安装此处列出的插件,在代码编辑器中点击扩展或使用快捷键 Ctrl + Shift + X(Windows)或 Cmd + Shift + X(macOS)。
以下是为开发 Angular 应用程序推荐的 VS Code 插件。
Git 扩展包
Git 扩展包 (marketplace.visualstudio.com/items?itemName=donjayamanne.git-extension-pack) 并非专门用于开发 Angular 应用程序,但它对任何类型的工作都很有用。
Git 是版本控制的默认工具,VS Code 对其有原生支持。这一套插件进一步增强了这种支持,增加了在编辑器中读取先前提交中注释和更改的能力、支持多个项目,以及更好地查看您的存储库历史和日志。
Angular 语言服务
Angular 语言服务 (marketplace.visualstudio.com/items?itemName=Angular.ng-template) 扩展由 Angular 团队维护,并从代码编辑器开始添加了对框架大多数功能的支持。
通过将此扩展添加到您的编辑器中,它将具有以下功能:
-
在 HTML 模板文件中进行自动完成,让您无需查阅 TypeScript 文件即可使用组件方法
-
检查 HTML 模板文件和 TypeScript 文件中可能的编译错误
-
快速在 HTML 和 TypeScript 模板之间导航,让您能够查阅方法和对象的定义
此扩展也适用于其他 IDE,如 WebStorm 和 Eclipse。
Prettier
Prettier (marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) 是一个 JavaScript 工具,用于解决代码格式化问题。尽管可以进行一些自定义,但它对格式化设置有明确的观点。
除了 TypeScript,Prettier 还格式化 HTML、CSS、JSON 和 JavaScript 文件,这使得此扩展对于使用 Node.js 进行后端开发也非常有用。
为了在整个团队中标准化格式,您可以将 Prettier 作为项目包安装,并在项目的 CI/CD 轨道上运行它,我们将在 第十二章,打包一切 – 部署最佳实践 中看到。
ESLint
在创建应用程序时,强烈建议使用代码检查器以确保良好的语言实践并避免开发初期的错误。
在过去,用于检查 TypeScript 项目的默认工具是 TSLint,但该项目已被 ESLint (https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) 吸收,这使得您能够验证 JavaScript 和 TypeScript 项目。
使用此扩展,在您键入项目代码的同时,验证会迅速发生。ESLint 可以作为包安装到您的 Angular 项目中,从而在项目的 CI/CD 传输带上执行此验证,我们将在 第十二章,打包一切 – 部署最佳实践 中看到。
EditorConfig
EditorConfig (marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) 插件不仅为 VS Code,还为任何支持此格式的 IDE 提供创建默认配置文件的功能。
此插件对标准化您的项目和团队中的事物非常有用——例如,每个 Tab 键表示的空格数,或者您的项目是否将使用单引号或双引号来表示字符串。
要使用它,只需在项目的根目录下创建或拥有一个名为 .editorconfig 的文件,VS Code 将尊重文件中描述的设置。
VS Code 设置
VS Code 除了扩展之外,还有一些原生设置可以帮助您在日常工作中。通过访问 文件 菜单,我们可以激活自动保存标志,这样您就不必总是担心按 Ctrl + S(尽管这个习惯已经在我们的脑海中根深蒂固...)。
另一个有趣的设置是 禅模式,其中所有窗口和菜单都被隐藏,这样您就可以专注于代码。要激活它,请转到 视图 | 外观 | 禅模式,或使用键盘快捷键 Ctrl + K + Z(Windows/Linux 系统)和 Cmd + K + Z(macOS)。
为了在编辑时提高代码的可读性,一个有趣的设置是 括号着色,它将为您的代码中的每个括号和括号赋予不同的颜色。
要启用此设置,请使用快捷键 Ctrl + Shift + P(Windows/Linux)或 Cmd + Shift + P(macOS)打开 配置 文件,并输入 打开用户 设置 (JSON)。
在文件中添加以下元素:
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true
}
VS Code 还具有 内联提示 功能,它显示参数类型和返回方法的详细信息,以及您正在阅读的代码行上的其他有用信息。
要在设置菜单中配置它,查找内联提示并激活它,如果它尚未配置。对于您的 Angular 应用程序的开发,您还可以通过选择TypeScript来执行特定的配置。
您也可以通过直接使用以下元素配置 settings.json 文件来启用此功能:
{
"typescript.inlayHints.parameterNames.enabled": "all",
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
"typescript.inlayHints.parameterTypes.enabled": true,
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
"typescript.inlayHints.variableTypes.enabled": true,
"editor.inlayHints.enabled": "on"
}
Fira Code 字体和连字符
并非每个开发者都会注意到的一个重要细节是他们代码编辑器中使用的字体类型。一个令人困惑的字体可能会使阅读代码变得困难,并使您的眼睛感到疲劳。
理想的选择是使用等宽字体,即字符占据相同水平空间的字体。
如下图中所示,一个非常受欢迎的字体是 ==、>= 和 =>:

图 1.1 – 带有字体连字符的符号示例
在您的操作系统上安装字体后,要启用 VS Code 中字体的连字符,按照上一节中的说明访问 configuration 文件,并添加以下元素:
{
"editor.fontFamily": "Fira Code",
"editor.fontLigatures": true,
}
在项目中标准化扩展和设置
在 为什么选择 Angular? 部分中,我们了解到选择这个框架进行项目开发的一个优点是它为开发团队提供标准化。
您还可以标准化您的 VS Code 设置,并将它们记录在您的 Git 仓库中,这样不仅您,我们的团队也能获得这种生产力飞跃。
要完成这个任务,在你的仓库中创建一个名为 .vscode 的文件夹,并在该文件夹内创建两个文件。extensions.json 文件将包含项目推荐的所有扩展。在这个例子中,我们将使用我们之前看到的扩展:
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"Angular.ng-template",
"donjayamanne.git-extension-pack",
"editorconfig.editorconfig"
]
}
让我们再创建一个 settings.json 文件,它允许您将 VS Code 设置添加到您的工作区。这些设置优先于用户设置和 VS Code 的默认设置。
此文件将包含之前建议的设置:
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true
"editor.fontFamily": "Fira Code",
"editor.fontLigatures": true,
"typescript.inlayHints.parameterNames.enabled": "all",
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
"typescript.inlayHints.parameterTypes.enabled": true,
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
"typescript.inlayHints.variableTypes.enabled": true,
"editor.inlayHints.enabled": "on"
}
通过在您的仓库中同步这些文件,当您的团队成员下载项目并首次打开 VS Code 时,将显示以下消息:

图 1.2 – VS Code 推荐扩展的提示
一旦确认,文件中配置的所有扩展都将安装到团队成员的 VS Code 开发环境中,从而自动化标准化团队工作环境的任务。
Angular DevTools
从 Angular 框架中缺失的一个工具是钻入浏览器中的应用程序的方式。多年来,像 Chrome 和 Firefox 这样的浏览器已经极大地改善了所有类型网站的开发者体验。
在此背景下,Angular 团队从版本 12 开始为 Chrome 和 Firefox 创建了 Angular DevTools 扩展。
要安装它,您需要前往浏览器(Chrome 或 Firefox)的扩展程序商店并点击 安装。
安装后,访问使用 Angular 构建的网站,以及为开发设置的构建,开发者工具中会出现 Angular 选项卡:

图 1.3 – Angular DevTools Chrome 扩展示例
此工具允许您浏览应用程序的结构,定位屏幕上组件的代码,并对您的应用程序进行性能分析以检测可能的问题。
现在,您已经拥有了开发 Angular 应用程序的生产力开发环境,我们准备开始我们的应用程序。
开始一个 Angular 项目
我们已经安装并配置了我们的工具,现在我们将开始我们的 Angular 应用程序。首先,我们将安装 Angular CLI,它将负责创建和构建我们的应用程序。在您的终端中,输入以下命令:
npm install -g @angular/cli@16
安装 CLI 后,使用以下命令来确认安装:
ng version
以下图应在您的终端中显示(Angular 版本可能更新):

图 1.4 – Angular CLI 提示确认您已正确安装工具
如果 ng 命令不被识别,请重新启动终端。此 ng 命令是 CLI 调用,并将在本章和其他章节中使用。
让我们使用 ng new 命令开始我们的项目。Angular CLI 将要求您定义一些项目:
-
第一项是项目的名称;对于此示例,输入
angular-start。 -
第二个提示是您是否想配置项目的路由,我们将输入
Yes。此请求将告诉 CLI 创建路由的基本文件,这对于大多数应用程序是推荐的;一个例外可能是您想要创建的 Angular 库。 -
下一个提示将告诉您项目将使用哪种 CSS 格式。Angular 默认支持常规 CSS 以及 SCSS、Sass 和 Less 工具。对于本书中的此和其他示例,我们将使用
CSS。 -
确认 Angular CLI 将创建项目的整个初始结构,并使用
npm i命令安装依赖项,为开发启动做好准备,如下例所示。

图 1.5 – 由 angular-cli 生成的文件提示
要验证项目是否成功安装,在您的操作系统终端中输入以下命令:
ng serve
此命令将启动开发 Web 服务器并加载示例项目页面,如图 图 1**.6 所示:

图 1.6 – 创建项目时由 angular-cli 生成的示例页面
ng new命令还有其他选项,可以用于项目中特定的需求。它们列在官方文档(angular.io/cli/new)中,以下是一些可能有趣的选项:
-
参数 '—package-manager': 使用此参数,可以选择其他 node 包管理器,例如https://yarnpkg.com/)。 -
参数 '--skip-install': 使用此参数,CLI 不会执行包安装步骤,这对于为你的团队创建自动化工具可能很有用。 -
参数 '--strict': 此参数默认设置为true,但重要的是要提及它,因为它将项目配置为strict模式,该模式配置 TypeScript 和 Angular 机制以改进类型和模板验证。有关更多详细信息,请参阅第三章,Angular 的 TypeScript 模式。
项目结构
Angular CLI 使用 Angular 团队推荐的结构创建项目,并默认配置所有文件。为了深化我们对框架的了解,我们需要了解主要文件、它们的函数以及可用的自定义化,如下所示:
-
src: 这是你的项目所在的文件夹,包括所有组件、模块和服务。 -
assets: 包含你在项目中需要的静态文件,例如图片和图标。在构建过程中,默认情况下,它将从此文件夹导出文件,而不会对生产构建进行任何更改。 -
index.html: 这是应用程序的初始文件。在构建过程中,此文件将被使用,除非有非常具体的需求,否则建议不要修改它。标题信息必须使用 Angular 功能进行更改,而不是直接在此文件中更改。 -
main.ts: 这是将在应用程序中加载的第一个 JavaScript 文件。除非你的项目有非常具体的需求需要更改它,否则你不应该更改它。 -
styles.css: 这是一个可以包含应用程序全局 CSS 的文件,即所有组件都可以读取的 CSS,因为 Angular 默认将每个组件的 CSS 隔离。当你的项目使用 Material(material.angular.io/)等设计系统时,通常需要修改此文件。 -
.editorconfig: 如本章的VS Code部分所述,此文件以及解释和配置 IDE 的扩展,允许在代码约定中实现标准化,例如使用双引号或单引号以及使用制表符或缩进空格。 -
angular.json: 这是 Angular 应用程序最重要的配置文件。在其中,您可以自定义项目构建的方式,并定义包大小的预算(更多详情请参阅第十二章,打包一切 – 部署最佳实践),以及其他设置。 -
package.json和package-lock.json:这些文件指的是项目npm包的依赖项,也是创建将在 Angular 应用程序 CI/CD 管道中使用的npm脚本的地点(更多详情请参阅第十二章,打包一切 – 部署最佳实践)。
截至 Angular 版本 15,CLI 默认隐藏 Karma 配置文件和环境变量文件(enviroment.ts),理由是简化项目结构。仍然可以创建这些文件以微调应用程序构建、测试和环境过程(更多详情请参阅第八章,改进后端集成:拦截器模式)。
我们使用angular-cli工具创建了我们的项目,但这个工具可以为我们提供更多帮助,正如我们接下来将要学习的。
使用 Angular CLI 提高您的生产力
我们学习了如何创建具有所有选项的项目,但 Angular CLI 远不止是一个项目创建工具。它是 Angular 应用程序生产力和工作流程中非常重要的工具。所有可用选项都使用以下命令描述:
ng --help
我们将详细介绍一些最有趣的选择,在接下来的章节中,我们将继续使用这些工具,鉴于这个工具的实用性。
ng add
此命令的功能是将 Angular 库添加到您的项目中。您可能会想,“npm install 不是做同样的事情吗?”您是对的。然而,当您需要将 Angular Material 作为库安装时,安装依赖项只是第一步。
许多库,如 Angular Material 本身,需要配置angular.json文件和创建一些其他lib文件,以及其他任务。ng add命令允许库创建者自动化这些步骤并简化他们的工作流程。
为了在创建的项目中举例说明这一点,我们将使用以下命令:
ng add @angular/material
执行上述命令后,库将进行一些提示(格式与ng new命令中看到的相同),最后,它将使用库配置我们的项目,如图图 1.7所示。

图 1.7 – 使用 angular-cli 安装 Angular Material
ng update
在我们项目的开发过程中,更新某个版本的耗时往往比添加一个新库要长。ng update 命令使这项任务变得几乎微不足道,在我们更新应用程序的 Angular 版本时,它是最大的盟友之一。
在 Angular 更新网站(update.angular.io/)上,Angular 团队详细说明了如何更新旧版本的项目。较大的和更复杂的项目可能有它们的怪癖(通常在网站上描述),但所有应用程序都从以下命令开始(在这种情况下,版本 15):
ng update @angular/core@15 @angular/cli@15
Angular CLI 将负责更新包,甚至可能进行破坏自动化的更改;通常,这已经足够完全更新您的应用程序。
这个命令,就像 ng add 一样,也适用于由其作者配置过的库,并且可以从这种自动化中受益。
ng serve
这个命令被每一位 Angular 开发者使用(这是您创建项目后应该做的第一件事),其功能是上传开发 Web 服务器。
此命令最有趣和最有生产力的特性之一是热重载功能;也就是说,每当项目文件更新时,服务器都会重新启动,让您能够实时在界面上看到其修改。
对于此命令的一个生产力提示是使用以下open参数:
ng serve --open
使用此参数,一旦 Angular 加载您的应用程序,CLI 就会打开操作系统默认的浏览器,并显示您正在工作的应用程序。
ng build
ng build 命令旨在准备您的应用程序包以便由您选择的生成 Web 服务器执行。
它执行一系列优化以确保交付尽可能小的应用程序包。
这导致了性能提升,因为客户端下载较小的包更快,这在互联网速度慢的环境中尤为重要。
我们将在第十二章中更详细地讨论此命令,打包一切 – 部署最佳实践。
ng deploy
ng deploy 命令允许您将应用程序完全部署到云服务提供商,如微软 Azure。
此命令与您想要使用的提供商的 Angular 库协同工作,因此为了使其工作,您需要安装它。
我们将在第十二章中更详细地讨论此命令,打包一切 – 部署最佳实践。
ng generate
ng generate 命令具有生成您应用程序可以使用的几乎所有类型的 Angular 组件的功能。此功能在您的流程中提高了生产力,因为它生成了所有必要的文件。
让我们在示例项目中使用以下命令生成我们的about页面:
ng generate component about
我们可以在我们的项目文件夹中分析,Angular CLI 创建了渲染组件所需的 TypeScript、HTML 和 CSS 文件。
然而,它还为此组件生成了单元测试文件,并更新了模块以供其使用。所有这些文件都已经包含了组件开发所需的最小样板代码。
除了生成几乎所有标准的 Angular 组件外,此命令还可以被希望提供这种开发体验的外部库使用,如下面的 Angular Material 示例所示:
ng generate @angular/material:navigation home
在本书的几乎每一章中,我们都会使用此命令来生成我们将要研究的组件,以及它们的最佳实践和模式。
摘要
在本章中,我们介绍了 Angular 的特性和哲学,以及如何以最高效的方式启动一个项目。我们学习了构成其生态系统的技术,以及如何使用最佳的 VS Code 扩展和设置来配置其桌面环境。最后,我们学习了如何使用 Angular CLI 启动一个项目,以及这个强大工具能为我们提供哪些其他功能。
现在,你将能够争论为什么要在你团队的项目中使用 Angular,你将能够帮助它设置一个高效的工作环境。你还将能够使用 Angular CLI 来创建和维护你的项目。
在下一章中,我们将学习如何组织 Angular 应用程序的组件。
第二章:组织您的应用程序
一个混乱的项目是一个等待破坏用户体验的虫窝。除了质量外,从开始就组织良好的项目将为您的团队带来生产力,在 Angular 的情况下,还有可能提高您应用程序的性能。
在本章中,您将了解 Angular 模块的功能,这些模块与 JavaScript 模块之间的区别,以及如何以最佳方式在项目中使用它们。
您将了解单模块应用的反模式以及如何避免它,以及为什么要避免它。您还将使用 Angular 模块通过 SharedModule 模式优化对应用程序中常用组件的导入。最后,您将了解如何使用懒加载来优化您应用程序的性能。
在本章中,我们将介绍以下主题:
-
使用 Angular 模块组织应用程序
-
第一个模块:
AppModule -
避免反模式:单模块应用
-
优化常用模块的使用:
SharedModule模式 -
提高您应用程序的大小:懒加载
到本章结束时,您将能够将您的 Angular 应用程序组织成功能化和优化的模块。
技术要求
要遵循本章中的说明,您需要以下内容:
-
Visual Studio Code (
code.visualstudio.com/Download) -
Node.js 18 或更高版本 (
nodejs.org/en/download/)
本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch2 获取。
使用 Angular 模块组织应用程序
使用框架组织应用程序组件的基础是 Angular 模块,在文档和社区中更广为人知的是名称 NgModules。
Angular 模块是一个带有 @NgModule 装饰器的 TypeScript 类,其中包含元数据,如下例所示:
import { NgModule } from '@angular/core';
@NgModule({
declarations: [SimulationComponent],
providers:[],
imports: [
CommonModule,
SharedModule,
MatCardModule,
MatButtonModule,
MatSelectModule,
MatRadioModule,ReactiveFormsModule,
],
exports: [SimulationComponent],
})
export class SimulationModule {}
让我们在以下子节中详细说明这些元数据类型。
声明
此元数据包含一个由组件、指令和管道组成的数组,这些组件必须只属于一个模块,否则 Angular 编译器将抛出错误,如图 图 2**.1 所示:

图 2.1 – 在多个模块中声明组件时的错误信息
提供者
在这个属性中,我们可以使用 Angular 的依赖注入系统注册我们想要注入的类,通常用于服务(将在 第五章,Angular 服务和 单例模式 中详细介绍)。
导入
在这个元数据中,我们通知模块我们想要导入并使用它们的组件和服务。例如,如果我们想使用 Angular 的 HTTP 请求服务,我们必须在这里声明 HttpClientModule 模块。
重要的是要知道,在这里,我们不应该导入组件或服务,而只导入 Ngmodules。
exports
默认情况下,declarations 属性中的所有项都是私有的。这意味着如果一个模块包含了 StateSelectorComponent 组件和另一个模块,例如,导入该模块以使用此组件将导致以下错误发生:

图 2.2 – 使用未正确导出的组件时的错误信息
为了让 Angular 知道该组件可以被使用,必须在 exports 元数据中声明它。
与 imports 元数据不同,在这里,你可以声明组件、管道、指令和其他模块(如我们将在 优化常用模块的使用 – SharedModule 模式 部分中看到的)。
现在我们已经知道了如何声明一个模块,让我们来研究创建 Angular 项目时生成的模块。
第一个模块 – AppModule
Angular 中的模块对于框架来说非常重要,因此当您启动一个项目时,它会自动创建一个名为 AppModule 的模块。
此模块包含我们在上一节中研究的所有参数(declarations、providers、imports 和 exports),以及一个额外的参数:bootstrap。此模块包含将被注入到应用程序的 index.html 文件中的第一个组件,并将成为您 Angular 应用程序组件树的根。
您可能想知道这是哪个 index.html 文件和哪个树。
如我们在 第一章 中所述,正确开始项目,Angular 是一个框架,index.html 文件实际上是网络服务器向其用户提供的唯一 页面。
由 Angular 引擎渲染的所有接口(称为 index.html 文件,第一个组件在 bootstrap 元数据中描述)遵循逻辑树类型的数据结构,此树的根是第一个组件。
Angular 和 JavaScript 模块之间的区别是什么?
几乎所有编程语言都为开发者提供了一种方式,可以在一个或多个文件中组织函数、类和变量,从而提高可维护性和关注点的分离。
在 JavaScript 中,在其创建和几个提议之后,语言模块的概念得到了巩固。解释这个概念的最佳方式是使用示例来演示。首先,我们创建一个 sum.mjs 文件 – 一个接收两个数字并返回它们的和的 sum 函数。这里重要的是,我们使用 export 关键字来指示我们想在源文件之外的作用域中使用它:
export function sum(numberA,numberB){
return numberA + numberB;
}
在index.mjs文件中,我们将使用创建的函数,为此,我们在文件的第一行进行声明。使用保留字import,我们指明哪个函数以及它来自哪个文件:
import {sum} from './sum.mjs';
const numberA = 5;
const numberB = 10;
console.log(sum(numberA,numberB));
你可能想知道为什么使用.mjs扩展名。这是因为,在示例中,我们正在使用 Node.js 来执行,这种类型的模块——ECMAScript 模块(ESM),因为 JavaScript 语言的官方名称是 ECMAScript——是在版本 14 中引入的。
Angular,以及所有其他 SPA 框架,在其开发中使用 JavaScript 模块,我们可以在任何 Angular 组件或服务中注意到我们导出类并使用 ESM 导入:
import { Component } from '@angular/core';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent {
在前面的代码片段中,我们正在从@angular/core库中导入Component装饰器,并将HomeComponent类导出以在其他项目部分中使用。
模块类型
现在我们已经理解和加强了 Angular 框架中模块的概念,让我们将应用程序分割并更好地利用这个特性。组织应用程序模块没有固定的规则,但 Angular 团队和社区建议根据具有共同特性的功能分组来分离模块。
基于这个想法,我们可以有以下类型的 Angular 模块:
-
业务领域模块
-
组件模块
业务领域模块
一个应用程序将服务于一个或多个用户工作流程。此类模块旨在根据组成它们的接口的亲和力对这些流程进行分组。例如,在资源管理应用程序中,我们可以有会计模块和库存模块。
在ch2文件夹中可用的应用程序中,有一个我们将用于本章和其他章节以将我们的知识付诸实践的talktalk应用程序。在项目文件夹中,让我们使用以下命令创建home模块:
ng g m home
在此命令中,我们使用 Angular CLI 的缩写ng和g代表m代表home。
让我们创建一个表示应用程序主页的Page组件,由于我们正在使用 Angular material,我们将使用 Angular CLI 来生成一个带有侧菜单的页面,使用以下命令:
ng generate @angular/material:navigation home/home
除了创建组件外,Angular CLI 还编辑了home.module.ts文件,将其添加到declarations属性中。按照以下示例更改此文件:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomeComponent } from './home/home.component';
import { LayoutModule } from '@angular/cdk/layout';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
@NgModule({
declarations: [HomeComponent],
imports: [
CommonModule,
LayoutModule,
MatToolbarModule,
MatButtonModule,
MatSidenavModule,
MatIconModule,
MatListModule,
],
exports: [HomeComponent],
})
export class HomeModule {}
在此模块中,我们将导出HomeComponent组件以在应用程序的路由中使用。在app.module.ts文件中,按以下方式导入模块:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HomeModule } from './home/home.module';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
HomeModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
在NgModule元数据的import属性中,我们可以更改app-routing.module.ts文件中的路由:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home/home/home.component';
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' },
{
path: 'home',
component: HomeComponent
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
routes组件也是NgModule,然而,它专门用于组织路由,并且只从 Angular 导入和导出RouterModule。在这里,在routes数组中,我们为HomeComponent创建方向。
运行ng serve --o命令,我们得到应用程序的主页:

图 2.3 – talktalk 示例应用程序菜单页面
组件模块
这个模块的目的是将那些将被业务域组件和其他组件重用的指令组件和管道分组。即使使用像 Angular Material 这样的组件库,你的系统也需要根据你的业务域规则创建自定义组件。
这种类型的组件在 declaration 属性中声明了组件、指令和管道,并在 exports 属性中导出,如下面的示例所示:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StatesSelectorComponent } from './states-selector/states-selector.component';
import { MatSelectModule } from '@angular/material/select';
@NgModule({
declarations: [StatesSelectorComponent],
imports: [CommonModule, MatSelectModule],
exports: [StatesSelectorComponent],
})
export class ComponentsModule {}
将项目分解为业务域模块和组件将组织你的代码并提高其可维护性。让我们分析 Angular 应用程序中的一种常见反模式。
避免反模式 – 单模块应用
当我们开始使用 Angular 进行学习和开发时,通常不太关注应用模块的组织和使用。正如我们在本章开头所学的,NgModules 对 Angular 来说是如此基础,以至于当我们开始一个项目时,Angular CLI 就会为该项目创建第一个模块,即 AppModule。
理论上,仅此模块对于你的应用程序运行是必要的。从那里,我们可以声明所有组件和指令,并导入项目可能需要的所有库,如下面的示例所示:
import { NgModule } from '@angular/core';
. . .
@NgModule({
declarations: [
AppComponent,
StatesSelectorComponent,
HomeComponent,
SimulationComponent
],
imports: [
BrowserModule,AppRoutingModule,
BrowserAnimationsModule,HttpClientModule,
ReactiveFormsModule,LayoutModule,
MatToolbarModule,MatButtonModule,
MatSidenavModule,MatIconModule,
MatListModule,
],
bootstrap: [AppComponent]
})
export class AppModule { }
这种方法存在一些问题,是一种我们称之为单模块应用的反模式。
我们在这里遇到的问题如下:
-
无序的文件夹结构:随着项目的增长,团队将很快不知道哪些组件属于项目的哪个区域。随着项目的增长,这个文件将变得更大、更混乱。
-
包大小和构建时间:Angular 有几个构建和打包优化,这些优化依赖于应用模块的定义。如果我们只在一个模块中,这些优化效果并不明显。
-
组件可维护性和更新问题:随着这个文件的增长,团队将难以弃用不再使用的组件或更新那些 Angular CLI 无法自动更新的组件。
解决这种反模式的方法是应用我们在本章中学到的知识:将模块分为业务域(或功能)和组件模块。
我们可以使用 NgModel 来减少在应用程序中重复导入常用组件,正如我们将在下一节关于 SharedModule 模式的部分中所看到的。
优化常用模块的使用 – SharedModule 模式
如果我们观察 Angular 项目,我们将看到模块使用模式,例如 HttpModule,如下面的示例所示:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HomeComponent } from './home/home.component';
import { LayoutModule } from '@angular/cdk/layout';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
@NgModule({
declarations: [HomeComponent],
imports: [
CommonModule,
LayoutModule,
MatToolbarModule,
MatButtonModule,
MatSidenavModule,
MatIconModule,
MatListModule,
],
exports: [HomeComponent],
})
export class HomeModule {}
为了避免代码重复并使新团队成员更容易上手,别忘了将一个重要的模块添加到项目中;我们可以创建 SharedModule 来集中管理 Angular 项目的公共依赖。
让我们在我们的项目中使用 Angular CLI 来实现这一点:
ng generate module shared
在新创建的文件中,我们将放置 Angular Material 的依赖项:
import { NgModule } from '@angular/core';
...
@NgModule({
imports: [
CommonModule,
LayoutModule,
MatToolbarModule,
MatButtonModule,
MatSidenavModule,
MatIconModule,
MatListModule,
],
exports: [
CommonModule,
LayoutModule,
MatToolbarModule,
MatButtonModule,
MatSidenavModule,
MatIconModule,
MatListModule,
]
})
export class SharedModule { }
在本模块中,我们正在导入 Angular Material 的依赖项并导出相同的依赖项,而不声明任何组件、指令或管道。
在home.module.ts文件中,我们可以重构以使用SharedModule:
import { NgModule } from '@angular/core';
import { HomeComponent } from './home/home.component';
import { SharedModule } from '../shared/shared.module';
@NgModule({
declarations: [HomeComponent],
imports: [
SharedModule
],
exports: [HomeComponent],
})
export class HomeModule {}
注意使用SharedModule后,文件变得多么简洁且易于阅读。
重要
在SharedModule中存在的模块必须是您项目中大多数模块共有的模块,因为这会增加模块包的大小。如果模块需要一些特定的依赖项,您必须在那个依赖项中声明它,而不是在SharedModule中。
在下一个主题中,我们将看到一个将提高用户体验的功能,该功能基于将应用程序组织成模块。
提高应用程序的大小 – 懒加载
将模块从您的 Angular 应用程序中分离出来的好策略将提高团队的生产力并改善代码组织。但另一个将影响用户质量的优势是使用模块的懒加载技术。
如果我们使用ng build命令运行示例应用程序的构建过程,我们可以看到以下消息:

图 2.4 – 示例应用程序包大小
我们应用程序的初始包(main.ts文件)的大小为 94.73 kB,这可能看起来很小,但考虑到我们的应用程序功能较少,这是一个相当大的大小。
随着项目功能的增加,这种初始包的增长趋势会相当明显,这会损害用户的体验,因为他们最初需要下载一个更大的文件。这个问题在互联网不太好的环境中尤其明显,例如 3G 网络。
为了减小这个文件并相应地提高用户体验,理想的情况是拥有更小的包,并且这些包只在必要时加载 – 也就是说,以懒加载的方式。
我们将重构我们的项目,我们已经采取的第一步是将功能分离到功能模块中(在避免反模式 – 单模块应用部分,我们解释了不分离应用程序模块的危险,毫无疑问,包的大小对用户影响最大)。
现在,让我们为Home模块创建一个路由文件。由于该模块已经存在,让我们在home.module.ts文件所在的同一文件夹中手动创建home-routing.module.ts文件。
在此文件中,我们将添加以下代码:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home/home.component';
const routes: Routes = [
{
path: '',
component: HomeComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class HomeRoutingModule {}
此路由文件与应用程序的主要路由文件类似,不同之处在于@NgModule的导入使用forChild方法而不是forRoot。这是因为此模块是主路由的子路由。
另一个需要注意的重要细节是,为HomeComponent组件选择的路径是空的。我们可以解释这一点,因为定义/home路由以及如何表示/home组件的主要路由文件已经定义。
在home.module.ts文件中,让我们将其更改为导入路由文件:
import { NgModule } from '@angular/core';
import { HomeComponent } from './home/home.component';
import { SharedModule } from '../shared/shared.module';
import { HomeRoutingModule } from './home-routing.module';
@NgModule({
declarations: [HomeComponent],
imports: [
SharedModule,HomeRoutingModule
]
})
export class HomeModule {}
在这个文件中,我们还移除了HomeComponent组件的导出,因为Home模块的路由文件会加载它。
在项目的主要路由文件app-routing.module.ts中,让我们按照以下方式重构它:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' },
{
path: 'home',
loadChildren: () =>
import('./home/home.module').then((file) => file.HomeModule),
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
在此代码中,最重要的部分是loadChildren属性。这是我们配置懒加载的地方,因为我们向 Angular 的路由机制传递一个返回import承诺的函数。
注意,import函数不是一个 Angular 函数,而是一个标准的 JavaScript 函数,它允许动态加载代码。Angular 的路由引擎使用这个语言特性来实现这一功能。
最后,在主模块AppModule中,让我们移除HomeModule的导入:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
使用ng serve命令运行我们的应用程序时,我们没有注意到任何差异。然而,当执行ng build命令时,我们可以注意到以下诊断:

图 2.5 – 使用懒加载重构后的应用程序包大小
Angular 构建过程已将Home模块分离成其自己的包,并将main.ts包变得更小。这种差异可能看起来很小,但请注意,这样我们的应用程序可以扩展并增加复杂性,而初始包将保持较小或增长很少。
新功能仍然存在并被应用程序加载,但初始加载将更快,并且这些新功能将仅在用户访问他们想要的路径时按需下载,从而提供非常积极的流畅性和响应性。
摘要
在本章中,我们详细研究了 Angular 模块以及我们如何利用它们来组织应用程序的性能。我们学习了 Angular 模块与 JavaScript 模块之间的区别,并看到了模块定义的每个属性以及我们可以在项目中创建的类型。最后,我们学习了如何避免单模块应用程序的反模式以及如何创建SharedModule。
我们重申了我们的示例应用,使用分批懒加载,这证明了良好的模块组织反映了我们用户的表现力和流畅性。现在,你可以以这种方式组织你的应用程序,使其能够扩展并增加复杂性和功能,而不会损害项目的可维护性。
在下一章中,我们将学习如何有效地使用 TypeScript 来为我们的 Angular 项目提高生产效率。
第三章:TypeScript Patterns for Angular
自框架的第 2 版以来,Angular 无论是内部开发还是用于构建应用程序的用户,都基于 TypeScript 进行开发。
当时这是一个有争议的决定,因为这个由微软创建的 JavaScript 超集是新的。如今,大多数 Web 框架,如 React、Vue.js 和 Svelte,都支持 TypeScript,一些 Web 框架甚至积极推荐使用 TypeScript 作为编程语言。
本章中,我们将研究使用 TypeScript 与 Angular 以及其他技术的最佳实践和模式;这些技术可以应用于 Node.js 后端开发,甚至其他 Web 框架,如 React 和 Vue.js。
我们将学习如何更好地声明我们的应用程序的方法和函数,以及如何利用 TypeScript 的类型推断机制来使我们的类更加简洁。
本章将涵盖以下主题:
-
创建类和类型
-
创建方法和函数
-
减少冗余:类型推断
-
验证类型:类型守卫
-
使用
any类型的更好替代方案
到本章结束时,你将能够更好地在你的项目中应用 TypeScript 资源,提高代码质量和团队的生产力。
技术要求
要遵循本章的说明,你需要以下内容:
-
Visual Studio Code (VS Code) (
code.visualstudio.com/Download) -
Node.js 18 或更高版本 (
nodejs.org/en/download/)
本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch3 找到。
创建类和类型
使用 Angular 进行应用程序开发的基础是面向对象编程,因此深入了解如何创建类和实例化对象对我们来说非常重要。使用 TypeScript 而不是纯 JavaScript,我们在类型工具箱中又多了一个强大的元素。
通过类型化变量和对象,TypeScript 编译器能够执行检查和警告,防止在开发过程中由于这个过程不存在而可能发生的运行时错误。
请记住,在将 TypeScript 代码转换为 JavaScript(这是一个转换过程)之后,发送到客户端浏览器的代码是纯 JavaScript,包括一些优化;也就是说,用 TypeScript 编写的代码在性能上并不逊色于直接用 JavaScript 编写的代码。
为了从基础知识开始,让我们来探索原始和基本类型。
原始和基本类型
尽管 JavaScript 不是强类型语言,但它有三个称为原始类型:
-
boolean:表示两个二进制值false和true -
string:表示一组字符,如单词 -
number:表示数值
对于这些原始类型中的每一个,TypeScript 已经有一个表示它们的内置数据类型,分别是Boolean、String和Number。
重要
TypeScript 中原始类型的第一个字母是大写的,以区分它们与原始 JavaScript 类型的区别。如果您想使用typeof函数在运行时检查类型,请使用原始类型的小写名称。
要声明这些类型的变量,只需在变量声明前使用:符号,如下面的示例所示:
export function primitive_example() {
let name: string;
let age: number;
let isAlive: boolean;
name = "Mario";
age = 9;
isAlive = true;
console.log(`Name:${name} Age:${age} is alive:${isAlive ? "yes" : "no"}`);
}
在前面的示例中,我们将name、age和isAlive变量分别声明为string、number和boolean。请注意,我们可以在 TypeScript 中使用 JavaScript 类型名称,因为 TypeScript 允许这些原始类型使用两种形式。
在 JavaScript 中,使用数组数据结构非常常见。这种结构允许我们存储和操作应用程序中的值列表。TypeScript 有一个名为Array的类型,其中不仅可以创建具有该类型的变量,还可以指明数组将包含哪种类型的值:
export function array_example() {
let names: Array<string>;
let surnames: string[];
names = ["Mario", "Gabriel", "Lucy"];
surnames = ["Camillo", "Smith"];
names.forEach((name) => console.log(`Name:${name}`));
surnames.forEach((surname) => console.log(`Surname:${surname}`));
}
在这个函数中,我们使用Array类型声明names数组,并声明它是一个字符串列表,因为我们是在方括号内通知它的。在surnames数组声明中,我们进行相同的声明,但使用 TypeScript 的语法糖,在string类型后面使用[]。这种声明方式具有相同的效果;它只是更简洁。
在示例的末尾,我们使用Array的forEach方法来打印数组的元素。最后,另一个广泛使用的类型是any类型。此类型告诉 TypeScript 编译器不要对此类型执行任何类型检查,并且其内容可以在代码的任何地方更改类型,如下面的示例所示:
export function any_example(){
let information:any;
information = 'Mario';
console.log(`Name: ${information}`);
information = 7;
console.log(`Age: ${information}`);
}
information变量被声明为any,然后我们将Mario字符串放入其中。随后,我们用值5重新定义了变量。
默认情况下,在 TypeScript 中,每个没有声明类型的变量,或者在其声明中定义了值的变量,都是any类型。
这种语言规则允许,例如,一个包含 JavaScript 代码的项目可以通过最初将所有变量声明为any类型来逐步转换为 TypeScript。any类型的另一个用途是当您的代码需要 JavaScript 的灵活性以处理一些更通用的算法类型时。
然而,建议 Angular 开发者避免使用any,因为它部分地禁用了 TypeScript 在您的代码中执行的检查,而没有充分利用其功能。
如果您需要any类型的灵活性,而又不想牺牲类型检查和 TypeScript 推断,本章将展示一些替代方案。
类
在我们了解基本类型的基础上,现在让我们创建更复杂的数据类型。我们将要探索的第一个是 类。面向对象编程的一个基本元素,类代表一个模型,可以是真实的,如人或车辆,也可以是抽象的,如网页上的文本框。
在课程中,我们创建了对象,这些对象是我们系统将用来执行业务规则的基本元素,如下面的示例所示:
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
export function basic_class() {
let client: Person = new Person("Mario", 7);
console.log(`Name:${client.name} Age:${client.age}`);
}
首先,我们通过键入属性声明 Person 类,具有 name 和 age 属性,然后我们为该类创建一个名为 constructor 的方法。这个方法很特殊,因为它定义了从该类实例化对象时的规则。
在 basic_class 函数中,我们使用 new 关键字实例化了一个名为 client 的对象,它是 Person 类型的。为了检索这个实例化对象的属性,我们使用 client.name 和 client.age 的表示法。
TypeScript 中类的声明和使用几乎与 JavaScript 相同,只是对类的属性进行类型化。
纯 JavaScript 中的相同示例如下:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
function basic_class() {
let client = new Person("Mario", 7);
console.log(`Name:${client.name} Age:${client.age}`);
}
注意,声明类和从它实例化对象的过程在 TypeScript 中变化很小。然而,正如我们将在下面的代码块中看到的,TypeScript 为我们在项目中使用类提供了更多的资源。
除了属性之外,类还定义了方法,这些是对象可以执行的功能。在我们正在工作的示例中,我们现在将添加一个方法:
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
toString(){
return `Name:${this.name} Age:${this.age}`;
}
}
toString 方法返回一个表示对象的 string,因此它使用保留的 JavaScript 关键字 this 访问对象实例的属性。
面向对象编程中有一个称为 属性封装 的概念。这包括定义哪些属性可供实例化给定对象的函数访问。
这个概念对于正确使用某些设计模式非常重要,但在 JavaScript 中并不完整存在。每个类属性都是公开的,但在 TypeScript 中,它通过编译器实现和验证,如下面的示例所示:
class Person {
name: string;
age: number;
private id:number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
this.id =Math.floor(Math.random() * 1000);
}
toString(){
return `Name:${this.name} Age:${this.age} ID: ${this.id}`;
}
}
在这里,我们创建了一个名为 id 的属性,该属性在对象实例化时生成,我们使用保留字 private 来表示它不应从类外部访问。请注意,在类方法中,此属性可以正常访问。
让我们尝试从外部强制访问,如下面的示例所示,看看会发生什么:
export function basic_class() {
let client: Person = new Person("Mario", 7);
console.log(client.toString());
client.id = 100;
}
在这个函数中,我们实例化了一个 Person 类的 client 对象,然后我们尝试修改 id 属性。当尝试运行代码时,TypeScript 将显示以下错误:

图 3.1 – 访问私有属性时的错误信息
另一个面向对象编程的概念是继承。它定义了类之间的 is a 关系,例如,一个客户是 一个人。
在实践中,它使一个类具有扩展类的所有属性和方法,如下面的示例所示:
class Client extends Person {
address: string;
constructor(name: string, age: number, address: string) {
super(name, age);
this.address = address;
}
toString(): string {
return `${super.toString()} Address: ${this.address}`;
}
}
在这里,我们正在创建一个 Client 类,它从 Person 类扩展而来。我们添加一个名为 address 的属性并创建构造函数。由于它是一个从 Person 继承而来的类,因此必须调用 super 方法,这是我们访问原始类的方法和属性的方式。
当使用继承时,我们可以选择性地重写原始类的方法,就像我们重写 toString 方法一样。这个概念在 JavaScript 中也存在,但 TypeScript 会检查构造函数和方法重写的规则,在编译时进行检查,这增加了我们对开发的信心。
接口
在 TypeScript 中,我们还有一种方式来指定对象的结构,称为 接口。以下示例演示了其用法:
export interface Animal {
species: string;
kingdom: string;
class: string;
}
要声明一个接口,我们使用保留字 interface 并像之前看到的那样将其属性声明为一个类。
要使用 interface,我们可以这样做:
import { Animal } from "./animals";
export function basic_interface() {
let chicken: Animal = {
kingdom: "Animalia",
species: "Gallus",
class: "birds",
};
console.log(
`kingdom:${chicken.kingdom} species:${chicken.species} class:${chicken.class}`
);
}
注意,为了使用一个类,我们只需输入变量并声明其值,而不使用保留字 new。这是因为接口不是 JavaScript 元素,它仅由 TypeScript 编译器用于检查对象是否包含定义的属性。
为了证明接口不存在,如果我们编译 interface 文件,TypeScript 将生成一个空文件!
我们还可以使用接口为类创建合约,如果类需要某些方法和属性的话。让我们看看以下示例:
export interface Animal {
species: string;
kingdom: string;
class: string;
}
export interface DoSound {
doASound: () => string;
}
export class Duck implements DoSound {
public doASound(){
return 'quack';
}
}
export class Dog implements DoSound {
public doASound(){
return 'bark';
}
}
要定义一个类遵循 DoSound 合约,我们使用保留字 implements。TypeScript 然后要求定义一个名为 doASound 的方法,并且该方法返回一个字符串。
接口这一特性促进了面向对象语言非常重要的能力——多态性的使用。让我们看看以下示例:
export function animalDoSound() {
let duck = new Duck();
let dog = new Dog();
makeSound(duck);
makeSound(dog);
}
function makeSound(animal: DoSound) {
console.log(`The animal make this sound:${animal.doASound()}`);
}
我们创建一个 makeSound 函数,它接收一个实现了 DoSound 合约的动物。该函数不关心动物的类型或其属性;它只需要遵循 DoSound 接口合约,因为它将调用其方法之一。
Angular 大量使用 TypeScript 接口的这一特性,正如我们可以在组件声明中看到的那样:
export class SimulationComponent implements OnInit {
当我们通知 Angular 组件实现了 OnInit 接口时,它将在组件生命周期的开始执行所需的 ngOnInit 方法(我们将在 第四章,组件 和 页面)中更详细地研究这一点)。
类型别名
本章我们将看到的最后一种类型化变量的方式是最简单的一种,即创建类型别名。与接口一样,类型别名仅存在于 TypeScript 中,我们可以在以下示例中使用它们:
type Machine = {
id: number;
description: string;
energyOutput: number;
};
export function basic_type() {
let car: Machine = {
id: 123,
description: "Car",
energyOutput: 1000,
};
console.log(
`ID:${car.id} Description:${car.description} Energy Output:${car.energyOutput} `
);
}
在此代码中,我们创建了一个Machine类型,描述了我们想要表示的对象,并在basic_type函数中,我们使用该类型实例化了一个变量。
注意,我们使用这个变量的属性就像之前的例子一样。这展示了 TypeScript 在保持 JavaScript 的灵活性同时,为开发者提供了更多可能性。
类型别名的常用特性之一是从其他类型创建一个类型。其中最常见的是类型的联合,正如我们可以在以下代码中看到:
type ID = string | number;
type Machine = {
id: ID;
description: string;
energyOutput: number;
};
这里,我们正在创建一个名为id的类型,它可以是一个string或number。为此,我们使用|符号,这与 JavaScript 中用来表示条件OR的符号相同。
这个特性对于使用更高级的技术非常重要,例如guard类型,我们将在本章中看到。
何时使用类、接口或类型
在所有这些创建类型化对象的方式中,你可能会想知道在哪些情况下我们应该使用哪一种。基于每种形式的特性,我们可以对每种使用进行分类:
-
类型别名:创建的最简单形式,推荐用于类型化输入参数和函数返回值。
-
implements关键字。 -
类:面向对象的基础,也存在于 JavaScript 中。当我们需要具有方法和属性的对象时,我们应该使用它。在 Angular 中,所有组件和服务最终都是通过类创建的对象。
记住,在 TypeScript 中,你可以创建一个表现得像接口的alias类型,以及将接口作为函数的参数和返回值,但这里的建议是针对每种情况使用最佳实践,并解释它们通常如何在 Angular 应用程序中使用。
现在我们已经很好地理解了创建更复杂变量作为对象的不同方式,让我们来了解如何使用 TypeScript 创建函数和方法。
创建方法和函数
TypeScript 用来改善 Angular 应用程序开发中开发者体验的最好方法之一是通过能够类型化参数和函数及方法。
对于创建库和框架的开发者以及使用这些软件组件的开发者来说,了解函数期望什么以及预期的返回值可以让我们减少阅读和查找文档的时间,特别是我们系统可能遇到的运行时错误。
要对函数的参数和返回值进行类型化,让我们考虑以下示例:
interface invoiceItem {
product: string;
quantity: number;
price: number;
}
type Invoice = Array<invoiceItem>;
function getTotalInvoice(invoice: Invoice): number {
let invoiceTotal = invoice.reduce(
(total, item) => total + item.quantity * item.price,
0
);
return invoiceTotal;
}
export function invoiceExample() {
let example: Invoice = [
{ product: "banana", price: 1.5, quantity: 3 },
{ product: "apple", price: 0.5, quantity: 5 },
{ product: "pinaple", price: 3, quantity: 12 },
];
console.log(`Invoice Total:${getTotalInvoice(example)}`);
}
在这个例子中,我们首先定义一个表示发票项目的接口,然后创建一个表示发票的类型,在这个简化中,它是一个项目的数组。
这展示了我们如何使用接口和类型更好地表达我们的 TypeScript 代码。不久之后,我们创建了一个返回发票总价值的函数;作为输入参数,我们接收一个具有发票类型的值,函数的返回值将是 number。
最后,我们创建了一个示例函数来使用 getTotalInvoice 函数。在这里,除了类型检查之外,如果我们使用具有 TypeScript 支持的编辑器,如 VS Code,我们将获得基本的文档和自动完成功能,如下面的截图所示:

图 3.2 – 由 TypeScript 生成并由 VS Code 可视化的文档
除了原始类型和对象之外,函数还必须准备好处理 null 数据或未定义变量。在下一节中,我们将探讨如何实现这一点。
处理 null 值
在 TypeScript 中,默认情况下,所有函数和方法参数都是必需的,并且由编译器进行检查。
如果任何参数是可选的,我们可以在它所代表的类型中定义它,如下面的示例所示:
function applyDiscount(
invoice: Invoice,
discountValue: number,
productOfDiscount?: string
) {
discountValue = discountValue / 100;
let newInvoice = invoice.map((item) => {
if (productOfDiscount === undefined || item.product === productOfDiscount) {
item.price = item.price - item.price * discountValue;
}
return item;
});
return newInvoice;
}
在应用折扣到发票的函数中,我们创建了一个可选参数,允许函数用户确定要应用折扣的产品。如果该参数未定义,折扣将应用于整个发票。
要定义一个可选参数,我们使用 ? 字符。在 TypeScript 中,可选参数必须是函数中最后定义的。如果我们更改函数参数的位置,编译器将抛出以下错误:
error TS1016: A required parameter cannot follow an optional parameter.
此外,TypeScript 允许您为参数定义一个默认值:
function applyDiscount(
invoice: Invoice,
discountValue = 10,
productOfDiscount?: string
)
在参数声明中赋值时,如果函数用户没有使用该参数,将对发票项目应用 10%的折扣。
我们已经看到了如何使用 TypeScript 来类型化函数参数和返回值。现在让我们讨论类型推断以及我们如何使用它来减少代码的冗余。
减少冗余 – 类型推断
在本章中,我们看到了 TypeScript 的最佳功能,这些功能有助于我们开发 Angular 项目。我们输入所有变量,并依赖 TypeScript 编译器来避免在用户运行时可能发生的错误。
现在我们来探索 TypeScript 强大的推断机制。通过它,TypeScript 通过内容识别变量的类型,而不需要您显式地定义类型。让我们观察以下示例:
export function primitive_example() {
let name = "Mario";
let age = 9;
let isAlive = true;
console.log(`Name:${name} Age:${age} is alive:${isAlive ? "yes" : "no"}`);
}
这个例子与 原始和基本类型 中的例子相同,但我们直接在变量中告知值。这种声明变量的方式与显式方法具有相同的效果。如果您更改变量的值为另一种类型,TypeScript 将执行如下示例中的验证:
TSError: ⨯ Unable to compile TypeScript:
src/basic_types/primitive.ts:6:3 - error TS2322: Type 'number' is not assignable to type 'string'.
TypeScript 也可以推断复杂类型,如数组和函数返回值。在这里的一个好做法是使用推断能力来编写更少的代码,并且只从接口中类型化对象,例如。
验证类型 – 类型守卫
现在我们已经了解了 TypeScript 的推断机制,我们可以理解其中另一个特性,类型守卫。让我们在以下示例中考虑这些内容:
function getDiscount(value: string | number) {
if (typeof value === "number") {
return value;
} else {
return parseInt(value);
}
}
在这个函数中,我们可以接收一个可以是原始类型 string 或 number 的值。
由于它们是原始类型,我们可以使用 typeof 函数来定义变量是否为数值型;否则,它是一个 string,我们必须将其转换为数值型。
TypeScript 转译器可以解释这个条件语句的上下文,并在其中将值视为 number 或 string,包括在 VS Code 的自动完成中。

图 3.3 – TypeScript 中的条件部分,识别变量为数字
图中的 VS Code 插件在后台运行转译器,并识别出 if 语句内部的变量只能是一个 number。

图 3.4 – TypeScript 中的条件 else 部分,识别变量为字符串
由于它们是原始类型,我们可以使用 typeof 函数来定义变量是否为数值型;否则,它是一个 string,我们必须将其转换为数值型。
对于更复杂的数据类型,如对象,使用 typeof 函数的这种守卫是不可能的,因为它总是会识别变量为 object 类型。然而,我们可以创建自己的自定义类型守卫函数:
interface Person {
socialSecurityNumber: number;
name: string;
}
interface Company {
corporateNumber: number;
name: string;
}
type Client = Person | Company;
function isPerson(client: Client): client is Person {
return (client as Person).socialSecurityNumber !== undefined;
}
function getID(client: Client) {
if (isPerson(client)) {
return client.socialSecurityNumber;
} else {
return client.corporateNumber;
}
}
在这里,我们有两个接口,Person 和 Company,我们创建了一个名为 Client 的类型。对于遵循接口的每种对象类型,我们都有一个 id 类型,对于个人是 socialSecurityNumber,对于公司是 corporateNumber。
为了执行 guard 类型,我们创建了 isPerson 函数。关于这个函数的不同之处在于,我们将 client is Person 表达式放在了函数返回值的定义中。
在其中,我们定义了将对象视为人的规则,并在 getID 函数中使用它。我们不仅有一个在运行时检查对象的函数,而且以这种方式,TypeScript 转译器在编译时检查操作是否报告了错误。
使用 better alternative 到 any 类型
在 TypeScript 应用程序的开发过程中,我们可能会遇到不知道将要接收哪种类型参数的情况,例如 API 的返回值。
通过创建表示数据的接口可以定义传输的内容,(更多详情,见 第五章**,Angular 服务和单例模式)。由于纯文本在互联网上传输,因此无法保证这一点。
在这些情况下,我们可以使用 any 类型,这可以防止 TypeScript 进行类型检查。
在这个例子中,我们可以看到 any 的使用:
interface Products {
id: number;
description: string;
}
type ListOfProducts = Array<Products>;
const exampleList: ListOfProducts = [
{ id: 1, description: "banana" },
{ id: 2, description: "apple" },
{ id: 3, description: "pear" },
];
function getProductById(id: any) {
return exampleList.find((product) => product.id === id);
}
在前面的代码示例中,我们创建了一个表示产品的接口和一个表示产品列表的类型。然后我们创建了一个接收 any 类型 id 的函数,并在 Array 中搜索,从产品列表中返回一个项。
在这些简单的例子中,我们可以假设没有错误,但让我们创建一个将使用此片段的函数,看看会发生什么:
export function getProductTest() {
const id = '2';
const item = getProductById(id);
if (item !== undefined) {
console.log(`ID:${item.id} Description:${item.description}`);
} else {
console.log("No product found");
}
}
在这个例子中,项目未找到是因为我们传递的变量是一个 string。这可能发生在我们传递给函数的数据来自 API 或外部调用,并且数据格式不正确。
运行代码,我们得到以下结果:

图 3.5 – 由于 id 变量类型错误,函数返回“未找到产品”
当我们使用 any 类型时,我们放弃了类型检查的优势,这种类型的错误可能会出现在我们的应用程序中。但如何在不失去 TypeScript 类型检查的情况下拥有 any 类型的灵活性?
在这些情况下,我们使用 unknown 类型。这种类型与 any 类型具有相同的灵活性,但有一个细节:TypeScript 强制你在使用变量之前执行类型守卫。
让我们重构我们的示例函数:
function getProductById(id: unknown) {
if (typeof id === 'string'){
id = parseInt(id);
} else if (typeof id !== 'number'){
return
}
return exampleList.find((product) => product.id === id);
}
在这里,我们声明 id 将是 unknown 类型,紧接着我们在该变量中创建一个 guard 类型,处理变量可能是数字的可能情况。
any 类型仍将在你的应用程序中使用,但考虑使用 unknown 类型以确保当你不确定谁会调用你的函数时,可以正确处理类型。
摘要
在这一章中,我们看到了如何使用 TypeScript 以更少的努力创建更高质量的代码,从而提高我们的生产力。我们学习了基本 TypeScript 类型,如 number、string 和 Array。
我们还研究了创建类、接口和类型别名,以及我们如何选择和混合这些结构类型,使我们的代码更简洁、更易于维护。
最后,我们学习了 TypeScript 的类型推断机制以及我们如何使用类型守卫的概念来进一步改进类型检查机制。通过这些概念,我们还熟悉了 unknown 类型,它为 any 类型提供了一个更好的替代方案。
在下一章中,我们将学习 Angular 项目接口的基础知识,即组件。
第四章:组件和页面
Angular 应用程序的主要构建块是 组件。正是通过使用它们,我们组装用户界面并定义体验的流程。在 Angular 架构中,组件将应用程序组织成可重用的部分,使其易于维护和扩展。
在本章中,我们将探讨组件之间的通信,并使用组件组合来组装我们的页面,避免创建单体界面的反模式。
在本章中,我们将涵盖以下主题:
-
创建组件
-
组件之间的通信 – 输入和输出
-
最佳实践 – 使用
TrackBy属性 -
分离责任 – 智能组件和展示组件
-
子组件之间的通信 – 使用
@Output
到本章结束时,你将能够创建可重用且易于维护的组件和页面,从而简化项目开发并提高你和你团队的生产力。
技术要求
要遵循本章中的说明,你需要以下内容:
-
Visual Studio Code (
code.visualstudio.com/Download) -
Node.js 18 或更高版本 (
nodejs.org/en/download/)
本章的代码文件可在 github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch4 找到。
创建组件
使用 Angular 创建的每个接口都是框架架构中的一个组件;因此,从理论上讲,我们可以在单个组件中拥有我们的整个应用程序。
正如我们在 第二章 中所研究的,组织你的应用程序,最好将你的应用程序分成模块,并且通过组件,我们通过将我们的接口分成不同的组件并使用不同的组件来组合它们,来使用相同的推理,以最大化重用性和可维护性。
在本章中,我们将通过以下健身日记应用程序来展示这一点,如图所示 – 为了专注于 Angular,我们不会使用 Angular Material,只使用 HTML、CSS(在这种情况下,Tailwind CSS)和 TypeScript。

图 4.1 – 健身日记应用程序 UI
在这个初始示例中,我们创建了一个仅包含 HTML 模板、CSS 和 TypeScript 文件的组件。以下是页面的顶部内容:
<div class="min-h-screen bg-gray-200">
<header class="bg-blue-500 py-4 text-white">
<div class="mx-auto max-w-6xl px-4">
<h1 class="text-2xl font-bold">Workout diary</h1>
</div>
</header>
使用良好的 HTML 语义实践,让我们创建一个 main 部分:
<main class="mx-auto mt-8 max-w-6xl px-4">
<section class="mb-8">
<h2 class="mb-4 text-xl font-bold">List of entries</h2>
<ul class="rounded border shadow">
<li class="mb-4 border-b bg-white p-4">
<span class="font-bold">Date:</span> 2023-03-20<br />
<span class="font-bold">Exercise:</span> Bench press<br />
<span class="font-bold">Sets:</span> 3<br />
<span class="font-bold">Reps:</span> 10
</li>
<!-- more entries here -->
</ul>
</section>
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
>
Add new entry
</button>
</main>
</div>
我们可以看到,在前面的示例中,界面已经设计和样式化,但它不是功能性的,因为日记条目固定在 HTML 中,在我们的应用程序中,用户应该能够添加他们想要的任意数量的条目。
我们可以识别出,这个日记条目的这部分可能是一个页面可以使用的组件,所以让我们创建一个名为entry的组件。正如我们在第一章,“正确开始项目”中学到的,我们将使用 Angular CLI 在所需的模块中创建这个新组件:
ng g c diary/entry-item
使用这个命令,Angular CLI 将创建一个包含以下四个文件的新文件夹,并更新diary模块以包含新组件。
-
entry-item.component.css: 此文件将包含组件的样式表。Angular 成功地解决了 Web 应用的一个大痛点,即每个组件的 CSS 作用域。有了这个特性,我们可以在不担心是否会影响到应用程序的 CSS(即使使用相同的属性或选择器名称)的情况下指定组件的样式。 -
entry-item.component.html: 此文件包含组件的 HTML 模板,尽管扩展名似乎表明我们只能使用 HTML 标签,但在模板文件中,我们可以使用 Angular 指令,正如我们将在本章学习的。 -
entry-item.component.spec.ts: 此文件包含组件的单元测试,我们将在第十章“为测试而设计:最佳实践”中详细说明。 -
entry-item.component.ts: 这是代表组件本身的 TypeScript 文件。所有其他文件都是可选的,这使得你可以只使用这个文件来创建一个组件,尽管这并不是在 Angular 项目中广泛应用的实践,并且仅推荐用于非常小的组件。
在entry-item.component.ts文件中,Angular CLI 创建了以下结构:
import { Component } from '@angular/core';
@Component({
selector: 'app-entry-item',
templateUrl: './entry-item.component.html',
styleUrls: ['./entry-item.component.css']
})
export class EntryItemComponent {
}
通过这个示例,我们强化了组件是一个 TypeScript 类的定义,并通过使用@Component装饰器,我们向 Angular 指示了组装组件的部分在哪里。
主要属性如下:
-
selector: 这是一个可选属性,定义了如果组件在另一个组件的模板中使用时,其选择器将是什么。代表页面的组件不需要定义选择器,因为它们是从路由中实例化的。Angular CLI 根据在angular.json文件的prefix属性中定义的应用程序前缀以及你在ngg命令中定义的名称来建议选择器。 -
templateUrl: 这定义了包含组件模板的 HTML 文件的路径。或者,我们可以使用template属性来定义一个包含所有组件 HTML 的字符串。 -
styleUrls: 这定义了包含组件样式的 CSS 文件的路径。这个属性的细节是它是一个数组,因此可以链接多个 CSS 文件到组件。或者,我们可以使用style属性来定义一个包含组件 CSS 的字符串。
在entry-item.component.html文件中,我们将放置代表我们健身房日记中练习列表中一个条目的片段:
<div class="mb-4 border-b bg-white p-4">
<span class="font-bold">Date:</span> 2023-03-20<br />
<span class="font-bold">Exercise:</span> Bench press<br />
<span class="font-bold">Sets:</span> 3<br />
<span class="font-bold">Reps:</span> 10
</div>
在这里,我们有了一个条目的表示,区别在于我们使用 <div> 元素而不是 <li>,因为我们希望我们的组件尽可能可重用——它可能不一定在列表和 <ul> 元素中使用。
让我们把我们的组件用起来。在 diary.component 组件中,让我们按照以下方式重构 diary.component.html 文件:
<div class="min-h-screen bg-gray-200">
<header class="bg-blue-500 py-4 text-white">
<div class="mx-auto max-w-6xl px-4">
<h1 class="text-2xl font-bold">Workout diary</h1>
</div>
</header>
<main class="mx-auto mt-8 max-w-6xl px-4">
<section class="mb-8">
<h2 class="mb-4 text-xl font-bold">List of entries</h2>
<ul class="rounded border shadow">
<li>
<app-entry-item />
</li>
<li>
<app-entry-item />
</li>
<!-- more entries here -->
</ul>
</section>
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
>
Add new entry
</button>
</main>
</div>
使用 app-entry-item 选择器,我们在页面上使用了我们的新组件。从 Angular 的第 15 版开始,我们可以为组件使用自闭合标签,所以我们在这里使用了 <app-entry-item />,但如果你更喜欢以前的方式,<app-entry-item> <app-entry-item> 仍然有效。
运行我们的项目,我们可以看到它仍然在正常工作。然而,两个条目中的数据是相同的。我们现在需要一种在组件之间传递信息的方法,我们将在下一节中看到如何做到这一点。
组件之间的通信 – 输入和输出
在我们的健身房日记应用程序中,我们现在需要一个锻炼列表页面组件 DiaryComponent 与列表项组件 EntryItemComponent 进行通信。
实现这种通信的最简单方式是使用 Angular 的属性绑定概念。尽管名字听起来复杂,但实际上,我们通过在组件对象的属性上添加 @Input 注解来标注,这样 Angular 就会在组件上创建一个自定义的 HTML 属性。
让我们看看这个概念在实际中的应用;首先,让我们创建一个接口来表示我们日记中的一个条目:
ng g interface diary/interfaces/exercise-set
使用前面的命令,我们创建了文件,并且作为一个有组织的实践,我们创建了一个文件夹来存储模块的接口。在生成的文件中,我们将定义我们想要通信的对象:
export interface ExerciseSet {
id?: string;
date: Date;
exercise: string;
sets: number;
reps: number;
}
export type ExerciseSetList = Array<ExerciseSet>;
我们创建了一个接口来定义对象,并定义了一个类型来定义一系列锻炼,这提高了我们实现的可读性。
现在,在 entry-item.component.ts 文件中,让我们添加新的属性:
import { Component, Input } from '@angular/core';
import { ExerciseSet } from '../interfaces/exercise-set';
@Component({
selector: 'app-entry-item',
templateUrl: './entry-item.component.html',
styleUrls: ['./entry-item.component.css']
})
export class EntryItemComponent {
@Input('exercise-set') exerciseSet!:ExerciseSet;
}
在这里,我们创建了一个名为 exerciseSet 的属性,其类型为 ExerciseSet,这是我们刚刚定义的。我们在类型定义中使用 ! 符号,因为我们将在运行时定义其值。
@Input 注解接收 exercise-set 字符串作为参数。有了这个参数,我们定义了在模板中使用的自定义 HTML 属性的名称。这个参数是可选的;如果不使用它,属性名称将与属性名称相同。在这里,它将是 exerciseSet。
现在,让我们更改我们的模板以使用这个属性:
<div class="mb-4 border-b bg-white p-4">
<span class="font-bold">Date:</span> {{ exerciseSet.date | date }}<br />
<span class="font-bold">Exercise:</span> {{ exerciseSet.exercise }}<br />
<span class="font-bold">Sets:</span> {{ exerciseSet.sets }}<br />
<span class="font-bold">Reps:</span> {{ exerciseSet.reps }}
</div>
要在模板中使用组件的属性,我们使用 {{ }} 语法。在这里,我们可以看到启用 Angular 语言服务扩展的 VS Code 的一个优点,因为我们可以在 HTML 模板中进行类型检查,避免例如拼写错误等问题。
在这个例子中需要强调的是 Date 属性。在这里,我们使用了一个名为 pipe 的 Angular 功能,它允许格式化模板元素。在这种情况下,我们正在格式化日期。
现在,让我们在 diary.component.ts 文件中配置一个锻炼列表:
import { Component } from '@angular/core';
import { ExerciseSetList } from '../interfaces/exercise-set';
@Component({
templateUrl: './diary.component.html',
styleUrls: ['./diary.component.css'],
})
export class DiaryComponent {
exerciseList: ExerciseSetList = [
{ id: '1', date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
{ id: '2', date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
{ id: '3', date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
];
}
对于这个例子,我们创建一个名为 exerciseListExample 的属性,并用来自 ExerciseSet 接口的对象填充它。现在,让我们在 diary.component.html 文件中更改列表模板:
. . .
<section class="mb-8">
<h2 class="mb-4 text-xl font-bold">List of entries</h2>
<ul class="rounded border shadow">
<li *ngFor="let item of exerciseList">
<app-entry-item [exercise-set]="item" />
</li>
</ul>
</section>
. . .
在模板中,我们使用 ngFor 指令,它具有遍历列表并渲染我们在模板中想要定义的元素的功能。对于每个列表项,我们将创建一个新的 app-entry-item 组件,现在我们想要将它分配给它。
要做到这一点,我们使用 [exercise-set] 属性来传递 ngFor 提供的项目。当我们运行我们的项目时,我们会看到以下图所示的列表:

图 4.2 – 重构后的健身房日记应用程序 UI
通过这种方式,我们了解了如何从一个组件传递信息到另一个组件,但我们可以通过引入良好的性能实践,即 TrackBy 属性来改进这个项目。
最佳实践 – 使用 TrackBy 属性
在 *ngIf 指令之后,ngFor 指令很可能是你将在你的 Angular 项目中最常使用的指令。尽管简单,但这个指令可以隐藏前端可能发生的性能和感知问题,这些问题将影响你的用户。
为了演示这一点,让我们添加一个新的列表按钮,模拟来自后端的一个列表更新。
在 diary.component.ts 文件中,添加以下方法:
newList() {
this.exerciseList = [
{ id: '1', date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
{ id: '2', date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
{ id: '3', date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
{ id: '4', date: new Date(), exercise: 'Leg Press', reps: 15, sets: 3 },
];
}
此方法用这个新数组替换数组,该数组包含相同的元素,但多了一个项目。
让我们在列表模板中添加按钮:
<br>
<br>
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
(click)="newList()"
>
Server Sync
</button>
当我们点击 服务器同步 按钮时,整个项目列表都会被渲染,尽管新的列表与原始列表除了新增一个项目外完全相同。

图 4.3 – Chrome 开发者工具
对于少量项目,这可能不一定是问题,但对于较长的列表,这种不必要的渲染可能会冒犯用户对我们应用程序性能的感知。
为了改进这类情况,ngFor 指令有 TrackBy 选项。让我们重构我们的代码来展示这个选项;首先,让我们为练习列表组件创建一个方法:
itemTrackBy(index: number, item: ExerciseSet) {
return item.id;
}
这种方法告诉 Angular 如何识别它将遍历 *ngFor 指令的集合中的单个元素。把它想象成集合的 主键。
在组件的模板中,让我们更改 ngFor 配置:
<section class="mb-8">
<h2 class="mb-4 text-xl font-bold">List of entries</h2>
<ul class="rounded border shadow">
<li *ngFor="let item of exerciseList; index as i; trackBy: itemTrackBy">
<app-entry-item [exercise-set]="item" />
</li>
</ul>
</section>
在这里,我们告诉 ngFor 根据对象的 id 属性进行渲染。再次在浏览器中使用 Chrome DevTools 运行它,我们看到现在只有具有 id 属性的项目在页面上被渲染。
TrackBy 属性除了避免不必要的渲染外,还有以下优点:
-
在从集合中添加和删除项目时启用动画
-
当集合发生变化时,保留任何与 DOM 特定的 UI 状态,例如焦点和文本选择。
现在我们已经了解了ngFor属性的使用,让我们研究如何构建我们组件和页面的架构。
分离责任 – 智能和展示组件
单页应用(SPA)的信息流可能相当复杂,如果你在设计之初没有考虑这种流程,它可能会随着时间的推移影响你项目的生产力和质量。
简单为佳;因此,在 Angular 应用中以及一般单页应用中,一个非常常见的模式是使用智能组件和展示组件来组合接口。在文献和社区中,你也会找到这个模式被称为智能和愚笨组件或容器和展示组件。
智能组件具有 UI 业务规则;这是我们注入将与后端通信的服务的地方,以及与展示组件的接口将被组合的地方。
展示组件是一个仅用于展示智能组件通过输入传递的数据的组件。展示组件反过来可以包含一个或多个展示类型的组件。
为了说明这个模式,我们将使用以下图表:

图 4.4 – 智能和展示组件
注意,我们有一个真相来源,即智能组件,通信只在一个方向上发生,这就是我们所说的单向数据流。这个模式的目的是将组件内的所有状态隔离开来,从而简化状态管理。
让我们重构我们的项目以适应这个设计模式。让我们使用 Angular CLI 创建一个新的展示组件:
ng g c diary/list-entries
在这个新组件中,我们将把渲染日记条目列表的部分移动到模板中。在list-entries.component.html文件中,添加以下代码:
<section class="mb-8">
<h2 class="mb-4 text-xl font-bold">List of entries</h2>
<ul class="rounded border shadow">
<li *ngFor="let item of exerciseList; index as i; trackBy: itemTrackBy">
<app-entry-item [exercise-set]="item" />
</li>
</ul>
</section>
将要显示的列表将直接从DiaryComponent组件中准备好,因此,在list-entries.component.ts文件中,我们将添加以下代码:
import { Component, Input } from '@angular/core';
import { ExerciseSet, ExerciseSetList } from '../interfaces/exercise-set';
@Component({
selector: 'app-list-entries',
templateUrl: './list-entries.component.html',
styleUrls: ['./list-entries.component.css'],
})
export class ListEntriesComponent {
@Input() exerciseList!: ExerciseSetList;
itemTrackBy(index: number, item: ExerciseSet) {
return item.id;
}
}
在这里,我们将itemTrackBy函数移动到组件中,因为这将是其显示列表的功能,并且我们包含带有@Input装饰器的exerciseList属性。在这个例子中,我们没有指定任何参数,所以模板属性的名称将与exerciseList类的属性名称相同。
让我们在diary.component.html文件中更改Diary模板以使用我们创建的新展示组件:
<main class="mx-auto mt-8 max-w-6xl px-4">
<app-list-entries [exerciseList]="exerciseList" />
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
>
Add new entry
</button>
<br />
<br />
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
(click)="newList()"
>
erver Sync
</button>
</main>
DiaryComponent智能组件只是将列表传递给ListEntriesComponent展示组件,该组件通过调用EntryItemComponent展示组件来遍历列表。在这种结构下,只有DiaryComponent组件需要担心练习列表,遵循 SOLID 的单一职责概念。
我们已经学习了如何构建我们的页面和组件,但子组件如何与父组件通信呢?让我们接下来学习 Angular 组件的输出属性。
子组件的通信 - 使用@Output
我们学习了父组件,无论是智能组件还是展示组件,可以通过使用带有@Input装饰器的属性与子组件通信。
然而,当我们需要相反的情况时,子组件将一些信息传递给父组件。正如我们在上一节中看到的,业务规则处理理想情况下应该在智能组件中完成。对于这种类型的通信,我们使用@Output装饰器标记属性。
让我们为我们的日记创建一个添加条目的按钮。我们将在第六章中看到表单的使用,处理用户输入:表单,但在这里我们想要关注组件之间的交互。
使用 Angular CLI,我们将使用以下命令创建新组件:
ng g c diary/new-item-button
在新组件的模板中,让我们将日记按钮模板移动到组件中:
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
>
Add new entry
</button>
在new-item-button.component.ts文件中,我们将添加一个新的属性:
import { Component, EventEmitter, Output } from '@angular/core';
import { ExerciseSet } from '../interfaces/exercise-set';
@Component({
selector: 'app-new-item-button',
templateUrl: './new-item-button.component.html',
styleUrls: ['./new-item-button.component.css'],
})
export class NewItemButtonComponent {
@Output() newExerciseEvent = new EventEmitter<ExerciseSet>();
addNewExercise() {
const id = Date.now().toString();
const date = new Date();
const reps = 10;
const sets = 4;
const exercise = 'Leg Press';
const newExerciseSet: ExerciseSet = { id, date, reps, sets, exercise };
this.newExerciseEvent.emit(newExerciseSet);
}
}
在这里,我们首先创建newExerciseEvent属性,并添加@Output装饰器来定义它将在组件的模板中作为一个属性存在。
在这里,与@Input属性有所不同;在这种情况下,我们已经在变量中分配了一个EventEmitter类的对象。这个 Angular 类旨在在发生某些操作时发出事件。
这是因为,与@Input不同,其值在组件结构化和渲染时分配,@Output通信可以在任何时间发生,取决于用户的操作。
EventEmitter类利用 TypeScript 的类型检查功能,使我们能够确定我们将要向父组件发出的对象类型。
在addNewExercise方法中,我们创建一个ExerciseSet类型的对象,并使用EventEmitter类的emit方法将此对象传递给父组件。
回到模板 - 让我们在按钮的click动作中添加方法调用:
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
(click)="addNewExercise()"
>
Add new entry
</button>
现在,让我们重构DiaryComponent以使用新的按钮:
. . .
<main class="mx-auto mt-8 max-w-6xl px-4">
<app-list-entries [exerciseList]="exerciseList" />
<app-new-item-button (newExerciseEvent)="addExercise($event)" />
<br />
<br />
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
(click)="newList()"
>
Server Sync
</button>
</main>
. . .
在模板中,我们使用app-new-item-button组件将addExercise函数传递给newExerciseEvent属性。
在这里,我们可以强调,@Output属性的绑定必须使用括号——( )——并且这个$event参数代表子组件将要发出的对象。如果你在 VS Code 中突出显示此参数,我们可以验证它属于ExerciseSet类型。
最后,让我们在组件中创建addExercise方法:
. . .
addExercise(newSet: ExerciseSet) {
this.exerciseList.push(newSet);
}
. . .
我们的方法接收发出的值并将其添加到exercises数组中。运行我们的项目,我们可以看到项目已成功添加。
在这个例子中,我们可以看到设计模式中智能和展示组件的整个流程的实际应用。当点击Diary智能组件时,它会从NewItemButtonComponent展示组件接收新的练习。
通过更新列表,列表会自动传递到ListEntriesComponent组件,该组件在屏幕上渲染列表。现在我们将实现列表中练习项的动作——我们将看到如何发出这些项的事件以及如何识别这些元素。
从嵌套组件传播事件
我们将在日记中添加删除列表项和增加重复次数的选项。首先,让我们向列表项模板中添加按钮。在entry-item.component.html文件中,我们将编辑模板:
<div class="mb-4 flex items-center justify-between border-b bg-white p-4">
<div>
<span class="font-bold">Date:</span> {{ exerciseSet.date | date }}<br />
<span class="font-bold">Exercise:</span> {{ exerciseSet.exercise }}<br />
<span class="font-bold">Sets:</span> {{ exerciseSet.sets }}<br />
<span class="font-bold">Reps:</span> {{ exerciseSet.reps }}
</div>
<div class="flex items-center">
<button
class="mr-2 rounded bg-red-500 py-2 px-4 font-bold text-white hover:bg-red-700"
>
Delete
</button>
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
>
New Rep
</button>
</div>
</div>
这里的挑战是确保正确识别列表中每个项目的动作,以便正确应用——也就是说,处理列表的Diary智能组件将找到相应的项目并更改它。
为了做到这一点,我们将应用 Angular 输出功能到项目组件:
@Output() newRepEvent = new EventEmitter<ExerciseSet>();
@Output() deleteEvent = new EventEmitter<string>();
delete() {
this.deleteEvent.emit(this.exerciseSet.id);
}
newRep() {
const reps = ++this.exerciseSet.reps;
const newItem: ExerciseSet = {
...this.exerciseSet,
reps,
};
this.newRepEvent.emit(newItem);
}
我们创建两个输出,每个输出对应我们想要发出的不同事件,并且我们输入它们,因为我们需要不同的动作。
然后我们创建delete方法,该方法将发出我们想要删除的项目id值,以及newRep方法,我们将使用它为将要执行的运动项目添加重复次数并发出该项目。
我们将回到模板,将方法与创建的按钮关联:
<button
class="mr-2 rounded bg-red-500 py-2 px-4 font-bold text-white hover:bg-red-700"
(click)="delete()"
>
Delete
</button>
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
(click)="newRep()"
>
New Rep
</button>
现在,让我们更改list-entries.component展示组件以创建输出,这里为了简单起见,它将具有与项目输出相同的名称:
export class ListEntriesComponent {
@Input() exerciseList!: ExerciseSetList;
@Output() newRepEvent = new EventEmitter<ExerciseSet>();
@Output() deleteEvent = new EventEmitter<string>();
. . .
}
为了传播项目事件,我们将更改列表模板:
<li *ngFor="let item of exerciseList; index as i; trackBy: itemTrackBy">
<app-entry-item
[exercise-set]="item"
(deleteEvent)="deleteEvent.emit($event)"
(newRepEvent)="newRepEvent.emit($event)"
/>
</li>
我们可以看到我们只使用输出的emit方法发出项目事件。
最后,我们将重构DiaryComponent智能组件以响应项目事件。首先,让我们看看模板:
<main class="mx-auto mt-8 max-w-6xl px-4">
<app-list-entries
[exerciseList]="exerciseList"
(deleteEvent)="deleteItem($event)"
(newRepEvent)="newRep($event)"
/>
. . .
</main>
如前例所示,我们使用括号将其与一个方法关联,该方法将处理事件并使用$event变量接收该方法参数发出的元素。
我们现在将通过创建两个新方法来重构组件——一个用于删除日记条目,另一个用于为练习创建新的重复项:
. . .
deleteItem(id: string) {
this.exerciseList = this.exerciseList.filter((item) => item.id !== id);
}
newRep(exerciseSet: ExerciseSet) {
const id = exerciseSet.id;
const i = this.exerciseList.findIndex((item) => item.id === id);
if (i >= 0) {
this.exerciseList[i] = { ...exerciseSet };
}
}
. . .
我们使用 TypeScript 数组方法来模拟删除和更改项目数组。我们可以看到,由于 Angular 的事件发射机制,该方法已经自动接收删除项或 id。
我们在这里利用智能和展示组件模式,以适应稍微复杂一些的需求。
摘要
在本章中,我们研究了负责渲染我们项目界面的元素,即组件。我们看到了如何以细粒度创建和组织组件,从而使我们的项目更具可维护性。
我们还研究了如何使用@Input和@Output属性在组件之间进行通信,利用 Angular 提供的促进这种通信的能力。
我们看到了使用TrackBy在模板中通过ngFor指令迭代列表的良好实践,这特别提高了具有许多项目的列表的性能。
最后,我们研究了智能和展示组件的设计模式,这是一种组织组件及其交互的方式,以便通过单向信息流简化这种编排。
在下一章中,我们将研究 Angular 中负责业务规则和与后端交互的元素——服务。
第五章:Angular 服务和单例模式
静态网页和单页应用之间的一大区别是用户浏览器中的处理能力和交互,给人一种在设备上安装了应用程序的感觉。在 Angular 框架中,进行这种处理和交互的元素,不仅与后端,而且与用户,是服务。
这个元素对 Angular 来说非常重要,以至于团队创建了一个依赖管理系统,它允许以简化的方式在组件中创建、组合和使用服务。
在本章中,我们将探讨这个元素,了解它使用的模式以及在你的项目中应遵循的最佳实践。
在这里,我们将涵盖以下主题:
-
创建服务
-
理解依赖注入模式
-
使用服务在组件之间进行通信
-
消费 REST API
到本章结束时,你将能够创建可重用和可维护的服务,同时了解将提高你生产力的实践。
技术要求
要遵循本章的说明,你需要以下内容:
-
Visual Studio Code (
code.visualstudio.com/Download) -
Node.js 18 或更高版本 (
nodejs.org/en/download/)
本章的代码文件可在github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch5找到。
创建服务
Angular 中的服务是 TypeScript 类,旨在实现我们接口的业务逻辑。在前端项目中,业务逻辑可能是一个有争议的问题,因为理想情况下,所有逻辑和处理都应该在后台进行,这是正确的。
这里我们使用的是业务规则;这些规则是通用的行为,不依赖于视觉组件,可以在其他组件中重用。
前端业务规则的例子可能如下所示:
-
应用状态控制
-
与后端的通信
-
使用固定规则(如电话号码中的数字数量)进行信息验证
我们将把这个概念付诸实践,并在我们的健身房日记应用程序中创建第一个服务。在命令行中,我们将使用 Angular CLI:
ng generate service diary/services/ExerciseSets
与组件不同,我们可以看到 Angular CLI 创建的元素仅由一个 TypeScript 文件(及其相应的单元测试文件)组成。
在这个文件中,我们将看到 Angular CLI 生成的样板代码:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ExerciseSetsService {
constructor() { }
}
在这里,我们有一个名为ExerciseSetsService的 TypeScript 类,它有一个名为@Injectable的装饰器。正是这个装饰器定义了 Angular 中的服务;我们将在本章后面了解更多关于它的细节。
让我们重构我们的项目,并将日记的初始系列设置放在这个服务中。
首先,我们将创建获取初始列表并在后端刷新它的方法:
private setList?: ExerciseSetList;
getInitialList(): ExerciseSetList {
this.setList = [
{ id: 1, date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
{ id: 2, date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
{ id: 3, date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
];
return this.setList;
}
refreshList(): ExerciseSetList {
this.setList = [
{ id: 1, date: new Date(), exercise: 'Deadlift', reps: 15, sets: 3 },
{ id: 2, date: new Date(), exercise: 'Squat', reps: 15, sets: 3 },
{ id: 3, date: new Date(), exercise: 'Barbell row', reps: 15, sets: 3 },
{ id: 4, date: new Date(), exercise: 'Leg Press', reps: 15, sets: 3 },
];
return this.setList;
}
在服务中,我们将日记组件的初始化和刷新操作移动到服务中,使用 getInitialList 和 refreshList 方法。
当我们看到与后端的通信时,这些方法将得到改进,但在这里,我们已经在将管理练习列表的业务规则从渲染用户界面的组件中解耦,创建了一个特定的服务。
现在让我们考虑向练习列表添加项的方法:
addNewItem(item: ExerciseSet): ExerciseSetList {
if (this.setList) {
this.setList = [...this.setList, item];
} else {
this.setList = [item];
}
return this.setList;
}
服务的 setList 属性可以是 null,因此在这里我们使用 TypeScript 类型守卫概念(更多详情见 第三章,Angular 的 TypeScript 模式)来操作数组。在这里,我们也使用不可变性的概念,在添加新元素后返回一个新的数组。
在 DiaryComponent 组件中,我们将使用我们创建的服务:
export class DiaryComponent {
constructor(private exerciseSetsService: ExerciseSetsService) {}
exerciseList = this.exerciseSetsService.getInitialList();
newList() {
this.exerciseList = this.exerciseSetsService.refreshList();
}
addExercise(newSet: ExerciseSet) {
this.exerciseList = this.exerciseSetsService.addNewItem(newSet);
}
}
在组件中,我们首先可以观察到的是类构造函数的使用,声明了一个类型为 ExerciseSetsService 的私有属性 exerciseSetsService。通过这个声明,我们实例化了一个对象,并重构了我们的组件,用服务方法替换了列表的初始化和刷新操作。
从现在起,组件不再关心如何获取和管理练习列表;这是服务的责任,如果需要,我们现在可以在其他组件中使用这个服务。在这段代码中,你可能想知道为什么我们使用了 ExerciseSetsService 服务,如果我们没有实例化该类的对象。
这里,Angular 有一个很好的特性,即依赖注入机制,我们将在下一节深入探讨这个话题。
理解依赖注入模式
在面向对象的软件开发中,优先考虑组合而非继承是一个好的实践,这意味着一个类应该由其他类(最好是接口)组成。
在我们之前的例子中,我们可以看到 service 类包含了 DiaryComponent 组件。另一种使用此服务的方法如下:
. . .
export class DiaryComponent {
private exerciseSetsService: ExerciseSetsService;
exerciseList: ExerciseSetList;
constructor() {
this.exerciseSetsService = new ExerciseSetsService();
this.exerciseList = this.exerciseSetsService.getInitialList();
}
. . .
}
在这里,我们修改我们的代码,明确地将服务类对象的创建留在了组件的构造函数方法中。再次运行我们的代码,我们可以看到界面保持不变。
这种方法虽然功能齐全,但存在一些问题,例如以下内容:
-
组件和服务之间的高耦合,这意味着如果我们需要更改服务的实现,例如构建单元测试,我们可能会遇到问题。
-
如果服务依赖于另一个类,正如我们将要在 Angular 的 HTTP 请求服务
HttpClient类中看到的那样,我们将在我们的组件中实现这个依赖,从而增加其复杂性。
为了简化开发并解决我们所描述的问题,Angular 有一个依赖注入机制。这个特性允许我们仅通过在构造函数中声明所需的对象来组合一个类。
Angular 利用 TypeScript,将使用在此声明中定义的类型来组装我们所需的类的依赖树,并创建所需的对象。
让我们回到我们的代码,分析这个机制是如何工作的:
. . .
export class DiaryComponent {
constructor(private exerciseSetsService: ExerciseSetsService) {}
exerciseList = this.exerciseSetsService.getInitialList();
. . .
}
在代码中,我们在构造函数中声明了我们类的依赖,创建了exerciseSetsService属性。有了这个,我们就可以在它的声明中初始化exerciseList属性。
在第十章,为测试而设计:最佳实践中,我们将替换测试运行时中此服务的实现。所有这一切都得益于 Angular 的依赖注入功能。
从 Angular 的 14 版开始,我们有了一个依赖注入的替代方案,我们将在下一节中看到。
使用 inject()函数
inject()函数允许你以更简单的方式使用相同的依赖注入功能。
让我们重构我们的组件代码:
import { Component, inject } from '@angular/core';
import { ExerciseSet } from '../interfaces/exercise-set';
import { ExerciseSetsService } from '../services/exercise-sets.service';
. . .
export class DiaryComponent {
private exerciseSetsService = inject(ExerciseSetsService);
exerciseList = this.exerciseSetsService.getInitialList();
. . .
}
在这里,我们移除了依赖注入的构造函数声明,并直接声明了exerciseSetsService服务。对于对象的创建,我们使用inject函数。
需要注意的是,我们使用的是@angular/core模块中的inject函数,而不是@angular/core/testing模块中存在的函数,后者将用于其他目的。
这种方法,除了更简单、更清晰(服务是通过函数注入的)之外,如果需要为特定组件使用继承,还可以简化开发。记住,良好的实践建议我们应优先选择组合而非继承,但在库中,这个特性可能很有趣。
关于inject函数的一个需要注意的点是其只能在组件的构造阶段使用,即在方法的属性声明或类的构造方法中。
在其他上下文中的任何使用都将生成以下编译错误:
inject() must be called from an injection context
such as a constructor, a factory function, a field initializer,
or a function used with `runInInjectionContext`.
现在,让我们深入探讨 Angular 服务的另一个方面,即单例设计模式的使用,以及我们如何利用这种能力在组件之间进行通信。
使用服务进行组件间的通信
关于 Angular 服务,我们必须理解的一个特点是,默认情况下,由依赖注入机制实例化的每个服务都有相同的引用;也就是说,不会创建新的对象,而是重用。
这是因为依赖注入机制实现了单例设计模式来创建和传递对象。单例模式是一种创建型设计模式,允许创建在系统中具有全局访问权限的对象。
这个特性对于服务很重要,因为服务处理可重用的业务规则,我们可以在组件之间使用相同的实例,而无需重建整个对象。此外,我们可以利用这个特性,将服务用作组件之间通信的替代方案。
让我们修改我们的健身房日记,使ListEntriesComponent组件通过服务而不是@Input接收初始列表:
export class ListEntriesComponent {
private exerciseSetsService = inject(ExerciseSetsService);
exerciseList = this.exerciseSetsService.getInitialList();
itemTrackBy(index: number, item: ExerciseSet) {
return item.id;
}
}
在DiaryComponent组件中,我们将从输入中删除列表:
<main class="mx-auto mt-8 max-w-6xl px-4">
<app-list-entries />
<app-new-item-button (newExerciseEvent)="addExercise($event)" />
<br />
<br />
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
(click)="newList()"
>
Server Sync
</button>
</main>
再次运行它,我们可以看到列表继续出现。这是因为两个组件中使用的服务实例是相同的。然而,这种通信形式需要我们使用 RxJS 通过日记屏幕上的按钮来更新值。我们将在第九章中更深入地探讨这个主题,使用 RxJS 探索反应性。
我们看到,默认情况下,服务是单例的,但在 Angular 中,如果需要解决应用程序中的某些边缘情况,可以更改此配置以用于其他服务。
当我们创建一个服务时,它有一个@Injectable装饰器,就像我们的例子一样:
@Injectable({
providedIn: 'root',
})
export class ExerciseSetsService {
provideIn元数据决定了服务的范围。值'root'表示每个应用程序都将有一个唯一的服务实例;这就是为什么默认情况下,Angular 服务是单例的。
要更改此行为,让我们首先回到ListEntriesComponent组件以接收@Input:
export class ListEntriesComponent {
@Input() exerciseList!: ExerciseSetList;
itemTrackBy(index: number, item: ExerciseSet) {
return item.id;
}
}
让我们回到DiaryComponent组件中通知属性:
<main class="mx-auto mt-8 max-w-6xl px-4">
<app-list-entries [exerciseList]="exerciseList" />
<app-new-item-button (newExerciseEvent)="addExercise($event)" />
<br />
<br />
<button
class="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
(click)="newList()"
>
Server Sync
</button>
</main>
在ExerciseSetsService服务中,我们将删除provideIn元数据:
@Injectable()
export class ExerciseSetsService {
如果我们现在运行我们的应用程序,将发生以下错误:
ERROR Error: Uncaught (in promise): NullInjectorError: R3InjectorError(DiaryModule)[ExerciseSetsService -> ExerciseSetsService -> ExerciseSetsService -> ExerciseSetsService]: NullInjectorError: No provider for ExerciseSetsService!
这个错误发生在我们通知 Angular 服务不应该在应用程序范围内实例化时。为了解决这个问题,让我们直接在DiaryComponent组件中声明对服务的使用:
@Component({
templateUrl: './diary.component.html',
styleUrls: ['./diary.component.css'],
providers: [ExerciseSetsService],
})
export class DiaryComponent {
因此,我们的系统再次工作,并且组件有自己的服务实例。
然而,这种技术必须在特定情况下使用,其中组件必须使用它自己的服务实例;建议在服务中保留provideIn。
现在,让我们开始使用 Angular 探索我们的应用程序与后端之间的通信。
REST API 消费
毫无疑问,Angular 服务的主要用途之一是与应用程序的后端通信,使用表示状态传输(REST)协议。
让我们通过准备我们的项目以使用其后端来实际了解这个功能。
首先,让我们通过访问gym-diary-backend文件夹并在您的命令行提示符中使用以下命令来本地上传后端:
npm start
我们可以保留这个命令运行,并现在可以创建用于消费 API 的服务。
为了执行这种消费,Angular 有一个专门的服务——HttpClient。要使用它,我们首先将其模块导入到app.module.ts文件中:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { HttpClientModule } from '@angular/common/http';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, AppRoutingModule, HttpClientModule],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
我们项目的后端 API 返回一些 JSON,包含当天的练习列表。作为良好的实践,我们应该创建一个界面来简化我们在前端应用程序中输入和操作结果。在exercise-set.ts文件中,我们将添加以下接口:
export interface ExerciseSetListAPI {
hasNext: boolean;
items: ExerciseSetList;
}
现在我们可以重构我们的ExerciseSetsService服务以使用HttpClient:
export class ExerciseSetsService {
private httpClient = inject(HttpClient);
private url = 'http://localhost:3000/diary';
getInitialList(): Observable<ExerciseSetListAPI> {
return this.httpClient.get<ExerciseSetListAPI>(this.url);
}
refreshList(): Observable<ExerciseSetListAPI> {
return this.httpClient.get<ExerciseSetListAPI>(this.url);
}
}
首先,我们使用inject函数将HttpClient服务注入到我们的类中。然后我们创建url变量来包含该服务将用于其方法的端点。
最后,我们将getInitialList和refreshList方法重构为消费项目的 API。最初,它们有相同的实现,但我们将在整个书中改进这段代码。
进行了一个重要的更改,使得该方法不返回练习列表,而是一个包含练习列表的 Observable。这是因为涉及消费 REST API 的操作是异步的,通过使用 RxJS 及其 Observables,Angular 处理这种异步性。我们将在第九章中更深入地探讨这个主题,使用 RxJS 探索反应性。
使用HttpClient服务消费GET 类型API,我们声明由ExerciseSetListAPI类型表示的返回类型和服务的get方法,将我们要消费的端点的 URL 作为参数传递。
现在我们添加其他方法以完善我们的服务:
addNewItem(item: ExerciseSet): Observable<ExerciseSet> {
return this.httpClient.post<ExerciseSet>(this.url, item);
}
updateItem(id: string, item: ExerciseSet): Observable<ExerciseSet> {
return this.httpClient.put<ExerciseSet>(`${this.url}/${id}`, item);
deleteItem(id: string): Observable<boolean> {
return this.httpClient.delete<boolean>(`${this.url}/${id}`);
}
}
对于包含一个新的集合,我们使用服务中的POST方法,该方法使用同名的动词调用 API。我们始终传递 URL,在这种情况下,请求正文将是新的练习集合。
要更新集合,我们使用带有正文的PUT方法,并使用字符串插值传递 API 合同中要求的id值。最后,为了删除,我们使用DELETE方法,并使用插值传递我们想要删除的元素的id值。
让我们调整我们的DiaryComponent组件以消费重构后的服务。我们的挑战是如何处理通过 HTTP 请求消费 REST API 的异步性。
首先,让我们调整练习列表的初始化:
@Component({
templateUrl: './diary.component.html',
styleUrls: ['./diary.component.css'],
})
export class DiaryComponent implements OnInit {
private exerciseSetsService = inject(ExerciseSetsService);
exerciseList!: ExerciseSetList;
ngOnInit(): void {
this.exerciseSetsService
.getInitialList()
.subscribe((dataApi) => (this.exerciseList = dataApi.items));
}
}
在DiaryComponent类中,我们将实现OnInit接口并创建onInit方法。这是 Angular 组件的生命周期事件之一,这意味着 Angular 将在构建和渲染界面时在某个时刻调用它。
onInit方法在构建组件后、渲染组件之前被调用。我们需要实现这个方法,因为练习列表的填充将异步发生。在onInit方法中实现这个初始化将确保当 Angular 开始渲染屏幕时数据已经存在。
在这个方法中,我们正在使用该服务,但由于它现在返回一个 Observable,我们需要调用 subscribe 方法,并在其中实现列表的初始化。由于我们正在使用智能和展示组件架构,我们可以在 DiaryComponent 智能组件中实现按钮方法如下:
newList() {
this.exerciseSetsService
.refreshList()
.subscribe((dataApi) => (this.exerciseList = dataApi.items));
}
addExercise(newSet: ExerciseSet) {
this.exerciseSetsService
.addNewItem(newSet)
.subscribe((_) => this.newList());
}
deleteItem(id: string) {
this.exerciseSetsService.deleteItem(id).subscribe(() => {
this.exerciseList = this.exerciseList.filter(
(exerciseSet) => exerciseSet.id !== id
);
});
}
newRep(updateSet: ExerciseSet) {
const id = updateSet.id ?? '';
this.exerciseSetsService
.updateItem(id, updateSet)
.subscribe();
}
在 newList 方法中,我们将它重构为通过 refreshList 方法获取列表元素。
在 addExercise、deleteItem 和 newRep 方法中,我们将之前的逻辑重构为使用 exerciseSetsService 服务。
摘要
在本章中,我们学习了 Angular 服务以及如何以简单和可重用的方式从我们的应用程序中正确隔离业务规则,以及 Angular 服务如何使用单例模式进行内存和性能优化。
我们与 Angular 的依赖注入机制进行了合作并研究,注意到能够组织和重用组件和其他服务之间的服务是多么重要。我们还学习了如何使用 inject 函数作为 Angular 服务的替代,以通过 Angular 的构造函数进行依赖注入。
最后,我们与服务的其中一个主要用途——与后端通信——进行了合作,并在本章中,我们开始探索将我们的前端应用程序与后端集成的过程。
在下一章中,我们将研究使用表单的最佳实践,这是用户将信息输入到我们系统中的主要方式。
第二部分:利用 Angular 的功能
在本部分中,你将使用 Angular 的更高级功能,并了解你如何使用此框架的常见任务。你将了解表单的最佳实践,如何正确使用 Angular 的路由机制,以及最后如何使用拦截器设计模式和 RxJS 库优化 API 消费。
本部分包含以下章节:
-
第六章**,处理用户输入:表单
-
第七章**,路由和路由器
-
第八章**,改进后端集成:拦截器模式
-
第九章**,使用 RXJS 探索响应性
第六章:处理用户输入:表单
自从 Web 应用程序的早期以来,在<form>标签的概念被用来创建、组织和将表单发送到后端之前。
在常见的应用程序中,例如银行系统和健康应用程序,我们使用表单来组织用户需要在我们的系统中执行的操作。由于 Web 应用程序中这样一个常见的元素,Angular 这样的框架,其哲学是“内置电池”,自然为开发者提供了这一功能。
在本章中,我们将深入探讨 Angular 中的以下表单功能:
-
模板驱动表单
-
响应式表单
-
数据验证
-
自定义验证
-
打字响应式表单
到本章结束时,您将能够为您的用户创建可维护且流畅的表单,同时通过此类任务提高您的生产力。
技术要求
要遵循本章中的说明,您需要以下内容:
-
Visual Studio Code (
code.visualstudio.com/Download) -
Node.js 18 或更高版本(
nodejs.org/en/download/)
本章的代码文件可在github.com/PacktPublishing/Angular-Design-Patterns-and-Best-Practices/tree/main/ch6找到。
在学习本章内容时,请记住使用npm start命令运行位于gym-diary-backend文件夹中的应用程序的后端。
模板驱动表单
Angular 有两种不同的方式处理表单:模板驱动和响应式。首先,让我们探索模板驱动表单。正如其名所示,我们最大限度地利用 HTML 模板的能力来创建和管理与表单关联的数据模型。
我们将演进我们的健身日记应用程序,以更好地说明这一概念。在以下命令行中,我们使用 Angular CLI 创建新的页面组件:
ng g c diary/new-entry-form-template
要访问新的分配表单,我们将重构日记页面组件,使添加新条目按钮将用户带到我们创建的组件。
让我们在DiaryModule模块中添加对负责管理应用程序路由的框架模块的导入:
. . .
import { RouterModule } from '@angular/router';
@NgModule({
declarations: [
DiaryComponent,
EntryItemComponent,
ListEntriesComponent,
NewItemButtonComponent,
NewEntryFormTemplateComponent,
],
imports: [CommonModule, DiaryRoutingModule, RouterModule],
})
export class DiaryModule {}
导入RouterModule模块后,我们将能够使用 Angular 的路由服务。有关路由的更多详细信息,请参阅第七章,路由和路由器。我们将在DiaryRoutingModule模块中添加新组件到路由:
. . .
import { NewEntryFormTemplateComponent } from './new-entry-form-template/new-entry-form-template.component';
const routes: Routes = [
{
path: '',
component: DiaryComponent,
},
{
path: 'new-template',
component: NewEntryFormTemplateComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DiaryRoutingModule {}
为了能够比较两种表单创建方法,我们将为将要创建的每个示例组件创建一个路由。在这里,URL /home/new-template将引导我们到模板驱动表单路由。
现在,我们将重构DiaryComponent以修改添加新条目按钮的行为:
. . .
import { Router } from '@angular/router';
@Component({
templateUrl: './diary.component.html',
styleUrls: ['./diary.component.css'],
})
export class DiaryComponent implements OnInit {
private exerciseSetsService = inject(ExerciseSetsService);
private router = inject(Router)
. . .
addExercise(newSet: ExerciseSet) {
this.router.navigate(['/home/new-template'])
}
. . .
}
首先,我们需要注入 Angular 的路由服务。我们将addExercise方法改为使用该服务,并使用navigate方法导航到页面。
我们可以继续在 new-entry-form-template.component.html 文件中的表单 HTML 模板,并仅放置表单的元素:
<div class="flex h-screen items-center justify-center bg-gray-200">
<form class="mx-auto max-w-sm rounded bg-gray-200 p-4">
. . .
<input
type="date"
id="date"
name="date"
/>
. . .
<input
type="text"
id="exercise"
name="exercise"
/>
. . .
<input
type="number"
id="sets"
name="sets"
/>
</div>
<input
type="number"
id="reps"
name="reps"
/>
</div>
<div class="flex items-center justify-center">
<button
type="submit"
>
Add Entry
</button>
...
Angular 使用 HTML 最佳实践,因此我们现在将在 HTML <form> 标签下创建表单字段。在输入字段中,我们尊重 HTML 语义,并创建与客户端所需信息类型正确的 <input> 字段。
让我们使用 ng serve 命令运行我们的应用程序。通过点击 新条目 按钮,我们将能够注意到我们的日记条目添加表单。

图 6.1 – 健身日记表单 UI
这里,我们有表单的结构和模板。现在,我们将准备让 Angular 通过模板中的用户输入来管理字段的状态。要使用模板驱动表单,我们需要将 FormModule 模块导入到我们的功能模块 DiaryModule 中:
import { FormsModule } from '@angular/forms';
@NgModule({
declarations: [
DiaryComponent,
EntryItemComponent,
ListEntriesComponent,
NewItemButtonComponent,
NewEntryFormTemplateComponent,
],
imports: [CommonModule, DiaryRoutingModule, RouterModule, FormsModule],
})
export class DiaryModule {}
在我们的表单模板中,我们将添加创建和链接表单信息到其数据模型的指令:
. . .
<form
(ngSubmit)="newEntry()"
class="mx-auto max-w-sm rounded bg-gray-200 p-4">
<div class="mb-4">
. . .
<input type="date" id="date" name="date"
. . .
[(ngModel)]="entry.date"
/>
</div>
<div class="mb-4">
. . .
<input type="text" id="exercise" name="exercise"
[(ngModel)]="entry.exercise"
. . . />
</div>
<div class="mb-4">
. . .
<input type="number" id="sets" name="sets" [(ngModel)]="entry.sets"
. . ./>
</div>
<div class="mb-4">
. . .
<input type="number" id="reps" name="reps" [(ngModel)]="entry.reps"
. . ./>
. . .
</form>
</div>
ngSubmit parameter to state which method will be called by Angular when the user submits the form. Then, we link the HTML input elements with the data model that will represent the form. We do this through the [(ngModel)] directive.
`ngModel` is an object managed by the `FormModule` module that represents the form’s data model. The use of square brackets and parentheses signals to Angular that we are performing a two-way data binding on the property.
This means that the `ngModel` property will both receive the `form` property and emit events. Finally, for development and debugging purposes, we are placing the content of the entry object in the footer and formatting it with the JSON pipe.
Let’s finish the form by changing the component’s TypeScript file:
export class NewEntryFormTemplateComponent {
private exerciseSetsService = inject(ExerciseSetsService);
private router = inject(Router);
entry: ExerciseSet = { date: new Date(), exercise: '', reps: 0, sets: 0 };
newEntry() {
const newEntry = { ...this.entry };
this.exerciseSetsService
.addNewItem(newEntry)
.subscribe((entry) => this.router.navigate(['/home']));
}
}
First, we inject the `ExerciseSetsService` service for the backend communication and the router service because we want to return to the diary as soon as the user creates a new entry.
Soon after we create the entry object that represents the form’s data model, it is important that we start it with an empty object because Angular makes the binding as soon as the form is loaded. Finally, we create the `newEntry` method, which will send the form data to the backend through the `ExerciseSetsService` service.
For more details about Angular services, see *Chapter 5*, *Angular Services and the Singleton Pattern*. If we run our project and fill in the data, we can see that we are back to the diary screen with the new entry in it.
Notice that at no point did we need to interact with the entry object, as Angular’s form template engine took care of that for us! This type of form can be used for simpler situations, but now we will see the way recommended by the Angular team to create all types of forms: reactive forms!
Reactive forms
Reactive forms use a declarative and explicit approach to creating and manipulating form data. Let’s put this concept into practice by creating a new form for our project.
First, on the command line, let’s use the Angular CLI to generate the new component:
ng g c diary/new-entry-form-reactive
In the same way as we did with the template-driven form, let’s add this new component to the `DiaryRoutingModule` routing module:
import { NewEntryFormReactiveComponent } from './new-entry-form-reactive/new-entry-form-reactive.component';
const routes: Routes = [
{
path: '',
component: DiaryComponent,
},
{
path: 'new-template',
component: NewEntryFormTemplateComponent,
},
{
path: 'new-reactive',
component: NewEntryFormReactiveComponent,
},
];
In the `DiaryModule` module, we need to add the `ReactiveFormsModule` module responsible for all the functionality that Angular makes available to us for this type of form:
@NgModule({
declarations: [
. . .
],
imports: [
. . .
ReactiveFormsModule,
],
})
To finalize the component’s route, let’s change the main screen of our application, replacing the route that the **New Entry** button will call:
addExercise(newSet: ExerciseSet) {
this.router.navigate(['/home/new-reactive']);
}
We will now start creating the reactive form. First, let’s configure the component elements in the `new-entry-form-reactive.component.ts` TypeScript file:
export class NewEntryFormReactiveComponent implements OnInit {
public entryForm!: FormGroup;
private formBuilder = inject(FormBuilder);
ngOnInit() {
this.entryForm = this.formBuilder.group({
date: [''],
exercise: [''],
sets: [''],
reps: [''],
});
}
}
Note that the first attribute is `entryForm` of type `FormGroup`. It will represent our form—not just the data model, but the whole form—as validations, field structure, and so on.
Then, we inject the `FormBuilder` service responsible for assembling the `entryForm` object. Note the name of the service that Angular uses from the `Builder` design pattern, which has the objective of creating complex objects, such as a reactive form.
To initialize the `entryForm` attribute, we’ll use the `onInit` component lifecycle hook. Here, we’ll use the `group` method to define the form’s data model. This method receives the object, and each attribute receives an array that contains the characteristics of that attribute in the form. The first element of the array is the initial value of the attribute.
In the component’s template, we will create the structure of the form, which, in relation to the template-driven form example, is very similar:


浙公网安备 33010602011771号