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

Bulletin 团队在开发 PWA 时学到的有关 Service Worker 的知识。

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

这是一系列博文的第一篇,介绍了 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 库会做出一些假设,这些假设在由服务工件运行时无法按预期运行。例如,假设 windowdocument 可用,或者使用服务工作器不可用的 API(XMLHttpRequest、本地存储空间等)。确保应用所需的所有关键库都与服务工件兼容。对于这个特定的 PWA,我们希望使用 gapi.js 进行身份验证,但由于它不支持服务工件,因此无法使用。库作者还应尽可能减少或移除关于 JavaScript 上下文的无必要假设,以支持服务工作器用例,例如避免使用与服务工作器不兼容的 API 和避免全局状态

避免在初始化期间访问 IndexedDB

请勿在初始化服务工件脚本时读取 IndexedDB,否则可能会遇到以下不希望的情况:

  1. 用户使用的是 IndexedDB (IDB) 版本为 N 的 Web 应用
  2. 使用 IDB 版本 N+1 推送新的 Web 应用
  3. 用户访问 PWA,触发下载新的 Service Worker
  4. 新服务工作器会先从 IDB 读取,然后再注册 install 事件处理脚本,从而触发 IDB 升级周期,从 N 升级到 N+1
  5. 由于用户使用的是版本为 N 的旧版客户端,因此由于仍有活跃连接与旧版数据库保持打开状态,因此服务工件升级流程会挂起
  6. 服务工作器挂起,且从未安装

在我们的示例中,缓存在服务工作器安装时失效,因此,如果服务工作器从未安装,用户将永远不会收到更新后的应用。

提高弹性

虽然服务工件脚本在后台运行,但也可以随时终止,即使在执行 I/O 操作(网络、IDB 等)期间也是如此。任何长时间运行的进程都应能够随时恢复。

对于将大型文件上传到服务器并保存到 IDB 的同步流程,我们针对中断的部分上传问题的解决方案是利用内部上传库的可续传系统,在上传之前将可续传上传网址保存到 IDB,并在首次上传未完成时使用该网址来继续上传。此外,在任何长时间运行的 I/O 操作之前,系统都会将状态保存到 IDB,以指明每个记录在流程中的位置。

请勿依赖全局状态

由于服务工件存在于不同的上下文中,因此您可能预期会看到的许多符号都不存在。我们的许多代码同时在 window 上下文和服务工件上下文中运行(例如日志记录、标志、同步等)。代码需要对其使用的服务(例如本地存储空间或 Cookie)采取防御措施。您可以使用 globalThis 以适用于所有上下文的方式引用全局对象。此外,请谨慎使用存储在全局变量中的数据,因为无法保证脚本何时终止以及状态何时被驱逐。

本地开发

服务工作线程的一个主要组件是在本地缓存资源。不过,在开发过程中,这与您想要的完全相反,尤其是在延迟进行更新时。您仍希望安装服务器工作器,以便调试与其相关的问题,或使用其他 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 值

有时,您需要在服务工件上下文中访问 Cookie 值。在我们的示例中,我们需要访问 Cookie 值以生成令牌,以对第一方 API 请求进行身份验证。在服务工作线程中,无法使用 document.cookies 等同步 API。您可以随时从服务工作器向处于活动(窗口化)状态的客户端发送消息,以请求 Cookie 值,但服务工作器可能会在后台运行,而没有任何窗口化客户端可用,例如在后台同步期间。为解决此问题,我们在前端服务器上创建了一个端点,该端点只会将 Cookie 值回传给客户端。服务工件向此端点发出网络请求,并读取响应以获取 Cookie 值。

随着 Cookie Store API 的发布,对于支持该 API 的浏览器,此权宜解决方法应该不再需要,因为它提供了对浏览器 Cookie 的异步访问,并且可供服务工件直接使用。

非生成的 Service Worker 的陷阱

确保在任何静态缓存文件发生更改时,服务工作器脚本也会发生更改

常见的 PWA 模式是,Service Worker 在其 install 阶段安装所有静态应用文件,以便客户端在所有后续访问中直接命中 Cache Storage API 缓存。只有当浏览器检测到服务工作器脚本以某种方式发生更改时,才会安装服务工作器,因此我们必须确保在缓存的文件发生更改时,服务工作器脚本文件本身也发生了某种更改。我们通过在服务工作器脚本中嵌入静态资源文件集中的哈希来手动实现这一点,因此每个版本都会生成一个不同的服务工作器 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,请访问我们的作者个人资料,了解如何与我们联系: