在 Google 上构建 PWA(第 1 部分)

公告团队在开发 PWA 时了解到的有关 Service Worker 的信息。

Douglas Parker
Douglas Parker
Joel Riley
Joel Riley
Dikla Cohen
Dikla Cohen

这是 Google 公告团队在构建面向外部的 PWA 时所学到的经验的系列博文中的第一篇。在这些博文中,我们将分享我们面临的一些挑战、我们克服这些挑战所采取的方法,以及有关如何避免陷阱的一般性建议。这并非对 PWA 的完整概述。目的是分享我们团队的经验教训。

在这第一篇博文中,我们先介绍一些背景信息,然后深入了解我们学到的有关 Service Worker 的所有知识。

背景

从 2017 年年中到 2019 年年中,本公告一直在积极开发。

我们选择开发 PWA 的原因

在深入了解开发过程之前,我们先了解一下为什么构建 PWA 对这个项目具有吸引力:

  • 能够快速迭代。由于公告栏将在多个市场试运行,因此它尤其有用。
  • 单一代码库。我们的用户在 Android 和 iOS 设备上的用户数大致均衡。使用 PWA 意味着我们可以构建一个可同时在这两个平台上运行的 Web 应用。这提高了该团队的速度和影响力
  • 更新速度快,并且不依赖于用户行为。PWA 可以自动更新,从而减少实际使用的过时客户端的数量。我们得以在极短的时间内向客户端推送重大的后端更改。
  • 与第一方和第三方应用轻松集成。此类集成是应用的一项要求。对于 PWA,通常只需打开一个网址即可。
  • 消除了安装应用的麻烦。

我们的框架

对于公告,我们使用的是 Polymer,但任何得到良好支持的现代框架都可以使用。

关于 Service Worker 的知识

没有 Service Worker,您就无法拥有 PWA。Service Worker 为您提供了大量功能,例如高级缓存策略、离线功能、后台同步等。虽然 Service Worker 确实增加了一些复杂性,但我们发现其优势超过了增加的复杂性。

可以的话,生成验证码

避免手动编写 Service Worker 脚本。手动编写 Service Worker 需要手动管理缓存的资源并重写大多数 Service Worker 库(例如 Workbox)通用的逻辑。

话虽如此,但由于我们的内部技术栈,我们无法使用库来生成和管理 Service Worker。我们在下文中了解到的信息会不时地反映这一点。如需了解详情,请转到非生成 Service Worker 的问题

并非所有库都与 Service Worker 兼容

某些 JS 库的假设在由 Service Worker 运行时无法按预期工作。例如,假设 windowdocument 可用,或者使用 Service Worker 不可用的 API(XMLHttpRequest、本地存储空间等)。确保应用所需的所有关键库与 Service Worker 兼容。对于这种特定的 PWA,我们希望使用 gapi.js 进行身份验证,但无法这样做,因为它不支持 Service Worker。库作者还应尽可能减少或移除有关 JavaScript 上下文的不必要假设,以支持 Service Worker 用例,例如避免与 Service Worker 不兼容的 API 和避免全局状态

避免在初始化期间访问 IndexedDB

在初始化 Service Worker 脚本时不要读取 IndexedDB,否则可能会发生以下意外情况:

  1. 用户拥有采用 IndexedDB (IDB) 版本 N 的 Web 应用
  2. 使用 IDB 版本 N+1 推送新的 Web 应用
  3. 用户访问 PWA,这会触发下载新的 Service Worker
  4. 新的 Service Worker 在注册 install 事件处理程序之前从 IDB 读取数据,从而触发 IDB 升级周期从 N 升级到 N+1
  5. 由于用户的旧客户端版本为 N,Service Worker 升级过程挂起,因为活跃连接仍对旧版本数据库开放
  6. Service Worker 挂起,并且永远不会安装

在本例中,缓存在 Service Worker 安装时失效,因此,如果 Service Worker 从未安装,用户永远不会收到更新的应用。

提升弹性

虽然 Service Worker 脚本在后台运行,但它们也可以随时终止,即使在 I/O 操作进行期间(网络、IDB 等)也是如此。任何长时间运行的进程应该可以随时恢复。

对于将大型文件上传到服务器并保存到 IDB 的同步过程,我们针对部分上传中断的解决方案是利用我们的内部上传库的可续传系统,在上传之前将可续传上传网址保存到 IDB,并在第一次未完成的情况下使用该网址恢复上传。此外,在执行任何长时间运行的 I/O 操作之前,状态已保存到 IDB,以指明每条记录在进程中所处的位置。

不依赖于全局状态

由于 Service Worker 存在于不同的上下文中,因此不会出现您可能预期存在的许多符号。我们的许多代码既在 window 上下文又在 Service Worker 上下文(如日志记录、标志、同步等)中运行。代码需要对所使用的服务(例如本地存储或 Cookie)具有防御性。您可以使用 globalThis 以适用于所有上下文的方式引用全局对象。此外,也请谨慎使用存储在全局变量中的数据,因为无法保证何时终止脚本和逐出状态。

本地开发

Service Worker 的一个主要组成部分是在本地缓存资源。不过,在开发期间,这与您期望的结果完全相反,尤其是在延迟完成更新时。您仍然需要安装服务器工作器,以便调试其问题或使用其他 API(如后台同步或通知)。在 Chrome 上,您可以通过 Chrome 开发者工具实现此目的,只需选中 Bypass for network 复选框(Application 面板 > Service Worker 窗格),即可在 Network 面板中选中 Disable cache 复选框以停用内存缓存。为了覆盖更多浏览器,我们选择另一种解决方案,即在我们的 Service Worker 中添加一个用于停用缓存的标志,该标志在开发者 build 中默认处于启用状态。这可确保开发者始终能获取最新的更改,而不会出现任何缓存问题。请务必同时添加 Cache-Control: no-cache 标头,以防止浏览器缓存任何资源

灯塔

Lighthouse 提供了多种适用于 PWA 的调试工具。它会扫描网站并生成涵盖 PWA、性能、无障碍功能、搜索引擎优化 (SEO) 和其他最佳实践的报告。我们建议您在持续集成环境中运行 Lighthouse,以便在您违反某个 PWA 标准时收到提醒。事实上,我们遇到过一次这种情况,Service Worker 没有安装,我们在生产推送之前没有意识到这一点。如果将 Lighthouse 纳入我们的 CI,就可以避免这种情况。

拥抱持续交付

由于 Service Worker 可以自动更新,因此用户无法限制升级。这大大减少了实际使用的过时客户端的数量。当用户打开我们的应用时,Service Worker 将服务于旧客户端,同时延迟下载新客户端。新客户端下载后,会提示用户刷新页面以使用新功能。即使用户忽略了此请求,下次刷新页面时,他们也会收到新版客户端。因此,用户很难像拒绝 iOS/Android 应用那样拒绝更新。

我们得以在极短的时间内为客户端推送重大的后端更改。通常,在做出重大更改之前,我们会为用户提供一个月来更新到较新的客户端。由于应用将在过时期间运行,因此如果用户很长时间没有打开应用,较旧的客户端实际上也可能存在于应用中。在 iOS 上,Service Worker 会在几周后被逐出,因此不会发生这种情况。对于 Android,此问题可以通过在过时不传送或手动使内容几周后过期来缓解。在实践中,我们从来没有遇到过过时客户端的问题。特定团队对团队要求的严格程度取决于他们的具体用例,但 PWA 提供的灵活性远高于 iOS/Android 应用。

在 Service Worker 中获取 Cookie 值

有时,有必要在 Service Worker 上下文中访问 Cookie 值。在本例中,我们需要访问 Cookie 值来生成令牌,以便对第一方 API 请求进行身份验证。在 Service Worker 中,document.cookies 等同步 API 不可用。您始终可以从 Service Worker 向活跃(窗口化的)客户端发送消息以请求 Cookie 值,但 Service Worker 可以在后台运行而没有任何窗口化的客户端(例如,在后台同步期间)。为解决此问题,我们在前端服务器上创建了一个端点,该端点直接将 Cookie 值回显给客户端。Service Worker 向此端点发出网络请求并读取响应,以获取 Cookie 值。

随着 Cookie Store API 的发布,支持它的浏览器应该不再需要这种解决方法,因为它提供了对浏览器 Cookie 的异步访问,并且可以直接由 Service Worker 使用。

非生成的 Service Worker 的误区

确保在任何静态缓存文件发生更改时,Service Worker 脚本发生更改

一种常见的 PWA 模式是 Service Worker 在 install 阶段安装所有静态应用文件,这使得客户端能够在所有后续访问中直接命中 Cache Storage API 缓存。仅在浏览器检测到 Service Worker 脚本以某种方式发生更改时才会安装 Service Worker,因此我们必须确保 Service Worker 脚本文件本身在缓存文件发生更改时发生了某种更改。我们通过在 Service Worker 脚本中嵌入静态资源文件集的哈希值来手动执行此操作,因此每个版本都会生成不同的 Service Worker JavaScript 文件。Workbox 等 Service Worker 库可为您自动执行此过程。

单元测试

通过向全局对象添加事件监听器来运行 Service Worker API。例如:

self.addEventListener('fetch', (evt) => evt.respondWith(fetch('/foo')));

这可能很难进行测试,因为您需要模拟事件触发器和事件对象,等待 respondWith() 回调,然后等待 promise,最后再对结果做出断言。一种更简单的构建方法就是将所有实现委托给另一个文件,这样更易于测试。

import fetchHandler from './fetch_handler.js';
self.addEventListener('fetch', (evt) => evt.respondWith(fetchHandler(evt)));

由于对 Service Worker 脚本进行单元测试比较困难,因此我们尽可能保留核心 Service Worker 脚本,将大部分实现拆分到其他模块中。由于这些文件只是标准 JS 模块,因此可以更轻松地使用标准测试库对它们进行单元测试。

敬请期待第 2 部分和第 3 部分

在此系列的第 2 和第 3 部分中,我们将讨论媒体管理和 iOS 特有的问题。如果您想向我们咨询有关在 Google 上构建 PWA 的更多信息,请访问我们的作者个人资料,了解如何与我们联系: