Bulletin 团队在开发 PWA 时学到的有关 Service Worker 的知识。
这是一系列博文的第一篇,介绍了 Google 动态板块团队在构建面向外部用户的 PWA 时学到的教训。在这些博文中,我们将分享我们面临的一些挑战、我们克服这些挑战所采取的方法,以及有关如何避免陷阱的一般建议。这绝不是对 PWA 的完整概述。目的是分享我们团队从经验中总结出的经验教训。
在本系列的第一篇文章中,我们将先介绍一些背景信息,然后深入探讨我们学到的有关服务工作器的所有内容。
背景
2017 年年中至 2019 年年中,我们一直在积极开发公告。
我们选择开发 PWA 的原因
在深入探讨开发流程之前,我们先来探讨为何构建 PWA 是此项目的理想之选:
- 能够快速迭代。这对我们来说非常有价值,因为我们将在多个市场试行动态。
- 单个代码库。Android 和 iOS 用户分布情况大致相同。使用 PWA 意味着我们可以构建一个可同时在这两个平台上运行的 Web 应用。这提高了团队的速度和影响力。
- 更新速度快,并且不依赖于用户行为。PWA 可以自动更新,从而减少实际使用的过时客户端的数量。我们能够推送破坏性的后端更改,并让客户在很短的时间内完成迁移。
- 可轻松与第一方和第三方应用集成。此类集成是应用的一项要求。对于 PWA,这通常意味着只需打开网址即可。
- 消除了安装应用的阻碍。
我们的框架
对于公告栏,我们使用了 Polymer,但任何受良好支持的现代框架都可以使用。
我们学习了什么关于 Service Worker 的内容
没有服务工作器,您就无法构建 PWA。Service Worker 可为您提供强大的功能,例如高级缓存策略、离线功能、后台同步等。虽然 Service Worker 确实会增加一些复杂性,但我们发现其优势大于增加的复杂性。
如果可以,请生成该报告
避免手动编写 Service Worker 脚本。手动编写 Service Worker 需要手动管理缓存的资源,并重写大多数 Service Worker 库(例如 Workbox)中常见的逻辑。
尽管如此,由于我们的内部技术栈,我们无法使用库生成和管理我们的服务工件。以下学习成果有时会反映这一点。如需了解详情,请参阅非生成的服务工件存在的陷阱。
并非所有库都与服务工件兼容
某些 JS 库的假设在由 Service Worker 运行时无法按预期工作。例如,假设 window
或 document
可用,或者使用服务工作器不可用的 API(XMLHttpRequest
、本地存储空间等)。确保应用所需的所有关键库都与服务工件兼容。对于这种特定的 PWA,我们希望使用 gapi.js 进行身份验证,但无法这样做,因为它不支持 Service Worker。库作者还应尽可能减少或移除有关 JavaScript 上下文的不必要假设,以支持 Service Worker 用例,例如避免与 Service Worker 不兼容的 API 和避免全局状态。
避免在初始化期间访问 IndexedDB
请勿在初始化服务工件脚本时读取 IndexedDB,否则可能会遇到以下不希望的情况:
- 用户使用的是 IndexedDB (IDB) 版本为 N 的 Web 应用
- 使用 IDB 版本 N+1 推送新的 Web 应用
- 用户访问 PWA,触发下载新的 Service Worker
- 新服务工作器会先从 IDB 读取,然后再注册
install
事件处理脚本,从而触发 IDB 升级周期,从 N 升级到 N+1 - 由于用户使用的是版本为 N 的旧版客户端,因此由于仍有活跃连接与旧版数据库保持打开状态,因此服务工件升级流程会挂起
- Service Worker 挂起,并且永远不会安装
在本例中,缓存在 Service Worker 安装时失效,因此,如果 Service Worker 从未安装,用户永远不会收到更新的应用。
提高弹性
虽然服务工件脚本在后台运行,但也可以随时终止,即使在执行 I/O 操作(网络、IDB 等)期间也是如此。任何长时间运行的进程应该可以随时恢复。
对于将大型文件上传到服务器并保存到 IDB 的同步过程,我们针对部分上传中断的解决方案是利用我们的内部上传库的可续传系统,在上传之前将可续传上传网址保存到 IDB,并在第一次未完成的情况下使用该网址恢复上传。此外,在执行任何长时间运行的 I/O 操作之前,状态已保存到 IDB,以指明每条记录在进程中所处的位置。
不依赖于全局状态
由于服务工件存在于不同的上下文中,因此您可能预期会看到的许多符号都不存在。我们的许多代码同时在 window
上下文和服务工件上下文(例如日志记录、标志、同步等)中运行。代码需要对所使用的服务(例如本地存储或 Cookie)具有防御性。您可以使用 globalThis
以适用于所有上下文的方式引用全局对象。此外,也应谨慎使用存储在全局变量中的数据,因为无法保证何时终止脚本和逐出状态。
本地开发
Service Worker 的一个主要组成部分是在本地缓存资源。不过,在开发期间,这与您想要的结果完全相反,尤其是在延迟完成更新时。您仍然需要安装服务器工作器,以便调试其问题或使用其他 API(如后台同步或通知)。在 Chrome 中,您可以通过 Chrome 开发者工具实现此目的,具体方法是启用 Bypass for network 复选框(Application 面板 > Service workers 窗格),并在 Network 面板中启用 Disable cache 复选框以停用内存缓存。为了覆盖更多浏览器,我们选择了一种不同的解决方案,即在服务工作器中添加一个用于停用缓存的标志(在开发者 build 中默认处于启用状态)。这样可以确保开发者始终获取最新的更改,而不会出现任何缓存问题。请务必添加 Cache-Control: no-cache
标头,以防止浏览器缓存任何资源。
灯塔
Lighthouse 提供了一些适用于 PWA 的实用调试工具。它会扫描网站并生成涵盖 PWA、性能、无障碍功能、搜索引擎优化 (SEO) 和其他最佳实践的报告。我们建议您在持续集成时运行 Lighthouse,以便在您违反 PWA 的某个条件时收到提醒。我们实际上就遇到过一次这种情况,服务工件无法安装,但我们在发布到生产环境之前并未意识到这一点。如果将 Lighthouse 纳入我们的 CI 中,就不会出现这种情况。
拥抱持续交付
由于服务工件可以自动更新,因此用户无法限制升级。这大大减少了实际使用的过时客户端的数量。当用户打开我们的应用时,Service Worker 会在延迟下载新客户端的同时提供旧客户端。下载新客户端后,系统会提示用户刷新页面以使用新功能。即使用户忽略了此请求,在下次刷新页面时,他们也会收到新版本的客户端。因此,用户很难像拒绝 iOS/Android 应用更新一样拒绝 Windows 应用更新。
我们能够推送破坏后端变更,客户端只需很短的迁移时间。通常,我们会先给用户一个月的时间来更新到较新版本的客户端,然后再进行重大更改。由于应用将在过时期间运行,因此如果用户很长时间没有打开应用,较旧的客户端实际上也可能存在于应用中。在 iOS 上,服务工作器会在几周后被驱逐,因此不会出现这种情况。对于 Android,您可以通过以下方式缓解此问题:在内容过时时停止投放,或在几周后手动使内容过期。在实践中,我们从未遇到过因客户端过时而导致的问题。给定团队希望在此处采用的严格程度取决于其具体用例,但 PWA 比 iOS/Android 应用提供更大的灵活性。
在 Service Worker 中获取 Cookie 值
有时,有必要在 Service Worker 上下文中访问 Cookie 值。在我们的示例中,我们需要访问 Cookie 值以生成令牌,以对第一方 API 请求进行身份验证。在服务工作线程中,无法使用 document.cookies
等同步 API。您可以随时从服务工作器向处于活动(窗口化)状态的客户端发送消息,以请求 Cookie 值,但服务工作器可能会在后台运行,而没有任何窗口化客户端可用,例如在后台同步期间。为解决此问题,我们在前端服务器上创建了一个端点,该端点只会将 Cookie 值回传给客户端。服务工件向此端点发出网络请求,并读取响应以获取 Cookie 值。
随着 Cookie Store API 的发布,对于支持该 API 的浏览器,应该不再需要此权宜解决方法,因为它提供了对浏览器 Cookie 的异步访问,并且可供服务工件直接使用。
非生成的 Service Worker 的误区
确保在任何静态缓存文件发生更改时,服务工作器脚本也会发生更改
一种常见的 PWA 模式是 Service Worker 在 install
阶段安装所有静态应用文件,这使得客户端能够在所有后续访问中直接命中 Cache Storage API 缓存。仅在浏览器检测到 Service Worker 脚本以某种方式发生更改时才会安装 Service Worker,因此我们必须确保 Service Worker 脚本文件本身在缓存文件发生更改时发生了某种更改。我们通过在服务工作器脚本中嵌入静态资源文件集中的哈希来手动实现这一点,因此每个版本都会生成一个不同的服务工作器 JavaScript 文件。Workbox 等服务工作线程库可为您自动完成此过程。
单元测试
服务工作器 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)));
由于服务工作器脚本的单元测试很难,因此我们尽可能将核心服务工作器脚本保持为基本框架,并将大部分实现拆分到其他模块中。由于这些文件只是标准的 JS 模块,因此可以更轻松地使用标准测试库对其进行单元测试。
敬请期待第 2 部分和第 3 部分
在此系列的第 2 和第 3 部分中,我们将讨论媒体管理和 iOS 特有的问题。如果您想向我们咨询有关在 Google 上构建 PWA 的更多信息,请访问我们的作者个人资料,了解如何与我们联系: