在 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 确实会增加一些复杂性,但我们发现其优势大于增加的复杂性。

如果可以,请生成该报告

避免手动编写服务工件脚本。手动编写服务工件需要手动管理缓存的资源,并重写大多数服务工件库(例如 Workbox)中常见的逻辑。

尽管如此,由于我们的内部技术栈,我们无法使用库生成和管理服务工件。以下学习成果有时会反映这一点。如需了解详情,请参阅非生成的服务工件存在的陷阱

并非所有库都与服务工件兼容

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

避免在初始化期间访问 IndexedDB

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

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

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

提高弹性

虽然服务工件脚本在后台运行,但也可以随时终止,即使在执行 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 中,就不会出现这种情况。

拥抱持续交付

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

我们能够推送破坏性的后端更改,客户端的迁移时间非常短。通常,我们会先给用户一个月的时间来更新到较新版本的客户端,然后再进行重大更改。由于应用会在过时时提供服务,因此如果用户很长时间没有打开应用,旧版客户端实际上可能会在野外存在。在 iOS 上,服务工件会在几周后被驱逐,因此不会出现这种情况。对于 Android,您可以通过以下方式缓解此问题:在内容过时时停止投放,或在几周后手动使内容过期。在实践中,我们从未遇到过因客户端过时而导致的问题。具体团队希望采用多严格的限制取决于其特定用例,但 PWA 比 iOS/Android 应用提供更大的灵活性。

在服务工件中获取 Cookie 值

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

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

非生成的 Service Worker 的陷阱

确保在任何静态缓存文件发生更改时,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,请访问我们的作者个人资料,了解如何与我们联系: