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

107 阅读22分钟

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

原文:Progressive Web Apps with Angular

协议:CC BY-NC-SA 4.0

十三、使用 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。

在下一章,我们将探索高级功能,如后台同步,这有助于重试由于没有连接而失败的请求;推送参与通知;通知用户刷新应用以接收最新更新的更新流通知;和离线分析。

Footnotes 1

https://developers.google.com/web/updates/2017/02/navigation-preload

  2

https://developers.google.com/web/tools/workbox/modules/workbox-cli

 

十四、高级 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 的下一步是什么。

Footnotes 1

截至撰写本书时,广播频道 API 仅在这些浏览器中受支持,但以后可能会有所变化。然而,在 Workbox 4 中,如果不支持这个 API,将会有一个到另一个方法的回退。目前,我们使用的是 Workbox 3.6.3。

 

十五、后续步骤

恭喜你!您已经完成了 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!)尽管我很喜欢写这本书。

一切顺利。

Footnotes 1

https://github.com/angular/angular/blob/master/packages/core/src/render3/STATUS.md