首页 > 其他分享 >构建-Angular-离线应用-全-

构建-Angular-离线应用-全-

时间:2024-10-05 17:15:32浏览次数:1  
标签:IndexedDB 服务 创建 离线 构建 应用 组件 Angular

构建 Angular 离线应用(全)

原文:Building Offline Applications with Angular

协议:CC BY-NC-SA 4.0

一、构建现代 Web 应用

欢迎光临!恭喜你选择了这本书来学习如何用 Angular 构建离线应用。这一介绍性的章节为这本书设定了期望和框架。它简要介绍了传统的 web 应用开发,以及为什么仅仅创建另一个传统的 web 应用是不够的。

奠定基础

这本书提供了一些观点,并详细阐述了服务工作器和索引数据库等前沿功能。它提供了创建 Angular 应用的分步说明,并逐步添加特性来构建复杂的 web 应用。这本书使用了一个虚构的在线游戏系统 Web Arcade 来说明这些技术。它充当构建现代 web 应用的用例,该应用对网络连接性下降和速度变化具有弹性。

让我们用一点历史来建立背景。您可能知道,web 应用已经流行了二十多年。已经构建了大量的应用。事实上,许多 web 应用对于业务运营至关重要。

在 21 世纪初,这些应用取代了安装在设备、台式机或笔记本电脑上的胖客户端。胖客户端带来了挑战,因为应用必须针对操作系统来构建。大多数应用不能在苹果 macOS 和微软 Windows 之间互操作;然而,也有例外。一些组织和开发人员使用基于 Java 的技术来构建胖客户端,这些客户端运行在 Java 虚拟机(JVM)上。在这种解决方案流行起来之前,web 应用占据了主导地位。很大程度上,胖客户端在不同的平台和操作系统之间是不兼容的。

网络应用帮助解决了这个问题。web 应用在浏览器上运行。浏览器解释并执行超文本标记语言(HTML)、JavaScript 和级联样式表(CSS)。这些特征由欧洲计算机制造商协会(ECMA)和技术委员会 39 (TC39)标准化。

随着在移动设备上安装和运行的移动应用的出现,这种情况发生了变化。这可能是一部手机或平板设备,如 iPad。始于 2008 年的苹果应用商店在组织和开发者向应用的转变中发挥了重要作用。如今,苹果的 iOS 和谷歌的 Android 是两大移动平台。iOS 使用 App Store,Android 使用 Google Play(也叫 Play Store)分发各自平台的应用。

原始问题

在移动设备上,应用又带来了最初的问题:你需要为各自的平台开发应用。你只需为 iOS 构建一次原生应用,然后为 Android 重复构建。当然,还有其他选择,包括混合和跨平台技术的变通方法。它们适合主要的用例,但是总有一些工作流,这样的解决方案没有帮助。此外,对原生用户体验有所妥协;如果用户界面最初是为 Android 开发的,那么 iOS 用户可能会觉得与平台不匹配。许多工作流和应用需要大屏幕和桌面级操作系统提供的灵活性。将 iOS 和 Android 应用带到桌面上的动力很小。在大多数情况下,用户体验是有限的。

Web 应用解决方案的注意事项

组织和开发人员在 iOS 和 Android 以及 macOS 和 Microsoft Windows 上构建 web 应用,以满足所有主要平台和浏览器的需求。请记住,组织最初在远离胖客户端时采用了这种解决方案。当时,这些设备大多固定在桌子上,不能移动。他们通过电缆或无线网络连接。连接是稳定的。在构建应用时,连接不是一个考虑因素。

移动平台改变了这一场景。用户高度移动,进出网络。应用需要考虑连接性因素。假设用户暂时失去连接。当她试图启动移动 web 应用时,传统的 web 应用显示类似“无法显示页面”的消息,并且应用无法启动。用户无法继续。如果用户正在进行交易,问题可能会更严重。数据丢失,用户可能不得不重试整个工作流。

应用提供了现成的解决方案。记住,应用是安装在设备上的。用户可以启动应用,并与过去的数据或消息进行交互,即使用户断开连接。如果用户提交了交易或表格,应用可以临时缓存并在在线时同步。

以 Twitter 这样的社交应用为例。离线时,它允许你启动应用,查看缓存的推文,甚至撰写新推文并保存为草稿。

现代 web 应用支持这种高级缓存特性。这本书详细介绍了如何构建一个现代化的 web 应用,让用户在脱机时也能正常工作。

用例

这本书使用 Web Arcade,一个基于 Web 的在线游戏系统作为用例。您将使用 Angular 和 TypeScript 构建应用。这本书提供了如何创建应用、各种组件、服务等的分步说明。

在本书的整个过程中,您将学习如何做到以下几点:

  • 安装和升级 web 应用。

  • 缓存应用,以便在脱机时可以访问。

  • 缓存检索到的数据。

  • 使应用能够在脱机时运行。借助 Web Arcade 用例,我们将详细介绍如何向系统添加数据。离线时,数据缓存在设备上。一旦重新上线,该应用将提供与服务器同步的机会。

在桌面和移动设备上,谷歌 Chrome、微软 Edge、Safari(在 Mac 和 iOS 上)和 Firefox 等现代浏览器允许用户安装该应用。该书提供了一步一步的指导,使他们能够安装 web 应用并为该应用创建快捷方式。该快捷方式提供了对 web 应用的轻松访问,并在它自己的窗口中启动它(不像典型的 web 应用,它总是在浏览器中启动)。图 1-1 显示了安装在 macOS 上的 Web Arcade 应用。请注意 Dock 中的 Web Arcade 应用图标。您将在 Windows 任务栏中看到类似的图标。应用在它自己单独的窗口中,而不是在浏览器标签中。

img/515699_1_En_1_Fig1_HTML.png

图 1-1

安装在 macOS 上的 Web Arcade 应用

这本书详细介绍了如何使用服务工作器(带 Angular)来缓存应用。它提供了设置开发环境和测试缓存特性的分步说明。在这个阶段,即使网络不可用,应用也会加载。例如,即使在应用脱机时,您也可以使用该功能来掷骰子。可以想象,掷骰子不需要服务器端连接。它是在 1 和 6 之间生成的随机数。应用可视化滚动骰子(图 1-2 )。

img/515699_1_En_1_Fig2_HTML.png

图 1-2

在 Web Arcade 应用中掷骰子

您将从缓存应用数据并在离线时(或在慢速网络上)使用它开始。您将看到当真正的服务器端服务不可用时,如何利用服务工作器缓存。

这本书还详细介绍了如何创建数据;具体来说,Web Arcade 应用将允许用户在离线时添加评论。使用 IndexedDB(一个本地数据库)缓存评论。一旦连接建立,应用就进行识别。它将缓存的离线评论与服务器端服务和数据库同步。

稍后,您需要创建应用和数据库的新版本。这本书涵盖了提示用户可用升级的特性和实现。它详细介绍了如何无缝过渡到新数据库并升级其结构。

代码示例

本节解释如何下载和运行代码示例。从这个 GitHub 位置: https://git-scm.com/downloads 克隆 Web Arcade 库。打开终端/命令提示符并使用下面的命令,这需要在您的机器上安装 Git。

git clone https://github.com/kvkirthy/web-arcade.git

Note

Git 是一个流行的分布式源代码管理(SCM)工具。它占地面积小,在机器上使用最少的资源和磁盘空间。它也是免费和开源的。

默认情况下,您已经克隆了master分支。查看名为book-samples的分支,获取书中示例的样本。首先把目录改成web-arcade。接下来,检查一下book-samples 分店。

cd web-arcade
git checkout book-samples

我们预计会有改进,并将相应地将反馈纳入master分支。然而,分支book-samples致力于精确匹配书中的代码样本。

接下来,运行以下命令来安装代码示例所需的所有包。在整本书中,我们提供了使用节点包管理器(NPM)和 Yarn 的说明。虽然 NPM 是 Node.js 的默认包管理器,但 Yarn 是一个由脸书支持的开源项目。由于其在性能和安全性方面的优势,它在开发人员社区中受到了广泛关注。我们建议你挑一个,坚持到书结束。

npm install
(or)
yarn install

Note

这个命令需要在你的机器上安装 Node.js、NPM 或者 Yarn。如果他们不在,暂时跟着读。下一章提供了详细的说明。

接下来,运行以下命令启动 Web Arcade 示例应用:

npm run start-pwa
(or)
yarn start-pwa

前面的命令启动一个运行在开发人员级 web 服务器上的成熟应用。在阅读和理解代码的同时运行应用是很有用的。但是,如果您正在进行更改和更新代码,这些更改需要在应用中显示出来。通常,页面会重新加载,应用会根据更改进行更新。使用前面的命令很难做到这一点。每次进行更改时,您可能都必须结束该过程并重新启动。因此,考虑在更新代码时使用下面的命令。它会立即用更改更新应用。

npm start
(or)
yarn start

让应用在后台运行是一个很好的做法。在整本书中,您将不断地创建和更新代码,并运行示例。前面代码运行的脚本保持应用正常运行。除非得到指示,否则不要终止此脚本。

Note

在撰写本文时,该命令不支持在应用离线时缓存和加载应用。这是本书中解释的示例应用和概念的一个重要特性。要使用服务工作器测试缓存特性,请使用start-pwa命令。

摘要

这一章提到了对新实现的需求,比如 service workers 和 IndexedDB,它们是大多数现代浏览器所固有支持的。本书的其余部分将详细介绍如何在 web 应用中实现和集成这些技术。我们还介绍了一个名为 Web Arcade 的用例,这是一个基于 Web 的在线游戏系统,将在本书的其余部分使用。

二、入门指南

本章提供了如何开始使用 Angular 应用的说明。这是所有即将到来的章节的基础。按照本章中详细介绍的步骤来设置您的开发环境。接下来的章节将使用本指南中详细介绍的软件、库和软件包。

具体来说,本章提供了创建一个示例应用(即 Web Arcade)的步骤。样例应用将为本书中的所有概念及其解释提供用例及示例。在本章中,您将从创建 Web Arcade Angular 应用开始。

您还将向 Web Arcade Angular 应用添加离线功能。您将看到如何在没有网络连接的情况下访问 Angular 应用的介绍性细节。

先决条件

要创建、运行和测试 Angular 应用,您需要在计算机上安装和设置软件列表。幸运的是,本书中列出和描述的所有软件和库都是开源的,可以免费使用,至少对个人开发者来说是这样。本节列出了开始创建 Angular 应用所需的最低软件要求。

Node.js 和 NPM

Node.js 是一个跨平台的 JavaScript 运行时,在名为 V8 的 JavaScript 引擎上运行。它主要用于在服务器端和后端运行 JavaScript。

在本书中,您将在很大程度上使用 Node.js 安装附带的节点包管理器(NPM)。顾名思义,它是一个包管理器,帮助您安装和管理库和包。它跟踪一个包的整个依赖树。通过简单的命令,它可以帮助下载和管理库及其依赖项。

例如,Lodash 是一个非常有用的 JavaScript 实用程序和函数库。只需一个命令,您就可以将软件包作为依赖项安装并添加到项目中。其他人下载您的项目不需要执行额外的步骤。

从 Node.js 官方网站, https://nodejs.org 下载安装 Node.js。单击网站上的下载链接。它列出了长期支持(LTS)和最新版本的安装程序。最好选择 LTS。接下来,根据您的操作系统和平台选择一个选项。它将下载安装程序。

它安装 Node.js 和 NPM。在撰写本书时,Node.js 版本是 14.17.0,NPM 是 6.14.13。

下载完成后,打开安装程序,按照步骤完成安装。介绍画面和版本信息见图 2-1 。

img/515699_1_En_2_Fig1_HTML.jpg

图 2-1

Node.js installer(节点. js 安装程序)

故事

虽然 NPM 是 Node.js 的默认包管理器,但 Yarn 是一个由脸书支持的开源项目。由于其在性能和安全性方面的优势,它在开发人员社区中受到了广泛关注。书中的例子包括纱线和 NPM 命令。如果你是 Angular 发展的新手,选择一个并在所有的例子和练习中坚持使用它。包括纱线为读者提供了一种选择。有时,团队和组织在选择他们的工具集时会很挑剔(出于各种原因,包括像安全性这样的重要考虑)。因此,它有助于学习 NPM 和纱线。

要安装 Yarn,请运行以下命令:

npm install -g yarn

Note

注意选项-g,它代表“全局”这个包可以在所有的项目和目录中使用。因此,它可能需要提升权限才能运行和安装。

在 Windows 计算机上,以管理员身份运行此命令。

在 macOS 上,以超级用户身份运行命令。考虑下面的片段。该命令将提示输入 root 用户密码。

 sudo npm install -g yarn

如果您不使用-g选项,您仍然可以在目录级别使用yarn(或者任何其他没有安装-g的工具)。您可能需要为每个新目录或项目重新安装它。如果您喜欢将资源保存在项目或目录的本地,这是一个不错的解决方案。

要验证安装是否成功,请运行yarn --version。确保yarn命令被识别并返回版本信息。

% yarn --version
1.22.10

Angular CLI

在使用 Angular 应用时,Angular CLI 是一个非常有用的命令行工具。您将使用该工具完成所有与 angle 相关的任务,包括创建项目、添加新的 angle 组件、使用 angle 服务、运行 angle 应用、执行与构建相关的任务等。

使用以下命令安装 Angular CLI:

 npm install -g @angular/cli
# (or)
 yarn global add @angular/cli

要验证安装是否成功,请运行ng --version。见清单 2-1 。确保 Angular CLI 命令被识别并返回版本信息。

% ng --version

Listing 2-1Verify Angular CLI Installation

img/515699_1_En_2_Figa_HTML.jpg

Angular CLI: 12.0.1
Node: 14.16.1
Package Manager: npm 6.14.12
OS: darwin x64
Angular: undefined

Package                      Version
------------------------------------------------------
@angular-devkit/architect    0.1200.1 (cli-only)
@angular-devkit/core         12.0.1 (cli-only)
@angular-devkit/schematics   12.0.1 (cli-only)
@schematics/angular          12.0.1 (cli-only)

Note

注意在清单 2-1 中,Angular 是未定义的;但是,Angular CLI 的版本是 12.0.1。安装成功。一旦您使用 CLI 创建项目,Angular 将显示一个版本。

Visual Studio Code

严格地说,您可以使用任何简单的文本编辑器来编写 Angular 代码,并使用终端或命令提示符来编译、构建和运行应用。对于大多数编程语言来说,这可能是真的。但是,为了提高生产率和简化开发,请使用集成开发环境(IDE ),如 Visual Studio Code。该软件是由微软开发的,它是免费的,占用空间小,易于下载和安装。它的功能在处理 Angular 应用时非常有用。不过,这是个人喜好。你可以选择任何你觉得合适的 IDE 来创建和运行本书中描述的 Angular 应用。

从其网站 https://code.visualstudio.com 下载 Visual Studio Code。使用页面上的下载链接。在撰写本内容时,网站会自动识别您的操作系统,并提供相应的下载链接。

有关 Visual Studio Code 的快照,请参见图 2-2 。

img/515699_1_En_2_Fig2_HTML.png

图 2-2

Visual Studio Code 快照

以下是一些其他的选择:

  • 这是一个共享软件,也是一个有用的文本编辑器。它非常适合 JavaScript 开发,因为它占用空间小、响应速度快、易于使用。

  • 这是一个由 JetBrains 为 JavaScript 开发构建的复杂的 IDE。它为包括 Angular 和 Node.js 在内的许多流行框架提供了定制功能。但是,它是需要购买的专有软件。

  • Atom:这是一个开源的自由文本编辑器。它是一个用 HTML 和 JavaScript 构建的跨平台应用。

http-服务器

Http-Server 是为静态文件运行基于 Node.js 的 web 服务器的一种快速有效的方式。在开发过程中,您将使用这个 NPM 包来处理缓存的应用。

运行以下命令在您的计算机上全局安装 Http-Server:

 npm install -g http-server
# (or)
 yarn global add http-server

要验证安装是否成功,请运行http-server --version 确保http-server命令被识别并返回版本信息。

% http-server --version
v0.12.3

Note

如果您在使用yarn global add时遇到问题,并且在全局安装后仍未找到包,请参考本章后面的“使用 yarn 全局添加”一节。

创建 Angular 应用

既然所有的先决条件都已具备,您就可以创建一个新的 Angular 应用了。您将使用刚刚安装的 Angular CLI(@angular/cli)。Angular CLI 的可执行文件命名为ng。换句话说,您将使用一个ng命令来运行该工具。它使用您通过ng命令提供的选项来执行任务。

按照以下说明创建新的 Angular 应用:

ng new web-arcade

ng new是创建新应用的 Angular CLI 命令。创建新应用时,CLI 通常会提示您选择是否要使用 Angular 路由和样式表格式。参见清单 2-2 。

对于示例应用,选择实现路由。随着示例应用的发展,您将创建多个页面。这些页面之间的导航需要 Angular 路由。

Note

路由是单页应用(SPAs)的一个重要特性,因为大多数 web 应用都有不止一个页面。用户在页面之间导航。每个页面将有一个唯一的网址。

在 SPA 中,当用户在页面之间导航时,不会重新加载整个页面。在路由实现的帮助下(在这种情况下是角路由),SPA 只更新页面中在两个 URL 之间变化的部分。

对于关于选择样式表格式的第二个提示,如果您希望匹配书中的示例,请选择 Sass。但是,如果您喜欢其他样式表格式,可以随意选择其他应用。

 % ng new web-arcade
? Would you like to add Angular routing? Yes
? Which stylesheet format would you like to use? Sass   [ https://sass-lang.com/documentation/syntax#the-indented-synt
ax ]

Listing 2-2Prompts While Creating a New Angular Application

现成的 Angular CLI 提供了以下样式表格式的选择:

  • 层叠样式表(CSS) :这是样式表开发的传统方法。它在处理小代码单元时工作良好。

  • 语法上令人敬畏的样式表(SCSS -时髦的 CSS) :与 CSS 相比,它提供了更好的编程类型的特性。这些特性包括变量、函数、混合和嵌套规则。它是 CSS 的超集。

样式表被写入扩展名为.scss的文件中。它被预处理成 CSS。它完全兼容 CSS。因此,所有有效的 CSS 语句也在一个.scss文件中工作。

SCSS 语法包括大括号来表示块的开始和结束,以及分号来表示样式表语句的结束。

  • 语法上令人敬畏的样式表(SASS) :这类似于 SCSS,除了样式表语句是缩进的,而不是使用花括号和分号。为了明确指出文件格式,SASS 代码被写入.sass文件。

  • 更精简的样式表(LESS) :这是 CSS 的另一个超集,允许你使用变量、函数、混合等等。

使用 Angular CLI 的一个优点是,它将构建过程构建为包括样式表的预处理。运行或构建应用时,不需要额外的工作来创建脚本或运行预处理程序。

接下来,Angular CLI 复制文件并安装应用(清单 2-3 )。

CREATE web-arcade/README.md (1055 bytes)
CREATE web-arcade/.editorconfig (274 bytes)
CREATE web-arcade/.gitignore (604 bytes)
CREATE web-arcade/angular.json (3231 bytes)
CREATE web-arcade/package.json (1072 bytes)
CREATE web-arcade/tsconfig.json (783 bytes)
CREATE web-arcade/.browserslistrc (703 bytes)
CREATE web-arcade/karma.conf.js (1427 bytes)
CREATE web-arcade/tsconfig.app.json (287 bytes)
CREATE web-arcade/tsconfig.spec.json (333 bytes)
CREATE web-arcade/src/favicon.ico (948 bytes)
CREATE web-arcade/src/index.html (295 bytes)
CREATE web-arcade/src/main.ts (372 bytes)
CREATE web-arcade/src/polyfills.ts (2820 bytes)
CREATE web-arcade/src/styles.sass (80 bytes)
CREATE web-arcade/src/test.ts (743 bytes)
CREATE web-arcade/src/assets/.gitkeep (0 bytes)
CREATE web-arcade/src/environments/environment.prod.ts (51 bytes)
CREATE web-arcade/src/environments/environment.ts (658 bytes)
CREATE web-arcade/src/app/app-routing.module.ts (245 bytes)
CREATE web-arcade/src/app/app.module.ts (393 bytes)
CREATE web-arcade/src/app/app.component.sass (0 bytes)
CREATE web-arcade/src/app/app.component.html (23809 bytes)
CREATE web-arcade/src/app/app.component.spec.ts (1069 bytes)
CREATE web-arcade/src/app/app.component.ts (215 bytes)
⠼ Installing packages (npm)..

Listing 2-3Angular CLI: Create and Install Web Arcade

添加服务工作器

要向 Angular 应用添加离线特性,运行清单 2-4 中的命令。

ng add @angular/pwa

# This command install @angular/pwa on default project
# If you are running the above command on an existing Angular # solution that has multiple projects, use
# ng add @angular/pwa --project projectname

Listing 2-4Add Progressive Web App Features for Offline Access

服务工作器是渐进式 web 应用(PWAs)的功能之一。它们在浏览器的后台运行,使您能够缓存应用,包括脚本、资产、远程服务响应等。

传统上,web 应用易于部署和管理。为了添加新功能,开发人员或工程团队在一台或多台 web 服务器上部署新版本。用户下次打开网站时,可以访问新的应用和新的功能。但是,移动应用和安装的客户端应用(Windows 或 Mac)需要定期更新。安装需要在数以千计的客户端设备上进行(甚至更多,这取决于应用)。

但是,移动应用和客户端应用有一个优势,即使在网络不可用的情况下也可以访问。例如,社交媒体空间(Twitter 或脸书)中的移动应用可能会显示帖子,即使在没有网络可用的情况下。当网络不可用时,传统的 web 应用会显示一条消息,大意是“找不到页面”。用户完全无法访问应用。他们不能查看缓存的数据或与之交互。

进步的网络应用,特别是服务工作器,弥补了这一差距。您可以继续利用 web 应用的轻松部署和管理。它允许您安装应用、缓存脚本和资产等。

清单 2-4 中的命令允许您缓存脚本、资产和数据。它添加所需的配置,并在 Angular 应用模块中注册服务工作器。

运行 Angular 应用

到目前为止,您已经建立了一个使用 Angular 应用的环境,创建了一个名为 Web Arcade 的新应用,并安装了 PWA 特性。接下来,运行这个基本的应用来验证一切都工作正常。要运行 Web Arcade 应用,请将目录更改为应用文件夹的根目录,并执行以下命令:

npm run build
#(or)
yarn build

前面的命令在package.json文件中运行一个 NPM 脚本(即build)。这是 Angular CLI 在创建新项目时创建的文件之一。在 Visual Studio Code 或您选择的 IDE 中打开该文件。您将在清单 2-5 中看到脚本。

 "scripts": {
   "ng": "ng",
   "start": "ng serve",
   "build": "ng build",
   "watch": "ng build --watch --configuration development",
   "test": "ng test"
 },

Listing 2-5Scripts in the package.json File

Note

在清单 2-5 之前提供的构建脚本运行ng build。记住,Angular CLI 的可执行文件是ng。你可以直接在控制台或终端上运行ng build。两者都会导致同样的结果。

先前的构建命令输出到dist/web-arcade。接下来,在这个目录中运行 Http-Server。它启动一个基于 Node.js 的 web 服务器来呈现 Angular 应用。web 服务器在默认端口 8080 上运行。参见清单 2-6 。

% http-server dist/web-arcade

Starting up http-server, serving dist/web-arcade
Available on:
  http://127.0.0.1:8080
  http://192.168.0.181:8080

Listing 2-6Run Http-Server on Web Arcade Build Output

现在可以在端口号为 8080 的本地主机上访问该应用。在任何现代浏览器上打开链接http://localhost:8080。见图 2-3 。这张图片是在谷歌浏览器上拍摄的。

img/515699_1_En_2_Fig3_HTML.jpg

图 2-3

在 Http 服务器上运行的新应用

Note

您可以使用选项-p <new port number>在不同的端口上启动 Http-Server,例如http-server dist/web-arcade -p 4100

请注意,这是由 Angular CLI 搭建的默认内容。它提供了有用的链接和文档,可以继续用 Angular 构建应用。在本书接下来的章节和代码片段中,我们将增强代码示例,并创建新的组件和服务来创建 Web Arcade 应用。

接下来,打开开发人员工具并重新加载应用。在谷歌浏览器上,你可以在“更多工具”菜单下找到开发者工具。导航到网络选项卡并查找ngsw.json。这是服务工作器的配置文件。这允许您在浏览器中注册 Web Arcade 服务工作器。见图 2-4 。

img/515699_1_En_2_Fig4_HTML.png

图 2-4

Web Arcade 的 Chrome 开发工具

在节流选项中(在开发工具的网络选项卡上),选择选项离线现在,如果您在此浏览器中导航到任何在线网站,它都不会加载页面。但是,localhost:8080 继续工作,从服务工作器缓存中呈现。

安全:服务工作器需要 HTTPS

服务工作器是一个强大的特性,它可以充当来自应用的所有网络请求的代理。代理实现可能会导致安全风险。因此,浏览器实施安全的 HTTPS 连接。如果没有 HTTPS 实现的网站试图注册服务工作器,浏览器会忽略该功能。

但是,请注意,在部署到 Http-Server 时,我们没有实现 HTTPS 证书。浏览器不会对本地主机上的 HTTPS 执行这种检查。这是规则的例外。它有助于轻松开发应用。出于开发目的实现 HTTPS 连接可能是一项单调乏味的任务。并且在本地主机上可访问的应用安装并运行在本地机器上;因此,不存在安全风险。

请注意,清单 2-6 显示该应用在两个 IP 地址上可用,如下所示:

  • 127.0.0.1.这代表本地主机。

  • 192.x.x.x。这是机器的本地 IP 地址。它没有安装安全的 HTTPS 证书。

即使 192.x.x.x 是本地 IP 地址,浏览器也无法验证这一点。考虑到连接不是 HTTPS,浏览器不注册服务工作器。参见图 2-5 。浏览器处于脱机模式。尽管有服务工作器,服务工作器也不加载页面。当您将应用联机时,服务工作器不会加载到网络选项卡中。

img/515699_1_En_2_Fig5_HTML.png

图 2-5

服务工作器不在 HTTP 连接上工作

使用纱线全局添加

Yarn 提供了几个命令,包括addlistremove,分别用来安装包、列出包和移除包。这些包安装在给定目录的node_modules中。目录是命令或操作的范围。

然而,由于前缀为yarn global,该命令在全局级别运行。全局级别通常是指登录用户的所有目录/项目。运行清单 2-7 中的命令,查看 Yarn 的全局bin目录。请注意,它位于登录用户的目录下。这是纱线安装的默认路径。

# macOS
% yarn global bin
/Users/logged-in-user/.yarn/bin

# Microsoft Windows
C:> yarn global bin
C:\Users\logged-in-user\AppData\Local\Yarn\bin

Listing 2-7Show Global Yarn bin Directory

如果该目录不包含在环境变量path中,包甚至在全局安装后也不工作。设置路径并重试软件包安装。参见清单 2-8 设置路径。

#On Microsoft Windows, set path with the following command
set PATH=%path%;c:\users\logged-in-user\AppData\Local\Yarn\bin

#On macOS, set path with the following command
export PATH="$(yarn global bin):$PATH"

Listing 2-8Set Path to Yarn Global bin Directory

摘要

这一章是一个入门指南,为离线功能的服务工作器构建一个 Angular 应用。它提供了必备软件和库的列表,以及下载和安装说明。本书中使用的大多数软件和库都是开源和免费的。

Exercise: Creating the Web Arcade Application

在计算机上为 Angular 设置一个开发环境。一定要安装 Node.js。

  1. 选择一个包管理器来安装和管理 Angular 和 TypeScript(或 JavaScript)包。选择使用 NPM 或纱线。更好的是,在未来的练习中坚持你的决定。

  2. 安装和验证 Angular CLI,最好是在全球层面。

  3. 安装 Http-Server,最好是在全局级别。

  4. 创建一个新的 Angular 项目并添加@angular/pwa

  5. 在 Http-Server 上构建和部署项目的第一个版本。

  6. 查看并确保服务工作器在网络不可用时可用。

三、安装 Angular 应用

本章首先提供在 Angular 应用中创建新屏幕和组件的说明。它提供关于组件的介绍性细节,然后提供足够的细节来构建一个离线 Angular 应用。如果您正在寻找关于组件和 Angular 概念的深入信息,请阅读《材料设计的 Angular》一书或参考本书末尾参考资料中提供的 Angular 文档。在本章快结束时,您将把应用打包并安装到客户端设备(桌面或移动设备)上。

Angular 分量

web 应用是由许多网页组成的。在网页中,用户与之交互的视图,包括标签、文本字段、按钮等。,是用 HTML 构建的。文档对象模型(DOM)节点组成一个 HTML 页面。DOM 被组织成一棵树。HTML 页面从一个根节点开始,通常是一个html元素(<html></html>)。它有子节点,子节点有更多的子节点。

可以想象,所有的浏览器都知道这些 HTML 元素。它们有内置的实现来呈现 HTML 页面中的每个元素。比如浏览器遇到一个input元素(<input />),就显示一个文本字段;浏览器将以粗体显示strong元素(<strong>text</strong>)中的文本。

然而,我们是否局限于 HTML 中预定义的元素?如果您想创建封装视图和行为的新元素,该怎么办?假设您想为 Web Arcade 构建一个骰子。

创建一个组件

Angular 组件使开发人员能够构建自定义元素。组件是 Angular 应用的构建块。本节提供了如何创建新组件的说明。

正如您在第二章中看到的,您将使用 Angular CLI 来创建和管理 Angular 应用。当您在前一章中设置开发环境时,您已经安装了 Angular CLI,所以它应该可以使用了。

要创建新组件,请运行以下命令:

% ng generate component components/dice

正如您之前看到的,ng可执行文件运行 Angular CLI。

  • 带 Angular CLI 的generate命令创建文件。

  • 接下来,component集合在与generate一起使用时指定如何添加组件文件。

  • 第三个参数指定组件名dice。在值前面加上components/会在文件夹components下创建它。如果文件夹不存在,它将创建该文件夹。

Note

或者,您可以使用下面的简短形式,使用g表示生成,使用c表示组件。

% ng g c components/dice

Angular CLI 在名为src/app/components/dice的新文件夹中生成以下文件。见图 3-1 。

img/515699_1_En_3_Fig1_HTML.png

图 3-1

使用 Angular CLI 生成的组件文件

  • dice.component.html:用于标记的 HTML 模板文件。要在网页中创建视图,可以使用 HTML。骰子的一面是用 HTML 模板创建的。

  • 样式表文件包含组件外观的 SASS 样式。它包括颜色、文本装饰、边距等。

  • dice.component.spec.ts:一个用于单元测试的 TypeScript 文件。

  • dice.component.ts:用于dice组件的功能实现的 TypeScript 文件。

网络街机:创建一个骰子

本节详细介绍了dice组件的代码。这是对创建一个dice组件的固执己见的看法。请参考“练习”部分,了解关于构建骰子的其他想法。

组件的样式

样式表管理外观、颜色、字体等。,用于组件。样式可以应用于组件的元素。将样式表的范围限定在组件本地是一个很好的做法。这是 Angular 应用中的默认行为。本节详细介绍了如何为dice组件创建样式表。

注意,在 Web Arcade 代码示例中,src/assets目录中有六个 PNG 文件,分别对应于骰子的每一面。在样式表中使用这些图像来显示骰子的侧面。首先在样式表中为每一方创建变量。考虑清单 3-1 中的变量列表,这些变量带有指向骰子图像侧面的 URL 引用。冒号的左边(:)是变量名。冒号的右边是一个值,在本例中是骰子的 PNG 图像。使用url()功能将图像文件包含在 CSS 中。

// Variables in SASS
$side1:url('/assets/side1.png')
$side2:url('/assets/side2.png')
$side3:url('/assets/side3.png')
$side4:url('/assets/side4.png')
$side5:url('/assets/side5.png')
$side6:url('/assets/side6.png')

Listing 3-1Sides of the Die Images

接下来,为骰子的每一面创建 CSS 类,可用于div元素。参见清单 3-2 。

div.img-1
  background-image: $side1

div.img-2
  background-image: $side2

div.img-3
  background-image: $side3

div.img-4
  background-image: $side4

div.img-5
  background-image: $side5

div.img-6
  background-image: $side6

Listing 3-2Code for the CSS Class That Defines Each Side of a Die

每个img-x类(例如img-6)都是一个 CSS 类。它的前缀是div 。在 HTML 中,CSS 类img-6只能应用在div元素上。

Note

记住,SASS 文件不使用花括号或分号。注意元素和类名下 CSS 样式的缩进。也就是说,名为$side6的样式背景图像与div.img-6相关,因为它缩进了一个制表符,表明它与 CSS 类相关。

TypeScript:组件的功能逻辑

每个组件至少有一个 TypeScript 文件和一个 TypeScript 类。组件的功能/行为逻辑写在这个类中。例如,考虑掷骰子并生成 1 到 6 之间的随机数的逻辑。这就是dice组件的功能/行为逻辑。本节介绍如何为dice组件创建 TypeScript 类。

组件的输出(事件发射器)

到目前为止,您已经看到该组件有一个样式表来显示骰子的六个面中的任何一面。当你掷骰子时,它会抽取六个数字中的一个。利用dice组件的代码可能需要掷骰子的结果。把这当成输出。用输出装饰器为输出创建一个EventEmitter对象。在这个对象上使用emit()函数来输出组件外部的值。继续阅读进一步的解释和代码示例。

组件的输入

该组件还可能允许从组件外部设置值。只要是合法值(1 到 6 之间的值),组件就可以显示在骰子上。把这当成输入。创建一个类级变量,用Input()修饰它。这充当组件的属性。对于此属性,代码可以使用组件提供输入值。

考虑清单 3-3 和图 3-2 中显示的 TypeScript。注意用粗体突出显示的文本,第 7 行和第 8 行。Input()装饰器允许从组件外部设置变量值。Output()装饰器启用从组件发出的rollResult值。

img/515699_1_En_3_Fig2_HTML.png

图 3-2

骰子和应用组件之间的输入和输出

import { Component, Input, OnInit, Output, EventEmitter } from '@angular/core';
1\. @Component({
2\.  selector: 'wade-dice',
3\.  templateUrl: './dice.component.html',
4\.  styleUrls: ['./dice.component.sass']
5\. })
6\. export class DiceComponent implements OnInit {
7\.   @Input() draw: string = '';
8\.   @Output() rollResult = new EventEmitter<number>();
9\.   constructor() { }
10\.  ngOnInit(): void { }
11\. }

Listing 3-3Dice Component TypeScript File

还要注意清单 3-3 ,第 1 行到第 5 行。组件装饰器为组件指定元数据。

  • 如前所述,组件是可重用的定制元素。在 HTML 文件中使用组件时,您将使用该值引用组件。在本例中,参见<wade-dice></wade-dice>

    注意 wade-是为 Web 归档代码样本中的所有组件选择的任意前缀。Angular 使用的默认是app

  • template-url:指组件使用的 HTML 文件。见图 3-1 。它显示了为dice组件创建的 HTML 文件。

  • style-urls:指组件使用的样式表文件。可以有多个样式表。因此,它是一个值数组。见图 3-1 。它显示了为dice组件创建的 SASS 文件。

注意第 9 行中显示的构造函数。它实例化了 TypeScript 类。第 10 行中的函数ngOnInit(),是在构造函数之后调用的 Angular 生命周期钩子。因此,当这个函数被调用时,类变量已经被实例化了。这是设置 Angular 组件上下文的理想位置。

在当前的dice组件案例中,我们通过滚动骰子来设置上下文,生成 1 到 6 之间的随机数,并显示骰子的那一面。或者,dice组件也允许您从组件外部设置一个值。考虑上市 3-4 。

01: @Input() draw: string = '';
02: @Output() rollResult = new EventEmitter<number>();
03:
04: constructor() { }
05:
06: ngOnInit(): void {
07:   if(this.draw){
08:     this.showOnDice(+this.draw);
09:   } else {
10:     this.rollDice();
11:   }
12: }

Listing 3-4ngOnInit() Hook for the Component

Note

TypeScript 类变量和方法(函数)是用this关键字访问的。

注意第 1 行和第 7 行。this.draw是组件的输入属性。如果向元件提供输入,则在骰子上显示该值。使用组件的 Angular 代码明确提供了一个值。你不需要掷骰子来产生一个随机数。参见清单 3-5 。

另一方面,当没有输入时,滚动骰子,产生一个随机数,并在骰子上显示该值。参见清单 3-6 。

Note

注意清单 3-4 第 8 行的+前缀。输入属性是一个字符串值。+前缀将字符串值转换为数字。

/*
     At class level, a variable selectedDiceSideCssClass is declared.

selectedDiceSideCssClass: string = '';
/*
01:  // show the given number (draw parameter) on the dice
02:   showOnDice(draw: number){
03:     // the css class img-x show appropriate side on the dice.
04:     switch (draw) {
05:       case 1: {
06:         this.selectedDiceSideCssClass = 'img-1';
07:         break;
08:       }
09:       case 2: {
10:         this.selectedDiceSideCssClass = 'img-2';
11:         break;
12:       }
13:       case 3: {
14:         this.selectedDiceSideCssClass = 'img-3';
15:         break;
16:       }
17:       case 4: {
18:         this.selectedDiceSideCssClass = 'img-4';
19:         break;
20:       }
21:       case 5: {
22:         this.selectedDiceSideCssClass = 'img-5';
23:         break;
24:       }
25:       case 6: {
26:         this.selectedDiceSideCssClass = 'img-6';
27:         break;
28:       }
29:       default: {
30:         break;
31:       }
32:     }
33:   }

Listing 3-5Show the Given Number on a Die

记住清单 3-1 和 3-2 中的样式表。他们定义了 CSS 类img-1img-6,显示描述骰子六个面的图像。清单 3-5 为一个名为selectedDiceSideCssClass的变量设置了一个合适的 CSS 类名。您将在 HTML 模板中使用这个 CSS 类。

要滚动骰子(无输入时),使用功能rollDice()

01: rollDice(){
02:     let i = 0;
03:
04:     // run the provided function 25 times depicting a rolloing dice
05:     const interval = setInterval(() => {
06:
07:       // random number generator for numbers between 1 and 6
08:       let randomDraw = Math.round((Math.random()*5) + 1);
09:       this.showOnDice(randomDraw);
10:
11:       // After 25, clear the interval so that the dice doesn't roll next time.
12:       if(i > 25) {
13:         clearInterval(interval);
14:         this.rollResult.emit(randomDraw);
15:       }
16:
17:       i += 1;
18:
19:     }, 100);
20: }

Listing 3-6Roll the Die

该函数试图模仿滚动骰子。因此,它在骰子上设置值 25 次(任意次数)。它每 100 毫秒运行一次代码。看到 5 和 19 之间的线。

  • 一个 JavaScript 函数接受一个回调函数作为第一个参数。

  • 第二个参数指示第一个回调函数运行之前的毫秒数。

为了更容易理解,请参见清单- 3-7 中的一小段空白代码。

setInterval(() => { }, // first parameter, callback
 100 // second parameter, interval duration in
     // milliseconds
);

Listing 3-7The setInterval() Function

要生成随机数,请参考清单 3-6 中的第 8 行。

  • Math.random()生成一个介于 0 和 1 之间的值。

  • 将这个数乘以 5 会将值限制在 0 到 5 之间。

  • 骰子不显示小数值;因此,使用 JavaScript 函数Math.round()对数字进行四舍五入。

  • 骰子不显示零;因此,加 1。

Note

什么时候用rollResult?想象一下,在棋盘游戏中,根据骰子抽出的数字移动一个棋子。棋盘游戏组件使用来自dice组件的随机数结果。dice组件发出棋盘游戏的号码。

在一个例子中,假设骰子抽取 6。一篇关于垄断的文章应该移动六个位置。dice组件显示 6 并发出数字。垄断组件接收 6,并将棋子移动 6 个位置。

请参考 TypeScript 类文件的完整代码段。参见清单 3-8 。

import { Component, Input, OnInit, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'wade-dice',
  templateUrl: './dice.component.html',
  styleUrls: ['./dice.component.sass']
})
export class DiceComponent implements OnInit {

  @Input() draw: string = '';
  @Output() rollResult = new EventEmitter<number>();

  selectedDiceSideCssClass: string = '';

  constructor() { }

  ngOnInit(): void {
    if(this.draw){
      this.showOnDice(+this.draw);
    } else {
      this.rollDice();
    }
  }

  // show the given number (draw parameter) on the dice
  showOnDice(draw: number){
    // the css class img-x show appropriate side on the dice.
    switch (draw) {
      case 1: {
        this.selectedDiceSideCssClass = 'img-1';
        break;
      }
      case 2: {
        this.selectedDiceSideCssClass = 'img-2';
        break;
      }
      case 3: {
        this.selectedDiceSideCssClass = 'img-3';
        break;
      }
      case 4: {
        this.selectedDiceSideCssClass = 'img-4';
        break;
      }
      case 5: {
        this.selectedDiceSideCssClass = 'img-5';
        break;
      }
      case 6: {
        this.selectedDiceSideCssClass = 'img-6';
        break;
      }
      default: {
        break;
      }
    }
  }

// generate a random number between 1 and 6
// and set on the dice
  rollDice(){
    let i = 0;

// run the provided function 25 times depicting a rolling dice
    const interval = setInterval(() => {

      // random number generator for numbers between 1 and 6
      let randomDraw = Math.round((Math.random()*5) + 1);
      this.showOnDice(randomDraw);

      // After 25, clear the interval so that the dice doesn't roll next time.
      if(i > 25) {
        clearInterval(interval);
        this.rollResult.emit(randomDraw);
      }
      i += 1;
    }, 100);
  }
}

Listing 3-8Dice Component, TypeScript File

HTML 模板

关于用户与之交互的视图,请参考清单 3-9 中的 HTML 模板。

<div
  class="dice"
  [ngClass]="selectedDiceSideCssClass"
></div>

Listing 3-9Dice Component, HTML Template

注意 CSS 类值dice是静态的。它提供了在骰子滚动时不会动态改变的填充、高度和宽度。

另一方面,ngClass使用属性绑定来动态设置 CSS 类。这是一个 Angular 属性指令。ngClass将通过变量selectedDiceSideCssClass提供的 CSS 类应用到div元素上。参见清单 3-8 中的showOnDice()功能。它有条件地选择一个 CSS 类名。

Note

属性指令允许您更改 DOM 元素的外观和行为。ngClass是 Angular 提供的一个内置指令,用于动态更新 CSS 类。这对于动态控制元素的外观非常有用。

属性绑定在 HTML 属性上启用单向数据绑定 TypeScript 变量。

现在,dice组件已经可以使用了。转到app.component.html并删除导致第二章图 2-3 的默认内容。它是用 Angular CLI 创建的应用的占位符。记住,dice组件的选择器是wade-dice 在 HTML 模板的 app 组件中使用。参见清单 3-10 。

<div class="container align-center">
   <wade-dice></wade-dice>
</div>

Listing 3-10App Component of the HTML Template

Note

在这一点上,div和两个 CSS 类containeralign-center没有太大的意义。它们有助于在页面上更好地呈现内容。

记住清单 3-4 中的代码。如果您没有为draw属性(输入)提供一个值,dice组件会生成一个随机数。因此,清单 3-10 掷骰子,生成一个随机数,并将其设置在骰子上。相反,如果你使用属性draw,它不会掷骰子。它只显示了骰子的第 4 面。

<wade-dice draw="4"></wade-dice>

结果如图 3-3 所示。

img/515699_1_En_3_Fig3_HTML.jpg

图 3-3

使用应用组件中的骰子组件

服务工作器配置

接下来,您已经准备好安装应用了。完成后,我们可以回顾如何在桌面上安装应用。

在第二章中,当你安装@angular/pwa时,它创建了以下配置。ng add @angular/pwa命令添加了@angular/service-worker包。考虑应用的以下更新:

  1. 该命令将manifest.webmanifest文件添加到应用中。当您在桌面或移动浏览器上加载应用时,它会在此配置文件的帮助下识别渐进式 web 应用。ng add @angular/pwa命令更新index.html并添加一个到这个配置文件的链接。

  2. 该命令将ngsw-config.json文件添加到应用中。这是服务工作器的 Angular 特定配置文件。它由 CLI 和构建过程使用。它为应用配置缓存行为。

  3. 在 Angular 的应用模块中,它导入服务工作器模块并注册它。

Note

加载 web 应用时,每次都会从 web 服务器获取ngsw-config.json文件。它不与服务工作器一起缓存。它有助于识别对应用的更改,并获取一个全新的版本。

创建图标

PWA 需要各种图标文件。请记住,它现在是一个可安装的应用。你需要不同分辨率的启动图标。这些图标用在移动设备主屏幕上的应用中,桌面上的快捷方式,Windows 任务栏或 macOS Dock 上,等等。默认图标是一个 Angular 标志。您可以使用位于web-arcade/src/assets/icons的 Web Arcade 代码示例中的图标。

服务工作器应用需要具有以下分辨率(像素)的图标:

  • 72 × 72

  • 96 × 96

  • 128 × 128

  • 144 × 144

  • 152 × 152

  • 192 × 192

  • 384 × 384

  • 512 × 512

将图标复制到文件夹<your-project-folder>/src/assets/icons。运行 build 命令。请注意,图标和应用包一起被复制到可部署目录中。确保 Http-Server 正在dist/web-arcade目录中运行,以便 URLhttp://localhost:8080继续为应用提供服务。

在支持服务工作器的新浏览器窗口中启动应用。请注意,Web Arcade 应用有一个安装按钮。图 3-4 显示了桌面上 Google Chrome 的安装选项。

img/515699_1_En_3_Fig4_HTML.jpg

图 3-4

安装维修工人

Note

在新的浏览器窗口(或选项卡)中启动应用可确保旧版本的应用不会由服务工作器缓存提供。相反,它在 web 服务器上识别更新的应用包,并下载新的应用。

单击安装。它现在可以在桌面上使用。见图 3-5 。

img/515699_1_En_3_Fig5_HTML.png

图 3-5

macOS 上安装的应用

摘要

本章继续构建 Web Arcade 示例应用。在这个过程中,本章详细介绍了 Angular 和 service worker 概念。它还为骰子开发了样式表,最后在桌面和移动设备上安装应用,并列出由包@angular/pwa添加的配置。

Exercise

  • 本章中描述的代码示例没有显示重复掷骰子的按钮。给dice组件添加一个按钮,再次滚动。使用 click 事件来处理按需掷骰子。

  • 用 CSS 创建骰子的侧面,而不是使用图像。

  • 创建一个 8 面或 12 面的模具。

  • 探索滚动骰子的动画。

四、服务工作器

服务工作器在您的浏览器后台运行。它们为现代 web 应用提供了基础,并且可以安装、脱机工作,在低带宽情况下也是可靠的。本章介绍了维修工人。它讨论了服务工作器的缓存功能以及如何在 Angular 应用中使用它们。它详细描述了服务工作器的生命周期。接下来,本章将讨论 Angular 在与维修人员合作时的配置和功能。它解释了如何为 Web Arcade 示例应用实现缓存。最后,它提供了关于浏览器兼容性的细节。

服务工作器是运行在浏览器上的网络代理。他们可以拦截从浏览器发出的网络请求。这些请求包括应用的 JavaScript 包文件、样式表、图像、字体、数据等。您可以对服务工作器进行编程,以响应来自缓存的请求。这使得 web 应用能够适应网络速度和连接丢失。传统的 web 应用在失去连接时会返回“page not found”错误,与此不同,服务工作器使应用能够利用已安装和缓存的资源。您可以对应用进行编程,以加载缓存的数据或显示优雅的错误信息。即使在低带宽情况下,服务工作器也能让您构建流畅、响应迅速的应用,并提供出色的用户体验。

即使在应用或浏览器关闭后,服务工作器仍会保留。要查看在职服务工作器的列表,请导航至 Google Chrome 上的页面chrome://inspect/#service-workers或 Microsoft Edge 上的页面edge://inspect/#service-workers。见图 4-1 。请注意使用服务工作器的应用中包括 Angular 的 Angular.io 在内的热门网站。还要注意,Web Arcade 示例应用的开发 URL localhost:8080 已经注册了一个服务工作器。

img/515699_1_En_4_Fig1_HTML.png

图 4-1

在谷歌浏览器上检查服务工作器

Note

要查看您在计算机上访问的各种 web 应用注册的所有服务工作器,请启动“服务工作器内部信息”页面。注意,第一个 URLchrome://inspect/#service-workers只列出了活动的服务工作器。通过在 Google Chrome 上导航到chrome://serviceworker-internals(或在 Microsoft Edge 上导航到edge://serviceworker-internals)来访问服务工作器的内部信息。

请注意,此页面将来可能会被弃用。chrome://inspect/#service-workers URL 可能包括所有服务工作器调试特性。

服务工作器生命周期

本节详细介绍了服务工作器生命周期及其在后台运行的状态(在浏览器上)。参见图 4-2 ,该图描绘了一个服务工作器的生命周期。它从注册一个新的服务工作器开始。使用服务工作器的 web 应用在浏览器中加载时注册。“注册”可以在每次用户加载应用时发生。如果服务工作器已经注册,浏览器会忽略新的注册。

成功注册服务工作器会触发安装事件。典型的安装事件处理缓存逻辑。所有静态资源,包括应用包、图像、字体和样式表,都在安装过程中被缓存。这些是可配置的。

服务工作器安装是原子性的。下载和缓存一个或多个资源失败会导致事件完全出错。下次用户访问该网站时,它会尝试再次安装。这是为了确保没有部分安装的应用导致不可预见的问题和错误。

当应用打开时,安装的服务工作器被激活。它在后台运行,充当所有网络调用的代理。根据应用逻辑和配置,您可以从缓存中提供数据。如果在缓存中找不到数据,则调用网络服务并通过网络检索数据。

如果应用或服务工作程序不在使用中,则服务工作程序会终止以节省内存。需要时,它会激活服务工作器。注意图 4-1 (在浏览器窗口中)中的终止按钮,用于手动终止一个活动的维修工人。您可以使用它来强制关闭服务工作器。这有助于服务工作器重新开始工作。如果您的计算机资源不足,您可以终止服务工作以节省内存。另外,请注意 Inspect 链接,它启动开发工具,允许您探索网络资源和应用源代码。

工作流程和事件的描述见图 4-2 。

img/515699_1_En_4_Fig2_HTML.png

图 4-2

服务工作器生命周期

Angular 应用中的服务工作器

Angular 使得在应用中使用服务工作器和缓存特性变得更加容易。Angular scaffolds 提供了上一节“服务工作器生命周期”中描述的许多功能,尤其是缓存特性。本节详细介绍了整合服务工作器的开箱即用的 Angular 功能。

当您将@angular/pwa添加到项目中时,Angular CLI 会生成ngsw-config.json。它为 Angular 应用提供了一种服务工作器配置。Angular 构建过程使用这种配置。配置的一个方面是要缓存和安装的静态和动态资源的列表。静态资源包括组成应用的 JavaScript 包文件、样式表、图像、字体和图标。典型的动态资源包括数据响应。

一个ngsw-config.json文件包括以下几个部分:

  • 请记住,Web Arcade Angular 应用是可安装的,并且维护版本。配置中的该字段提供了应用版本的简要描述。更新应用时,使用此字段提供有关软件升级和版本的有意义的详细信息。

  • index:指定 Angular 应用和单页应用(SPA)的根 HTML 文件。在 Web Arcade 示例应用中,它是src目录中的index.html。有了这个字段索引,您就提供了一个到应用起点的链接。正如您接下来将看到的,Web Arcade 使用服务工作器缓存该文件。

  • assetGroups:这是对资产的配置,通常是 JavaScript 应用包、样式表、字体、图像、图标等。这些资源可以是 Angular 项目的一部分,也可以从远程位置下载,如内容交付网络(CDN)。

    • 注意列表 4-1 中 Web Arcade 的ngsw-config文件。它包括构成应用的文件,即index.html,所有的 JavaScript 包,以及 CSS 文件。它还包括图像、图标、字体等资产。

Note

记住,在 Web Arcade 中,SASS 文件编译成 CSS。服务工作器正在处理 Angular 应用的构建输出。所有的文件,包括 JavaScript 包,编译的 CSS,图片等。,相对于dist目录(yarn build命令的输出)。

  1. 可以配置多个assetGroups。请注意,该字段是一个数组。您可以列出带有配置细节的 JSON 对象。一个assetGroup对象定义了以下字段:

    1. name:这是一个任意的资产组名称。

    2. resources: The resources are the files or URLs to be cached by the service worker. As mentioned, the files could be JavaScript files, CSS stylesheets, images, icons, etc. On the other hand, for resources such as fonts (and a few other libraries), you may use CDN locations, which are URLs.

      1. files:这是为服务工作器配置的要缓存的文件数组。

      2. urls:这是为服务工作器配置的要缓存的 URL 数组。

      在构建时,您不太可能知道要缓存的每个文件。因此,该配置允许您使用文件和 URL 模式。有关更多详细信息,请参见“模式匹配要缓存的资源”一节。

    3. installMode:安装模式决定当浏览器上没有服务工作器的现有版本时,如何首次缓存资源。它支持两种缓存模式。

      1. prefetch:在开头缓存所有的资源、文件和 URL。服务工作器不等待应用请求资源。当应用请求时,资源在缓存中随时可用。

        这种方法对于根index.html文件、核心应用包、主样式表等非常有用。但是,预取安装模式可能会占用大量带宽。

        当没有提供配置值时,预取是默认的安装模式。

      2. lazy:仅当应用第一次请求资源时才缓存资源。如果配置了特定的资源,但从未请求过,则不会缓存该资源。它是高效的。但是,该资源只有在第二次使用后才能脱机使用。

  2. updateMode:更新模式决定当发现新版本的应用时如何缓存资源。这适用于已经安装在浏览器中的服务工作器(Angular 应用)。如您所知,与典型的 web 应用不同,服务工作器支持缓存 Angular 应用。它还允许您发现并安装可用的更新。它支持两种缓存模式。

    1. prefetch:更新应用时,下载并缓存所有资源、文件和 URL。服务工作器不等待应用请求资源。当应用请求某个资源时,该资源在缓存中随时可用。

      Default:当没有提供配置值时,使用为installMode设置的值。

    2. lazy:仅当应用第一次请求资源时才缓存资源。如果配置了特定的资源,但从未请求过,则不会缓存该资源。这是高效的。但是,该资源只有在第二次使用后才能脱机使用。

      如果installModeprefetch,该配置值将被覆盖。为了在懒惰模式下真正缓存,installMode也需要懒惰。

  3. dataGroups:assetGroups支持缓存应用资产,主要是静态资源,dataGroups帮助缓存动态数据请求。它是数据组对象的数组。可以配置多个dataGroups。您可以列出带有配置细节的 JSON 对象。一个dataGroup对象定义了以下字段:

    1. name:这是一个数据组的任意标题。

    2. urls:这是一个字符串数组,用于配置 URL 列表或匹配 URL 的模式列表。与assetGroups不同,该模式不支持与?匹配,因为它是 URL 中查询字符串的常用字符。

    3. version:这有助于识别dataGroup资源新版本的可用性。服务工作器丢弃旧版本的缓存,获取新数据,并缓存新的 URL 响应。如果没有提供版本,则默认为 1。

      对数据组进行版本控制非常有用,尤其是当资源与旧的 URL 响应不兼容时。

    4. cacheConfig:定义数据缓存策略的配置。它包括以下字段:

      1. maxSize:定义缓存数据大小的上限。通过设计来限制大小是一个很好的做法。浏览器(像其他平台一样)为每个应用管理和分配内存。如果应用超出上限,整个数据集和缓存都可能被收回。因此,设计一个系统来限制缓存大小,并防止由于驱逐导致的不可预见的结果。

      2. maxAge : dataCache本质上是动态的。通常情况下,数据在源位置会发生变化。缓存数据时间过长可能会导致应用使用过时的字段和记录。服务工作器配置提供了一种定期自动清除数据的机制,确保应用不使用过时的数据。举个例子,假设利率每天更新一次。这意味着缓存的利率值需要在 24 小时内到期。另一方面,用户的个人资料图片很少更新。因此,它们可以在缓存中存储更长时间。

        您可以使用以下一项或多项来限定最大年龄值:

        d代表天。比如用7d七天。

        h代表小时。比如用12h12 小时。

        m代表分钟。比如用30m30 分钟。

        s代表秒。比如用10s十秒。

        u代表毫秒,比如用500u代表半秒。

        您可以混合搭配来创建一个复合值。例如,2d12h30m代表 2 天 12 小时 30 分钟。

      3. timeout:根据dataCache策略(见下一条),数据请求经常试图通过网络使用响应。只有当网络请求耗时太长(或失败)时,它才使用缓存的响应。

        超时定义了一个值,超过该值后,服务工作人员将忽略网络请求,并使用缓存的值进行响应。

        您可以使用以下一项或多项来限定超时值:

        d代表天。比如用7d七天。

        h代表小时。比如用12h12 小时。

        m代表分钟。比如用30m30 分钟。

        s代表秒。比如用10s十秒。

        u代表毫秒,例如使用500u代表半秒。

        您可以混合搭配来创建一个复合值。例如,2d12h30m 代表 2 天 12 小时 30 分钟。

      4. 服务工作器可以使用以下两种策略之一:

        • 对于少数数据请求,Angular 应用可能会优先考虑性能,指示服务工作器使用缓存的响应。由于响应来自本地缓存,因此返回速度更快。新的网络服务请求仅在maxAge之后发送(参见前面关于maxAge的要点)。在一个例子中,它对于每晚更新的利率请求很有用。想象一下maxAge设置为 1d,服务工作器使用缓存 24 小时,之后缓存过期。

        • freshness:在许多情况下,Angular 应用会将服务工作器配置为在使用缓存数据之前,首先通过网络获取数据。想象一下,在慢速网络上,如果数据请求超时,服务工作器会使用缓存,以便应用仍然可用。

Web Arcade 的服务工作器配置

考虑列出 4-1 。这是为 Web Arcade 项目生成的默认配置文件。

  • 注意第 4 行的字段assetGroups。第 5 行和第 17 行之间是第一个资产组对象。该对象详细描述了服务工作器要缓存的资源。

    1. 字段nameassetGroup的任意标题(第 6 行)。它使用一个任意的名字app,这个名字代表了主要的应用资源,比如 JavaScript 应用包、样式表、index.html文件等等。

    2. 第 9 行和第 12 行之间的前几个资源文件包括以下内容:

      1. 与应用标题一起显示的收藏夹图标。

      2. Web Arcade 应用的根 HTML 文件。

      3. web 清单配置,它将应用标识为渐进式 web 应用。

    3. 注意第 13 行和第 14 行的星号,指示如何缓存所有的 JavaScript 和 CSS 文件(文件名以jscss结尾)。请参阅“模式匹配资源到缓存”一节,了解更多关于模式匹配资源到缓存的信息。

  • 注意第 7 行的安装模式是prefetch。它使服务工作器能够在开始时下载所有资产,而不管它们是否被立即利用。在一个示例中,一些 CSS 或 JS 文件可能在加载时不使用。它们只能在导航到不同的路由或页面后使用。但是,预回迁安装模式会下载整个文件列表。

    考虑到这些文件构成了应用,在开始时下载整个资产组是合适的。不要总是使用这种安装模式,因为它可能会导致大量的网络请求,降低应用的速度并产生冗余的网络流量。

  • 注意第 18 行和第 28 行之间的第二个资产组。

    1. 该资产组被命名为assets(第 19 行)。这些是静态资源,通常包括图像、图标、字体等。

    2. 资源文件包括/assets 文件夹下的所有文件。见第 24 行。注意通配符星号的用法。指目录assets下的所有文件和目录。请记住,在assets目录中,Web Arcade 的每一面都有六个骰子图像。

    3. 见第 25 行。它指示应用缓存给定扩展名列表中的所有文件。扩展名列表指示字体和图像文件。

  • 注意第 20 行上的安装模式是lazy它使服务工作器能够仅在需要时下载文件。与第一个资产组不同,服务工作器仅在应用请求时才开始下载文件。

--- ngsw-config.json ---

01: {
02:   "$schema": "./node_modules/@angular/service-worker/config/schema.json",
03:   "index": "/index.html",
04:   "assetGroups": [
05:     {
06:       "name": "app",
07:       "installMode": "prefetch",
08:       "resources": {
09:         "files": [
10:           "/favicon.ico",
11:           "/index.html",
12:           "/manifest.webmanifest",
13:           "/*.css",
14:           "/*.js"
15:         ]
16:       }
17:     },
18:     {
19:       "name": "assets",
20:       "installMode": "lazy",
21:       "updateMode": "prefetch",
22:       "resources": {
23:         "files": [
24:           "/assets/**",
25:           "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
26:         ]
27:       }
28:     }
29:   ]
30: }

Listing 4-1ngsw-config.json File

Note

第 21 行中的更新模式用于更新应用的服务工作程序的新版本。第六章详细介绍了更新服务工作器的方法和策略。

模式匹配资源到缓存

在清单 4-1 中,注意第 8、9、22 和 23 行中的资源文件路径。他们遵循一种模式。可以想象,在开发应用时,不可能单独列出所有的资源(文件和 URL)。该列表可以是动态的。即使它们都是已知的,在一个大型项目中列出每一项资产也是一项乏味的工作。

使用模式匹配列出资源。以下是模式匹配到文件或 URL 的链接的一些语法:

img/515699_1_En_4_Fig3_HTML.jpg

图 4-3

资产目录

  • 使用两个星号(**)来匹配路径段。这通常是为了包括所有文件和子目录。在一个例子中,assets目录有另一个名为icons的子目录和一个芯片图像列表。见图 4-3 。要包含assets下的所有文件和目录,请使用/assets/**

  • 要包含任何文件名或任何数量的字符,请使用单个星号(*)。这匹配零个或多个字符。它不包括子目录。

    1. 在一个例子中,assets/*包括目录资产中的所有文件。但是,它不包括图标目录。要显式包含icons目录,可以使用assets/icons/*,它包含了assets/icons/目录下的所有文件。

    2. 在另一个例子中,假设您只需要在目录图标中包含 PNG 文件。你可以使用assets/icons/*.png。这将选择图 4-3 中的所有图标文件。如果目录有文件,比如说icon.jpeg,就会被排除。这是假设。注意,第 13 行和第 14 行遵循相似的模式匹配,包括所有的.js (JavaScript)和.css (CSS)文件。

    3. 您可以重写第 24 行,如清单 4-2 所示。

// Comment- rewriting "/assets/**",

"files": [
  "/assets/*.png",
  "/assets/icons/*.png"
]

Listing 4-2ngsw-config.json File

这是一个特殊的指令,包括目录/assets/assets/icons 下所有扩展名为.png的文件。最初的语句是通用的,它包括了assets目录下的所有内容。

当您可以包含assets目录下的所有内容时,为什么要编写类似于清单 4-2 的特定模式呢?记住服务工作器的安装阶段。如果单个文件下载失败,它要么全部安装,要么什么都不安装。尽管这不会影响应用的功能,但服务工作器的安装会推迟到下次应用重新加载时进行。这种情况可能发生在低带宽网络上。配置通用模式可能会包含不必要的文件,当这些文件无法下载时,可能会导致服务工作器安装出现问题。因此,尽你所能,具体一点是个好习惯。然而,如果您知道assets目录下的所有内容无论如何都需要缓存,那么使用通用规则并简化配置。通常,这取决于您是使用通用模式还是特定模式。

Note

匹配模式时,两个行项目可能匹配一个文件。一旦找到匹配项,服务工作程序就会缓存或排除某个文件。它不会继续在接下来的几个项目中寻找模式。

假设/assets/**在数组的顶部。匹配assets下的所有文件,特定规则永不运行。因此,在列表底部指定通用规则;特定的规则应该在数组的开头。

到目前为止,您已经看到了包含文件的模式。您可以使用感叹号(!)来匹配排除文件。在一个例子中,假设您想要排除缓存所有的地图文件。映射文件包含 JavaScript 代码的符号,这有助于调试文件的缩小版本。它用于调试,对于用户来说,通过服务工作器缓存这些文件没有任何价值。因此,排除模式为!/**/*.map的地图文件。

请注意,您选择了带有*.map的地图文件,并在开头用感叹号将其排除。

Note

要匹配单个字符,使用?。我们很少能如此具体地知道一个文件或目录名中的字符数。因此,在ngsw-config.json中很少使用。

浏览器支持

考虑图 4-4 ,它描述了服务工作器的浏览器支持及其特性。注意,数据是在 Mozilla 上捕获的。org 网站,在 https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker 。对于 web 技术来说,这是一个可靠的开源平台。Mozilla 是开放网络的倡导者,也是包括 Firefox 浏览器在内的安全免费互联网技术的先驱。

img/515699_1_En_4_Fig4_HTML.png

图 4-4

服务工作器浏览器支持

Note

CanIUse.com 是浏览器兼容性的另一个优质信息来源。作为 Mozilla 的替代产品,请尝试使用 https://caniuse.com/serviceworkers 了解更多信息。

摘要

本章介绍了服务工作器和服务工作器生命周期。它们是在浏览器后台运行的代理。服务工作器拦截来自应用的所有网络请求。服务工作器缓存静态和动态资源,并以编程方式使用缓存的应用脚本、图像、数据等。它们使应用即使在与网络断开连接和在低速网络上也能运行。

Exercise

  • 在 Web Arcade 中选择并使用谷歌字体。启用服务工作器缓存字体文件。不要将字体复制到项目中。使用 CDN 位置。

  • 假设一个生产应用的发布被安排在每个季度一次。不管服务工作器功能更新的频率如何,图标、图像和样式表可能每个季度都会发生变化。每 12 周使此类资源的缓存过期。

  • 浏览并查看安装在您最喜欢的浏览器中的所有服务工作器。

五、服务工作器缓存数据

服务工作器用于缓存数据响应。到目前为止,您已经看到了如何创建一个新的 Angular 应用,如何将应用配置为可安装的,以及如何缓存应用,以便即使在脱机时也可以访问它。本章介绍了如何缓存来自 HTTP 服务的数据响应。

本章首先创建一个新组件来检索和显示来自 HTTP 服务的数据。接下来,讨论如何创建一个接口,作为服务和 Angular 应用之间的契约。接下来,您将学习如何创建 Node.js Express 模拟服务,为 Angular 应用提供数据。它在 Angular 应用之外的独立进程中运行。本章详细介绍了如何创建一个 Angular 服务,它使用一个现成的HttpClient服务来调用 HTTP 服务。

既然您已经集成了 HTTP 服务并访问了数据,本章将详细介绍如何配置 Web Arcade 来缓存数据响应。它详细描述了配置,并展示了一个带有模拟离线浏览器的缓存数据响应。

记住,网络街机是一个在线游戏系统。想象一个列出应用上可用棋盘游戏的屏幕,如图 5-1 所示。按照说明来构建这个组件。它在 HTML 表格中显示数据。在加载页面时,组件调用服务来检索 Web Arcade 棋盘游戏。

img/515699_1_En_5_Fig1_HTML.jpg

图 5-1

棋盘游戏列表

添加一个组件来列出棋盘游戏

首先创建一个列出棋盘游戏的组件。记住第三章中的“Angular 组件”一节。通过运行以下命令创建一个新组件。它将为新组件搭建支架。

% ng generate component components/board-games

之前,您在App组件中使用了dice组件。更新它以使用新的组件,如清单 5-1 所示。注意被称为wade-dicedice组件已经被注释了。

<div class="container align-center">
   <!-- <wade-dice></wade-dice> -->
   <wade-board-games></wade-board-games>
</div>

Listing 5-1Use the Board Component

Note

Angular 单页应用(SPAs)使用路由在具有独立组件的两个页面之间导航。清单 5-1 是临时的,所以本章的重点是数据缓存。第章第八章介绍 Angular 路由。

定义棋盘游戏的数据结构

接下来,定义棋盘游戏页面的数据结构。您创建一个 TypeScript 接口来定义数据结构。它定义了棋盘游戏数据对象的形状。TypeScript 使用一个接口来定义一个契约,这在 Angular 应用中以及与提供棋盘游戏数据的外部远程服务一起使用时非常有用。

TypeScript 接口强制执行棋盘游戏所需的字段列表。如果由于远程服务中的问题或 Angular 应用中的错误导致所需字段丢失,您将会注意到一个错误。接口充当 Angular 应用和外部 HTTP 服务之间的契约。

运行以下命令创建接口。它在名为common的新目录中创建了一个名为board-games-entity.ts的新文件。典型地,数据结构/实体在 Angular 应用中使用。因此,将这个目录命名为common

ng generate interface common/board-games-entity

清单 5-2 定义了棋盘游戏的特定区域。远程服务应该返回相同的字段。组件对数据使用这种形状和结构。将代码添加到board-games-entity.ts

export interface BoardGamesEntity {
   title: string;
   description: string;
   age: string;
   players: string;
   origin: string;
   link: string;
   alternateNames: string;
}

/* Multiple games data returned, hence creating an Array */
export interface GamesEntity {
   boardGames: Array<BoardGamesEntity>;
}

Listing 5-2Interfaces for Board Games

BoardGamesEntity代表单一的棋盘游戏。考虑到 Web Arcade 将有多个游戏,GamesEntity包括了一系列的棋盘游戏。后来,GamesEntity可以扩展到网络街机系统中其他类别的游戏。

模拟数据服务

一个典型的服务是从数据库或后端系统中检索和更新数据,这超出了本书的范围。然而,为了与 RESTful 数据服务集成,本节将详细介绍如何开发模拟响应和数据对象。模拟服务以 JavaScript 对象符号(JSON)格式返回棋盘游戏数据。它可以很容易地与在前面的“向列表棋盘游戏添加组件”一节中创建的 Angular 组件集成。

您将使用 Node.js 的 Express 服务器来开发模拟服务。按照这些说明创建新服务。

使用 Express application generator 轻松生成 Node.js Express 服务。运行以下命令进行安装:

npm install --save-dev express-generator

# (or)

yarn add --dev express-generator

Note

注意带有npm命令的--save-dev选项和带有yarn命令的--dev选项。它在package.jsondev-dependencies安装这个包,使它成为一个开发工具。它不会包含在生产版本中,这有助于减少内存占用。参见清单 5-3 ,第 15 行。

01: {
02:  "name": "web-arcade",
03:  "version": "0.0.0", /* removed code for brevity */
04:  "dependencies": {
05:    "@angular/animations": "~12.0.1",
06:    /* removed code for brevity */
07:    "zone.js": "~0.11.4"
08:  },
09:  "devDependencies": {
10:    "@angular-devkit/build-angular": "~12.0.1",
11:    "@angular/cli": "~12.0.1",
12:    "@angular/compiler-cli": "~12.0.1",
13:    "@types/jasmine": "~3.6.0",
14:    "@types/node": "¹².11.1",
15:    "express-generator": "⁴.16.1",
16:    "jasmine-core": "~3.7.0",
17:    "karma": "~6.3.0",
18:    "karma-chrome-launcher": "~3.1.0",
19:    "karma-coverage": "~2.0.3",
20:    "karma-jasmine": "~4.0.0",
21:    "karma-jasmine-html-reporter": "¹.5.0",
22:    "typescript": "~4.2.3"
23:  }
24: }

Listing 5-3Package.json dev-dependencies

接下来,为模拟服务创建一个新目录;命名为mock-services(一个任意的名字)。将目录更改为mock-services。运行以下命令创建新的快速服务。它构建了新的 Node.js Express 应用。

npx express-generator

Note

npx命令首先检查包的本地node_modules。如果找不到,该命令会将包下载到本地缓存并运行该命令。

前面的命令运行,即使没有前面步骤(npm install --save-dev express-generator)中的dev-dependency安装。如果您不打算经常运行这个命令,您可以跳过dev-dependency安装。

接下来,运行mock-services目录中的npm install(或yarn install)。

在 JSON 文件中创建和保存棋盘游戏数据。代码示例将其保存到[application-directory]/mock-services/data/board-games.json。服务器端 Node.js 服务将这些字段和值返回给 Angular 应用。该结构与清单 5-2 中定义的角接口结构相匹配。参见清单 5-4 。

{
   "boardGames": [
       {
           "title": "Scrabble",
           "description": "A crossword game commonly played with English alphabets and words",
           "age": "5+",
           "players": "2 to 5",
           "origin": "Started by an architect named Alfred Mosher Butts in the year 1938",
           "link": "https://simple.wikipedia.org/wiki/Scrabble",
           "alternateNames": "Scrabulous (a version of the game on Facebook)"
       },

       {
           "title": "Checkers",
           "description": "Two players start with dark and light colored pieces. The pieces move diagonally.",
           "age": "3+",
           "players": "Two players",
           "origin": "12th century France",
           "link": "https://simple.wikipedia.org/wiki/Checkers",
           "alternateNames": "Draughts"
       }

/* You may extend additional mock games data*/
   ]
}

Listing 5-4Board Games Mock Data

接下来,更新模拟服务应用以返回以前的棋盘游戏数据。在mock-services/routes下创建一个名为board-games.js的新文件。添加清单 5-5 中的代码。

01: var express = require('express'); // import express
02: var router = express.Router(); // create a route
03: var boardGames = require('../data/board-games.json');
04:
05: /* GET board games listing. */
06: router.get('/', function(req, res, next) {
07:     res.setHeader('Content-Type', 'application/json');
08:     res.send(boardGames);
09: });
10:
11: module.exports = router;

Listing 5-5New API Endpoint That Returns Mock Board Games Data

考虑以下解释:

  • 第 3 行在一个变量上导入并设置棋盘游戏模拟数据。

  • 第 6 到 9 行创建了返回棋盘游戏数据的端点。

  • 注意第 6 行中的get()函数。端点响应 HTTP GET 调用,该调用通常用于检索数据(与创建、更新或删除相对)。

  • 第 7 行将响应内容类型设置为application/json,确保客户端浏览器准确地解释响应格式。

  • 第 8 行用棋盘游戏数据响应客户机。

  • 第 11 行导出了封装服务端点的路由器实例。

接下来,端点需要与路由相关联,以便在客户端请求数据时调用前面的代码。在服务应用(mock-services/app.js)的根目录下编辑app.js。将清单 5-6 中粗体显示的代码行(第 9 行和第 25 行)添加到文件中。

07: var indexRouter = require('./routes/index');
08: var usersRouter = require('./routes/users');
09: var boardGames = require('./routes/board-games');
10:
11: var app = express();
12:
13: // view engine setup
14: app.set('views', path.join(__dirname, 'views'));
15: app.set('view engine', 'jade');
16:
17: app.use(logger('dev'));
18: app.use(express.json());
19: app.use(express.urlencoded({ extended: false }));
20: app.use(cookieParser());
21: app.use(express.static(path.join(__dirname, 'public')));
22:
23: app.use('/', indexRouter);
24: app.use('/users', usersRouter);
25: app.use('/api/board-games', boardGames);

Listing 5-6Integrate the New Board Games Endpoint

考虑以下解释:

  • 第 9 行导入了在前面的清单 5-5 中导出的棋盘游戏路由实例。

  • 第 25 行将路由/api/board-games添加到应用中。当客户端调用这个端点时,新服务被调用。

使用命令npm start 运行模拟服务。默认情况下,它在端口 3000 上运行 Node.js Express 服务应用。通过访问http://localhost:3000/api/board-games 访问新端点。见图 5-2 。

img/515699_1_En_5_Fig2_HTML.jpg

图 5-2

通过浏览器访问棋盘游戏端点

Note

请注意,您正在一个单独的端口 3000 上运行服务应用。记住,在前面的例子中,Angular 应用运行在端口 8080(使用 Http-Server)和 4200(使用在内部使用 Webpack 的ng serve命令)上。运行在其中一个端口上的 Angular 应用应该连接到运行在端口 3000 上的服务实例。

调用 Angular 应用中的服务

本节详细介绍了如何更新 Angular 应用来使用 Node.js 服务中的数据。在典型的应用中,Node.js 服务是从数据库或其他服务访问数据的服务器端远程服务。

在 Angular 应用中配置服务

Angular 提供了一种简单的方法来配置各种值,包括远程服务 URL。在 Angular 项目中,注意目录src/environment。默认情况下,您将看到以下内容:

  • environment.ts:这是开发人员在本地主机上使用的调试构建配置。通常,ng serve命令会使用它。

  • environment.prod.ts:这是针对生产部署的。运行ng build(或yarn buildnpm run build)使用这个配置文件。

编辑文件src/environments/environment.ts并添加清单 5-7 中的代码。它有一个到服务端点的相对路径。

1: export const environment = {
2:     boardGameServiceUrl: `/api/board-games`,
3:     production: false,
4:   };
5:

Listing 5-7Integrate the New Board Games Endpoint

考虑以下解释:

  • 第 2 行添加了服务端点的相对路径。在调用服务时,您将导入并使用配置字段boardGameServiceUrl

  • 第 3 行将production设置为false。记住,文件environment.tsng serve命令一起使用,后者在 Webpack 的帮助下运行一个调试版本。它在替换环境文件environment.prod.ts中被设置为true

创建有 Angular 的服务

Angular 服务是可重用的代码单元。Angular 提供了创建服务、实例化服务以及将服务注入组件和其他服务的方法。Angular 服务有助于分离关注点。Angular 组件主要关注表示逻辑。另一方面,您可以将服务用于其他不包括表示的可重用功能。请考虑以下示例:

  • 服务可以用于在组件之间共享数据。想象一个有用户列表的屏幕。假设列表由一个UserList组件显示。用户可以选择一个用户。应用导航到另一个屏幕,加载另一个组件,比如说UserDetails。“用户详细信息”组件显示系统中用户的附加信息。用户详细信息组件需要关于所选用户的数据,以便它可以检索和显示附加信息。

您可以使用服务来共享选定的用户信息。第一个组件将选定的用户详细信息更新到公共服务。第二个组件从同一个服务中检索数据。

Note

服务是在组件之间共享数据的一种简单易行的方式。然而,对于大型应用,建议采用 Redux 模式。它有助于维护应用状态,确保单向数据流,提供选择器以便于访问 Redux 存储中的状态,并具有更多功能。对于 Angular,NgRx 是一个流行的库,它实现了 Redux 模式及其概念。

组件如何共享同一个服务实例?有关如何提供 Angular 服务以及如何在 Angular 应用中管理服务实例的详细信息,请参见下一节。

  • 服务可以用来聚集和转换 JSON 数据。Angular 应用可能从各种数据源获取数据。创建一个具有可重用功能的服务来聚合和返回数据。这使得组件可以很容易地将 JSON 对象用于表示。

  • 服务用于从远程 HTTP 服务中检索数据。在这一章中,您已经构建了一个与 Angular 应用共享棋盘游戏数据的服务。在单独的进程中运行的 Node.js Express 服务器(理想情况下在远程服务器上)通过 HTTP GET 调用共享这些数据。

通过运行以下 Angular CLI 命令创建新服务。您将使用这个服务来调用在上一节中构建的api/board-games服务。

ng generate service common/games

CLI 命令创建新的游戏服务。它在目录common中创建以下文件:

  • common/games.services.ts:添加 Angular 服务代码的 TypeScript 文件,Angular 服务代码对游戏数据进行 HTTP 调用

  • common/games.services.spec.ts:针对games.service.ts中函数的单元测试文件

考虑为游戏服务列出 5-8 。添加一个名为getBoardNames()的新函数来调用 HTTP 服务。

01: @Injectable({
02:     providedIn: 'root'
03: })
04: export class GamesService {
05:
06:     constructor() { }
07:
08:     getBoardGames(){
09:     }
10: }

Listing 5-8Angular Service Skeleton Code

提供服务

注意第 1 行到第 3 行中的代码语句。这些行包含Injectable装饰器,而provideIn位于根级别。Angular 为整个应用共享一个实例。以下是备选方案:

  • 在模块级提供:服务实例在模块内可用并共享。后面的章节给出了更多关于 Angular 模块的细节。

  • 在组件级提供:服务实例被创建并可用于组件及其所有子组件。

服务一旦提供,就需要注入。一个服务可以被注入到一个组件或另一个服务中。在当前示例中,棋盘游戏组件需要数据,以便列出游戏供用户查看。注意,在前面的清单 5-8 中,代码创建了一个名为getBoardGames()的新函数,用于从远程 HTTP 服务中检索列表。

GamesService注入BoardGamesComponent,如清单 5-9 第 5 行所示。构造函数创建了一个名为gameService的新字段,类型为GamesService。该语句将服务注入到组件中。

01: export class BoardGamesComponent implements OnInit {
02:
03:     games = new Observable<GamesEntity>();
04:
05:     constructor(private gameService: GamesService) { }
06:
07:     ngOnInit(): void {
08:       this.games = this.gameService.getBoardGames();
09:     }
10:
11:   }

Listing 5-9Inject Games Service into a Component

Note

第 7 行的ngOnInit()函数是一个 Angular 生命周期钩子。它在框架完成组件及其属性的初始化后被调用。这个函数非常适合在组件中进行额外的初始化,包括服务调用。

清单 5-9 中的第 8 行调用检索棋盘游戏数据的服务函数。该数据是组件初始化的一部分,因为组件的主要功能是显示游戏列表。

HttpClient 服务

接下来,调用远程 HTTP 服务。Angular 提供的HttpClient服务是@angular/common/http 套餐的一部分。它提供了一个 API 来调用各种 HTTP 方法,包括 GET、POST、PUT 和 DELETE。

作为先决条件,从@angular/common/http导入HttpClientModule。将它(HttpClientModule)添加到 Angular 模块的导入列表中,如清单 5-10 ,第 7 行和第 13 行所示。

01: import {HttpClientModule} from '@angular/common/http';
02:
03: @NgModule({
04:   declarations: [
05:  // pre-existing declaratoins
06:   ],
07:   imports: [
08:    // pre-existing imports
09:     BrowserModule,
10:     HttpClientModule,
11:     AppRoutingModule,
12:
13:   ],
14:   providers: [],
15:   bootstrap: [AppComponent]
16: })
17: export class AppModule { }
18:

Listing 5-10Import HttpClientModule

请记住清单 5-5 (第 6 行)中的服务通过 GET 调用将数据返回给 Angular 应用。因此,我们将在HttpClient实例上使用get()函数来调用服务。记住,我们已经创建了函数getBoardGames()作为GamesService的一部分(参见清单 5-8 ,第 8 行)

接下来,将HttpClient服务注入到GamesService中,并使用get() API 进行 HTTP 调用。参见清单 5-11 。

01: import { Injectable } from '@angular/core';
02: import { HttpClient } from '@angular/common/http';
03: import { environment } from 'src/environments/environment';
04: import { GamesEntity } from './board-games-entity';
05: import { Observable } from 'rxjs';
06:
07:
08: @Injectable({
09:   providedIn: 'root'
10: })
11: export class GamesService {
12:
13:   constructor(private client: HttpClient) { }
14:
15:   getBoardGames(): Observable<GamesEntity>{
16:     return this
17:       .client
18:       .get<GamesEntity>(environment.boardGameServiceUrl);
19:   }
20: }
21:

Listing 5-11GamesService Injects and Uses HttpClient

考虑以下解释:

  • 第 13 行将HttpClient注入GamesService 注意这个字段的名字(HttpClient的一个实例)是client。它是一个私有字段,因此只能在服务类中访问。

  • 第 16 到 18 行的语句调用了client.get() API。因为客户机是该类的一个字段,所以使用this关键字来访问它。

  • get()函数接受一个参数,即服务的 URL。注意第 3 行中环境对象的 import 语句。它导入从环境配置文件导出的对象。参见清单 5-7 。它是环境配置文件之一。使用配置中的boardGameServiceUrl字段(列表 5-11 ,第 18 行)。您可能在环境文件中配置了多个 URL。

  • 请注意,get()函数应该检索GamesEntity。它是在清单 5-2 中创建的。

  • getBoardGames()函数返回一个Observable<GamesEntity> Observable 对于异步函数调用很有用。远程服务可能需要一些时间来返回数据,比如几毫秒或者几秒钟。因此,服务函数返回一个可观察的。订户提供函数回调。一旦数据可用,观察对象就执行函数回调。

  • 注意,第 16 行返回了get()函数调用的输出。它返回一个指定类型的Observable。您在第 18 行指定了类型GamesEntity。因此,它返回一个类型为GamesEntityObservable。与第 15 行getBoardGames()的返回类型匹配。

现在,服务功能准备好了。再次回顾清单 5-9 ,它是一个组件 TypeScript 类。它调用服务函数并将类型Observable<GamesEntity>的返回值设置为一个类字段。class 字段使用 HTML 模板中返回的对象。模板文件在页面上呈现棋盘游戏列表。参见清单 5-12 。

01: <div>
02:     <table>
03:         <tr>
04:             <th> Title </th>
05:             <th> History </th>
06:         </tr>
07:         <ng-container *ngFor="let game of (games | async)?.boardGames">
08:             <tr>
09:                 <td>
10:                     <strong>
11:                         {{game.title}}
12:                     </strong>
13:                     <span>{{game.alternateNames}}</span>
14:                 </td>
15:                 <td>{{game.origin}}</td>
16:             </tr>
17:             <tr >
18:                 <td class="last-cell" colspan="2">{{game.description}}</td>
19:             </tr>
20:         </ng-container>
21:
22:     </table>
23: </div>

Listing 5-12Board Games Component Template Shows List of Games

考虑以下解释:

  • 该模板将列表呈现为 HTML 表格。

  • 注意,在第 7 行中,*ngFor指令遍历了boardGames 见清单 5-2 。注意,boardGames是接口GamesEntity上的一个数组。

  • 该模板显示了实体中每个游戏的字段。请参见第 11、13、15 和 18 行。它们显示了字段titlealternateNamesorigindescription

  • 记住,类字段games是用服务返回的值设置的。该字段在模板中使用。见第 7 行。

  • 注意第 7 行带有async ( | async)的管道。它应用在Observable上。记住,服务返回一个Observable。如前所述,Observable对于异步函数调用非常有用。远程服务可能需要几毫秒或者几秒钟的时间来返回数据。当数据可用时,换句话说,当从服务获得数据时,模板使用games Observable上的字段boardGames

缓存棋盘游戏数据

到目前为止,我们已经创建了一个 HTTP 服务来提供棋盘游戏数据,创建了一个 Angular 服务来使用 HTTP 服务获取数据,并添加了一个新组件来显示列表。现在,配置服务工作器来缓存棋盘游戏数据(甚至其他 HTTP 服务响应)。

记住,在上一章中,我们列出了各种 Angular 维修工人的配置。如您所见,Angular 使用一个名为ngsw-config.json的文件进行服务工作器配置。在本节中,您将添加一个dataGroups部分来缓存 HTTP 服务数据。请参见清单 5-13 了解缓存棋盘游戏数据的新配置。

01: "dataGroups": [{
02:     "name": "data",
03:     "urls": [
04:       "api/board-games"
05:     ],
06:     "cacheConfig": {
07:       "maxAge": "36h",
08:       "timeout": "10s",
09:       "maxSize": 100,
10:       "strategy":"performance"
11:     }
12:   }]

Listing 5-13Data Groups Configuration for a Service Worker in an Angular Application

考虑以下解释:

  • 第 4 行配置服务 URL 来缓存数据。它是一个数组,我们可以在这里配置多个 URL。

  • URL 支持匹配模式。例如,您可以使用api/*来配置所有的 URL。

  • 作为缓存配置的一部分(cacheConfig),参见第 10 行。将strategy设置为performance。这指示服务工作器首先使用缓存的响应以获得更好的性能。或者,您可以使用freshness,它首先进入网络,仅在应用离线时使用缓存。

  • 注意maxAge被设置为 36 小时,在此之后,服务工作器清除缓存的响应(棋盘游戏)。缓存数据时间过长可能会导致应用使用过时的字段和记录。服务工作器配置提供了一种定期自动清除数据的机制,确保应用不会使用过时的数据。

  • 超时设置为 10 秒。这个要看strategy。假设strategy被设置为freshness,10 秒钟后,服务工作器使用缓存的响应。

  • maxSize设置为 100 条记录。通过设计来限制大小是一个很好的做法。浏览器(像其他平台一样)为每个应用管理和分配内存。如果应用超出上限,整个数据集和缓存都可能被收回。

清单 5-13 有一个单一的数据组配置对象。随着我们进一步开发应用,额外的服务可能会有稍微不同的缓存需求。例如,玩家列表可能需要是最新的。如果你的朋友加入了街机,你更愿意看到她被列出来而不是显示旧的列表。因此,您可以将策略更改为freshness 将这个 URL 配置作为另一个对象添加到dataGroups数组中。另一方面,对于适合当前配置的服务,将 URL 添加到第 4 行的urls字段。

运行 Angular 构建并启动 Http-Server 来查看变化。请参见以下命令:

yarn build && http-server dist/web-arcade --proxy http://localhost:3000

请参见图 5-3 了解服务工作器缓存的服务响应。

img/515699_1_En_5_Fig3_HTML.png

图 5-3

服务工作器缓存的服务响应

Angular 模块

传统上,Angular 有自己的模块化系统。新框架(Angular 2 和更高版本)使用 NgModules 为应用带来模块化。Angular 模块封装了包括组件、服务、管道等在内的指令。创建 Angular 模块以对特征进行逻辑分组。见图 5-4 。

img/515699_1_En_5_Fig4_HTML.png

图 5-4

Angular 模块

所有 Angular 应用至少使用一个根模块。通常,该模块被命名为AppModule,并在src/app/app.module.ts中定义。一个模块可以导出一个或多个功能。应用中的其他模块可以导入导出的组件和服务。

Note

Angular 模块独立于 JavaScript (ES6)模块。它们相辅相成。Angular 应用同时使用 JavaScript 模块和 Angular 模块。

摘要

本章提供了为棋盘游戏列表创建新组件的说明。通过这个代码示例,它演示了服务工作器如何缓存来自 HTTP 服务的数据响应。它提供了使用 Angular CLI 创建棋盘游戏组件的说明。您还更新了应用以使用这个新组件来代替dice

它还定义了 Angular 应用和外部 HTTP 服务之间的数据契约,详细介绍了如何创建 Node.js Express 服务来为 Angular 应用提供数据,并介绍了 Angular 服务。

Exercise

  • 在 Node.js Express 应用中创建一个新的路由,用于显示拼图游戏列表。

  • 创建一个 Angular 服务来使用新的 jigsaw puzzles 服务端点并检索数据。

  • 确保最新的拼图数据对用户可用。仅在用户离线或失去连接时缓存。

  • 对于新服务,将配置为如果服务在一分钟后没有响应,则使用缓存中的数据。

六、升级应用

到目前为止,您已经创建了一个 Angular 应用、注册的服务工作器和缓存的应用资源。本章详细介绍了如何发现应用的更新,如何与用户通信,以及如何处理事件以顺利升级到下一版本。

本章广泛使用 Angular 的SwUpdate服务,该服务提供了识别和升级应用的现成特性。它从包含(导入和注入)服务的指令开始。接下来,它详细介绍了如何识别可用的升级并激活升级。它还详细介绍了如何定期检查升级。最后,本章详细介绍了如何处理边缘情况,即浏览器清理未使用的脚本时的错误场景。

考虑到 Web Arcade 应用是可安装的,您需要一种机制来寻找更新,通知用户应用的新版本,并执行升级。服务工作器管理 Angular 应用的安装和缓存。本章详细介绍了如何使用SwUpdate,这是 Angular 提供的一种开箱即用的服务,旨在简化服务工作器的沟通。当新版本的应用可用、下载和激活时,它提供对事件的访问。您可以使用此服务中的功能定期检查更新。

SwUpdate 入门

本节向您展示如何通过导入和注入服务来开始使用SwUpdate服务。SwUpdate服务是 Angular 模块ServiceWorkerModule的一部分,已经在AppModule的导入列表中引用。在app.module.ts文件中验证示例应用中的代码。当您运行第二章中的 Angular CLI ng add @angular/pwa命令时,它已包含在内。考虑清单 6-1 ,第 15 行和第 22 行。

01: import { NgModule } from '@angular/core';
02: import { BrowserModule } from '@angular/platform-browser';
03: import { environment } from '../environments/environment';
04: import { ServiceWorkerModule } from '@angular/service-worker';
05: import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
06:
07: import { AppComponent } from './app.component';
08:
09: @NgModule({
10:   declarations: [
11:     AppComponent,
12:   ],
13:   imports: [
14:     BrowserModule,
15:     ServiceWorkerModule.register('ngsw-worker.js', {
16:       enabled: environment.production,
17:       // Register the ServiceWorker as soon as the app is stable
18:       // or after 30 seconds (whichever comes first).
19:       registrationStrategy: 'registerWhenStable:30000'
20:     }),
21:     BrowserAnimationsModule
22:   ],
23:   providers: [],
24:   bootstrap: [AppComponent]
25: })
26: export class AppModule { }
27:

Listing 6-1ServiceWorkerModule Imported in AppModule

确保ServiceWorkerModule如图所示导入(粗体)。这使得SwUpdate服务可以随时使用。创建一个新的 Angular 服务来封装代码,以便识别服务工作器的新版本、与用户通信以及管理细节。这有助于代码的可重用性和关注点的分离。要安装该服务,请运行以下命令:

ng g s common/sw-communication

Note

在代码片段中,g是“生成”的缩写,s是“服务”的缩写。

可以把前面的命令改写成ng generate service common/sw-communication

Angular CLI 命令在目录common中创建了名为SwCommunicationService的新服务。默认情况下,它是在根级别提供的。将SwUpdate服务导入并注入SwCommunicationService,如清单 6-2 所示。

01: import { Injectable } from '@angular/core';
02: import { SwUpdate } from '@angular/service-worker';
03:
04: @Injectable({
05:   providedIn: 'root'
06: })
07: export class SwCommunicationService {
08:   constructor(private updateSvc: SwUpdate)
09:   }
10: }

Listing 6-2Scaffolded SwCommunicationService

2、8 号线导入注入SwUpdate角服务。第 5 行提供了根级别的服务。虽然服务是在根级别提供的,但它还没有在应用中使用。与 Web Arcade 中创建的其他服务不同,它需要在后台运行,当您启动应用时或定期运行。因此,在根组件AppComponent中导入并注入swCommunicationService,如清单 6-3 ,第 2 行和第 9 行所示。

01: import { Component } from '@angular/core';
02: import { SwCommunicationService } from 'src/app/common/sw-communication.service';
03: @Component({
04:   selector: 'app-root',
05:   templateUrl: './app.component.html',
06:   styleUrls: ['./app.component.sass']
07: })
08: export class AppComponent {
09:   constructor(private commSvc: SwCommunicationService){
10:   }
11: }

Listing 6-3Import and Inject SwCommunicationService in AppComponent

识别应用的更新

接下来,更新SwCommunicationService以识别应用的更新版本是否可用。SwUpdate服务提供了一个名为available的可观察对象。订阅这个可观察的。当服务工作器识别出应用的更新版本时,就会调用它。

Note

此时,您已经知道服务器上有可用的升级。您尚未下载并激活它。

考虑列出 6-4 。

1: export class SwCommunicationService {
2:   constructor(private updateSvc: SwUpdate
3:    ){
4:     this.updateSvc.available.subscribe( i => {
5:       console.log('A new version of the application available', i.current, i.available);
6:     });
7:   }
8: }

Listing 6-4Identify a New Version of the Application

考虑以下解释:

img/515699_1_En_6_Fig1_HTML.png

图 6-1

基于可观察数据的结果

  • 如何在updateSvc上使用对象available(SwUpdate的一个对象),见第 4 行。它是可观察的,当应用的新版本可用时,它发送值。

  • 第 5 行打印currentavailable对象。考虑图 6-1 中的结果。请注意消息中的版本号。

Note

清单 6-4 不会启动对新版本的检查。当一个新版本被识别时,订阅回调(第 5 行)运行。请参阅“检查新版本”一节,定期检查新版本。

另外,新版本还没有下载和激活。

  • 记住应用的ngsw-config.json中的appData字段。(参见第四章。)对象currentavailable包括来自appData 的数据。在示例应用中,我们添加了一个描述应用变化的字段名称。图 6-1 中的结果打印出currentavailable对象。它们是应用当前版本和新版本中来自ngsw-config.json的字段。ngsw-config.json见清单 6-5 。
01: {
02:   "appData": {"name": "New games available and few bug fixes"},
03:   "$schema": "./node_modules/@angular/service-worker/config/schema.json",
04:   "index": "/index.html",
05:   "assetGroups": [
06:     {
07:       // Removed code for brevity. See code sample for the complete file.
42:     }]
43: }

Listing 6-5ngsw-config.json with appData

如前所述,available观察者验证服务工作器的新版本是否已经准备好并可用。这并不意味着它还在使用中。您可以提示用户更新应用。这为用户提供了完成当前工作流的机会。例如,在 Web Arcade 中,您不希望在用户玩游戏时重新加载并升级到新版本。

识别更新被激活的时间

到目前为止,您已经确定了升级是否可用。此步骤允许您识别已激活的升级。一旦服务工作器开始提供新版本应用的内容,就会触发activated观察。

考虑在SwCommunicationService中列出使用activated可观测值的 6-6 。

01: export class SwCommunicationService {
02:
03:     constructor(private updateSvc: SwUpdate) {
04:       this.updateSvc
05:         .available
06:         .subscribe( i => {
07:             console.log('A new version of the application available', i.current, i.available);
08:       });

09:       this.updateSvc
10:         .activated
11:         .subscribe( i =>
12:             console.log('A new version of the application activated', i.current, i.previous));
13:     }
14:
15: }

Listing 6-6Activated Observable on SwUpdate

请参见第 9 行到第 13 行。请注意,您订阅了activated可观察值。它在激活应用的新版本时被触发。见第 12 行的console.log。这印出了currentprevious类似于available可观察对象上的currentavailable对象(见清单 6-4 ),激活前。由于激活的可观察对象是在激活后触发的,所以可用版本现在是当前版本。结果如图 6-2 所示。

img/515699_1_En_6_Fig2_HTML.png

图 6-2

激活的可观察对象的结果

Note

类似于available可观察对象,currentprevious对象包括来自ngsw-config.jsonappData,用于应用的各个版本。

使用 SwUpdate 服务激活

当用户在新窗口中打开应用时,服务工作器会检查是否有新版本可用。服务工作器可能仍然从缓存中加载应用。这是因为新版本可能尚未下载和激活。它触发了available事件。对available可观察对象的subscribe回调将被调用(类似于清单 6-4 )。通常,下一次用户试图在新窗口中打开应用时,服务工作器和应用的较新版本被提供。确切的行为取决于配置和其他一些因素。

但是,一旦知道有新版本可用,您可以选择激活新版本。SwUpdate服务提供了activateUpdate() API 来激活应用的新版本。该函数返回一个promise<void>??。激活更新后,调用承诺的成功回调。考虑清单 6-7 ,它激活了更新。

Note

该部分并不提示用户选择更新新版本。当您进行到下一部分时,您将添加代码来提醒用户有新版本的应用可用。

01: export class SwCommunicationService {
02:
03:     constructor(private updateSvc: SwUpdate) {
04:       this.updateSvc
05:         .available
06:         .subscribe( i => {
07:             console.log('A new version of the application available', i.current, i.available);
08:             this.updateSvc
09:                 .activateUpdate()
10:                 .then( () => {
11:                     console.log("activate update is successful");
12:                     window.location.reload();
13:                 });
14:       });
15:
16:       this.updateSvc
17:         .activated
18:         .subscribe( i => console.log('A new version of the application activated', i.current, i.previous));
19:     }
20:   }

Listing 6-7Activate Update with the SwUpdate Service

请参见第 8 行和第 13 行。第 9 行调用了activateUpdate()函数。它回报一个承诺。对返回的承诺调用then()函数。您提供了一个回调,该回调在承诺完成后被调用。参见第 11 行,它显示了一条消息“激活更新成功”

请注意,当有更新可用时,会调用activateUpdate()。在available可观察订阅中调用activateUpdate()方法。请参见第 6 行和第 14 行。这是对available可观察对象的订阅回调。

第 12 行在更新激活后重新加载应用(浏览器窗口)。成功更新后重新加载浏览器是一个很好的做法。在少数情况下,如果窗口在服务工作器和缓存刷新后没有重新加载,延迟加载路由可能会中断。

检查新版本

SwUpdates服务可以在服务器上检查应用的新版本(服务工作器配置)。如前所述,到目前为止,如果新版本可用或被激活,您已经收到了事件。如果新版本可用,您激活了它。但是,我们依赖浏览器和服务工作器的内置机制来寻找更新。

可以在SwUpdate的实例上调用函数checkForUpdates()来查找服务器上的更新。如果有新版本,它将触发available观察。如果您预计用户会让应用长时间保持打开状态,有时是几天,那么这个函数就特别有用。也有可能您预期对应用进行频繁的更新和部署。您可以设置一个时间间隔并定期检查更新。

考虑到checkForUpdates()与间隔一起使用,在检查更新之前确保 Angular 应用完全启动并稳定是很重要的。过于频繁地使用此功能可能会导致 Angular 应用不稳定。因此,检查应用是否稳定并使用checkForUpdates是一个好的实践,如清单 6-8 所示。它被添加到SwCommunicationService中。

01: import { SwUpdate } from '@angular/service-worker';
02: import { ApplicationRef, Injectable } from '@angular/core';
03: import { first} from 'rxjs/operators';
04: import { interval, concat } from 'rxjs';
05:
06: @Injectable({
07:   providedIn: 'root'
08: })
09: export class SwCommunicationService {
10:
11:   constructor(private updateSvc: SwUpdate,
12:     private appRef: ApplicationRef) {
13:
14:     let isApplicationStable$ = this.appRef.isStable.pipe(first(isStable => isStable === true));
15:     let isReadyForVersionUpgrade$ = concat( isApplicationStable$, interval (12 * 60 * 60 * 1000)); // //twelve hours in milliseconds
16:     isReadyForVersionUpgrade$.subscribe( () => {
17:       console.log("checking for version upgrade...")
18:       this.updateSvc.checkForUpdate();
19:     });
20:   }
21: }
22:

Listing 6-8Invoking CheckForUpdates at Regular Intervals

考虑以下解释:

  • 你注入ApplicationRef来验证 Angular app 是否稳定。第 2 行和第 12 行分别导入和注入该类。

  • 第 13 行验证应用是否稳定。字段isStable的类型为Observable<boolean>。订阅时,当应用稳定时返回 true。第 14 行将可观察值赋给局部变量isApplicationStable$

  • 在第 15 行,interval()函数返回一个Observable<number> 该函数接受以毫秒为单位的持续时间作为参数。在指定的时间间隔后调用订阅回调。请注意,代码片段以毫秒为单位指定了 12 小时。

  • 第 15 行将isApplicationStable$interval()返回的可观察值连接起来。由此产生的可观测值被设置为isReadyForVersionUpgrade$ 订阅此可观察。当应用稳定并且经过了指定的时间间隔(12 小时)时,调用success回调。

Note

concat函数现已被弃用。如果您使用的是 RxJS 版本 8,使用concatWith()函数连接观察值。

  • 在第 18 行,在可观察对象isReadyForVersionUpgrade$subscribe回调中,您使用SwUpdate实例检查更新。

  • 简单回顾一下,当应用稳定时,每 12 小时检查一次升级。checkForUpdate()函数可能触发available订户,该订户在成功激活新版本的应用后调用触发activate订户的activateUpdate()

通知用户新版本

请注意,当识别出服务工作器的新版本时,当前代码会重新加载应用。它还没有提醒用户,并允许她选择何时重新加载。要提供此选项,请将应用与 Snackbar 组件集成在一起。这是角材库中可用的现成元件。Angular 材料为 Angular 应用提供材料设计实现。

我们为什么选择 Snackbar 组件?典型的警报会阻止用户工作流。在对警报采取措施之前,用户无法继续使用该应用。当没有用户的决定,工作流无法继续时,这样的范例工作得很好。例如,它可能是一个错误场景,这对于用户来说是非常重要的,用户需要确认并做出纠正。

另一方面,当新版本的服务工作程序(和应用)可用时,您不希望中断当前的用户会话。用户可以继续使用当前版本,直到手头的任务完成。例如,如果用户正在 Web Arcade 上玩游戏,则继续玩,直到游戏完成。当用户认为重新加载窗口是合适的时候,她可以选择响应警告。

Snackbar 组件非常适合我们的场景。它在应用的一角显示警告,而不会干扰页面的其余部分。默认情况下,警告显示在页面底部,靠近中央。见图 6-3 。如前所述,您应该允许用户单击 Snackbar 组件上的按钮来安装应用的新版本。

img/515699_1_En_6_Fig3_HTML.png

图 6-3

警告用户有新版本的应用可用的 Snackbar 组件

要安装 Snackbar 组件,请向 Web Arcade 应用添加 Angular 材质。运行以下命令:

ng add @angular/material

Angular Material 是一个 UI 组件,其他组件和样式表都符合 Google 的材质设计。首先,CLI 会提示您选择一个主题。见图 6-4 。

img/515699_1_En_6_Fig4_HTML.jpg

图 6-4

选择有 Angular 的材质主题

接下来,CLI 执行以下操作:

  1. 提示您选择有 Angular 的材料排版。这是一个关于使用有 Angular 的材料字体、默认字体大小等的决定。

  2. 提示您包含与 Angular 材料构件交互的浏览器动画。动画为用户动作提供视觉反馈,例如动画化点击按钮、转换内容标签等。

结果见清单 6-9 。

✔ Package successfully installed.
? Choose a prebuilt theme name, or "custom" for a custom theme: Indigo/Pink        [ Preview: https://material.angular.io?theme=indigo-pink ]
? Set up global Angular Material typography styles? Yes
? Set up browser animations for Angular Material? Yes
UPDATE package.json (1403 bytes)
✔ Packages installed successfully.
UPDATE src/app/app.module.ts (1171 bytes)
UPDATE angular.json (3636 bytes)
UPDATE src/index.html (759 bytes)
UPDATE node_modules/@angular/material/prebuilt-themes/indigo-pink.css (77575 bytes)

Listing 6-9Result Installing Angular Material

向 Web Arcade 添加 Angular 材质允许您使用各种 Angular 材质组件。您将只导入和使用所需的组件。整个组件库不会增加包的大小。

要使用 Snackbar 组件,请将MatSnackBarModule导入 Web Arcade 中的AppModule。考虑上市 6-10 。

01: import { MatSnackBarModule } from '@angular/material/snack-bar';
02:
03: @NgModule({
04:   declarations: [
05:     AppComponent,
06:     // You may have more components
07:   ],
08:   imports: [
09:     BrowserModule,
10:     AppRoutingModule,
11:     MatSnackBarModule,
12:     // You may have additional modules imported
13:   ],
14:   providers: [],
15:   bootstrap: [AppComponent]
16: })
17: export class AppModule { }

Listing 6-10Add the Snackbar Module to the Web Arcade Application

参见第一行,它从 Snackbar 组件的 Angular Material 模块导入MatSnackBarModule。另请参见第 11 行,它将模块导入 Web Arcade 上的AppModule

接下来,将 Snackbar 组件导入并注入到SwCommunication服务中。请记住,当应用的新版本可用时,您会向用户显示一个警告。它在SwCommunication服务的构造函数中被识别。见清单 6-11 。

01: import { Injectable } from '@angular/core';
02: import { SwUpdate } from '@angular/service-worker';
03: import { MatSnackBar } from '@angular/material/snack-bar';
04:
05: @Injectable({
06:   providedIn: 'root'
07: })
08: export class SwCommunicationService {
10:   constructor(private updateSvc: SwUpdate,
11:     private snackbar: MatSnackBar) {
13:     this.updateSvc.available.subscribe( i => {
14:       let message = i?.available?.appData as { "name": string };
15:       console.log('A new version of the application available', i.current, i.available);
16:       let snackRef = this.snackbar
17:         .open(`A new version of the app available. ${message.name}. Click to install the application`, "Install new version");
18:     });
19:   }
20: }

Listing 6-11Alert with a Snackbar that a new version of the application is available

考虑以下解释:

  • 第 3 行从 Angular Material 的snackbar模块导入 Snackbar 组件。它被注入到第 11 行的服务中。

  • 注意第 14 行和第 18 行之间的success回调。它在available上可以观察到。如前所述,当一个更新就绪时,就会触发这个观察者功能。

  • 见第 16 行。open()函数显示了一个 Snackbar 组件。您需要提供两个参数,即显示在警报上的消息(Snackbar 组件)和 Snackbar 组件上操作(或按钮)的标题。重新查看图 6-3 以匹配代码和结果。

  • 注意,open 函数也返回一个 Snackbar 引用对象。当用户单击 Snackbar 组件上的按钮时,您将使用此对象来执行操作。

  • 注意message.name被插入第 17 行。message对象是在前一行 14 上获得的。注意它是ngsw-config.json上的appData物体。这是为应用的每个版本升级提供友好消息的一种方式,并在用户选择重新加载和安装新版本时向用户显示信息。见图 6-3 。来自appData的消息说,“我们给街机增加了更多的游戏。”

接下来,使用由open()函数返回的 Snackbar 组件引用,如第 16 行所示。参考对象被命名为snackRef。您使用snackRef 上的onAction()功能。这返回了另一个可观察值。顾名思义,当用户在 Snackbar 组件上执行一个操作时,就会触发 observer 回调函数。请注意,在前面的代码示例中,您有一个操作,即 Snackbar 组件上的按钮“Install new version”。因此,当这个观察者被调用时,您知道用户点击了按钮并可以执行安装。见清单 6-12 。使用修改后的代码,只有在用户通过单击 Snackbar 组件上的按钮选择安装后,您才能执行安装。

01:
02: // include this snippet after snackRef created in the SwCommunicationService
03: snackRef
04: .onAction()
05: .subscribe (() => {
06:   console.log("Snackbar action performed");
07:   this.updateSvc.activateUpdate().then( () => {
08:     console.log("activate update invoked");
09:     window.location.reload();
10:   });
11: });

Listing 6-12Add Snackbar Module to Web Arcade

考虑以下解释:

  • 第 4 行调用了snackRef对象上的onAction()函数。你在返回的对象上链接subscribe()函数。如前所述,onAction()函数返回一个可观察值。

  • 作为观察者提供的成功回调调用SwUpdate对象上的activateUpdate()函数。当用户在 Snackbar 组件上执行操作时,调用第 6 行和第 10 行之间的观察者回调。

  • 请记住,第 6 行和第 10 行之间的代码与清单 6-6 相同,它在识别出新版本后立即执行安装。

在不可恢复的情况下管理错误

用户可能有一段时间没有返回到机器上的应用。浏览器将清空缓存并占用磁盘空间。当用户返回到应用时(在缓存被清理之后),服务工作器可能没有它需要的所有脚本。与此同时,假设在服务器上部署了一个新版本的应用。现在,浏览器无法(从缓存中)获取已删除的脚本,甚至无法从服务器获取。这导致应用处于不可恢复的状态。对用户来说,只剩下一个选择:升级到最新版本。

SwUpdate服务提供了一个名为unrecoverable的可观察对象来处理这样的场景,如清单 6-13 所示。当出现不可恢复的状态时,添加一个错误处理程序。通知用户并重新加载浏览器窗口以清除错误。与前面的代码示例类似,在SwCommunicationService构造函数中添加代码。

01: export class SwCommunicationService {
02:
03:   constructor(private updateSvc: SwUpdate,
04:     private snackbar: MatSnackBar) {
05:     this.updateSvc.unrecoverable.subscribe( i => {
06:       console.log('The application is unrecoverable', i.reason);
07:       let snackRef = this.snackbar
08:       .open(`We identified an error loading the application. Use the following reload button. If the error persists, clear cache and reload the application`,
09:         "Reload");
10:     snackRef
11:       .onAction()
12:       .subscribe (() => {
13:         this.updateSvc.activateUpdate().then( () => {
14:           window.location.reload();
15:         });
16:      });
17:     });
18:   }
19: }

Listing 6-13Handle the Unrecoverable State

考虑以下解释:

  • 见第 5 行。它在SwUpdate(即updateSvc)的实例上订阅了unrecoverable观察者。可观察对象的成功回调位于第 6 行和第 17 行之间。

  • You open a Snackbar component, which alerts the user about the error at the bottom center of the page. See Figure 6-5.

    img/515699_1_En_6_Fig5_HTML.png

    图 6-5

    警告用户不可恢复的错误的 Snackbar 组件

  • 注意第 6 行的原因字段。它有关于不可恢复状态的附加信息。您可以在浏览器控制台中打印和使用这些信息,或者将其记录到一个中心位置,以便进一步调查。

摘要

可安装和缓存的 web 应用非常强大。它们使用户能够在低带宽和离线情况下访问应用。然而,您还需要构建无缝升级应用的特性。如您所知,对于 Web Arcade,服务工作器管理缓存和离线访问功能。

本章详细介绍了如何无缝升级 Angular 应用并与服务工作器进行沟通。它广泛使用 Angular 的SwUpdate服务,该服务为识别和激活新版本的应用提供了许多现成的功能。它使用可观测量;当新版本的应用可用或激活时,您可以进行订阅。

Exercise

  • 本章使用 Snackbar 组件在应用的新版本可用时发出警报。激活升级后显示警告。在ngsw-config.json中包含来自appData的信息。

  • 扩展ngsw-config.json以显示关于发布的附加信息。包括增强功能和错误修复的详细信息。让它接近真实世界的用例。用户希望看到有关升级的详细信息摘要。

  • 探索将 Snackbar 组件定位在页面上的不同位置(与默认的底部居中相对)。

  • 探索使用除 Snackbar 组件之外的更多组件向用户发出警报。构建更适合不同用例的体验。请记住,我们使用了 Snackbar 组件,因为它不会中断和阻塞活动用户的工作流。然而,Snackbar 组件也用于警告不可恢复的错误场景。因此,为这种错误情况选择适当的警报组件和机制。

七、IndexedDB 简介

到目前为止,您已经缓存了应用框架和 HTTP GET 服务调用。RESTful 服务为数据检索提供 GET 调用。但是,HTTP 也支持 POST 来创建实体,PUT 和 PATCH 来更新,DELETE 来删除实体。除了 GET 调用之外,示例应用 Web Arcade 还不支持对服务调用的离线访问。

本章介绍了 IndexedDB,用于更高级的脱机操作。在本章中,你将对 IndexedDB 有一个基本的了解,它运行在浏览器的客户端。您将学习如何在 Angular 应用中使用 IndexedDB。JavaScript 提供 API 来与 IndexedDB 集成。您可以在 IndexedDB 中创建、检索、更新和删除数据,大多数现代浏览器都支持 indexed db。这一章着重于构建数据库,包括创建对象存储、索引等。在下一章中,您将通过创建和删除记录来处理数据。

传统上,web 应用使用各种客户端存储功能,包括 cookies、会话存储和本地存储。即使在今天,它们对于存储相当少量的数据也非常有用。另一方面,IndexedDB 为更复杂的客户端存储和检索提供了一个 API。大多数现代浏览器都支持 JavaScript API。IndexedDB 为相对大量的数据(包括 JSON 对象)提供持久存储。然而,没有一个数据库支持存储无限量的数据。相对于磁盘和设备的大小,浏览器对 IndexedDB 中存储的数据量设置了上限。

IndexedDB 对于持久化结构化数据很有用。它以键值对的形式保存数据。它像 NoSQL 数据库一样工作,支持使用包含数据记录的对象存储。对象存储类似于关系数据库中的表。传统的关系数据库在很大程度上使用根据列和约束(主键、外键等)具有预定义结构的表。).但是,IndexedDB 使用对象存储来保存数据记录。

IndexedDB 支持高性能搜索。借助于索引(在对象存储上定义)来组织数据,这有助于更快地检索数据。

术语

考虑以下使用 IndexedDB 的术语:

  • 对象存储:一个 IndexedDB 可能有一个或多个对象存储。每个对象存储充当数据的键值对的容器。如前所述,对象存储类似于关系数据库中的表。

    • 对象存储为 IndexedDB 提供结构。创建一个或多个对象存储作为应用数据的逻辑容器。例如,您可以创建一个名为users的对象存储来存储用户详细信息,并创建另一个名为games的对象存储来保存游戏相关对象的列表。
  • 事务:indexed db 上的数据操作是在事务的上下文中执行的。这有助于保持数据的一致性。记住,IndexedDB 在浏览器中的客户端存储和检索数据。用户可能会打开多个应用实例。它可以创建场景,其中创建/更新/删除操作由浏览器的每个实例部分执行。当更新操作正在进行时,其中一个浏览器可能会检索过时的数据。

    • 事务有助于避免前面提到的问题。事务锁定数据记录,直到操作完成。数据访问和修改操作是原子性的。也就是说,创建/更新/删除操作要么完全完成,要么完全回滚。检索操作仅在数据修改操作完成或回滚后执行。因此,retrieve 从不返回不一致和陈旧的数据对象。

    • IndexedDB 支持三种交易模式,即readonlyreadwriteversionchange、??。可以想象,readonly帮助检索操作,readwrite帮助创建/更新/删除操作。然而,versionchange模式有助于在 IndexedDB 上创建和删除对象存储。

  • 索引:索引有助于更快地检索数据。对象存储按照键的升序对数据进行排序。键隐式不允许重复值。您可以创建额外的索引,这些索引也可以作为唯一性约束。例如,在社会保险号或身份证号上添加索引可以确保 IndexedDB 中没有重复项。

  • 游标:游标帮助遍历对象存储中的记录。在查询和检索过程中迭代数据记录时,这很有用。

IndexedDB 入门

主流浏览器都支持 IndexedDB。API 使应用能够在浏览器中创建、存储、检索、更新和删除本地数据库中的记录。本章详细介绍了如何在本机浏览器 API 中使用 IndexedDB。

以下是使用 IndexedDB 时的典型步骤:

  1. 创建和/或打开一个数据库:第一次创建一个新的 IndexedDB 数据库。当用户返回 web 应用时,打开数据库以对数据库执行操作。

  2. 创建和/或使用对象存储库:用户第一次访问该功能时,创建一个新的对象存储库。如前所述,对象存储类似于关系数据库管理系统(RDBMS)中的表。您可以创建一个或多个对象存储。您可以在对象存储中创建、检索、更新和删除文档。

  3. 开始一个事务:在一个 IndexedDB 对象存储上作为一个事务执行动作。它使您能够保持一致的状态。例如,当对 IndexedDB 数据库执行操作时,用户可能会关闭浏览器。因为动作是在事务的上下文中执行的,所以如果动作没有完成,则事务被中止。事务确保错误或边缘情况不会使数据库处于不一致或不可恢复的状态。

  4. 执行 CRUD :与任何数据库一样,您可以在 IndexedDB 中创建、检索、更新或删除文档。

考虑下面的 Web Arcade 用例,利用 IndexedDB。

在前面的章节中,你已经看到了一个显示棋盘游戏列表的页面。考虑用游戏细节页面来支持一个新的用例。图 7-1 详细描述了一个游戏的所有信息。在游戏描述的下面,显示了一个用户评论列表和一个允许用户添加新评论的表单。当用户输入新的注释并提交表单时,您将这些数据发送到远程 HTTP 服务。理想情况下,该服务将用户评论保存在永久性存储/数据库中,如 MongoDB、Oracle 或 Microsoft SQL Server。考虑到服务器端代码不在本书讨论范围内,我们将保持简单。在下一章中,代码示例展示了一个将用户评论存储在文件中的服务。

图 7-1 显示了带有当前评论列表和允许用户提交新评论的表单的页面部分。

img/515699_1_En_7_Fig1_HTML.png

图 7-1

在游戏详情页面上列出并提交评论

“提交”操作会创建新的注释。服务端点是一个 HTTP POST 方法。如前所述,Web Arcade 支持 HTTP GET 调用的离线访问。想象一下,当用户输入评论并点击提交时失去连接。典型的 web 应用会返回一个错误或类似“无法显示页面”的消息 Web Arcade 旨在对网络连接的丢失具有弹性。因此,Web Arcade 缓存用户评论,并在用户返回应用时与服务器端服务同步。

索引的 Angular 服务 b

通过运行以下命令创建新服务:

ng generate service common/idb-storage-access

该命令在目录src/app/common 中创建了一个名为IdbStorageAccessService的新服务。该服务用于抽象访问 IndexedDB 的代码语句。它是一个中央服务,使用浏览器 API 与 IndexedDB 集成。在初始化期间,该服务执行一次性活动,如创建新的 IndexedDB 存储或打开数据库(如果它已经存在)。见清单 7-1 。

01: @Injectable()
02: export class IdbStorageAccessService {
03:
04:   idb = this.windowObj.indexedDB;
05:
06:   constructor(private windowObj: Window) {
07:   }
08:
09:   init() {
10:     let request = this.idb
11:       .open('web-arcade', 1);
12:
13:     request.onsuccess = (evt:any) => {
14:       console.log("Open Success", evt);
15:     };
16:
17:     request.onerror = (error: any) => {
18:       console.error("Error opening IndexedDB", error);
19:     }
20:   }
21:
22: }
23:

Listing 7-1Initialize IndexedDB with the IdbStorageAccessService

Note

默认情况下,ng generate service命令在根级别提供服务。在 Web Arcade 应用的上下文中,您可能希望删除第 1 行的provideIn: 'root'语句。只需离开inject()装饰器,如第一行所示。

这将在下一节连同清单 7-2 一起详细解释。

考虑以下解释:

  • 第 4 行创建了类变量idb(indexed db 的缩写)。它被设置为全局窗口对象上的indexedDB实例。indexedDB对象有一个 API 来帮助打开或创建一个新的 IndexedDB。第 4 行在初始化IdbStorageAccessService时运行,类似于构造函数。

Note

注意,全局窗口对象是通过一个Window服务来访问的。参见第 6 行的构造函数。它注入窗口服务。实例变量被命名为windowObjWindow服务在AppModule提供。

  • 关于初始化服务的init()函数,请参见第 9 行到第 20 行。

  • 参见对idb对象运行open()函数的第 10 行和第 11 行。如果用户第一次在浏览器上打开应用,它会创建一个新的数据库。

    1. 第一个参数是数据库的名称web-arcade

    2. 第二个参数(值 1)指定数据库的版本。可以想象,应用的新更新会导致 IndexedDB 结构的变化。IndexedDB API 使您能够随着版本的变化升级数据库。

要返回一个用户,数据库已经创建好,并且可以在浏览器上使用。open()函数试图打开数据库。它返回IDBOpenDBRequest对象的一个对象。

图 7-2 显示了一个新创建的 IndexedDB web-arcade。这张图片是用谷歌浏览器的开发工具拍摄的。包括 Firefox 和 Microsoft Edge 在内的所有主流浏览器都为开发者提供了类似的功能。

img/515699_1_En_7_Fig2_HTML.png

图 7-2

Google Chrome 开发工具中的 IndexedDB

几乎所有的 IndexedDB APIs 都是异步的。像 open 这样的操作不会尝试立即完成操作。您指定一个回调函数,该函数在完成操作后被调用。可以想象,打开操作可能成功,也可能出错。因此,为每个结果定义一个回调函数,onsuccessonerror 见清单 7-1 第 13-15 行和第 17-19 行。目前,您只需在控制台上打印结果(第 14 和 18 行)。我们将在接下来的代码片段中进一步增强对结果的处理。

什么时候调用init()函数?这是 Angular 服务的方法之一。您可以在组件中调用它,这意味着只有当您加载(或导航)到组件时,IndexedDB 才会被初始化。另一方面,像 Web Arcade 这样的应用高度依赖于 IndexedDB。您可能需要利用来自多个组件的服务。该服务需要完成初始化,并为 CRUD 操作做好准备。因此,在主模块AppModule启动时,将服务和应用一起初始化是一个好主意。考虑将 7-2 上市。

03: import { NgModule, APP_INITIALIZER } from '@angular/core';
15: import { IdbStorageAccessService } from './common/idb-storage-access.service';
18:
19: @NgModule({
20:   declarations: [
21:     AppComponent,
25:   ],
26:   imports: [
27:     BrowserModule,
40:   ],
41:   providers: [
42:     IdbStorageAccessService,
43:     {
44:       provide: APP_INITIALIZER,
45:       useFactory: (svc: IdbStorageAccessService) => () => svc.init(),
46:       deps: [IdbStorageAccessService], multi: true
47:     }
48:   ],
49:   bootstrap: [AppComponent]
50: })
51: export class AppModule { }
52:

Listing 7-2Initialize IndexedDB with IdbStorageAccessService

考虑以下解释:

  • 参见第 42 至 48 行。块中的第一行(第 42 行)提供了一个新创建的IDBStorageAccessService 我们为什么需要它?如您所见,我们没有在根级别提供服务。我们删除了IdbStorageAccessService(清单 7-1 )中的代码行provideIn: 'root'

  • 参见第 43 行到第 47 行,它们提供了APP_INITIALIZER并使用了调用init()的工厂函数。

  • 总之,我们在模块级提供并初始化了IdbStorageService。在本例中,您在AppModule中完成。它可能是任何模块。

    它在浏览器上创建和/或打开Web-Arcade IndexedDB。它使数据库为进一步的操作(如 CRUD)做好准备。这段代码消除了将服务注入组件(或另一个服务)并调用init()函数的需要。服务随着AppModule一起初始化。

正在创建对象存储

虽然数据库是 IndexedDB 中的最高级别,但它可以有一个或多个对象存储。您为数据库中存储每个对象提供一个唯一的名称。对象存储是保存数据的容器。在当前的 Web Arcade 示例中,您将看到如何保存 JSON 对象。为了便于理解,对象存储类似于关系数据库中的表。

使用“onupgradeneeded”事件

创建或打开 IndexedDB 后,会触发一个名为onupgradeneeded的事件。您提供了一个回调函数,当该事件发生时,浏览器将调用该函数。对于新数据库,回调函数是创建对象存储的好地方。对于预先存在的数据库,如果需要升级,您可以在此处执行设计更改。例如,您可以创建新的对象存储,删除未使用的对象存储,并通过删除和重新创建来修改现有的对象存储。考虑上市 7-3 。

01: init() {
02:     let request = this.idb
03:         .open('web-arcade', 1);
04:
05:     request.onsuccess = (evt: any) => {
06:         console.log("Open Success", evt);
07:     };
08:
09:     request.onerror = (error: any) => {
10:         console.error("Error opening IndexedDB", error);
11:     }
12:
13:     request.onupgradeneeded = function (event: any) {
14:         console.log("version upgrade event triggered");
15:         let dbRef = event.target.result;
16:         dbRef
17:             .createObjectStore("gameComments", { autoIncrement: true });
18:     };
19: }

Listing 7-3onupgradeneeded Event Callback

考虑以下解释:

  • 注意,代码片段重复了清单 7-1 中的init()函数。除了onsuccessonerror回调之外,还包括一个名为onupgradeneeded的事件处理程序。参见第 13 行到第 18 行。

  • 该事件作为参数提供给函数回调。

  • 你可以访问一个对象上事件目标的 IndexedDB 的引用,即target

  • 使用db引用创建一个对象存储。在本例中,您将对象存储命名为gameComments 如前所述,如果用户失去连接,您可以使用 IndexedDB 和对象存储来缓存用户评论。

对象存储以键值对的形式保存数据。正如您将在接下来的几节中看到的,数据是使用键来检索的。它是唯一标识存储在 IndexedDB 中的值的主键。以下是创建键的两个选项(用于对象存储中存储的值)。这是在创建对象存储时决定的。参见清单 7-3 中的第 17 行。注意createObjectStore()函数的第二个参数。您可以指定以下两个选项之一:

  • 自动递增 : IndexedDB 管理密钥。它为对象存储中添加的每个新对象创建一个数值和增量。

    dbRef.createObjectStore("gameComments", {
    autoIncrement: true });
    
    
  • Key path :在正在添加的 JSON 对象中指定一个 Key path。因为键值是显式提供的,所以请确保提供唯一的值。重复值会导致插入失败。

    A field called commentId is provided as a keypath. If used, ensure you provide a unique value for commentId.

    dbRef.createObjectStore("gameComments", {
    keypath: 'commentId' });
    
    

Note

只能为 JavaScript 对象提供键路径。因此,创建带有键路径的对象存储会限制它只能存储 JavaScript 对象。但是,使用自动增量,考虑到键是由 IndexedDB 管理的,您可以存储任何类型的对象,包括基本类型。

参见图 7-3 中新创建的gameComments对象库。

img/515699_1_En_7_Fig3_HTML.png

图 7-3

gameComments 对象存储和示例值

创建索引

定义对象存储时,可以创建附加索引,这些索引也可以作为唯一性约束。该索引应用于对象存储中持久化的 JavaScript 对象中的字段。考虑下面的代码片段。它解释了对象存储引用上的createIndex API。

objectStoreReference.createIndex('indexName', 'keyPath', {parms})

考虑以下解释:

  • Index name:第一个参数是索引名(任意)。

  • Key path:第二个参数keypath,指定需要在给定的字段上创建索引。

  • Params:您可以为创建索引指定以下参数:

    1. unique:这在 keypath 提供的字段上创建了一个唯一性约束。

    2. multiEntry:应用于数组。

如果为 true,则约束确保数组中的每个值都是唯一的。为数组中的每个元素的索引添加一个条目。

如果为 false,索引将为整个数组添加一个条目。唯一性是在数组对象级别维护的。

gameComments对象存储中,假设每个评论都有一个 ID。要确保 ID 是唯一的,请添加一个索引。考虑上市 7-4 。

1: request.onupgradeneeded = function(event: any){
2:     console.log("version upgrade event triggered");
3:     let dbRef = event.target.result;
4:     let objStore = dbRef
5:       .createObjectStore("gameComments", { autoIncrement: true })
6:
7:     let idxCommentId = objStore.createIndex('IdxCommentId', 'commentId', {unique: true})
8:   };

Listing 7-4Create Index IdxCommentId for the Comment ID

注意,第 7 行使用对象存储引用objStore创建了一个索引。该索引被命名为IdxCommentId。该索引被添加到commentId字段。您可以看到参数unique被设置为 true,这确保了commentId对于每条记录都是不同的。图 7-4 展示了具有新索引的对象存储。

img/515699_1_En_7_Fig4_HTML.png

图 7-4

对象存储上的索引 IdxCommentId

浏览器支持

图 7-5 描述了浏览器对全局indexedDB对象(windowObj.indexedDB)的支持。请注意,这些数据是在 Mozilla 网站的 https://developer.mozilla.org/en-US/docs/Web/API/indexedDB 捕获的。对于 web 技术来说,它是一个可靠的开源平台。Mozilla 是开放网络的倡导者,也是包括 Firefox 浏览器在内的安全免费互联网技术的先驱。

img/515699_1_En_7_Fig5_HTML.png

图 7-5

window.indexedDB 浏览器支持

另请参考 CanIUse.com,它是浏览器兼容性数据的可靠来源。对于 IndexedDB,使用 URL https://caniuse.com/indexeddb

指数化的局限性 b

虽然 IndexedDB 为浏览器中的客户端持久化和查询提供了一个很好的解决方案,但了解以下限制很重要:

  • 它不支持国际化排序,因此对非英语字符串进行排序可能会很棘手。很少有语言对字符串的排序不同于英语。在撰写本章时,IndexedDB 和所有浏览器都不完全支持本地化排序。如果这个特性很重要,您可能必须从数据库中检索数据,并编写额外的自定义代码来排序。

  • 还不支持全文搜索。

  • IndexedDB 不能被视为数据的真实来源。这是临时存储。在以下情况下,数据可能会丢失或被清除:

    1. 用户重置浏览器或手动清除数据库。

    2. 用户在 Google Chrome 匿名窗口或私人浏览会话(在其他浏览器上)中启动应用。由于浏览器窗口关闭,考虑到这是一个私人会话,数据库将被删除。

    3. 永久存储的磁盘配额是根据一些因素计算的,包括可用磁盘空间、设置、设备平台等。应用可能超出了配额限制,进一步的持久化失败。

    4. 各种情况,包括损坏的数据库、由不兼容的更改导致的数据库升级错误等。

摘要

本章提供了对 IndexedDB 的基本理解,它运行在浏览器的客户端。JavaScript 提供了一个本地 API 来处理 IndexedDB。大多数现代浏览器都支持它。

本章还解释了如何使用AppModule初始化 Angular 服务。在初始化过程中,您为 Web Arcade 创建或打开 IndexedDB 商店。如果用户第一次在浏览器上访问应用,您将创建一个新的 IndexedDB 存储。如果已经存在,则打开预先存在的数据库。

接下来,本章解释了如何使用onupgradeneeded函数回调来创建对象存储和索引。这些是用户首次访问应用时的一次性活动。

Exercise

  • 为创建新游戏创建一个额外的对象存储。加载应用(或 Angular 模块)时执行操作。

  • 创建对象存储以使用指定的 ID 作为键(主)。不要使用自动增量。

  • 在游戏标题上创建一个额外的索引。确保它是唯一的。

八、创建实体用例

在处理数据时,您从数据检索开始。您使用远程 HTTP 服务进行 GET 调用,并在 Angular 应用的屏幕上显示数据。您创建了缓存包括数据调用在内的资源的能力。随着您的进展,应用需要创建、更新和删除数据。在支持脱机功能的应用中,创建、更新和删除操作是复杂的。

本章建立了处理此类场景的用例。它详细描述了如何构建执行创建操作的 Angular 组件和服务。该示例可以很容易地升级为编辑和删除操作。前一章介绍了在浏览器中持久化数据的 IndexedDB。它通常用于管理缓存。本章描述的用例有助于充分利用 IndexedDB 实现。随着本书的深入,下一章将详细介绍如何使用 IndexedDB 执行离线操作,因此您需要理解我们将在本章构建的用例。

在 Web Arcade 中,创建、更新和删除操作在游戏详细信息页面上执行。页面将调用远程 HTTP 服务来保存数据。本章首先解释了前面提到的动作的 HTTP 方法。接下来,它详细介绍了如何创建组件。它为列表组件(显示棋盘游戏列表)和详细信息页面之间的导航引入了 Angular 路由。接下来,它详细描述了我们在开发用例时在游戏细节页面上构建的特性。最后,本章详细介绍了如何构建模拟服务器端服务来支持 Angular 应用。

网络街机:游戏详情页面

游戏详细信息页面显示所选游戏的详细信息。我们使用这个页面来展示一个如何将离线操作与远程 HTTP 服务同步的例子。

当调用远程 HTTP 服务时,HTTP 方法定义要执行的期望动作。考虑以下最常用的 HTTP 方法:

  • GET 检索数据。例如,它检索棋盘游戏列表。

  • POST 提交或创建实体。例如,它创建一个服务来创建一个棋盘游戏,或者在一个实现 POST 方法的游戏上添加用户评论。

  • 放置替换或完全更新实体。例如,考虑一个棋盘游戏实体,该实体具有游戏 ID、游戏标题、游戏描述、具有关于游戏的综合细节的网络链接、起源等字段。对于给定的游戏 ID,使用 PUT 方法替换所有字段。即使一些字段没有改变,您也可以在使用 PUT 方法时再次提供相同的值。

  • PATCH 替换或更新实体上的一些字段。在前面的示例中,考虑只更新原点字段。开发一个带有补丁的 HTTP 服务来更新实体上的 origin 字段。

  • 删除移除或删除实体。

Note

除了前面的 HTTP 方法,还有其他较少使用的 HTTP 方法,包括 HEAD、CONNECT、OPTIONS 和 TRACE。

到目前为止,提供离线访问来获取服务调用。本章使用 IndexedDB 来缓存和同步 POST 服务调用。您可以在其余的 HTTP 方法上使用类似的实现。

在前面的章节中,您创建了一个组件来显示游戏列表。在本章中,您将更新示例,以便通过单击选择游戏来导航到详细信息页面。见图 8-1 。

img/515699_1_En_8_Fig1_HTML.png

图 8-1

导航至游戏详情页面

游戏详细信息页面有游戏的描述和其他详细信息。它列出了所有用户的评论。它在底部提供了一个提交新评论的表单。参见图 8-2 。

img/515699_1_En_8_Fig2_HTML.png

图 8-2

游戏详细信息页面上的字段

离线场景

用户可以提交评论。在线时,该服务调用 HTTP 服务来发布新的评论。但是,如果脱机,请使用 IndexedDB 临时保存注释。重新上线后,将评论发布到远程服务。

为游戏细节创建组件

运行以下 Angular CLI 命令创建新组件:

ng g c game-details

在接下来的几节中,您将更新组件以显示游戏细节。但是,该游戏是在早期的游戏列表组件中选择的。游戏细节组件如何知道所选择的游戏?请记住,当用户从棋盘游戏列表中选择时,您将导航到游戏详细信息。列表组件在 URL 中提供选择的游戏 ID 作为query param。考虑以下带有游戏 ID 参数的 URL:

http://localhost:4200/details?gameId=1

选择途径

Angular routing 使 Angular 应用能够利用 URL(查看浏览器中的地址栏)并动态加载内容。您将组件映射到 URL 中的路径。该组件在用户导航到相应路径时加载。

请记住,当您使用 Angular CLI 为 Web Arcade 创建新应用时,路由已经设置好了。这包括一个用于配置自定义路径和 URL 的AppRoutingModule。更新路由配置,以在导航到详细信息页面时首先显示游戏列表组件和游戏详细信息组件(如前面提到的 URL 所示)。考虑app-routing.module.ts中的路由配置,如清单 8-1 所示。

06: const routes: Routes = [{
07:   path: "home",
08:   component: BoardGamesComponent
09: }, {
10:   path: "details",
11:   component: GameDetailsComponent
12: }, {
13:   path: "",
14:   redirectTo: "/home",
15:   pathMatch: "full"
16: }];
17:
18: @NgModule({
19:   imports: [RouterModule.forRoot(routes)],
20:   exports: [RouterModule]
21: })
22: export class AppRoutingModule { }
23:

Listing 8-1Route Configuration

注意,棋盘游戏组件被配置为使用路径/home加载,游戏细节组件被配置为使用路径/details加载,例如http://localhost:4200/homehttp://localhost:4200/details

组件在 HTML 模板中的router-outlet处加载。记住,AppComponent是根组件。更新路由器出口,以便在用户导航到相应的 URL(路径)时加载前面提到的组件。请考虑以下简短片段:

1: <div class="container align-center">
2:     <router-outlet></router-outlet>
3: </div>

导航到游戏详情页面

接下来,更新列表组件(BoardGamesComponent)以导航到详细信息页面。编辑组件的 HTML 模板(src/app/components/board-games/board-games.component.html)。见清单 8-2 。

01: <mat-toolbar color="primary">
02:     <mat-toolbar-row>Game List</mat-toolbar-row>
03: </mat-toolbar>
04: <div>
05:     <ng-container *ngFor="let game of (games | async)?.boardGames">
06:         <a (click)="gameSelected(game)">
07:             <mat-card>
08:                 <mat-card-header>
09:                     <h1>
10:                         {{game.title}}
11:                     </h1>
12:                 </mat-card-header>
13:                 <mat-card-content>
14:                     <span>{{game.alternateNames}}</span>
15:                     <div>{{game.origin}}</div>
16:                     <div>{{game.description}}</div>
17:                 </mat-card-content>
18:             </mat-card>
19:         </a>
20:     </ng-container>
21: </div>

Listing 8-2Board Games Component Template

考虑以下解释:

  • 请参见第 6 行和第 19 行。每个游戏(卡片)都包含在一个超级链接元素<a></a>中。

  • 注意,第 5 行使用ngFor指令遍历游戏列表。变量game代表迭代中的一个游戏。

  • 在第 6 行,点击事件由gameSelected()函数处理。注意游戏变量是作为参数传入的。这是当前迭代中包含游戏数据的变量。

gameSelected函数(在游戏细节组件的 TypeScript 文件中定义)导航到游戏细节页面,如清单 8-3 所示。

01:
02: export class BoardGamesComponent implements OnInit {
03:
04:   constructor(private router: Router) { }
05:
06:   gameSelected(game: BoardGamesEntity){
07:    this.router.navigate(['/details'], {queryParams: {gameId: game.gameId}})
08:   }
09:
10: }

Listing 8-3Navigate to the Details Page

考虑以下解释:

  • 第 4 行注入了一个路由器服务实例。

  • 第 7 行使用一个路由器实例导航到详细信息页面。

  • 注意,提供了一个查询参数游戏 ID。游戏对象是从模板传入的。参见之前的清单 8-2 。

  • 在当前列表组件(BoardGamesComponent)中选择的game-id将用于游戏详情组件。它检索所选游戏的完整细节。

接下来,从游戏细节组件中的 URL 检索游戏 ID,如清单 8-4 所示。

01: import { Component, OnInit } from '@angular/core';
02: import { ActivatedRoute } from '@angular/router';
03:
04: @Component({
05:   selector: 'wade-game-details',
06:   templateUrl: './game-details.component.html',
07:   styleUrls: ['./game-details.component.sass']
08: })
09: export class GameDetailsComponent implements OnInit {
10:   game: BoardGamesEntity;
      commentsObservable = new Observable<CommentsEntity[]>();
11:   constructor(private router: ActivatedRoute, private gamesSvc: GamesService) { }
12:
13:   ngOnInit(): void {
14:     this.router
15:       .queryParams
16:       .subscribe( r =>
17:         this.getGameById(r['gameId']));
18:   }
19:
20:   private getGameById(gameId: number){
21:     this.gamesSvc.getGameById(gameId).subscribe(
22:       (res: BoardGamesEntity) => {
23:         this.game = res;
24:         this.getComments(res?.gameId);
25:       });
26:   }
27: }

28:
29: private getComments(gameId: number){
30:     this.commentsObservable = this.gamesSvc.getComments(gameId);
31: }
32:

Listing 8-4Retrieve Game ID from Query Params

考虑以下解释:

  • 该示例使用ActivatedRoute服务读取 URL 中的查询参数。

  • 第 2 行从 Angular 的路由器模块导入了ActivatedRoute服务。接下来,第 11 行将服务注入到组件中。服务实例被命名为router

  • 参见第 13 行和第 18 行之间的ngOnInit()功能。该函数在构造函数之后和组件初始化期间被调用。

  • 请参见第 14 行和第 17 行之间的代码。注意,代码使用了router.queryParamsqueryParams是可观测的。订阅它以访问查询参数。

  • queryParams订阅的结果被命名为r。以结果字段的形式访问游戏 ID,r['gameId']。现在,你可以访问BoardGamesComponent提供的游戏 ID。

  • 将游戏 ID 作为函数参数传递给私有函数getGameById()。第 20 到 27 行定义了这个函数。

  • getGameById()函数调用另一个同名函数getGameById(),该函数被定义为GamesService的一部分。它返回一个可观察对象,订阅它会从 HTTP 服务返回结果。远程 HTTP 服务通过GameId提供游戏详情。

  • 在第 23 行中,您将来自 HTTP 服务的结果设置到组件上的一个游戏对象上,该对象在 HTML 模板中使用。

  • HTML 模板向用户显示游戏细节。

  • 接下来,调用私有函数getComments(),该函数检索对给定棋盘游戏的评论。参见第 29 行到第 31 行。它调用GameService实例上的getComments()函数,该函数从远程 HTTP 服务获取数据。

  • 将来自 HTTP 服务的结果设置到组件上的一个commentsObservable对象上,该对象在 HTML 模板中使用。HTML 模板显示了注释。

总之,清单 8-4 检索游戏细节和注释,并将它们设置在一个类变量上。在第 23 行,类别字段game已经选择了游戏标题、描述等。接下来,在第 30 行,类字段commentsObservable有一个注释列表。这些是不同用户对所选游戏的评论。接下来,查看呈现游戏细节和评论的 HTML 模板代码。考虑上市 8-5 。

01:
02: <!-- Toolbar to provide a title-->
03: <mat-toolbar [color]="toolbarColor">
04:     <h1>Game Details</h1>
05: </mat-toolbar>
06:
07:
08: <!-- This section shows game title and description-->
09: <div *ngIf="game">
10:     <h2>{{game.title}}</h2>
11:     <div>{{game.description}}</div>
12: </div>
13:
14:
15: <!-- Following section shows comments made by users -->
16: <div>
17:     <strong>
18:         Comments
19:     </strong>
20:     <hr />
21:     <mat-card *ngFor="let comment of commentsObservable | async">
22:         <mat-card-header>
23:             <strong>
24:                 {{comment.title}}
25:             </strong>
26:         </mat-card-header>
27:         <mat-card-content>
28:             <div>{{comment.comments}}</div>
29:             <div><span>{{comment.userName}}</span> <span class="date">{{comment.timeCommented | date}}</span></div>
30:         </mat-card-content>
31:     </mat-card>
32: </div>

Listing 8-5Game Details Component HTML Template

考虑以下解释:

  • 第 10 行和第 11 行显示了游戏标题和描述。第 9 行检查游戏对象是否被定义(用一个ngIf指令)。这是为了避免在从服务获取游戏数据之前组件出错。可以想象,当组件第一次加载时,服务调用仍在进行中。游戏标题、描述和其他字段尚不可用。从服务中检索后,ngIf条件变为真,并显示数据。

  • 见第 21 行。它遍历注释。第 24 行显示了注释标题。第 28 和 29 行显示了评论描述、用户名和评论时间戳。

  • 参见清单 8-4 。commentsObservable属于Observable类型。因此,清单 8-5 中的第 27 行使用了| async

  • 请注意以下 HTML 样式决定:

    • 清单使用 Angular Material 的工具栏组件(mat-toolbar)来显示标题。请参见第 3 行到第 5 行。

    • 每条评论都显示在一张有角的材料卡片上。参见第 21 至 31 行的组件mat-cardmat-card-headermat-card-content

清单 8-4 使用服务中的两个函数:getGameById()getComments().可以想象,Angular service 函数调用远程 HTTP 服务来获取数据。

记住,我们开发了模拟服务来演示远程 HTTP 服务功能。你为棋盘游戏返回了模拟 JSON。对于前面的两个函数,getGameById()getComments(),您将扩展 Node.js Express 服务。这将在本章后面的“模拟 HTTP 服务的更新”一节中介绍

Note

真实世界的服务与 Oracle、Microsoft SQL Server 或 MongoDB 等主流数据库集成,并在其中创建、检索和更新数据。这超出了本书的范围。为了确保代码示例的功能,我们创建了模拟服务。

然而,正如您在前面的代码示例中看到的,这些组件并不直接与远程 HTTP 服务集成。您使用一个 Angular 服务,它使用其他服务从组件中抽象出这个功能。组件纯粹关注应用的表示逻辑。

记住,您创建了一个名为GamesService的服务,用于封装检索游戏数据的代码。接下来,更新服务以包含之前的两个函数getGamesById()getComments(),如清单 8-6 所示。

01: @Injectable({
02:     providedIn: 'root'
03:   })
04:   export class GamesService {
05:
06:     constructor(private httpClient: HttpClient) { }
07:
08:     getGameById(gameId: number): Observable<BoardGamesEntity>{
09:       return this
10:         .httpClient
11:         .get<BoardGamesEntity>(environment.boardGamesByIdServiceUrl,{
12:           params: {gameId}
13:         });
14:     }
15:
16:     getComments(gameId: number): Observable<CommentsEntity[]>{
17:         return this
18:           .httpClient
19:           .get<CommentsEntity[]>(environment.commentsServiceUrl,{
20:             params: {gameId}
21:           });
22:       }
23:
24: }

Listing 8-6Game Service Invoking Remote HTTP Services

考虑以下解释:

  • 第 6 行注入了HttpClient服务。它是 Angular 提供的一种开箱即用的服务,可以进行 HTTP 调用。

  • 请参见第 10 行和第 18 行。这些函数使用HttpClient实例httpClient.来调用远程 HTTP 服务。

  • 这两个函数都使用 GET HTTP 方法。第一个参数是端点 URL。

  • 建议配置 URL(而不是在应用中对它们进行硬编码)。因此,URL 在环境文件中被更新。环境文件见清单 8-7 。

  • 注意,这两个函数都需要gameId作为参数。请参见第 8 行和第 16 行。

  • 游戏作为查询参数传递给远程 HTTP 服务。请参见第 12 行和第 20 行。

  • 请注意,getGameById()返回了一个类型为BoardGamesEntity (Observable<BoardGamesEntity>)的可观察对象。远程服务应该返回一个符合BoardGamesEntity中指定的接口契约的 JSON 响应。接口定义见清单 8-8 (a)。

  • getComments()返回一个CommentsEntity ( Observable<CommentsEntity>)类型的可观察值。由于从服务中检索到多个注释,所以它是一个数组。远程服务应该返回一个符合CommentsEntity中指定的接口契约的 JSON 响应。接口定义见清单 8-8 (b)。

  • 远程服务调用返回一个可观察的,因为它们是异步的。浏览器一调用数据,服务就不会立即返回数据。代码不会等到结果返回。因此,一旦远程服务中的数据可用,就会调用订户回调函数。

1: export interface CommentsEntity {
2:     title: string;
3:     comments: string;
4:     timeCommented: string;
5:     gameId: number;
6:     userName:string;
7: }

Listing 8-8(b)TypeScript Interface CommentsEntity

01: export interface BoardGamesEntity {
02:     gameId: number;
03:     age: string;
04:     link: string;
05:     title: string;
06:     origin: string;
07:     players: string;
08:     description: string;
09:     alternateNames: string;
10: }

Listing 8-8(a)TypeScript Interface BoardGamesEntity

08: export const environment = {
09:   boardGameServiceUrl: `/api/board-games`,
10:   commentsServiceUrl: '/api/board-games/comments',
11:   boardGamesByIdServiceUrl: '/api/board-games/gameById',
12:   production: false,
13: };
14:

Listing 8-7Environment File with Additional Endpoints

Note

两个文件中需要 URL:src/environments/environment.tssrc/environments/environment.prod.tsenvironment.ts文件用于开发构建(例如,yarn start)。environment.prod.ts文件用于生产构建(例如yarn buildng build)。

添加注释

参见图 8-2 。请注意数据表单的最后一部分添加了注释。它使用户能够添加关于棋盘游戏的评论。到目前为止,您主要从事数据检索工作。这是一个创建实体的例子,即一个评论实体。如前所述,您使用 HTTP POST 方法在后端系统中创建一个实体。

考虑清单 8-9 ,它显示了 Add Comment HTML 模板。

01: <div>
02:     <mat-form-field>
03:         <mat-label>Your name</mat-label>
04:         <input matInput type="text" placeholder="Please provide your name" (change)="updateName($event)">
05:     </mat-form-field>
06: </div>
07:
08: <div>
09:     <mat-form-field>
10:         <mat-label>Comment Title</mat-label>
11:         <input matInput type="text" placeholder="Please provide a title for the comment" (change)="updateTitle($event)">
12:     </mat-form-field>
13: </div>
14:
15: <div>
16:     <mat-form-field>
17:         <mat-label>Comment</mat-label>
18:         <textarea name="comment" id="comment" placeholder="Write your comment here" (change)="updateComments($event)" matInput cols="30" rows="10"></textarea>
19:     </mat-form-field>
20: </div>
21:
22: <button mat-raised-button color="primary" (click)="submitComment()">Submit</button>

Listing 8-9Add Comment HTML Template

考虑以下解释:

  • 注意第 1 行到第 20 行。他们为用户名、标题和评论详细信息创建表单字段。

  • 该列表使用材料设计组件和指令。第 4、11 和 18 行分别对元素输入和文本区域使用matInput。这些 Angular 材料元素需要材料设计输入模块。参见清单 8-10 ,第 1 行和第 8 行。

  • mat-form-field组件封装了表单字段和标签。组件mat-label显示了表单字段的标签。

  • 第 4、11 和 18 行使用与函数updateName()updateTitle()updateComments().绑定的变更事件数据,清单 8-11 将表单字段的值设置为组件中的一个变量。每当表单域发生更改(用户键入值)时,change 事件就会发生。

  • 在清单 8-9 中,注意第 22 行 HTML 模板中的点击事件数据绑定。当用户点击按钮时,TypeScript 函数submitComments()被调用。

01: import { MatInputModule } from '@angular/material/input';
02:
03: @NgModule({
04:   declarations: [
05:     AppComponent,
06:   ],
07:   imports: [
08:     MatInputModule,
09:     BrowserAnimationsModule
10:   ],
11:   bootstrap: [AppComponent]
12: })
13: export class AppModule { }
14:

Listing 8-10Import Angular Material Input Module

考虑为组件的 TypeScript 代码列出 8-11 。它包括“注释”表单中的“更改事件处理程序”和“单击事件处理程序”。

01: import { Component, OnInit } from '@angular/core';
02: import { MatSnackBar } from '@angular/material/snack-bar';
03: import { GamesService } from 'src/app/common/games.service';
04:
05: @Component({
06:   selector: 'wade-game-details',
07:   templateUrl: './game-details.component.html',
08:   styleUrls: ['./game-details.component.sass']
09: })
10: export class GameDetailsComponent implements OnInit {
11:
12:   name: string = "";
13:   title: string = "";
14:   comments: string = "";
15:
16:   constructor( private gamesSvc: GamesService,
17:     private snackbar: MatSnackBar) { }
18:
19:   updateName(event: any){
20:     this.name = event.target.value;
21:   }
22:
23:   updateTitle(event: any){
24:     this.title = event.target.value;
25:   }
26:
27:   updateComments(event: any){
28:     this.comments = event.target.value;
29:   }
30:
31:   submitComment(){
32:       this
33:         .gamesSvc
34:         .addComments(this.title, this.name, this.comments, this.game.gameId)
35:         .subscribe( (res) => {
36:           this.snackbar.open('Add comment successful', 'Close');
37:         });
38:   }
39:
40: }

Listing 8-11Comments Form Handlers

考虑以下解释:

  • 参见第 19 至 29 行的功能updateName()updateTitle()updateComments()。请记住,它们是在表单字段的变更事件中调用的。注意函数定义使用了event.target.value。事件的目标指向表单域(DOM 元素)。该值返回用户键入的数据。

  • 这些值被设置为类变量name(用户名)、title(评论标题)和comments(评论描述)。

  • 提交按钮的点击事件是绑定到submitComment()函数的数据。请参见第 32 至 38 行。注意,它调用了服务GameService实例(gameSvc)上的addComments()函数。在第 16 行,GameService被注入到组件中使用。

  • 注意,服务函数需要一个参数列表,包括用户名、标题和描述。先前捕获的值(使用 change 事件处理程序)被传递到服务函数中。

  • addComments()调用服务器端 HTTP 服务。如果添加注释动作成功,将调用可观察对象的成功回调。它显示一条成功消息,提供关于添加评论操作的反馈。

清单 8-12 显示了GameService的实现。清单主要关注addComments()动作。

01: import { Injectable } from '@angular/core';
02: import { HttpClient } from '@angular/common/http';
03: import { environment } from 'src/environments/environment';
04:
05:
06: @Injectable({
07:   providedIn: 'root'
08: })
09: export class GamesService {
10:
11:   constructor(private httpClient: HttpClient) { }
12:
13:   addComments(title: string, userName: string, comments: string, gameId: number, timeCommented = new Date()){
14:     return this
15:       .httpClient
16:       .post(environment.commentsServiceUrl, [{
17:         title,
18:         userName,
19:         timeCommented,
20:         comments,
21:         gameId
22:       }]);
23:   }
24: }

Listing 8-12GameService Implementation

考虑以下解释:

  • 第 11 行注入了HttpClient服务。这是 Angular 提供的现成服务,用于进行 HTTP 调用。

  • 在第 14 行和第 22 行,函数使用了名为httpClient.HttpClient实例,这调用了远程 HTTP 服务。

  • 在第 16 行,注意您正在进行一个 HTTP POST 调用。第一个参数是服务 URL。考虑到 URL 是一个配置工件,它在环境文件中被更新。

  • 第二个参数是 POST 方法的请求体。参见图 8-5 了解这些值如何在网络上转换为请求体。

Note

一个评论 URL 用于两个操作,检索和创建评论。RESTful 服务使用 HTTP 方法 GET 进行检索。对于创建操作,POST HTTP 方法使用相同的 URL。

模拟 HTTP 服务的更新

新组件需要来自远程 HTTP 服务的附加数据和特性。本节详细介绍了对模拟服务的更改。在实际应用中,这些服务和功能是通过查询和更新数据库来开发的。由于这超出了本书的范围,我们将开发模拟服务。

按 ID 过滤游戏详情

游戏细节组件一次需要一个游戏细节。请记住,在前面的章节中,您开发了一个返回所有棋盘游戏的服务。本节详细介绍了如何通过 ID 检索游戏数据。

记住,我们使用mock-data/board-games.js来表示所有棋盘游戏相关的端点。添加一个新的端点,它通过 ID 检索游戏。命名为/gameById,如清单 8-13 所示。

1: var express = require('express');
2: var router = express.Router();
3: var dataset = require('../data/board-games.json');
4:
5: router.get('/gameById', function(req, res, next){
6:     res.setHeader('Content-Type', 'application/json');
7:     res.send(dataset
          .boardGames
          .find( i => +i.gameId === +req.query.gameId));
8: });

Listing 8-13Filter Game by an ID

考虑以下解释:

img/515699_1_En_8_Fig3_HTML.png

图 8-3

通过 ID 过滤游戏

  • 第 7 行通过游戏 ID 过滤棋盘游戏。语句dataset .boardGames.find( i => +i.gameId === +req.query.gameId)返回给定 ID 的游戏细节。通常,我们期望一个游戏有一个 ID。在另一个不同的场景中,如果您预期不止一个结果,请使用filter()函数而不是find()

  • 来自find()函数的结果作为参数传递给响应对象上的send()函数(变量名res)。这将结果返回给客户端(浏览器)。见图 8-3 。

    • 见第 3 行。模拟服务从数据目录中的模拟 JSON 对象检索棋盘游戏列表。

    • 见第 5 行。这个过滤器端点的 HTTP 方法是 GET。

    • 见第 6 行。响应内容类型设置为 JSON,这是 Angular 服务和组件的现成格式。

Note

注意第 7 行的+号。这是 JavaScript 中一种将字符串大小写转换为数字的方法。

正在检索注释

游戏详情页面列出评论,如图 8-2 所示。注意游戏描述下面的评论列表。本节详细介绍了如何创建一个模拟服务器端服务来检索注释。

该服务返回对给定游戏的评论。它使用游戏 ID 的查询参数。考虑一个示例 URL,http://localhost:3000/api/board-games/comments?gameId=1。见图 8-4 。

img/515699_1_En_8_Fig4_HTML.png

图 8-4

注释端点

真实世界的服务与数据库相集成,以有效地存储和查询数据。如前所述,它是一个模拟服务。因此,它从文件中读取注释。考虑在mock_sevices/routes/board-games.js中列出 8-14 。board-games.js文件是合适的,因为它包含了所有与棋盘游戏相关的端点。评论在一个棋盘游戏上。

01: var fs = require('fs');
02: var express = require('express');
03: var router = express.Router();
04:
05: router.get('/comments', function(req, res){
06:     fs.readFile("data/comments.json", {encoding: 'utf-8'},  function(err, data){
07:         let comments = [];
08:         if(err){
09:             return console.log("error reading from the file", err);
10:         }
11:         res.setHeader('Content-Type', 'application/json');
12:         comments = JSON.parse(data);
13:         comments = Object.values(comments).filter( i => {
14:
15:             return +i.gameId === +req.query.gameId
16:         });
17:         res.send(comments);
18:     });
19: });

Listing 8-14An Endpoint to Retrieve Comments

考虑以下解释:

  • 第 5 行创建了一个端点,它响应一个 HTTP 方法 GET。快速路由器实例上的get()功能使您能够创建端点。

  • 第 6 行从磁盘上的一个文件中检索当前的注释列表,data/comments.json

  • fs模块上的readFile()功能(用于“文件系统”)是异步的。您提供了一个回调函数,当 API 成功读取一个文件或错误时调用该函数。在清单 8-14 中,注意第 6 行和第 18 行之间的回调函数。

  • 第一个参数err表示错误,第二个参数data包含文件的内容。请参见第 8 行到第 10 行。如果返回一个错误,它将被记录下来,并将控制返回到函数之外。

  • 假设读取文件时没有错误,文件内容包括属于系统中所有游戏的完整评论列表。该服务预计只返回给定游戏的评论。因此,你通过游戏 ID 过滤评论。参见第 13 到 16 行的代码,它创建了一个过滤器。在 JavaScript 数组对象上定义了filter()函数。第 15 行的谓词测试数组中的每一项。返回带有给定游戏 ID 的评论。

  • 参见第 17 行,该行用过滤后的注释响应客户机(例如,浏览器)。

添加注释

游戏详情页面允许用户评论,如图 8-2 所示。该表单包含用户名、标题和详细评论字段。本节详细介绍了如何创建一个模拟服务器端服务来保存注释。

添加注释是通过创建操作完成的。您正在创建一个新的注释实体。请记住,POST 方法适用于创建实体。POST 方法有一个请求体,其中包含一个由 Angular 应用创建的注释列表(由用户键入)。参见图 8-5 。

img/515699_1_En_8_Fig5_HTML.png

图 8-5

创建注释端点

考虑在mock_sevices/routes/board-games.js中列出 8-15 。board-games.js文件是合适的,因为它包含了所有与棋盘游戏相关的端点。用户在评论桌游。

01: var fs = require('fs');
02: var express = require('express');
03: var router = express.Router();
04:
05: router.post('/comments', function(req, res){
06:     let commentsData = [];
07:     try{
08:         fs.readFile("data/comments.json", {encoding: 'utf-8'},  function(err, data){
09:             if(err){
10:                 return console.log("error reading from the file", err);
11:             }
12:             commentsData = commentsData.concat(JSON.parse(data));
13:             commentsData = commentsData.concat(req.body);
14:
15:             fs.writeFile("data/comments.json", JSON.stringify(commentsData), function(err){
16:                 if(err){
17:                     return console.log("error writing to file", err);
18:                 }
19:                 console.log("file saved");
20:             });
21:         });
22:         res.send({
23:             status: 'success'
24:         });
25:     }catch(err){
26:         console.log('err2', err);
27:         res.sendStatus(200);
28:     }
29: });

Listing 8-15A POST Endpoint to Create Comments

考虑以下解释:

  • 第 5 行创建了一个端点,它响应 HTTP POST 方法。快速路由器实例上的post()功能使您能够创建端点。

  • 端点需要将新的注释附加到当前的注释列表。文件data/comments.json有一个当前注释列表的数组。

  • fs模块上的readFile()功能是异步的。您提供了一个回调函数,当 API 成功读取一个文件或错误时调用该函数。在清单 8-15 中,注意第 8 到 21 行的回调函数。

  • 第一个参数err表示错误,第二个参数data包含文件内容。请参见第 9 行到第 11 行。如果返回一个错误,它将被记录下来,并将控制返回到函数之外。

  • 假设读取文件没有错误,第 12 行将文件中的注释添加到一个名为commentsData的局部变量中。

  • 接下来,第 13 行将请求对象上的新注释列表连接到commentsData变量。如前所述,POST 方法有一个请求体。它包括一个由 Angular 应用提供的注释列表。

  • 注释的综合列表被写回到文件中。参见第 15 行,它将整个注释列表写入文件。

摘要

本章基于 Web Arcade 使用案例。这对理解我们将在下一章构建的离线函数至关重要。到目前为止,在处理数据时,您执行了数据检索和缓存。本章为创建、更新和删除场景建立了一个用例。

Exercise

  • 游戏详情页面只显示一个棋盘游戏的标题和描述。然而,模拟服务和 TypeScript 接口包括许多附加字段,包括来源、别名、推荐的玩家数量等。包括游戏详细信息页面上的附加字段。

  • 如果操作成功,添加注释功能会显示一条 Snackbar 组件消息(参见清单 8-11 ,第 36 行)。该示例不显示错误消息。更新代码示例以显示 Snackbar 组件错误警报。

  • 在游戏详细信息页面上实现一个后退按钮,以导航回列表屏幕。

九、离线创建数据

在本书的前面,我们开始在 Angular 应用中集成 IndexedDB。我们还建立了一个离线创建数据的用例,即用户评论。假设一个用户在一个游戏详情页面上,并试图添加评论。但是,用户无法访问网络。Web Arcade 应用具有弹性,因为它将评论临时保存在客户端设备/浏览器上。当用户重新上线时,应用会在线同步评论。

本章详细阐述了如何离线创建数据。它以识别应用是在线还是离线的指令开始。您使用状态来确定如何访问服务器端服务或使用本地 IndexedDB 存储。接下来,本章详细介绍了如何在脱机状态下向 IndexedDB 添加注释。它详细说明了如何向用户提供应用脱机但数据被临时保存的反馈。

然后,本章介绍了一旦应用恢复在线,如何将离线注释与服务器端服务同步。记住,服务器端数据库和服务是数据的真实来源。IndexedDB 是临时的,为用户提供无缝体验。

在线和离线添加评论

前一章描述了如何添加注释。提交操作调用服务器端 HTTP 端点。如果设备离线并失去网络连接,典型的 web 应用会显示一个错误。一旦联机,用户可能必须重试该操作。如前所述,Web Arcade 使用 IndexedDB,临时保存数据,并在远程服务可用时进行同步。

用 Getter 识别联机/脱机状态

要识别设备(和浏览器)是否在线,请使用 navigator 对象上的 JavaScript API。它是window对象的只读属性。字段onLine返回当前状态,如果在线则为真,如果离线则为假。

谷歌 Chrome 上的开发者工具提供了一个降低网速的选项。这有助于应用评估其性能和用户体验。见图 9-1 。这些工具将onLine字段值打印在导航器对象上。请注意,浏览器窗口是离线节流的。

img/515699_1_En_9_Fig1_HTML.png

图 9-1

Google Chrome 开发者工具,在控制台上打印在线状态

Note

您可以在自己选择的浏览器上运行类似的命令。图 9-1 显示的是谷歌 Chrome,是任意选择的。

记住,我们创建了一个名为IdbStorageAccessService的服务来封装对 IndexedDB 的访问。联机/脱机状态决定了可以访问 IndexedDB 的组件。因此,您应该包含代码行来确定服务中的在线/离线状态。

Window服务注入IdbStorageAccessService,如清单 9-1 第 3 行所示。

1: @Injectable()
2: export class IdbStorageAccessService {
3:   constructor(
          private windowObj: Window) {
4:     // this.create();
5:   }
6: }

Listing 9-1Inject the Window Service

确保提供了Window服务。关于 Web Arcade 应用,请参见清单 9-2 ,第 10 行和第 15 行。您为Window服务提供了一个全局变量window,如第 14 行所示。这提供了对有用属性的访问,如documentnavigator等。

01: @NgModule({
02:     declarations: [
03:       AppComponent,
04:       // ...
05:     ],
06:     imports: [
07:       BrowserModule,
08:       // ...
09:     ],
10:     providers: [
11:       IdbStorageAccessService,
12:       {
13:         provide: Window,
14:         useValue: window
15:       }
16:     ],
17:     bootstrap: [AppComponent]
18:   })
19:   export class AppModule { }

Listing 9-2Provide the Window Service

IdbStorageAccessService 中创建一个名为IsOnline的 getter 函数。服务实例可以使用IsOnline字段来获取浏览器的状态。代码在服务中被抽象。见清单 9-3 。

1: get IsOnline(){
2:     return this.windowObj.navigator.onLine;
3: }

Listing 9-3IsOnline Getter as Part of IdbStorageAccessService

添加在线/离线事件监听器

当应用联机或脱机时,您可能会遇到需要执行某个操作的情况。窗口对象(以及窗口服务)提供事件onlineoffline。初始化时将这些事件添加到IdbStorageAccessService。事件处理程序回调函数在事件发生时被调用。

清单 9-4 在浏览器控制台上打印一条包含事件数据的消息。您可以在事件触发时执行操作。具体参见第 8 至 11 行和第 13 至 16 行。

01: @Injectable()
02: export class IdbStorageAccessService {
03:
04:   constructor(private windowObj: Window) {
05:   }
06:
07:   init() {
08:     this.windowObj.addEventListener("online", (event) => {
09:       console.log("application is online", event);
10:       // Perform an action when online
11:     });
12:
13:     this.windowObj.addEventListener('offline', (event)=> {
14:         console.log("application is offline", event)
15:         // Perform an action when offline
16:     });
17:   }
18: }

Listing 9-4Online and Offline Events

图 9-2 显示了结果。

img/515699_1_En_9_Fig2_HTML.png

图 9-2

线上和线下活动

向索引添加注释 b

记住,当需要时,我们打算在 IndexedDB 中缓存注释。考虑到IdbStorageAccessService从应用的其余部分抽象出访问数据库的任务,增加服务并添加一个在 IndexedDB 中缓存注释的功能。但是在我们开始之前,清单 9-5 显示了到目前为止服务的快速回顾。

01: @Injectable()
02: export class IdbStorageAccessService {
03:
04:   idb = this.windowObj.indexedDB;
05:
06:   constructor(private windowObj: Window) {
07:   }
08:
09:   init() {
10:
11:     let request = this.idb.open('web-arcade', 1);
12:
13:     request.onsuccess = (evt:any) => {
14:       console.log("Open Success", evt);
15:
16:     };
17:
18:     request.onerror = (error: any) => {
19:       console.error("Error opening IndexedDB", error);
20:     }
21:
22:     request.onupgradeneeded = function(event: any){
23:       let dbRef = event.target.result;
24:       let objStore = dbRef
25:         .createObjectStore("gameComments", { autoIncrement: true })
26:
27:       let idxCommentId = objStore.createIndex('IdxCommentId', 'commentId', {unique: true})
28:     };
29:
30:     this.windowObj.addEventListener("online", (event) => {
31:       console.log("application is online", event);
32:       // Peform an action when online
33:     });
34:
35:     this.windowObj.addEventListener('offline', (event) => {
36:         console.log("application is offline", event)
37:         // Perform an action when offline
38:     });
39:
40:   }
41: }

Listing 9-5IdbStorageAccessService

到目前为止,该服务创建了对 IndexedDB 的引用,打开了一个新的数据库,并创建了一个对象存储和一个索引。考虑下面对清单 9-5 的详细解释:

  • 在第 4 行,在一个类变量上设置了一个 IndexedDB 引用,即idb

  • 接下来,在init()函数(它初始化服务并出现在第 11 行)中,对idb对象运行open()函数。它返回IDBOpenDBRequest对象的一个对象。

  • 如果这是用户第一次在浏览器上打开应用,它会创建一个新的数据库。

    1. 第一个参数是数据库的名称web-arcade

    2. 第二个参数(值为 1)指定数据库的版本。可以想象,应用的新更新会导致 IndexedDB 结构的变化。IndexedDB API 使您能够随着版本的变化升级数据库。

对于再次访问的用户,数据库已经创建好,并且可以在浏览器上使用。open()函数试图打开数据库。

  1. IndexedDB APIs 是异步的。第 11 行的打开操作没有完成。您为成功和失败场景提供了一个函数回调。它们作为打开操作的结果被调用。

    1. 注意第 13 到 16 行的onsuccess()函数回调,如果打开数据库操作成功,就会调用这个函数。

    2. 如果 open database 动作失败,则调用第 18 到 20 行的onerror()函数回调。

    3. open 函数调用返回IDBOpenDBRequest 之前的回调函数onsuccessonerror被提供给这个返回的对象。

  2. 参见第 22 到 28 行的代码,其中onupgradeneeded在创建或打开 IndexedDB 后被触发。您提供了一个回调函数,当此事件发生时,浏览器会调用该函数。onupgradeneeded事件的意义是什么?

    1. 对于新数据库,回调函数是创建对象存储的好地方。在当前用例中,您创建一个对象存储来保存游戏评论。你给它取名gameComments

    2. 对于预先存在的数据库,如果需要升级,您可以在此处执行设计更改。

  3. 最后,在第 30 到 38 行,查看当浏览器联机/脱机时,联机和脱机事件的函数回调。

Angular 服务IdbStorageAccessService需要对数据库web-arcade的引用。你用它来创建一个交易。使用 IndexedDB,您需要一个事务来执行创建、检索、更新和删除(CRUD)操作。第 11 行的语句,this.idb.open('web-arcade',1)函数调用,试图打开一个数据库,即web-arcade。如果成功,您可以访问数据库引用作为onsuccess()函数回调的一部分。考虑清单 9-6 。

01: @Injectable()
02: export class IdbStorageAccessService {
03:
04:   idb = this.windowObj.indexedDB;
05:   indexedDb: IDBDatabase;
06:   init() {
07:
08:     let request = this.idb.open('web-arcade', 1);
09:
10:     request.onsuccess = (evt:any) => {
11:       console.log("Open Success", evt);
12:       this.indexedDb = evt?.target?.result;
13:     };
14:   }
15: }

Listing 9-6Access the web-arcade Database Reference

考虑以下解释:

  • 见第 5 行。indexedDB是一个可以跨服务访问的类变量。

  • 在成功打开web-arcade数据库时会分配一个值,如第 12 行所示。数据库实例在event(event.target.result)对象的target属性的result变量中可用。

接下来,添加一个函数在 IndexedDB 中创建注释。这将创建一个 IndexedDB 事务,访问对象存储,并添加一条新记录。考虑列出 9-7 。

01: addComment(title: string, userName: string, comments: string, gameId: number, timeCommented = new Date()){
02:     let transaction = this.indexedDb
03:       .transaction("gameComments", "readwrite");
04:
05:       transaction.objectStore("gameComments")
06:         .add(
07:           {
08:             title,
09:             userName,
10:             timeCommented,
11:             comments,
12:             gameId,
13:             commentId: new Date().getTime()
14:           }
15:         )
16:
17:
18:       transaction.oncomplete = (evt) => console.log("add comment transaction complete", evt);
19:       transaction.onerror = (err) => console.log("add comment transaction errored out", err);
20:
21:   }

Listing 9-7Add a New Record in IndexedDB

考虑以下解释:

  • 接下来,对 IndexedDB 执行添加记录操作。使用事务对象访问需要执行添加操作的对象存储。参见第 5 行,它使用了objectStore函数来访问对象store()

  • 请参见第 6 行和第 15 行。您存储了一个 JavaScript 对象,包括评论标题、用户名、评论时间、评论描述、添加评论的游戏 ID 和唯一的评论 ID。为了确保唯一性,可以使用时间值。您可以使用任何唯一的值。

  • 正如您在 IndexedDB 中看到的,数据库操作是异步的。add()函数不会立即添加记录。它最终调用一个成功或错误回调函数。事务具有以下回调函数:

    1. 成功时调用。见第 18 行。它在控制台上打印状态。

    2. onerror:出错时调用。见第 19 行。

  • 首先,用类变量indexedDB创建一个新的事务(在清单 9-6 中创建)。参见第 3 行的事务函数。它需要两个参数:

    1. 需要在其中创建事务的一个或多个对象存储。在这种情况下,您在对象存储库gameComments上创建一个事务。

    2. 指定交易模式,readwrite。IndexedDB 支持三种交易模式,即readonlyreadwriteversionchange可以想象,readonly帮助检索操作,readwrite帮助创建/更新/删除操作。然而,versionchange模式有助于在 IndexedDB 上创建和删除对象存储。

图 9-3 显示了 IndexedDB 中的一条记录。

img/515699_1_En_9_Fig3_HTML.png

图 9-3

索引中的新纪录 b

添加评论的用户体验

记得上一章提到过,UserDetailsComponent通过调用名为addComments GameService函数来添加注释。这将调用服务器端 POST 调用来添加注释。如果应用脱机,它将出错。您向用户显示一个错误反馈,并请求用户重试。

在这一章中,如果浏览器离线,你已经完成了在 IndexedDB 中缓存注释的后台工作。接下来,更新组件以检查应用是在线还是离线,并调用相应的服务函数。考虑清单 9-8 中的代码片段,它来自GameDetailsComponent ( app/components/game-details/game-details.component.ts)。

01: @Component({ /* ... */ })
02: export class GameDetailsComponent implements OnInit {
03:
04:     constructor(private idbSvc: IdbStorageAccessService,
05:         private gamesSvc: GamesService,
07:         private snackbar: MatSnackBar,
08:         private router: ActivatedRoute) { }
09:
10:     submitComment() {
11:         if (this.idbSvc.IsOnline) {
12:             this
13:                 .gamesSvc
14:                 .addComments(/* provide comment fields */)
15:                 .subscribe((res) => {
16:
17:                     this.snackbar.open('Add comment successful', 'Close');
18:                 });
19:         } else {
20:             this.idbSvc.addComment(this.title, this.name, this.comments, this.game.gameId);
21:             this.snackbar.open('Application is offline. We saved it temporarily', 'Close');
22:         }
23:     }
24: }

Listing 9-8Add a Comment in the Game Details Component

考虑以下解释:

img/515699_1_En_9_Fig4_HTML.jpg

图 9-4

指示应用脱机的 Snackbar 组件警报

  • 请注意,在第 21 行,您显示了一条 Snackbar 组件消息,表明应用处于脱机状态。图 9-4 显示结果。

  • 一开始,注射IdbStorageAccessService。见第 4 行。服务实例被命名为idbSvc

  • 第 11 行检查应用是否在线。注意,您使用了在清单 9-3 中创建的IsOnline getter。

    1. 如果为真,继续调用游戏服务函数,addComments()。它调用服务器端服务。

    2. 如果离线,使用IdbStorageAccessService函数addComment(),将注释添加到 IndexedDB。参见清单 9-7 中的实现。

将离线注释与服务器同步

当应用离线时,您可以使用 IndexedDB 将浏览器中的注释缓存在持久存储中。最终,一旦应用重新上线,当用户再次启动应用时,评论需要与服务器端同步。这一节详细介绍了识别应用在线并同步注释记录的实现。

当浏览器获得或失去连接时,window对象上的两个事件onlineoffline被触发。IdbStorageAccessService服务包括onlineoffline事件的事件处理程序。参见清单 9-4 。

接下来,更新在线事件处理程序。考虑以下步骤来将数据与服务器端数据库同步。当应用重新联机时,您需要执行以下操作:

  1. 从 IndexedDB 中检索所有缓存的注释。

  2. 调用服务器端 HTTP 服务,该服务为用户评论更新主数据库。

  3. 最后,清空缓存。删除与远程服务同步的注释。

让我们从前面列表中的第一步开始,从 IndexedDB 中检索所有缓存的注释。下一节详细介绍了从 IndexedDB 检索数据的各种选项和可用的 API。

从 IndexedDB 中检索数据

IndexedDB 提供了以下用于检索数据的 API:

  • getAll():检索对象存储中的所有记录

如前所述,CRUD 操作在事务范围内运行。因此,您将在对象存储上创建一个只读事务(考虑它是一个数据检索操作)。调用getAll() API,它返回IDBRequest,如清单 9-9 所示。

IDBRequest对象上,提供onsuccessonerror回调函数定义。如您所知,几乎所有的 IndexedDB 操作都是异步的。使用getAll()的数据检索不会立即发生。它回调提供的回调函数。

1: let request = this.indexedDb
2: .transaction("gameComments", "readonly")
3: .objectStore("gameComments")
4: .getAll();
5:
6: request.onsuccess = resObject => console.log('getAll results', resObject);
7: request.onerror = err => console.error('Error reading data', err);

Listing 9-9Using getAll()

结果如图 9-5 所示。注意清单 9-9 中第 6 行的成功处理程序。结果变量命名为resObject 结果记录可以在resObject ( resObject.target.result)的target属性的result对象上获得。

img/515699_1_En_9_Fig5_HTML.png

图 9-5

getAll()结果

  • get(key):按键检索记录。get()函数在对象存储上运行。

类似于getAll(),在对象存储上为get()创建一个只读事务。get() API 返回IDBRequest,如清单 9-10 所示。

处理结果或错误的其余代码是相同的。在IDBRequest对象上,提供onsuccessonerror回调函数定义。如您所知,几乎所有的 IndexedDB 操作都是异步的。使用get()的数据检索不会立即发生。它回调提供的回调函数。

1: let request = this.indexedDb
2:  .transaction("gameComments", "readonly")
3:  .objectStore("gameComments")
4:  .get(30);
5:
6: request.onsuccess = resultObject => console.log('get() results', resultObject);
7: request.onerror = err => console.error('Error reading data', err);

Listing 9-10Using get()

注意清单 9-10 中第 6 行的成功处理程序。结果变量命名为resultObject 结果记录在resultObject ( resultObject.target.result)的target属性的结果对象上可用。

  • 光标允许你遍历结果。它允许您一次处理一个记录。我们为注释用例选择了这个选项。它提供了在从 IndexedDB 读取数据时转换数据格式的灵活性。另外两个 API,getAll()get(),需要一个额外的代码循环来转换数据。

如前所述,CRUD 操作在事务范围内运行。因此,您将在对象存储上创建一个只读事务(考虑它是一个数据检索操作)。调用openCursor() API,它返回IDBRequest

同样,处理结果或错误的代码保持不变。在IDBRequest对象上,提供onsuccessonerror回调函数定义。与openCursor()的数据检索是异步的,它调用上述的onsuccessonerror回调函数。

创建一个新的私有函数来检索缓存的注释记录。提供任意名称getAllCachedComments()。在IdbStorageAccessService 中添加清单 9-11 所示的私有函数。

01: private getAllCachedComments() {
02:     return new Promise(
03:       (resolve, reject) => {
04:         let results: Array<{
05:           key: number,
06:           value: any
07:         }> = [];
08:
09:         let query = this.indexedDb
10:           .transaction("gameComments", "readonly")
11:           .objectStore("gameComments")
12:           .openCursor();
13:
14:           query.onsuccess = function (evt: any) {
15:
16:             let gameCommentsCursor = evt?.target?.result;
17:             if(gameCommentsCursor){
18:               results.push({
19:                 key: gameCommentsCursor.primaryKey,
20:                 value: gameCommentsCursor.value
21:               });
22:               gameCommentsCursor.continue();
23:             } else {
24:               resolve(results);
25:             }
26:           };
27:
28:           query.onerror = function (error: any){
29:             reject(error);
30:           };
31:
32:       });
33:   }

Listing 9-11Retrieve Cached Comments from IndexedDB

考虑以下解释:

  • 该函数创建并返回一个承诺。见第 2 行。考虑到数据检索是异步的,您不能立即从getAllCachedComments()函数返回注释记录。一旦游标从 IndexedDB 中检索完数据,该承诺就会得到解决。

  • 第 9 行和第 12 行创建一个只读事务,访问对象存储库gameComments,并打开一个游标。该语句返回一个IDBRequest对象,该对象被分配给一个局部变量query

  • 记住,如果光标能够从对象存储中检索数据,就会调用onsuccess回调。否则,调用onerror回调(第 28 和 30 行)。

  • 参见第 14 至 26 行中定义的onsuccesscallback()

  • event.target.result访问结果。见第 16 行。

Note

evt?.target?.result中的?.语法执行空检查。如果一个属性未定义,它将返回 null,而不是抛出一个错误并使整个函数工作流崩溃。前面的语句可能返回结果或 null。

  • 如果结果已定义,则将数据转换为键值对格式。将对象添加到名为result的局部变量中。

  • 记住,光标一次只作用于单个注释记录(不像get()getAll())。要将光标移动到下一条记录,请对查询对象调用 continue 函数。记住,query对象是由openCursor()返回的IDBRequest对象。

  • 第 17 行的if条件产生一个真值,直到游标中的所有记录都用完。

  • 如果为 false,当整个数据集(注释记录)被检索并添加到局部变量result时,解析承诺。调用函数使用从getAllCachedComments() 成功解析的结果。

这完成了前面描述的三个步骤中的第一步,如下所示:

  1. 从 IndexedDB 中检索所有缓存的评论。

    接下来,让我们继续另外两个步骤:

  2. 调用服务器端 HTTP 服务,该服务为用户评论更新主数据库。

  3. 最后,清空缓存。删除与远程服务同步的注释。

在服务器端批量更新注释

用户可能在应用脱机时添加了多个注释。建议在一次通话中上传所有评论。服务器端 HTTP POST 端点/comments接受一组注释。

记住,Angular 服务GameService ( src/app/common/game.service.ts)封装了所有游戏相关的服务调用。添加一个新函数,该函数接受一组注释并进行 HTTP POST 调用。与早期的服务调用类似,新函数使用一个HttpClient对象进行 post 调用。新功能addBulkComments见清单 9-12 (功能名称随意)。请参见第 9 行和第 18 行。

02: @Injectable({
03:   providedIn: 'root'
04: })
05: export class GamesService {
06:
07:   constructor(private httpClient: HttpClient) { }
08:
09:   addBulkComments(comments: Array<{title: string,
10:     userName: string,
11:     comments: string,
12:     gameId: number,
13:     timeCommented: Date}>){
14:     return this
15:       .httpClient
16:       .post(environment.commentsServiceUrl, comments);
17:
18:   }
19: }

Listing 9-12Add Bulk Comments

Note

函数addBulkComments()使用匿名数据类型作为参数。注释变量的类型是Array<{title: string, userName: string, comments: string, gameId: number, timeCommented: Date}>。突出显示的类型没有名称。您可以对一次性数据类型使用这种技术。

您可以选择创建一个新实体并使用它。

服务函数现在是可用的,但是还没有被调用。但是,您可以使用服务功能来批量更新缓存的注释。在我们开始使用这个函数之前,考虑添加一个函数来删除。

这也完成了第二步。现在,您有了从 IndexedDB 中检索缓存注释的代码,并调用服务器端服务来同步离线注释。

  1. 从 IndexedDB 中检索所有缓存的评论。

  2. 调用服务器端 HTTP 服务,该服务为用户评论更新主数据库。

  3. 最后,清空缓存。删除与远程服务同步的注释。

接下来,添加代码来清理 IndexedDB。

从索引中删除数据 b

IndexedDB 为从 IndexedDB 中删除数据提供了以下 API:

  • delete():删除对象存储中的记录。这将通过记录 ID 选择要删除的记录。

如前所述,CRUD 操作在事务范围内运行。因此,您将在对象存储上创建一个读写事务。调用getAll() API,它返回IDBRequest

IDBRequest对象上,提供onsuccessonerror回调函数定义。如前所述,几乎所有的 IndexedDB 操作都是异步的。删除操作不会立即发生。它回调提供的回调函数,如清单 9-13 所示。请注意,它返回一个承诺。如果删除操作成功,则承诺得到解决。见第 10 行。如果失败了,这个承诺就被拒绝了。见第 14 行。

01: deleteComment(recordId: number){
02:     return new Promise( (resolve, reject) => {
03:       let deleteQuery = this.indexedDb
04:             .transaction("gameComments", "readwrite")
05:             .objectStore("gameComments")
06:             .delete(recordId);
07:
08:       deleteQuery.onsuccess = (evt) => {
09:         console.log("delete successful", evt);
10:         resolve(true);
11:       }
12:       deleteQuery.onerror = (error) => {
13:         console.log("delete successful", error);
14:         reject(error);
15:       }
16:     });
17:   }

Listing 9-13Using delete()

IdbStorageAccessService中包含之前的功能。记住,这个服务封装了所有与 IndexedDB 相关的动作。现在,您已经有了同步脱机评论的所有三个步骤的代码。

  1. 从 IndexedDB 中检索所有缓存的评论。

  2. 调用一个服务器端 HTTP 服务,它为用户评论更新主数据库。

  3. 最后,清空缓存。删除与远程服务同步的注释。

请注意,这些服务功能是可用的,但是当应用重新联机时,它们还没有被触发。在本章的前面,服务IdbStorageAccessService包括一个用于online事件的事件处理程序。当应用重新联机时,将调用它。更新此事件处理程序以同步脱机注释。考虑在IdbStorageAccessService更新清单 9-14 。

01: this.windowObj.addEventListener("online", (event) => {
02:     this.getAllCachedComments()
03:     .then((result: any) => {
04:       if (Array.isArray(result)) {
05:         let r = this.transformCommentDataStructure(result);
06:         this
07:           .gameSvc
08:           .addBulkComments(r)
09:           .subscribe(
10:             () => {
11:               this.deleteSynchronizedComments(result);
12:             },
13:             () => ({/* error handler  */})
14:           );
15:       }
16:     });
17:   });

Listing 9-14The Online Event Handler

考虑以下解释:

  • 首先,检索所有缓存的注释。参见第 2 行,它调用了getAllCachedComments()服务函数。参见清单 9-11 查看从 IndexedDB 中检索缓存的注释。

  • 该函数返回一个承诺。解决承诺后,您可以从 IndexedDB 访问注释记录。您使用这些数据在后端添加注释,同步服务器端服务和数据库。

  • 在调用服务器端服务之前,将注释记录转换为请求对象结构。您遍历所有的注释,并根据服务器端服务的要求更改字段名称。

    1. 清单 9-15 定义了一个名为transformCommentDataStructure() 的私有函数。注意从 IndexedDB 对象存储中获得的注释数组上的forEach()。注释被转换并添加到一个本地变量comments 这是在函数结束时返回的。
  • 接下来调用GameService函数addBulkComments(),该函数又调用服务器端服务。要查看addBulkComments()功能,请参见清单 9-12 。

  • 记住,函数addBulkComments()返回一个可观察值。你订阅了可观察的,它有成功和失败的处理器。成功处理程序指示注释被添加/与服务器端同步。因此,现在可以删除 IndexedDB 中缓存的注释。

  • 调用被定义为服务IdbStorageAccessService的一部分的私有函数deleteSynchronizedComments()。它遍历每个注释记录,并从本地数据库中删除注释。关于deleteSynchronizedComments()功能的定义,参见清单 9-16 。

    1. 注意,forEach循环使用了一个匿名类型和一个键值对。见第 3 行(r: {key: number; value: any})。它定义了注释数据的预期结构。

    2. deleteComment()按 ID 删除每条记录。要再次查看该功能,请参见清单 9-13 。

1: private deleteSynchronizedComments(result: Array<any>){
2:     result
3:       ?.forEach( (r: {key: number; value: any}) => this.deleteComment(r.key));
4:   }

Listing 9-16Delete Synchronized Comments

01: private transformCommentDataStructure(result: Array<any>){
02:     let comments: any[] = [];
03:     result?.forEach( (r: {key: number; value: any}) => {
04:         comments.push({
05:           title: r.value.title,
06:           userName: r.value.userName,
07:           comments: r.value.comments,
08:           gameId: r.value.gameId,
09:           timeCommented: new Date()
10:         });
11:     });
12:     return comments ;
13: }

Listing 9-15Transform Comments Data

现在,您已经将离线评论与服务器端同步了。参见清单 9-17 ,其中包括用于处理在线事件的事件处理程序和编排同步步骤的私有函数。

01: @Injectable()
02: export class IdbStorageAccessService {
03:
04:   idb = this.windowObj.indexedDB;
05:   indexedDb: IDBDatabase;
06:
07:   constructor(private gameSvc: GamesService, private windowObj: Window) {
08:   }
09:
10:   init() {
11:     let request = this.idb
12:       .open('web-arcade', 1);
13:
14:     request.onsuccess = (evt:any) => {
15:       this.indexedDb = evt?.target?.result;
16:     };
17:
18:     request.onupgradeneeded = function(event: any){
19:         // Create object store for game comments
20:     };
21:
22:     this.windowObj.addEventListener("online", (event) => {
23:       console.log("application is online", event);
24:       this.getAllCachedComments()
25:       .then((result: any) => {
26:         if (Array.isArray(result)) {
27:           let r = this.transformCommentDataStructure(result);
28:           this
29:             .gameSvc

30:             .addBulkComments(r)
31:             .subscribe(
32:               () => {
33:                 this.deleteSynchronizedComments(result);
34:               },
35:               () => ({/* error handler  */})
36:             );
37:         }
38:       });
39:     });
40:
41:     this.windowObj.addEventListener('offline', (event) => console.log("application is offline", event));
42:
43:   }
44:
45:   private deleteSynchronizedComments(result: Array<any>){
46:     result?.forEach( (r: {key: number; value: any}) => {
47:       this.deleteComment(r.key);
48:     });
49:   }
50:
51:   private transformCommentDataStructure(result: Array<any>){
52:       let comments: any[] = [];
53:       result?.forEach( (r: {key: number; value: any}) => {
54:           comments.push({
55:             title: r.value.title,
56:             userName: r.value.userName,
57:             comments: r.value.comments,
58:             gameId: r.value.gameId,
59:             timeCommented: new Date()
60:           });
61:       });
62:       return comments ;
63:   }

64:
65:   deleteComment(recordId: number){
66:     // Code in the listing 9-13
67:   }
68:
69:   private getAllCachedComments() {
70:     // Code in the listing 9-11
71:   }
72:
73: }

Listing 9-17Synchronized Comments with Online Event Handler

更新索引数据库中的数据

IndexedDB 为更新 IndexedDB 中的数据提供了以下 API:

  • put():更新对象存储中的记录。这将通过记录 ID 选择要更新的记录。

如前所述,CRUD 操作在事务范围内运行。因此,您将在对象存储上创建一个读写事务。调用put() API,它返回IDBRequest

IDBRequest对象上,提供onsuccessonerror回调函数定义。如前所述,几乎所有的 IndexedDB 操作都是异步的。使用put()的数据检索不会立即发生。它回调提供的回调函数,如清单 9-18 所示。

01: updateComment(recordId: number, updatedRecord: CommentEntity){
02:     /* let updatedRecord = {
03:         commentId: 1633432589457,
04:         comments: "New comment data",
05:         gameId: 1,
06:         timeCommented: 'Tue Oct 05 2021 16:46:29 GMT+0530 (India Standard Time)',
07:         title: "New Title",
08:         userName: "kotaru"
09:     } */
10:
11:     let update = this.indexedDb
12:         .transaction("gameComments", "readwrite")
13:         .objectStore("gameComments")
14:         .put(updatedRecord, recordId);
15:
16:     update.onsuccess = (evt) => {
17:         console.log("Update successful", evt);
18:     }
19:     update.onerror = (error) => {
20:         console.log("Update failed", error);
21:     }
22: }

Listing 9-18Update Records in IndexedDB

考虑以下解释:

  • 您创建了一个新函数来更新注释。想象一个允许用户编辑评论的表单。前面的函数可以执行此操作。

    注意当前用例不包括编辑评论用例。前面的函数用于演示 IndexedDB 上的put() API。

  • 注意第 2 行和第 9 行之间的注释代码行。这为更新的注释数据提供了一个任意的结构。然而,调用函数在一个updatedRecord变量中提供了更新的注释。

  • 见第 14 行。put 函数有两个参数。

    1. updatedRecord:这是替换当前对象的新对象。

    2. recordId:标识第二个参数recordId要更新的记录。

摘要

本章提供了向 IndexedDB 添加记录的详细说明。在带有游戏细节页面的 Web Arcade 用例中,应用允许用户离线添加评论。数据临时缓存在 IndexedDB 中,最终与服务器端服务同步。

Exercise

  • 您已经看到了如何使用put() API 来更新 IndexedDB 中的记录。添加编辑评论的功能。如果应用脱机,提供在 IndexedDB 中临时保存编辑内容的能力。

  • 注意,deleteComment()函数一次删除一条记录。提供错误处理以识别和纠正故障。

  • 当应用脱机时,提供一个可视指示器。您可以选择更改工具栏和标题的颜色。

十、将 Dexie.js 用于 IndexedDB

到目前为止,您已经看到了在客户端使用数据库的用例及实现。您了解并实现了 IndexedDB。浏览器 API 使您能够创建数据库,执行创建/检索/更新/删除(CRUD)操作。这些功能是浏览器自带的。所有主流浏览器的最新版本都支持 IndexedDB。然而,可以说,IndexedDB API 是复杂的。一个普通的开发者可能需要一个简化的版本。

Dexie.js 是 IndexedDB 的包装器。这是一个简单易用的库,可安装在您的应用中。它是一个拥有 Apache 2.0 许可的开源存储库。许可证允许商业使用、修改、分发、专利使用和私人使用。但是,它在商标使用方面有限制,并且没有责任和担保。在将该库用于您可能正在开发的业务应用时,更好地理解该协议。

本章是对 Dexie.js 的介绍。它概述了 Web Arcade 使用案例参数中的库。首先是在 Web Arcade 应用中安装 Dexie.js 的说明。接下来,它详细介绍了如何在 TypeScript 文件中使用该库。您将创建一个新的类和一个服务,使用 Dexie.js 将数据访问逻辑封装到 IndexedDB 中。最后,本章在 IndexedDB 的基础上列出了一些额外的库和包装器。

安装 Dexie.js

安装 Dexie 包。

npm i -S dexie
or
yarn add dexie

Note

命令npm i -S dexienpm install --save dexie的简称。

-S--save是将 Dexie 添加到 Web Arcade 包中的选项。一个条目将被添加到package.json。这将确保将来的安装包括 Dexie。

Yarn 不需要这个选项。它是含蓄的;它总是会将包添加到 Web Arcade。

网络商场数据库

创建封装 Web Arcade IndexedDB 连接的 TypeScript 类。使用此类通过 Dexie API 访问 IndexedDB 数据库web-arcade。运行此命令创建一个 TypeScript 类:

ng generate class common/web-arcade-db

使用类WebArcadeDb指定要创建和连接的 IndexedDB 数据库。您还将使用该类来定义对象存储、索引等。将清单 10-1 中所示的代码添加到新的类WebArcadeDb中。

01: import { Dexie } from 'dexie';
02: import { CommentsEntity } from './board-games-entity';
03:
04: const WEB_ARCADE_DB_NAME = 'web-arcade-dexie';
05: const OBJECT_STORE_GAME_COMMENTS = 'gameComments';
06: export class WebArcadeDb extends Dexie {
07:     comments: Dexie.Table<CommentsEntity>;
08:
09:     constructor() {
10:       super(WEB_ARCADE_DB_NAME);
11:       this.version(1.0).stores({
12:         gameComments: '++IdxCommentId,timeCommented,userName'
13:       });
14:       this.comments = this.table(OBJECT_STORE_GAME_COMMENTS);
15:      }
16:   }

Listing 10-1A TypeScript Class for the Web Arcade DB

考虑以下解释:

  • 第 6 行创建了一个新的 TypeScript 类,WebArcadeDb。它扩展了Dexie类,该类提供了许多开箱即用的特性,包括打开数据库、创建商店等。

  • 注意第 1 行的Dexie类是从dexie ES6 模块(Dexie 库的一部分)导入的。

  • 向超类提供web-arcade数据库名称。参见第 10 行,构造函数的第一行。在这个代码示例中,TypeScript 类WebArcadeDb专用于一个 IndexedDB,web-arcade。数据库名在第 4 行被赋给一个常量。它在打开数据库连接时使用。

对象存储/表

考虑下面的解释,它详细说明了如何在第 11 行和第 14 行之间使用stores()table()API:

  • 构造函数还定义了对象存储结构。在当前示例中,它创建了一个名为gameComments的商店。请参见第 5 行中的字符串值。您可以通过在 JSON 对象中包含额外的字段来创建额外的对象存储。它作为参数传递给stores()函数。

  • gameComments对象存储定义了两个字段,IdxCommentIdtimeCommented

  • 你在主键上加前缀(或后缀)++。此字段唯一标识每个注释。对于添加到对象存储中的每个记录,它都会自动递增。

  • 对象存储包括一个或多个字段。在这个例子中,对象存储包括两个字段:timeCommenteduserName 该语句使用列出的字段创建对象存储。

  • 在将记录插入对象存储时,您可能会包括更多字段。然而,索引只在用stores() API 指定的字段上创建(第 12 行)。查询仅限于使用对象存储索引的字段。因此,包括您将来可能会查询的任何字段。

  • 注意,stores 函数在一个version() API 中,它为 Web Arcade IndexedDB 定义了一个版本。正如您将在下一节中看到的,您可以创建数据库的附加版本并进行升级。

  • Dexie 使用名为Table的 TypeScript 类来引用对象存储。参见第 7 行的类变量注释。您创建了类型为Table ( Dexie.Table)的变量。

  • 注意传递给Table的泛型类型CommentsEntity类变量comments被限制在接口CommentsEntity中。记住,评论实体包括与用户评论相关的所有字段。在src/app/common/comments-entity.ts重访CommentsEntity。参见清单 10-2 。

  • 接下来,请参见第 14 行。this.table()函数返回一个对象存储引用。table()函数继承自父类。注意,您为table()函数提供了一个对象存储名称。它使用该名称返回特定的对象存储,例如一个gameComments对象存储。

  • 返回的对象存储被设置为类变量comments。在WebArcadeDb实例上访问这个变量指的是对象存储库gameComments。例如,webArcadeDbObject.comments指的是gameComments对象存储。

1: export interface CommentsEntity {
2:     IdxCommentId?: number;
3:     title: string;
4:     comments: string;
5:     timeCommented: string;
6:     gameId: number;
7:     userName:string;
8: }

Listing 10-2Comments Entity

索引数据库版本

随着应用的发展,预测数据库的变化。IndexedDB 支持版本在升级之间转换。Dexie 使用底层 API,并提供一种干净的方式来版本化 IndexedDB。

清单 10-3 用一个对象存储和三个字段(一个主键和两个索引)创建了web-arcade数据库。见第 12 行。假设您需要在索引中添加一个额外的字段gameId,并为棋盘游戏评论创建一个新的对象存储。

在对数据库进行更改之前,请增加版本号。考虑将其更新到 1.1。

Note

在版本号 1.0 中,小数点前的数字称为主版本。小数点后的数字是的小版本。顾名思义,如果数据库结构有重大变化,请考虑更新主版本号。对于单个字段、索引或对象存储的次要添加,请更新次要版本。

接下来,为gameId添加一个新的索引。包括一个名为boardGameComments的新对象存储,带有一个主键commentId。考虑将 10-3 上市。结果见图 10-1 。这是一个使用 Google Chrome 开发者工具的 IndexedDB 视图。

img/515699_1_En_10_Fig1_HTML.png

图 10-1

新的对象存储,版本 11 (1.1)中的索引

1: this.version(1.1).stores({
2:     gameComments: '++IdxCommentId,timeCommented, userName, gameId',
3:     boardGameComments: '++commentId'
4:   });

Listing 10-3Upgrade Web Arcade to a New Version

接下来,考虑一个场景,您需要删除一个对象存储和一个索引。考虑移除用户名上的索引并删除boardGameComments对象存储。请遵循以下说明:

  1. 更新版本号。考虑用 1.2。这将在 IndexedDB 上转换为 12。

  2. 将要删除的对象存储设置为空。在当前示例中,将boardGameComments设置为空。参见清单 10-4 中的第 3 行。

  3. 要对对象存储进行更改,请在数据库对象上使用upgrade() API。在当前的例子中,我们在对象存储库gameComments上删除了一个名为userName的索引,并提供了一个回调函数。函数参数是数据库的引用变量。考虑将 10-4 上市。

  • 第 8 行删除了comments对象上的用户。修改对象库gameComments?? 时获得comments引用。记住,Dexie 的表类(和实例)指的是一个对象存储。
1: this.version(1.2).stores({
2:     gameComments: '++IdxCommentId,timeCommented, userName, gameId',
3:     boardGameComments: null
4:   }).upgrade( idb =>
5:     idb.table(OBJECT_STORE_GAME_COMMENTS)
6:       .toCollection()
7:       .modify( comments => {
8:         delete comments.userName;
9:       }) );

Listing 10-4Remove Object Store and Index

与 Web-Arcade IndexedDB 连接

记住创造IdbStorageAccessService的思维过程。它从应用的其余部分抽象出 IndexedDB API。如果您选择使用 Dexie 而不是本机浏览器 API,请遵循类似的方法并创建一个服务。运行以下命令创建服务。为服务提供任意名称dexie-storage-access

ng g s common/dexie-storage-access

Note

命令ng g s common/dexie-storage-accessng generate service common/dexie-storage-access 的简称。

g-生成

s- service

IdbStorageAccessService类似,在应用启动时初始化DexieStorageAccessService。在初始化代码中包含一个init()函数。使用 Angular 的APP_INITIALIZER并将其包含在AppModule中。考虑清单 10-5 。参见第 11 行到第 16 行。注意,应用初始化器调用了init()函数(第 13 行)。

01: @NgModule({
02:     declarations: [
03:       AppComponent,
04:       /* More declarations go here */
05:     ],
06:     imports: [
07:       BrowserModule,
08:       /* additional imports go here */
09:     ],
10:     providers: [
11:       {
12:         provide: APP_INITIALIZER,
13:         useFactory: (svc: DexieStorageAccessService) => () => svc.init(),
14:         deps: [DexieStorageAccessService],
15:         multi: true
16:       }
17:       /* More providers go here */
18:     ],
19:     bootstrap: [AppComponent]
20:   })
21:   export class AppModule { }

Listing 10-5Initialize DexieStorageAccessService at Application Startup

正在初始化 IndexedDB

DexieStorageAccessService使用WebArcadeDB类的实例初始化 IndexedDB(在清单 10-3 中创建)。使用open()函数,如果数据库已经存在,它会打开一个到数据库的连接。如果没有,它将创建一个新的数据库并打开连接。考虑清单 10-6 。

01: import { Injectable } from '@angular/core';
02: import { WebArcadeDb } from 'src/app/common/web-arcade-db';
03: import { CommentsEntity } from 'src/app/common/comments-entity';
04:
05: @Injectable({
06:   providedIn: 'root'
07: })
08: export class DexieStorageAccessService {
09:   webArcadeDb = new WebArcadeDb();
10:   constructor() {}
11:   init(){
12:     this.webArcadeDb
13:     .open()
14:     .catch(err => console.log("Dexie, error opening DB"));
15:   }
16: }

Listing 10-6Dexie Storage Access Service

考虑以下解释:

  • 创建一个新的类级实例WebArcadeDb并实例化。它封装了 Web Arcade IndexedDB。参见清单 10-9 中的第 9 行。

  • 请记住,您在APP_INITIALIZER的帮助下从 app 模块调用了init()函数。注意第 11 到 15 行的定义。这通过调用 IndexedDB 上的open()来初始化。如前所述,它为 Web Arcade 创建一个数据库,如果它不存在的话。它将打开到 IndexedDB 的连接。

  • 初始化后,IndexedDB 对包括 CRUD 在内的数据库操作开放。

  • open 函数返回一个承诺。如果失败了,这个承诺就被拒绝了。注意第 14 行。这是承诺被拒绝时的错误处理语句。在当前示例中,您将消息和错误记录到浏览器控制台。

处理

在事务中包含数据库操作是很重要的。事务确保所有封闭的操作都是原子的,即作为单个单元执行。要么执行所有操作,要么不执行任何操作。这有助于确保数据的一致性。

在一个示例中,假设您正在将数据从对象存储 1 传输到对象存储 2。您从对象存储 1 中读取并删除了数据。假设用户在对对象存储 2 的更新完成之前关闭了浏览器。如果没有事务,数据就会丢失。如果在将数据添加到对象存储 2 之前出现故障,事务确保从对象存储 1 的删除被恢复。这确保了数据不会丢失。

在一个WebArcadeDb对象上创建一个事务,如清单 10-7 所示。

1: this.webArcadeDb.transaction("rw",
2:     this.webArcadeDb.comments,
3:     () => {
4:
5:     })

Listing 10-7Create a Transaction

考虑以下解释:

  • 见第 1 行。在WebArcadeDb对象的实例上创建一个事务。它是DexieStorageAccessService上的类级变量。

  • 交易函数的第一个参数是交易模式。两个值是可能的。

    • Read:第一个参数的值为r。该事务只能执行读取操作。

    • Read-Write:第一个参数的值为rw。参见清单 10-10 中的第 1 行。该事务可以执行读写操作。

  • 第二个参数是对象存储引用。见第 2 行。comments字段指向对象存储器gameComments。参见清单 10-3 中的第 14 行。

  • 您可以在一个事务中包含多个对象存储。

  • 最后一个参数是函数回调。它包括对数据库执行创建、检索、更新或删除操作的代码。

增加

记住,到目前为止,您创建了一个名为gameComments的对象存储。清单 10-8 向对象存储中添加一条记录。

01: addComment(title: string, userName: string, comments: string, gameId: number, timeCommented = new Date()){
02:     this.webArcadeDb
03:       .comments
04:       .add({
05:         title,
06:         userName,
07:         timeCommented: `${timeCommented.getMonth()}/${timeCommented.getDate()}/${timeCommented.getFullYear()}`,
08:         comments,
09:         gameId,
10:       })
11:       .then( id => console.log(`Comment added successfully. Comment Id is ${id}`))
12:       .catch( error => console.log(error))
13:       ;
14: }

Listing 10-8Add a Comment Record to the Object Store

考虑以下解释:

  • 见第 2 行。您使用了一个WebArcadeDb对象的实例。它是DexieStorageAccessService上的类级变量。

  • add()函数将一条记录插入到对象存储中(第 4 行)。该记录包括各种注释字段,包括标题、用户名、注释日期和时间、注释描述以及添加了注释的游戏的 ID。

  • add()函数返回一个承诺。如果添加操作成功,则承诺得到解决。参见第 11 行,它将注释 ID(主键)记录到浏览器控制台。如果添加操作失败,承诺将被拒绝。第 12 行的catch()函数运行,它在浏览器控制台上打印错误信息。

删除

使用数据库对象webArcadeDb执行删除操作。调用数据库上的delete() API。它需要一个注释 ID,主键作为输入参数。考虑上市 10-9 。

1: deleteComment(id: number){
2:     return this.webArcadeDb
3:       .comments
4:       .delete(id)
5:       .then( id => console.log(`Comment deleted successfully.`))
6:       .catch(err => console.error("Error deleting", err));
7: }
8:

Listing 10-9Delete a Comment Record in the Object Store

考虑以下解释:

  • 见第 2 行。您使用了一个WebArcadeDb对象的实例。它是DexieStorageAccessService上的类级变量。

  • delete()函数从对象存储中删除一条记录(第 4 行)。要删除的记录由注释 ID(一个主键)标识。

  • delete()函数返回一个承诺。如果删除操作成功,则承诺得到解决。请参见第 5 行,该行向浏览器控制台记录了一条成功消息。如果删除操作失败,承诺将被拒绝。第 6 行的catch()函数运行,它在浏览器控制台上打印错误信息。

更新

使用数据库对象webArcadeDb执行更新操作。调用数据库上的update() API。它需要一个注释 ID,这是主键,作为第一个输入参数。它使用注释 ID 选择要更新的记录。它使用一个带有键路径和要更新的新值的对象。考虑上市 10-10 。

1: updateComment(commentId: number, newTitle: string, newComments: string){
2:     this.webArcadeDb
3:       .comments
4:       .update(commentId, {title: newTitle, comments: newComments})
5:       .then( result => console.log(`Comment updated successfully. Updated record ID is ${result}`))
6:       .catch(error => console.error("Error updating", error));
7:   }

Listing 10-10Update a Comment Record

考虑以下解释:

  • 见第 2 行。您使用了一个WebArcadeDb对象的实例。它是DexieStorageAccessService上的类级变量。

  • update()函数更新对象存储上的记录(第 4 行)。要更新的记录由注释 ID(一个主键)标识。第二个参数是一个对象,包含要更新的值的键值对。请注意,在对象存储中,一个键标识记录中要更新的字段。

  • 例如,下面的代码片段用注释 ID 1(主键)更新一条记录。接下来的两个参数分别是新的标题和描述。

    this.updateComment(1, "New title", "new comment description");

图 10-2 显示了结果。

img/515699_1_En_10_Fig2_HTML.png

图 10-2

更新结果

  • update()函数返回一个承诺。如果更新操作成功,则承诺得到解决。请参见第 5 行,该行向浏览器控制台记录了一条成功消息。如果更新操作失败,承诺将被拒绝。第 6 行的catch()函数运行,它在浏览器控制台上打印错误信息。

Note

update()函数更新对象存储中记录的特定字段。要替换整个对象,请使用put()

恢复

Dexie 提供了一个全面的函数列表,用于从 IndexedDB 中查询和检索数据。请考虑以下几点:

  • get(id):使用 ID/主键选择对象存储中的记录。ID 作为参数传入。get()函数返回一个承诺。成功获取后,then()回调函数返回结果。

  • bulkGet([id1, id2, id3]):选择对象库中的多条记录。id 作为参数传入。bulkGet()函数返回一个承诺。在一次成功的 get 中,then()回调函数返回结果。

  • where({keyPath1:value, keyPath2: value…, keyPath: value}):根据keyPath指定的字段和给定值过滤记录。

  • each(functionCallback):遍历对象库中的对象。API 异步调用提供的函数回调。考虑将 10-11 上市。

1: getAllCachedComments(){
2:     this.webArcadeDb
3:       .comments
4:       .each( (entity: CommentsEntity) => {
5:         console.log(entity);
6:       })
7:       .catch(error => console.error("Error updating", error));
8: }
9:

Listing 10-11Get All Cached Comments from the gameComments Object Store

考虑以下解释:

  • 第 2 行使用了一个WebArcadeDb对象的实例。它是DexieStorageAccessService上的类级变量。

  • 第 4 行遍历gameComments对象存储中的每条记录。

  • 回调函数使用类型为CommentsEntity的参数。当回调被异步调用时,局限于CommentsEntity接口的数据将被返回。

  • 第 5 行将实体打印到浏览器控制台。

更多选项

在本书中,您已经看到了浏览器原生支持的 IndexedDB API。本章介绍了 Dexie.js,这是一个旨在简化数据库访问的包装器。

以下是几个额外的选项供您考虑。虽然实现细节超出了本书的范围,但是可以考虑阅读并进一步了解这些库。所有这些库都在底层使用 IndexedDB。

  • 本地饲料:提供简单的 API 和函数。API 类似于本地存储。在不支持 IndexedDB 的传统浏览器上,Local feed 提供了一个 polyfill。它能够回退到 WebSQL 或本地存储。它是一个开源库,拥有 Apache 2.0 许可。

  • ZangoDB :这提供了一个简单易用的 API,模仿 MongoDB。该库使用 IndexedDB。包装器描述了一个用于过滤、排序、聚合等的简单 API。这是一个拥有 MIT 许可的开源库。

  • JS Store :为 IndexedDB 提供类似 API 的结构化查询语言(SQL)。它在一个类似于传统 SQL 的易于理解的 API 中提供了 IndexedDB 提供的所有功能。这是一个拥有 MIT 许可的开源库。

  • PouchDB :这提供了一个 API 来同步客户端的离线数据和 CouchDB。对于使用 CouchDB 服务器端后端的应用来说,这是一个非常有用的库。它是一个开源库,拥有 Apache 2.0 许可。

摘要

本章介绍了 Dexie.js。它在 Web Arcade 使用案例的参数内提供了对库的基本理解。它列出了将 Dexie.js NPM 软件包安装到 web arcade 应用的说明。

此外,本章在 IndexedDB 的基础上列出了一些额外的库和包装器。虽然实现细节超出了本书的范围,但它列出了名称和一行代码介绍以供进一步学习。

Exercise

  • 更新游戏细节组件,以便在应用离线时使用 Dexie 存储访问服务来缓存评论。

  • 更新 online 事件,以便在应用重新联机时使用 Dexie storage access 服务来检索记录。与服务器端服务集成,使用 Dexie.js API 同步数据和删除本地记录。

  • 提供使用 Dexie 存储访问服务更新注释的能力。

标签:IndexedDB,服务,创建,离线,构建,应用,组件,Angular
From: https://www.cnblogs.com/apachecn/p/18448070

相关文章

  • [论文阅读报告] All pairs shortest paths using bridging sets and rectangular matr
    本篇文章介绍整数边权下\((\min,+)\)矩阵乘、APSP等问题的一些做法。若每个元素的权值在\([-M,M]\cap\mathbbZ\)中,\(n\timesn^r\)和\(n^r\timesn\)的\((\min,+)\)矩阵乘可做到\(\tildeO(Mn^{\omega(r)})\);有向图APSP可做到\(\tildeO(n^{2+\mu(t)})\),......
  • VisionTS:基于时间序列的图形构建高性能时间序列预测模型,利用图像信息进行时间序列预测
    构建预训练时间序列模型时面临的主要挑战是什么?获取高质量、多样化的时间序列数据。目前构建基础预测模型主要有两种方法:迁移学习LLM:通过针对时间序列任务定制的微调或分词策略,重新利用预训练的大型语言模型(LLM),如GPT-4或Llama。从零训练:构建大规模时间序列数据集,并从头开始预训......
  • 构建前缀信息解决子数组问题
    构建前缀信息解决子数组问题303.区域和检索-数组不可变#include<vector>usingnamespacestd;classNumArray{public://前缀和数组vector<int>prefixSum;NumArray(vector<int>&nums){prefixSum.resize(nums.size()+1);prefix......
  • 网站二级域名+cploar+shinyproxy构建web APP私有服务器
    网站二级域名+cploar+shinyproxy构建webAPP私有服务器WebAPP是一种功能相对单一的,可以在浏览器中运行的应用程序,构建相对容易且易于传播,被认为是临床预测模型最佳的载体。一种理想的情况,就是我们将构建的各种临床预测模型放置到网上,以“微服务”的形式存在,需要的时候登......
  • 用C/C++构建自己的Redis——第五章、Redis中的AVL树实现
    用C/C++构建自己的Redis——第五章、Redis中的AVL树实现文章目录用C/C++构建自己的Redis——第五章、Redis中的AVL树实现前言一、键值对集查询概念1.1键值对集合查询1.2数据结构排序的复习排序数组(SortedArrays)树形数据结构(TreeDataStructures)通过随机性平衡(Balan......
  • 构建未来城市天际线:低空经济基础设施的发展趋势
    随着科技的不断进步和城市化进程的加快,低空经济正在成为推动城市发展的重要力量。低空经济不仅包括传统的航空业务,还涵盖了无人机物流、空中游览、农业植保、应急救援、空中交通等多个领域。在这一背景下,低空经济基础设施的建设显得尤为重要。 1.低空经济基......
  • 【大模型指令微调: 从零学会炼丹】第一章: 微调数据集构建
    大模型指令微调:从零学会炼丹系列目录第一章:微调数据集构建文章目录大模型指令微调:从零学会炼丹系列目录第一章:微调数据集构建Alpaca格式编写Instructioninstruction-key读取本地数据定义format函数第一章:微调数据集构建Alpaca格式Alpaca格式是一......
  • 图解Docker Compose 架构设计分析与全攻略:构建、扩展和管理你的容器(第一部分)
    DockerCompose是Docker官方编排工具,它允许用户通过简洁的YAML文件定义多容器的Docker应用程序。无论是开发者、系统管理员还是DevOps工程师,DockerCompose都能帮助轻松地管理复杂的服务堆栈。通过本文,将深入了解DockerCompose的强大功能和使用场景,探索如何利......
  • 构建墙绘产品交易平台:SpringBoot实践
    1绪论1.1研究背景当前社会各行业领域竞争压力非常大,随着当前时代的信息化,科学化发展,让社会各行业领域都争相使用新的信息技术,对行业内的各种相关数据进行科学化,规范化管理。这样的大环境让那些止步不前,不接受信息改革带来的信息技术的企业随时面临被淘汰,被取代的风险。......
  • Java Deeplearning4j:构建和训练卷积神经网络(CNN)模型
    ......