Angular-渐进式-Web-应用教程-全-

Angular 渐进式 Web 应用教程(全)

原文:Progressive Web Apps with Angular

协议:CC BY-NC-SA 4.0

一、设置要求

在这本书里,我努力带你踏上一段旅程,你可以用 Angular 创建最全面的渐进式 Web 应用(pwa)。但在我开始之前,我们将回顾一些 PWA 基础知识,并设置将在整本书中使用的环境。

渐进式 Web 应用基础

PWAs 适用于那些快速、引人入胜、可靠、的 web 应用,并且将尝试逐步增强用户体验,而不管它们的浏览器、平台或设备如何。换句话说,PWA 不仅仅是一个框架、工具或时髦的术语,而是一种通过利用浏览器的现代 API 来不断增强的思维方式,这使得每个用户都感到满意。

无论您选择使用哪种框架,无论您选择用哪种语言编写代码,PWAs 都必须具有特殊的特征:

  1. 即时加载:应用应该快速加载,并且必须能够快速交互。

  2. 连接独立:没有网络或者连接缓慢且不稳定,应用必须继续工作。

  3. 响应式、移动优先、离线优先的设计:先针对移动进行重点和优化,移动的硬件容量较低,应用在移动上应该完全可用。

  4. 重新参与:推送通知是向用户发送提醒的一种方式。

  5. 类原生特性:拥有 App Shell 这样的 UI 架构,使用 Web 蓝牙这样的硬件 API,可以让我们的 web app 更像一个原生 App。

  6. 安全:安全是最高优先级,每个 PWA 必须通过 HTTPs 服务。

  7. 可安装:可安装意味着它将被添加到设备的主屏幕上,并像本地应用一样启动。

  8. 渐进式:无论使用何种浏览器或设备,我们的应用都应该不断发展,拥抱新功能,为每一个应用提供最佳的用户体验。

为什么有 Angular?

几年前,甚至在 React 上市之前,前端世界就被 Angular 1.x 所主宰。通过建立和最终确定 ES6 和 TypeScript 外观,以及广泛适应的新浏览器功能和标准,得到谷歌支持的 Angular 团队决定重写 AngularJS,以前称为 Angular 1.x,导向 Angular 2,现在称为 Angular。Angular 由具有 Rxjs 和 TypeScript 的可观察 API 支持,并具有独特的功能,如健壮的更改检测和路由、动画、延迟加载、令人头痛的捆绑过程、CLI 和大量其他 API。这些使得它成为一个出色的、有能力的、成熟的前端框架,被世界上许多公司信任来构建和分发复杂的 web 应用。

此外,Angular Service Worker 模块已在版本 5 中引入,在版本 6 中进行了改进, 1 现在正在定期更新,以便添加更多功能并变得稳定。尽管 Angular Service Worker 和 Angular CLI 并不是创建 PWA 的唯一选择,但它得到了很好的维护,使我们能够毫不费力地创建 Angular 应用或将它转换为 PWA。

总而言之,说你有一个一体化的框架来创建一个 web 和移动应用并不遥远,这使得 Angular 独一无二。

安装节点和 NPM

您需要确保您的计算机上安装了节点和 NPM。只需运行以下命令来检查您的节点和 NPM 版本,或者查看您是否已经安装了它们:

$ node -v
$ npm -v

需要节点 8 或更高版本以及 NPM 5 或更高版本。您可以在 https://nodejs.org 访问节点网站,根据您的操作系统下载最新版本(图 1-1 )。

img/470914_1_En_1_Fig1_HTML.jpg

图 1-1。

Node 官方网站,在那里可以下载 NodeJS 的最新版本

是 NPM 的替代品,已经存在一段时间了。如果您更喜欢使用它,您应该访问 https://yarnpkg.com/en/docs/install ,然后根据您的操作系统安装最新版本。要检查是否安装了 YARN,只需运行以下命令:

$ yarn -v

安装 Chrome

尽管我们创建了一个可以在任何浏览器下工作的 PWA,但我将坚持使用 Chrome 及其开发工具来开发和调试 Service Worker 以及其他 PWA 特性。在写这本书的时候,Chrome 有一个名为 Lighthouse 的 PWA 审计工具,内置在 Audit 标签下。如果你想下载 Chrome,可以访问 https://www.google.com/chrome/

在本书的后面,我会用 Lighthouse 评估我们的申请,并提高我们的 PWA 分数。我们持续使用应用选项卡来调试我们的服务工作器、索引数据库、Web 应用清单等。

搭建我们的项目

是时候使用 Angular CLI 搭建我们的项目了。因此,在我们继续之前,首先通过运行以下命令来全局安装 Angular CLI:

$ npm install -g @angular/cli

$ yarn global add @angular/cli

现在 CLI 已在全球范围内安装,我们可以生成一个新的 Angular 应用。

使用 CLI 生成新的 Angular App

一旦安装了 Angular CLI 版本 6(当您阅读本书时,您可能会有更高的版本),您的终端中就有了全局可用的 ng 命令。让我们通过运行以下命令来搭建我们的项目:

$ ng new lovely-offline –-routing –-style=scss

可爱-离线是我们的应用名称,路由将生成路由模块, style=scss 表示我们的样式文件的 scss 前缀。

添加有 Angular 的材料设计

Angular Material 模块可能是 web 应用最好的 UI 库之一。它将让我们快速而完美地开发我们的应用。你不仅仅局限于这个库,但是我为这个项目推荐它。要安装:

$ npm install --save @angular/material @angular/cdk @angular/animations

现在在你的编辑器或 Idea 中打开项目,然后在/src/app,下找到app.module.ts,并将BrowserAnimationsModule导入到你的应用中以启用动画支持。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    AppRoutingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

要使用每个组件,我们应该将它们的相关模块导入到ngModule中,例如:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { MatToolbarModule } from '@angular/material/toolbar';

import { MatIconModule } from '@angular/material/icon';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    MatToolbarModule,
    MatIconModule,
    AppRoutingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

需要一个主题;因此,我将在我们的项目中向style.scs添加一个可用的主题:

@import "~@angular/material/prebuilt-themes/indigo-pink.css";

建议您安装并包含hammer.js,因为该库中依赖于材料设计中的手势。

$ npm install hammerjs

安装后,在src/main.ts中导入

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

import 'hammerjs';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.log(err));

Icons 需要 Google Material Icons 字体;因此,我们将把字体 CDN 链接添加到我们的index.html文件中:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>LovelyOffline</title>
  <base href="/">

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>

现在我们的项目已经可以使用了。只需运行 ng 发球npm 启动。您可以通过输入localhost:4200在浏览器中访问该项目。

设置移动设备

没有什么比在真实设备中测试我们的应用更好的了。Android 和 Chrome 一起支持大多数 PWA 功能,包括服务工作器、推送通知和后台同步,以及更现代的浏览器 API。

如果你有一个真实的设备,并希望方便地将其连接到 Chrome dev tools,请阅读谷歌开发者网站上的文章 https://developers.google.com/web/tools/chrome-devtools/remote-debugging 。请记住,真正的设备是不必要的;你可以随时通过 Android 和 iOS 模拟器测试你的应用。

设置移动模拟器

要运行 Android 模拟器,我建议您安装 Android Studio ,并按照 Android 开发者网站上的说明进行操作: https://developer.android.com/studio/run/emulator

Mac 用户也可以在 Mac 上安装 xCode 并运行 iPhone 模拟器。从 https://developer.apple.com/xcode/ 安装 xCode 后,你应该可以在 xCode 菜单下找到打开开发者工具,然后你就可以打开模拟器打开你选中的 iPhone / iPad。

将 Android 模拟器连接到 Chrome 开发工具

你现在应该可以将你的 Android 模拟器连接到 Chrome 开发工具了。请参考“设置移动设备”一节。

摘要

在本章中,我们已经了解了 PWA 的基础知识,然后我们使用 CLI 搭建了我们的项目。Angular 的材料已经添加到我们的项目,以风格我们的应用。

此外,我们还回顾了本课程中需要用到的其他工具,如 Node、NPM、YARN 和 Chrome 我们还学习了如何设置我们的真实设备和模拟器,以便正确测试我们的应用。

二、部署到 Firebase 作为后端

Firebase 被认为是后端即服务,它现在是谷歌云平台的一部分,但仍然是一个独立的实体。它提供不同的服务,如托管、实时数据库和云功能。

在这一章中,我将向你展示如何将我们的应用部署到 Firebase。值得一提的是,Firebase 并不是唯一的选择。然而,由于它易于设置和部署,我鼓励您使用 Firebase 作为我们的主机服务器。

此外,我们可能需要为我们的应用编写一些后端逻辑;因此,为了利用无服务器架构并减少我们对后端系统的担忧,Firebase Function 是最佳选择之一,而前端仍将是我们的主要关注点。

最后但同样重要的是,为了持久化我们的数据,我们将使用 Firebase Firestore,它为我们提供了尽可能快速地存储和检索数据的最佳被动能力,并在需要时内置了对每个集合和文档的 JSON 访问。

设置您的帐户

让我们从打开开始吧。使用您的 Gmail 凭据登录,但如果您没有任何凭据,请首先注册一个 Google 帐户,然后继续操作。

*登录后,继续并点击“转到控制台”您将被重定向到控制台,在那里您可以看到您的项目。

创建项目

现在是时候添加您的项目了;只需点击添加项目,如图 2-1 所示。

img/470914_1_En_2_Fig1_HTML.jpg

图 2-1

Firebase 控制台,您应该点击添加项目来创建一个新项目

您应该会看到一个新的视图,它会询问您有关项目的详细信息,例如项目名称。我选择用 Awesome-Apress-PWA 来命名我的项目。

您可能需要更改您的组织或云 Firestore 位置;但是,默认设置应该足以开始使用。请记住,如果您更改了云 Firestore 的位置,在创建项目之前,您将无法更改它。

我将让“使用默认设置共享 Firebase 数据的 Google Analytics”“条款和条件”处于选中状态现在,点击创建项目按钮,如图 2-2 所示。

img/470914_1_En_2_Fig2_HTML.jpg

图 2-2

Firebase 项目详细模型

您的项目可能需要几秒钟才能准备就绪。一旦项目准备就绪,您就可以继续项目的仪表板(参见图 2-3 )。

img/470914_1_En_2_Fig3_HTML.jpg

图 2-3

几秒钟后,项目就准备好了,所以只需点击“继续”按钮就可以重定向到仪表板

部署到火力基地

我们选择 Firebase 是因为它易于在我们的项目中使用,您很快就会看到使用 Firebase CLI(命令行界面)进行部署是多么容易。

生成新的 Angular 应用

在开始之前,我们需要使用 Angular CLI(命令行界面)生成一个新的 Angular app。如果您的计算机上没有全局安装@angular/cli,您应该首先运行以下命令:

$ npm install -g @angular/cli

要生成一个新的 Angular 应用,并设置好路由scss ,我们可以运行:

$ ng new lovely-offline     --routing           --style=scss
   Name of project      enable routing      styling with scss

安装完所有 NPM 依赖项后,您就可以准备好构建和部署您的应用了。

├── README.md
├── angular.json
├── e2e
├── node_modules
├── package-lock.json
├── package.json
├── src
│   ├── app
│   ├── assets
│   ├── browserslist
│   ├── environments
│   ├── favicon.ico
│   ├── index.html
│   ├── karma.conf.js
│   ├── main.ts
│   ├── polyfills.ts
│   ├── styles.scss
│   ├── test.ts
│   ├── tsconfig.app.json
│   ├── tsconfig.spec.json
│   └── tslint.json
├── tsconfig.json
└── tslint.json

现在让我们为生产构建我们的应用。

$ ng build --prod
> ng build
Date: 2018-08-26T17:20:35.649Z
Hash: e6da8aa80ad79bc41363
Time: 6332ms

chunk {main} main.js, main.js.map (main) 11.6 kB [initial] [rendered]
chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 227 kB [initial] [rendered]
chunk {runtime} runtime.js, runtime.js.map (runtime) 5.22 kB [entry] [rendered]
chunk {styles} styles.js, styles.js.map (styles) 16 kB [initial] [rendered]
chunk {vendor} vendor.js, vendor.js.map (vendor) 3.18 MB [initial] [rendered]

构建是成功的,现在是时候将我们的应用部署到 Firebase 了。让我们安装 Firebase CLI。

$ npm install -g firebase-tools

现在 firebase 命令在我们的命令行中是全局可用的。在部署之前,我们需要确保我们有足够的权限;因此,我们现在应该登录 Firebase 来设置我们的凭证,所以只需运行:

$ firebase login

问卷如下所示:

Allow Firebase to collect anonymous CLI usage and error reporting  information? (Y/n) Y

Visit this URL on any device to log in:
https://accounts.google.com/o/oauth2/........

Waiting for authentication...

一旦您看到验证 URL,您将被重定向到浏览器,以便登录您的 Google 帐户。然后,你要通过点击允许访问来授予 Firebase CLI 足够的权限,如图 2-4 所示。

img/470914_1_En_2_Fig4_HTML.jpg

图 2-4

点击允许授予 Firebase CLI 访问您的帐户的权限

一旦获得许可,你应该会在浏览器中看到一条成功的消息,如图 2-5 所示。

img/470914_1_En_2_Fig5_HTML.jpg

图 2-5

授予 Firebase CLI 权限后,浏览器中出现成功消息

您还会在终端中看到如下所示的成功消息,这意味着 Firebase CLI 现在有足够的权限访问您的 Firebase 项目。

Success! Logged in as mhadaily@gmail.com

正在初始化应用

下一步是初始化 Firebase 项目。这将把您的本地 Angular 应用链接到我们刚刚创建的 Firebase 应用。为此,请确保您位于项目的根目录下,并运行:

$ firebase init

点击上面的命令后,Firebase CLI 会在您的终端中询问您几个问题,以便构建您的 Firebase 项目,并创建将我们的应用部署到 Firebase 的必要需求。让我们一步步复习每个问题。

特征选择

如下所示,第一个问题是关于我们希望使用哪些 Firebase 特性:

Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices.
  ◯ Database: Deploy Firebase Realtime Database Rules
> ◉ Firestore: Deploy rules and create indexes for FirestoreFunctions: Configure and deploy Cloud FunctionsHosting: Configure and deploy Firebase Hosting sites
  ◯ Storage: Deploy Cloud Storage security rules

Firebase 实时数据库 1 和 Firestore 2 是两个 NoSQL 数据库服务,用于存储和同步客户端和服务器端开发的数据。Firebase 的云函数允许您自动运行后端代码,以响应由 Firebase 特性和 HTTPS 请求触发的事件。你的代码存储在谷歌的云中,在一个托管的环境中运行。Firebase 托管为您的 web 应用、静态和动态内容以及微服务提供快速、安全的托管。云存储是为需要存储和提供用户生成的内容(如照片或视频)的应用开发人员而构建的。

我将为这个项目选择 Firestore功能主机功能,因为我将在本书中通篇使用它们。一旦你选择了你需要的,按下进入进入下一步。

项目选择

如下所示,第二个问题显示了您在 Firebase 中的项目,由于我们已经创建了一个项目,我将选择该项目并按下 enter 键继续。请注意,您也可以在这一步中创建一个项目。

Select a default Firebase project for this directory: (Use arrow keys)
[don't set up a default project]
> awesome-apress-pwa (awesome-apress-pwa)
  [create a new project]

数据库设置

Firebase Firestore 是一个可扩展和灵活的 NoSQL 3 实时数据库,用于存储和同步客户端或服务器端应用开发的数据。该数据库使我们的数据在多个客户端应用之间保持同步,并提供离线功能。Firestore 中的数据保存包含映射到值的字段的文档。集合是文档的容器,它不仅允许我们组织数据,还允许我们构建查询。

因为我们已经在步骤特征选择步骤中选择了 Firestore 服务,所以如下所示,第三个问题是关于数据库规则文件,以编写关于我们的项目数据库的所有规则。我继续使用默认名称,即 database.rules.json:

 What file should be used for Database Rules? (database.rules.json)

功能设置

Firebase 中的云功能让我们可以在 HTTPS 请求上运行后端代码,而不需要一个实际的服务器来维护、管理和存储我们在谷歌的云管理环境中的代码。为了在我们的 app 中实现无服务器 4 架构,我们要使用函数来编写和运行我们必不可少的后端代码。

由于我们已经在特性选择步骤中选择了使用 Firebase 函数特性,如下所示,第四个问题要求选择我们想要的语言来编写函数

What language would you like to use to write Cloud Functions? (Use arrow keys)
> JavaScript
  TypeScript

JavaScript 是我现在的选择,因为我们在这本书里不会有很多函数;因此,我保持简单。如果您喜欢,可以继续使用 TypeScript。

在选择语言之后,Firebase CLI 提供了一个林挺工具来帮助我们在下一个问题中找到可能的错误和样式问题,如下所示。如果您喜欢强制样式化并捕捉云函数中可能的 bug,请继续使用 y。

Do you want to use ESLint to catch probable bugs and enforce style? (Y/N) y

最终设置

我将继续回答最后三个问题,以完成我的项目初始化。

如果您想现在安装依赖项,请在下一个问题中输入 Y。

Do you want to install dependencies with npm now? (Y/n)

下一步,我们需要定义我们的随时部署应用的位置。默认情况下,在 Angular 里面是dist目录;因此,我也输入dist来设置我的公共目录。所以,我会如下图继续回答问题:

What do you want to use as your public directory? (public) dist

最后,我们的应用将在前端有一个路由系统,这意味着我们将创建一个单页面应用。因此,当 Firebase CLI 被询问是否重写所有到 index.html 的 URL 时,我们应该回答 Y,以确保我们的前端正在单独处理路由,而不考虑我们的服务器路由。

尽管我们正在开发单页面应用,但这绝对不是创建 PWA 所必需的。注意,在本书中,我们将通过 Angular 制作单页 PWA。让我们继续最后一个问题,Y 如下图所示:

Configure as a single-page app (rewrite all urls to /index.html)? (y/N) y

使用 Firebase CLI 初始化我们的应用已经完成!初始化后,我们的应用结构看起来像下面的树。

.
├── README.md
├── angular.json
├── database.rules.json   -> firebase databse rules
├── dist
├── e2e
├── firebase.json -> firebase configs
 ├── functions-> firebase cloud funtions directory
 │   ├── index.js
 │   ├── node_modules
 │   ├── package-lock.json
 │   └── package.json
├── node_modules
├── package-lock.json
├── package.json
├── src
│   ├── app
│   ├── assets
│   ├── browserslist
│   ├── environments
│   ├── favicon.ico
│   ├── index.html
│   ├── karma.conf.js
│   ├── main.ts
│   ├── polyfills.ts
│   ├── styles.scss
│   ├── test.ts
│   ├── tsconfig.app.json
│   ├── tsconfig.spec.json
│   └── tslint.json
├── tsconfig.json
└── tslint.json

Angular 项目设置中的调整

在我们可以部署我们的应用之前,我们需要对位于 Angular.json 中的 Angular 设置进行微小的更改。 Angular CLI 能够构建多个应用,每个应用都可以简单地放在 dist 文件夹中。然而,我们现在只想处理一个应用,我们需要将它构建在 dist 文件夹中,Firebase 将在那里找到并部署它。因此,我们应该从

   "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/lovely-offline",  // outputPath showes where to build

   "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist ",  // build app just in dist

通过从输出路径中移除我们的应用名称,我们强制 Angular CLI 构建所有文件并将其放入 dist 文件夹中。现在是时候最终将我们的应用部署到 Firebase 上了。

部署我们的应用

当我们在项目目录的根目录中时,我们可以简单地运行以下命令:

$ firebase deploy

部署开始…

> firebase deploy

=== Deploying to 'awesome-apress-pwa'...

i  deploying database, functions, hosting
Running command: npm --prefix "$RESOURCE_DIR" run lint

> functions@ lint ~/awesome-apress-pwa/functions
> eslint .

✓  functions: Finished running predeploy script.
i  database: checking rules syntax...
✓  database: rules syntax for database awesome-apress-pwa is valid
i  functions: ensuring necessary APIs are enabled...
✓  functions: all necessary APIs are enabled
i  functions: preparing functions directory for uploading...
i  hosting[awesome-apress-pwa]: beginning deploy...
i  hosting[awesome-apress-pwa]: found 14 files in dist
✓  hosting[awesome-apress-pwa]: file upload complete
i  database: releasing rules...
✓  database: rules for database awesome-apress-pwa released successfully
i  hosting[awesome-apress-pwa]: finalizing version... 

✓  hosting[awesome-apress-pwa]: version finalized
i  hosting[awesome-apress-pwa]: releasing new version...
✓  hosting[awesome-apress-pwa]: release complete

✓  Deploy complete!

Project Console: https://console.firebase.google.com/project/awesome-apress-pwa/overview
Hosting URL: https://awesome-apress-pwa.firebaseapp.com

祝贺您-部署成功完成,现在网站可在 https://awesome-apress-pwa.firebaseapp.com 访问。

设置角火5

AngularFire2 是 Angular 支持 Firebase 功能的官方库。它由可观察的实时绑定、身份验证和离线数据支持提供支持。我强烈建议实现这个库,以便让我们的开发过程更容易处理 Firebase。

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

$ npm install firebase @angular/fire –-save

要添加一个 Firebase 配置,打开/src/environment/environment.ts file, and添加如下设置:

export const environment = {
  production: false,
  firebase: {
    apiKey: '<your-key>',
    authDomain: '<your-project-authdomain>',
    databaseURL: '<your-database-URL>',
    projectId: '<your-project-id>',
    storageBucket: '<your-storage-bucket>',
    messagingSenderId: '<your-messaging-sender-id>'
  }
};

要找到你的 app 配置,打开 Firebase 控制台,在项目总览页面,点击齿轮图标,点击项目设置,如图 2-6 所示。

img/470914_1_En_2_Fig6_HTML.jpg

图 2-6

点按齿轮图标以查看项目设置菜单

从项目设置视图中,找到将 Firebase 添加到您的 web app (参见图 2-7 )。

img/470914_1_En_2_Fig7_HTML.jpg

图 2-7

单击将 Firebase 添加到您的应用按钮查看项目设置

替换environment.ts中的项目设置。(参见图 2-8 )。

img/470914_1_En_2_Fig8_HTML.jpg

图 2-8

复制要在 environment.ts 中替换的项目设置

导航到/src/app/app.module.ts并注入 Firebase 提供程序。Injector 确保在应用中正确指定了 Firebase 配置。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AngularFireModule } from 'angularfire2';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

import { environment } from '../environments/environment';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    AngularFireModule.initializeApp(environment.firebase)
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

AngularFire 是一个模块化的软件包,支持不同的 Firebase 特性。AngularFirestoreModuleAngularFireAuthModuleAngularFireDatabaseModuleAngularFireStorageModule可以单独添加到 @NgModules 中。例如,在这个应用中,我们将分别添加AngularFireStoreModuleAngularFireAuthModule,以获得对数据库和认证特性的支持。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AngularFireModule } from 'angularfire2';

import { AngularFirestoreModule } from 'angularfire2/firestore';

import { AngularFireAuthModule } from 'angularfire2/auth';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; 

import { environment } from '../environments/environment';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule, // needed for database features
    AngularFireAuthModule, // needed for auth features,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

很好,AngularFirestore provider 现在可以访问 Firebase 数据库集合,以修改/删除或执行更多操作。比如打开/src/app/app . component . ts,注入AngularFirestore

import { Component } from '@angular/core';

import { AngularFirestore } from 'angularfire2/firestore';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'lovely-offline';

  constructor(db: AngularFirestore) {

  }
}

下一步是绑定特定 Firestore 集合。例如,在将来,我们将创建一个名为 notes 的集合。下面的代码演示了我们如何访问所有数据,并在我们的视图中显示这些数据。

import { Component } from '@angular/core';
import { AngularFirestore } from 'angularfire2/firestore';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-root',
  template: `
    <h1>Bind Firestore collection example</h1>
    <ul>
      <li class="text" *ngFor="let note of notes$ | async">
        {{note.title}}
      </li>
    </ul>
    <router-outlet></router-outlet>
  `,
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  notes$: Observable<any[]>;
  constructor(db: AngularFirestore) {
    this.notes$ = db.collection('notes').valueChanges();
  }
}

摘要

本章介绍了一种将我们的 Angular 应用部署到 Firebase 的简单方法,并介绍了一些服务,如 Firestore 云功能,以管理和运行我们的后端代码。即使我们没有深入研究每个功能,但这足以启动并使应用运行。

AngularFire2 是 Firebase 的官方 Angular 库,它已经在我们的应用中设置好了,在接下来的章节中解释了如何将它注入我们的组件,以便访问 Firestore 和其他 Firebase 功能。

现在,我们已经准备好了部署需求,我们准备进入下一章,创建我们的应用框架,并准备好开始构建 PWA。

*

三、完成 Angular 应用

到目前为止,我们已经回顾了基础知识和需求,并设置了在云中托管、存储数据和运行功能的先决条件。对你来说这可能听起来有点无聊,但是随着我们继续每一章,它会变得更加令人兴奋,因为我们将通过添加更多的功能来逐渐构建一个真正的 PWA。

现在,是时候步入现实世界,创建一个有效的应用了。在这一章中,我们将实现一个在 Firebase 中保存个人笔记的程序。这个应用将具有用户认证功能,让用户保存,编辑和删除他们的个人帐户中的笔记。我们将分别为这些功能创建 ui 和路由。

此外,本章还有两个目标。首先,当我们继续下一章时,你将看到我们如何从头开始一个应用,并理解我们如何将它转换成 PWA。其次,您将看到我们如何将现有的应用转换为 PWA。那么,我们还在等什么?我们开始吧。

实现我们的用户界面

首先,我们需要创建一个看起来不错的应用。我们为我们的 UI 所选择的至少要包含以下特征:现代快速一致通用灵活移动优先反应灵敏和用户友好。Angular Material1是其中最好的一种,它完美地符合 Angular,帮助我们快速开发我们的应用,同时它看起来很好,满足我们的需求。

安装和设置 Angular 材质、CDK 和动画

Angular CLI 6+提供了一个新命令ng add,以便用正确的依赖关系更新 Angular 项目,执行配置更改,并执行初始化代码(如果有)。

使用 Angular CLI 自动安装@angular/material

我们现在可以使用这个命令来安装@angular/material :

ng add @angular/material

您应该会看到以下消息:

> ng add @angular/material

Installing packages for tooling via npm.
npm WARN @angular/material@6.4.6 requires a peer of @angular/cdk@6.4.6 but none is installed. You must install peer depen
dencies yourself.

+ @angular/material@6.4.6

added 2 packages from 1 contributor and audited 24256 packages in 7.228s
found 12 vulnerabilities (9 low, 3 high)
  run `npm audit fix` to fix them, or `npm audit` for details
Installed packages for tooling via npm.

UPDATE package.json (1445 bytes)

UPDATE angular.json (3942 bytes)

UPDATE src/app/app.module.ts (907 bytes)

UPDATE src/index.html (477 bytes)

UPDATE src/styles.scss (165 bytes)

added 1 package and audited 24258 packages in 7.297s

太棒了——Angular CLI 为我们处理了所有配置。然而,为了更好地理解它是如何详细工作的,我还将继续手动添加 Angular 材质到我的项目中,如下所述。

手动安装@ angular/材料

您可以使用 NPM 或纱来安装软件包,所以使用最适合您的项目。我继续讲npm

npm install --save @angular/material @angular/cdk @angular/animations

要在软件包安装后启用动画支持,BrowserAnimationsModule应该是:

imported into our application.

import { BrowserModule } from '@angular/platform-browser';

import { NgModule } from '@angular/core';
import { AngularFireModule } from 'angularfire2';
import { AngularFirestoreModule } from 'angularfire2/firestore';
import { AngularFireAuthModule } from 'angularfire2/auth';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { environment } from '../environments/environment';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule, // needed for database features
    AngularFireAuthModule,  // needed for auth features,
    BrowserAnimationsModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

要在安装软件包后启用动画支持,应该导入BrowserAnimationsModule

字体和图标帮助我们的应用看起来更好,感觉更好。因此,我们将添加 Roboto 和材料图标字体到我们的应用中。要包含它们,修改index.html,并在<head></head>:之间添加以下链接

<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">

最后,我们需要包含一个主题。在我写这本书的时候,@angular/material库中有预先构建的主题,如下所示:

  • deeppurple-amber.css

  • indigo-pink.css

  • pink-bluegrey.css

  • purple-green.css

打开angular.json,并添加一个主题 CSS 文件到建筑师➤建立➤风格,所以它看起来像下面的配置:

"architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "src/tsconfig.app.json",
            "assets": [
              "src/favicon.ico",
              "src/assets"

            ],
            "styles": [
              {
                "input": "node_modules/@angular/material/prebuilt-themes/indigo-pink.css"
              },
              "src/styles.scss"
            ],
            "scripts": []
          },

太好了——我们已经为我们的 UI 添加了我们需要的东西;现在让我们为我们的应用创建一个基本框架。

创建核心模块/共享模块

Angular 中受益于延迟加载和代码分割的一种常见方式是模块化应用,同时保持其基于组件的方法。这意味着我们将尽可能多的组件封装到一个模块中,并通过导入到其他模块中来重用这个模块。首先,我们将生成 SharedModule 以导入到所有其他模块中,并公开将在我们的应用和 CoreModule 中重用的所有公共组件和模块,CoreModule【】将在我们的根模块AppModule,中仅导入一次,并包含所有的提供者,这些提供者是单例的,并将在应用启动时立即初始化。

运行以下命令来生成核心模块。

ng generate module modules/core
> ng g m modules/core
CREATE src/app/modules/core/core.module.spec.ts (259 bytes)
CREATE src/app/modules/core/core.module.ts (188 bytes)

Angular CLI 生成的CoreModule位于模块文件夹中。让我们再执行一次这个命令来生成SharedModule located in the 模块文件夹 :

ng generate module modules/shared
> ng g m modules/shared
CREATE src/app/modules/shared/shared.module.spec.ts (275 bytes)
CREATE src/app/modules/shared/shared.module.ts (190 bytes)

为了确保CoreModule不会被多次导入,我们可以为这个模块创建一个防护。只需将以下代码添加到您的模块中:

export class CoreModule {
  constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
    if (parentModule) {
      throw new Error(`CoreModule has already been loaded. Import Core modules in the AppModule only.`);
    }
  }
}

因此,我们的核心模块如下所示:

import { NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';

@NgModule({
  imports: [
    CommonModule,
  ],
  providers: []
})
export class CoreModule {
  constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
    if (parentModule) {
      throw new Error(`CoreModule has already been loaded. Import Core modules in the AppModule only.`);
    }
  }
}

让我们将CoreModule导入到AppModule中。现在我们准备开始创建我们的第一个共享组件。

页眉、页脚和正文组件

在本节中,我们将基于图 3-1 所示的简单草图创建我们的第一个应用——一个主应用布局。

img/470914_1_En_3_Fig1_HTML.png

图 3-1

初始应用草图

我们将继续发展,而我们心中有这个草图。首先,让我们创建一个名为LayoutModule的模块,它包含页脚、页眉和菜单组件,然后将这个模块导入到AppModule中,以重用app.component.ts文件中的页眉/页脚。

ng g m modules/layout
import LayoutModule into AppModule:
...imports: [
    CoreModule,
    LayoutModule,...

通过运行以下命令,将分别生成页脚和页眉组件。

ng generate component modules/layout/header
ng generate component modules/layout/footer

我们已经创建了SharedModule;然而,我们需要在这个模块中做一些改变。首先,我们作为共享模块或共享组件导入的内容也应该导出。Angular 材料是一种模块化包装;也就是说,我们应该导入 UI 所需的模块。然后,我将在这个应用中根据我们的需要添加尽可能多的角状材料模块。以后可以添加或删除模块和组件。

最后,我们的SharedModule看起来像下面的代码:

const SHARED_MODULES = [
  CommonModule,
  MatToolbarModule,
  MatCardModule,
  MatIconModule,
  MatButtonModule,
  MatDividerModule,
  MatBadgeModule,
  MatFormFieldModule,
  MatInputModule,
  MatSnackBarModule,
  MatProgressBarModule,
  MatProgressSpinnerModule,
  MatMenuModule,
  ReactiveFormsModule,
  FormsModule,
  RouterModule
];
const SHARED_COMPONENTS = [];
@NgModule({
  imports: [ ...SHARED_MODULES2  ],
  declarations: [ ...SHARED_COMPONENTS ],
  exports: [ ...SHARED_MODULES,    ...SHARED_COMPONENTS  ],
})
export class SharedModule { }

SharedModule导入LayoutModule后,我们可以根据所需的材料组件设计页眉/页脚。

以下是标题组件:

// header.component.html

<mat-toolbar color="primary">
  <span>ApressNote-PWA</span>
  <span class="space-between"></span>
  <button mat-icon-button [mat-menu-trigger-for]="menu">
    <mat-icon>more_vert</mat-icon>
  </button>
</mat-toolbar>
<mat-menu x-position="before" #menu="matMenu">
  <button mat-menu-item>Home</button>
  <button mat-menu-item>Profile</button>
  <button mat-menu-item>Add Note</button>
</mat-menu>

// header.component.scss

.space-between {
    flex:1;
}

// header.component.ts

import { Component, OnInit } from '@angular/core';
@Component({
  selector: 'app-header',
  templateUrl: './header.component.html',
  styleUrls: ['./header.component.scss']
})
export class HeaderComponent { }

下面是页脚组件:

// footer.component.html

<footer>
  <div class="copyright">Copyright Apress - Majid Hajian</div>
</footer>
<div class="addNote">
  <button mat-fab>
    <mat-icon>add circle</mat-icon>
  </button>
</div>

// footer.component.scss

footer{
    background: #3f51b5;
    color: #fff;
    display: flex;
    box-sizing: border-box;
    padding: 1rem;
    flex-direction: column;
    align-items: center;
    white-space: nowrap;
}
.copyright {
    text-align: center;
}
.addNote {
 position: fixed;
 bottom: 2rem;
 right: 1rem;
 color: #fff;
}

// footer.component.ts

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

@Component({
  selector: 'app-footer',
  templateUrl: './footer.component.html',
  styleUrls: ['./footer.component.scss']
})
export class FooterComponent { }

现在在style.scss文件中添加一些自定义的 CSS 行来调整我们的布局:

html, body { height: 100%; }
body { margin: 0; font-family: 'Roboto', sans-serif; }
.appress-pwa-note {
    display: flex;
    flex-direction: column;
    align-content: space-between;
    height: 100%;
}
.main{
    display: flex;
    flex:1;
}
mat-card {
 max-width: 80%;
 margin: 2em auto;
 text-align: center;
}

mat-toolbar-row {
 justify-content: space-between;
}

最后,添加页脚、页眉和必要的修改到app.component.ts:

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
  <div class="appress-pwa-note">
    <app-header></app-header>
    <div class="main">
      <router-outlet></router-outlet>
    </div>
    <app-footer></app-footer>
  </div>
  `,
})
export class AppComponent { }

到目前为止,一切顺利——基于草图的初始骨架现已准备就绪,如图 3-2 所示。

让我们继续前进,创建不同的页面和路由。

注意

你会在 www.github.com/mhadaily/awesome-apress-pwa/chapter03/01-material-design-and-core-shared-modules-setup 中找到所有的代码。

img/470914_1_En_3_Fig2_HTML.jpg

图 3-2

初始应用外壳

登录/个人资料页面

我们需要创建页面,以便我的用户可以注册,登录,并看到他们的个人资料。首先,我们创建UserModule,包括路由:

ng generate module modules/user --routing

因为我们要延迟加载这个模块,我们至少需要一个路径和一个组件。要生成组件,请继续运行以下命令:

ng generate component modules/user/userContainer --flat
flag --flat ignores creating a new folder for this component.

一旦组件生成,我们应该将它添加到UserModule declarations,然后在UserModuleRouting中定义我们的路径——路径/user可以相应地在AppRoutingModule中延迟加载。

// UserModuleRouting

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { UserContainerComponent } from './user-container.component';

const routes: Routes = [
  {
    path: '',
    component: UserContainerComponent
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class UserRoutingModule { }

//AppModuleRouting

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  {
    path: 'user',
    loadChildren: './modules/user/user.module#UserModule',
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]

})
export class AppRoutingModule { }

添加登录、注册和个人资料用户界面和功能

在我们继续添加登录/注册功能之前,我们必须激活 Firebase 中的登录提供者。因此,转到您的 project Firebase 控制台,在左侧菜单列表的 develop 组下找到 Authentication,然后将当前选项卡移动到 Sign-in methods。为了简单起见,我们将使用电子邮件/密码提供者;但是,您应该能够根据需要添加更多的提供者(参见图 3-3 )。

img/470914_1_En_3_Fig3_HTML.jpg

图 3-3

启用电子邮件/密码验证

让我们继续创建一个处理所有 Firebase 身份验证方法的 Angular 服务。通过运行以下命令继续:

ng generate service modules/core/firebaseAuthService

我们需要编写几个方法,检查用户登录状态,进行登录、注册和注销。

慢慢来,看看清单 3-1 ,我们在其中实现了FirebaseAuthService,以便从AngularFireAuth服务中调用必要的方法,并在整个应用中共享状态。服务方法是不言自明的。

export class AuthService {
  // expose all data
  public authErrorMessages$ = new Subject<string>();
  public isLoading$ = new BehaviorSubject<boolean>(true);
  public user$ = new Subject<User>();

  constructor(private afAuth: AngularFireAuth) {
    this.isLoggedIn().subscribe();
  }

  private isLoggedIn() {
    return this.afAuth.authState.pipe(
      first(),
      tap(user => {
        this.isLoading$.next(false);
        if (user) {
          const { email, uid } = user;
          this.user$.next({ email, uid });
        }
      })
    );
  }

  public signUpFirebase({ email, password }) {
    this.isLoading$.next(true);
    this.handleErrorOrSuccess(() => {
      return this.afAuth.auth.createUserWithEmailAndPassword(email, password);
    });
  }

  public loginFirebase({ email, password }) {
    this.isLoading$.next(true);
    this.handleErrorOrSuccess(() => {
      return this.afAuth.auth.signInWithEmailAndPassword(email, password);
    });
  }

  public logOutFirebase() {
    this.isLoading$.next(true);
    this.afAuth.auth
      .signOut()
      .then(() => {
        this.isLoading$.next(false);
        this.user$.next(null);
      })
      .catch(e => {
        console.error(e);
        this.isLoading$.next(false);
        this.authErrorMessages$.next("Something is wrong when signing out!");
      });
  }

  private handleErrorOrSuccess(
    cb: () => Promise<firebase.auth.UserCredential>
  ) {
    cb()
      .then(data => this.authenticateUser(data))
      .catch(e => this.handleSignUpLoginError(e));
  }

  private authenticateUser(UserCredential) {
    const {
      user: { email, uid }
    } = UserCredential;

    this.isLoading$.next(false);
    this.user$.next({ email, uid });
  }

  private handleSignUpLoginError(error: { code: string; message: string }) {
    this.isLoading$.next(false);
    const errorMessage = error.message;
    this.authErrorMessages$.next(errorMessage);
  }
}

Listing 3-1App/modules/core/auth.service.ts

最后,应用应该提供登录和注册的 UI 以及用户信息。回到我们的userContainerComponent ,我们将分别实现 UI 和方法。清单 3-2 到 3-4 显示了我们的 TypeScript、HTML 和 CSS。

export class UserContainerComponent implements OnInit {
  public errorMessages$ = this.afAuthService.authErrorMessages$;
  public user$ = this.afAuthService.user$;
  public isLoading$ = this.afAuthService.isLoading$;
  public loginForm: FormGroup;
  public hide = true;

  constructor(
    private fb: FormBuilder,
    private afAuthService: FirebaseAuthService
  ) {}

  ngOnInit() {
    this.createLoginForm();
  }

  private createLoginForm() {
    this.loginForm = this.fb.group({
      email: ["", [Validators.required, Validators.email]],
      password: ["", [Validators.required]]
    });
  }

  public signUp() {
    this.checkFormValidity(() => {
      this.afAuthService.signUpFirebase(this.loginForm.value);
    });
  }

  public login() {
    this.checkFormValidity(() => {
      this.afAuthService.loginFirebase(this.loginForm.value);
    });
  }

  private checkFormValidity(cb) {
    if (this.loginForm.valid) {
      cb();
    } else {
      this.errorMessages$.next("Please enter correct Email and Password value");
    }

  }

  public logOut() {
    this.afAuthService.logOutFirebase();
  }

  public getErrorMessage(controlName: string, errorName: string): string {
    const control = this.loginForm.get(controlName);
    return control.hasError("required")
      ? "You must enter a value"
      : control.hasError(errorName)
        ? `Not a valid ${errorName}`
        : "";
  }
}

Listing 3-2User-container.component.ts

<mat-card *ngIf="user$ | async as user">
  <mat-card-title>
    Hello {{user.email}}
  </mat-card-title>
  <mat-card-subtitle>
    ID: {{user.uid}}
  </mat-card-subtitle>
  <mat-card-content>
    <button mat-raised-button color="secondary" (click)="logOut()">Logout</button>
  </mat-card-content>
</mat-card>

<mat-card *ngIf="!(user$ | async)">
  <mat-card-title>
    Access to your notes
  </mat-card-title>
  <mat-card-subtitle class="error" *ngIf="errorMessages$ | async as errorMessage">
    {{ errorMessage }}
  </mat-card-subtitle>
  <mat-card-content>
    <div class="login-container" [formGroup]="loginForm">
      <mat-form-field>

        <input matInput placeholder="Enter your email" formControlName="email" required>
        <mat-error *ngIf="loginForm.get('email').invalid">{{getErrorMessage('email', 'email')}}</mat-error>
      </mat-form-field>
      <br>
      <mat-form-field>
        <input matInput placeholder="Enter your password" [type]="hide ? 'password' : 'text'" formControlName="password">
        <mat-icon matSuffix (click)="hide = !hide">{{hide ? 'visibility' : 'visibility_off'}}</mat-icon>
        <mat-error *ngIf="loginForm.get('password').invalid">{{getErrorMessage('password')}}</mat-error>
      </mat-form-field>
    </div>
    <button mat-raised-button color="primary" (click)="login()">Login</button>
  </mat-card-content>
  <mat-card-content><br>----- OR -----<br><br></mat-card-content>
  <mat-card-content>
    <button mat-raised-button color="accent" (click)="signUp()">Sign Up</button>
  </mat-card-content>
  <mat-card-footer>
    <mat-progress-bar *ngIf="isLoading$ | async" mode="indeterminate"></mat-progress-bar>
  </mat-card-footer>
</mat-card>

Listing 3-3
User-container.component.html

.login-container {
  display: flex;
  flex-direction: column;
  > * {
    width: 100%;
  }

}

Listing 3-4User-container.component.scss

图 3-4 显示了到目前为止我们所做的结果。

img/470914_1_En_3_Fig4_HTML.jpg

图 3-4

应用中的登录、注册和个人资料用户界面

注意

你会在 www.github.com/mhadaily/awesome-apress-pwa/chapter03/02-login-signup-profile 中找到所有的代码。

尽管我们需要做的已经实现了,但是你并没有受到限制,你可以继续添加更多的 Firebase 特性,比如忘记密码链接、无密码登录和其他登录提供者。

注模块的 Firebase CRUD 3 操作

在下一节中,我们将使用不同的视图和方法,以便在应用中列出、添加、删除和更新注释;让我们一步一步来。

建立火风暴数据库

首先要做的事情:快速开始展示如何建立我们的 Firestore 数据库。

  1. 打开浏览器,进入 Firebase 项目控制台。

  2. 数据库部分,点击云火商店的入门创建数据库按钮。

  3. 为您的云 Firestore 安全规则选择锁定模式4

  4. 点击启用,如图 3-5 所示。

img/470914_1_En_3_Fig5_HTML.jpg

图 3-5

在 Firebase 中创建新数据库时选择锁定模式

下面是数据库模式 5 ,我们的目标是创建存储我们的用户和他们的笔记。

----- users // this is a collection
      ------- [USER IDs] // this is a document
             ------ notes // this is a collection
                    ----- [NOTE DOCUMENT]
                    ----- [NOTE DOCUMENT]
                    ----- [NOTE DOCUMENT]
       ------- [USER IDs] // this is a document
             ------ notes // this is a collection
                    ----- [NOTE DOCUMENT]
                    ----- [NOTE DOCUMENT]
                    ----- [NOTE DOCUMENT]

可以在 Firestore 中手动创建收藏和文档;但是我们稍后将通过在我们的应用中实现适当的逻辑来编程实现它(参见图 3-6 )。

img/470914_1_En_3_Fig6_HTML.jpg

图 3-6

Firestore 视图一旦启用

最后一步是设置 Firestore 规则,要求用户在请求中使用唯一的 id ( uid),以便给予足够的权限来执行创建/读取/更新/删除操作。点击规则选项卡,复制粘贴以下规则(见图 3-7 )。

img/470914_1_En_3_Fig7_HTML.jpg

图 3-7

Firestore 规则

service cloud.firestore {

  match /databases/{database}/documents {
    // Make sure the uid of the requesting user matches name of the user
    // document. The wildcard expression {userId} makes the userId variable
    // available in rules.
    match /users/{userId} {
      allow read, update, delete: if request.auth.uid == userId;
      allow create: if request.auth.uid != null;
      // make sure user can do all action for notes collection if userID is matched
        match /notes/{document=**} {
          allow create, read, update, delete: if request.auth.uid == userId;
        }
    }
  }

}

列表、添加和详细注释视图

Firestore 设置完成后,下一步是创建我们的组件,以便显示注释列表、添加注释以及详细说明注释视图及其相关功能。

首先,通过运行以下命令生成一个 notes 模块,包括路由:

ng generate module modules/notes --routing

我们来看看NotesRoutingModule:

const routes: Routes = [
  {
    path: "",
    component: NotesListComponent
  },
  {
    path: "add",
    component: NotesAddComponent
  },
  {
    path: ":id",
    component: NoteDetailsComponent
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class NotesRoutingModule {}

如您所见,已经定义了三条路径;因此,我们应该通过分别运行每个命令来生成相关组件:

ng generate component modules/notes/notesList
ng generate component modules/notes/notesAdd
ng generate component modules/notes/noteDetails

最后,通过将NotesRoutingModule添加到AppRoutingModule:中来延迟加载NotesModule

const routes: Routes = [
  {
    path: "",
    redirectTo: "/notes",
    pathMatch: "full"
  },
  {
    path: "user",
    loadChildren: "./modules/user/user.module#UserModule",
  },
  {
    path: "notes",
    loadChildren: "./modules/notes/notes.module#NotesModule"
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}

认证服务

身份验证服务用于登录、注销和注册,并检查用户是否已经通过了应用的身份验证。通过调用 AngularFire Auth 服务上的适当方法,凭证被发送到 Firebase,以相应地执行每个功能。

需要注入AuthService6来处理我们 app 中的认证层:

ng generate service modules/core/auth

以下代码显示了AuthService的逻辑:

// auth.service.ts

interface User {
  uid: string;
  email: string;
}

@Injectable({
  providedIn: "root"
})
export class AuthService {
  public authErrorMessages$ = new BehaviorSubject<string>(null);
  public isLoading$ = new BehaviorSubject<boolean>(true);
  public user$ = new BehaviorSubject<User>(null);

  private authState = null;

  constructor(private afAuth: AngularFireAuth) {
    this.isLoggedIn().subscribe(user => (this.authState = user));
  }

  get authenticated(): boolean {
    return this.authState !== null;
  }

  get id(): string {
    return this.authenticated ? this.authState.uid : "";
  }

  private isLoggedIn(): Observable<User | null> {
    return this.afAuth.authState.pipe(
      map(user => {
        if (user) {
          const { email, uid } = user;
          this.user$.next({ email, uid });
          return { email, uid };
        }
        return null;
      }),
      tap(() => this.isLoading$.next(false))
    );
  }

  public getCurrentUserUid(): string {
    return this.afAuth.auth.currentUser.uid;

  }

  public signUpFirebase({ email, password }) {
    this.isLoading$.next(true);
    this.handleErrorOrSuccess(() => {
      return this.afAuth.auth.createUserWithEmailAndPassword(email, password);
    });
  }

  public loginFirebase({ email, password }) {
    this.isLoading$.next(true);
    this.handleErrorOrSuccess(() => {
      return this.afAuth.auth.signInWithEmailAndPassword(email, password);
    });
  }

  public logOutFirebase() {
    this.isLoading$.next(true);
    return this.afAuth.auth.signOut();
  }

  private handleErrorOrSuccess(
    cb: () => Promise<firebase.auth.UserCredential>
  ) {
    cb()
      .then(data => this.authenticateUser(data))
      .catch(e => this.handleSignUpLoginError(e));
  }

  private authenticateUser(UserCredential) {
    const {
      user: { email, uid }
    } = UserCredential;

    this.isLoading$.next(false);
  }

  private handleSignUpLoginError(error: { code: string; message: string }) {
    this.isLoading$.next(false);
    const errorMessage = error.message;
    this.authErrorMessages$.next(errorMessage);
  }
}

数据服务

该服务包含一组标准的 CRUD 方法(创建、读取、更新和删除)。获取所有笔记等功能;添加、更新和删除;并通过调用适当方法或从适当的 API 请求来获取详细注释。事实上,它充当了 Angular 应用和后端 API 之间的接口。

要生成数据服务,请运行以下命令:

ng generate service modules/core/data

以下代码显示了DataService的逻辑:

// data.service.ts

interface Note {
  id: string;
  title: string;
  content: string;
}

@Injectable({
  providedIn: "root"
})
export class DataService {
  protected readonly USERS_COLLECTION = "users";
  protected readonly NOTES_COLLECTION = "notes";

  public isLoading$ = new BehaviorSubject<boolean>(true);

  get timestamp() {
    return new Date().getTime();
  }

  constructor(private afDb: AngularFirestore, private auth: AuthService) {}

  getUserNotesCollection() {
    return this.afDb.collection(
      this.USERS_COLLECTION + "/" + this.auth.id + "/" + this.NOTES_COLLECTION,
      ref => ref.orderBy("updated_at", "desc")
    );
  }

  addNote(data): Promise<DocumentReference> {
    return this.getUserNotesCollection().add({
      ...data,
      created_at: this.timestamp,
      updated_at: this.timestamp
    });
  }

  editNote(id, data): Promise<void> {
    return this.getUserNotesCollection()
      .doc(id)
      .update({
        ...data,
        updated_at: this.timestamp
      });
  }

  deleteNote(id): Promise<void> {
    return this.getUserNotesCollection()
      .doc(id)
      .delete();
  }

  getNote(id): Observable<any> {
    return this.getUserNotesCollection()
      .doc(id)
      .snapshotChanges()
      .pipe(
        map(snapshot => {
          const data = snapshot.payload.data() as Note;
          const id = snapshot.payload.id;
          return { id, ...data };
        }),
        catchError(e => throwError(e))
      );
  }

  getNotes(): Observable<any> {
    return this.getUserNotesCollection()
      .snapshotChanges()
      .pipe(
        map(snapshot =>
          snapshot.map(a => {
            //Get document data
            const data = a.payload.doc.data() as Note;
            //Get document id
            const id = a.payload.doc.id;
            //Use spread operator to add the id to the document data
            return { id, ...data };
          })
        ),
        tap(notes => {
          this.isLoading$.next(false);
        }),
        catchError(e => throwError(e))
      );
  }
}

认证守卫

因为这个应用要求用户在执行任何操作之前进行身份验证,所以我们应该确保所有的路由都受到保护。

AuthGuard 有助于保护对身份验证路由的访问。因为我们需要在一个惰性加载模块上设置这个防护,所以应该实现CanLoad

Ng generate guard modules/notes/auth

以下代码显示了AuthGuard的逻辑:

// auth.guard.ts

@Injectable()
export class AuthGuard implements CanLoad {
  constructor(private auth: AuthService, private router: Router) {}

  canLoad(): Observable<boolean> {
    if (!this.auth.authenticated) {
      this.router.navigate(["/user"]);
      return of(false);
    }
    return of(true);
  }
}

我们应该在我们的AppRoutingModule中提供AuthGuard。记住将这种保护添加到提供者中是很重要的。

  {
    path: "notes",
    loadChildren: "./modules/notes/notes.module#NotesModule",
    canLoad: [AuthGuard]
  }

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  providers: [AuthGuard],
  exports: [RouterModule]

})

NoteList、NoteAdd 和 NoteDetail 组件

我们已经准备好了应用中需要的所有服务层和路由。应用的其余部分只是为 NotesList、NoteAdd 和 NoteDetail 组件实现适当的 UI 和组件逻辑(清单 3-5 到 3-13 )。因为很简单,所以我希望你看一下组件,最后,图 3-8 将展示结果。

export class NotesListComponent implements OnInit {
  notes$: Observable<Note[]>;
  isDbLoading$;

  constructor(private db: DataService) {}

  ngOnInit() {
    this.notes$ = this.db.getNotes();
    this.isDbLoading$ = this.db.isLoading$;
  }
}

Listing 3-5// Notes-list.component.ts

<div *ngIf="notes$ | async as notes; else notFound">
  <app-note-card *ngFor="let note of notes" [note]="note" [loading]="isDbLoading$ | async" [routerLink]="['/notes', note.id]">
  </app-note-card>
</div>
<ng-template #notFound>
  <mat-card>
    <mat-card-title>
      Either you have no notes
    </mat-card-title>
  </mat-card>
</ng-template>

Listing 3-6// Notes-list.component.html

@Component({
  selector: "app-note-card",
  templateUrl: "./note-card.component.html",
  styleUrls: ["./note-card.component.scss"]
})
export class NoteCardComponent {
  @Input()
  note;

  @Input()
  loading;

  @Input()
  edit = true;
}

Listing 3-7// Notes-card.component.ts

<mat-card>
  <mat-card-title>{{ note.title }}</mat-card-title>
  <mat-card-subtitle>{{ note.created_at | date:"short" }}</mat-card-subtitle>
  <mat-card-content>{{ note.content }}</mat-card-content>
  <mat-card-footer class="text-right">
    <button color="primary" *ngIf="edit"><mat-icon>edit</mat-icon></button>
    <mat-progress-bar *ngIf="loading" mode="indeterminate"></mat-progress-bar>
  </mat-card-footer>
</mat-card>

Listing 3-8// Notes-card.component.html

export class NotesAddComponent {
  public userID;
  public errorMessages$ = new Subject();

  constructor(
    private router: Router,
    private data: DataService,
    private snackBar: SnackBarService
  ) {}

  onSaveNote(values) {
    this.data
      .addNote(values)
      .then(doc => {
        this.router.navigate(["/notes"]);
        this.snackBar.open(`Note ${doc.id} has been succeffully saved`);
      })
      .catch(e => {
        this.errorMessages$.next("something is wrong when adding to DB");
      });
  }

  onSendError(message) {
    this.errorMessages$.next(message);
  }

}

Listing 3-9// Notes-add.component.ts

<mat-card>
  <mat-card-title>New Note</mat-card-title>
  <mat-card-subtitle class="error" *ngIf="errorMessages$ | async as errorMessage">
    {{ errorMessage }}
  </mat-card-subtitle>
  <mat-card-content>
    <app-note-form (saveNote)="onSaveNote($event)" (sendError)="onSendError($event)"></app-note-form>
  </mat-card-content>
</mat-card>

Listing 3-10// Notes-add.component.html

export class NoteFormComponent implements OnInit {
  noteForm: FormGroup;

  @Input()
  note;

  @Output()
  saveNote = new EventEmitter();

  @Output()
  sendError = new EventEmitter();

  constructor(private fb: FormBuilder) {}

  ngOnInit() {
    this.createForm();

    if (this.note) {
      this.noteForm.patchValue(this.note);
    }
  }

  createForm() {
    this.noteForm = this.fb.group({
      title: ["", Validators.required],
      content: ["", Validators.required]
    });
  }

  addNote() {
    if (this.noteForm.valid) {
      this.saveNote.emit(this.noteForm.value);
    } else {
      this.sendError.emit("please fill all fields");
    }
  }
}

Listing 3-11// Notes-form.component.ts

<div class="note-container" [formGroup]="noteForm">
  <mat-form-field>
    <input matInput placeholder="Enter your title" formControlName="title" required>
  </mat-form-field>
  <br>
  <mat-form-field>
    <textarea matInput placeholder="Leave a comment" formControlName="content" required cdkTextareaAutosize></textarea>
  </mat-form-field>
</div>
<br>
<br>
<div class="text-right">
  <button mat-raised-button color="primary" (click)="addNote()">Save</button>
</div>

Listing 3-12// Notes-form.component.html

export class NoteDetailsComponent implements OnInit {
  public errorMessages$ = new Subject();
  public note$;
  public isEdit;

  private id;

  constructor(
    private data: DataService,
    private route: ActivatedRoute,
    private snackBar: SnackBarService,
    private router: Router
  ) {}

  ngOnInit() {
    const id = this.route.snapshot.paramMap.get("id");
    this.id = id;
    this.note$ = this.data.getNote(id);
  }

  delete() {
    if (confirm("Are you sure?")) {
      this.data
        .deleteNote(this.id)
        .then(() => {
          this.router.navigate(["/notes"]);
          this.snackBar.open(`${this.id} successfully was deleted`);
        })
        .catch(e => {
          this.snackBar.open("Unable to delete this note");
        });
    }
  }

  edit() {
    this.isEdit = !this.isEdit;
  }

  saveNote(values) {
    this.data
      .editNote(this.id, values)
      .then(() => {
        this.snackBar.open("Successfully done");
        this.edit();
      })
      .catch(e => {
        this.snackBar.open("Unable to edit this note");
        this.edit();
      });
  }

  sendError(message) {
    this.errorMessages$.next(message);
  }
}

Listing 3-13// Notes-details.component.ts

img/470914_1_En_3_Fig8_HTML.jpg

图 3-8

添加注释、详细信息和注释列表视图

<div *ngIf="note$ | async as note; else spinner">

    <mat-card *ngIf="isEdit">
        <mat-card-subtitle class="error" *ngIf="errorMessages$ | async as errorMessage">
            {{ errorMessage }}
        </mat-card-subtitle>
        <mat-card-content>
            <app-note-form [note]="note" (saveNote)="saveNote($event)" (sendError)="sendError($event)"></app-note-form>
        </mat-card-content>
    </mat-card>

    <app-note-card *ngIf="!isEdit" [note]="note" [loading]="isDbLoading$ | async"></app-note-card>

    <button mat-raised-button color="accent" (click)="delete()"><mat-icon>delete</mat-icon></button>
    <button mat-raised-button color="primary" (click)="edit()"><mat-icon>edit</mat-icon></button>

</div>

<ng-template #spinner>
    <mat-spinner></mat-spinner>
</ng-template>

Listing 3-14// Notes-details.component.html

注意

如果你觉得舒服,可以看看最后的代码。你可以在 github . com/mha daily/chapter 03/03-note-list-add-edit-update-delete/中找到。克隆项目并导航到文件夹。然后运行以下命令:

npm install // to install dependencies

npm start // to run development server

npm run deploy // to deploy to firebase

摘要

前三章的目标是揭示 PWA 的基本原理;工具;一步一步地一起创建一个应用。听起来可能与 PWA 无关;然而,正如我们在本书中继续的那样,一章接一章,一节接一节,我们将努力使我们的应用逐步变得更好,最终拥有一个带 Angular 的伟大 PWA。

从下一章开始,我们将深入实现离线功能、缓存、推送通知、新的现代浏览器 API,以及更多,只是为了创建一个类似本机的应用,以便在移动和网络上获得更好的用户体验。虽然这在几年前还是不可能的,但是现在它在主流浏览器中得到了广泛的支持。

四、Angular 的服务工作器

到目前为止,我们构建的应用没有 PWA 特征。从本章开始,我们将逐步添加 PWA 功能并深入研究。Angular 提供了一个名为service-worker的模块来处理缓存策略和即时通知。Angular Service Worker 高度可配置,可以满足 Angular app 需求。但是,在我们开始实施本模块之前,我们应该对服务工作器有一个基本的了解。

本章从服务工作器基础和缓存 API 开始,因为当我们使用 Angular Service Worker 编码时,了解幕后发生的事情是至关重要的。然后,Angular CLI 将通过使用@angular/pwa原理图帮助我们搭建支架并将我们的项目转化为 PWA。

虽然重点是 CLI v6,但为了让我们更好地了解手动实现时需要做的工作,每个修改都将被分解,例如,在 Angular 版本 5 或更低版本中。

服务工作器:艾滋病人的大脑

你的大脑是决策的中心,完全控制着你的身体。服务器工人类似于我们的大脑。它们的核心是用 JavaScript 编写的工作脚本,在现代浏览器中只需几行代码就能实现,并在后台运行。一旦激活,开发人员就能够拦截网络请求,处理推送通知,管理缓存,并执行许多不同的任务。

你可能会问,不支持怎么办? 1

如果它没有在用户的浏览器中实现,它只是后退,网站将正常运行。PWA 的定义是,任何人——无论选择何种浏览器和操作系统——都应该能够浏览网站并获得最佳用户体验。这个描述指的是被称为“完美渐进增强”的短语

了解服务工作器

为了理解服务工作器,想象你自己坐在你大脑的中心。你被提供了不同的工具来控制你的身体。你什么都看得到,你可以做任何决定。这取决于你,要么让你的身体做它正常做的事情,要么把决定导向一个不同的方向。你甚至可能完全停止大脑的运作。这是您可以在服务工作器中对网络请求执行的操作;它的作用类似于网站和服务器之间的代理。完全接管网络请求的能力使服务工作器非常强大,并允许您做出反应和响应!

值得一提的是,尽管 Service Worker 是用 JavaScript 编写的,但它的行为略有不同,如下所示:

img/470914_1_En_4_Fig1_HTML.jpg

图 4-1

服务工作器可以在不同的线程中运行并拦截请求

  • 在不同的线程中运行,与支持应用的主 JavaScript 不同。图 4-1 展示了服务工作器是如何坐在不同的线程上截取网络请求的。

  • 在它自己的全球环境中运行。

  • 设计为完全异步;因此,它不能访问诸如同步 XHR 和本地存储之类的东西。

  • 在 worker 上下文中运行——因此,它不能访问 DOM。

  • 除了用于开发的 Localhost 之外,仅在生产中运行 HTTPS。

  • 在 1:1 范围内运行,这意味着每个范围只能有一个服务工作器。

  • 可以随时终止。

服务工作器是事件驱动的。因此,一旦了解了事件的基本情况,就比你想象的更容易开始。简单地挑选你想参加的活动,你就可以开始了。让我们来看看服务工作器中的主要事件。

服务工作器生命周期

服务工作器在其生命周期中有不同的阶段。慢慢来,看看图 4-2 ,它展示了服务工作器生命周期是如何分四步进行的。想象一下,你的网站将由一名服务工作器提供服务:

步骤 1,当用户导航到网站时,通过调用register()函数,浏览器检测到服务工作器 JavaScript 文件;因此,它下载、解析并开始执行阶段。Register 函数返回一个承诺 2 ,在错误的情况下,注册被拒绝,服务工作器注册过程停止。

然而,第 2 步,如果注册顺利并得到解决,服务工作器状态变为 installed 。因此,一个安装事件在预缓存所有静态资产的最佳位置触发。请记住,安装事件仅在注册后第一次发生。

步骤 3,一旦安装成功完成,服务工作器就会被激活,并在自己的范围内拥有完全控制权。类似于安装事件,激活仅在注册后第一次发生,并且一旦安装完成。

img/470914_1_En_4_Fig2_HTML.jpg

图 4-2

服务器工作生命周期

注意

作用域用于指定您希望服务器工作程序控制的内容子集,可以通过register()函数第二个参数中的可选参数scope来定义,也可以默认定义服务器工作程序 JavaScript 文件所在的位置。例如,如果服务器 worker 文件位于应用的根目录中,它就可以控制所有页面。然而, /sw-test/指定只能访问这个原点下的所有页面。图 4-3 展示了示波器如何工作。

步骤 4,安装和激活事件无误完成后,服务工作器将开始工作。但是,如果它在安装、激活期间失败,或者被新的替换,它仍然是多余的,不会影响应用。

img/470914_1_En_4_Fig3_HTML.jpg

图 4-3

服务工作器范围演示

如前所述,没有服务工作器的网站不会处理任何请求;然而,一旦安装并激活了它,它就可以控制自己范围内的每一个请求。因此,要在第一次安装和激活后启动 Service Worker 中的逻辑,需要刷新网站,或者我们应该导航到另一个页面。

最后但同样重要的是,可能会发生这样的情况,我们想要改变一个注册并激活的服务工作器。如果在注册的文件中有字节大小的变化,浏览器会考虑它,所有的步骤,如上所述,会再次发生。但是,由于我们已经激活了一个服务工作器,因此流程略有不同。这一次,服务工作器不会立即被激活;因此,Service Worker 中的逻辑不会执行。它保持等待,直到所有运行旧服务工作器的标签和客户端被终止。换句话说,所有打开网站的标签都必须关闭,然后重新打开。因为我们是开发人员,并且知道忍者技巧,我们可以简单地跳过 DevTools 的等待,或者如果我们愿意,我们也可以在服务工作器逻辑中以编程方式完成。我们将在本章中对此进行详细的回顾。

服务工作器功能事件

除了安装激活事件之外,获取推送同步事件在服务工作器中也是可用的,称为功能事件。简而言之:

  • Fetch :每次浏览器请求静态资产或动态内容时发生;例如,对图像、视频、CSS、JS、HTML 的请求,甚至 ajax 请求。

  • 推送:web app 收到推送通知时发生。

  • Sync :让您推迟操作,直到用户拥有稳定的连接。这有助于确保用户想要发送的任何内容都被实际发送。该 API 还允许服务器向应用推送定期更新,以便应用可以在下次上线时进行更新。

Chrome DevTools(铬 DevTools)

没有合适的调试工具,任何开发人员都不会感到舒服。在所有浏览器中,在写这本书的时候,Chrome DevTools 是调试服务工作器的最佳选择。让我们来看一下 Chrome DevTools,看看它提供了哪些选项来帮助我们简化调试并更好地增强 PWAs。

控制台、应用和审计是 Chrome DevTools 中调试服务工作器的主要面板。审计小组利用 Lighthouse3T5,这是一个开源的自动化工具,用于提高网站质量,可用于运行可访问性、性能、SEO、最佳实践和 PWA 审计测试。我们使用审计面板来鉴定网页,特别是渐进式网络应用,这是我们的目标(见图 4-4 )。

img/470914_1_En_4_Fig4_HTML.jpg

图 4-4

Chrome 中的审计面板,我们在这里对网页进行审计测试

查看了应用面板后,我们看到以下内容:

  • 清单:我们可以调试 Web App 清单 的地方。 4

  • 服务工作器:在这里我们调试服务工作器,有很多选项,比如更新服务工作器、移除、跳过等待,以及不同的选项与网络一起工作(图 4-5 )。

    • 离线:在浏览器中模拟无法上网。

    • 重新加载时更新:每次页面重新加载时下载服务工作器,因此所有生命周期事件,包括安装和激活,都在重新加载时发生。这对调试非常有用。

    • 绕过网络:将强制浏览器忽略任何活动的服务工作器,并从网络获取资源。这对于您希望处理 CSS 或 JavaScript,而不必担心服务工作器意外缓存和返回旧文件的情况非常有用。

  • 清除存储:在这里我们可以删除所有缓存。

  • 本地存储、会话存储、索引数据库、Web SQL 和 cookies 都是您可能熟悉的不同类型的存储。索引数据库将是本书的重点,因为它是异步的,服务工作器可以访问它。

  • 缓存存储:这是浏览器中新的缓存 API,基于键值,能够存储请求和响应。我们打开这个缓存来存储我们的大部分资产和动态内容。这种缓存非常强大,在应用和服务工作器中都可用。

如果你有兴趣了解更多关于 Chrome DevTools 的信息,你可以在谷歌开发者网站的 https://developers.google.com/web/tools/chrome-devtools/ 中查看详细文档。我强烈建议您花点时间深入探索关于 DevTools 的信息,我相信这会让您更有效率。

img/470914_1_En_4_Fig5_HTML.jpg

图 4-5

Chrome DevTools 应用面板下的服务工作器选项

我知道您迫不及待地想要开始编码和查看示例代码,所以让我们开始吧。

服务工作器示例代码

是时候写几行代码来看看我们如何注册一个服务工作器并探索它自己的生命周期了。首先,我将创建一个简单的 html 文件,在</body>之前,我将打开一个<script>标签,并将注册service-worker.js文件,该文件位于index.html.旁边的根目录中

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Apress Simple Service Worker Registartion</title>
</head>
<body>
    <div style="text-align: center; padding: 3rem">
        <h1>Apress Simple Service Worker Registartion</h1>
    </div>
    <script>
        if ('serviceWorker' in navigator) {
            navigator.serviceWorker
                .register('/service-worker.js')
                .then(registration => {  // registeration object
                    console.log('Service worker is registered', registration);
                })
                .catch(e => {
                    console.error('Something went wrong while registaring service worker.')
                });
        }
    </script>
</body>
</html>

渐进式改进意味着允许所有用户加载我们的网页,无论他们使用的是旧版本还是最新版本的浏览器。因此,我们应该经常检查不同浏览器中可能没有的功能。上面的代码由一个特性检查语句if ('serviceWorker' in navigator) {}.开始。一旦确保了可用性,就通过传递服务工作器路径来调用 register 方法register('/service-worker.js')。这个方法有第二个参数,这个参数是可选的,用于向方法传递额外的选项:例如,定义范围。因为 register 方法中没有第二个参数,所以 scope 应该是缺省值;在这种情况下,它是服务工作器文件所在的根目录。thencatch分别在承诺注册被解析或拒绝时返回注册或错误对象。

server-worker.js中的逻辑是激活安装事件的监听器,其中我们在控制台的回调函数中记录两条消息。Self在这里指的是ServiceWorkerGlobalScope

//service-worker.js

self.addEventListener("install", (event) => {
  console.log("[SW.JS] Step 2, Service worker has been installed");
});

self.addEventListener("activate", (event) => {
  console.log("[SW.JS] Step 2, Service worker has been activated");
});

当您在控制台面板中打开 devTools 时,您将能够看到日志(参见图 4-6 )。

注意

可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter04/01-simple-service-worker 。运行npm install然后运行npm start。它在端口 8080 上运行一个 web 服务器。可以导航到localhost:8080。如果您将书中的代码复制并粘贴到项目中,您需要一个 web 服务器来运行您的代码。

重新加载网页;从现在开始直到service-wokrer.js中的新变化,你将只能看到登录在控制台中的注册对象,并且安装和激活不再被触发(见图 4-7 )。

img/470914_1_En_4_Fig7_HTML.jpg

图 4-7

一旦服务工作器被激活,第二次重新加载不再触发安装和激活事件

img/470914_1_En_4_Fig6_HTML.jpg

图 4-6

首次注册时的服务工作器生命周期。如您所见,安装和激活事件时有发生。

Reload the web page; from now on until the new change in

只需在服务工作器文件中添加几行,然后在应用面板中观察服务工作器的同时重新加载应用。

// modified service-worker.js
// this is equivalent to following addEventistener
// self.oninstall = (event) => { };
self.addEventListener("install", event => {
  console.log("[SW.JS] Step 2, Service worker has been installed");
  console.log("Just added something;");
});

// this is equivalent to following addEventistener
// self.onactivate = (event) => { };
self.addEventListener("activate", event => {
  console.log("[SW.JS] Step 3, Service worker has been activated");
});

重新加载后,您将看到一个新的服务工作器正在等待,直到所有客户端都被终止。一旦浏览器检测到服务工作器的新变化,就安装这个文件;然而,直到所有的客户端都被声明,它才被激活——换句话说,所有的标签页都需要被关闭并重新打开,以编程方式在 Service Worker 中执行skipWaiting,或者你可以手动点击 Chrome DevTools 中的SkipWaiting,如图 4-8 所示。

img/470914_1_En_4_Fig8_HTML.jpg

图 4-8

在 DevTools 中,您可以单击 SkipWaiting 来激活新的服务工作器

到目前为止,我们已经发现了 Service Worker 及其生命周期是如何工作的。现在是时候展示缓存 API 功能了,并在下一节中看到它的实际应用。

缓存 API

连通性独立性是 PWAs 的一个顶级特征,它使 PWAs 与众不同。缓存 API 是浏览器中一个新的缓存存储,我们可以将请求存储为键,将响应存储为值。在本节中,我们将快速浏览一下缓存 API,以了解离线特性是如何工作的。

我改变了应用的结构,加入了app.js文件和style.css文件,前者操纵 DOM 显示标题,后者包含一个title使标题居中。

.
├── app.js
├── index.html
├── service-worker.js
└── style.css

// app.js

const title = document.querySelector(".title");
title.innerHTML = "<h1>Apress Simple Service Worker Registartion</h1>";

// style.css

.title {
  text-align: center;
  padding: 3rem;
}

// index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Apress Simple Service Worker Registartion</title>
    <link href="/style.css" rel="stylesheet">
</head>

<body>
    <div class="title"></div>

    <script src="/app.js"></script>
    <script>
        if ('serviceWorker' in navigator) {
            navigator.serviceWorker.register('/service-worker.js') 

        }
    </script>
</body>

</html>

预缓存静态资产

每个 web 应用都包含许多静态资产,包括样式、JavaScript 和图像。正如本章前面提到的,一旦安装事件触发,就可以利用该事件并编写所需的逻辑。在服务工作器控制所有内容之前,它会在每次安装时触发一次;因此,这里是打开缓存并将数据添加到缓存存储的最佳位置之一,在这里加载应用基础是必不可少的。

server-worker.js

// always add version to your cache
const CACHE_VERSION = "v1";
const PRECACHE_ASSETS = ["/", "/style.css", "/index.html", "/app.js"];

self.oninstall = event => {
  console.log("Install event, start precaching...");
  event.waitUntil(
    caches.open(CACHE_VERSION).then(cache => {
      return cache.addAll(PRECACHE_ASSETS);
    })
  );
};

让我们分解代码。首先,我们定义了一个缓存存储名称,它被指定为版本名称。其次,这个应用要求,为了在没有互联网连接的情况下运行,它的一些静态资产必须列在一个数组中。

一旦 Service Worker 中的 install 事件触发,不管 callback 中的逻辑结果是什么,它都会被关闭。因此,我们需要一种机制来告诉服务工作器在行动解决之前保持不动。因此,waitUntil()是一个方法,它告诉浏览器保持在同一个事件中,直到将要传递给该方法的一个或多个承诺被解析。

最后,caches.open()接受一个名字并打开缓存将数据存入其中.其他的Caches方法有:

  • delete(cacheName) :删除整个缓存名,返回 Boolean。

  • has(cacheName) :查找缓存名,返回 Boolean。

  • keys() :检索所有缓存名称并返回字符串数组。

  • 匹配(请求):匹配一个请求,如果有的话。

  • open(cacheName) :打开一个缓存,添加请求/响应。

所有缓存 API 都是基于承诺的。

一旦一个缓存打开,我们可以一个接一个地或者作为一个数组添加我们所有的资产。

其他可用的缓存方法如下:

  • add(request) :添加请求,可以字符串形式添加名称。

  • addAll(requests) :添加请求数组或字符串数组。

  • delete(request) :删除请求或名称字符串,返回布尔值。

  • keys() :检索所有缓存名称并返回字符串数组。

  • 匹配(请求):匹配一个请求,如果有的话。

  • matchAll(requests) :匹配请求数组,如果有的话。

  • put(request,response) :用新的响应修改现有的请求。

您可能会问,我应该将缓存转储到哪里?很好的问题——就在服务工作器控制其范围内的所有页面之前,这意味着激活一个事件。假设我们已经将缓存版本升级到 v2 ,我们想要删除所有过时的缓存,这有助于清理过时的缓存并释放空间(参见图 4-9 )。

img/470914_1_En_4_Fig9_HTML.jpg

图 4-9

在安装事件中有两个版本的缓存可用,因为新的服务工作器尚未激活

我们需要过滤掉除当前缓存之外的所有其他缓存,并删除所有缓存。

// service-worker.js

self.onactivate = event => {
  console.log("activate event, clean up all of our caches...");
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames
        .filter(cacheName => cacheName !== CACHE_VERSION)          .map(cacheName => caches.delete(cacheName));
    })
  );
};

我们调用waitUntil()方法来告诉浏览器停留在激活事件中,直到传递给该方法的所有承诺都被解析。正如你在上面的代码中看到的,所有的密钥被检索,然后在不等于当前版本的地方被过滤,然后删除所有以前的缓存(见图 4-10 )。

img/470914_1_En_4_Fig10_HTML.jpg

图 4-10

一旦新的服务工作器被激活,所有先前更新的缓存将被删除

在回顾了服务工作器和缓存 API 之后,我迫不及待地期待开始 Angular 服务工作器模块。

Angular 维修工人模块

从概念上讲,Angular Service Worker 类似于安装在最终用户 web 浏览器中的转发缓存或 CDN edge,它满足 Angular 应用对本地缓存中的资源或数据的请求,而无需等待网络。像任何缓存一样,它有内容过期和更新的规则。

在向项目添加任何东西之前,让我们使用审计面板中的 Lighthouse 来分析我们的应用。

导航到 awesome-apress-pwa.firebaseapp.com5或您已经部署了应用的 Firebase URL。

注意

可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter04/03-analyze-using-lighthouse 。运行npm install然后运行npm run serve:prod。它在网络服务器上运行一个生产应用。可以导航到localhost:4200。您可能还需要将这段代码部署到 Firebase,以便在添加服务工作器之前评估您的应用。

接下来,在 Chrome 中打开开发者工具 6 ,点击审计面板。我们的主要目标群体是移动用户。因此,最好在手机上选择仿真,并取消选中除Progress Web App7之外的所有复选框,并选择模拟快速 3G,4 倍 CPU 减速中的节流 8 选项,以确保我们的测试环境与普通真实移动用户设备相似。确保清除存储也被选中,因为重点访问者是第一次加载网页的人。

按下运行审计,等待 Lighthouse 生成报告。结果显示一个 54/100 9 的分数;那是因为我们有一些审核通过了。如图 4-11 所示,六个故障主要与服务工作器、渐进式增强和 Web App 清单有关。

注意

如果你在本地主机上运行审计,请记住,因为你不是用 HTTPS 运行你的应用,你可能会看到一个较低的分数。

img/470914_1_En_4_Fig11_HTML.jpg

图 4-11

向项目添加任何新优化之前的初始结果

对服务工作器的支持

Angular schematics10已引入 Angular CLI 6,并对我们如何快速搭建 Angular 应用产生了显著影响。因此,添加 PWA 功能(包括服务工作器)是一个简单的过程,非常容易。由于@angular/cli已经在全球范围内安装,只需在您的终端中运行以下命令。

ng add @angular/pwa

该命令 11 将通过扩展样板代码和添加新文件到 Angular app 结构中来自动修改一些现有文件。让我们仔细看看修改。

CREATE ngsw-config.json (441 bytes)
CREATE src/manifest.json (1085 bytes)
CREATE src/assets/icons/icon-128x128.png (1253 bytes)
CREATE src/assets/icons/icon-144x144.png (1394 bytes)
CREATE src/assets/icons/icon-152x152.png (1427 bytes)
CREATE src/assets/icons/icon-192x192.png (1790 bytes)
CREATE src/assets/icons/icon-384x384.png (3557 bytes)
CREATE src/assets/icons/icon-512x512.png (5008 bytes)
CREATE src/assets/icons/icon-72x72.png (792 bytes)
CREATE src/assets/icons/icon-96x96.png (958 bytes)
UPDATE angular.json (4049 bytes)
UPDATE package.json (1646 bytes)
UPDATE src/app/app.module.ts (1238 bytes)
UPDATE src/index.html (652 bytes)

如你所见,不同大小的图标, ngsw-config.json, manifest.json,ngsw-worker.js 12 被添加到项目中while angular.json, app.module.ts, index.html,package.json被修改。

让我们来分解一下变化,看看它在哪些方面发生了变化:

  1. package . JSON:Angular Service Worker"@angular/service-worker"已经被添加到依赖列表中,在撰写本书时,已经安装了 6.1.0 版本。当你读到这本书时,它可能会升级或增加一个新版本。

  2. ngsw-config.json :添加到项目的,包含一个 Service Worker 配置。在这一章中,我们将看一看它并浏览基础知识,而在下一章中,我们将深入研究它并添加更多的高级配置以及提示和技巧。

    {
      "index": "/index.html",
      "assetGroups": [
        {
          "name": "app",
          "installMode": "prefetch",
          "resources": {
            "files": [
              "/favicon.ico",
              "/index.html",
              "/*.css",
              "/*.js"
            ]
          }
        }, {
          "name": "assets",
          "installMode": "lazy",
          "updateMode": "prefetch",
          "resources": {
            "files": [
              "/assets/**"
            ]
          }
        }
      ]
    }
    
    
  3. manifest.json :添加到项目中的 /src/ 文件夹。它包含一个使应用可安装的配置。在第六章中,manifest.json将被深入回顾。

    {
      "name": "lovely-offline",
      "short_name": "lovely-offline",
      "theme_color": "#1976d2",
      "background_color": "#fafafa",
      "display": "standalone",
      "scope": "/",
      "start_url": "/",
      "icons": [
        {
          "src": "assets/icons/icon-72x72.png",
          "sizes": "72x72",
          "type": "image/png"
        },
        {
          "src": "assets/icons/icon-96x96.png",
          "sizes": "96x96",
          "type": "image/png"
        },
        {
          "src": "assets/icons/icon-128x128.png",
          "sizes": "128x128",
          "type": "image/png"
        },
        {
          "src": "assets/icons/icon-144x144.png",
          "sizes": "144x144",
          "type": "image/png"
        },
        {
          "src": "assets/icons/icon-152x152.png",
          "sizes": "152x152",
          "type": "image/png"
        },
        {
          "src": "assets/icons/icon-192x192.png",
          "sizes": "192x192",
          "type": "image/png"
        },
        {
          "src": "assets/icons/icon-384x384.png",
          "sizes": "384x384",
          "type": "image/png"
    
        },
        {
          "src": "assets/icons/icon-512x512.png",
          "sizes": "512x512",
          "type": "image/png"
        }
      ]
    }
    
    
  4. 不同图标:在 src/assets/icons/ 中增加,并在manifest.json中重复使用。我们将在第六章回到这些图标。

  5. Angular.json :如你所知,这个文件包含了所有 Angular CLI 配置。由于manifest.json需要在公共/构建文件夹中公开,因此必须在适用的架构配置中将它添加到assets数组中。例如,请参见下面的代码片段:

    "architect": {
            "build": {
              "builder": "@angular-devkit/build-angular:browser",
              "options": {
                ...
                "assets": [
                  "src/favicon.ico",
                  "src/assets",
                  "src/manifest.json"
                ],
                "styles": [
                  ...
                  "src/styles.scss"
                ],
                "scripts": []
              },
              ...
    
    

    There will be one more change here. serviceWorker has been added to the production configuration to inform Angular CLI that this feature is enabled. Let take a look at the configuration’s snippet:

    "configurations": {
                "production": {
                  "fileReplacements": [
                    {
                      "replace": "src/environments/environment.ts",
                      "with": "src/environments/environment.prod.ts"
                    }
                  ],
                  "optimization": true,
                  "outputHashing": "all",
                  "sourceMap": false,
                  "extractCss": true,
                  "namedChunks": false,
                  "aot": true,
                  "extractLicenses": true,
                  "vendorChunk": false,
                  "buildOptimizer": true,
                  "serviceWorker": true
                }
              }
    
    
  6. 【Index.html】:将manifest.json添加到项目后,需要通过 index.html 头部的rel=manifest进行暴露,让浏览器知道这个文件是项目的清单配置文件。主题颜色 meta 告诉浏览器用什么颜色来着色 UI 元素,比如地址栏。

    <link rel="manifest" href="manifest.json">
    
    <meta name="theme-color" content="#1976d2">
    
    
  7. app.module.ts :是我们的主应用模块,已经修改为导入ServiceWorkerModule,以便为项目添加服务工作器功能和特性。该模块注册了ngsw-worker.js服务工作器 JavaScript 文件,该文件由 Angular 团队编写和维护,将在 prod 构建后添加到项目的根目录中。它还有第二个参数,以确保只有当应用准备好生产时才启用注册,并且不会中断开发环境。

    ServiceWorkerModule.register("ngsw-worker.js", {
          enabled: environment.production
    })
    
    

    Angular 中的服务工作器还可以在另外两个选项中注册:

  • index.html中添加注册脚本,请参考上一节我们注册一个简单的服务工作器。记得注册ngsw-worker.js。我不推荐这个选项;相反,如有必要,请使用下一个选项。

  • bootstrapModule()被解析后,在main.ts中使用相同的注册码,

    // main.ts
    platformBrowserDynamic().bootstrapModule(AppModule)
      .then(() => {
        if ('serviceWorker' in navigator && environment.production) {
          window.addEventListener('load', () => {
            navigator.serviceWorker.register('/ngsw-worker.js') ;
          });
        }
      })
      .catch(err => console.log(err));
    
    

注意

ServiceWorkerModule.register()除了enable还有scope选项。

虽然@angular/pwa原理图有助于快速建立一个 Angular PWA 项目,但有些情况下我们需要手动完成上述所有步骤。例如:

  1. 如果您在生产中运行 Angular 5,仍然有机会将 Angular Service Worker 模块添加到您的应用中。简单地回到每一步,尝试一个接一个地添加或修改所有的更改。运行npm install以确保@angular/service-worker已成功安装,您可以开始运行了!

  2. 您可能只需要单独的 ServiceWorker 模块,而不需要其余的特性:例如, manifest.json.

看起来每一个部分都已经就位,可以开始生产了。在下一部分中,我们将检查 dist 文件夹并探索新的内容。

ngsw-config.json 解剖学

Angular Server Worker 是为大型应用而设计和编程的;因此,它是高度可配置的。

规则写在ngsw-config json文件中。顶级 Angular 服务工作器配置对象接口指示有五个主要属性可以使用。

interface Config {
    appData?: {};
    index: string;
    assetGroups?: AssetGroup[];
    dataGroups?: DataGroup[];
    navigationUrls?: string[];
}

默认情况下,index.html已被添加为主入口点。看了一下 assetGroups 接口,它是一个为 JavaScript、图像、图标、CSS 和 HTML 文件等静态资产设置规则的数组。

type Glob = string;

interface AssetGroup {
    name: string;
    installMode?: 'prefetch' | 'lazy';
    updateMode?: 'prefetch' | 'lazy';
    resources: {
        files?: Glob[];
        versionedFiles?: Glob[];
        urls?: Glob[];
    };
}

注意

VersionedFiles 是贬值的,从 v6 开始,“versionedFiles”和“Files”选项具有相同的行为。请改用“文件”。

我们已经看到 Angular CLI 向ngsw-config.json添加了默认规则:

"assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"]
      }
    },
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": ["/assets/**"]
      }
    }
  ]

如图所示,这个数组中有两个对象。让我们探索第一个对象。

  1. name :定义组名,并将成为缓存 API 存储名的一部分。

  2. installMode :决定缓存或提取组资源时缓存策略的行为。它有两个选项:

    1. prefetch :表示所有的资源都被下载,并且应该在 install 事件时立即被缓存;这类似于我们在本章前面看到的预缓存资产。这种模式用于缓存应用引导(如 app-shell)所需的资产,以使应用完全具备离线能力。

    2. lazy :意思是每个资源在运行时被请求时被单独缓存。

  3. resources:要缓存的资源的明确列表。有两种方法来设置它们:文件或网址。如上所述,VersionedFiles 是贬值的,其行为与文件相同。

    1. files :包含与根中的文件匹配的 globs 列表(在本例中)。代表已经用适当的文件扩展名定义的文件名。例如, *。js 表示所有的 JavaScript 文件, / 表示它们位于根目录。总之, **/。js** 表示位于项目根目录下的所有 JavaScript 文件。

    2. urls :包含一个应该被缓存的外部 URL 列表(相对的,绝对的路径,或者在不同的原点上):例如,Google 字体。URL 不能被散列,因此通过配置的改变,它们将被更新。在默认配置中,没有 URL,但是在下一章中我们将需要它来添加我们的外部资源。

{
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"]
      }
    },

注意

文件的内容将被散列到ngsw.json文件的散列表 13 节点中。它有助于有一个准确的版本。请记住,文件路径被映射到应用的 URL 空间,从基本 href 开始。

显然,它试图预先缓存运行 Angular 应用所需的基本文件,即使在没有网络的情况下。

前进到第二个对象,它具有类似的配置,除了它以所有文件为目标,而不考虑它们在/assets文件夹下的文件扩展名,这些文件将在运行时一被获取就被缓存。如果这些资产中的每一个有新的变化,它将被立即获取和更新。

  {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": ["/assets/**"]
      }
    }

  1. 安装模式:请参考对象一描述。

  2. updateMode :确定当应用具有新版本并被下载时,每个缓存的资产应该如何表现;类似于安装模式,它有两个选项:

    1. 预取:意味着每一个新的应用版本都应该刷新每一项资产(如果需要的话)。Angular 创建 hashTable 来比较哈希值,如果有新的变化,就会下载资产。以这种方式,缓存中的 URL 将总是被刷新(具有 If-Modified-Since14请求)。

    2. 懒惰:然而,当请求资源时,执行类似于上面的流程。这个模式只有在installMode也很懒的情况下才有效。

  3. 资源:请参考对象一描述。

    1. 档案 : **代表一切。在这种情况下,/assets/**表示资产文件下的所有文件,包括图像、图标等。

注意

installModeupdateMode的默认值为ngsw.js中的prefetch

我相信这样一句话:理解,不要模仿。“评估每个对象应该有助于我们根据应用中的需求编写自己的规则。基本面是一样的;然而,你可能需要更高级的设置,比如为外部资源和导航 URL 编写规则,这将在下一章讨论。

与 Angular 的服务工作器一起构建项目

ServiceWorker模块仅在我们运行生产版本时启用。运行以下命令,开始在生产环境中构建一个应用:

npm run build:prod // or ng build --prod

ngsw-worker.js是我们的服务工作器文件,而ngsw.json是我们的配置,将由服务工作器获取并相应地实施。

.
├── 0.c570a2562d2874d34dc4.js
├── 1.71eb2445db7dfda9e415.js
├── 2.df6bb6a6fde654fe8392.js
├── 3rdpartylicenses.txt
├── assets
├── favicon.ico
├── index.html
├── main.873527a6348e9dfb2cc1.js
├── manifest.json
├── ngsw-worker.js
├── ngsw.json
├── polyfills.8883ab5d81bf34ae13b1.js
├── runtime.e14ed3e6d31ff77728e9.js
├── safety-worker.js
├── styles.7a3dc1d11e8502df3926.css
└── worker-basic.min.js

ngsw-worker被注册为服务工作器逻辑文件,ngsw.json正在基于ngsw-config.json创建。所有的配置和资源都是在ngsw.json清单中生成的,它是由ngsw-worker中的编写逻辑自动获取的,并根据该文件中定义的 URL、文件和策略添加、更新或删除缓存。它包含一个根据 build-hash 和 Angular Service Worker 的哈希表。如果有任何变化,检查该散列以更新在 dist 文件夹中的资源。

如果你打开 ngsw manifest 文件,静态资产和 JavaScript 文件在构建之后已经被神奇地添加了。最终,Angular CLI 将匹配我们所有的文件,并将它们添加到 ngsw,因为我们需要每个文件的完整路径。ngsw.json还通知 Angular 将这些资源提取到缓存中,并相应地更新它们。值得一提的是,尽管这个文件是为 Angular Service Worker 设计的,但对于我们这些开发人员来说,这是一个非常可读的文件。

让我们按照启动本地服务器的命令运行:

npm run prod

导航到localhost:4200并打开你的 Chrome 开发工具。打开应用面板并检查服务工作器。图 4-12 清楚地显示了ngsw-worker.js已经成功安装,以及缓存存储器中不同的缓存是如何创建的。

在下一章中,ngsw manifest 和ngsw-worker将被深入回顾。

img/470914_1_En_4_Fig12_HTML.jpg

图 4-12

ngsw-worker.js已安装,资源已添加到缓存存储中

我们需要像往常一样运行以下命令来部署一个新的构建到 Firebase,并查看我们在设置中的所有工作是如何进行的:

npm run deploy

一旦部署完成,在 Chrome DevTools 中打开审计面板,按运行审计(见图 4-13 ) 记住,我们应该保持本章前面所做的所有设置。

是的,这是真的:图 4-13 所示的 100/100 分已经通过在 Angular 中添加几个步骤实现了,这主要是通过 CLI 完成的。这很好,但是我们还有很多事情要做。

img/470914_1_En_4_Fig13_HTML.jpg

图 4-13

通过 ng CLI 为 PWA 原理图设置 Angular 后,得分为 100

注意

第十三章和第十四章致力于构建一个带有 Workbox 的 PWA,这是一个创建我们的服务工作器和缓存策略的工具。我们的目标是对所有 Angular 应用进行 100% PWA 覆盖,无论其版本如何。因此,如果您的 Angular 版本没有 Angular Service Worker 模块,或者 Angular Service Worker 不符合您的基本要求,请不要担心。你很快就会被覆盖。

摘要

Angular 团队的目标是使 PWA 特性尽可能简单。如您所见,在 Angular 项目中设置这些特性是一个简单的过程。在本章中,我们已经了解了如何使用 Angular CLI 将我们的 Angular 应用转换为 PWA,不仅使用 pwa 原理图,还使用定义的步骤手动再现,同时解释了默认配置。

虽然这个应用得到了 100 分,但这并不意味着我们已经完成了在任何情况下运行我们的应用所需的所有内容。因此,请耐心等待,我们将深入探讨更多配置、设置和高级技术,以满足所有生产就绪型应用的要求。

话虽如此,我鼓励你继续下一章。

五、高级 Angular 服务工作器和运行时缓存

在前一章中,我们实现了 Angular Service Worker,并看到 Angular CLI 帮助我们以最小的努力运行 PWA。基本配置是我们创建带 Angular PWA 之旅的开始。很明显,随着应用的发展,它将需要先进的技术和策略。因此,Angular Service Worker 提供了更多功能来处理各种情况。

在这一章中,我将把配置扩展到一个更高的层次,以便创建一个完全脱机的应用。然而,我们从学习服务工作器中复杂的缓存策略开始,这使我们能够理解 Angular 服务工作器实现的基础。

缓存策略

Service Worker 中有几种处理请求和响应的模式。它因应用而异。根据需要,您可以使用下面几节中讨论的一个或多个策略。

仅缓存

在这种策略中,请求总是在缓存中寻找匹配,并做出相应的响应。这对于“版本化”文件来说是理想的,因为它们应该存在于您的应用中,并且在下一次部署之前被认为是静态的和不变的。通常应用需要运行的所有静态资产,我们在安装事件时缓存它们。图 5-1 是显示其工作原理的简单图示。

img/470914_1_En_5_Fig1_HTML.jpg

图 5-1

仅缓存策略说明

下面的代码片段显示了我们如何使用这个策略。

self.addEventListener("fetch", event => {
  event.respondWith(caches.match(event.request));
});

请注意,如果在缓存中找不到匹配的请求,respond 将看起来像一个连接错误。

仅网络

有些用例没有离线的对等物。假设您有一个股票交易网站,并且总是需要向用户显示最新的汇率。图 5-2 简单展示了其工作原理。

img/470914_1_En_5_Fig2_HTML.jpg

图 5-2

仅网络

self.addEventListener("fetch", event => {
  event.respondWith(fetch(event.request));
});

有可能你没有调用event.respondWith ,,这导致了默认的浏览器行为。

缓存退回到网络或缓存优先

这为您提供了仅缓存和仅网络的组合,其中它尝试匹配来自缓存的请求,如果它不存在,则它退回到从网络获取请求。参见图 5-3 了解其工作原理。

img/470914_1_En_5_Fig3_HTML.jpg

图 5-3

缓存退回到网络或缓存优先

self.addEventListener('fetch', function(event) {
const request = event.request;
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  );
});

我们可以利用这种策略来动态缓存内容。

self.addEventListener("fetch", event => {
const request = event.request;
  event.respondWith(
    caches.match(request).then(res => {
      // Fallback
      return (
        res ||  fetch(request).then(newRes => {
          // Cache fetched response
          caches
            .open(DYNAMIC_CACHE_VERSION)
            .then(cache => cache.put(request, newRes));
// Response can be used once, we need to clone to use it more in the context
          return newRes.clone();
        })
      );
    })
  );
});

请记住,更新缓存的内容将在同一请求的下一次访问中可用。

网络退回到缓存或网络优先

这种策略适用于那些无论应用版本或版本化文件如何都应该更新的资源:例如,在社交媒体中显示最新文章或时间线(见图 5-4 )。最终,最新的内容将显示给我们的在线用户,而在离线模式下,用户将收到内容的旧缓存版本。与前面的策略类似,当网络请求成功时,我们很可能希望更新缓存条目。

img/470914_1_En_5_Fig4_HTML.jpg

图 5-4

网络退回到缓存或网络优先

self.addEventListener("fetch", event => {
const request = event.request;
  event.respondWith(
    fetch(request)
      .then(res => {
        // Cache latest version
        caches
          .open(DYNAMIC_CACHE_VERSION)
          .then(cache => cache.put(request, res));
        return res.clone();
      }) // Fallback to cache
      .catch(err => caches.match(request))
  );
});

然而,在慢速或间歇连接的情况下,用户面临不可接受的和不愉快的体验,因为获取需要非常长的时间;因此,从用户的 Angular 来看,这将是令人沮丧的。如果你正在寻找更好的选择,请看下一个模式。

缓存和网络

其思想是首先向用户显示旧的缓存内容(如果存在的话),然后在网络请求成功时更新 UI。换句话说,您必须在页面中发出两个 fetch 请求,并且在 Service Worker 中,您应该总是使用最新的 fetch 响应来更新缓存。图 5-5 展示了其工作原理。

你在 Twitter 等许多社交媒体平台上看到过这种模式,它们通常会显示旧的缓存内容,然后在时间轴上添加新的内容,并调整滚动位置,以便用户不受干扰。总而言之,这非常适合需要经常更新的内容,比如文章或活动时间表。

虽然这种策略给了我们的用户更好的体验,但它也可能是破坏性的:例如,当用户阅读网站上的内容时。突然间,为了更新用户界面并向他们显示新的数据,一大块内容消失了。因此,重要的是我们要确保用户与应用的互动,永远不要中断,以使它尽可能平稳。请记住,PWA 最重要的目标之一是为我们的用户提供更好的体验。

应用中的代码如下所示:

img/470914_1_En_5_Fig5_HTML.jpg

图 5-5

缓存和网络

const hasFetchData = false;
// fetch fresh data
const freshDataFromNetwork = fetch(YOUR_API)
.then((response) => response.json())
.then((data) => {
  hasFetchData = true;
  showDataInPage();
});

// fetch cached data
caches.match(YOUR_API)
.then((response) => response.json())
.then(function(data) {
  if (!hasFetchData) {
    showDataInPage(data);
  }
})
.catch((e)=>{
// in case if cache is not availble, we hope data is received by network fetch
return freshDataFromNetwork;
})

注意

除了服务工作器之外,缓存 API 在窗口对象和其他工作器中可用。

服务工作器中的代码类似于网络在更新缓存时回退到缓存。

self.addEventListener("fetch", event => {
  const request = event.request;
  event.respondWith(
    caches.open(DYNAMIC_CACHE_VERSION).then(cache => {
      return fetch(request).then(res => {
        cache.put(request, res.clone());
        return res;
      });
    })
  );
});

你可能会问,网络和缓存都失效怎么办?查看下一个模式,了解更多信息。

通用回退

这种模式非常适合于替代那些在缓存和网络中都不可用的请求:例如,当一个用户有一个虚拟角色,而从网络和缓存获取失败时。因此,我们可以简单地用照片占位符替换这个请求。另一个例子是当请求失败时向用户显示一个离线页面。您可以简单地预缓存 offline.html 页面,并在必要时从缓存中进行匹配。图 5-6 说明了它是如何工作的。

img/470914_1_En_5_Fig6_HTML.jpg

图 5-6

通用回退

self.addEventListener("fetch", event => {
  const request = event.request;
  event.respondWith(
    // check with cache first
    caches
      .match(request)
      .then(res => {
        // Fall back to network and if both failes catch error
        return res || fetch(request);
      })
      .catch(() => {
        // If both fail, show a generic fallback:
        return caches.match("/offline.html");
      })
  );
});

在实际的应用中,即使您可以向用户显示脱机替换,您也可能希望将数据存储到 indexedDB 中,并让您的用户知道请求被成功保留并将被同步。我们将在第九章一起回顾离线存储。

注意

很可能在一个应用中使用所有或许多缓存策略取决于我们需要实现什么。评估您的特定用例,然后选择一个适合它的模式。

在我们回顾 Angular 运行时缓存之前,理解 Service Worker 中大多数常见的缓存模式是很重要的。我相信您会对 Angular 缓存策略有更好的理解,因为您知道它们是如何工作的。让我们继续学习 Angular Service Worker 高级配置。

Angular Service Worker 中的运行时缓存

使用ngsw-config.json配置 Angular 维修工人。在 Angular CLI 的帮助下,运行准系统 Angular 应用的默认设置已经就绪。但是随着应用的开发,我们发现需要缓存外部文件、CDN 资源以及从远程 API 调用填充数据。它变得更加复杂,我们希望缓存所有数据或至少部分缓存具有增强的性能、更快的应用和流畅的体验。我的目标是在这一节中介绍应用在数据和外部文件缓存方面的需求。让我们继续。

注意

运行时缓存也可以称为动态内容缓存。其思想是在应用运行时获取或请求数据时缓存数据,而数据在安装事件时尚未存储到缓存中,这被称为预缓存。

外部资源

不同来源或 CDN 上托管字体、JavaScript、样式、图像和其他类型的文件被视为外部资源。无论我们是想预先缓存还是在运行时将它们添加到缓存中,我们都需要在ngsw-config.json中定义它们。必须使用urls键将它们添加到assetGroup中,其中值将为array of Glob, meaning we can also use glob pattern to specify urls。URL 没有经过哈希处理;因此,只要配置发生变化,它们就会更新。如前几章所述,我们在应用中添加了两种字体。

<head>

<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">

  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="manifest" href="manifest.json">
  <meta name="theme-color" content="#1976d2">
</head>

现在我们想缓存这些字体。代码类似于以下内容:

// this is our application ngsw-config.json file
{
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"],
         "urls": [
      https://fonts.googleapis.com/icon?family=Material+Icons,
      https://fonts.googleapis.com/css?family=Roboto:300,400,500,
      https://fonts.gstatic.com/s/roboto/v18/KFOlCnqEu92Fr1MmSU5fCRc4AMP6lbBP.woff2,
      https://fonts.gstatic.com/s/materialicons/v41/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2
             ]
      }
    },
   {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": ["/assets/**"],
      }
    }

这里有一个例子,我们可以添加精确的网址,因为我们已经知道这些网址。然而,并不总是清楚确切的 URL 是什么。因此,我们可以添加一个 glob 模式来缓存 googleapis.com 和 gstatic.com 托管的所有URL,以便动态托管 woff 字体。

{
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"],
      }
    },
   {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": ["/assets/**"],
          "urls": [
        https://fonts.googleapis.com/**,
         https://fonts.gstatic.com/**
             ]
      }
    }

除非另有明确说明,否则模式在配置中使用有限的 glob 格式。

  1. ****** 匹配 0 个或多个路径段

    1. //*。html** 指定所有的 html 文件

    2. //*。js** 指定所有的 js 文件

    3. example.com**/* ***指定主机名匹配的所有请求

  2. ***** 匹配 0 个或更多字符,不包括/

    1. /*。html 仅指定根目录中的 html 文件

    2. /a/folder/*。png 只指定了/a/文件夹/中的 png 文件

  3. 只匹配一个字符,不包括/

    1. /什么?ver.js 指定了根目录下的所有 js 文件,其中第 5 个字符可以是任何东西
  4. 该!前缀的作用是否定的,意味着只有与模式不匹配的文件才会被包含进来。

    1. !//*.地图**排除所有源地图

    2. !/*.pdf 排除根目录中的所有 pdf 文件

注意

urls不支持负 glob 模式和会按字面匹配;这意味着什么?将不匹配除。本身。

运行 build 命令。完成后,导航到/dist文件夹,打开 Angular CLI 基于 ngsw-config.json 生成的ngsw.json

"assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "updateMode": "prefetch",
      "urls": [
        "/0.c570a2562d2874d34dc4.js",
        "/1.71eb2445db7dfda9e415.js",
        "/2.df6bb6a6fde654fe8392.js",
        "/favicon.ico",
        "/index.html",
        "/main.f224c8a2c47bceb8bef0.js",
        "/polyfills.8883ab5d81bf34ae13b1.js",
        "/runtime.e14ed3e6d31ff77728e9.js",
        "/styles.7a3dc1d11e8502df3926.css"
      ],
      "patterns": []
    },
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "urls": [
        "/assets/icons/icon-128x128.png",
        "/assets/icons/icon-144x144.png",
        "/assets/icons/icon-152x152.png",
        "/assets/icons/icon-192x192.png",
        "/assets/icons/icon-384x384.png",
        "/assets/icons/icon-512x512.png",
        "/assets/icons/icon-72x72.png",
        "/assets/icons/icon-96x96.png"
      ],
      "patterns": [
        "https:\\/\\/fonts\\.googleapis\\.com\\/.*",
        "https:\\/\\/fonts\\.gstatic\\.com\\/.*"
      ]
    }
  ],

通过查看生成的ngsw-worker.jsngsw.json,,我们注意到 glob 变成了一个作为 regex 使用的模式。下面是从ngsw-worker.js中提取的class AssetGroup中的一段将模式映射到正则表达式的代码:

            // Patterns in the config are regular expressions disguised as strings. Breathe life into them.
            this.patterns = this.config.patterns.map(pattern => new RegExp(pattern));

在未来,在代码中,它被用作:

// Either the request matches one of the known resource URLs, one of the patterns for
// dynamically matched URLs, or neither. Determine which is the case for this request // in order to decide how to handle it.
if (this.config.urls.indexOf(url) !== -1 || this.patterns.some(pattern => pattern.test(url))) {

添加这些是为了在拦截请求并将其存储在缓存存储中时匹配请求。

无哈希资源的重新验证策略

当哈希存在于缓存中时,某些资源可能没有哈希。Angular 服务工作器将检查该请求有多长时间了,并确保它仍然可用。关于资源重新验证,Angular Service Worker 中应用了三种不同的策略:

  1. 请求有一个Cache-Control头,因此过期需要基于它的年龄。

    • 这为请求和响应中的缓存机制指定了指令。客户端可以在 HTTP 请求中使用标准的缓存控制指令。

      Cache-Control: max-age=<seconds>
      Cache-Control: max-stale[=<seconds>]
      Cache-Control: min-fresh=<seconds>
      Cache-Control: no-cache
      Cache-Control: no-store
      Cache-Control: no-transform
      Cache-Control: only-if-cached
      
      
    • 取决于条件角寻找:最大年龄日期标题。

  2. 请求有一个Expires头,到期时间基于当前时间戳。

    • Expires头包含日期/时间,而无效日期,如值 0,表示资源已经过期。如果响应中有带有"max-age""s-maxage"指令的Cache-Control报头,则 Expires 报头将被忽略。

    • 例如:Expires: Wed, 21 Oct 2019 07:28:00 GMT.

  3. 该请求没有适用的缓存头,必须重新验证。

    • 如果无法评估陈旧性,则假设响应已经陈旧。

因此,将缓存控件添加到您的资源中是一个很好的做法;它不仅有助于浏览器重新验证响应,而且 Angular Service Worker 有助于有效地保持更新。

数据组设置

除了 assetGroups,还有dataGroups.与资产资源不同,本节定义的数据请求独立于应用版本,而 assetGroups 缓存更新策略不同:如果单个资源被更新,我们回收整个版本缓存。它们遵循自己手动配置的策略,这对于处理 API 请求和其他数据依赖等情况非常有用。我们可以使用它们来缓存来自外部服务的响应,以防应用离线。

看了一下DataGroup Typescript 接口,下面的属性揭示了:

export interface DataGroup {
  name: string;
  urls: string[];
  version?: number;
  cacheConfig: {
    maxSize: number;
    maxAge: string;
    timeout?: string;
    strategy?: 'freshness' | 'performance';
  };
}

  1. name:

    (必需)将包含在高速缓存 API 存储名称中的组的名称。它应该是string,描述我们的知识,并且是唯一标识的。

  2. urls:

    (必需)根据此数据策略,用于匹配这些 URL 以进行缓存的 glob 模式列表。与assetGroups类似,不支持负 glob 模式。会完全匹配也就是说。仍将是。性格和什么都不搭。

  3. version:

    (可选)API 中的版本控制很常见。在某种程度上,有时新版本格式将不会与旧 API 向后兼容;因此,现有的缓存包含较旧的格式,可能会破坏应用,因为它与较新的 API 结构不匹配。尽管version是可选的,并且整数字段默认为0,但是它提供了一种机制来指示正在缓存的 API 响应是否已经以向后不兼容的方式进行了更改。因此,存储该 API 响应的所有旧缓存条目必须被丢弃、根除,并用新响应替换。

  4. cacheConfig:

    (required) settings that define the policies and strategies by which matching requests will be cached:

    • maxSize:

      (必选)当缓存打开接受无限数量的响应时,取决于你的 app 大小,它可以迅速增长,最终超过存储配额, 1 调用驱逐。因此,我们可以在这里定义条目或响应的最大数量。

    • maxAge:

      (必需)表示响应在被标记为无效并被逐出之前,允许在缓存中保留多长时间。指定持续时间的字符串,可以设置为d:天、h:小时、m:分、s:秒、u:毫秒。例如, 10d12h4m 将内容缓存长达 10 天半零 4 分钟。

    • timeout:

      (可选)虽然这是一个可选参数,但它告诉 Angular Service Worker 在返回缓存内容之前应该等待网络响应多长时间。当strategy新鲜度时,这是有效的,这意味着网络优先(见下一个属性)。持续时间指定类似于maxAge单位的持续时间。 1d 考虑 1 天。

    • 策略

      (optional) it can have two options for all data resources:

      • performance :

        它指的是缓存优先策略。不经常改变的内容可以属于这种策略,因为它已经针对更快的响应进行了优化。

        它首先检查缓存,如果资源存在,并且根据maxAge它没有过期,则根据maxAge缓存的版本将立即被提供,以换取更好的性能。如果内容过期,它会尝试更新缓存。

        例如,我们有一个端点来检索用户的期望列表。基于我们的 app,我们真的不需要调用这个 API 因此,我们可以设置 1 小时的maxAge和性能策略,以向用户显示更快的响应。

      • freshness :

        这种策略被认为是网络优先的,因为它总是试图只从网络获取数据。根据timeout,如果网络没有相应的响应,请求会退回到缓存中。它适合所有需要频繁更新的数据。

        例如:显示用户积分余额的用户仪表板。

注意

默认情况下。Angular Service Worker 不缓存运行时获取的任何数据或文件。它们必须被明确地定义和配置。

现在是时候配置我们的 Note 应用了。我将使用网络优先的策略,通过** glob 从 Firebase 端点检索注释。我希望将大小设置为 100,最大缓存年龄设置为 10 天零 5 秒,超时后请求将退回到缓存(如果存在)。

为了更好地理解,我将在data.service.ts中创建两个新方法来直接向 Firestore API 发出 GET 请求,并创建另一个方法来获取一个随机的爸爸笑话。新方法看起来像下面的代码:

 // data.service.ts

// DataService

protected readonly FIRESTORE_ENDPOINT =
    'https://firestore.googleapis.com/v1beta1/projects/awesome-apress-pwa/databases/(default)/documents/';
  protected readonly DAD_JOKE = 'https://icanhazdadjoke.com';

// Get a random joke

getRandomDadJoke(): Observable<string> {
    return this.http
      .get<Joke>(this.DAD_JOKE, {
        headers: {
          Accept: 'application/json'
        }
      })
      .pipe(map(data => data.joke));
  }

// Get note Details

getNoteFromDirectApi(id): Observable<any> {
    return this.auth.getToken().pipe(
      switchMap(idToken => {
        return this.http.get(

          `${this.FIRESTORE_ENDPOINT}users/${this.auth.id}/notes/${id}`,
          {
            headers: {
              Authorization: `Bearer ${idToken}`
            }
          }
        );
      }),
      map(notes => this.transfromNote(notes))
    );
  }

// List all notes for current user

initializeNotes(): Observable<any> {
    return this.auth.getToken().pipe(
      switchMap(idToken => {
        return this.http.get(
          `${this.FIRESTORE_ENDPOINT}users/${this.auth.id}/notes`,
          {
            headers: {

              Authorization: `Bearer ${idToken}`
            }
          }
        );
      }),
      map((data: { documents: { fields: {} }[] }) => data.documents),
      map(notes => this.transfromNotes(notes)),
      tap(notes => {
        this.isLoading$.next(false);
      })
    );
  }

  private transfromNotes(notes) {

    return notes.map(note => this.transfromNote(note));
  }

// since I am calling google API directly, a simple transfromationm make it easy to use data in our application

  private transfromNote(note) {
    const _note = {};
    _note['id'] = note.name.split('/').reverse()[0];
    for (const prop in note.fields) {
      if (note.fields[prop]) {
        _note[prop] =
          note.fields[prop]['stringValue'] || note.fields[prop]['integerValue'];
      }
    }
    return _note;
  }

那我就分别把notes-list.component.ts``note-details.component.ts``,中的getNotes()换成initializeNotes(),把getNote()换成getNoteFromDirectApi()。最后,我将在我的app.component.ts.中加入一个笑话

@Component({
  selector: 'app-root',
  template: `
  <div class="appress-pwa-note">
    <app-header></app-header>
    <div class="main">
      <div *ngIf="joke$ | async as joke" class="joke">
      {{ joke }}
      </div>
      <router-outlet></router-outlet>
    </div>
    <app-footer></app-footer>
  </div>
  `,
  styles: [
    `
      .joke {
        margin-top: 0.5rem;
        padding: 1rem;
        border: 1px solid #ccc;
      }
    `
  ]
})
export class AppComponent implements OnInit {
  joke$: Observable<string>;

  constructor(private db: DataService) {}

  ngOnInit() {
    this.joke$ = this.db.getRandomDadJoke();
  }
}

基于我在应用中的策略,我决定使用freshness作为 Firestore Google API 端点,使用performance作为随机笑话端点,因为这不需要被多次调用;每 15 分钟一次应该足够了。相应的配置如下所示:

"dataGroups": [
    {
      "name": "api-network-first",
      "version": 1,
      "urls": ["https://firestore.googleapis.com/v1beta1/**"],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 100,
        "maxAge": "10d",
        "timeout": "5s"
      }
    },
    {
      "name": "api-cache-first",
      "version": 1,
      "urls": ["https://icanhazdadjoke.com"],
      "cacheConfig": {
        "strategy": "performance",
        "maxSize": 20,
        "maxAge": "15m"
      }
    }
  ]

现在,我将构建我的生产就绪应用,并在本地提供服务。

npm run prod

导航至localhost:4200并查看缓存存储和服务工作器选项卡。你会注意到现在我们有了缓存名来存储我们的两个策略,如图 5-7 所示。

img/470914_1_En_5_Fig7_HTML.jpg

图 5-7

运行时缓存

现在花一点时间使用应用,几分钟后,关闭网络,如图 5-8 所示,然后重新加载应用。

img/470914_1_En_5_Fig8_HTML.jpg

图 5-8

选中“脱机”以断开网络连接

惊喜!即使您没有任何网络连接,您看到的所有数据,包括笔记、静态资产和笑话,现在都是可用的。让我们看看网络选项卡中的所有请求。您可能会注意到,在图 5-9 中,没有对笑话端点的请求。

img/470914_1_En_5_Fig9_HTML.jpg

图 5-9

离线模式网络请求

是的,这是正确的,因为我们已经为该端点设置了performance(缓存优先)策略,并且由于maxAge,为 15 分钟,该策略尚未过期,Angular Service Worker 将丢弃该请求,直到其过期,然后将重新验证该请求,并使用适当的响应更新缓存。

导航缓存

在单页面应用中,路由在前端处理。前端中的所有路由最终被指向index.html,其中框架,特别是 Angular 路由器模块,将导航请求匹配到特定视图。

什么使一个请求被认为是导航请求分为三个要点:

  1. 它的模式是导航。

    请求接口的模式只读属性,用于确定跨源请求是否导致有效响应,以及响应的哪些属性是可读的——值为 cors、no-cors、同源或导航。导航是一种支持导航的模式,仅供 HTML 导航使用。只有在文档间导航时,才会创建导航请求。22

  2. 它接受文本/html 响应(由 Accept 头的值决定)。

  3. 它的 URL 符合某些标准,默认为:

    1. URL 不得包含文件扩展名(即)。a . 最后一个路径段。

    2. URL 不得包含 __

看了一下Config界面,你会注意到有一个 Angular 或自定义导航navigationUrls的特定属性。如您所见,这是可选的,使我们能够定制一个 URL 列表。

export interface Config {
  appData?: {};
  index: string;
  assetGroups?: AssetGroup[];
  dataGroups?: DataGroup[];
  navigationUrls?: string[];
}

URL 可以是 URL 数组,也可以是在运行时匹配的类似 glob 的 URL 模式。支持负模式和非负模式。

虽然默认值在大多数情况下已经足够,但有时需要配置不同的规则。假设我们的应用中有一些特定的 URL 需要在后端提供服务,我们需要将它们传递给服务器进行处理,因为它们不是有 Angular 的路由。

如果省略nagivationUrls,默认值将被替换:

  "navigationUrls": [
    "/**",          // Include all URLs.
    "!/**/*.*",           // Exclude URLs to files.
    "!/**/*__*",          // Exclude URLs containing `__` in the last segment.
    "!/**/*__*/**"        // Exclude URLs containing `__` in any other segment.
  ]

结果会是这样的:

"navigationUrls": [
    {
      "positive": true,
      "regex": "^\\/.*$"
    },
    {
      "positive": false,
      "regex": "^\\/(?:.+\\/)?[^/]*\\.[^/]*$"
    },
    {
      "positive": false,
      "regex": "^\\/(?:.+\\/)?[^/]*__[^/]*$"
    },
    {
      "positive": false,
      "regex": "^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$"
    }
  ]

作为一个例子,我将实现一个不需要缓存的路由。

我将生成一个名为NoCacheRouteComponent的组件。

@Component({
  selector: 'app-no-cache-route',
  template: `
    <div class="appress-pwa-note">No-cache</div>
  `
})
export class NoCacheRouteComponent {}

然后我再加一条到app-routing.module.ts的路由。

  {
    path: 'no-cache-route',
    component: NoCacheRouteComponent
  }

最后,我将在ngsw-config.json中排除这个 URL。

  "navigationUrls": [
    "/**",
    "!/**/*.*",
    "!/**/*__*",
    "!/**/*__*/**",
    "!/**/no-cache-route"
  ]

注意

可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter05/03-no-cache-route 。运行npm install然后运行npm run serve:prod。它在网络服务器上运行生产应用。可以导航到localhost:4200

appdata config(appdata config)

此属性也是可选的,可能包含该特定版本的应用元数据。服务工作器不使用appData,但可以在服务器工作人员更新中确定,它可以用于在 UI 通知中显示附加信息,以通知用户或在应用上做出明智的决定。

例如,诸如发布日期、构建散列、指示服务器安全缺陷的标志之类的信息可以在下次重新加载时应用,而不会中断用户。

我将在下一节中使用这个对象,并在后面的其他章节中查看这个配置。

处理更新

通过在我们的应用中实现 Service Worker,与已缓存和使用的应用相比,处理过时版本的应用迟早会成为一个问题,因为新版本的 Service Worker 只会在页面重新加载时激活。Angular Service Worker 通过提供一个SwUpdate类来解决这个问题,这个类可以很容易地检查可用的更新。让我们来看看这个课程:

class SwUpdate {
  available: Observable<UpdateAvailableEvent>
  activated: Observable<UpdateActivatedEvent>
  isEnabled: boolean
  checkForUpdate(): Promise<void>
  activateUpdate(): Promise<void>
}

让我们来分解这个类:

  1. available: an observable that emits UpdateAvailableEvent whenever a new app version is available.

    interface Version {
        hash: string;
        appData?: Object;
    }
    
    interface UpdateAvailableEvent {
      type: 'UPDATE_AVAILABLE';
      current: Version
      available: Version;
    }
    
    

    界面非常简单明了。如您所见,在当前和可用属性中,appData是一个选项,如果我们在ngsw-config.json.中定义它,它将是可用的

    For example:

    {
      "index": "/index.html",
      "appData": {
        "version": "1.0.1"
      },
      "assetGroups": []
    }
    
    
  2. activated:每当应用更新到新版本时发出UpdateActivateEvent的可观察对象。

    interface UpdateActivatedEvent {
        type: 'UPDATE_ACTIVATED';
        previous?: Version;
        current: Version;
    }
    
    
  3. isEnabled:布尔值,用于检查浏览器是否支持服务工作器,是否通过 ServiceWorkerModule 启用。

  4. 当有更新时,这个承诺将被兑现,它允许我们定期检查更新。

  5. 将通过强制服务工作器更新来解决的承诺。我们可能需要在解决此功能后采取其他措施。例如,我们需要重新加载应用,因为当前加载的资源变得无效。

现在是时候在我们的应用中实现了,看看结果如何。

export class AppComponent implements OnInit {
  joke$: Observable<string>;

  constructor(private db: DataService, private swUpdates: SwUpdate, private snackbar: SnackBarService) {}

  ngOnInit() {
    this.joke$ = this.db.getRandomDadJoke();
    this.swUpdateFlow();
  }

  swUpdateFlow() {
    // check if service worker is enabled and only check if it's production
    if (this.swUpdates.isEnabled && environment.production) {
      // subscribe to recieve update when it's available
      this.swUpdates.available.subscribe((event: UpdateAvailableEvent) => {
        // console log version on appData Object defined in ngsw-config.js
        console.log(`Version: ${event.current.appData['version']}`);

        // an update is available, inform user

and take an action
        this.snackbar
          .action(
            `${event.type}: current is ${event.current.hash} but available is ${event.available.hash}`,
            'Activate'
          )
          .subscribe(() => {
            // force to activate update
            this.swUpdates
              .activateUpdate()
              .then(() => {
                this.snackbar.open('Update has been applied', 1000);
                // force to reload to ensure new update is in place
                // (<any>window).location.reload();
              })
              .catch(e => {
                this.snackbar.open('Something is wrong, please reload manually');
              });
          });
      });

      // subscribe to receive an notification when new version is activated
      this.swUpdates.activated.subscribe((event: UpdateActivatedEvent) => {
        // console log version on appData Object defined in ngsw-config.js
        console.log(`Version: ${event.current.appData['version']}`);

        this.snackbar
          .action(`${event.type}, current is ${event.current.hash} but previously was ${event.previous.hash}`, 'Reload')
          .subscribe(() => {
            // force to reload to ensure new update is in place
            (<any>window).location.reload();
          });
      });
    }
  }
}

app.component.ts,我会先注射SwUpdate。然后,我将确保在生产和服务工作人员上运行代码。我将订阅可用的 observables,一旦有更新可用,我将显示 snackbar 通知,并通知用户有一个新版本的应用可用,并要求他们重新加载页面,以便查看应用的最新版本。

注意

可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter05/04-notification-updates 。运行npm install然后运行npm run serve:prod。它在网络服务器上运行一个生产应用。您可以导航到localhost:4200.

部署到火力基地

现在我们已经准备好构建我们的应用并部署到 Firebase。像往常一样,只需运行:

npm run deploy

✓  hosting[awesome-apress-pwa]: file upload complete
i  database: releasing rules...
✓  database: rules for database awesome-apress-pwa released successfully
i  hosting[awesome-apress-pwa]: finalizing version...
✓  hosting[awesome-apress-pwa]: version finalized
i  hosting[awesome-apress-pwa]: releasing new version...
✓  hosting[awesome-apress-pwa]: release complete

✓  Deploy complete!

Project Console: https://console.firebase.google.com/project/awesome-apress-pwa/overview
Hosting URL: https://awesome-apress-pwa.firebaseapp.com

让我们导航到该网站,并检查服务工作器。如图 5-10 所示,已经安装并激活了新的服务工作器,并且创建了新的缓存。

img/470914_1_En_5_Fig10_HTML.jpg

图 5-10

成功部署到 Firebase

注意

可以下拉 www.github.com/mhadaily/awesome-apress-pwa/chapter05/02-runtime-cache 。运行npm install然后运行npm run serve:prod。它在网络服务器上运行一个生产应用。可以导航到localhost:4200。您可能还需要将这段代码部署到 Firebase,以便在添加服务工作器之前评估您的应用。

摘要

在前两章中,我深入研究了 Angular Service Worker 的配置和设置,为我们的应用实现了最佳策略,并部署了一个离线就绪的应用。尽管我们的应用独立于 connection 工作,但仍有许多增强用户体验的可能性。

在下一章中,我们将详细了解应用清单,它使我们的应用可以安装在用户可以从主屏幕运行我们的应用的地方。

六、应用清单和可安装的 Angular 应用

到目前为止,我们已经关注了渐进式 Web 应用(PWA)的核心特性,即服务工作器。它使我们能够缓存静态资产和动态内容。该应用将继续离线工作,这在移动设备上尤为重要。然而,一个应用的“外观和感觉”是另一个重要因素,它可以增强用户体验,真正让用户高兴。

在这一章中,我们重点关注视觉吸引力,以及一些有助于提高应用参与度的不同方式。我们探讨了添加到主屏幕和定制等功能,这些功能会提示用户将网络添加到他们的设备主屏幕。

Web 应用清单

Web 应用清单是一个遵循 Web 应用清单规范的 JSON 文本文件,它提供了有关应用的信息,如名称、作者、图标和描述。但更重要的是,这个文件允许用户在他们的设备上安装应用,并允许我们修改主题、应该打开的 URL、闪屏、主页上的图标等等。

让我们来看看 Angular CLI 默认创建的位于/src/,中的manifest.json

{
  "name": "lovely-offline",
  "short_name": "ApressPWA",
  "theme_color": "#1976d2",
  "background_color": "#fafafa",
  "display": "standalone",
  "scope": "/",
  "start_url": "/",
  "icons": [
    {
      "src": "assets/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "assets/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "assets/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "assets/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "assets/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "assets/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "assets/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "assets/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

大多数属性都是不言自明的,但是我将尝试提供描述性的定义。清单文件中的每个属性都有一个角色,并告诉浏览器关于我们应用的外观和感觉的信息。尽管 Angular CLI 添加的默认manifest.json对于大多数用例来说应该没问题,但是我们可以添加更多属性来更好地增强用户体验,这取决于我们的需求和要求。

让我们来分解一下:

  • name:

    向用户显示或作为图标标签的应用的可读名称。

  • short_name:

    如果由于空间不足而不合适,替换名称的短名称。

  • theme_color:

    定义应用的默认主题颜色,以给操作系统或浏览器相关的用户界面着色:例如,浏览器的工具栏或 Android 的任务切换器。可以使用十六进制代码或颜色名称。

  • background_color:

    甚至在用户代理加载网站样式表之前,定义应用的预期背景颜色。通常,在启动 web 应用和加载站点内容之间会有一个短暂的间隔。这将创建一个平滑的过渡来填充延迟。您可以使用颜色十六进制代码或标准颜色的名称。请注意,在样式表可用之后,用户代理将不会使用这个背景。

  • display:

    网站的首选显示模式。根据规范,有四个选项可用,但并非在所有浏览器中都可用:

    fullscreen:使用所有可用的显示。如果不支持,则退回到standalone模式。

    大多数浏览器元素都是隐藏的。感觉像一个独立的应用。在这种模式下,用户代理将排除用于控制导航的 UI 元素,但可以包括其他 UI 元素,如状态栏。如果不支持,则退回到minimal-ui模式。

    这个应用看起来像一个独立的应用;但是,浏览器的基本用户界面仍然可见,如导航按钮。不支持,退回到browser模式。

    常规的浏览器标签或新窗口。

    It’s interesting to know what there is a feature in CSS where you can detect display-mode. See code below:

    @media all and (display-mode: minimal-ui) {
      /* ... */
    }
    @media all and (display-mode: standalone) {
      /* ... */
    }
    
    
  • scope:

    或多或少类似于定义该网站上下文导航范围的服务工作器范围。如果页面在此范围之外,它会返回到浏览器选项卡/窗口内的正常网页。对于相对 URL,基本 URL 将是清单的 URL。如果省略,默认为清单目录和所有子目录下的所有内容。

  • start_url:

    用户启动应用时加载的 URL。它可以不同于主页,例如,如果您希望您的 PWA 用户直接进入登录页面或注册页面,而不是主页。出于分析目的,可以精心制作start_url来指示应用是从浏览器外部启动的,这可以转换为 PWA。也就是:"start_url": "/?launcher=homescreen"

  • icons:

    一组图像文件,根据上下文来指定应用图标。每个图像都有三个属性:

    src:图像文件的路径;对于相对 URL,基本 URL 将是清单的 URL。

    sizes:指定图标尺寸(甚至是包含空格分隔的图像尺寸的多个尺寸)。我们应该支持各种不同的屏幕尺寸;我们包含的维度越多,图标的质量就越好。

    type:图像的媒体类型1;如果用户代理不支持这种类型,他们可以很快忽略它。

  • prefer_related_applications:

    这要求浏览器通过 PWA 向用户指示在下一个属性中推荐的指定本机应用。虽然这听起来可能很傻,但有时我们有一个非常具体的原生功能,而这在网络上并不存在,所以我们希望我们的用户使用原生应用来代替。如果省略,默认值为false

  • related_applications:

    recommended native applications that are installable or accessible from underlying platform store. For example, link to an Android app from Google Play Store. The objects may contain platform, url, and id.

    {
        "platform": "play",
        "url": "https://play.google.com/store/apps/details?id=com.example.app1",
        "id": "com.example.app1"
      }, {
        "platform": "itunes",
        "url": "https://itunes.apple.com/app/example-app1/id123456789"
      }
    
    
  • orientation:

    sets the app work on default orientation. Orientation may be one of the following values:

    any, natural, landscape, landscape-primary, landscape-secondary
    portrait, portrait-primary, portrait-secondary
    
    
  • dir:

    指定nameshort_namedescription的主要文本方向。有两个值:ltr, auto,rtl。省略该值时,默认为auto

  • lang:

    dir一起指定正确的显示语言。默认是en-US. 2

  • description:

    网站功能的一般描述。

  • serviceWorker:

    this member represents an intended service worker registration in form of a registration object.

    "serviceworker": {
      "src": "sw.js",
      "scope": "/foo",
      "update_via_cache": "none"
    }
    
    

此功能可能无法在任何浏览器中运行。

  • categories:

    以小写形式指定 web 应用所属的预期应用类别的字符串数组。

  • screenshots:

    在常见使用场景中表示 web 应用的图像资源数组。这可能还不能在任何浏览器或平台上运行。

  • iarc_rating_id:

    代表国际年龄分级联盟(IARC) 3 的 web 应用的认证代码。

为了引用清单文件,我们需要在 web 应用的所有页面的head之间添加一个link标签。然而,我们有一个带有 Angular 的单页面应用,ng-cli已经添加了到index.htmlangular.json的链接,以便在构建后将这个文件复制到根文件夹中。

// index.html where we added manifest.json link.
<head>
.
.
  <base href="/">
  <link rel="manifest" href="manifest.json">
.
.
 </head>

调试 Web 应用清单

现在我已经介绍了 Web 应用清单并引用了 index HTML 页面,我们应该能够运行一个应用,然后在Chrome中导航到该应用。在DevTools,中,转到Application选项卡,点击左侧Service Workers正上方的manifest选项(见图 6-1 )。

详细信息显示在那里,包括错误(如果有)。还有一个选项可以测试将应用添加到主屏幕的提示。

img/470914_1_En_6_Fig1_HTML.jpg

图 6-1

DevTools 中的应用清单详细信息

尽管 Chrome DevTools 可以很好地调试您的清单文件,但是您可以使用一些工具来验证您的清单文件是否符合 W3C 规范。一个例子是 manifest-validator.appspot.com,在那里你可以简单地审计你的清单文件。图 6-2 显示了 Web 清单验证器的屏幕截图。

img/470914_1_En_6_Fig2_HTML.jpg

图 6-2

Web 清单验证器是一个可以调试清单文件的工具

添加到主屏幕

默认情况下,本机应用会安装在您的主屏幕上。你会看到一个图标和短名称,当你需要运行这个应用时,很容易回到主屏幕,点击图标打开应用。作为网页开发者,吸引用户并让他们继续使用我们的应用是非常重要的。因此,作为原生应用的功能是解决参与度的难题之一。让我们的用户无缝地将我们的 web 应用添加到他们的主屏幕的一个很好的方法是添加到主屏幕(你可能会看到 A2HS)功能,也称为 web 应用安装横幅。

该功能使得在移动或桌面设备上安装 PWA 变得容易。它会显示一个提示,用户接受后,你的 PWA 会被添加到他们的启动器或主屏幕上。它将像任何其他已安装的应用一样运行,并且看起来与原生应用相似。

但是,除非满足以下条件,否则不会显示 web 应用安装横幅提示:

  1. 在 HTTPS 上空服务(这是 PWA 的核心概念之一,也是服务工作器的要求)。

  2. Web 应用清单必须包括:

    1. short_namename

    2. icons必须包括一个 192 像素和一个 512 像素大小的图标

    3. start_url必须有适当的值

    4. 显示必须是下列之一:fullscreen, standalone, or minimal-ui

  3. web 应用尚未安装。

  4. 适当的用户参与启发式。

    这一项可能会随着时间的推移而改变,所以你应该随时了解最新的消息,并不时查看不同浏览器的标准列表。在写这本书的时候,一个用户必须和这个域进行至少 30 秒的交互。

  5. App 有一个带有fetch事件处理程序的注册服务工作器。

虽然这个列表有点不稳定,并且经常更新,但是如果满足这些标准,Google Chrome 将触发一个名为beforeinstallprompt的事件,我们应该使用它来向用户显示提示。关注不同的浏览器,查看最新消息,看看它们是否支持这个事件或类似的事件。

虽然 Safari 不支持自动添加到主屏幕提示或beforeinstsallprompt事件,但手动添加到主屏幕是通过轻按共享按钮来显示的,即使它的行为与其他浏览器略有不同。我希望当你读到这本书时,Safari 和所有其他浏览器都将支持这一功能的自动版本。

注意

Chrome 67 和更早的版本显示了“添加到主屏幕”的横幅。它在 Chrome 68 中被删除,如果收听beforeinstallprompt并且用户点击具有正确手势事件的元素,将会显示一个对话框。

处理安装事件(推迟提示)

正如我们已经看到的,当满足所有标准时,beforeinstallprompt事件在window对象上触发。监听该事件以指示应用何时可安装是至关重要的,我们需要相应地对 web 应用采取行动,以显示适当的 UI 来通知我们的用户,他们可以在主屏幕上安装该应用。

虽然添加到主屏幕是我们的主要目标,但此事件也可用于其他目的,例如:

  1. 将用户选择发送到我们的分析系统。

  2. 推迟显示通知,直到我们确定这是显示哪个用户将点击或点击的最佳时间。

为了保存已经触发的事件,我们需要编写如下代码:

let deferredPrompt;

window.addEventListener('beforeinstallprompt', event => {

  // Prevent automatically showing the prompt if browser still supports it
  event.preventDefault();

  // Stash the event so it can be triggered later.
  deferredPrompt = event;

  // This is time to update UI, notify the user they can install app to home screen
  const button = document.getElementById('add-to-home-screen-button');
  button.style.display = 'block';

  button.addEventListner('click', () => {
    if (deferredPrompt) {
      // will show prompt
      deferredPrompt.prompt();

      // Wait for the user to respond to the prompt

      deferredPrompt.userChoice.then(choiceResult => {
        // outcome is either "accepted" or "dismissed"
        if (choiceResult.outcome === 'accepted') {
          // User accepted the A2HS prompt
          // send data to analytics
          // do whatever you want
        } else {
          // User dismissed the A2HS prompt
          // send data to analytics
          // do whatever you want
        }

         // we don't need this event anymore
        deferredPrompt = null;
        // delete or hide this button as it's not needed anymore
        button.style.display = 'none';
      });
    }
  });

});

只能对延迟事件调用一次prompt()。如果用户不理会它,我们需要等到浏览器在下一个页面导航中触发beforeinstallprompt事件。

迷你信息栏

在撰写本书时,mini-info bar是 Android 上 Chrome 的临时体验;它正朝着创建跨所有平台的一致体验的方向发展,包括 omnibox 中的安装按钮,如图 6-3 所示。

img/470914_1_En_6_Fig3_HTML.jpg

图 6-3

Android 上谷歌 Chrome 浏览器的迷你信息栏 4

这是 Chrome UI 组件,我们无法控制它。一旦被用户取消,它将不会再次出现,直到足够长的时间。不管beforeinstallprompt事件上的preventDefault(),如果网站满足以上所有标准,这个迷你吧就会出现。

这种实验功能在未来可能是可控的或完全根除的。

在 Angular App 中实现功能

现在让我们在 Angular sample 项目中实现上面的代码。首先创建一个名为AddToHomeScreenService的服务,并将其导入到CoreModule.

该服务将保存提示事件,并将根据模块共享该事件。

@Injectable({
  providedIn: 'root'
})
export class AddToHomeScreenService {
  public deferredPromptFired$ = new BehaviorSubject<boolean>(false);
  public deferredPrompt;

  get deferredPromptFired() {
    this.deferredPromptFired$.next(!!this.deferredPrompt);
    return this.deferredPromptFired$;
  }

  public showPrompt() {
    if (this.deferredPrompt) {
      // will show prompt
      this.deferredPrompt.prompt();

      // Wait for the user to respond to the prompt
      this.deferredPrompt.userChoice.then(choiceResult => {
        // outcome is either "accepted" or "dismissed"
        if (choiceResult.outcome === 'accepted') {
          // User accepted the A2HS prompt
          // send data to analytics
          // do whatever you want
          this.sendToAnalytics(choiceResult.userChoice);
        } else {
          // User dismissed the A2HS prompt

          // send data to analytics
          // do whatever you want
          this.sendToAnalytics(choiceResult.userChoice);
        }

        // we don't need this event anymore
        this.deferredPrompt = null;
        this.deferredPromptFired$.next(false);
      });
    }
  }

  public sendToAnalytics(userChoice) {
    // for example, send data to Google Analytics
    console.log(userChoice);
  }
}

app.component.ts文件中,通过添加@HostListener,我们将监听一个beforeinstallprompt事件,通过注入AddToHomeScreenService,我们可以访问deferredPrompt,,这有助于保持我们的事件对象。

export class AppComponent implements OnInit {
  joke$: Observable<string>;

  @HostListener('window:beforeinstallprompt', ['$event'])
  onEventFire(e) {
    this.a2hs.deferredPrompt = e;
  }

  constructor(
 private db: DataService,
 private a2hs: AddToHomeScreenService
) {}

  ngOnInit() {
    this.joke$ = this.db.getRandomDadJoke();
  }
}

接下来,我决定在 notes list 页面上向我的用户显示一个通知框。我认为这是询问用户是否愿意安装该应用的最佳位置,因为他们已经从该应用中受益,他们很可能会接受提示。因此,最好不要用不想要的提示或通知打扰用户,而是在有意义的时候询问他们。

AddToHomeScreenService被注入到NotesListComponent中,并相应地创建了 UI。

export class NotesListComponent implements OnInit {

       isAddToHomeScreenEnabled$;

         constructor(private db: DataService,
       private a2hs: AddToHomeScreenService) {}

         ngOnInit() {
           // this.notes$ = this.db.getNotes();
           this.notes$ = this.db.initializeNotes();
           this.isDbLoading$ = this.db.isLoading$;
           this.isAddToHomeScreenEnabled$ = this.a2hs.deferredPromptFired;
         }
}

在 notes-list.component.html 文件中,在页面的顶部,我会添加一个简单的卡片,询问用户是否愿意在提示准备好的时候与它进行交互。

<mat-card *ngIf="isAddToHomeScreenEnabled$ | async">

  <mat-card-subtitle>Add To Home Screen</mat-card-subtitle>
  <mat-card-content>
    Do you know you can install this app on your homescreen?
    <button mat-raised-button color="primary" (click)="showPrompt()">Show me</button>
  </mat-card-content>

</mat-card>

<div *ngIf="notes$ | async as notes; else notFound">
  <app-note-card *ngFor="let note of notes" [note]="note" [loading]="isDbLoading$ | async" [routerLink]="['/notes', note.id]">
  </app-note-card>
</div>
<ng-template #notFound>
  <mat-card>
    <mat-card-title>
      Either you have no notes
    </mat-card-title>
  </mat-card>
</ng-template>

将所有这些放在一起,构建一个用于生产的应用,然后部署到 Firebase。

添加到手机和桌面的主屏幕

现在我们已经实现了所有的标准,是时候在移动和桌面上测试它了。由于谷歌 Chrome 为安装应用提供了最好的支持,你可能会问,当用户接受提示时,谷歌 Chrome 实际上做了什么?

Chrome 为我们处理了大部分繁重的工作:

  1. 手机:

    Chrome 将生成一个 WebAPK, 5 ,为用户带来更好的综合体验。

  2. 桌面:

    您的应用已安装,并将在 Mac 和 Windows 机器上的应用窗口 6 中运行。

注意

要在 Mac 上测试桌面 PWA 的安装流程,您需要在 Google Chrome 中启用#enable-desktop-pwas 标志。可能是以后或者你在看这本书的时候默认的。

让我们看看这在 Mac 和 Android 手机上的运行情况,如图 6-4 所示。

img/470914_1_En_6_Fig4_HTML.jpg

图 6-4

Android 和 Mac 上的 Chrome 一旦触发beforeinstallprompt,就会显示通知

当点击按钮显示提示时,会出现浏览器对话框提示(见图 6-5 )。

img/470914_1_En_6_Fig5_HTML.jpg

图 6-5

Mac 上 Chrome 中的提示对话框

点击“安装”后,该应用将安装在 Chrome Apps 文件夹中,并可作为独立应用使用(见图 6-6 )。这个功能在 Windows 10 上也有。

img/470914_1_En_6_Fig6_HTML.jpg

图 6-6

PWA 安装在 Mac 上的 Chrome 应用中

微软视窗 7

边缘的 PWA 是一等公民。一旦 PWA 通过微软商店发布,拥有 6 亿多月活跃用户的整个 Windows 10 安装群就是你的潜在应用受众!

有趣的是,当 pwa 在 Windows 10 中时,它们作为通用 Windows 平台应用运行,并将获得以下技术优势:

  • 独立窗口

  • 独立于浏览器的进程(隔离缓存,开销更少)

  • 没有存储配额(用于索引数据库、本地存储等。)

  • 离线和后台进程通过 JavaScript 访问本机 Windows 运行时(WinRT)API

  • 出现在“应用”上下文中,如 Windows 开始菜单和 Cortana 搜索结果

最大的特性之一是能够访问 WinRT APIs。这只是确定您需要使用什么,获得必要的权限,并使用特性检测在支持的环境中调用该 API 的问题(参见图 6-7 )。让我们看一个例子:

img/470914_1_En_6_Fig7_HTML.jpg

图 6-7

Microsoft Edge 和 Windows 应用上的上下文菜单

if (window.Windows && Windows.UI.Popups) {
    document.addEventListener('contextmenu', function (e) {

        // Build the context menu
        var menu = new Windows.UI.Popups.PopupMenu();
        menu.commands.append(new Windows.UI.Popups.UICommand("Option 1", null, 1));
        menu.commands.append(new Windows.UI.Popups.UICommandSeparator);
        menu.commands.append(new Windows.UI.Popups.UICommand("Option 2", null, 2));

        // Convert from webpage to WinRT coordinates
        function pageToWinRT(pageX, pageY) {
            var zoomFactor = document.documentElement.msContentZoomFactor;
            return {
                x: (pageX - window.pageXOffset) * zoomFactor,
                y: (pageY - window.pageYOffset) * zoomFactor
            };
        }

        // When the menu is invoked, execute the requested command
        menu.showAsync(pageToWinRT(e.pageX, e.pageY)).done(function (invokedCommand) {
            if (invokedCommand !== null) {
                switch (invokedCommand.id) {
                    case 1:
                        console.log('Option 1 selected');
                        // Invoke code for option 1
                        break;
                    case 2:
                        console.log('Option 2 selected');
                        // Invoke code for option 2
                        break;
                    default:
                        break;
                }
            } else {
                // The command is null if no command was invoked.
                console.log("Context menu dismissed");
            }
        });
    }, false);
}

安卓和 Chrome

Android 中 Chrome 的 Flow 类似。beforeinstallprompt事件被触发。一旦我们点击我们实现的按钮,对话框将会显示(见图 6-8 )。

img/470914_1_En_6_Fig8_HTML.jpg

图 6-8

向用户安装应用通知并添加到主屏幕对话框

一旦用户接受安装,应用图标和short_name将被放置在主屏幕中其他本地应用图标的旁边,如图 6-9 所示。

注意

三星互联网浏览器的行为与 Chrome 相似,但反应略有不同。

当你点击打开应用时,没有浏览器 chrome(导航按钮、地址栏、菜单选项等。)在全屏选项下可见,你会注意到顶部的状态栏采用了我们在应用中配置的theme_color,(见图 6-10 )。

img/470914_1_En_6_Fig10_HTML.jpg

图 6-10

打开后,PWA 看起来类似于本机应用

img/470914_1_En_6_Fig9_HTML.jpg

图 6-9

应用安装在主屏幕上,一旦点击打开,带有配置背景和图标的闪屏显示

When you tap to open the app, no browser

手动添加到主屏幕

不保证总是触发对话框提示。因此,有可能手动将 PWA 添加到主屏幕。Safari iOS 上也有这个功能。

在 Chrome 中,如果你点击浏览器右上角的菜单上下文菜单,你会看到菜单选项,你可以找到添加到主屏幕,点击它,一个提示对话框 UI 出现。

在 Safari 中,“添加到主屏幕”功能隐藏在“共享”按钮下。你应该明确点击共享,然后你会发现添加到主屏幕,如图 6-11 所示。然而,Safari 并不完全遵循 Web 应用清单规范,将来可能会有所改变——希望是在你阅读这本书的时候。

img/470914_1_En_6_Fig11_HTML.jpg

图 6-11

Safari 和 Chrome 上都有“添加到主屏幕”按钮

进一步增强

在不支持 web 清单的 Apple 和 Microsoft 中,有一些标签可用于改进 UI。我将它们添加到head标签之间的index.html。尽管这是一个微小的改进,但我们仍然在逐步增强我们的用户体验,这是我们在 PWA 中的目标。

  <!-- Enhancement for Safari-->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
  <meta name="apple-mobile-web-app-title" content="ApressNote">
  <link rel="apple-touch-startup-image" href="/assets/icons/icon-512x512.png">
  <link rel="apple-touch-icon" sizes="57x57" href="/assets/icons/icon-96x96.png">
  <link rel="apple-touch-icon" sizes="76x76" href="/assets/icons/icon-72x72.png">
  <link rel="apple-touch-icon" sizes="114x114" href="/assets/icons/icon-114x114.png">
  <link rel="apple-touch-icon" sizes="167x167" href="/assets/icons/apple-icon-384x384.png">
  <link rel="apple-touch-icon" sizes="152x152" href="/assets/icons/apple-icon-152x152.png">
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-icon-384x384.png">
  <link rel="apple-touch-icon" sizes="192x192" href="/assets/icons/icon-192x192.png">
  <!-- Tile icon for Win8 (144x144 + tile color) -->
  <meta name="msapplication-TileImage" content="/asseimg/icon-144x144.png">
  <meta name="msapplication-TileColor" content="#3372DF">
  <meta name="msapplication-starturl" content="/">
  <meta name="application-name" content="ApressPWA">

  <!-- Mobile specific browser color -->
  <meta name="theme-color" content="#3f51b5">

apple-mobile-we b-app-capable:如果设置为 yes,其行为类似于全屏显示模式。我们使用 Safari 中的window . navigator . standalone来确定网页是否以全屏模式显示。

apple-mobile-we B- app-status-bar-style:这个 meta 标签没有任何作用,除非你首先按照中的描述指定全屏模式。如果内容设置为默认,状态栏显示正常。如果设置为黑色,状态栏背景为黑色。如果设置为黑色半透明,状态栏为黑色半透明。如果设置为默认或黑色,web 内容将显示在状态栏下方。如果设置为黑色半透明,web 内容会显示在整个屏幕上,部分内容会被状态栏遮住。默认值为 default。

apple-touch-startup-image:指定 web 应用启动时显示的启动屏幕图像。默认情况下,使用 web 应用上次启动时的屏幕截图。

apple-mobile-we b-app-title:指定启动图标的 web 应用标题。默认情况下,使用<标题>标签。

apple-touch-icon:8指定一个图标,代表用户可能想要添加到主屏幕的网络应用或网页。这些由图标表示的链接称为 Web 剪辑。

应用名称 : 9 默认名称用钉住的站点磁贴(或图标)显示。

msapplication-starturl :被钉住站点的根 url,类似于 web 清单中的start_url

msapplication-TileColor :设置动态磁贴的背景色。

msapplication-TileImage :指定实时平铺背景图像中所需图像的 URI。

虽然您可以自己手动添加所有的增强功能,但 Google Chrome 团队有一个库可以帮助您自动缓解这个问题。

PWACompat 库 10

PWAcompat 是一个库,它将 Web 应用清单提供给不兼容的浏览器,以获得更好的 PWAs 你可以使用 PWACompat 库,我们将通过遗留的 HTML 标签为图标和主题填充空白,以便在大多数浏览器中获得更广泛的支持。基本上,您只需要在页面中包含库脚本,就大功告成了!

<link rel="manifest" href="manifest.json" />
<script async src="https://cdn.jsdelivr.net/npm/pwacompat@2.0.7/pwacompat.min.js"></script>

这个库实际上做的是更新你的页面和以下内容:

  • 为清单中的所有图标创建元图标标签(例如,对于收藏夹图标、旧浏览器)

  • 为各种浏览器(如 iOS、WebKit/Chromium forks 等)创建后备元标签。)描述 PWA 应该如何打开

  • 根据清单设置主题颜色

对于 Safari,PWACompat 还:

  • 将 apple-mobile-web-app-capable(不使用浏览器 chrome 打开)设置为独立、全屏或最小 ui 显示模式

  • 创建苹果触摸图标图像,将清单背景添加到透明图标:否则,iOS 会将透明度渲染为黑色

  • 创建动态的闪屏图像,与基于 Chromium 的浏览器生成的闪屏图像非常相似

对于可以访问 UWP API 的 Windows 上的 pwa:

  • 设置标题栏的颜色

请关注库,查看最新版本和功能。

摘要

高级缓存和添加到主屏幕已经实现。我们离原生应用又近了一步。在下一章中,我们将提高 Angular 性能,并在 App Shell 上工作,以将我们的应用提升到一个新的水平。

七、App 外壳和 Angular 性能

没有人喜欢等很久才看到应用正在加载。事实上,统计数据显示,如果初始渲染时间超过三秒,用户很可能会离开我们的应用。PWAs 的主要基本原则之一是要快。在原生应用中,用户通常会看到一个闪屏,过一段时间后就会看到主要内容和框架。另一方面,在引导完成之前会出现白屏,尤其是单页应用。

在本章中,我们将回顾应用外壳模型,以了解它是什么以及它是如何工作的。然后,我们将设置 Angular CLI 来帮助我们生成 Angular 应用外壳。最后,我们将超越应用外壳,优化 Angular 应用,以实现更好的性能。

应用外壳模型

引入该模型是为了构建一个 PWA,它可以可靠地即时加载并提升用户感知的启动性能,就像他们在本地应用中看到的一样。

应用“外壳”是用户界面所需的最少的 HTML、CSS 和 JavaScript,以便在加载应用时看到有意义的内容。我们可以尽快想到他们应该在折叠内容或主骨架上方看到什么。它可以脱机缓存,应该立即加载,并且在用户重复访问时必须具有可靠的性能。换句话说,每次用户访问应用时,都不会从网络加载应用外壳。

你可能会问,那内容呢?在这种情况下,如有必要,从网络请求内容。这种架构可能不适用于所有场景和应用;然而,它一直是 Angular 应用的首选方法,这种应用通常被认为是单页面应用。

如图 7-1 和 7-2 所示,应用外壳类似于本地应用框架,是启动应用并向用户显示初始 UI 所必需的;但是,它不包含数据。因此,我们可以简单地将它打包并发布到应用商店。这种架构不仅有助于模拟类似本机的应用并快速加载,而且从经济的 Angular 来看,将保存我们缓存的数据,并在重复访问时重新加载缓存。

img/470914_1_En_7_Fig2_HTML.jpg

图 7-2

动态内容

img/470914_1_En_7_Fig1_HTML.jpg

图 7-1

应用外壳

在第四章中,我们在技术上缓存了我们的应用外壳,甚至设法在第五章中缓存了我们的部分动态内容,这也提升了我们的用户体验。

Angular 的 App 外壳

Angular 中的应用外壳概念包含两个含义:“预缓存应用的 UI”和“在构建时预呈现 UI”一般来说,同时使用缓存和预渲染 ui 可以创建一个有 Angular 的应用外壳。

尽管我们已经缓存了静态资产,其中包括应用外壳需求,但直到 Angular 被引导后才会向用户显示。我们向用户展示有意义内容的时间是 JavaScript 文件被解析和执行的时候;因此,Angular 应用已经启动。正如我们所知,我们在index.html中引用我们的 JavaScript 文件;因此,在下载文件之前,用户首先点击这个文件。

在低性能的应用中,尤其是在首次访问时,看到应用内容和黑屏之间有一段时间,这基本上是我们的index.html,没有任何元素。

Angular CLI 有一个内置功能,可以帮助我们在构建时自动生成应用外壳。在我们继续之前,让我们看看在/dist文件夹中为 prod 构建之后index.html包含了什么。打开您的项目并为 prod 构建,或者如果您已经为这本书克隆了存储库,只需将您的目录更改为chapter07,然后更改为02-app-shell;最后,运行以下命令:

npm run build:prod

如果我们比较来自src文件夹和dist文件夹的index.html,我们注意到我们只看到 JS 文件和 CSS 文件被注入到这个文件中。

<!doctype html>
<html lang="en">

<head>
 ...
  <link rel="stylesheet" href="styles.c418d0a7774195ac83e5.css">
</head>

<body>
  <app-root></app-root>
  <noscript>Please enable JavaScript to continue using this application.</noscript>
  <script type="text/javascript" src="runtime.3d4490af672566f1a0de.js"></script>
  <script type="text/javascript" src="polyfills.c53b1132b0de9f2601bd.js"></script>
  <script type="text/javascript" src="main.a136972022b8598085fb.js"></script>
</body>

</html>

我想在构建后测量应用的启动性能。你可以运行ng serve --prod或者在构建之后运行一个本地服务器来运行应用。如果您仍然在这本书的项目库中,只需运行npm run prod然后遵循以下步骤:

  1. 打开一个新的浏览器,可能隐姓埋名,我们确保没有缓存。

  2. 在 Chrome 中打开 DevTools,选择选项卡 performance。

  3. Open capture setting and select Fast 3G for Network and 4x Slowdown for CPU; this is typically when we want to simulate throttling for a mobile (Figure 7-3).

    img/470914_1_En_7_Fig3_HTML.jpg

    图 7-3

    打开捕捉设置,选择快速 3G 和 4x 减速

  4. 点击记录并按回车键加载网站,或点击性能选项卡中的重新加载图标简单地重新加载页面。

正如您在图 7-4 中所看到的,浏览器在大约 2000 毫秒时呈现页面,而第一次绘制尝试已经开始了大约 500 毫秒,但是因为没有内容和任何东西要显示,所以它保持空白。

img/470914_1_En_7_Fig4_HTML.jpg

图 7-4

在 Angular 引导大约 2 秒后,应用外壳的初始渲染

Angular App 外壳和 Angular 通用

Angular Universal 通过称为服务器端呈现(SSR)的过程在服务器上生成静态应用页面。当 Universal 与您的应用集成时,它可以预先生成 HTML 文件形式的页面,供以后使用。

看了一下应用的结构,app.component.ts有一个主框架,包括一个页眉和页脚。

  template: `
  <div class="appress-pwa-note">
    <app-header></app-header>
    <div class="main">
      <div *ngIf="joke$ | async as joke" class="joke">
      {{ joke }}
      </div>
      <router-outlet></router-outlet>
    </div>
    <app-footer></app-footer>
  </div>
  `,

看来如果能预渲染这个组件,在自举 Angular 之前就能有个 app 外壳了。正如您在组件模板中看到的,它的将被替换为内容;因此,我们需要指定我们想要放置什么来代替路由器出口。

这就是我们使用 Angular Universal 的地方,通过 Angular CLI 运行简单的命令,在构建时生成一个 app-shell,并在index.html中输出。我们定义了想要预渲染的路由,然后就可以开始了。因此,我们将为一个 Angular 宇宙搭建支架,以获得预渲染能力。Angular CLI 将是一个内置命令,帮助我们轻松实现目标。只需运行以下命令:

  • Angular CLI 生成 App Shell。

  • --universal-project指定我们要使用哪个 Angular 通用应用进行预渲染。

  • --client-project指定我们想要用于预渲染的客户端项目。

  • 或者,您可以使用--route来指定应该使用什么路径名来生成应用外壳。默认值为shell.

ng generate app-shell --client-project <my-app> --universal-project <server-app>

由于 Angular CLI 6+可以处理多个客户端项目,因此找到正确的应用非常重要。如果您不知道您的客户端项目名称,请查看angular.json CLI 配置文件。

以下是命令输出:

CREATE src/main.server.ts (220 bytes)
CREATE src/app/app.server.module.ts (590 bytes)
CREATE src/tsconfig.server.json (219 bytes)
CREATE src/app/app-shell/app-shell.component.css (0 bytes)
CREATE src/app/app-shell/app-shell.component.html (28 bytes)
CREATE src/app/app-shell/app-shell.component.spec.ts (643 bytes)
CREATE src/app/app-shell/app-shell.component.ts (280 bytes)
UPDATE package.json (1822 bytes)
UPDATE angular.json (5045 bytes)
UPDATE src/main.ts (656 bytes)
UPDATE src/app/app.module.ts (1504 bytes)

如果你想手动完成这个过程,或者你想知道它是如何工作的。我会破解密码。

Main.server.ts已经被创建来引导app-server-module :

import { enableProdMode } from '@angular/core';

import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

export { AppServerModule } from './app/app.server.module';

app-server.module.ts只有一条路由shell被替换为router-outlet

import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { Routes, RouterModule } from '@angular/router';
import { AppShellComponent } from './app-shell/app-shell.component';

const routes: Routes = [ { path: 'shell', component: AppShellComponent }];

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    RouterModule.forRoot(routes),
  ],
  bootstrap: [AppComponent],
  declarations: [AppShellComponent],
})
export class AppServerModule {}

在这里我们可以添加预渲染需要显示的内容;在这种情况下,我将添加一个简单的加载消息。

// app-shell.component.html
<div class="loading" style="text-align:center; padding:3rem">
  loading... will be sevring you very very soon
</div>`

// app-shell.component.ts
@Component({
  selector: 'app-app-shell',
  templateUrl: './app-shell.component.html',
  styleUrls: ['./app-shell.component.css']
})
export class AppShellComponent implements OnInit {
  constructor() { }
  ngOnInit() {
  }
}

tsconfig-server.json将具备服务器端渲染一个 Angular app 的所有要求。

{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app-server",
    "baseUrl": "."
  },
  "angularCompilerOptions": {
    "entryModule": "app/app.server.module#AppServerModule"
  }
}

并且platform-server模块已添加到package.json:

"@angular/platform-server": "⁷.0.1",

app.module.ts, BrowserModule中已配置,以便从服务器渲染的应用(如果页面上有)过渡。

BrowserModule.withServerTransition({ appId: 'serverApp' }),

除了所有其他变化,在angular.json文件中还有新的配置,我们有两个新的目标:

"server": {
          "builder": "@angular-devkit/build-angular:server",
          "options": {
            "outputPath": "dist/lovely-offline-server",
            "main": "src/main.server.ts",
            "tsConfig": "src/tsconfig.server.json"
          },
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ]
            }
          }
        },
        "app-shell": {
          "builder": "@angular-devkit/build-angular:app-shell",
          "options": {
            "browserTarget": "lovely-offline:build",
            "serverTarget": "lovely-offline:server",
            "route": "shell" // where we define our route
          },
          "configurations": {
            "production": {
              "browserTarget": "lovely-offline:build:production"
            }
          }
        }

如您所见,app-shell组件被链接到/shell路由,但仅在Angular Universal应用中。这个特殊路径是一个内部 Angular CLI 机制,用于生成 App Shell。它将替换router-outlet标签,用户将无法导航到它。

在生产中生成应用外壳

因此,一切似乎都准备好了,并已设置妥当。现在让我们使用应用外壳目标进行构建。

要触发生产构建,您只需运行以下命令之一:

ng run <project-name>:app-shell:production
ng run <project-name>:app-shell --configuration production

因此,在项目中,运行以下命令:

ng run lovely-offline:app-shell:production

这个命令将把名为lovely-offline的客户端应用和目标构建app-shell作为目标。Angular CLI 开始构建和捆绑,一旦完成,输出就在dist文件夹中准备好了。现在就来看看index.html吧。

<app-root _nghost-sc0="" ng-version="7.0.1">
    <div _ngcontent-sc0="" class="appress-pwa-note">
      <app-header _ngcontent-sc0="" _nghost-sc1="">
        <mat-toolbar _ngcontent-sc1="" class="mat-toolbar mat-primary mat-toolbar-single-row" color="primary"
          ng-reflect-color="primary"><span _ngcontent-sc1="" tabindex="0" ng-reflect-router-link="/">ApressNote-PWA</span><span

            _ngcontent-sc1="" class="space-between"></span><button _ngcontent-sc1="" aria-haspopup="true"
            mat-icon-button="" class="mat-icon-button _mat-animation-noopable"
            ng-reflect-_deprecated-mat-menu-trigger-for="[object Object]"><span class="mat-button-wrapper">
              <mat-icon _ngcontent-sc1="" class="mat-icon material-icons" role="img" aria-hidden="true">more_vert</mat-icon>
            </span>
            <div class="mat-button-ripple mat-ripple mat-button-ripple-round" matripple="" ng-reflect-centered="true"
              ng-reflect-disabled="false" ng-reflect-trigger="[object Object]"></div>
            <div class="mat-button-focus-overlay"></div>
          </button></mat-toolbar>
        <mat-menu _ngcontent-sc1="" x-position="before" class="ng-tns-c6-0">
          <!---->
        </mat-menu>
      </app-header>
      <div _ngcontent-sc0="" class="main">
        <!--bindings={
  "ng-reflect-ng-if": "How many kids with ADD does it"
}-->
        <div _ngcontent-sc0="" class="joke ng-star-inserted"> How many kids with ADD does it take to change a
          lightbulb? Let's go ride bikes! </div>
        <router-outlet _ngcontent-sc0=""></router-outlet>
        <app-app-shell _nghost-sc7="" class="ng-star-inserted">
          <div _ngcontent-sc7="" class="loading" style="text-align:center; padding:3rem"> loading... will be sevring

            you very very soon
          </div>`
        </app-app-shell>
      </div>
      <app-footer _ngcontent-sc0="" _nghost-sc2="">
        <footer _ngcontent-sc2="">
          <div _ngcontent-sc2="" class="copyright">Copyright Apress - Majid Hajian</div>
        </footer>
        <div _ngcontent-sc2="" class="addNote"><button _ngcontent-sc2="" mat-fab="" class="mat-fab mat-accent _mat-animation-noopable"
            tabindex="0" ng-reflect-router-link="/notes/add"><span class="mat-button-wrapper">
              <mat-icon _ngcontent-sc2="" class="mat-icon material-icons" role="img" aria-hidden="true">add circle</mat-icon>
            </span>
            <div class="mat-button-ripple mat-ripple mat-button-ripple-round" matripple="" ng-reflect-centered="false"

              ng-reflect-disabled="false" ng-reflect-trigger="[object Object]"></div>
            <div class="mat-button-focus-overlay"></div>
          </button></div>
      </app-footer>
    </div>
  </app-root>

它看起来和我们以前的很不一样。Angular CLI 已经生成了一个基于/shell route 的 shell,它有一个页脚和页眉,包括一个笑话部分。

除了 HTML,你看到所有基于这些组件的 CSS 也已经生成并添加到<head> </head>中。

我将添加一个 npm 脚本来构建 App Shell,并再次测量性能。

"build:prod:shell": "ng run lovely-offline:app-shell:production",

"prod": "npm run build:prod:shell && cd dist && http-server -p 4200 -c-1",

通过点击运行本地服务器

npm run prod

如果你正在运行你自己的项目,确保你已经安装了http-server,你把目录改成/ dist并运行http-server -p 4200 c-1

在服务器准备好之后,在 Chrome 中导航到localhost:4200,进行与我们在应用外壳实现之前所做的相同的性能分析。

结果可能因应用和运行测试的位置而异,但重点是有 Angular 的应用外壳可能会增加启动加载时间。正如您在图 7-5 中看到的,我们设法在大约 100 毫秒内将我们的应用外壳呈现给用户,一旦 Angular 启动了动态内容,它就会被加载。

img/470914_1_En_7_Fig5_HTML.jpg

图 7-5

在我们实现了 Angular 应用外壳之后,大约 100 毫秒开始第一次绘制

通过 webpagetest.org 测量应用外壳性能

尽管我们已经通过 DevTools 中的 Chrome Performance 选项卡在本地服务器上运行了一次本地测试,但并不十分精确。Webpagetest.org 是一个工具,我们可以用它来衡量网站的性能,并生成有关测试的详细信息,包括许多对 web 应用优化有用的功能。

在部署新的应用 Shell 实现之前,让我们在 Firebase 上对我们的应用进行测试。

打开webpagetest网站,进入简单测试选项卡。输入你的网站名称,选择“移动普通 3G ”(见图 7-6 )。选择“包括重复查看”和“运行 lighthouse 审计”您可以使用不同的设置运行更多的测试。最后,开始测试。

img/470914_1_En_7_Fig6_HTML.jpg

图 7-6

webpagetest.org 上的简单测试设置

一旦结果准备好,我们看到在应用外壳优化之前,交互时间约为 7.8 秒,浏览器开始渲染约为 6.9 秒,由于 bootstrapping Angular,这在某种程度上是意料之中的(见图 7-7 )。要了解更多详情,请点击以下链接:

https://www.webpagetest.org/result/181030_ZA_ff4f3780bea8eb430be1171a5297ae35/

img/470914_1_En_7_Fig7_HTML.jpg

图 7-7

在移动常规 3G 网络上运行应用外壳和更多优化之前的网页测试结果

我将用应用外壳实现将应用部署到 Firebase。部署完成后,导航到网站并通过 Chrome 查看源代码。您将看到应用外壳和内联样式已经在源代码中。打开 webpagetest.org,再次运行完全相同的测试。

一旦结果准备就绪,就会看到显著的改进。与之前的测试相比,交互时间减少到了 5.9 秒,开始渲染时间至少减少了 2 秒。你会发现应用中的一个简单模型可能会对用户体验产生显著影响(见图 7-8 )。

img/470914_1_En_7_Fig8_HTML.jpg

图 7-8

App Shell 后的 Webpagetest 结果,运行在移动常规 3G 网络上

要了解更多详情,请点击以下链接:

https://www.webpagetest.org/result/181031_KE_538b7df1cabf6cbe4a3565a3f6c42fc6/

通过 Chrome DevTools 中的审计选项卡测量应用外壳性能

虽然 webpagetest.org 能够通过 Lighthouse 运行一个测试应用,但我想在我的机器上运行 Chrome DevTools 上的 web 应用来运行这项措施。记住,你做的测试越多越好。所以,不要放弃,在不同的工具上进行更多的测试。

当 Firebase 上的 web 应用加载时,只需打开 Chrome DevTools。转到您熟悉的审计选项卡,并选择性能复选框以及渐进式 Web 应用。确保选择模拟快速 3G,4 倍 CPU 减速进行节流,然后点击“运行审计”按钮。我将在使用应用外壳实现部署应用之前和之后进行此测试。你可以在图 7-9 到 7-12 中看到结果。

img/470914_1_En_7_Fig12_HTML.jpg

图 7-12

性能选项卡部署应用外壳实现后,在良好的互联网连接上进行测试,初始渲染时间约为 150 毫秒

img/470914_1_En_7_Fig11_HTML.jpg

图 7-11

在 Chrome audit 选项卡中审核网站,以检查在模拟移动快速 3G 上部署 App Shell 实施后的性能得分

img/470914_1_En_7_Fig10_HTML.jpg

图 7-10

部署应用外壳实现之前的性能选项卡,在良好的互联网连接上进行测试,初始渲染大约需要 700 毫秒

img/470914_1_En_7_Fig9_HTML.jpg

图 7-9

在模拟移动快速 3G 上部署应用外壳实施之前,在 Chrome 审计选项卡中审计网站以检查性能得分

正如我们所看到的,在这个特定的应用中,应用 Shell 对整个 SPA 的第一次油漆的典型时间进行了巨大的改进,这有时会让用户等待几秒钟。

尽管应用外壳模型是提高启动负载的一种方式,但它不是我们在应用中唯一可以做的事情。为了提高性能,我们可以在 web 应用中进行更多的优化,尤其是在 Angular 应用中。

除了应用外壳,进一步优化

我们知道,web apps 在性能感知上还在和原生 app 较劲;因此,每一次眨眼都很重要。我们可以在应用中尝试无数的技巧和窍门,来再挤出几毫秒的时间。

一般来说,Angular 性能主要分为两个部分:

  1. 运行时性能,以最佳实践为目标,主要改进变更检测和渲染相关的优化。

  2. 网络性能,以最佳实践为目标,以提高我们的应用的加载时间,包括延迟和带宽减少。

web 开发中有一些常见的最佳实践,这两个部分都有所重叠。然而,在这一节中,我的重点是网络性能和更快的加载时间。为了提高加载速度,我将回顾一些最重要的技巧。

分析包大小和延迟加载模块

毫无疑问,包中的 JavaScript 代码越少,下载和解析就越好。Angular CLI 使用 Webpack 捆绑应用。通过在build命令中传递--stats-json,Angular CLI 将生成一个 JSON 文件,其中包含所有的包信息,我们可以简单地对其进行分析。

只需遵循以下步骤:

  1. npm install webpack-bundle-analyzer -D安装工具

  2. packge.json中添加--stats-json来构建脚本

    "build:prod": "ng build --prod --stats-json",
    
    
  3. package.json文件添加新脚本

    "analyzer": "webpack-bundle-analyzer dist/stats.json"
    
    
  4. 构建然后运行npm run analyzer

一旦构建完成,在the /dist文件夹中会有一个stats.json文件,包含关于项目包的所有信息。只要运行 npm 命令,你就会被重定向到浏览器,你会看到应用的统计信息,如图 7-13 所示。

img/470914_1_En_7_Fig13_HTML.jpg

图 7-13

项目 app 分析;图片右侧显示了延迟加载的模块

分割代码以减小包大小的一种方法是使用 Angular 延迟加载。延迟加载通过将应用拆分为功能模块并按需加载,使您能够优化应用的加载时间。

  {
    path: 'user',
    loadChildren: './modules/user/user.module#UserModule'
  },
  {
    path: 'notes',
    loadChildren: './modules/notes/notes.module#NotesModule',
    canLoad: [AuthGuard]
  }

我们甚至可以基于某些条件阻止整个模块被加载。例如,在项目应用中,我们通过添加canLoad guard 来防止加载整个模块,如果根据 guard 中的规则有必要的话。

分析可能因应用而异,这取决于你如何设计你的应用。

来自网页测试的瀑布视图

瀑布视图揭示了许多有用的加载细节,可用于跟踪瓶颈:例如,阻碍呈现的东西,或者可以消除或推迟的请求。概述从初始请求到完成需要多长时间。关于 http 握手等有用的信息。例如,图 7-14 显示项目应用通过加载谷歌字体来渲染块,或者它延迟了 450 ms 左右的绘制,因为浏览器正在解析 CSS。

img/470914_1_En_7_Fig14_HTML.jpg

图 7-14

来自 webpagetest.org 的应用瀑布视图

减少渲染阻塞 CSS

这是一个常见的错误,在许多应用中,他们会加载大量的 CSS,而对于那些在屏幕上可以看到的内容或所谓的折叠内容来说是不必要的。

一般来说,我们应该确定什么对应用框架、应用外壳和初始加载至关重要,并将它们添加到 style.css 中。我们应该尽量减少初始样式文件的占用空间。

此外,我们应该在惰性加载模块中导入共享样式。有时,我们甚至需要将样式导入到那些需要特定样式的惰性加载模块中。例如,假设我们有一个已经被延迟加载的图表模块。如果这个模块需要一个特定的样式,我们应该只将它导入到这个模块中,它将在需要时被加载。

在一个真实的例子中,想想我们的应用,因为我们正在预渲染,应用 shell 和 Angular CLI 将把所有基本样式注入到index.html。将 Angular Material 主题 CSS 文件和我们的主style.scss文件移除到AppComponent中可能是有意义的,因为基本上整个应用都需要这些样式,我们可以简单地预渲染并将样式注入到 index.html 头部,这将导致移除阻止渲染的 style.css 捆绑文件。

// Angular.json
"styles": [
              {
                "input": "node_modules/@angular/material/prebuilt-themes/indigo-pink.css"
              },
              "src/styles.scss"
            ],

// remove these files and it looks like
"styles": [],

然后将它们导入AppComponent:

//app.component.ts
@Component({
  selector: 'app-root',
  template: `
  <div class="appress-pwa-note">
    <app-header></app-header>
    <div class="main">
      <div *ngIf="joke$ | async as joke" class="joke">
      {{ joke }}
      </div>
      <router-outlet></router-outlet>
    </div>
    <app-footer></app-footer>
  </div>
  `,
  styleUrls: [
    '../../node_modules/@angular/material/prebuilt-themes/indigo-pink.css',
    '../../styles.scss'
  ]
})

现在,如果您构建应用,然后检查/dist/index.html,您将会看到所有的样式都已经添加到头部,不再有以前的 css 文件。

再说一次,这只是一个例子,我想告诉你如何优化你的应用;这可能对我们的笔记应用有意义,但似乎不是你下一个项目的好选择。请记住,您应该在每次更改后评估应用性能,看看是否有改进。

优化字体

很有可能你现在在网络应用中使用的是字体,尤其是谷歌字体等外部字体。当我们将样式链接添加到页面头部时,应该考虑到这些字体会阻碍渲染。这意味着渲染将推迟,直到这些样式被下载和渲染。重要的是要减少对字体的初始需求,并在需要时加载它们的重置。

自托管字体

使用 web 字体外观块意味着,如果获取 web 字体需要很长时间,浏览器会决定如何处理。一些浏览器在回到系统字体之前会等待三秒钟,一旦下载完成,他们最终会换成系统字体。

我们正试图避免不可见的文本,因此多亏了名为font-display的新功能,这有助于根据网页字体交换所需的时间来决定它们如何呈现或后退。

交换给字体一个零秒的块周期和一个无限的交换周期。换句话说,如果下载需要时间,浏览器会用备用字体快速显示文本。一旦网络字体准备好了,它就要交换了。这个功能有很好的浏览器支持。 1

@font-face {
  font-family: YouFont;
  font-style: normal;
  font-display: swap;
  font-weight: 400;
  src: local(yo-font Regular'), local(YouFont -Regular'),
      /* Chrome 26+, Opera 23+, Firefox 39+ */
      url(you-font -v12-latin-regular.woff2') format('woff2'),
        /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
      url(you-font -v12-latin-regular.woff') format('woff');
}

在笔记应用的情况下,尽快向用户展示他们的笔记是有意义的,然后继续前进,一旦准备好就过渡到 web 字体。请记住,我们仍然会得到一个 FOUT 2 (无样式文本的闪烁)。

基于 CDN 的字体

很明显,我们在笔记应用中使用了谷歌网络字体。有许多不同的方法来优化这些类型的字体。一种方法是将它们异步添加到 UI 中,这有助于避免块呈现。我们可以使用一些工具和库来延迟加载字体,但是最著名的库之一可能是 Web 字体加载器。 3

然而,我已经决定在我的 Angular 项目中以不同的方式加载我的字体,以便揭示两个属性,这两个属性有助于加载 JavaScript 文件,同时不会阻碍渲染。我已经创建了一个名为lazy-fonts.js的 JavaScript 文件,并将其添加到/src中,并将添加以下代码,这基本上是在文件头添加了一个脚本标签。

(function(d) {
  var x = d.createElement('link');
  var y = d.getElementsByTagName('script')[0];
  x.rel = 'stylesheet';
  x.href = 'https://fonts.googleapis.com/icon?family=Material+Icons';
  y.parentNode.insertBefore(x, y);
  })(document);

(function(d) {
  var x = d.createElement('link');
  var y = d.getElementsByTagName('script')[0];
  x.rel = 'stylesheet';
  x.href = 'https://fonts.googleapis.com/css?family=Roboto:300,400,500';
  y.parentNode.insertBefore(x, y);
})(document);

我还将删除应用中index.html文件中<head>之间的字体标签,并在</body>之前引用该文件。最后但同样重要的是,我将把这个文件添加到 Angular 配置文件的 assets 数组中,这告诉 Angular CLI 把这个文件从src文件夹复制到dist文件夹根目录。

// angular.json
"assets": [
              "src/favicon.ico",
              "src/assets",
              "src/manifest.json",
              "src/lazy-fonts.js"
            ],

// index.html
<script type="text/javascript" src="lazy-fonts.js"></script>
</body>

现代浏览器有几个额外的选项来防止脚本阻塞页面呈现过程。两个主要特征如下:

  • defer 属性:告诉浏览器在下载资源时继续渲染,但在完成 HTML 渲染之前不执行这个 JS 资源。换句话说,浏览器将等待脚本执行,直到渲染完全完成。对于angular-cli应用,目前没有办法在构建期间自动添加,所以你必须在构建之后手动添加。

  • async 属性:告诉浏览器在下载脚本资源的同时继续渲染只会暂停解析 HTML 来执行脚本。当您需要尽可能快地执行脚本,但又不阻止应用外壳的呈现时,这很有用。最好的例子是将它与 Google analytics 脚本一起使用,这些脚本通常独立于任何其他脚本。

因此,根据定义,我想将 async 添加到我的脚本文件中。

// index.html
<script type="text/javascript" src="lazy-fonts.js" async></script>
</body>

这将有助于渲染 HTML 而不会被脚本阻止,脚本还会将字体添加到应用中。

浏览器资源搜寻

你可能听说过preloadprefetch,preconnect。最终,这些使 web 开发人员能够优化资源的交付,减少往返行程,并以比请求更快的速度获取资源。

img/470914_1_En_7_Fig17_HTML.jpg

图 7-17

撰写本书时的预连接浏览器支持

  • Preload: is a new standard to of how to gain more control on how resources should be fetched for current navigation. This directive is defined within a <link> element, <link rel="preload">. This allows the browser to set priority to prefetch even before the resource is reached. See Figure 7-15 for browser support.

    img/470914_1_En_7_Fig15_HTML.jpg

    图 7-15

    截至撰写本书时,预加载浏览器支持

    <link rel="preload" href="https://example.com/fonts/font.woff" as="font" crossorigin>
    
    
  • Prefetch: is set as a low priority resource hint that informs the browser to fetch that particular resource in the background when the browser is idle. We use prefetch for those resources that may be needed later: for example, prefetch pictures that will need to be shown on the second navigation on the website. Element is defined similar to preload. See Figure 7-16 for browser support.

    img/470914_1_En_7_Fig16_HTML.jpg

    图 7-16

    写这本书时预取浏览器支持

    <link rel="prefetch" href="/uploaimg/pic.png">
    
    
  • 预连接:这允许浏览器在 HTTP 请求实际发送到服务器之前建立早期连接,包括 DNS 查找、TLS 协商和 TCP 握手。拥有这个资源提示的好处之一是消除了往返延迟,为用户节省了时间。在某些情况下,对于初始负载,它可以提高 400 ms。标签类似于 preload,在 HTML 中添加到头部。对通过 CDN 提供的字体等外部资源使用preconnect可能会增加加载时间。浏览器支持见图 7-17 。

      <link rel="preconnect" href="https://fonts.googleapis.com"crossorigin="anonymous">
    
    

由于我们在 PWA Note 项目中使用 Google 字体,添加资源会影响preconnectpreload并可能有助于加载性能。打开src/index.html,添加以下代码:

<head>
  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin="anonymous">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
  <link rel="preload" href="https://fonts.googleapis.com/icon?family=Material+Icons" as="style">
  <link rel="preload" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" as="style">
  <link rel="preload" href="lazy-fonts.js" as="script">

一旦我们构建并部署到 Firebase,我们可以运行另一个测试来测量添加这些标记后的性能。您可能看不到巨大的改进,但即使 100 毫秒也很重要。记住,我们努力减少毫秒。

预加载有 Angular 的惰性加载模块

Angular 使我们能够预加载所有延迟加载的模块。这个特性是从 Angular Router 模块继承来的,在这里我们可以改变预加载策略。

虽然您可以编写一个自定义提供程序来定义预加载策略,但我使用的是已经包含在 Angular Router 模块中的PreloadAllModules。打开app-routing.module.ts并在RouterModuleforRoot添加第二个参数。

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  providers: [AuthGuard],
  exports: [RouterModule]
})
export class AppRoutingModule {}

这允许浏览器甚至在模块被请求之前预取和缓存这些模块;因此,后续导航是即时的,而初始负载尽可能小。当我们的惰性加载模块非常大时,这可能特别有用。请记住,预加载不会影响初始负载性能。

HTTP/2 服务器推送 4

HTTP/2 (h2)服务器推送是 HTTP 协议版本 2 中包含的性能特性之一。

只要所有的 URL 都通过相同的主机名和协议传送,web 服务器就可以提前将内容“推”给客户机,即使客户机没有请求它们。

让推送资源与 HTML 的交付竞争会影响页面加载时间。这可以通过限制推送的数量和大小来避免。

Firebase 允许我们通过配置位于项目根目录的firebase.json将内容推送到客户端。该规则类似于下面的代码:

"hosting": {
    "headers": [
      {
        "source": "/",
        "headers": [
          {
            "key": "Link",
            "value": "</lazy-fonts.js>;rel=preload;as=script,</css/any-file.css>;rel=preload;as=style"
          }
        ]
      }
    ],
}

虽然这个特性听起来很有前途,但是不要试图过度使用它。

摘要

App Shell 模式是提高初始加载速度的一种很好的方式,我们已经看到 Angular CLI 通过利用 Angular Universal 将为任何 Angular 项目生成合适的 app shell,只要它的架构良好。

我们优化我们的应用,以提高网络级别的性能,从而构建一个速度极快的应用。虽然我已经介绍了性能和优化方面的主要重要主题,但是您不会受到限制,应该更进一步,甚至进行更多的改进。像 Google Closure Compiler,Tree-shaking,Build-optimizer flag,Ivy Render Engine,Cache-control header,Gzip compression,HTTP/2 中的 Header compression,Compressing images,Change Detection optimization,Pure pipes 和 memoring,这些只是应用能走多远、能走多快的例子。

正如我提到的,渐进式改进是构建 PWAs 的最重要的关键之一。因此,在浏览器中实现具有功能检测的功能,始终牢记您的所有用户,迭代增强您的应用性能,并为您的用户提供最佳的集成和交互体验,无论他们使用什么浏览器。

在下一章中,我们将注意力转移到 PWA 的参与部分,看看如何向用户发送推送通知。

八、推送通知

有不同的方法让你的用户参与进来并保持更新,比如通过电子邮件、应用内通知和推送通知!长期以来,本地移动应用一直通过推送通知来吸引用户。直到 PWAs 诞生,这个功能才在 web 上得到支持。多亏了新的标准 API,比如 Notification API 和 Push API,它们都是建立在 Service Worker 之上的,这使得向 web 用户发送推送通知成为可能。

在这一章中,您将发现推送通知的基础知识,并将为现有的应用 PWA Note 构建一个工作示例。您将看到我们如何编写 Firebase Cloud 函数来从服务器发送推送通知。总之,在学完这一章之后,你应该能够运行自己的服务器来发送推送通知,并且很快就能在 Angular 中实现这个特性。

推送通知简介

大多数现代网络应用将通过不同的渠道,如社交媒体、电子邮件和应用内通知,保持用户更新和沟通。虽然所有这些渠道都很棒,但它们并不总能抓住用户的注意力,尤其是当用户离开应用时。

传统上,原生应用有这种惊人的能力,推送通知,直到 PWAs 诞生。这就是为什么 pwa 是一个游戏改变者。通知是一条可以显示在用户设备上的消息,可以由 Web 通知 API 1 在本地触发,也可以在应用甚至没有运行时从服务器推送给用户,这要感谢服务工作器。

网络通知

通知 API 允许网页控制向用户显示系统通知。由于此消息显示在顶级浏览上下文视口之外,因此即使用户切换选项卡,它也可以显示给用户。最好的部分是,这个 API 被设计为与跨不同平台的现有通知系统兼容。在受支持的平台上,用户需要授予当前 origin 权限来显示系统通知。一般可以通过调用Notification.requestPermission()方法来完成(图 8-1 )。

img/470914_1_En_8_Fig1_HTML.jpg

图 8-1

不同浏览器中 web 通知的权限弹出窗口

一旦许可被授予,在网页上,我们只需要用适当的标题和选项实例化Notification构造函数(见图 8-2 )。

img/470914_1_En_8_Fig2_HTML.jpg

图 8-2

在 Chrome 浏览器中收到简单通知

new Notification("New Email Received", { icon: "mail.png" })

这太棒了。如果我们能让服务工作器也参与进来,那就太好了。当显示由服务工作器处理的通知时,它被称为“持久通知”,因为服务工作器在应用的后台保持持久,无论它是否运行。

几乎所有的代码都将和以前一样;唯一不同的是,我们只需要在sw对象上调用showNotification方法。

nagivator.serviceWorker.ready.then(sw =>{
       sw.showNotification('title', { icon: "main.png"})
})

您将在本章中找到更多关于通知的可能选项。

推送通知

毫无疑问,与我们的用户互动的最强大和最不可思议的方式之一是推送通知,它们将应用扩展到浏览器之外。有几个部分组合在一起使推送通知发挥作用。其中一个主要部分是 Push API,它使 web 开发人员能够以类似于原生应用技术的方式完成这项工作,这被称为 Push Messaging。

在几个步骤中,我将尝试简化推送通知架构:

  1. 在用户授予权限后,应用向网络推送服务请求一个PushSubscription对象。请记住,每个浏览器都有自己的推送服务实现。

  2. 网络推送服务返回PushSubscription对象。此时,您可以将该对象保存在数据库中,以便在推送通知时重用它。

  3. 在我们的应用中,我们定义哪个动作需要推送通知。因此,应用后端将根据订阅详细信息处理推送通知的发送。

  4. 最后,一旦 web 推送服务发送了通知,服务工作人员就会收到通知并显示出来。

服务工作器中有不同的推送通知事件,如pushnotificationclick事件。

注意

在 Service Worker 中,您可以监听push和其他与推送通知相关的事件,如notificationclick

看着图 8-3 ,你会看到它是如何工作的。

img/470914_1_En_8_Fig3_HTML.jpg

图 8-3

推送通知流程

在 web 推送服务器中请求订阅对象而不识别应用本身,可能会暴露很多风险和漏洞。解决方案是使用自愿应用服务器标识(VAPID)密钥,也称为应用服务器密钥。这确保了服务器知道谁在请求推送,谁将接收推送。这被认为是一种安全预防措施,以确保应用和服务器之间不会出现恶意错误。

这个过程非常简单:

  1. 您的应用服务器创建一个公钥/私钥对。公钥用作唯一的服务器标识符,用于为用户订阅由该服务器发送的通知,私钥由应用服务器使用,用于在将消息发送到推送服务进行传递之前对消息进行签名。

    There are different ways to generate public/private keys. For instance,

    1. 可以使用we b-push-code lab . glitch . me并生成密钥。然后安全地存储密钥,尤其是私钥(它应该对公众隐藏)并在需要时使用它。

    2. 有一个名为web-pushnpm包,我们可以用它来生成私有/公共密钥。此外,它还可以用于在应用后端服务器中推送通知。

      要使用 web 推送库生成:

      npm install web-push -g
      
      

      Once package is installed, run the following command to generate key pair:

      web-push generate-vapid-keys --json
      
      

      Using this command, here is what a VAPID key pair looks like:

      {
        "publicKey":"ByP9KTS5K7ZLBx- _x3qf5F4_hf2WrL2qEa0qKb-aCJbcxEvyn62GDTy0K7TfmOKSPqp8vQF0DaG8hpSBknz iEFo",
        "privateKey":"fGcS9j-KgY29NM7myQXXoGcO-fGcSsA_fG0DaG8h"
      }
      
      
  2. 公钥是给你的 web 应用的。当用户选择接收推送时,将公钥添加到subscribe()调用的 options 对象中。在本章的后面,我们需要将公钥传递给requestSubscription方法,Angular Service Worker 将处理许可请求。

    PushManager上的subscribe方法需要ApplicationSeverKey作为UInt8Array, 2 由引擎盖下的 Angular Service Worker 处理。

  3. 当您的应用后端发送 push 消息时,包括一个签名的 JSON web 令牌和公钥。

注意

如果你已经克隆了项目源代码,请访问。com/mha daily/awesome-a press-pwa/tree/master/chapter 08/01-push-notification。要生成乏味的密钥对,首先是run npm install,然后是npm run vapid.

浏览器支持

在撰写本书时,主流浏览器 Firefox、Chrome、Opera 和 Microsoft Edge 都支持 Push API。Safari 不支持推送 API。然而,如果你也想针对 Safari,苹果开发者网站上有一个关于如何为网站发送推送通知的建议。你可以在 developer 上找到这个文档。苹果。有关更多信息,请访问。请记住,这个解决方案与 iOS 上的 Safari 无关。

既然您已经知道了推送通知是如何工作的,那么是时候开始在我们的应用中实现 Angular Service Worker 来处理推送通知了。

以 Angular 推送通知

Angular Service Worker 提供SwPush服务,有不同的方法和属性,方便推送通知的实现。虽然我们可以使用 Angular 方法,但为了订阅和取消订阅并不一定要使用它,因为这些方法基本上只是本地 pushManager 对象方法之上的语法糖。然而,在这一节,我将继续使用角的方式。

因为我们已经安装了 Angular Service Worker,所以我们现在能够注入SwPush服务。首先,我们应该允许用户订阅接收推送通知。为此,用户应该授予订阅通知的权限。让我们更改应用 UI,让用户启用通知。

我将在菜单中添加一个按钮,当用户点击时,它会触发请求权限。因为我们确实关心我们的用户体验,所以当用户想要取消订阅推送通知时,我将添加另一个按钮。

  <button mat-menu-item (click)="requestPermission()" *ngIf="!(subscription$ | async) && (user$ | async) && isEnabled">
    <mat-icon>notifications_on</mat-icon>
    <span>Enable alerts</span>
  </button>
  <button mat-menu-item (click)="requestUnsubscribe()" *ngIf="subscription$ | async">
    <mat-icon>notifications_off</mat-icon>
    <span>Disabled alerts</span>
  </button>

我们正在逐步构建我们的应用;因此,我们应该确保此功能对那些注册了服务工作器的人可用,并且 pushManager 对象在服务工作器注册中可用。如您所见,当已经启用订阅和服务工作器时,我们隐藏了 Enable Alerts 按钮。

requestPermissionrequestUnsubscribe方法在HeaderComponent类中定义。

export class HeaderComponent {
  private readonly VAPID_PUBLIC_KEY = 'YOUR VAPID PUBLIC KEY';

  public user$ = this.auth.user$;
  public subscription$ = this.swPush.subscription;
  public isEnabled = this.swPush.isEnabled;

  constructor(
       private auth: AuthService,  private swPush: SwPush,
       private snackBar: SnackBarService, private dataService: DataService,
      private router: Router
) {  }

  requestPermission() {
    this.swPush

      .requestSubscription({
        serverPublicKey: this.VAPID_PUBLIC_KEY
      })
      .then(async (sub: PushSubscription) => {
        const subJSON = sub.toJSON();
        await this.dataService.addPushSubscription(subJSON);
        return this.snackBar.open('You are subscribed now!');
      })
      .catch(e => {
        console.error(e);
        this.snackBar.open('Subscription failed');
      });
  }

  requestUnsubscribe() {
    this.swPush
      .unsubscribe()
      .then(() => {
        this.snackBar.open('You are unsubscribed');
      })
      .catch(e => {
        console.error(e);
        this.snackBar.open('unsubscribe failed');
      });
  }
}

让我们分解代码。

SwPush订阅属性是与服务工作器获取订阅方法相关联的可观察属性,否则订阅为空。

requestPermission方法中,用户通过调用swPush服务上的requestSubscription来请求许可。我们应该将我们乏味的公钥作为serverPublicKey传递给这个方法。

    this.swPush
      .requestSubscription({
        serverPublicKey: this.VAPID_PUBLIC_KEY
      })

这个方法返回一个包含PushSubscription的承诺。推送通知对象具有以下方法和属性:

interface PushSubscription {
    readonly endpoint: string;
    readonly expirationTime: number | null;
    readonly options: PushSubscriptionOptions;
    getKey(name: PushEncryptionKeyName): ArrayBuffer | null;
    toJSON(): PushSubscriptionJSON;
    unsubscribe(): Promise<boolean>;
}

因此,我们将调用toJSON() 3 函数来接收PushSubscriptionJSON对象,该对象包含发送通知的基本属性,我们将通知发送到后端并存储到数据库中。

interface PushSubscriptionJSON {
    endpoint?: string;
    expirationTime?: number | null;
    keys?: Record<string, string>;
}

我在数据服务中创建了一个简单的方法来将推送订阅数据存储在数据库中。

const subJSON = sub.toJSON();
 await this.dataService.addPushSubscription(subJSON);

通过将 subscription JSON 对象传递给addPushSUbscription方法,我将把这个对象存储到另一个名为 subscription 的集合中,用于Firestore中的活动用户。我们的用户可能有多个基于不同浏览器和设备的订阅。因此,存储该用户的所有订阅并向注册接收通知的所有设备发送通知非常重要。

  addPushSubscription(sub: PushSubscriptionJSON): Promise<DocumentReference> {
    const { keys, endpoint, expirationTime } = sub;
    return this.afDb
      .collection(this.USERS_COLLECTION)
      .doc(this.auth.id)
      .collection(this.SUBSCRIPTION_COLLECTION)
      .add({ keys, endpoint, expirationTime });
  }

我们实现了另一个按钮,允许用户选择不接收通知,如果他们愿意的话。因此,requestUnsubscribe方法将调用返回承诺的swPush上的unsubscribe()方法,一旦解决,用户将被取消订阅。

  requestUnsubscribe() {
    this.swPush
      .unsubscribe()
      .then(() => {
        this.snackBar.open('You are unsubscribed');
      })
      .catch(e => {
        console.error(e);
        this.snackBar.open('unsubscribe failed');
      });
  }

现在我们已经实现了基本的需求,让我们为生产和运行服务器构建一个应用。导航至 Chrome 浏览器,在菜单下点击启用提醒(见图 8-4 )。

img/470914_1_En_8_Fig4_HTML.jpg

图 8-4

启用服务工作器且没有通知订阅时,启用警报按钮

点击后,您应该能够看到一个权限弹出窗口(见图 8-5 和 8-6 )。你将看到的是一个原生的浏览器用户界面,在不同的平台上,它可能会因浏览器而异。但是,您将有两个选项—“allow”和“block”—您可以在其中授予足够的权限来接收通知。一旦选择了这两个选项中的任何一个,这个模式将不再被触发。

img/470914_1_En_8_Fig6_HTML.jpg

图 8-6

Android Chrome 上的通知请求模式

img/470914_1_En_8_Fig5_HTML.jpg

图 8-5

Chrome 中的通知请求弹出窗口

如果用户选择阻止,应用将进入阻止列表,并且不会授予任何订阅。但是,如果用户接受请求,浏览器将在设备上为该用户生成推送通知订阅,因此请求权限将被成功评估,然后推送订阅将被传递给then()。为了将结果转换成 JSON 格式,我们调用toJSON(),然后将它发送到后端,以便存储到数据库中(见图 8-7 )。

img/470914_1_En_8_Fig7_HTML.jpg

图 8-7

用户接受请求时的小吃店消息,它存储在数据库中

您现在可能会注意到,一旦获得许可,菜单下的启用提醒就变成了禁用提醒,订阅对象从推送服务器返回(见图 8-8 和 8-9 )。

img/470914_1_En_8_Fig8_HTML.jpg

图 8-8

如果有激活的订阅,将显示禁用警报,并允许用户取消订阅

这是一个很好的做法,让我们的用户能够选择不接收通知。

img/470914_1_En_8_Fig9_HTML.jpg

图 8-9

用户退订成功时的小吃店消息

查看数据库后,用户订阅已被添加到当前用户的订阅集合中。我们现在可以根据数据库中的用户订阅信息向用户推送通知了。

我们来看看 JSON 格式的订阅对象(见图 8-10 )。

{
    "endpoint": "UNIQUE URL",
    "expirationTime": null,
    "keys": {  "p256dh": "KEY",  "auth": "KEY }
}

为了更好地理解推送通知的一般工作方式,我将揭示推送通知对象的属性:

  • endpoint : 这包含来自浏览器推送服务的唯一 URL,应用后端使用该 URL 向该订阅发送推送通知。

  • expirationTime : 有些消息是时间敏感的,如果过了某个时间间隔就不需要发送了:例如,如果消息的认证码在某个时间过期。

  • p256dh : 这是一个加密密钥,在将消息发送到推送服务之前,我们的后端将使用它来加密消息。

  • auth : 这是一个认证秘密,是消息内容加密过程的输入之一。

所有这些信息对于向该用户发送推送通知至关重要。

img/470914_1_En_8_Fig10_HTML.jpg

图 8-10

为应用中的活动用户存储的订阅对象的 JSON 格式。例如,这个用户有不止一个订阅,我们可能希望向他们所有人发送推送通知

再次显示允许/阻止通知弹出窗口

在本地测试时,您可能会无意中或故意按下 Block 按钮,权限弹出窗口将不再显示。相反,如果你点击订阅按钮,承诺将被拒绝,我们代码中的 catch 块将被触发(见图 8-11 )。

img/470914_1_En_8_Fig11_HTML.jpg

图 8-11

在控制台和小吃店消息中权限被拒绝,显示在请求权限块的 Catch 块中触发订阅失败

要解决此问题,我们应该从浏览器的阻止列表中删除该应用。例如,在 Chrome 中:

  1. 转到chrome://settings/content/notifications.

  2. 向下滚动到阻止发送推送通知的所有网站所在的阻止列表。

  3. 从阻止列表中删除 localhost 或您的应用 URL。

弹出窗口现在应该再次出现,如果我们单击 Allow 选项,将会生成一个推送订阅对象。

发送推送通知

用户的订阅对象已存储在数据库中。这意味着即使有多个订阅,我们也能够向用户推送通知。

为了发送推送通知,我们将编写一个简单的 Firebase Cloud 函数,将便笺保存到数据库中,一旦保存,就向用户发送一个通知,通知中包含一个便笺 ID,说明便笺已经与从数据库中检索到的适当 ID 同步。这只是一个例子;您可能希望为不同的目的发送通知,在本节之后,您应该能够很快做到这一点。

注意

虽然发送推送通知是吸引用户的最佳方式之一,但是发送太多不想要和不必要的通知可能会产生相反的影响,使用户感到沮丧和烦恼。因此,我们有责任尊重用户的隐私和体验。

在应用中,我们将在DataService中定义一个新方法,该方法将接受一个 note 对象并将其发布到由 Firebase Cloud 函数创建的端点。它将取代addNote()方法。

// DataService
  protected readonly SAVE_NOTE_ENDPOINT =
    'https://us-central1-awesome-apress-pwa.cloudfunctions.net/saveNote';

  saveNoteFromCloudFunction(
    note: Note
  ): Observable<{ success: boolean; data: Note }> {
    return this.http.post<{ success: boolean; data: Note }>(
      this.SAVE_NOTE_ENDPOINT,
      {
        user: this.auth.id,
        data: {
          ...note,
          created_at: this.timestamp,

          updated_at: this.timestamp
        }
      }
    );
  }

现在我们将编写函数,一旦部署完毕,saveNote endpoint将由Firebase提供。

火基云函数

在第二章中,我们准备了一个准备定义函数的项目。Node.js 引擎已经被设置为 8,这是 Firebase 中 Node 的最新版本。

我们将使用 Firebase SDK 来设置 Firestore 的云功能。

const admin = require('firebase-admin');
const functions = require('firebase-functions');
const webpush = require('web-push');
const cors = require('cors')({
  origin: true
});

const serviceAccount = require('./awesome-apress-pwa-firebase-adminsdk-l9fnh-6b35c787b9.json');

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: 'https://awesome-apress-pwa.firebaseio.com'
});

const sendNotification = (noteId, subscriptions) => {
  webpush.setVapidDetails(
    'mailto:me@majidhajian.com',
    'VAPID PUBLIC KEY',
    'VAPID PRIVATE KEY

  );

  const pushPayload = {
    notification: {
      title: 'WebPush: New Note',
      body: `Note ${noteId} has been synced!`,
      icon: 'https://placekitten.com/200/139',
      badge: 'https://placekitten.com/50/50',
      dir: 'ltr',
      lang: 'en',
      renotify: false,
      requireInteraction: false,

      timestamp: new Date().getTime(),
      silent: false,
      tag: 'saveNote',
      vibrate: [100, 50, 100],
      data: {
        noteID: noteId,
        dateOfArrival: Date.now(),
        primaryKey: 1
      },
      actions: [
        {
          action: 'open',
          title: 'Open Note', icon: 'images/checkmark.png'
        },
        {
          action: 'cancel',
          title: 'Close', icon: 'images/checkmark.png'
        }
      ]
    }

  };

  if (subscriptions) {
    setTimeout(() => {
      subscriptions.forEach(pushConfig => {
        webpush
          .sendNotification(pushConfig.data(), JSON.stringify(pushPayload))
          .then(_ => console.log('message has been sent'))
          .catch(err => {
            console.log(`PushError ${err}`);
            // Check for "410 - Gone" status and delete it
            if (err.statusCode === 410) {
              pushConfig.ref.delete();
            }
          });
      });
    }, 3000);
  }
};

exports.saveNote = functions.https.onRequest((request, response) => {
  const { user, data } = request.body;

  cors(request, response, async () => {
    return admin
      .firestore()
      .collection(`users/${user}/notes`)
      .add(data)
      .then(async noteDoc => {
        const note = await noteDoc.get();
        const data = note.data();
        data.id = note.id;

        const subscriptions = await admin
          .firestore()
          .collection(`users/${user}/subscriptions`)
          .get();

        sendNotification(note.id, subscriptions);

        return response.status(201).json({
          succcess: true,
          data
        });
      })
      .catch(err => {
        console.log(err);

        response.status(500).json({
          error: err,
          succcess: false
        });
      });
  });
});

注意

我们在这个例子中使用了 Node.js,但是您也可以使用其他语言,比如 Python、Java 和 Go。随意选择你喜欢的。要了解更多信息,您可以查看 Firebase 文档网站。

让我们分解代码。

  1. 我们已经导入了函数所需的库。如你所见,我使用web-push库来发送通知。

    const admin = require('firebase-admin');
    const functions = require('firebase-functions');
    const webpush = require('web-push'); // to send Push Notification
    const cors = require('cors')({ // to solve CORS issue we use this library
      origin: true
    });
    
    

    webpush库将执行以下步骤:

    • 消息的有效负载将使用 p256dh 公钥和 auth 认证秘密进行加密

    • 然后,将使用 VAPID 私钥对加密的有效负载进行签名

    • 然后,消息将被发送到订阅对象的 endpoint 属性中指定的 Firebase Cloud 消息端点

  2. 要初始化应用,您需要传递必要的凭证和数据库 URL。当你拿到这个凭证,你应该去 Firebase 控制台设置,然后服务账户标签。选择 Admin SDK language,在本例中是 Node.js,然后单击 *Generate new private key。下载一个包含所有必要凭证的 JSON 文件。确保这些信息的安全,绝不公开泄露,这一点很重要。比如我的 JSON 文件已经添加到.gitignore

    const serviceAccount = require('./awesome-apress-pwa-firebase-adminsdk-l9fnh-6b35c787b9.json');
    
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
      databaseURL: 'https://awesome-apress-pwa.firebaseio.com'
    });
    
    ```* 
    
  3. saveNote功能将保存一个注意到数据库,然后我们从数据库中检索用户的订阅,并将发送推送通知给用户。您可能希望在应用中实现不同的逻辑来发送推送通知。然而,发送通知本身和下面描述的是一样的。如上所述,这个端点将在DataService中使用。

  4. sendNotification:这个函数非常简单.

    1. 通过调用webpush.setValidDetails(),设置 VAPID 细节,你需要传递一封电子邮件,公共和私人 VAPID 密钥。

    2. 通过调用webpush.sendNotification()发送通知。这个函数接受两个参数:订阅配置,我们已经为用户将它存储在数据库中,后面是推送负载。它回报一个承诺。如果通知发送成功,Promise 将会解决。基本上,这意味着订阅配置仍然有效。但是,如果订阅配置中出现错误,例如当用户取消订阅时,向该特定端点发送通知将被拒绝,状态代码将为 410,这意味着该端点已消失。因此,诺言拒绝了。Catch 块是我们通过删除失效的订阅配置来清理数据库的地方。

      // Check for "410 - Gone" status and delete it
                  if (err.statusCode === 410) {
                    pushConfig.ref.delete();
                  }
      
      

lPush 消息正文

Angular 服务工作器需要特定的格式来正确显示推送通知。正如所见,在上面的示例代码中,它是一个根对象,只有一个属性,即notification。在该属性中,我们将定义我们的推送消息配置。

让我们来分解一下:

记住ServiceWorkerRegistration.showNotification(title, [options]),这里是选项的属性,传递给服务工人中的showNotification():

  • title:通知中必须显示的标题。这个标签在 Angular Service Worker 中使用,作为第一个参数传递给showNotification函数。其余的属性作为一个名为 options 的对象在 show Notification functions 的第二个参数中传递。

  • body:表示通知中显示的额外内容的字符串

  • icon:通知要用作图标的图像的 URL

  • badge:当没有足够的空间来显示通知本身时,表示通知的图像的 URL。比如安卓设备上的安卓通知栏。

  • dir:通知的方向;可以是autoltrrtl

  • lang:指定通知中使用的语言

  • image:通知中要显示的图像的 URL。

  • renotify:一个Boolean,表示重用标签值时是否抑制振动和声音报警。默认值为 false。如果您在没有标签的通知上设置 renotify: true,您将得到以下错误:

    TypeError: Failed to execute 'showNotification' on 'ServiceWorkerRegistration':
     Notifications which set the renotify flag must specify a non-empty tag
    
    
  • requireInteraction:表示在屏幕足够大的设备上,通知应该保持活动状态,直到用户点击或取消它。如果该值不存在或为假,桌面版 Chrome 将在大约 20 秒后自动最小化通知。默认值为 false。

  • silent:此选项允许您显示新的通知,但会阻止振动、声音和打开设备显示屏的默认行为。如果同时定义了 silent 和 renotify,则 silent 优先。

  • tag:将通知“分组”在一起的字符串 ID,提供了一种简单的方法来确定如何向用户显示多个通知。

  • vibrate:显示通知时运行的振动模式。振动模式可以是只有一个成员的阵列。Android 设备尊重这种选择。

  • timestamp:显示通知的时间戳。

  • data:我们希望与通知相关联的任何数据类型。

  • 动作:要在通知中显示的动作数组。数组的成员应该是对象文本。它可能包含以下值:

    • action:要在通知上显示的用户动作。

    • title:显示给用户的文本。

    • icon:与动作一起显示的图标的 URL。

notificationclick事件中使用event.action构建适当的响应。

注意

静默推送通知现在包含在预算 API 中, 4 ,这是一个新的 API,旨在允许开发人员在不通知用户的情况下执行有限的后台工作,例如静默推送或执行后台获取。

这些全面的选项在每个平台上的表现各不相同。在写这本书的时候,Chrome,尤其是 Android 上的 Chrome,已经实现了所有这些选项。如果浏览器不支持这些选项中的一个或多个,它们很可能会被忽略。

发送推送通知后,所有订阅的用户浏览器都会在通知中心显示通知(见图 8-12 和 8-13 )。

img/470914_1_En_8_Fig13_HTML.jpg

图 8-13

Android 中的通知

img/470914_1_En_8_Fig12_HTML.jpg

图 8-12

通知显示在 Mac 上,包括 Chrome 和 Firefox

收听有 Angular 的消息

SwPush服务提供了一种可观察性,让我们能够倾听每一条信息。我们可能需要根据收到的信息执行不同的操作。

// header.componetnt.ts

constructor(
    private auth: AuthService,
    private swPush: SwPush,
    private snackBar: SnackBarService,
    private dataService: DataService,
    private router: Router
  ) {

    this.swPush.messages.subscribe((msg: { notification: object }) =>
      this.handlePushMessage(msg)
    );

  }

我们倾听并做我们想做的。例如,在这种情况下,我们只需要在小吃店向用户显示通知主体。

  handlePushMessage({ notification }) {
    this.snackBar.open(`Push Notification: ${notification.body}`);
  }

这已经存在了,但是如果用户点击通知呢?让我们在下一节探讨这个问题。

通知操作和处理通知点击事件

在服务工作器中,就像当我们收听installpush事件时,我们也可以收听notificationclick事件。因为我们已经在通知选项上实现了actions,我们将知道用户点击了什么,一个动作或者任何其他地方。这使得应用可以根据用户的选择非常灵活地处理我们想要做的事情。这个特性在 Angular Service Worker 版之前是不可用的,7.1 版在SwPush服务上引入了一个新的可观察对象notificationClicks。当前的实现有一些限制,因为这些事件是在应用中处理的,所以应该在浏览器中打开。

// header.componetnt.ts
constructor(
    private auth: AuthService,
    private swPush: SwPush,
    private snackBar: SnackBarService,
    private dataService: DataService,
    private router: Router
  ) {

    this.swPush.messages.subscribe((msg: { notification: object }) =>
      this.handlePushMessage(msg)
    );

    this.swPush.notificationClicks.subscribe(options =>
      this.handlePushNotificationClick(options)
    );
  }

传递的选项有两个属性:action,,这是用户在单击通知动作时选择的;和notification,,它们都是推送给用户的通知属性。

handlePushNotificationClick({ action, notification }) {
    switch (action) {
      case 'open': {
        this.router.navigate(['notes', notification.data.noteID, { queryParams: { pushNotification: true } }]);
        break;
      }
      case 'cancel': {
        this.snackBar.dismiss();
      }
        // or anything else
    }
  }

作为一个例子,在data属性中,我们定义了nodeID;,并且我们实现了当用户点击open动作时,我们将应用重定向到详细注释视图。

添加一些指标来衡量有多少用户点击了通知可能是个好主意。例如,你可以发送一些分析或者添加一个queryParams

注意

但是请记住,actions并不是所有的浏览器都支持的。所以,为你的应用做一个备份,以防你由于缺乏浏览器支持而面临undefined

部署到火力基地

看来我们已经实现了我们对应用 PWA 注释的需求。我将一如既往地通过运行以下命令将应用部署到 Firebase:

npm run deploy

摘要

在本章中,我们探索了另一个类似本机的特性,现在离构建一个类似本机应用的 PWA 又近了一步。

在下一章,我将把你的注意力转移回持久数据。虽然我们已经在运行时缓存了动态数据,但是您可以在应用中使用不同的解决方案和架构来帮助在用户浏览器中保存数据,并在必要时将其同步回服务器。这为我们的用户提供了一个强大的能力,可以完全脱机使用我们的应用,并帮助我们构建一个更快、更可靠、更高性能的应用。

九、弹性 Angular 应用和离线浏览

PWAs 的一个重要方面是构建一个可以离线服务的应用的概念。到目前为止,我们已经开发了一个应用并启用了离线功能。我们已经看到了 Service Worker 的威力,它通过利用缓存 API 完成了大部分存储静态资产和动态内容的繁重工作。总的来说,与传统的 web 应用相比,这一成就意义重大。

然而,仍有改进的余地。假设您正在构建一个通过 REST API 进行通信的应用。虽然服务工作器正在促进缓存内容和更快地提供服务,但一旦必须应用网络优先策略,它对糟糕的互联网连接没有帮助,并且响应和请求有很长的延迟。或者我们应该如何处理应用状态或应用数据集?

在 PWA Note 应用中,用户的体验最有可能被中断,因为如果他们的互联网连接不好,我们会让他们等待,直到向服务器发送消息成功完成。事实上,超过 10 秒的延迟往往会使用户立即离开一个站点。如果你的业务依赖于这款应用,缓慢和缺乏可接受的用户体验可能会突然影响你的业务。

在这一章中,我将探索一种方法,无论用户的设备没有连接、连接有限还是连接良好,都可以提供一致的用户体验。这种模式将延迟降至零,因为它提供了对直接存储在设备上的内容的访问,并通过 HTTP 同步所有用户设备中的数据。

离线存储

在 HTML5 之前,应用数据必须存储在 cookies 中,包括在每个服务器请求中,而它被限制在 4 KB 以内。网络存储不仅更加安全,而且能够在不影响网站性能的情况下在本地存储大量数据。它是基于原点的,来自同一原点的所有页面都可以存储和访问相同的数据。web 存储器中的两种机制如下:

  • sessionStorage为每个给定的原点维护一个单独的存储区域,该区域在页面会话期间可用(只要浏览器打开,包括页面重新加载和恢复)。

  • 做同样的事情,但即使在浏览器关闭并重新打开时仍然存在。

这个 API 有两个缺点:

  1. 当你想存储(只有字符串)时,你需要序列化和反序列化数据。

  2. API 是同步的,这意味着它阻塞了应用,并且没有 Web Worker 支持。

由于这些问题,我们将重点转移到其他选项上,以便在 Web Worker 中获得更好的性能和支持。

  • WebSQL是异步的(基于回调);然而,它也没有 Web Worker 支持,并被 Firefox 和 Edge 拒绝,但在 Chrome 和 Safari 中。也贬值了。

  • 也是异步的(基于回调),并且在 Web Workers 和 Windows 中工作(尽管使用了同步 API)。不幸的是,它在 Chrome 之外并没有太大的吸引力,而且是沙箱化的(意味着你不能获得原生文件访问)。

  • File API正在改进文件和目录条目 API 和文件 API 规范。有一个文件 API 库,为了保存文件,我一直使用 FileSaver.js 作为权宜之计。可写文件的提议可能最终会给我们一个更好的无缝本地文件交互的标准跟踪解决方案。

  • IndexedDB是一个键值对 NoSQL 数据库,支持大规模存储(高达 20%–50%的硬盘容量),支持多种数据类型,如数字、字符串、JSON、blob 等。因为它是异步的,所以它可以在任何地方使用,包括 Web 工作器,并且在浏览器中得到广泛支持。

  • Cache API为缓存的请求/响应对象对提供存储机制,例如,作为服务工作器生命周期的一部分。请注意,缓存接口向窗口范围和工作线程公开。

正如我们所见,似乎最佳选项是IndexedDBCache API。两种 API 的结合使它更加可靠,并提供了更好的用户体验。我们使用缓存 API 来存储 URL 可寻址资源,如静态文件,并从 REST APIs 请求和响应。对于如何使用和构建应用来利用这些 API,并没有硬性规定。有些应用可能非常简单,可以单独使用缓存 API,而其他应用可能会发现在 IDB 中部分缓存 JSON 有效负载很有价值,因此在没有缓存 API 支持的浏览器中,您仍然可以在会话期间获得一些本地缓存的好处。

注意

API 很强大,但是对于简单的情况来说似乎太复杂了。我推荐尝试像LocalForage, Dexie.js, zangoDB, PouchDB, LoxiJs, JsStore, IDB, LokiJs这样的库,它们有助于包装IndexedDBAPI,这使得它对程序员更友好。此外,这个 API 在 Safari 10 中漏洞百出,运行缓慢;因此,其中一些库在 Safari 中实现了回退到WebSQL,而不是indexedDB,以获得更好的性能。虽然这个问题已经解决,而且IndexedDB在所有主流浏览器中都是稳定的,但是如果你的应用因为某些原因面向旧的浏览器,你可能需要使用建议的库:例如Localforage

尽管没有具体的架构,但建议

  • 对于离线时加载应用所需的网络资源,请使用Cache.

  • 对于所有其他数据,使用IndexedDB,例如,应用状态和数据集是存储在IndexedDB中的最佳候选者。

离线优先方法

构建 web 应用的一种常见方式是作为后端服务器的消费者来存储和检索数据以实现持久性(图 9-1 )。

img/470914_1_En_9_Fig1_HTML.png

图 9-1

传统 web 应用中的数据绑定方式

这种方法的一个问题是,不稳定或不存在的互联网连接可能会中断用户体验,并导致不可靠的性能。为了解决这一问题,我们使用了服务工作器,并将利用其他存储技术来大幅改善所有情况下的用户体验,包括完美的无线环境。

在这种方法中(如图 9-2 所示),用户不断地与存储在客户端设备中的缓存进行交互;因此,将会有零延迟。

img/470914_1_En_9_Fig2_HTML.png

图 9-2

离线优先方法,4 向数据绑定

如果需要,服务工作器可以拦截客户机和服务器之间的请求。我们甚至可以想到如何将我们的数据与服务器同步。

注意

由于 Service Worker 中的后台同步事件,很容易解决同步问题。当我们实现 Workbox 时,我将在第十四章中探讨sync事件,因为这个特性目前在 Angular Service Worker(Angular 7.1)中还不可用。

我将进一步对这个模型进行一些调整。如果我们可以实现一个逻辑,无论用户在线还是离线,它都可以与服务器同步数据;因此,该服务器可以操纵数据并在之后进行必要的调整(见图 9-3 和 9-4 )。想想这种方法能在多大程度上改善用户体验。

img/470914_1_En_9_Fig4_HTML.png

图 9-4

数据可以通过所有用户的设备从/向同步服务器分发和同步

img/470914_1_En_9_Fig3_HTML.png

图 9-3

考虑同步的离线优先方法

让我们在 PWA Note 应用中试验离线第一数据库方法,看看它是如何工作的。

使用同步服务器实现离线优先方法

我们已经发现IndexedDB是我们需要在客户端应用中使用的。下一个障碍是弄清楚如何存储和同步应用的数据和状态。离线同步比看起来更有挑战性。我认为克服这一障碍的最佳解决方案之一是使用PouchDB . 2 请记住,您并不局限于这一解决方案,您可能需要为您的应用实现自己的逻辑,或者使用另一个第三方。 3 总而言之,目标是实现离线第一缓存用于存储数据并相应地同步回服务器。

注意

PouchDB是一个开源的 JavaScript 数据库,受 Apache CouchDB 4 的启发,设计用于在浏览器中运行良好。PouchDB的创建是为了帮助 web 开发人员构建离线时和在线时一样好用的应用。它使应用能够在离线时在本地存储数据,然后在应用恢复在线时将其与CouchDB和兼容的服务器同步,无论用户下次登录到哪里,都可以保持用户数据的同步。

您也可以在没有同步功能的情况下使用PouchDB,但是为了离线功能,我在PouchDB中启用了同步和离线功能。

首先,我们需要安装pouchdb:

npm install pouchdb

pouchdb-browser preset包含为浏览器设计的PouchDB版本。特别是,它附带了作为默认适配器的IndexedDBWebSQL适配器。它还包含复制、HTTP 和 map/reduce 插件。如果你只想在浏览器中使用PouchDB,而不想在 Node.js 中使用preset(例如,为了避免安装LevelDB)。)

因此,我不安装 pouchdb,而是交替安装pouchdb-browser:

npm install pouchdb-browser

通过运行以下命令,在 Angular 中继续并创建新的服务:

ng g s modules/core/offline-db

为了创建一个远程同步数据库服务器,为了简单起见,我安装了pouchdb-server5

npm install -g pouchdb-server

运行 PouchDB 服务器:

pouchdb-server --port 5984

如果您克隆了项目存储库并想要查看示例代码,首先安装 npm 包,然后安装npm run pouchdb-server

OfflineDbService,中,我们需要实例化PouchDB。要进行同步,最简单的情况是单向复制,这意味着您只想让一个数据库将其更改镜像到另一个数据库。但是,对第二个数据库的写入不会传播回主数据库;然而,我们需要双向复制,让你可怜的、疲惫的手指做起来更容易;PouchDB有一个快捷 API。

import PouchDB from 'pouchdb-browser';

  constructor() {
// create new local database
    this._DB = new PouchDB(this.DB_NAME);
// shortcut API for bidirectional replication
    this._DB.sync(this.REMOTE_DB, {
      live: true,
      retry: true
    });
  }

注意

如果您在控制台中看到由于undefined global对象导致的错误,请在Polyfills.ts. 6 底部添加(window as any).global = window;

数据库已成功实例化;因此,需要实现 CRUD 操作。

public get(id: string) {
    return this._DB.get(id);
  }
  public async delete(id) {
    const doc = await this.get(id);
    const deleteResult = this._DB.remove(doc);
    return deleteResult;
  }

  public add(note: any) {
    return this._DB.post({
      ...note,
      created_at: this.timestamp,
      updated_at: this.timestamp
    });
  }

  public async edit(document: any) {
    const result = await this.get(document._id);
    document._rev = result._rev;
    return this._DB.put({
      ...document,
      updated_at: this.timestamp
    });
  }

为了从数据库中检索所有笔记,我定义了另一个函数getAll,在加载应用时我将调用这个方法来向我的用户显示笔记。

public async getAll(page?: number) {
    const doc = await this._DB.allDocs({
      include_docs: true,
      limit: 40,
      skip: page || 0
    });
    this._allDocs = doc.rows.map(row => row.doc);
    // Handle database change on documents
    this.listenToDBChange();
    return this._allDocs;
  }

PouchDB提供了一个changes() method,它是一个事件发射器,将在每次文档更改时发出一个'change'事件,在所有更改都被处理后发出一个'complete'事件,在出现错误时发出一个'error'事件。调用cancel()会自动退订所有事件监听器。

  listenToDBChange() {
    if (this.listener) {
      return;
    }

    this.listener = this._DB
      .changes({ live: true, since: 'now', include_docs: true })
      .on('change', change => {
        this.onDBChange(change);
      });
  }

从现在开始,我们有了一个可以检测每个文档变化并相应地操作数据的监听器。例如,在OfflineDbServiceonDBChange方法中,我实现了一个非常简单的逻辑来检测文档发生了什么类型的变化,并基于此运行一个逻辑。

private onDBChange(change) {
    this.ngZone.run(() => {
      const index = this._allDocs.findIndex(row => row._id === change.id);

      if (change.deleted) {
        this._allDocs.splice(index, 1);
        return;
      }

      if (index > -1) {
        // doc is updated
        this._allDocs[index] = change.doc;
      } else {
        // new doc
        this._allDocs.unshift(change.doc);
      }
    });
  }

总之,OfflineDBServer看起来如下:

export class OfflineDbService {
  private readonly LOCAL_DB_NAME = 'apress_pwa_note';
  private readonly DB_NAME = `${this.LOCAL_DB_NAME}__${this.auth.id}`;
  private readonly REMOTE_DB = `http://localhost:5984/${this.DB_NAME}`;
  private _DB: PouchDB.Database;
  private listener = null;
  private _allDocs: any[];

  get timestamp() {
    return;
  }

  constructor(private auth: AuthService, private ngZone: NgZone) {
    this._DB = new PouchDB(this.DB_NAME);
    this._DB.sync(this.REMOTE_DB, {
      live: true,
      retry: true
    });
  }

  listenToDBChange() {
    if (this.listener) {
      return;
    }

    this.listener = this._DB
      .changes({ live: true, since: 'now', include_docs: true })
      .on('change', change => {
        this.onDBChange(change);
      });
  }

  private onDBChange(change) {

    console.log('>>>>>> DBChange', change);
    this.ngZone.run(() => {
      const index = this._allDocs.findIndex(row => row._id === change.id);

      if (change.deleted) {
        this._allDocs.splice(index, 1);
        return;
      }

      if (index > -1) {
        // doc is updated
        this._allDocs[index] = change.doc;
      } else {
        // new doc
        this._allDocs.unshift(change.doc);
      }
    });
  }

  public async getAll(page?: number) {
    const doc = await this._DB.allDocs({
      include_docs: true,
      limit: 40,
      skip: page || 0
    });
    this._allDocs = doc.rows.map(row => row.doc);
    // Handle database change on documents
    this.listenToDBChange();
    return this._allDocs;
  }

  public get(id: string) {
    return this._DB.get(id); 

  }

  public async delete(id) {
    const doc = await this.get(id);
    const deleteResult = this._DB.remove(doc);
    return deleteResult;
  }

  public add(note: any) {
    return this._DB.post({
      ...note,
      created_at: this.timestamp,
      updated_at: this.timestamp
    });
  }

  public async edit(document: any) {
    const result = await this.get(document._id);
    document._rev = result._rev;
    return this._DB.put({
      ...document,
      updated_at: this.timestamp

    });
  }
}

现在我需要改变所有的组件,将DataService替换为OfflineDbService.NotesListComponent:

constructor(
    private offlineDB: OfflineDbService,
  ) {}

  ngOnInit() {
// here is we call getAll() and consequesntly subscribe to change listerner
    this.offlineDB.getAll().then(allDoc => {
      this.notes = allDoc;
    });
  }

NotesAddComponent上的onSaveNote()更新为

constructor(
    private router: Router,
    private offlineDB: OfflineDbService,
    private snackBar: SnackBarService
  ) {}

  onSaveNote(values) {
    this.loading$.next(true);

// Notice we add everything to local DB

    this.offlineDB.add(values).then(
      doc => {
        this.router.navigate(['/notes']);
        this.snackBar.open(`LOCAL: ${doc.id} has been succeffully saved`);
        this.loading$.next(false);
      },
      e => {
        this.loading$.next(false);
        this.errorMessages$.next('something is wrong when adding to DB');
      }
    );
  }

这里是对NoteDetailsComponent的相同更改,其中我们有EditGetDelete操作。

constructor(
    private offlineDB: OfflineDbService,
    private route: ActivatedRoute,
    private snackBar: SnackBarService,
    private router: Router
  ) {}

  ngOnInit() {
    const id = this.route.snapshot.paramMap.get('id');
    this.id = id;
    this.getNote(id);
  }

  getNote(id) {

// get note from offline DB

    this.offlineDB.get(id).then(note => {
      this.note = note; 

    });
  }

  delete() {
    if (confirm('Are you sure?')) {

// delete note from offline DB

      this.offlineDB
        .delete(this.id)
        .then(() => {
          this.router.navigate(['/notes']);
          this.snackBar.open(`${this.id} successfully was deleted`);
        })
        .catch(e => {
          this.snackBar.open('Unable to delete this note');
        });
    }
  }

  edit() {
    this.isEdit = !this.isEdit; 

  }

  saveNote(values) {

// edit in offline DB

    this.offlineDB
      .edit(values)
      .then(() => {
        this.getNote(values._id);
        this.snackBar.open('Successfully done');
        this.edit();
      })
      .catch(e => {
        this.snackBar.open('Unable to edit this note');
        this.edit();
      });
  }

是时候测试应用了,我们不一定需要 Service Worker 因此,我们可以简单地在本地以开发模式运行我的应用。因此,运行npm start然后导航到localhost:4200来查看应用。尝试添加新注释并观察控制台消息(参见图 9-5 )。

img/470914_1_En_9_Fig5_HTML.jpg

图 9-5

对于数据库中的每个更改,都会发出更改对象

如图 9-5 所示,每个文档都有一个自动添加的_id_rev属性。change 对象包含所有必要的信息,我们可以在应用逻辑中使用这些信息来操作数据。

注意

响应中的rev字段表示文档的修订。每个文档都有一个名为_rev的字段。每次更新文档时,文档的_rev字段都会改变。每个修订版都指向它以前的修订版。PouchDB维护每个文档的历史(很像 git)。_rev允许PouchDBCouchDB优雅地处理冲突,还有其他好处。

在你的电脑上打开两个不同的浏览器,例如 Chrome 和 Firefox,并在每个浏览器上打开应用。首先,你会注意到你在两个浏览器上都有完全相同的笔记。现在在一个浏览器中添加一个新的便签,勾选另一个(见图 9-6);你会注意到新的笔记会很快出现在另一个打开应用的浏览器中。

img/470914_1_En_9_Fig6_HTML.jpg

图 9-6

该应用在两个不同的浏览器(设备)上运行,通过从一个浏览器添加笔记,一旦它被添加到同步服务器,就会发出更改,笔记会立即出现在另一个浏览器(设备)上

到目前为止还不错;您会注意到,显示或添加注释的延迟为零,因为内容会先添加到缓存中,然后再与服务器同步。因此,我们的用户不会注意到缓存和服务器之间的延迟。

如果我们的用户离线了怎么办?让我们来测试一下。我们将通过在 Chrome 中检查离线来断开网络连接,然后尝试从 Safari 中删除一个仍然在线的笔记,并从 Chrome 浏览器中添加一个离线的笔记(见图 9-7 和 9-8 )。

注意

PouchDB有两种类型的数据:文档和附件。

文档

和在CouchDB中一样,您存储的文档必须是可序列化的 JSON。

附件

PouchDB也支持附件,这是存储二进制数据最有效的方式。附件可以作为 base64 编码的字符串或 Blob 对象提供。

img/470914_1_En_9_Fig8_HTML.jpg

图 9-8

即使用户脱机,也可以在浏览器(设备)中添加便笺。应用允许用户添加此注释;但是,在用户重新联机之前,它不会反映在远程数据库上。

img/470914_1_En_9_Fig7_HTML.jpg

图 9-7

从另一个在线的浏览器中删除一个便笺会反映到远程数据库中,但是由于另一个浏览器是离线的,所以它不会收到更新

一旦我完成,我会让 Chrome 网络再次上线,并会等待一段时间。几秒钟后,你会看到两个浏览器中的应用将成功同步(见图 9-9 )。

img/470914_1_En_9_Fig9_HTML.jpg

图 9-9

当用户恢复在线时,两个浏览器(设备)中的应用会同步

用户体验没有出现中断,并且有快速的性能和可靠的数据和同步——这难道不令人惊讶吗?

如前所述,PouchDB是实现离线优先方法的一种方式。根据您的应用和需求,您可以使用不同的库,甚至是您自己的实现,直接使用IndexedDBAPI。

用 Angular Firebase 实现持久数据

云 Firestore 支持离线数据持久化。此功能会缓存您的应用正在使用的云 Firestore 数据的副本,以便您的应用可以在设备离线时访问这些数据。您可以写、读、听和查询缓存的数据。当设备恢复在线时,云 Firestore 会将您的应用所做的任何本地更改同步到远程存储在云 Firestore 中的数据。

Offline persistence is an experimental feature that is supported only by the Chrome, Safari, and Firefox web browsers.

要启用离线持久化,在将AngularFirestoreModule导入到您的@NgModule时,必须调用enablePersistence():

@NgModule({
  declarations: [AppComponent, LoadingComponent],
  imports: [
    CoreModule,
    LayoutModule,
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    HttpClientModule,
    AppRoutingModule,
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule.enablePersistence(),
    // AngularFirestoreModule, // needed for database features
    AngularFireAuthModule, // needed for auth features,
    BrowserAnimationsModule, // needed for animation
    ServiceWorkerModule.register('ngsw-worker.js', {
      enabled: environment.production
    }),
    RouterModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}
If a user opens multiple browser tabs that point to the same Cloud Firestore database, and offline persistence is enabled, Cloud Firestore will work correctly only in the first tab. However, As of September 2018, experimental multi-tab is available for you to play with. You just need to pass {experimentalTabSynchronization: true} to enbalePersistence() function such as:

AngularFirestoreModule.enablePersistence({experimentalTabSynchronization: true})

接下来,我们需要确保我们使用的是 Angular Firestore APIs。

比如在NotesListComponent中,用getNotes()的方法代替initializedNotes()

ngOnInit() {
        this.notes$ = this.db.getNotes();
            // this.notes$ = this.db.initializeNotes();
}

NoteDetailsComponent中,使用getNote()方法代替getNoteFromDirectApi():

  ngOnInit() {
    const id = this.route.snapshot.paramMap.get('id');
    this.id = id;
    this.note$ = this.data.getNote(id);
    // this.note$ = this.data.getNoteFromDirectApi(id);
  }

并且在NotesAddComponent中,调用DataService上的addNote()方法。

onSaveNote(values) {
    this.data.addNote(values).then(

      doc => {
        this.snackBar.open(`LOCAL: ${doc.id} has been succeffully saved`);
      },
      e => {
        this.errorMessages$.next('something is wrong when adding to DB');
      }
    );
    this.router.navigate(['/notes']);
  }

运行应用并断开网络连接。即使您处于脱机状态,也可以添加便笺;一旦你重新上线,数据就会同步回 Firestore。

我们可以通过运行以下命令将应用部署到 Firebase:

npm run deploy

用户界面注意事项

想象一下,即使用户离线,我们的应用也能工作。用户将继续添加内容和修改越来越多。用户通常不会注意到由于速度慢或没有互联网连接而导致的数据不同步。在这种情况下,可以在应用中完成一些 UI 考虑事项,以向用户显示一些信号,表明他们是离线还是在线:

  1. 将页眉和页脚的颜色更改为其他颜色,以表明它们处于脱机状态;例如,在笔记应用中,当用户离线时,我们可以将蓝色标题变灰。

  2. 当用户脱机时显示通知或弹出窗口;例如,当用户在 Note PWA 应用中添加笔记时,我们可以显示一条消息,表明您处于离线状态,但我们会在您在线时将数据同步回服务器。

  3. 显示一个图标或其他指示,清楚地表明即使添加了笔记,它还没有与服务器同步,只存在于用户本地设备上。

  4. 基于用户决策解决冲突;例如,当所有设备离线时,用户可能同时在不同的设备中编辑笔记,而当所有设备再次在线时,每个修订之间可能会有冲突。在这种情况下,向用户显示一个通知并告诉他们基于他们的编辑有不同的修订是一个好的做法;因此,他们可以选择需要应用哪个更新。

这些只是一些想法。基于你的应用,你可能会有更好的想法。重要的是增强用户界面,同时添加更多的功能和特性来提升用户体验。

最后但同样重要的是,通过监听 navigator.connection 上的更改事件,我们可以根据相应的更改对适当的逻辑做出反应。例如,看看下面的函数,从中我们可以找到更多的网络信息:

  constructor(
    private auth: AuthService,
    private swPush: SwPush,
    private snackBar: SnackBarService,
    private dataService: DataService,
    private router: Router
  ) {
    (<any>navigator).connection.addEventListener('change', this.onConnectionChange);
  }

  onConnectionChange() {
    const { downlink, effectiveType, type } = (<any>navigator).connection;

    console.log(`Effective network connection type: ${effectiveType}`);
    console.log(`Downlink Speed/bandwidth estimate: ${downlink}Mb/s`);
    console.log(
      `type of connection is ${type} but could be of bluetooth

, cellular, ethernet, none, wifi, wimax, other, unknown`
    );

    if (/\slow-2g|2g|3g/.test((<any>navigator).connection.effectiveType)) {
      this.snackBar.open(`You connection is slow!`);
    } else {
      this.snackBar.open(`Connection is fast!`);
    }
  }

如您所见,您可以根据网络信息的变化编写自己的逻辑。

注意

如果你想在你的机器上看到并运行所有的例子和代码,只需克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,然后转到chapter09。对于pouchdb的实现,你会发现01-pouchdb;进入文件夹,首先通过运行npm install安装所有软件包,然后通过分别运行npm startnpm run pouchdb-server运行 app 和pouchdb-server。对于Firestore实施,分别进入02-firebase-presistent-db,运行npm installnpm start

摘要

PWAs 的一个主要方面是增强用户体验。提供离线体验——无论是在交通工具上的脆弱连接还是在飞机上的离线——对于提高用户满意度和改善应用的性能都是非常重要的。

为了在离线情况下支持有意义的体验,我们不仅应该缓存静态资产、请求和响应,而且在客户端存储数据似乎也是必不可少的。通过重新思考如何在前端构建应用并使其离线——首先通过利用浏览器离线存储,如IndexedDB,以及可用的库之一(PouchDB),,应用已被提升到下一个级别。

十、调试和测量工具

作为开发人员,我们每天都在使用调试工具,我们无法想象没有它们的编码。为了开发 PWA,我们可能需要更多的工具来帮助我们检查代码、查找 bug、运行、模拟离线模式以及测试服务工作器。为了逐步增强我们的应用,衡量不同的方面,如绩效和 PWA 标准,以及通过跟踪的参与度,似乎也很重要。

在这一章中,我将探索许多工具,这些工具将帮助我们更轻松愉快地检查、调试和开发以及测量 PWA。尽管您可能会觉得这些工具很熟悉,但我仍然希望将它们都放在一个章节中,以便您可以随时查阅。

排除故障

首先,让我们从研究调试的可能性开始。

NGW 调试

Angular 服务工作器有一个特定的 URL,以便检查ngsw的状态。要访问它,您应该导航到/ngsw/state到您的网站基本 URL。

例如,如果您在本地机器上运行生产应用,您应该能够导航到https://localhost:3000/ngsw/state并查看信息,如下所示:

NGSW Debug Info:

Driver state: NORMAL ((nominal))
Latest manifest hash: b15d32a87eae976c0909801e2b8962df20a7deec
Last update check: 13s304u

=== Version b15d32a87eae976c0909801e2b8962df20a7deec ===

Clients: 9d63b22a-f76b-f642-aab4-e6c8e627f66a, 20e02d5b-746e-8e48-b04e-232d3a43e760, 40ccc813-b89f-5643-8e67-a6e93b688ee9

=== Idle Task Queue ===
Last update tick: 13s647u
Last update run: 8s646u
Task queue:

Debug log:
[13s638u] Error(Response not Ok (fetchAndCacheOnce): request for https://fonts.googleapis.com/icon?family=Material+Icons returned response 0 , fetchAndCacheOnce/<@https://awesome-apress-pwa.firebaseapp.com/ngsw-worker.js:589:31
fulfilled@https://awesome-apress-pwa.firebaseapp.com/ngsw-worker.js:312:52
) while running idle task revalidate(ngsw:b15d32a87eae976c0909801e2b8962df20a7deec:assets, assets): https://fonts.googleapis.com/icon?family=Material+Icons

这种状态可以帮助您找到有用的信息,使调试更容易。

Web 应用清单

web 清单允许您控制应用在启动和向用户显示时的行为。除了服务工作器,它还为用户提供了添加到主屏幕选项。在第六章中,我们深入研究了 web 应用清单。

Chrome DevTools(铬 DevTools)

Chrome DevTools 打开后,进入 应用 面板,点击 清单 进行检查(见图 10-1 )。

img/470914_1_En_10_Fig1_HTML.jpg

图 10-1

Chrome 中的清单检查器

  • 若要查看清单源,请单击应用清单标签下方的链接。

  • 按下添加到主屏幕按钮,模拟添加到主屏幕事件。在 Chrome 桌面上,它触发浏览器将应用添加到货架上。在手机上,它会提示用户安装应用(将图标添加到主屏幕)。

  • Identity 和 Presentation 部分只是以更加用户友好的方式显示来自清单源的字段。

  • 图标部分显示您指定的每个图标。

在线验证器

很容易找到许多也可以验证 web 应用清单的网站和在线工具,例如, manifest-validator.appspot.com

在线发电机

有时候,生成 web 应用清单可能很耗时或者很单调。于是,在线生成器就派上了用场,比如tomitm.github.io/appmanifest

服务工作器

服务工作器为开发人员提供了拦截网络请求和创建真正离线优先的 web 应用的惊人能力。在第 4 和 5 章节中,我们通过 Angular 服务工作器介绍了服务工作器。

Chrome DevTools(铬 DevTools)

打开 DevTools 并进入应用面板(见图 10-2 )。点击服务工作器。

img/470914_1_En_10_Fig2_HTML.jpg

图 10-2

Chrome DevTools 中的服务工作器调试器

  • 离线将相应标签页中的网站离线。

  • 重新加载时的更新强制服务工作器在每次页面加载时进行更新。

  • 绕过网络绕过服务工作器,并强制浏览器到网络获取请求的资源。

  • 更新对指定的服务工作器执行一次性更新。

  • Push 模拟带有特定消息的推送通知。

  • Sync 模拟带有特定标签的后台同步事件。

  • 注销注销指定的服务工作器。

  • 告诉你当前运行的服务工作器是什么时候安装的。如果您点击,它会将您重定向到面板下的维修工人源。

  • 状态告诉您服务工作器的状态。由于服务工作器被设计为可以在任何时候被浏览器停止和启动,我们可以使用 stop 按钮显式地停止服务工作器,这将模拟它来揭示由于对持久全局状态的错误假设而导致的错误。

  • 客户端告诉您服务工作器的作用域的来源。

Firefox DevTools

about:debugging页面提供了与服务工作器交互的界面。about:debugging有几种不同的打开方式;然而,我会鼓励你打开调试器,只需在 Firefox 地址栏中输入命令。

你会看到一些选项,比如 push、debug 和 unregister,这类似于一个没有有效负载的 Chrome expect push emulate push 事件(见图 10-3 )。

img/470914_1_En_10_Fig3_HTML.jpg

图 10-3

Firefox DevTools 中的服务工作器调试器

服务工作器模拟

Pinterest 的工程师开发了一套工具来与服务工作器合作。 Service Worker Mock 是一个库,它创建了一个具有以下属性的环境,可以很容易地将 Node.js 环境转换成一个仿 Service Worker 环境,并且在您需要编写集成测试时会很有帮助。

const env = {
  // Environment polyfills
  skipWaiting: Function,
  caches: CacheStorage,
  clients: Clients,
  registration: ServiceWorkerRegistration,
  addEventListener: Function,
  Request: constructor Function,
  Response: constructor Function,
  URL: constructor Function,

  // Test helpers
  listeners: Object,
  trigger: Function,
  snapshot: Function,
};

服务工作器模拟的最佳使用方法是将其结果应用到全局范围,然后调用 require('。/service-worker.js') 与您的服务工作器文件的路径。该文件将使用全局模拟来添加事件侦听器。让我们编写一个简单的测试:

// service-worker.js
const TESTCACHE = 'TESTCACHE';
const TESTCACHE_URLS = [
  'index.html',
  './' // Alias for index.html
];

self.addEventListener('install', event => {
  console.log('[SW.JS] Server worker has been installed');
  event.waitUntil(
    caches
      .open(TESTCACHE)
      .then(cache => cache.addAll(TESTCACHE_URLS))
      .then(self.skipWaiting())
  );
});

// The activate handler takes care of cleaning up old caches.
self.addEventListener('activate', event => {
  console.log('[SW.JS] Server worker has been activated');
  const currentCaches = [TESTCACHE];
  event.waitUntil(
    caches

      .keys()
      .then(cacheNames => cacheNames.filter(cacheName => !currentCaches.includes(cacheName)))
      .then(cachesToDelete => {
        return Promise.all(cachesToDelete.map(cacheToDelete => caches.delete(cacheToDelete)));
      })
      .then(() => self.clients.claim())
  );
});

self.addEventListener('push', event => {
  console.log(
    '[SWJ.S] Debug Push',
    event.data ? event.data.text() : 'no payload'
  );
});

self.addEventListener('sync', event => {
  console.log('[SWJ.S] Debug Sync', event.tag);
});

我将使用Jest框架和service-worker-mock库来编写我的测试。

// service-worker.test.js
const makeServiceWorkerEnv = require('service-worker-mock');
const makeFetchMock = require('service-worker-mock/fetch');

describe('Service worker', () => {
  beforeEach(() => {
    Object.assign(
      global,
      makeServiceWorkerEnv(),
      makeFetchMock()
      // If you're using sinon ur similar you'd probably use below instead of makeFetchMock
      // fetch: sinon.stub().returns(Promise.resolve())
    );
    jest.resetModules();
  });

  it('should add listeners', () => {
    require('./service-worker.js');
    expect(self.listeners['install']).toBeDefined();

    expect(self.listeners['activate']).toBeDefined();
    expect(self.listeners['push']).toBeDefined();
    expect(self.listeners['sync']).toBeDefined();
    expect(self.listeners['fetch']).toBeUndefined();
  });

  it('should delete old caches on activate', async () => {
    require('./service-worker.js');
    // Create old cache
    await self.caches.open('OLD_CACHE');
    expect(self.snapshot().caches.OLD_CACHE).toBeDefined();
    // Activate and verify old cache is removed
    await self.trigger('activate');
    expect(self.snapshot().caches.OLD_CACHE).toBeUndefined();
  });
});

运行Jestnpm test.

PASS  ./service-worker.test.js
  Service worker

    ✓ should add listeners (7ms)
    ✓ should delete old caches on activate (15ms)

  console.log service-worker.js:19
    [SW.JS] Server worker has been activated

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total

Snapshots:   0 total
Time:        0.77s, estimated 1s
Ran all test suites.

注意

克隆 https://github.com/mhadaily/awesome-apress-pwa.git 并进入the第十章目录查看样品。npm test将运行测试。

仓库

您可能已经熟悉了许多类型的 web 存储。web 存储的标准,如本地存储、会话存储、IndexedDB(广泛使用)、Web SQL 和 Cookies,在所有主流浏览器中都可以找到。我对 IndexedDB 特别感兴趣,一般可以用在服务工作器身上。

Chrome DevTools(铬 DevTools)

在 DevTools 中,导航至应用选项卡(参见图 10-4 )。选择 IndexedDB。

img/470914_1_En_10_Fig4_HTML.jpg

图 10-4

Chrome DevTools 中的 IndexedDB

通过右键单击对象存储,您可以找到一个清除动作,通过单击数据库名称,您可以找到删除和刷新数据库按钮,您可以在其中分别删除或刷新数据库(见图 10-5 )。

img/470914_1_En_10_Fig5_HTML.jpg

图 10-5

Chrome DevTools/中的清除、刷新、删除索引数据库

您可以通过对象存储列表 UI 顶部的小操作按钮来清除和刷新对象存储。您还可以删除选定的数据。您可以通过右键单击每条数据找到刷新和删除操作(参见图 10-6 )。

img/470914_1_En_10_Fig6_HTML.jpg

图 10-6

清除、刷新、删除对象存储上的索引数据库

Firefox DevTools

当您打开 Firefox DevTools 时,默认情况下您可能看不到存储面板。您应该从图 10-7 所示的设置中启用它。

img/470914_1_En_10_Fig7_HTML.jpg

图 10-7

从 Firefox 中的 DevTools 设置启用存储

启用后,点击存储面板,您会发现如图 10-8 所示的 IndexedDB。

img/470914_1_En_10_Fig8_HTML.jpg

图 10-8

Firefox DevTools 中的存储面板

躲藏

“缓存存储”窗格提供了使用缓存 API 缓存的资源的只读列表。

Chrome DevTools(铬 DevTools)

请注意,第一次打开缓存并向其中添加资源时,DevTools 可能不会检测到更改。重新加载页面,您应该会看到缓存。如果您打开了两个或更多缓存,您会看到它们列在缓存存储缓存存储下拉列表的下方(参见图 10-9 )。

img/470914_1_En_10_Fig9_HTML.jpg

图 10-9

Chrome DevTools 中的缓存存储

当使用缓存 API 加载由服务工作器缓存存储缓存的资源时,DevTools 的网络面板显示其来自服务工作器(参见图 10-10 )。

img/470914_1_En_10_Fig10_HTML.jpg

图 10-10

来自服务工作器的 Chrome DevTools 缓存存储中的网络请求

Firefox DevTools

如图 10-11 所示,缓存名称位于存储缓存下。

img/470914_1_En_10_Fig11_HTML.jpg

图 10-11

Firefox DevTools 中的缓存存储

当使用缓存 API 加载由服务工作器缓存存储缓存的资源时,Firefox 在网络面板中显示它被缓存(参见图 10-12 )。

img/470914_1_En_10_Fig12_HTML.jpg

图 10-12

来自服务工作器的 Firefox DevTools 缓存存储中的网络请求

模拟脱机行为

为了验证我们的应用离线时一切按计划运行,我们需要确保我们能够模拟没有连接。

Chrome 和 Firefox 提供了一个方便的特性,我们可以利用它来模仿离线模式。

除了应用面板下的服务工作器中的离线复选框,我们还可以使用网络面板下的离线复选框(参见图 10-13 )。

img/470914_1_En_10_Fig13_HTML.jpg

图 10-13

Chrome DevTools 网络面板下的离线模式

火狐浏览器

为了在 Firefox 中启用离线模式,点击菜单图标然后点击开发者➤离线工作(见图 10-14 )。

img/470914_1_En_10_Fig14_HTML.jpg

图 10-14

离线模式 Firefox

有时离线模拟器不能正常工作,你可能需要关闭你的网络并重新连接。例如,在我写这本书的时候,当你在 Service Worker 中使用后台同步时,你可能真的需要关闭你的互联网连接。

模拟不同的网络条件

在世界上的许多地方,3G 和 2G 速度是常态。此外,我们不断在各种连接状态之间移动。为了验证我们的应用对这些消费者是否有效,我们需要在不同的网络连接和设备中测试我们的应用。

在 Chrome 和 Firefox 中,我们都有一个节流选项,你可以在图 10-15 和 10-16 中找到。

img/470914_1_En_10_Fig16_HTML.jpg

图 10-16

Firefox 中的节流选项

img/470914_1_En_10_Fig15_HTML.jpg

图 10-15

Chrome 中网络选项卡下的节流选项;您可以根据需要添加自定义配置文件

注意

最终,Service Worker 是一个普通的 JavaScript 文件,在这里您可以使用所有的 JavaScript 调试特性,比如Debugger或 break point 来检查 Service Worker 内部的代码。

模拟移动设备

您可以在通过 USB 连接到浏览器的真实设备上运行您的 PWA,或者您可以运行仿真器并执行您的测试和检查您正在寻找的内容。

远程调试和测量

要将你的 Android 设备连接到 Chrome,你可以按照这个链接上的说明:https://goo.gl/syNfSR;进行操作,要连接到 Firefox,你可以在这个链接上找到说明:https://goo.gl/P7gFNE

仿真器

要设置和运行 iOS 模拟器,请点击此链接:https://goo.gl/ymihLs。而对于 Android,按照这个链接上的说明:https://goo.gl/EGPpxx

在线工具

BrowserStack 是一款跨浏览器的测试工具。有了它,您可以在多个操作系统和移动设备上跨浏览器测试您的网站,而无需单独的虚拟机、设备或模拟器。BrowserStack 还提供物理设备上的远程测试,所以如果你发现自己需要在许多设备上测试你的网站性能,它可以是一个有用的时间节省器。

尺寸

为了逐步交付高质量的应用,从速度、性能或用户体验等不同方面来衡量我们的应用总是很重要的。在这一节中,我将探索帮助我们更好地了解我们的应用的可能性,这允许我们不断地改进我们的应用。

审计

如前几章所述,由 Lighthouse 支持的 Chrome DevTools 中的审计面板是我们可以用来对应用进行审计的最佳工具之一。它有不同的选项,包括性能和 PWA(见图 10-17 )。

img/470914_1_En_10_Fig17_HTML.jpg

图 10-17

Chrome DevTools 审计标签中的灯塔,在这里可以选择和执行不同的审计

很可能我们会自动化我们的审计测试,或者将它添加到 CD/CI 1 管道中。Lighthouse 2 也可以作为节点命令行工具,也可以通过编程作为节点模块使用。

要在命令行中运行 Lighthouse,请执行以下操作:

  1. 确保安装了 Chrome for Desktop and Node。

  2. 安装灯塔。

    npm install -g lighthouse
    
    

    to run an audit

    lighthouse <url>
    
    

    for example

    lighthouse https://awesome-apress-pwa.firebaseapp.com --view
    
    

    You can see more options by running

    lighthouse --help
    
    

让我们看看如何以编程方式添加 Lighthouse。

带有铬发射器的灯塔

我们将编写一个运行chrome-launcher并执行灯塔审计的例子。当您为您的应用运行一个测试时,这个测试是很有帮助的,尤其是当您想要运行多个自动化测试时。

// lighthouse-chrome-launcher.js

const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');

function launchChromeAndRunLighthouse(url, opts, config = null) {
  return chromeLauncher
    .launch({ chromeFlags: opts.chromeFlags })
    .then(chrome => {
      opts.port = chrome.port;
      return lighthouse(url, opts, config).then(results => {
        // use results.lhr for the JS-consumeable output
        // https://github.com/GoogleChrome/lighthouse/blob/master/types/lhr.d.ts
        // use results.report for the HTML/JSON/CSV output as a string
        // use results.artifacts for the trace/screenshots/other specific case you need (rarer)
        return chrome.kill().then(() => results.lhr);
      });
    });
}

const opts = {
  chromeFlags: ['--show-paint-rects'],
  onlyCategories: ['performance', 'pwa'] // you can leave it empty for all audits
};

// Usage:

launchChromeAndRunLighthouse(

  'https://awesome-apress-pwa.firebaseapp.com',
  opts
).then(results => {
  // Use results!
  console.log({
    pwa: results.categories.pwa.score,
    performance: results.categories.performance.score
  });
});

当您运行这个文件时,您将得到结果,并且您可以根据分数添加您的逻辑。

node lighthouse-chrome-launcher.js

你会明白的

{ pwa: 1, performance: 0\. 95 }

例如,如果某个页面在 PWA 中的得分低于 0.5,您可以退出构建并要求改进该页面。

灯塔与木偶师 3

Puppeteer 是一个节点库,它提供了一个高级 API 来控制 Chrome 或 DevTools 协议上的 Chrome。默认情况下,Puppeteer 是无头运行的,但是可以配置为运行完整的(非无头)Chrome 或 Chrome。Lighthouse 和 Puppeteer 是在我们的 CD/CI 中运行审计的一个很好的组合,在那里我们不能使用 Chrome 启动器。

// lighthouse-puppeteer.js
const puppeteer = require('puppeteer');
const lighthouse = require('lighthouse');
const { URL } = require('url');
const run = async url => {
  // Use Puppeteer to launch headful Chrome and don't use its default 800x600 viewport.
  const browser = await puppeteer.launch({
    headless: true,
    defaultViewport: null
  });
  browser.on('targetchanged', async target => {
    const page = await target.page();
    function addStyleContent(content) {
      const style = document.createElement('style');
      style.type = 'text/css'; 

      style.appendChild(document.createTextNode(content));
      document.head.appendChild(style);
    }
    const css = '* {color: red}';
    if (page && page.url() === url) {
      const client = await page.target().createCDPSession();
      await client.send('Runtime.evaluate', {
        expression: `(${addStyleContent.toString()})('${css}')`
      });
    }
  });
  const { lhr } = await lighthouse(
    url,
    {
      port: new URL(browser.wsEndpoint()).port,
      output: 'json', 

      logLevel: 'error',
      chromeFlags: ['--show-paint-rects'],
      onlyCategories: ['performance', 'pwa']
    },
    {
      extends: 'lighthouse:default'
    }
  );
  await browser.close();
  return {
    pwa: lhr.categories.pwa.score,
    performance: lhr.categories.performance.score
  };
};

run('https://awesome-apress-pwa.firebaseapp.com').then(res => console.log(res));

然后,您可以运行该文件:

node lighthouse-puppeteer.js

你会明白的

{ pwa: 1, performance: 0.96 }

分析学

PWAs 允许应用提供以前不可能的功能:例如,向页面添加离线行为或允许用户从主屏幕启动网站。

通常,我们对三个事件感兴趣:

  • 添加到主屏幕:这将允许我们了解用户对浏览器提示的反应,并且根据用户的选择,我们可以知道服务对用户有多大价值。

  • 从主屏幕运行:在主屏幕上添加图标只是第一步。了解将我们的服务添加到主屏幕如何影响用户参与度将是有益的。

  • 离线浏览量频率:这使我们能够跟踪有多少用户在离线时访问服务。

跟踪主屏幕提示

我们将使用beforeinstallprompt事件来跟踪有多少用户被要求将网站添加到他们的主屏幕上,他们会做出什么决定,基于此,我们将向我们的跟踪系统发送信息:例如,谷歌分析。

打开AddToHomeScreenService

public showPrompt() {
    if (this.deferredPrompt) {
      // will show prompt
      this.deferredPrompt.prompt();

      // Wait for the user to respond to the prompt
      this.deferredPrompt.userChoice.then(choiceResult => {
        // outcome is either "accepted" or "dismissed"
        if (choiceResult.outcome === 'accepted') {
          // User accepted the A2HS prompt
          // send data to analytics
          // do whatever you want
          this.sendToAnalytics(choiceResult.userChoice);
        } else {
          // User dismissed the A2HS prompt
          // send data to analytics
          // do whatever you want
          this.sendToAnalytics(choiceResult.userChoice);
        }

        // we don't need this event anymore

        this.deferredPrompt = null;
        this.deferredPromptFired$.next(false);
      });
    }
  }

  public sendToAnalytics (userChoice) {
     // for example, send data to Google Analytics, you can create another service
    // or you may use a library to send this event to Google Analytics
   // ga('send', 'event', 'A2H', userChoice);
    console.log(userChoice);
    this.deferredPromptFired$.next(false);
  }

从主屏幕跟踪会话

跟踪从主屏幕启动的会话的最可靠的方法之一是在我们的应用清单上为start_url添加一个定制的查询参数。例如,如果你正在使用谷歌分析,你可以添加自定义活动参数。4

通常,有五个参数可以添加到您的 URL 中:

  • 标识广告客户、网站、出版物等。,这是发送流量到你的财产:例如,谷歌,newsletter4,广告牌。

  • utm_medium:广告或营销媒体:例如,cpc、横幅、电子邮件简讯。

  • utm_campaign:个人活动名称、口号、促销代码等。,对于一个产品来说。

  • utm_term:识别付费搜索关键词。如果您正在手动标记付费关键词活动,您也应该使用utm_term来指定关键词。

  • utm_content:用于区分同一广告中的相似内容或链接。例如,如果您在同一封电子邮件中有两个行动号召链接,您可以使用utm_content并为每个链接设置不同的值,这样您就可以知道哪个版本更有效。

举个例子:

// manifest.json
{ ...
  "background_color": "#fafafa",
  "display": "standalone",
  "scope": "/",
  "//": "Append tracking parameters to start_url",
  "start_url": "/?utm_source=homescreen",
  "icons": 
    {
      "src": "assets/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
...
}

要查看活动报告:

  1. 登录谷歌分析。

  2. 导航到您的视图。

  3. 打开报告。

  4. 选择收购➤活动。

您可以根据需要使用其他跟踪系统,使用相同的机制来创建您想要的东西。

跟踪离线浏览量

在写这本书的时候,Angular Service Worker 中还没有实现的解决方案。

Workbox正在为离线浏览量跟踪提供支持。在第 [13 和 14 章中,我们将实现这个模块,看看它是如何工作的。

在线工具

webpagetest.org是衡量绩效的常用工具。你会在这里找到深入的文档: sites.google.com/a/webpagetest.org/docs

web.dev/measure 是谷歌的一个新工具,帮助像你一样的开发者学习并应用网络的现代功能到你自己的网站和应用中。

真实设备

最后但同样重要的是,不要忘记真实设备测试,并根据您的分析系统在普通设备或访问量最大的设备上测量您的应用性能和行为。为了在真实世界中看到应用,有必要对真实设备进行概述。

摘要

在本章中,我们讨论了调试和测量 PWA 的最简便的工具。然而,事情不会总是这么顺利。在下一章中,我将揭示一些可能性,表明如果你的应用和服务工作器出错,你仍然能够生存。

十一、安全服务工作器

服务工作器确实厉害,Angular 的服务工作器也不例外。他们为构建 web 应用做复杂和高级的工作。然而,根据我多年来构建 pwa 的经验,事情并不总是按照我们喜欢的方式发展。可能会发生服务工作器以不可预见的方式行事,它可能会中断用户体验,甚至使我们的应用完全无用和不可及。

当您在浏览器中注册了一个服务工作器后,摆脱一个服务工作器并不像看起来那么容易。知道如何从客户端注销服务工作器可能会使您的站点处于失败的挂起状态,这可能会给用户带来令人沮丧的体验。

一个简单的例子是,当您已经注册了一个服务工作器并且想要删除一个已注册的服务工作器文件时;因此,浏览器将不再找到服务工作器文件,并且旧的服务工作器将停留在浏览器上,直到新的服务工作器文件被注册。你会发现这个错误会对你的客户产生破坏性的影响。

幸运的是,Angular Service Worker 包含了一些解决方案,比如 Fail-safe,这是一种从浏览器中注销自身的自毁方式。在这一章中,我将向您展示不同的机制,即所谓的“kill switch”,此外还有 Angular 解决方案,您可以取消或注销您的服务工作器,清理缓存等,以避免为用户提供破坏性的 web 应用。您可以在调试时使用这些方法,甚至在您觉得需要为您的应用去除 PWA 特性时也可以使用这些方法。

自动防故障

Angular 提供了一个简单的解决方案来停用服务工作器。正如我们在前面的章节中看到的,ngsw-config.json(在dist文件夹中构建后的ngsw.json)是我们定义服务工作器规则和逻辑的清单。

Angular Service Worker 试图通过执行fetchLatestManifest方法,在应用初始化和检查导航请求的新更新时获取ngsw清单。让我们仔细看看这个方法:

        fetchLatestManifest(ignoreOfflineError = false) {
            return __awaiter$5(this, void 0, void 0, function* () {
                const res = yield this.safeFetch(this.adapter.newRequest('ngsw.json?ngsw-cache-bust=' + Math.random()));
                if (!res.ok) {
                    if (res.status === 404) {
                        yield this.deleteAllCaches();
                        yield this.scope.registration.unregister();
                    }
                    else if (res.status === 504 && ignoreOfflineError) {
                        return null;
                    }

                    throw new Error(`Manifest fetch failed! (status: ${res.status})`);
                }
                this.lastUpdateCheck = this.adapter.time;
                return res.json();
            });
        }

正如在代码片段中看到的,Angular 试图用一个随机的 cache-bust 查询参数进行提取,以确保文件没有被缓存并且是新的。

如果这个文件不存在或者基本上响应状态码是 404,Angular Service Worker 将首先删除所有缓存,然后注销当前的 SW 注册。

因此,如果你的应用出错了,你可以简单地重命名或删除 ngsw.json文件,这实质上删除了所有的缓存;注销自身;或者换句话说,自我毁灭。

rm dist/ngsw.json

这是一个处理删除所有缓存的函数:

        deleteAllCaches() {
            return __awaiter$5(this, void 0, void 0, function* () {
                yield (yield this.scope.caches.keys())
                    .filter(key => key.startsWith('ngsw:'))
                    .reduce((previous, key) => __awaiter$5(this, void 0, void 0, function* () {
                    yield Promise.all([
                        previous,
                        this.scope.caches.delete(key),
                    ]);
                }), Promise.resolve());
            });
        }

注意,如果将angular.json中的serviceWorker变为false,将不会产生ngsw.json;所以这个机制也会起作用。

安全工人

Angular 服务工作器包包含一个简单的无操作 1 服务工作器脚本,可以替换 ngsw-worker.js:

self.addEventListener('install', event => { self.skipWaiting(); });

self.addEventListener('activate', event => {
  event.waitUntil(self.clients.claim());
  self.registration.unregister().then(
      () => { console.log('NGSW Safety Worker - unregistered old service worker');     });
});

让我们来分解这个脚本:

  1. 它甚至监听安装,并强制跳过等待,以便立即安装。

  2. 它监听激活事件:

    1. 确保所有客户端(例如选项卡)都已声明,以便使用最新安装的服务工作器。clients 的claim()方法允许活动的服务工作器将其自身设置为其范围内所有客户端的控制器。这将在由该服务工作器控制的任何客户端的navigator.serviceWorker上触发一个controllerchange事件。

    2. 它会立即注销自己。

为了注销您当前的服务工作器,请将文件内容复制到ngsw-worker.js或任何已注册并正在使用的服务工作器名称中。

cp dist/satefy-worker.js dist/ngsw-worker.js

该脚本可用于停用 Angular 服务工作器以及网站上可能提供服务的任何其他服务工作器。

扩展安全工人

然而,在大多数情况下,一个简单的无操作服务工作器就可以工作。在某些情况下,我们可能需要删除所有缓存或强制刷新用户的标签(网站的每个客户端),以便接收最新的更新。例如,当你重定向你的网站到一个新的起点(域名)时,你的服务工作器可能会突然行为不端。

那么,这个怎么解决呢?

  1. 移除所有缓存:

    caches.keys().then(cacheNames => {
          return Promise.all(
            cacheNames.map(cacheName => caches.delete(cacheName))
          );
        })
    
    

    如果您只想过滤 Angular 缓存名称并删除它们:

    caches.keys().then(cacheNames => {
          return Promise.all(
            cacheNames
              .filter(key => key.startsWith('ngsw:'))
              .map(cacheName => caches.delete(cacheName))
          );
        })
    
    
  2. 刷新所有 Windows 类型的客户端(选项卡):将它们放在一起:

    self.addEventListener('install', event => {
      self.skipWaiting();
    });
    
    self.addEventListener('activate', event => {
      event.waitUntil(self.clients.claim());
    
      self.registration.unregister().then(async () => {
        console.log('NGSW Safety Worker - unregistered old service worker');
    
        // Get all cache keys
    
        const cacheNames = await caches.keys();
        // If you want to delete Only Angular Caches
        const AngularCaches = cacheNames.filter(key => key.startsWith('ngsw:'));
        // Delete all caches
        await Promise.all(AngularCaches.map(cacheName => caches.delete(cacheName)));
    
        // Grab a list of all tabs
        const clients = await self.clients.matchAll({ type: 'window' });
        // Reload pages
        for (const client of clients) {
          client.navigate(client.url);
        }
    
      });
    });
    
    
    1. 获取所有窗口客户端(选项卡)的列表。

    2. 每个客户端都公开了一个名为 navigatem 的方法,允许我们将客户端重定向到另一个页面。

    3. 我们将每个客户端导航到自己,以便强制它重新加载页面!

      self.clients.matchAll({ type: 'window' })
      .then(clients => {
          for (const client of clients) {
            client.navigate(client.url);
          }
        });
      
      

      Put them all together:

摘要

虽然我们的目标是开发、构建和部署一个有效的应用,但是错误是不可避免的。在这一章中,我们制定了一个备份计划,即所谓的“终止开关”,以备在我们能够调试并修复问题之前,我们需要解雇一名有问题的维修人员。尤其是 Angular,它提供了几种方法来确保我们的应用尽可能完美地工作:比如故障安全和安全工人脚本机制。

我们还扩展了无操作服务工作器脚本,并学习了如何注销服务工作器、所有客户端的声明、清理缓存以及在必要时重新加载页面。我希望你永远不会使用这些方法,一切顺利;但是,您现在知道如果意外出错该怎么办了。

十二、现代 Web APIs

如果我告诉您,您可以构建一个 web 应用,连接到支持蓝牙低能耗的设备,并从您的 web 应用控制它,会怎么样?如果用户的登录凭证安全地保存在浏览器中,并且当用户访问网站时,他们会自动登录,会怎么样?如果登录 web 应用需要通过 USB 连接的设备来验证用户身份,该怎么办?如果我可以通过浏览器中的 JavaScript API 访问本地平台上的共享选项会怎么样?我知道你现在可能在想什么;但是,即使这些在 10 年前听起来像是梦想,今天它们中的大多数都是可以实现的,或者至少接近成为现实。

在过去的十年里,网络的大部分已经发生了显著的变化。新的 web APIs 允许开发人员通过蓝牙和 USB 将 web 应用连接到硬件。在线支付从未像今天这样简单。单点登录和无密码解决方案以最小的努力带来了更好的用户体验。通过跨所有设备和操作系统的相同 API 开发跨平台非常困难,而今天,这是开发和构建 web 应用的一种非常愉快的方式,尤其是渐进式 Web 应用(pwa ),因为许多新的 API 已经标准化,在我们的浏览器中提供了高级 JavaScript API,以访问平台的底层 API。

在这一章中,我选择了一些新技术和 API 来探索和集成 PWA note 应用,如凭据管理、支付请求、地理定位、媒体流、Web 蓝牙和 Web USB。我将确保这些 API 的基础知识将被涵盖。但是,您可能需要根据您的需要和要求为您的应用开发额外的。

此外,我建议密切关注 Web 共享、Web VR/AR、后台获取、可访问性改进、Web 组装以及更多正在开发或考虑中的新标准,这些标准将增强 Web 的能力,尤其是通过构建 PWA。

凭证管理

凭证管理 API 是一个基于承诺的标准浏览器 API,它通过提供网站和浏览器之间的接口来促进跨设备的无缝登录。该 API 允许用户通过帐户选择器使用一个标签登录,并帮助在浏览器中存储凭据,从而可以在设备之间同步。这有助于已经登录到一个浏览器的用户,如果他们使用同一个浏览器,他或她可以继续登录到所有其他设备。

该 API 不仅支持本机浏览器密码管理,还可以提供来自联合身份提供者的凭据信息。它的意思是:任何被网站信任来正确验证用户身份并为此提供 API 的实体都可以成为这个 API 中的提供者,以存储凭据并在必要时检索凭据。例如,Google Account、GitHub、Twitter、脸书或 OpenID Connect 都是联合身份提供者框架的例子。

请记住,这个 API 只有在来源安全的情况下才能工作;换句话说,类似于 PWA,你的网站必须在 HTTPS 上运行。

让我们开始在 Angular 项目中实现,看看它是如何工作的。

首先,我们将创建一个名为CredentialManagementService ,的服务,并导入到我的CoreModule中。

declare const PasswordCredential: any;
declare const FederatedCredential: any;
declare const navigator: any;
declare const window: any;

@Injectable({
  providedIn: 'root'
})
export class CredentialManagementService {
  isCredentialManagementSupported: boolean;

  constructor(private snackBar: SnackBarService) {
    if (window.PasswordCredential || window.FederatedCredential) {
      this.isCredentialManagementSupported = true;
    } else {
      this.isCredentialManagementSupported = false;
      console.log('Credential Management API is not supported in this browser');
    }
  }

  async store({ username, password }) {
    if (this.isCredentialManagementSupported) {
      // You can either pass the passwordCredentialData as below
      // or simply pass down your HTMLFormElement. A reference to an HTMLFormElement with appropriate input fields.
      // The form should, at the very least, contain an id and password

.
      // It could also require a CSRF token.
      /*
        <form id="form" method="post">
          <input type="text" name="id" autocomplete="username" />
          <input type="password" name="password" autocomplete="current-password" />
          <input type="hidden" name="csrf_token" value="*****" />
        </form>
        <script>
            const form = document.querySelector('#form');
            const credential = new PasswordCredential(form);
      // if you have a federated provider
       const cred = new FederatedCredential({
         id: id,
         name: name,
         provider: 'https://account.google.com',
         iconURL: iconUrl
             });
        <script>
      */
      // Create credential object synchronously.
      const credential = new PasswordCredential({
        id: username,
        password: password
        // name: name,
        // iconURL: iconUrl
      });
      const isStored = await navigator.credentials.store(credential);
      if (isStored) {
        this.snackBar.open('You password and username saved in your browser');
      }
    }
  }

  async get() {
    if (this.isCredentialManagementSupported) {
      return navigator.credentials.get({
        password: true,
        mediation: 'silent'
        // federated: {
        //   providers: ['https://accounts.google.com'] 

        // },
      });
    }
  }

  preventSilentAccess() {
    if (this.isCredentialManagementSupported) {
      navigator.credentials.preventSilentAccess();
    }
  }

}

该服务有三个方法,基本上是主要凭证 API 方法的包装器,用于检查 API 在浏览器中是否可用。让我们来分解服务:

  1. 服务初始化时的功能检测,以确保该 API 可用。

    if (window.PasswordCredential || window.FederatedCredential) {}
    
    
  2. store方法:

    1. 接受用户名和密码,因此我们可以创建一个密码凭据,并将其存储在凭据中。PasswordCredential构造函数接受HTMLFormElement和一个基本字段的对象。如果您想通过HTMLFormElement,请确保您的表单至少包含一个 ID 和密码以及 CSRF 令牌。在方法中,调用 ID 为的构造函数,它是用户名和密码。nameiconURL,分别是正在登录的用户的名字和用户的头像图像,可选。请记住,如果该功能可用,我们将运行此代码;否则,我们让用户正常使用应用。

      因为我们正在构建 PWA,所以为那些选择的浏览器不支持正在使用的功能的用户提供替代方案总是很重要的。

    2. 如果你打算使用第三方登录,你必须调用带有idprovider端点的FederatedCredential构造函数。

    3. 凭证 API 在 navigator 上可用,存储函数是基于承诺的,通过调用它,我们可以在浏览器中保存用户凭证。

    4. 最后,我们向用户显示一条消息,通知他们我们将他们的密码存储在浏览器中。

  3. get方法:

    在特征检测被检查后,我们通过传入配置如password来调用navigation.credentials上的getmediation. Mediation定义了我们想要如何告诉浏览器向用户显示账户选择器,它有三个值:optional, required,silent。当中介为optional时,在调用了navigator.credentials.preventSilentAccess()之后,用户会被明确地显示一个帐户选择器来登录。这通常是为了确保在用户选择退出或注销后不会自动登录。

    一旦navigator.credentials.get()解决了,它返回一个undefined或者一个credential object。要确定它是一个PasswordCredential还是一个FederatedCredential,只需查看对象的type属性,它将是passwordfederated。如果type是联合的,那么provider属性是一个表示身份提供者的字符串。

  4. preventSilentAccess方法:

    我们称之为preventSilentAccess() on navigator.credentials.,这将确保自动登录不会发生,直到用户下次启用自动登录。要恢复自动登录,用户可以通过从帐户选择器中选择他们希望登录的帐户来选择有意登录。然后,用户总是重新登录,直到他们明确注销。

为了继续使用UserContainerComponent,我们将首先注入这个服务,然后定义我的autoSignIn方法,并在signuplogin方法上调用ngOnInit.方法,我们将从credential service调用存储方法来保存和更新用户凭证。

最后,当用户注销时,我们需要调用preventSilentAccess()。看起来是这样的:

  constructor(
    private credentialManagement: CredentialManagementService,
    private fb: FormBuilder,
    private auth: AuthService,
    private snackBar: SnackBarService
  ) {}

  ngOnInit() {
    this.createLoginForm();
    if (!this.auth.authenticated) {
      this.autoSignIn();
    }
  }

  private async autoSignIn() {
    const credential = await this.credentialManagement.get();

    if (credential && credential.type === 'password') {
      const { password, id, type } = credential;
      const isLogin = await this._loginFirebase({ password, email: id });
      if (isLogin) {
      // make sure to show a proper message to the user
        this.snackBar.open(`Signed in by ${id} automatically!`);
      }
    }
  }

  public signUp() {
    this.checkFormValidity(async () => {
      const signup = await this.auth.signUpFirebase(this.loginForm.value);
      const isLogin = await this.auth.authenticateUser(signup);
      if (isLogin) {
        const { email, password } = this.loginForm.value;
        this.credentialManagement.store({ username: email, password });
      }
    });
  }

  public login() {
    this.checkFormValidity(async () => {
      const { email, password } = this.loginForm.value;
      const isLogin = this._loginFirebase({ email, password });
      if (isLogin) {
        this.credentialManagement.store({ username: email, password });
      }
    });
  }

  public logOut() {
    this.auth

      .logOutFirebase()
      .then(() => {
        this.auth.authErrorMessages$.next(null);
        this.auth.isLoading$.next(false);
        this.auth.user$.next(null);
        // prevent auto signin until next time user login explicity
        // or allow us for auto sign in

        this.credentialManagement.preventSilentAccess();
      })
      .catch(e => {
        console.error(e);
        this.auth.isLoading$.next(false);
        this.auth.authErrorMessages$.next(
          'Something is wrong when signing out!'
        );
      });
  }

注意

克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入 12 章,01-凭证-管理-api 文件夹,找到所有样本代码。

使用登录表单上的autocomplete属性来帮助浏览器正确识别字段也是一个很好的实践(图 12-1 )。

img/470914_1_En_12_Fig1_HTML.jpg

图 12-1

自动完成属性允许浏览器为网站显示适当的用户名和密码

   <input
          matInput
          placeholder="Enter your email"
          autocomplete="username"
          formControlName="email"
          required
        />
<input
          matInput
          autocomplete="current-password"
          placeholder="Enter your password"
          [type]="hide ? 'password' : 'text'"
          formControlName="password"
        />

我们在新的浏览器中运行该应用,然后我们将转到登录页面,通过输入我的凭据登录到网站。您将看到一条提示消息,要求用户在浏览器中保存凭证(参见图 12-2 )。

img/470914_1_En_12_Fig2_HTML.jpg

图 12-2

web 应用希望在浏览器中保存凭据时的凭据提示

为了测试我的auto sign-in,我们将打开一个新的干净的浏览器并进入登录页面,然后我们将注意到我们被重定向到笔记列表,并出现一条snackbar消息,显示我们已自动登录(见图 12-3 )。

img/470914_1_En_12_Fig3_HTML.jpg

图 12-3

网站 snackbar 消息和自动登录后来自浏览器本身的消息

最后,中介optionalrequired将显示一个帐户选择器提示,允许用户选择他们所选择的帐户,特别是如果他们保存了多个帐户(参见图 12-4 )。

img/470914_1_En_12_Fig4_HTML.jpg

图 12-4

中介是可选还是必需的帐户选择器

浏览器支持

到写这本书的时候,桌面和安卓的 Chrome,安卓浏览器,桌面和移动的 Opera,三星互联网浏览器都支持这个 API 目前正在考虑将其用于 Firefox。MS Edge 正在向 Chromium 平台迁移,这个 API 应该很快就会被覆盖。

付款申请

很有可能我们所有阅读这本书的人都在网上支付过——至少一次。所以我们都知道填写结帐表格是多么的耗时和无聊,尤其是如果它有不止一个步骤的话。

支付请求标准 API 是由 W3C 开发的,旨在确保消费者和商家的在线支付系统保持一致和顺畅。这不是一种新的支付方式;相反,这是一种旨在简化结账过程的方式。

有了这个 API,当消费者想要选择诸如送货地址、信用卡、联系方式等支付细节时,他们总能看到一个原生平台 UI。想象一下,一旦您在浏览器中保存了所有信息,您就可以在每个支持该 API 的结账页面中重用它们。这将是多么愉快的体验:忽略结帐表单中许多字段的填写、信用卡信息等等。相反,我们将看到保存的信息与熟悉的本地用户界面一致。只需几次点击或标签选择,它就完成了!

这种 API 的另一个优势是接受从各种处理程序到 web 的不同支付方式,并且相对容易集成:例如,Apple Pay、Samsung Pay、Google Pay。

长话短说,我准备在 PWA 笔记 app 里增加一个捐款按钮。

首先,我们将在 Angular 中创建一个名为WebPaymentService的服务,并在CoreModule中导入它。

export class WebPaymentService {
  public isWebPaymentSupported: boolean;
  private requestPayment = null;
  private canMakePaymentPromise: Promise<boolean> = null;

  private supportedPaymentMethods = [
    {
      // support credit card payment
      supportedMethods: 'basic-card',
      data: {
        supportedNetworks: ['visa', 'mastercard', 'amex'],
        supportedTypes: ['credit', 'debit']
      }
    }

  // Apple pay, Google Pay, Samasung pay, Stripe and others can be added here too.
 ];

// just an example of a simple product details

  private paymentDetails: any = {
    total: {
      label: 'Total Donation',
      amount: { currency: 'USD', value: 4.99 }
    },
    displayItems: [
      {
        label: 'What I recieve',
        amount: { currency: 'USD', value: 4.49 }
      },
      {
        label: 'Tax',
        amount: { currency: 'USD', value: 0.5 }
      }
    ]
  };

  private requestPaymentOptions = {
    requestPayerName: true,
    requestPayerPhone: false,
    requestPayerEmail: true,
    requestShipping: false
    shippingType: 'shipping'
  };

  constructor() {
    if (window.PaymentRequest) {
      // Use Payment Request API which is supported
      this.isWebPaymentSupported = true;
    } else {
      this.isWebPaymentSupported = false;
    }
  }

  constructPaymentRequest() {
    if (this.isWebPaymentSupported) {
      this.requestPayment = new PaymentRequest(
        this.supportedPaymentMethods,
        this.paymentDetails,
        this.requestPaymentOptions
      );

// ensure that user have a supported payment method if not you can do other things

      if (this.requestPayment.canMakePaymentPromise) {
        this.canMakePaymentPromise = this.requestPayment.canMakePayment();
      } else {
        this.canMakePaymentPromise = Promise.resolve(true);
      }
    } else {
      // do something else for instance redirect user to normal checkout
    }
    return this;
  }

  async show(): Promise<any> {
/*  you can make sure client has a supported method already if not do somethig else. For instance, fallback to normal checkout, or let them to add one active card */
    const canMakePayment = await this.canMakePaymentPromise;

    if (canMakePayment) {
      try {
        const response = await this.requestPayment.show();
        // here where you can process response payment with your backend
        // there must be a backend implementation too.
        const status = await this.processResponseWithBackend(response);

        // after backend responsed successfully, you can do any other logic here
        // complete transaction and close the payment UI
        response.complete(status.success);
        return status.response;
      } catch (e) {
        //  API Error or user closed the UI
        console.log('API Error or user closed the UI');
        return false;
      }
    } else {
      // Fallback to traditional checkout for example
      // this.router.navigateByUrl('/donate/traditional');
    }
  }

  async abort(): Promise<boolean> {
    return this.requestPayment.abort();
  }

  // mock backend response
  async processResponseWithBackend(response): Promise<any> {
    // check with backend and respond accordingly
    return new Promise(resolve => {
      setTimeout(() => {
        resolve({ success: 'success', response });
      }, 1500);
    });
  }
}

我们来分解一下。

  1. 一如既往,渐进增强的特征检测。

    if (window.PaymentRequest) {
          this.isWebPaymentSupported = true;
        } else {
          this.isWebPaymentSupported = false;
        }
    
    
  2. 对于每次支付,您需要构造一个接受三个参数的PaymentRequest

    new PaymentRequest(
            this.supportedPaymentMethods,
            this.paymentDetails,
            this.requestPaymentOptions
          );
    
    
  3. 定义supportedPaymentMethods ,,它是所有支持的支付方式的数组。在代码示例中,我刚刚定义了一个基本卡;然而,在本章的示例代码中,您会发现更多的方法,如 Apple Pay、Google Pay 和 Samsung Pay。你并不局限于它们;您可以实现任何受欢迎的方法,如 PayPal、Stripe 等支持该 API 的方法。

    private supportedPaymentMethods = [
        {
          // support credit card payment
          supportedMethods: 'basic-card',
          data: {
           // you can add more such as discover, JCB and etc.
            supportedNetworks: ['visa', 'mastercard', 'amex'],
            supportedTypes: ['credit', 'debit']
          }
        },
    ]
    
    

    这个数组中的每个对象都有特定于方法本身的supportedMethodsdata属性。为了更好地理解,我也将提供一个 Apple Pay 对象作为示例:

    {
          supportedMethods: 'https://apple.com/apple-pay',
          data: {
            version: 3,
            merchantIdentifier: 'merchant.com.example',
            merchantCapabilities: ['supports3DS', 'supportsCredit', 'supportsDebit'],
            supportedNetworks: ['amex', 'discover', 'masterCard', 'visa'],
            countryCode: 'US'
          }
        },
    
    
  4. In Define paymentDetails , for instance, in my example, I have a fixed donation number; however, you may have a cart page with different products and other details that need to be added to payment details accordingly.

    private paymentDetails: any = {
        total: {
          label: 'Total Donation',
          amount: { currency: 'USD', value: 4.99 }
        },
        displayItems: [
          {
            label: 'What I recieve',
            amount: { currency: 'USD', value: 4.49 }
          },
          {
            label: 'Tax',
            amount: { currency: 'USD', value: 0.5 }
          }
        ]
      };
    
    

    主要有两个属性:total表示总金额;和显示购物车商品的数组displayItems,

  5. Define requestPaymentOptions is optional; however, you may find it very useful for different purposes – for instance, if a shipping address is required or email must be provided.

      private requestPaymentOptions = {
        requestPayerName: true,
        requestPayerPhone: false,
        requestPayerEmail: true,
        requestShipping: false,
        shippingType: 'shipping'
      };
    
    

    在本例中,我们只要求付款人提供电子邮件和姓名。

  6. 最后但同样重要的是,我们在requestPayment上展示了 call show 方法,以便显示付款本地提示页面。

    async show(): Promise<any> {
        const canMakePayment = await this.canMakePaymentPromise;
        if (canMakePayment) {
          try {
            const response = await this.requestPayment.show();
            const status = await this.processResponseWithBackend(response);
            response.complete(status.success);
            return status.response;
          } catch (e) {
            return false;
          }
        }
      }
    
    

requestPayment上有另一个基于承诺的方法叫做canMakePayment(),它本质上是一个助手,在show()被调用之前,确定用户是否有一个支持的支付方法来进行支付。它可能不在所有用户代理中;因此,我们需要进行特征检测。

然后,一旦用户完成,我们就调用show() ,,Promise 将通过用户的选择细节得到解析,包括联系信息、信用卡、运费等等。现在是用后端验证和处理付款的时候了。

打开header.component.html()并添加以下按钮(见图 12-5 ):

img/470914_1_En_12_Fig5_HTML.jpg

图 12-5

触发付款的捐赠按钮本机 UI

  <button mat-menu-item (click)="donateMe()" *ngIf="isWebPaymentSupported">
    <mat-icon>attach_money</mat-icon>
    <span>Donate</span>
  </button>

最后,将WebPaymentService注入header.component.ts。应该定义donateMe()方法,一旦解决了这个问题,它将调用requestPayment并向用户显示适当的消息。

public isWebPaymentSupported: boolean;

  constructor(
    private webPayment: WebPaymentService,
  ) {
    this.isWebPaymentSupported = this.webPayment.isWebPaymentSupported;
  }

async donateMe() {
    const paymentResponse = await this.webPayment
      .constructPaymentRequest()
      .show();

    if (paymentResponse) {
      this.snackBar.open(
        `Successfully paid, Thank you for Donation ${paymentResponse.payerName}`
      );
    } else {
      // this.snackBar.open('Ops, sorry something went wrong with payment');
    }
  }

我们将构建应用,并在浏览器和手机中运行和测试(见图 12-6 和 12-7 )。

img/470914_1_En_12_Fig7_HTML.jpg

图 12-7

Safari、Chrome 和三星互联网浏览器中的 Apple pay 显示原生支付 UI

img/470914_1_En_12_Fig6_HTML.jpg

图 12-6

支付原生用户界面,Chrome,Mac

注意

克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入 13 章,02-请求-支付-api 文件夹,找到所有样本代码。

浏览器支持

在撰写本书时,几乎所有主流浏览器都支持这个 API,不管是在产品中还是在桌面和移动设备的夜间版本中,尽管它们也可能部分支持。

视频和音频捕捉

Media Streams 是一个与 WebRTC 相关的 API,它提供对流式音频和视频数据的支持。这个 API 已经存在一段时间了。新的基于承诺的getUserMedia()是一个请求用户允许麦克风和摄像头的方法;因此,您将可以访问实时流。

在本节中,我们将向“添加笔记”页面添加一个新功能,用户可以在其中将带有音频的交互式视频保存到他们的笔记中。

请注意,在本例中,我们不会将此视频发送到服务器,但是实现将准备好与后端通信,以便在需要时保存视频和音频。

notes-add.component.html中,我们将添加以下 html 片段:

<div class="media-container" *ngIf="isMediaRecorderSupported">
      <h1>Add video with audio Note</h1>
      <div class="videos">
        <div class="video">
          <h2>LIVE STREAM</h2>
          <video #videoOutput autoplay muted></video>
        </div>
        <div class="video">
          <h2>RECORDED STREAM</h2>
          <video #recorded autoplay loop></video>
        </div>
      </div>
      <div class="buttons">
        <button mat-raised-button color="primary" (click)="record()" *ngIf="disabled.record" > Start Recording</button>
        <button mat-raised-button color="primary" (click)="stop()" *ngIf="disabled.stop"> Stop Recording </button>
        <button mat-raised-button color="secondary" (click)="play()" *ngIf="disabled.play"> Play Recording</button>
        <button mat-raised-button color="primary" (click)="download()" *ngIf="disabled.download">  Download Recording </button>
        <a #downloadLink href="">Download Link</a>
      </div>
    </div>

这段代码非常简单明了。我们将逻辑添加到notes-add.component.ts:

export class NotesAddComponent {
  @ViewChild('videoOutput') videoOutput: ElementRef;
  @ViewChild('recorded') recordedVideo: ElementRef;
  @ViewChild('downloadLink') downloadLink: ElementRef;

  public disabled = { record: true, stop: false, play: false, download: false };
  public userID;
  public errorMessages$ = new Subject();
  public loading$ = new Subject();
  public isMediaRecorderSupported: boolean;
  private recordedBlobs;
  private liveStream: any;
  private mediaRecorder: any;

  constructor(
    private router: Router,
    private data: DataService,
    private snackBar: SnackBarService
  ) {
    if (window.MediaRecorder) {
      this.isMediaRecorderSupported = true;
      this.getStream();
    } else {
      this.isMediaRecorderSupported = false;
    }
  }

  async getStream() {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: true
      });
      this.handleLiveStream(stream);
    } catch (e) {
      this.isMediaRecorderSupported = false;
      this.onSendError('No permission or something is wrong');
      return 'No permission or something is wrong';
    }
  }

  handleLiveStream(stream) {
    this.liveStream = stream;
    this.videoOutput.nativeElement.srcObject = stream;
  }

  getMediaRecorderOptions() {
    let options = {
      mimeType: 'video/webm;codecs=vp9',
      audioBitsPerSecond: 1000000, // 1 Mbps
      bitsPerSecond: 1000000, // 2 Mbps
      videoBitsPerSecond: 1000000 // 2 Mbps
    };
    if (!MediaRecorder.isTypeSupported(options.mimeType)) {
      console.log(`${options.mimeType} is not Supported`);
      options = { ...options, mimeType: 'video/webm;codecs=vp8' };
      if (!MediaRecorder.isTypeSupported(options.mimeType)) {
        console.log(`${options.mimeType} is not Supported`);
        options = { ...options, mimeType: 'video/webm' };
        if (!MediaRecorder.isTypeSupported(options.mimeType)) {
          console.log(`${options.mimeType} is not Supported`);
          options = { ...options, mimeType: " };
        }
      }
    }
    return options;
  }

  record() {
    this.recordedBlobs = [];
    this.disabled = { play: false, download: false, record: false, stop: true };
    this.mediaRecorder = new MediaRecorder(
      this.liveStream,
      this.getMediaRecorderOptions
    );
    this.mediaRecorder.ondataavailable = e => {
      {
        if (e.data) {
          this.recordedBlobs.push(e.data);
        }
      }
    };
    this.mediaRecorder.start();
    console.log('MediaRecorder started', this.mediaRecorder);
  }

  stop() {
    this.disabled = { play: true, download: true, record: true, stop: false };
    this.mediaRecorder.onstop = e => {
      this.recordedVideo.nativeElement.controls = true;
    };
    this.mediaRecorder.stop();
  }

  play() {
    this.disabled = { play: true, download: true, record: true, stop: false };
    const buffer = new Blob(this.recordedBlobs, { type: 'video/webm' });
    this.recordedVideo.nativeElement.src = window.URL.createObjectURL(buffer);
  }

  download() {
    const blob = new Blob(this.recordedBlobs, { type: 'video/webm' });
    const url = window.URL.createObjectURL(blob);
    this.downloadLink.nativeElement.url = url;
    this.downloadLink.nativeElement.download = `recording_${new Date().getTime()}.webm`;
    this.downloadLink.nativeElement.click();
    setTimeout(() => {
      window.URL.revokeObjectURL(url);
    }, 100);
  }

  onSaveNote(values) {
    this.data.addNote(values).then(
      doc => {
        this.snackBar.open(`LOCAL: ${doc.id} has been succeffully saved`);
      },
      e => {
        this.errorMessages$.next('something is wrong when adding to DB');
      }
    );
    this.router.navigate(['/notes']);
  }

  onSendError(message) {
    this.errorMessages$.next(message);
  }
}

这段代码很简单。一如既往,这是对MediaRecorder,的功能检测,如果浏览器支持,我们将继续向用户展示这一功能,并将初始化getUserMedia();因此,我们请求音频和视频许可,如图 12-8 所示。

img/470914_1_En_12_Fig8_HTML.jpg

图 12-8

浏览器请求摄像头和麦克风的权限

一旦许可被授予,Promise 得到解决,流将是可访问的(见图 12-9 )。当用户点击标签“开始记录”按钮时,MediaRecorder构造器被调用,带有实时流数据和已经定义的选项。

我们将每个 blob 存储在一个数组中,直到调用stop()方法。一旦录制停止,媒体就可以播放了。通过点击“play”按钮,我们将简单地创建一个流数组的流缓冲区,通过创建一个Blob URL,我们将把它分配给一个< video>标签的src

img/470914_1_En_12_Fig9_HTML.jpg

图 12-9

请求获得在 Android mobile 上访问视频和音频的权限

哒哒,现在视频直接在浏览器里播放了。我们还可以制作该视频的下载版本(见图 12-10 )。

img/470914_1_En_12_Fig10_HTML.jpg

图 12-10

实时流和录制回放

通过选项卡或单击“下载”按钮,我们将从一个数组recordedBlob创建一个 Blob,然后将创建一个URL并将我在模板中用display: none定义的< a>标签分配给它,然后调用click()来强制浏览器为用户打开下载模式,以便询问他们该文件必须保存在系统的什么位置。

注意

克隆 https://github.com/mhadaily/awesome-apress-pwa.git 并转到章节 12 ,03-camera-and-microphone-api 文件夹找到所有示例代码。

浏览器支持

在写这本书的时候,Opera,Chrome,和 Firefox 在桌面上;Android 上的 Chrome 和三星互联网支持大多数标准规格。微软 Edge 也在考虑这个 API。它也适用于 Safari 12 / iOS 12。我相信 API 的未来是光明的。

地理定位

地理位置 API 提供用户的位置坐标,并将其公开给 web 应用。出于隐私原因,浏览器会请求许可。这种基于承诺的 API 已经存在很长时间了。你甚至可能已经开始使用它了。

我们将通过创建一个名为GeolocationService的服务来探索这个 API,您可以在modules/core/geolocation.service.ts.下找到它

export interface Position {

  coords: {
    accuracy: number;
    altitude: number;
    altitudeAccuracy: number;
    heading: any;
    latitude: number;
    longitude: number;
    speed: number;
  };
  timestamp: number;

}

@Injectable()
export class GeolocationService {
  public isGeoLocationSupported: boolean;

  private geoOptions = {
    enableHighAccuracy: true, maximumAge: 30000, timeout: 27000
  };

  constructor() {
    if (navigator.geolocation) {
      this.isGeoLocationSupported = true;
    } else {
      // geolocation is not supported, fall back to other options
      this.isGeoLocationSupported = false;
    }
  }

  getCurrentPosition(): Observable<Position> {
    return Observable.create(obs => {
      if (navigator.geolocation) {
        navigator.geolocation.getCurrentPosition(
          position => {
            obs.next(position);
            obs.complete();
          },
          error => {
            obs.error(error);
          }
        );
      }
    });
  }

  watchPosition(): Observable<Position> {
    return Observable.create(obs => {
      if (navigator.geolocation) {
        navigator.geolocation.watchPosition(
          position => {
            obs.next(position);
          },
          error => {
            obs.error(error);
          },
          this.geoOptions
        );
      }
    });
  }

}

我们来分解一下。

  1. 像往常一样,确保地理位置的特征检测是可用的。

  2. 定义getCurrentPosition() ,我要把geolocation.getCurrentPosition()回调转换成可观察的。

  3. 定义watchPosition() ,我们对geolocation.watchPosition()做同样的事情,把它的回调变成可观察的。

  4. 我们已经定义了地理定位方法提供的位置接口。

我想做的是为每个笔记添加用户坐标,以在保存时保持位置。因此,我们可以稍后使用第三方地图供应器(如 Google Map)显示用户笔记的坐标或确切地址。由于我们正在保存所有的坐标数据,因此我们将能够根据应用的需要,在后端甚至在前端使用第三方地图提供程序将这种坐标转换为有意义的地址。

目前,为了保持简洁,我们只向用户显示当前的纬度和经度。

首先,我们将地理定位服务注入到NotesAddComponent中,然后我们将调用getCurrentPosition()并将它赋给我的本地location$变量,在这里我们将位置object转换成简单的string

public isGeoLocationSupported = this.geoLocation.isGeoLocationSupported;
public location$: Observable<string> = this.geoLocation
    .getCurrentPosition()
    .pipe(map(p =>
        `Latitude:${p.coords.latitude}
        Longitude:${p.coords.longitude}`
        ));

  constructor(
    private router: Router,
    private data: DataService,
    private snackBar: SnackBarService,
    private geoLocation: GeolocationService
  ) {}

最后,添加下面的 html 片段,其中我们使用了带有async管道的location$ observable;然而,我们首先使用*ngIf检查地理定位是否可用(参见图 12-11 中的权限对话框)。

img/470914_1_En_12_Fig11_HTML.jpg

图 12-11

浏览器要求位置许可

    <h4 *ngIf="isGeoLocationSupported">You location is {{ location$ | async }}</h4>

一旦用户允许,浏览器将提供每个方法调用的协调数据(见图 12-12 )。

img/470914_1_En_12_Fig12_HTML.jpg

图 12-12

Android 上的地理位置许可对话框;一旦它被解决,协调显示

注意

克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入第十二章,04-地理定位-api 文件夹,找到所有样本代码。

浏览器支持

所有主流浏览器都支持这种 API,根据统计,这种 API 覆盖了全球 93%的市场。com 网站。 1

蓝牙 Web

这种基于承诺的 API 是一种新技术,通过 web 为物联网开辟了一个新时代。它允许 web 应用连接到蓝牙低能耗(BLE)设备。

想象一下,开发一个 PWA,我们能够访问蓝牙并控制智能家电、健康配件等设备,只需在不同平台的所有浏览器上保持 web API 的一致性。

请记住,该 API 仍在开发中,将来可能会略有变化。我推荐 GitHub 上的以下实现状态文档。 2

在我们继续之前,我建议学习蓝牙低能耗(BLE)和通用属性配置文件(GATT) 3 工作原理的基础知识。

在本节中,我们使用 Android 上的 BLE 外设模拟器 4 应用模拟 BLE 设备,并将我的 PWA note 应用与该设备配对,以便接收电池电量。我们所做的是:

  1. 已安装 BLE 外围设备模拟器应用

  2. 选择要广告的电池服务

  3. 保持屏幕打开,将电池电量调到 73

我们开始吧。

首先,我们将创建我的WebBluetoothService并将其导入到CoreModule中。

@Injectable()
export class WebBluetoothService {
  public isWebBluetoothSupported: boolean;
  private GATT_SERVICE_NAME = 'battery_service';
  private GATT_SERVICE_CHARACTERISTIC = 'battery_level';

  constructor() {
    if (navigator.bluetooth) {
      this.isWebBluetoothSupported = true;
    }
  }

  async getBatteryLevel(): Promise<any> {
    try {
       // step 1, scan for devices and pair
      const device = await navigator.bluetooth.requestDevice({
        // acceptAllDevices: true
        filters: [{ services: [this.GATT_SERVICE_NAME] }]
      });
      // step 2: connect to device
      const connectedDevice = await this.connectDevice(device);
      // step 3 : Getting Battery Service
      const service = await this.getPrimaryService(connectedDevice, this.GATT_SERVICE_NAME);
      // step 4: Read Battery level characterestic
      const characteristic = await this.getCharacteristic(service, this.GATT_SERVICE_CHARACTERISTIC);
      // step 5: ready battery level
      const value = await characteristic.readValue();
      // step 6: return value
      return `Battery Level is ${value.getUint8(0)}%`;
    } catch (e) {
      console.error(e);
      return `something is wrong: ${e}`;
    }
  }

  private connectDevice(device): Promise<any> {
    return device.gatt.connect();
  }

  private getPrimaryService(connectedDevice, serviceName): Promise<any> {
    return connectedDevice.getPrimaryService(serviceName);
  }

  private getCharacteristic(service, characterestic): Promise<any> {
    return service.getCharacteristic(characterestic);
  }
}

这项服务很简单。我们遵循以下步骤:

  1. 检测bluetooth是否可用。

  2. 使用适当的配置调用requestDevice(),我们要求浏览器过滤并显示我们感兴趣的内容。可能存在要求检查所有设备的选项;然而,就电池健康而言,不建议这样做。

    为了使服务简单,我们静态定义了 GATT 服务名称和特征。

  3. 出现提示模式时,尝试连接到设备。

  4. 调用getPrimaryService()获取电池服务。

  5. 通过调用getCharacteristic(),,我们将要求battery_level.

  6. 一旦特征被解析,我们将读取该值。

虽然这是一个非常简单的设备,文档也很清晰,但看起来有点复杂和混乱。你使用这些类型的设备和技术越多,你就能越好地理解这一切。

您只能要求浏览器通过单击或点击按钮来发现设备;因此,我们将在header.component.html中的菜单下添加一个按钮,并用ngIf确保该按钮在受支持时出现。

  <button mat-menu-item (click)="getBatteryLevel()" *ngIf="isWebBluetoothSupported">
    <mat-icon>battery_unknown</mat-icon>
    <span>Battery Level</span>
  </button>

最后,我将在header.component.ts,中定义我的getBatteryLevel方法,该方法仅在所有承诺都解决后显示电池电量信息(见图 12-13 )。

async getBatteryLevel() {
    const level = await this.bluetooth.getBatteryLevel();
    this.snackBar.open(level);
  }

注意

克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入 12 章,05-web-bluetooth-api 文件夹,找到所有样本代码。

上面的例子展现了从 BLE 设备读取的可能性;但是,将 5 写入蓝牙特性,订阅 6 接收 GATT 通知也是另外一种情况。

img/470914_1_En_12_Fig13_HTML.jpg

图 12-13

Web Bluetooth API:配对一个设备,读取一个特征,并在所有承诺完成后显示一条消息

The example above unfolds read possibilities from a BLE device; however, writing

我们已经回顾了网络蓝牙的基础知识,希望它能让你开始使用这项令人敬畏的网络技术。

我的社区朋友 Wassim Chegham 为 Web Bluetooth 提供了一个很棒的 Angular 库,带有 Observable API,您可以通过运行以下命令进行安装:

npm i -S @manekinekko/angular-web-bluetooth @types/web-bluetooth

在 GitHub https://github.com/manekinekko/angular-web-bluetooth 上找到文档。

浏览器支持

在我写这本书的时候,支持这种 API 的浏览器是 Chrome 桌面,适用于 Windows 和 Mac,以及 Android、Samsung internet 和 Opera。我希望在未来,尤其是当你正在阅读这一节的时候,会有更多的浏览器支持 Web 蓝牙 API。

USB web

这种基于承诺的 API 提供了一种安全的方式,通过使用 JavaScript 高级 API 的浏览器将 USB 设备暴露给 web。这仍然是一个相对较新的 API,可能会随着时间的推移而改变,实现是有限的,并且会报告一些错误。

默认情况下,Web USB API 需要 HTTPS,与 Web 蓝牙类似,它必须通过用户手势(如触摸或鼠标点击)来调用。类似于键盘和鼠标的设备不能被这个 API 访问。

我相信 Web USB 打开了一扇新的窗口,它为学术目的、学生、制造商和开发者带来了很多机会。想象一下,这不是一个可以直接访问 USB 板的在线开发工具,也不是需要编写原生驱动程序的制造商;相反,他们将能够开发一个跨平台的 JavaScript SDK。想象一个硬件支持中心,他们可以通过自己的网站直接访问我的设备并进行诊断或调试。我们可以统计越来越多的案例研究;然而,我应该提到,这项技术仍在发展中,即使不是现在,也将是未来网络的一个令人兴奋的特性。的确,网络是惊人的;不是吗?

说得够多了,让我们开始探索 API 吧。为了简单起见,并让您了解 Web USB 是如何工作的,我们将连接我的“创见笔驱动器”,一旦连接上,我将显示一条消息,其中显示包括“序列号”在内的硬件信息

首先,我编写一个名为WebUSBService的服务,并导入到CoreModule

@Injectable()
export class WebUSBService {
  public isWebUSBSupported: boolean;

  constructor(private snackBar: SnackBarService) {
    if (navigator.usb) {
      this.isWebUSBSupported = true;
    }
  }

  async requestDevice() {
    try {
      const usbDeviceProperties = { name: 'Transcend Information, Inc.', vendorId: 0x8564 };
      const device = await navigator.usb.requestDevice({ filters: [usbDeviceProperties] });
      // await device.open();
      console.log(device);
      return `
      USB device name: ${device.productName}, Manifacture is ${device.manufacturerName}
      USB Version is: ${device.usbVersionMajor}.${device.usbVersionMinor}.${device.usbVersionSubminor}
      Product Serial Number is ${device.serialNumber}
      `;
    } catch (error) {
      return 'Error: ' + error.message;
    }
  }

  async getDevices() {
    const devices = await navigator.usb.getDevices();
    devices.map(device => {
      console.log(device.productName); // "Mass Storage Device"
      console.log(device.manufacturerName); // "JetFlash"
      this.snackBar.open(
        `this. USB device name: ${device.productName}, Manifacture is ${device.manufacturerName} is connected.`
      );
    });
  }
}

让我们来分解一下:

  1. 功能检测以确保“usb”可用。

  2. 定义requestDevice方法,它调用navigator.usb.requestDevice()。我需要通过vendorID明确过滤我的 USB 设备。我没有神奇地想出供应商的十六进制数;我所做的就是在这个列表中搜索并找到我的设备名‘创见’www . Linux-USB . org/USB . ids

  3. 定义getDevices方法,它调用navigator.usb.getDevices();解析后,它将返回连接到源的设备列表。

我们在header.component.html ,中添加了两个按钮,点击后分别调用getDevices()requestDevice()方法。

  <button mat-menu-item (click)="getUSBDevices()" *ngIf="isWebUSBSupported">
    <mat-icon>usb</mat-icon>
    <span>USB Devices List</span>
  </button>
  <button mat-menu-item (click)="pairUSBDevice()" *ngIf="isWebUSBSupported">
    <mat-icon>usb</mat-icon>
    <span>USB Devices Pair</span>
  </button>

WebUSBService注入header.component.ts。如果isWebUSBSupportedtrue,确保按钮可见。

constructor(private webUsb: WebUSBService) {
        this.isWebUSBSupported = this.webUsb.isWebUSBSupported;
}

  getUSBDevices() {
    this.webUsb.getDevices();
  }

  async pairUSBDevice() {
    const message = await this.webUsb.requestDevice();
    this.snackBar.open(message);
  }

点击“USB 设备配对”,会出现一个列表,显示我的设备,我可以进行配对(参见图 12-14 )。

img/470914_1_En_12_Fig14_HTML.jpg

图 12-14

调用requestDevice()时基于过滤器选项的列表中的设备。配对后,根据逻辑,会出现一条消息,显示设备信息,如序列号、设备名称、制造商、USB 版本等。设备连接后,就可以传入和传出数据了。

配对成功完成后,设备就可以打开,数据可以传入传出。

例如,下面是一个设备与之通信的示例:

              await device.open();
              await device.selectConfiguration(1) // Select configuration #1
              await device.claimInterface(0) // Request exclusive control over interface #0
              await device.controlTransferOut({
                      "recipient": "interface",
                      "requestType": "class",
                      "request": 9,
                     "value": 0x0300,
                    "index": 0 })
             const result = await device.transferIn(8, 64); // Ready to receive data7
          // and you need to read the result...

该信息特定于每个设备。然而,这些方法是浏览器中的 API。

一般来说,Web USB API 提供所有端点类型的 USB 设备:

  • 中断传输:

通过调用transferIn(endpointNumber, length)transferOut(endpointNumber, data)用于典型的非周期性小型设备“启动”通信

  • 控制权转移:

通过调用controlTransferIn(setup, length)controlTransferOut(setup, data)用于命令和状态操作

  • 批量转账:

用于通过调用 t ransferIn(endpointNumber, length)transferOut(endpointNumber, data)发送到打印机的大量数据,如打印作业

  • 同步传输:

用于连续和周期性数据,如通过调用isochronousTransferIn(endpointNumber, packetLengths)isochronousTransferOut(endpointNumber, data, packetLengths)的音频或视频流

最后但并非最不重要的一点是,用户可能会将设备与系统连接或断开。有两个事件可以监听并据此采取行动。

      navigator.usb.onconnect = event => {
        // event.device will bring the connected device
        // do something here
        console.log('this device connected again: ' + event.device);
      };

      navigator.usb.ondisconnect = event => {
        // event.device will bring the disconnected device
        // do something here
        console.log('this device disconnected: ', event.device);
      };

在 Chrome 中调试 USB 更容易,有了内部页面chrome://device-log,你可以在一个地方看到所有与 USB 设备相关的事件。

注意

克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入第十二章,06-web-usb-api 文件夹,找到所有样本代码。

浏览器支持

在写这本书的时候,支持这个 API 的浏览器是 Chrome For desktop 和 Android 以及 Opera。虽然 API 正在迅速发展,但我希望我们很快能在浏览器中看到更好的支持。

摘要

在这一章中,我们刚刚探索了六个 web APIs。虽然它们不是 PWA 的重要组成部分,但它们有助于构建更接近原生应用的应用。

正如我在这一章的介绍中所写的,这些并不是唯一出现在 web 上的新 API。还有许多其他的正在开发中或考虑很快开发出来。

我对 web 开发的未来感到非常兴奋,因为我可以看到它将如何在我们面前打开无数的机会,来构建和发布一个更好的 web 应用。

十三、使用 Angular 和 Workbox 的 PWA

直到这本书的这一点,我赌 Angular Service Worker 模块,并在此基础上构建。它有很多优点,包括代码少;经过高规模、可靠性和稳定性测试;与服务工作器交流的 Angular;使用 CLI 自动构建;以及 Angular 团队的大力支持。这真的让我们安心。

然而,像其他工具一样,也有缺点。其中一个缺点是 Angular Service Worker 不能以适当的方式扩展(至少在写这本书的时候),这意味着如果你想的话,你不能给 Service Worker 添加你自己的逻辑;或者你可能需要一些 Angular Service Worker 还不支持的新的 Service Worker APIs 或者特性,可能需要一段时间团队才能给 Angular 开发者提供一个公共 API。

幸运的是,有一些工具支持轻松地生成服务工人,尽管它们可能比 Angular 服务工人更复杂。其中最好的是谷歌 Chrome 团队的库 Workbox。Workbox 是一个模块化的库,它提供了一种非常简单的方式来编写我们的服务工作器。Workbox CLI(命令行界面)由 Node.js 程序组成,可以从 Mac、Window 和 Unix 兼容的命令行环境中运行。它将 Workbox 构建模块包装在钩子下,这将生成整个服务工作器,或者只是生成一个资产列表,以便预先缓存可以在现有服务工作器中使用的资产。

在这一章中,我们将努力探索 Workbox 的功能,并使用 Workbox 再次构建 Note PWA。您将看到 Workbox 设置和 Angular Service Worker 之间的区别。因此,基于你的项目,你将能够决定选择哪一个来建造你的下一个奇妙的 PWA。

Angular 和 Workbox 设置

在我们继续深入之前,我们将探索 Workbox 并解释它是如何工作的。

Workbox 是一个模块化的库,有助于以最小的努力生成一个完整的服务工作器。它可以自动生成一个软件,或者允许我们编写一个定制的服务工作器,它将根据配置(也称为清单)注入脚本,并生成一个完整的服务工作器。

Workbox-cli 提供了一种简单的方法,通过灵活的配置将 Workbox 集成到命令行构建过程中。要安装 CLI:

npm install workbox-cli --global

或者如果您想在本地安装(我更喜欢):

npm install workbox-cli --save-dev // to run `npx workbox [mode]`

Workbox CLI 有四种不同的模式,分别是:

  • 向导:为你的项目设置 Workbox 的逐步指南。

  • generates SW:为你生成一个完整的服务工作器。

  • injectManifest :把要预缓存的资产注入到项目中。

  • copyLibraries :将 Workbox 库复制到一个目录中。

Workbox 由开发人员可以决定使用的不同模块组成。这些模块如下:

  • 核心:每个模块依赖的公共代码,比如日志级别。

  • 预缓存:简化安装事件时的预缓存应用外壳。

  • 路由:也许是最重要的模块,在这里您可以拦截网络请求并做出相应的响应。

  • 策略:提供最常用的缓存策略,以便在您的服务工作器中轻松应用。

  • 到期:允许您限制缓存中的条目数量和/或删除已经缓存了很长时间的条目。

  • BackgroundSync :检测由于连接问题导致的网络请求失败,并将它们在 IndexedDB 中排队,并将在“Sync”事件上重试,当用户重新连接时浏览器会触发该事件。这个模块也为那些仍然不支持后台同步 API 的浏览器提供了一个后备。在撰写本书时,Angular Service Worker 中还没有该功能。

  • GoogleAnalytics :帮助检测失败的测量协议请求,存储在 IndexedDB 中,并在连接恢复后重试。

  • CacheableResponse :提供了一种标准的方法来确定是否应该根据响应的数字状态代码、是否存在具有特定值的报头或者两者的组合来缓存响应。

  • BroadcastUpdate :提供一种通知窗口客户端缓存响应已经更新的标准方式。这个模块使用Broadcast Channel AI来宣布更新。在 Workbox 4 中,对于那些不支持广播通道 API 的浏览器,它会自动退回到postMessage() API

  • 导航预加载 : 1 会在运行时处理检查当前浏览器是否支持导航预加载;如果有,它将自动创建一个激活事件处理程序来启用它。

  • RangeRequests :当发出请求时,可以设置一个范围头,告诉服务器只返回整个请求的一部分。这对于某些文件非常有用,比如视频文件,用户可能会更改播放视频的位置。

我们现在知道了基本情况。让我们继续,并将 Workbox 添加到我们的 Angular 项目中。

注意

克隆 https://github.com/mhadaily/awesome-apress-pwa.git 并转到第十三章,文件夹 01-starter 没有服务工人实现,并准备开始添加 Workbox。

Workbox 向导模式

使用 Workbox 的第一种也是最简单的方法是使用向导模式。Workbox CLI 会问您几个问题。然后workbox-config.js被创建,您可以在您的构建过程中添加或生成一个服务工作器。事实上,可以通过创建workbox-config文件来手动完成所有这些步骤。

用 Workbox CLI 运行向导模式: 2

npx workbox wizard

然后出现问题,如下所示:

  1. 您的 web 应用的根目录是什么(例如,您部署哪个目录)?(地区或者可能是地区/项目名称 )

  2. 您希望预缓存哪些文件类型?(按选择,

  3. 您希望将服务工作器文件保存在哪里?(距离/开关位置)

  4. 您想在哪里保存这些配置选项? (workbox-config.js)

按照您在向导中的选择,将在名为workbox-config.js的文件中以最少的设置生成配置文件:

  1. globDirectory:Workbox 需要扫描模式或忽略下一个属性中提供的文件的文件夹。

  2. 为了将它们添加到预缓存中,一个 globs 数组,本质上是为了生成我们的 app-shell。

  3. globIgnores:对于 app-shell 将被忽略的 glob 类型的数组。

  4. swDest:生成后放置sw.js的文件夹。

  5. imporWorkboxFrom:定义如何将 Workbox 库导入到维修工人档案中。

    1. cdn:脚本将从 Google 云存储中导入。例如: https://storage.googleapis.com/workbox-cdn/releases/3.6.3/workbox-sw.js

    2. local:Workbox 库必须复制到dist文件夹,并导入到维修工人中。为了复制 Workbox 库,运行npx workbox copyLibraries dist

    3. none:将不导入任何内容。

  6. maximumFileSizeToCacheInBytes:发现文件过大时的防护。

module.exports = {
  globDirectory: 'dist/', // this could be dist/project-name in an Angular project
  globPatterns: ['**/*.{js,txt,png,ico,html,css}'  ],
  globIgnores: ['stats.json'],
  swDest: 'dist/sw.js', // this could be dist/project-name in an Angular project
  importWorkboxFrom: 'local',
 maximumFileSizeToCacheInBytes: 4 * 1024 * 1024 // not more than 4MB
};

这个配置足以生成一个服务工作器来预缓存静态资产和 app-shell。Angular 构建完成后,通过运行以下命令,Workbox 将自动生成一个服务工作器:

npx workbox generateSW workbox-config.js

Tada!自动生成的sw.jsdist文件夹里,我们来看一下:

importScripts(`workbox-v3.6.3/workbox-sw.js`);
workbox.precaching.precacheAndRoute([
  {
    "url": "favicon.ico",
    "revision": "b9aa7c338693424aae99599bec875b5f"
  },
  {
    "url": "index.html",
    "revision": "ba3375f16e2a5c7fdf36600745e88e98"
  },
  {
    "url": "styles.356e924fea446d033420.css",
    "revision": "b7a968bbc1b49cd4f6478cae97fed4f6"
  },
  {
    "url": "1.ee064b5075b0e24f691c.js",
    "revision": "1a0cf93d36be20c46550e5a85a91aeae"

  },
  {
    "url": "5.902dda00d476d615f591.js",
    "revision": "28265e0a43435a8acebad181a6f02056"
  },
  {
    "url": "6.58566fec934a1864fc29.js",
    "revision": "33af875f4f0454106aa0e23f66ee13d0"
  },
  {
    "url": "lazy-fonts.js",
    "revision": "62693c91e34c656d59025a6fb3e22f99"
  },
  {
    "url": "main.e1f6fe9ffe4709effd6b.js",

    "revision": "6debac0612cf6f10ab6140e18f310899"
  },
  {
    "url": "polyfills.c1da48c5c45ccdef1eb4.js",
    "revision": "7c508c4c2a0d8521e03909fb9e015ebe"
  },
  {
    "url": "runtime.0c53ce34d2b71056f3b2.js",
    "revision": "ad44f617b496d7cf73f3e6338864abe1"
  }
]);

首先导入 Workbox 库,然后使用预缓存模块中的一组 app-shell 资产,以便在 Service Worker 的“install”事件时将它们放入缓存中。

注意

Workbox 使用类似于 Angular Service Worker 的修订哈希来检测文件更改。

现在是注册sw.js的时候了。我们将在main.ts文件中添加我的服务工作器注册,其中 Angular bootstrapAppModule

document.addEventListener('DOMContentLoaded', async () => {
  try {
    const module = await platformBrowserDynamic().bootstrapModule(AppModule);

  const app = module.injector.get(ApplicationRef);

    const whenStable = await app.isStable
      .pipe(filter((stable: boolean) => !!stable), take(1)).toPromise();

    window.onload = async () => {
      if (whenStable && navigator.serviceWorker && environment.production) {

        const registration = await navigator
                          .serviceWorker.register('/sw.js', { scope:
                    '/' });
        console.log(`sw.js has been registered, scope is: ${registration.scope}`);

      }

    };
  } catch (err) {
    console.error(err);
  }
});

分解:

  1. 一旦 Angular AppModule被引导并保证得到解决,我们将通过依赖注入来访问ApplicationRef,以确定应用是否稳定。

  2. 为了确保注册尽可能高效,我们将逻辑保存在“窗口加载”事件中。

  3. 一旦 Angular 自举被解析并且AppModule稳定,这意味着当应用启动时没有任何类型的重复异步任务:例如,一个轮询过程从 setInterval 或 Rxjs Interval 开始。

    我们将为服务工作器进行功能检测,作为渐进增强和生产环境的一部分,以防止开发中的冲突。

最后,我们将在我的构建管道中添加一个 Workbox 命令,用于在生产 Angular 构建完成后立即生成一个服务工作器:

"build:prod": "ng build --prod && workbox copyLibraries dist && workbox generateSW workbox-config.js",

Workbox 注入清单

Workbox generateSW很简单,完全基于配置,并且很容易生成一个完整的服务工作器。它适用于许多网络应用。然而,如果我们出于任何原因想要向服务工作器添加我们定制代码,该怎么办呢?每次 Workbox 生成 SW 文件,我们的自定义代码都会被覆盖。一定有解决的办法。

幸运的是,Workbox 提供了injectManifest模式,在这种模式下,您可以控制您的服务工作器文件,并让 Workbox 生成它的一部分。您的所有配置都是作为自定义服务工作器中的代码而不是配置文件编写的。

要使用injectManifest,您需要通过swSrc属性指定自定义服务工作器的来源。我在src文件夹中创建了一个sw-srouce.js,并将其添加到配置文件中。

module.exports = {
  globDirectory: 'dist/',
  globPatterns: ['**/*.{js,txt,png,ico,html,css}'],
  globIgnores: ['stats.json'],
  swDest: 'dist/sw.js',
  swSrc: 'src/sw-source.js',
  maximumFileSizeToCacheInBytes: 4 * 1024 * 1024 // not more than 4MB
};

现在我们需要创建“源服务工作器”我们开始吧。不过,首先要做的是:我们需要导入 Workbox。

// current workbox version

const MODULE_PATH_PREFIX = 'workbox-v3.6.3';

// to copy workbox files run npm run copyWorkboxModules or 'npx workbox copyLibraries dist'
// this synchronously load workbox locally, if you prefer CDN use the linke
// mentioned earlier

importScripts(`${MODULE_PATH_PREFIX}/workbox-sw.js`);

if (!workbox) {
  // if workbox for any resson didn't happen simply ignore the rest of file
  console.error(`Something went wrong while loading ${modulePathPrefix}/workbox-sw.js`);
} else {
       // OUR CODE
}

我们可以根据需求修改 Workbox 配置和软件更新周期。

  // set module path prefix
  workbox.setConfig({ modulePathPrefix: MODULE_PATH_PREFIX });

  // overwrite cache name details if you like, if you don’t write this line,
  // Workbox uses default settings.
     workbox.core.setCacheNameDetails({
     prefix: 'angular-aprees-note-pwa',
     suffix: 'v1',
      precache: 'install-time',
      runtime: 'run-time',
     googleAnalytics: 'ga'
     });
// Modify SW update cycle
// forces the waiting service worker to become the active service worker.
  workbox.skipWaiting();
// ensure that updates to the underlying service worker take effect immediately // for both the current client and all other active clients.
  workbox.clientsClaim();

Workbox 还将生成预缓存资产;然而,我们需要明确地告诉 Workbox 资产(manifestEntry s)应该在源文件中的什么位置连接。我们可以通过两种方式进行配置:

  • 通过添加包含两个捕获组的不同的RegExp。清单数组将在捕获组之间注入。

例如:injectionPointRegexp: new RegExp('(const myManifest =)(;)'),

默认为: /(\.precacheAndRoute\()\s*\[\s*\]\s*(\))/

  • 或者,我们可以在源服务工作器文件中添加一个占位符,方法是使用一个预缓存模块,该模块通过传递一个空数组来调用precacheAndRoute([])
  /* PRE-CACHE STERATEGY */

  // this is a placeholder. All assets that must be precached will be injected here
  // automatically
  workbox.precaching.precacheAndRoute([]);

在第四章中,我们在ngsw-config.json中定义了assetGroups。如果你忘记了,请快速回顾第四章,其中解释了ngsw-config.json assetGroups

对于预取installMode,我也有一个可以写入 Workbox 配置文件的 globs 列表。

  globPatterns: [
    '**/favicon.ico',  '**/index.html', '**/*.css',  '**/*.js'
  ],

到目前为止,我们已经通过指示 Workbox 将 app-shell 资源添加到服务工作器中“安装事件”期间实际发生的缓存位置,完成了预缓存。现在我们需要为具有不同缓存策略的运行时缓存编写逻辑。Workbox 路由模块允许我们通过定义一个匹配特定请求的正则表达式来注册路由,然后为其分配一个缓存策略。

在我们继续之前,让我提醒您,我们已经在第四章探讨了高级缓存策略,基本上,Workbox 策略模块可以毫不费力地为您提供这些策略。

  • 重新验证时失效:workbox.strategies.staleWhileRevalidate()

  • 先缓存(缓存回退到网络):workbox.strategies.cacheFirst()

  • 网络优先(网络退回到缓存):workbox.strategies.networkFirst()

  • 仅网络:workbox.strategies.networkOnly()

  • 仅缓存:workbox.strategies.cacheOnly()

所有这些方法都可以通过传递包含以下内容的对象参数来配置:

  • cacheName:策略中使用的缓存名称。

  • plugins:一组插件,当获取和缓存请求时,将调用它们的生命周期方法。我们可以通过传入实例来使用所有的 Workbox 插件,如expiration, cacheableResponse, broadcastUpdate, and backgroundSync以及一个自定义插件。

我们来注册两个路由,用于缓存sw-source.js中动态请求的图片和 Google 字体。

  workbox.routing.registerRoute(
    new RegExp('/(.*)assets(.*).(?:png|gif|jpg)/'),
    // cacheFirst for images
    workbox.strategies.cacheFirst({
      cacheName: 'images-cache',
      plugins: [
                  // set cache expiration restrictions to use in the strategy
        new workbox.expiration.Plugin({
          // only cache 50 requests
          maxEntries: 50,
          // only cache requests for 30 days
          maxAgeSeconds: 30 * 24 * 60 * 60
        })
      ]
    })
  );
  // we need to handle Google fonts
  workbox.routing.registerRoute(
    new RegExp('https://fonts.(?:googleapis|gstatic).com/(.*)'),
    // stale-while-revalidate for fonts
    workbox.strategies.staleWhileRevalidate({
      cacheName: 'google-apis-cache',
      plugins: [
                  // set cache expiration restrictions to use in the strategy
        new workbox.expiration.Plugin({
          // only cache 50 requests
          maxEntries: 10,
          // only cache requests for 10 days
          maxAgeSeconds: 10 * 24 * 60 * 60
        })
      ]
    })
  );

看过第四章第四章的ngsw-config,在dataGroups中,我们定义了api-network-firstapi-cache-first。让我们用 Workbox 注册这些路由。

 // API with network-first strategy
  workbox.routing.registerRoute(
    new RegExp('https://firestore.googleapis.com/v1beta1/(.*)'),
    workbox.strategies.networkFirst({
      cacheName: 'api-network-first',
      plugins: [
        new workbox.expiration.Plugin({
          maxEntries: 100
        })
      ]
    })
  );
  // API with cache-first strategy
  workbox.routing.registerRoute(
    new RegExp('https://icanhazdadjoke.com/(.*)'),
    workbox.strategies.cacheFirst({
      cacheName: 'api-cache-first',
      plugins: [
        new workbox.expiration.Plugin({
          maxEntries: 20,
          maxAgeSeconds: 15 * 60 * 60 // 15 min
        })
      ]

    })
  );

路由模块允许我们为特定的导航路由添加白名单或黑名单。我们将使用来自 Angular 清单文件的相同的Regex

  // Register whitelist and black list
  workbox.routing.registerNavigationRoute('/index.html', {
    whitelist: [new RegExp('^\\/.*$')],
    blacklist: [
      new RegExp('/restricted/(.*)'),
      new RegExp('^\\/(?:.+\\/)?[^/]*\\.[^/]*$'),
      new RegExp('^\\/(?:.+\\/)?[^/]*__[^/]*$'),
      new RegExp('^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$')
    ]
  });

构建应用的时间到了。为了简化构建过程,我们将添加两个npm脚本到packge.json and将 injectManifest 添加到生产构建脚本。

"injectManifest": "workbox copyLibraries dist && workbox injectManifest",
"copyWorkboxModules": "workbox copyLibraries dist"
"build:prod:shell": "ng run lovely-offline:app-shell:production && npm run injectManifest",

让我们构建并运行应用。首次访问后,在离线模式下测试应用(参见图 13-1 )。

img/470914_1_En_13_Fig1_HTML.jpg

图 13-1

为 Workbox 编写逻辑后的缓存存储

注意

克隆 https://github.com/mhadaily/awesome-apress-pwa.git ,进入 12 章,02-Workbox-设置文件夹,找到所有样本代码。您可以通过运行npm run prod在浏览器中构建应用并进行测试。

摘要

在这一章中,我们设法用 Workbox 建立了一个 Angular 项目,并生成了我们的定制服务 Worker,它在这里缓存 app-shell 资源,并根据我们定义的模式和适当的策略拦截网络请求。

在发布 Workbox 4 时,您可以阅读本章。Workbox 4 中有一些突破性的变化,尽管可能会有额外的功能,但本章中揭示的许多技术也可以用于版本 4。

在下一章,我们将探索高级功能,如后台同步,这有助于重试由于没有连接而失败的请求;推送参与通知;通知用户刷新应用以接收最新更新的更新流通知;和离线分析。

十四、高级 Workbox

在前一章中,我教了你如何在 Angular 项目中使用 Workbox,无论你是否已经使用了 Angular Service Worker 并希望用 Workbox 替换它,或者你只是想从头开始一个新项目。

在这一章中,我将向你展示如何实现后台同步,推送通知,离线分析,以及如何在有新的更新时通知用户。

处理更新

当用缓存条目响应请求时,虽然速度很快,但也有一个代价,那就是我最终会看到稳定的数据。Workbox 提供了广播更新模块,这有助于在缓存响应有可用更新时以标准方式通知 Window 客户端。虽然默认情况下,Workbox 会比较Conent-LengthETagLast-Modified标头来检测更新,但我们仍然可以定义要检查的自定义标头。

如果预缓存资产有可用的更新,我们将开始实现一个广播消息的通道。在sw-source.js中,我们将把broadcastUpdate插件添加到预缓存模块中,以便打开一个新的通道来接收更新通知消息:

workbox.precaching.addPlugins([new

workbox.broadcastUpdate.Plugin('app-shell-update')]);

或者我们可以将这个插件与staleWhileRevalidate缓存策略一起使用,因为该策略包括立即返回缓存的响应,而且还提供了一种异步更新缓存的机制。插件的第一个参数是频道名,第二个参数是为函数提供选项的对象。例如,我们可以传递headersToCheck,这是一个数组,用于定义所有的定制头,必须对这些头进行检查,以检测变化并通知整个通道。

  workbox.routing.registerRoute(
    new RegExp('https://fonts.(?:googleapis|gstatic).com/(.*)'),
    workbox.strategies.staleWhileRevalidate({
      cacheName: 'google-apis-cache',
      plugins: [
        new workbox.expiration.Plugin({
          maxEntries: 10,
          maxAgeSeconds: 10 * 24 * 60 * 60 // 10 Days
        })
        // new workbox.broadcastUpdate.Plugin('apis-updates', {
        //   headersToCheck: ['X-Custom-Header']
        // })
      ]
    })
  );

在 Angular app-component 中,我们需要监听我们在 Service Worker 中打开的通道,以便接收消息并执行相应的操作。例如,当收到消息时,将显示具有更新操作按钮的小吃店。单击或点击“更新我”操作按钮后,我们将强制重新加载窗口,这有助于新的更新自动到位。

  ngOnInit() {
    this.joke$ = this.db.getRandomDadJoke();
    this.checkForUpdates();
  }

checkForUpdates() {
    const updateChannel = new this.window.native.BroadcastChannel('app-shell-update');
    updateChannel.addEventListener('message', event => {
      console.log(event);
      this.snackBar
        .open('Newer version of the app is available', 'Update me!')
        .onAction()
        .subscribe(() => {
          this.window.native.location.reload();
        });
    });
  }

对象可能在 Angular 运行的任何地方都不可用,例如移动或网络工作人员;因此,您会注意到我们正在使用注入到 app-component 中的WindowRef服务,而不是直接获取对window对象的引用来根据环境更改给定对象的具体运行时实例。对于这个项目来说,它可能看起来过度劳累了,但是让我们以有 Angular 的方式做它。

// app-component.ts
constructor(private window: WindowRef){}

并通过创建如下的WindowRefService来包装window:

// window.service.ts
function _window(): any {
  // return the native window obj
  return window;
}

@Injectable()
export class WindowRef {
  get native(): any {
    return _window();
  }
}

值得一提的是,在 Service Worker 的安装过程中,有一种替代方法可以监听更新。ServiceWorkerRegistration 接口的 onupdatefound 属性是每当激发 statechange 类型的事件时调用的 EventListener 属性;每当 serviceworkerregistration . installing 属性获取新的服务工作线程时,都会触发该事件。

if ("serviceWorker" in navigator) {
  // register service worker file
  navigator.serviceWorker
    .register("service-worker.js")
    .then(reg => {
      reg.onupdatefound = () => {
        const installingWorker = reg.installing;
        installingWorker.onstatechange = () => {
          switch (installingWorker.state) {
            case "installed":
              if (navigator.serviceWorker.controller) {
                // new update available
              } else {
                // no update available
              }
              break;
          }
        };
      };
    })
    .catch(err => console.error("[SW ERROR]", err));
}

上面的代码是一个例子,我们可以通过它来了解更新的目的。

让我们构建并运行应用。要查看通知,首先确保您在 Android 和桌面上运行的应用在支持的浏览器中运行,如 Firefox、Chrome 和 Opera。 1 当有更新可用时,snackBar 会显示一条带有操作按钮的消息(图 14-1 )。

img/470914_1_En_14_Fig1_HTML.jpg

图 14-1

“更新我”按钮将触发重新加载页面

后台同步

对于那些由于没有连接或服务器停机而失败的请求,BackgroundSync API 是一个理想的解决方案。当服务工作器检测到网络请求失败时,它可以注册一个sync事件,该事件在浏览器认为连接已经恢复时被发送。因此,我们可以保存请求,当sync事件发生时,重试发送请求。这比解决这个问题的传统策略更有效,因为即使用户已经离开应用,我们仍然可以将服务工作器的请求发送到服务器。

Workbox 提供了一个后台同步模块,帮助拦截失败的网络请求,并将其保存在 IndexedDB 中,以便在发生sync事件时重试。它还为还没有实现BackgroundSync的浏览器实现了一个后备策略。

实现后台同步的最佳选择是 Note PWA 中的 POST 和 DELETE 方法。为了演示后端 API,我们将创建一个简单的express应用来提供 POST 和 DELETE APIs:

const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios').default;
const app = express();

app.use(express.static(__dirname + '/dist'));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

app.post('/api/saveNote', async (req, res) => {
  try {
    const result = await axios.post('https://us-central1-awesome-apress-pwa.cloudfunctions.net/saveNote', req.body);
    return res.status(201).json(result.data);
  } catch (error) {
    return res.status(500).json({ success: false, error: { message: 'something went wrong with the endpoint' } });
  }
});

app.delete(`/api/deleteNote/users/:user_id/notes/:note_id`, async (req, res) => {
  try {
    const { user_id, note_id } = req.params;
    const { authorization } = req.headers;
    const result = await axios.delete(
      `https://firestore.googleapis.com/v1beta1/projects/awesome-apress-pwa/databases/(default)/documents/users/${user_id}/notes/${note_id}`,
      {
        headers: {
          Authorization: authorization
        }
      }

    );
    return res.json(result.data);
  } catch (error) {
    console.log(error);
    return res.status(500).json({ success: false, error: { message: 'something went wrong with the endpoint' } });
  }
});

// redirect all routes to index.html since we are running single page application
app.get('*', (req, res) => {
  res.sendfile('./dist/index.html');
});

app.listen(4200);
console.log('SEVER IS R'EADY -> PORT 4200');

您可以在终端.中运行这个服务器,就像"node simple-express-server.js"一样简单。您还记得,DataService负责发出 http 请求;因此,我们将稍微修改这个服务中的两个方法和端点,使它们指向新的后端 API。

我们将数据端点SaveNote指向我们的后端。

  protected readonly SAVE_NOTE_ENDPOINT = '/api/saveNote';
  saveNoteFromCloudFunction(note: Note): Observable<{ success: boolean; data: Note }> {
    return this.http.post<{ success: boolean; data: Note }>(this.SAVE_NOTE_ENDPOINT, {
      user: this.auth.id,
      data: {
        ...note,
        created_at: this.timestamp,
        updated_at: this.timestamp
      }
    });
  }

我还将定义一个新方法来删除指向我的后端 API 的注释。

  deleteNoteDirectly(id): Promise<any> {
    return this.auth
      .getToken()
      .pipe(
        switchMap(idToken => {
          return this.http.delete(`/api/deleteNote/users/${this.auth.id}/notes/${id}`, {
            headers: {
              Authorization: `Bearer ${idToken}`
            }
          });
        })
      )
      .toPromise();
  }

最后,我们将在保存和删除单个便笺时使用这些方法。一旦你克隆了 https://github.com/mhadaily/awesome-apress-pwa ,进入第十四章,然后 02-背景-同步。您会发现所有的示例代码,包括NoteModuleDataServicesw-source.js.中的所有新变化

我们将注册两条路由,以便拦截失败的网络请求,并使用backgroundSync插件来重试这些请求。

workbox.routing.registerRoute(

    new RegExp('/api/saveNote'),
    workbox.strategies.networkOnly({
      plugins: [
        new workbox.backgroundSync.Plugin('firebaseSaveNoteQueue',
 {
          callbacks: {
            queueDidReplay: StorableRequest => {
 // Invoked after all requests in the queue have successfully replayed.
              console.log('queueDidReplay', StorableRequest);
// show notification
              self.registration.showNotification('Background Sync Successful', {
                body: 'You notes has been saved in cloud! '});
            },
            requestWillEnqueue: StorableRequest => {
  // Invoked immediately before the request is stored to IndexedDB.
// Use this callback to modify request data at store time.
              console.log('requestWillEnqueue', StorableRequest);
            },
            requestWillReplay: StorableRequest => {
  // Invoked immediately before the request is re-fetched.
// Use this callback to modify request data at fetch time.
              console.log('requestWillEnqueue', StorableRequest);
            }

          },
          maxRetentionTime: 60 * 24 * 7 // 7 days in minutes
        })
      ]
    }),
    'POST'
  );

既然参数已经传递到了registerRoute()函数中,我们就来分解一下:

  1. 第一个参数是匹配网络请求的正则表达式,在本例中是/api/saveNote

  2. 后台同步已被添加到插件中。第一个参数是队列名称,第二个参数是 options,这是可选的。在选项中,有几个属性,比如指示这个请求应该重试多长时间的maxRetentionTime和可以访问生命周期方法的callbacks

    1. queueDidReplay:在队列中的所有请求成功重放后调用。

    2. requestWillEnqueue:在请求存储到 IndexedDB 之前立即调用。

    3. requestWillReplay:在重新获取请求之前立即调用。

  3. 第三个参数是 HTTP 方法。

我们将注册一个新的路由来拦截失败的DeleteNote网络请求。

  workbox.routing.registerRoute(
    new RegExp('/api/deleteNote/(.*)'),
    workbox.strategies.networkOnly({
      plugins: [
        new workbox.backgroundSync.Plugin('firebaseDeleteNoteQueue', {
          callbacks: {
            queueDidReplay: _ => {
              self.registration.showNotification('Background Sync Successful', {
                body: 'DELETE is done!'
              });
            }
          },
          maxRetentionTime: 24 * 60 // Retry for max of 24 Hours
        })
      ]

    }),
    'DELETE'
  );

可悲的是,测试BackgroundSync有些不直观和困难,原因有很多。最好的测试方法之一是使用以下步骤:

img/470914_1_En_14_Fig5_HTML.jpg

图 14-5

Workbox 登录后台同步的回调函数

img/470914_1_En_14_Fig4_HTML.jpg

图 14-4

Chrome 开发工具中的模拟后台同步

img/470914_1_En_14_Fig3_HTML.jpg

图 14-3

网络请求成功同步时的成功通知

img/470914_1_En_14_Fig2_HTML.jpg

图 14-2

indexeddb 中的队列数据库

  1. 注册服务工作器时,在生产环境中构建并运行应用。

  2. 关闭自己电脑的网络或者关闭后端服务器,也就是simple-express-server.js。请注意,你不能在 Chrome DevTools 中使用 offline,因为它只会影响来自页面的请求。服务工作器的请求将继续通过。

  3. 发出应通过 Workbox 后台同步排队的网络请求。例如,添加注释或删除注释。

  4. 您可以通过查看Chrome DevTools > Application > IndexedDB > workbox-background-sync > requests.来检查请求是否已经排队

  5. 现在打开网络或运行网络服务器(node simple-express-server.js)。

  6. 转到Chrome DevTools > Application > Service Workers,输入workbox-background-sync:<your queue name>的标签名称,例如workbox-background-sync:firebaseSaveNoteQueue,其中“”应该是您设置的队列名称,然后单击“同步”按钮,强制提前同步事件。

  7. 您应该看到失败请求的网络请求通过,IndexedDB 数据现在应该是空的,因为请求已经被成功重放(图 14-2 、 14-3 、 14-4 和 14-5 )。

如果你在localhost时查看控制台,你也能看到日志。

当同步完成时,你应该会看到一个通知,因为我们已经在queueDidReplay回调中使用了showNotification()

推送通知

在第八章中,我解释了网页推送通知的基础,并教你如何使用 Angular Service Worker SwPush服务。既然我们已经取出了这个模块,我们将首先创建一个名为SwPushService的服务,它提供与 Angular 相同的方法,并在我们的组件中使用它。

@Injectable()
export class SwPushService {
  constructor() {}

  public async checkSW(): Promise<{ isEnabled: boolean; subscription: any }> {
      if (navigator.serviceWorker) {
        const registration = await navigator.serviceWorker.getRegistration();
        let subscription;
        if ('PushManager' in window && registration) {
          subscription = await registration.pushManager.getSubscription();
        }
        return { isEnabled: true, subscription };
      } else {
        return { isEnabled: false, subscription: null };
      }
    } else {
      return { isEnabled: false, subscription: null };
  }

  urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
    const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);
    for (let i = 0; i < rawData.length; ++i) {
      outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
  }

  async requestSubscription({ serverPublicKey }) {
      const registration = await navigator.serviceWorker.getRegistration();
      return registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: this.urlBase64ToUint8Array(serverPublicKey)
      });
  }

  async unsubscribe(): Promise<boolean> {
      const registration = await navigator.serviceWorker.getRegistration();
      const subscription = await registration.pushManager.getSubscription();
      return subscription.unsubscribe();
  }
}

让我们来分解一下:

  1. 我们需要首先检查服务工作器是否准备好以及PushManager是否有空。我们用这个来确保当浏览器支持 web 推送通知时,UI 中的“订阅”按钮被显示。

  2. requestSubscription()方法接受serverPublicKey。我们在订购pushManager时使用它。服务器公钥必须通过调用urlBase64ToUint8Array()转换成Uint8Array

  3. Unsubscribe()取消订阅推送管理器。

现在我们只是用有 Angular 的服务工作器来代替这项服务。既然我们已经提供了相同的方法,我们不需要改变太多东西,只需要在组件初始化上运行checkSW()

constructor(
    private auth: AuthService,
    private swPush: SwPushService,
    private snackBar: SnackBarService,
    private router: Router,
    private dataService: DataService
  ) {}

ngOnInit() {
    this.checkSW();
  }

  async checkSW() {
    const { isEnabled, subscription } = await this.swPush.checkSW();
    this.isEnabled = isEnabled;
    this.subscription$.next(subscription);
  }

标题的其余部分将和我们在第八章中创建的一样。让我们继续在sw-source.js中添加我们的推送通知事件。正如我们在本书前面讨论的,当收到推送通知时,push事件触发。因此,我们需要在服务工作器中倾听这一事件。

  self.onpush = event => {
    const { notification } = event.data.json();
    const promiseChain  = self.registration.showNotification(notification.title, notification);
    event.waitUntil(promiseChain);
  };

我们还需要处理通知动作的点击事件。在第八章中,我们在 Firebase 函数方法中实现了一个逻辑,当保存笔记成功时会发送一个通知。发送的通知将有两个自定义动作:opencancel

// Custom notification actions
  self.onnotificationclick = event => {
    event.notification.close();
    switch (event.action) {
      case 'cancel': {
// do something if you want, e.g sending analytics to track these actions
        break;
      }
      case 'open': {
// we can track these actions in Analytics
        const URL = `${self.registration.scope}notes/${event.notification.data.noteID}`;
        event.waitUntil(clients.openWindow(URL));
        break;
      }
      default: {
        event.waitUntil(
          clients
            .matchAll({
              includeUncontrolled: true,
              type: 'window'
            })
            .then(clientList => {
              clientList.forEach(client => {
                if (client.url == '/' && 'focus' in client) {
                  return client.focus();
                }
              });
              if (clients.openWindow) {
                return clients.openWindow('/');
              }
            })
        );
      }

    }
  };
  // Closing notification action
  self.onnotificationclose = event => {
    console.log('Notification Close Event', event);
    // do something if you want!
  };

构建并再次运行应用后,添加注释。如果您拥有有效的订阅,将向浏览器发送通知并显示给用户(参见图 14-6 )。

img/470914_1_En_14_Fig6_HTML.jpg

图 14-6

在手机和桌面上保存便笺后的网络推送通知

请注意,您可以克隆 https://github.com/mhadaily/awesome-apress-pwa ,所有示例代码都可以在 14 章➤03-推送通知文件夹中找到。

离线分析

离线分析是一个模块,将使用后台同步,以确保谷歌分析的请求,无论当前的网络条件;这在用户离线时尤其有用。

无论是直接在index.html中使用 Google tracking tag,还是使用angulartics2之类的模块,都应该设置一个自定义维度来确定 app 何时离线,何时在线。让我们在index.html中添加脚本。

<script>
      /*
      (function(i, s, o, g, r, a, m) { i['GoogleAnalyticsObject'] = r;
        (i[r] = i[r] || function() {  (i[r].q = i[r].q || []).push(arguments);}),
        (i[r].l = 1 * new Date()); (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]);
        a.async = 1; a.src = g; m.parentNode.insertBefore(a, m);
      })(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga');
      ga('create', 'UA-XXXXX-Y', 'auto');
      // Set default value of custom dimension 1 to 'online'
      ga('set', 'networkstatus', 'online');
      ga('send', 'pageview');
      */
    </script>

启用离线分析可以像sw-source.js:中一样简单

    workbox.googleAnalytics.initialize({
      parameterOverrides: {
        networkstatus: 'offline'
      }
    });

googleAnalytics初始化时,我们将传入我们定义的parameterOverrides来覆盖我们已经定义的维度,以确定应用离线时接收到的跟踪。

摘要

在本章中,高级主题已在 Angular 和 Service Worker with Workbox 中实现。我们演练了如何向window客户端发送消息,以通知客户端缓存中有更新。后台同步帮助我们在连接或端点重新联机后,重新尝试向服务器发送失败的请求。参与是我们通过实施推送通知实现的 PWA 的主要特征之一。最后,Workbox Google Analytics 模块提供了一种机制,我们可以在应用离线使用时跟踪它。话虽如此,让我们进入下一章,看看构建 PWA 的下一步是什么。

十五、后续步骤

恭喜你!您已经完成了 Angular PWA 培训,这使您能够使用 Angular 构建一个渐进式 Web 应用,并对 PWA 的工作方式有了很好的了解。但是,等等!这只是开始!从现在开始,你要不断学习,努力把事情做得越来越好。你不必在这一点上停下来,而是应该继续你的道路,这是你一直跟随我到这本书的这一点。还有很多东西要学。我们在本书中共同探索的一些概念只是知识和信息海洋的表面。你应该继续越潜越深。

作为一名开发人员,我们都知道我们今天写的东西在未来五年内可能会过时。你可能也注意到了,我在不同的章节中几次提到,这些 API 中的许多仍然在随着时间的推移而发展和变化,这意味着我们需要接受这些变化并保持更新。

在这一章中,我将写几页关于学习资源、案例研究和现实世界中的 PWA 示例,并介绍一些您应该关注的新技术和工具。

学习资源

有大量关于 PWA 的文章、视频、教程、博客和播客。在下面的列表中,我向您介绍了一些资源,如果您愿意,它们可以帮助您了解更多关于 PWA 和 Angular 的信息,并进行深入研究:

  1. https://developers.google.com/web/progressive-web-apps/

    我相信你以前见过这个网站。Google Developer 在 Web 下有一个专门的 PWA 部分。只是检查一下!

  2. https://web.dev

    在 2018 年 Chrome Dev 峰会上,谷歌宣布了一个专门用于网络,特别是 PWA 的新网站。这个网站不仅帮助你了解更多,还提供工具来衡量和审计你的网络应用。

  3. https://serviceworke.rs

    这个网站由 Mozilla 提供支持,提供了一系列在现代网站中使用服务工作器的实用例子。

  4. https://blog.angular.io/

    确保你从 Angular 博客上获得了最新的更新。此外,注意有 Angular 的文档也很好,尤其是 PWA 指南。

  5. https://developer.mozilla.org/en-US/docs/Web/Apps/Progressive

    Mozilla MDN 网站是所有开发者都熟悉的。留意 PWA 部分。

个案研究

在我看来,阅读其他开发人员和团队的经验并跟随他们的旅程总是很棒的。我总能找到许多技巧和窍门,有时有助于避免 bug 和错误,或者很多时候加快我的开发过程。我不会在这里写案例研究,但我会鼓励您阅读以下资源:

  1. https://developers.google.com/web/showcase/2018/nikkei

    Nikkei 凭借其多页 PWA 实现了质量和性能的新水平。

  2. https://developers.google.com/web/showcase/2018/asda-george

    乔治。com 通过新的 PWA 提升移动客户体验。

  3. https://developers.google.com/web/showcase/2017/eleme

    Ele.me 通过多页 PWA 提高了性能加载时间。

  4. https://developers.google.com/web/showcase/2017/bookmyshow

    BookMyShow 的新 PWA 将转化率提高了 80%。

  5. https://developers.google.com/web/showcase/2016/aliexpress

    全球速卖通通过新的 PWA 将新用户的转化率提高了 104%。

如果您只是简单地搜索 PWA 案例研究或查看 www.pwastats.com 以查看更多业务优势方面的用例,您可以在谷歌网站上找到更多信息。

示例应用

如果你有兴趣看看现在谁在生产中使用 PWA,你可以在这个网站上找到 PWA 网站列表: https://outweb.io/ 或者 https://pwa.rocks/

我鼓励你去看看黑客新闻,比如 PWAs: https://hnpwa.com/ 网站,在那里你会发现很多不同技术和工具的 PWAs 的不同实现。这是一个很好的学习和研究资源,尤其是关于用来提高初始负载和应用性能的技术。

工具和技术

尽管在本书中,我提到了很多工具和技术,并对它们进行了回顾,但仍然有一些工具和技术我想在这里写几行。

  1. 桌面渐进式网络应用

    正如我们已经讨论过的,PWA 的一个主要优势是我们只为浏览器创建,并且我们可以将它发布到不同的平台。移动用户是我们 Angular PWA 最重要的目标;这就是我们关注移动优化并多次提到的原因。然而,我们不要忘记,我们的桌面用户也将从我们的优化中受益。事实上,桌面 pwa 已经在许多平台上得到支持,例如 Chrome OS、Linux、Windows 和 Mac 上的 Chrome 67+。更好的是,我们能够向微软商店提交我们的 PWA 应用;一旦发布,我们的客户可以作为应用安装到 Windows 10。那是巨大的。想象一下你的 PWA 会被数百万活跃的 Windows 用户发现。

    因此,当您使用 Angular 构建 PWA 时,您应该考虑从移动到桌面的各种各样的客户。我觉得我们可能会看到 Google Play 或者 Apple Store!此外,我们可能会在未来将我们的 PWA 提交给他们的商店,谁知道呢!光是想想就让我兴奋不已。

    为了了解更多关于 Windows 商店和 PWA 的信息,请点击此链接: https://developer.microsoft.com/en-us/windows/pwa 。此外,谷歌有一个关于这个主题的专门页面,可以在这里访问: https://developers.google.com/web/progressive-web-apps/desktop .

  2. 可信网络活动

    受信任的网络活动是一种新的方式,它使用基于自定义标签的协议将您的网络应用内容(如 PWA)与您的 Android 应用相集成。在 https:// developers 上了解更多信息。谷歌。com/web/updates/2019/02/using-twa。

  3. 网络共享 API

    这是我最喜欢的 API 之一,我希望它能很快得到更好的支持,尤其是在 iOS 上。这个方法提供了一个简单的高级 JavaScript API,它调用主机平台的本地共享功能。该 API 是基于承诺的,并且只有一个方法。它接受至少需要有texturl属性的配置对象。

    Here is an example:

    // a method which gets invoke by user mouse click or tab (touch)
    async openShare(){
          if (navigator.share) {
          try {
            const result = await navigator.share({
                title: 'Apress NG-PWANote',
                text: 'Check out Apress Angular PWA Note!',
                url: 'https://awesome-apress-pwa.firebaseapp.com',
            })
              console.log('Successful share')
          } catch(error) {
             console.log('Error sharing', error)
           }
          }
    }
    
    

    Android 版 Chrome 支持这个 API。写这本书的时候还没有更多的支持,但是我希望在你读这本书的时候,这个 API 已经在不同的平台和浏览器上得到广泛的支持。

  4. 离线网页包插件

    出于某些原因,您可能会使用或正在使用 webpack 进行 Angular 应用。如果是这样,webpack 生态系统中有一个插件可以带来离线功能。

    在这里找到 https://github.com/NekR/offline-plugin

  5. www.pwabuilder.com

    这个网站是由微软创建的,它帮助你从你的网站上获取数据,并使用这些数据生成一个跨平台的 PWA。

    如果你喜欢自动化并且没有为你的网站配置,你可能会发现这个网站很有用!

  6. www.webhint.io

    另一个来自微软开发者的伟大网站。

    Webhint 是一个林挺工具,通过检查代码中的最佳实践和常见错误,帮助您提高站点的可访问性、速度、安全性等。使用在线扫描仪或 CLI 开始检查您的站点是否有错误。

  7. 后台获取

    这是一个 web 标准 API,在用户可见的背景下处理大型上传/下载。问题是当你取东西的时候,服务工作器必须活着,而且这个过程应该很短;否则,由于用户隐私和电池的风险,浏览器将杀死服务工作器。

    这对于可能需要很长时间才能完成的任务非常有用,比如下载电影或播客。在写这一章的时候,这个 API 是作为 Chrome 71 的一个实验性的 web 平台特性标志引入的。

    请关注这个 API,并在这里找到更多信息:

    https://developers.google.com/web/updates/2018/12/background-fetch

  8. 网页性能

    我们构建 PWA 是因为我们希望用户拥有快速、可靠、引人入胜的原生体验。因此,web 性能永远是一个我们必须不断学习的话题。你学的越多,你构建应用的速度就越快。很多资源,包括我在本章前面提到的那些,也提供了与性能相关的主题;但是,除此之外,您还可以找到以下有用的链接:

    https://developers.google.com/web/fundamentals/performance/why-performance-matters/

  9. 网页组件

    Web Components 是一套不同的技术,允许您创建可重用的自定义元素,同时将它们的功能封装在代码的其余部分之外,并允许您在 Web 应用中使用它们。

    这是一项由 Angular 元件支撑的伟大技术。你可以在这里找到更多关于它的信息: https://angular.io/guide/elements 。角藤 1 (很快)之后,角元素会更好。别忘了留意它。

  10. 网络组装

Web assembly(缩写为 WASM)旨在帮助编译高级语言,如 C/C++/ Rust 以及 JavaScript,这意味着使用 Web Assembly JavaScript APIs,您可以将 WASM 模块加载到 JavaScript 应用中,并在两者之间共享功能。这是一项惊人的技术,目前已经被应用到所有主流浏览器中。

开发人员文档可从 Mozilla MDN web docs 网站的以下位置获得:

[`https://developer.mozilla.org/en-US/docs/WebAssembly`](https://developer.mozilla.org/en-US/docs/WebAssembly)

遗言

网络发展迅速。尤其是 PWA,发展迅速。我们几乎每天都听到新技术。即使在我写这本书的时候,也有很多关于 PWA 和 Angular 的新消息,我可能应该修改一下我写的内容。我个人很喜欢。作为一名 web 开发人员,我喜欢看到让我兴奋和激动的新 API。我想指出的是,尽管有时需要很快的速度才能赶上,但整本书教给你的渐进式 Web 应用的概念和原则,不管有没有 Angular,都将保持不变。Angular PWA 必须能够快速加载,可靠工作,并像过去和现在的原生应用一样引人入胜。它必须在所有浏览器和平台上运行,并且必须逐步开发和部署。

感谢您的阅读!我们一起经历了一次长途旅行。我希望你喜欢用 Angular(或者没有 Angular!)尽管我很喜欢写这本书。

一切顺利。

posted @   绝不原创的飞龙  阅读(65)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
点击右上角即可分享
微信分享提示