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

105 阅读42分钟

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

原文:Progressive Web Apps with Angular

协议:CC BY-NC-SA 4.0

七、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 的参与部分,看看如何向用户发送推送通知。

Footnotes 1

https://caniuse.com/#search=font-display

  2

https://en.wikipedia.org/wiki/Flash_of_unstyled_content

  3

https://github.com/typekit/webfontloader

  4

https://en.wikipedia.org/wiki/HTTP/2_Server_Push

 

八、推送通知

有不同的方法让你的用户参与进来并保持更新,比如通过电子邮件、应用内通知和推送通知!长期以来,本地移动应用一直通过推送通知来吸引用户。直到 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 又近了一步。

在下一章,我将把你的注意力转移回持久数据。虽然我们已经在运行时缓存了动态数据,但是您可以在应用中使用不同的解决方案和架构来帮助在用户浏览器中保存数据,并在必要时将其同步回服务器。这为我们的用户提供了一个强大的能力,可以完全脱机使用我们的应用,并帮助我们构建一个更快、更可靠、更高性能的应用。

Footnotes 1

https://https://www.w3.org/TR/notifications/

  2

如果你想了解更多,在这里查看urlB64ToUint8Array()功能: https://github.com/GoogleChromeLabs/web-push-codelab/blob/master/app/scripts/main.js

  3

标准序列化程序–返回订阅属性的 JSON 表示。

  4

https://developers.google.com/web/updates/2017/06/budget-api

 

九、弹性 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),,应用已被提升到下一个级别。

Footnotes 1

https://developer.mozilla.org/en/docs/Web/API/IndexedDB_API

  2

https://pouchdb.com

  3

帽衫是另一个例子;你可以在 https://hood.ie .上找到更多信息

  4

http://couchdb.apache.org

  5

可以将数据同步回任何使用 CouchDB 复制协议的服务。For examples, CouchDB, IBM Cloudant, Couchbase。

  6

这是本书写作时pouchdb 7 和 Angular 6 和 7 的已知问题。