Bulletin 团队在开发 PWA 时学到的关于 Service Worker 的知识。
这是关于 Google Bulletin 团队在构建面向外部的 PWA 时学到的经验教训的系列博文的第一篇。在这些文章中,我们将分享我们面临的一些挑战、我们为克服这些挑战而采取的方法,以及避免陷阱的一般建议。这绝不是 PWA 的完整概述。目的是分享我们团队经验中的学习成果。
在第一篇博文中,我们将首先介绍一些背景信息,然后深入探讨我们学到的关于 Service Worker 的所有内容。
背景
Bulletin 从 2017 年年中到 2019 年年中一直处于积极开发阶段。
我们为什么选择构建 PWA
在深入探讨开发过程之前,让我们先研究一下为什么构建 PWA 对这个项目来说是一个有吸引力的选择
- 快速迭代的能力。由于 Bulletin 将在多个市场进行试点,因此这一点尤其有价值。
- 单一代码库。我们的用户大致平均分布在 Android 和 iOS 之间。PWA 意味着我们可以构建一个可在两个平台上运行的单一 Web 应用程序。这提高了团队的速度和影响力。
- 快速更新且独立于用户行为。PWA 可以自动更新,从而减少了野外过时客户端的数量。我们能够推出突破性的后端更改,并且客户端的迁移时间非常短。
- 易于与第一方和第三方应用程序集成。 这种集成是应用程序的要求。对于 PWA,这通常意味着只需打开一个 URL。
- 消除了安装应用程序的摩擦。
我们的框架
对于 Bulletin,我们使用了 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 运行时会做出与预期不符的假设。例如,假设 window
或 document
可用,或者使用 Service Worker 不可用的 API(XMLHttpRequest
、本地存储等)。确保您的应用程序所需的任何关键库都与 Service Worker 兼容。对于这个特定的 PWA,我们想使用 gapi.js 进行身份验证,但由于它不支持 Service Worker 而无法使用。库作者还应尽可能减少或消除关于 JavaScript 上下文的不必要假设,以支持 Service Worker 用例,例如避免与 Service Worker 不兼容的 API 和 避免全局状态。
避免在初始化期间访问 IndexedDB
在初始化 Service Worker 脚本时,请勿读取 IndexedDB,否则您可能会陷入这种不希望出现的情况
- 用户拥有 IDB 版本为 N 的 Web 应用程序
- 推送了 IDB 版本为 N+1 的新 Web 应用程序
- 用户访问 PWA,这会触发下载新的 Service Worker
- 新的 Service Worker 在注册
install
事件处理程序之前从 IDB 读取,从而触发从 N 到 N+1 的 IDB 升级周期 - 由于用户拥有版本为 N 的旧客户端,因此 Service Worker 升级过程会挂起,因为活动连接仍旧打开到旧版本的数据库
- Service Worker 挂起,并且永远不会安装
在我们的例子中,缓存会在 Service Worker 安装时失效,因此如果 Service Worker 从未安装,用户将永远不会收到更新后的应用程序。
使其具有弹性
虽然 Service Worker 脚本在后台运行,但它们也可能随时终止,即使在 I/O 操作(网络、IDB 等)过程中也是如此。任何长时间运行的进程都应在任何时候都可恢复。
在将大文件上传到服务器并保存到 IDB 的同步过程中,我们针对中断的部分上传的解决方案是利用我们内部上传库的可恢复系统,在上传之前将可恢复上传 URL 保存到 IDB,并在首次上传未完成时使用该 URL 恢复上传。同样,在任何长时间运行的 I/O 操作之前,状态都会保存到 IDB,以指示我们每个记录的进程位置。
不要依赖全局状态
由于 Service Worker 存在于不同的上下文中,因此许多您可能期望存在的符号都不存在。我们的许多代码都在 window
上下文以及 Service Worker 上下文(例如日志记录、标志、同步等)中运行。代码需要防御性地对待它使用的服务,例如本地存储或 Cookie。您可以使用 globalThis
以一种跨所有上下文工作的方式引用全局对象。此外,请谨慎使用存储在全局变量中的数据,因为无法保证脚本何时终止以及状态何时被清除。
本地开发
Service Worker 的一个主要组成部分是在本地缓存资源。但是,在开发期间,这与您想要的完全相反,尤其是在延迟完成更新时。您仍然希望安装 Server Worker,以便您可以调试其问题或使用其他 API,如后台同步或通知。在 Chrome 上,您可以通过 Chrome DevTools 实现这一点,方法是启用绕过网络复选框(应用程序面板 > Service Worker 窗格),并启用禁用缓存复选框(在网络面板中)以同时禁用内存缓存。为了覆盖更多浏览器,我们选择了一种不同的解决方案,即在我们的 Service Worker 中包含一个禁用缓存的标志,该标志在开发人员构建中默认启用。这确保了开发人员始终获得最新的更改,而不会出现任何缓存问题。重要的是还要包含 Cache-Control: no-cache
标头,以防止浏览器缓存任何资源。
Lighthouse
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 的信息,请访问我们的作者个人资料以了解如何联系我们