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

公告团队在开发 PWA 时了解有关 Service Worker 的心得。

道格拉斯·帕克
Douglas Parker
乔尔·莱利
Joel Riley
迪克拉·科恩
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 的 Pitfalls

并非所有库都与 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 上,您可以通过选中 Bypass for network 复选框(Application 面板 > Service Worker 窗格),通过 Chrome 开发者工具实现这一点,并在网络面板中启用停用缓存复选框,以便也停用内存缓存。为了覆盖更多浏览器,我们选择了不同的解决方案,只需在 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 中,无法使用同步 API(例如 document.cookies)。您始终可以从 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,请访问我们的作者个人资料,了解如何与我们联系: