将 Service Worker 引入 Google 搜索

有关发布的内容、如何衡量影响以及做出的权衡取舍的故事。

发布时间:2019 年 6 月 20 日

在 Google 上搜索几乎任何主题,您都会看到一个包含有意义的相关结果的页面,该页面可立即识别。您可能没有意识到的是,在某些情况下,此搜索结果网页是由一种称为 service worker 的强大网络技术提供的。

在不影响性能的前提下为 Google 搜索推出 Service Worker 支持,需要数十位工程师跨多个团队协作。本文将介绍已发布的内容、如何衡量性能以及做出了哪些权衡。

探索 Service Worker 的主要原因

向 Web 应用添加 service worker 与对网站进行任何架构更改一样,都应明确目标。对于 Google 搜索团队而言,添加 service worker 值得探索,原因有以下几个方面。

有限的搜索结果缓存

Google 搜索团队发现,用户经常会在短时间内多次搜索相同的字词。搜索团队希望利用缓存在本地满足这些重复请求,而不是触发新的后端请求来获取可能相同的结果。

新鲜度非常重要,有时用户会反复搜索相同的字词,因为相关主题在不断发展变化,他们希望看到最新的搜索结果。借助服务工作线程,Google 搜索团队可以实现精细的逻辑来控制本地缓存的搜索结果的生命周期,并实现他们认为最能满足用户需求的精确的速度与新鲜度平衡。

有意义的线下体验

此外,Google 搜索团队还希望提供有意义的离线体验。当用户想要了解某个主题时,他们希望直接前往 Google 搜索页面并开始搜索,而无需担心网络连接是否正常。

如果没有 service worker,在离线状态下访问 Google 搜索页面只会导致浏览器显示标准网络错误页面,用户必须记住在连接恢复后返回并重试。借助服务工作线程,可以提供自定义的离线 HTML 响应,并允许用户立即输入搜索查询。

后台重试界面的屏幕截图。

在连接到互联网之前,您将无法看到搜索结果,但借助服务工作线程,您可以延迟搜索,并在设备重新联网后立即使用后台同步 API 将搜索内容发送到 Google 的服务器。

更智能的 JavaScript 缓存和传送

另一个动机是优化模块化 JavaScript 代码的缓存和加载,这些代码为搜索结果页面上的各种类型的功能提供支持。在不涉及 Service Worker 的情况下,JavaScript 打包可带来诸多好处,因此搜索团队并不想完全停止打包。

搜索团队利用服务工作线程在运行时对细粒度的 JavaScript 块进行版本控制和缓存,怀疑他们可以减少缓存抖动量,并确保将来重用的 JavaScript 可以高效缓存。其服务工作线程中的逻辑可以分析包含多个 JavaScript 模块的软件包的传出 HTTP 请求,并通过将多个本地缓存的模块拼接在一起,在可能的情况下有效地“解绑”该请求。这样可以节省用户带宽,并提高整体响应速度。

使用由 Service Worker 提供的缓存 JavaScript 还有性能方面的优势:在 Chrome 中,系统会存储并重复使用该 JavaScript 的已解析字节码表示形式,从而减少在运行时执行网页上的 JavaScript 所需的工作量。

挑战和解决方案

以下是为实现团队既定目标而需要克服的一些障碍。虽然其中一些挑战是 Google 搜索特有的,但许多挑战适用于可能考虑部署 Service Worker 的各种网站。

问题:服务工作线程开销

在 Google 搜索上启动 Service Worker 的最大挑战也是唯一真正的阻碍,是确保它不会执行任何可能会增加用户感知延迟的操作。Google 搜索非常重视性能,过去,即使新功能仅为特定用户群体增加了数十毫秒的延迟时间,Google 搜索也会阻止该功能的发布。

当团队在最早的实验中开始收集效果数据时,他们很快就发现存在问题。为搜索结果页的导航请求返回的 HTML 是动态的,并且会因需要在 Google 搜索的 Web 服务器上运行的逻辑而异。目前,服务工作线程无法复制此逻辑并立即返回缓存的 HTML;它最多只能将导航请求传递给后端 Web 服务器,这需要发出网络请求。

如果没有 service worker,此网络请求会在用户导航时立即发生。注册服务工作线程后,无论这些提取处理程序是否会执行除访问网络之外的任何操作,服务工作线程始终需要启动并有机会执行其 fetch 事件处理程序。启动并运行 service worker 代码所需的时间是每次导航时增加的纯开销:

一张示意图,显示了软件启动如何阻止导航请求。

这使得 service worker 实现的延迟过高,无法证明任何其他优势的合理性。此外,该团队还发现,根据对实际设备上的 Service Worker 启动时间进行的测量,启动时间分布范围很广,一些低端移动设备启动 Service Worker 所需的时间几乎与发出结果页面的 HTML 网络请求所需的时间一样长。

解决方案:使用导航预加载

让 Google 搜索团队能够顺利发布其 service worker 的最关键功能是导航预加载。对于需要使用来自网络的响应来满足导航请求的任何服务工作线程,使用导航预加载都是提升性能的关键。它会向浏览器提供提示,让浏览器在 Service Worker 启动的同时立即开始发出导航请求:

一张图,显示了与导航请求并行完成的软件启动。

只要服务工作线程的启动时间少于从网络获取响应的时间,服务工作线程就不会引入任何延迟开销。

搜索团队还需要避免在低端移动设备上使用服务工作线程,因为服务工作线程的启动时间可能会超过导航请求。由于没有明确的规则来界定“低端”设备,他们提出了检查设备上安装的总 RAM 的启发式方法。内存小于 2 GB 的设备属于低端设备,服务工作线程启动时间过长,无法接受。

另一个考虑因素是可用存储空间,因为要缓存以供日后使用的全套资源可能需要几兆字节。借助 navigator.storage 接口,Google 搜索页面可以提前确定其缓存数据尝试是否会因存储空间配额失败而面临失败的风险。

这样一来,搜索团队就可以使用多项条件来确定是否使用 Service Worker:如果用户使用支持导航预加载的浏览器访问 Google 搜索页面,并且拥有至少 2 GB 的 RAM 和足够的可用存储空间,则注册 Service Worker。不符合这些条件的浏览器或设备最终不会获得服务工作线程,但仍会获得与以往相同的 Google 搜索体验。

这种选择性注册的一个附带好处是能够提供更小、更高效的服务工作线程。以相当现代的浏览器为目标平台来运行 Service Worker 代码,可避免为旧版浏览器进行转译和填充的开销。最终,这从服务工作线程实现的总大小中减少了大约 8 KB 的未压缩 JavaScript 代码。

问题:service worker 范围

在搜索团队进行了足够的延迟时间实验,并确信使用导航预加载为他们提供了一条可行的、延迟时间中立的 Service Worker 使用路径后,一些实际问题开始凸显出来。其中一个问题与服务工作线程的作用域规则有关。 Service Worker 的作用域决定了它可以潜在控制哪些网页。

范围界定功能基于网址路径前缀。对于托管单个 Web 应用的网域,这并不是问题,因为您通常只会使用范围为 / 的 Service Worker,该 Service Worker 可以控制网域下的任何网页。但 Google 搜索的网址结构稍微复杂一些。

如果为服务工作线程分配了 / 的最大作用域,它最终将能够控制 www.google.com(或区域等效网域)下托管的任何网页,而该网域下的一些网址与 Google 搜索无关。更合理、更具限制性的范围是 /search,这样至少可以排除与搜索结果完全无关的网址。

遗憾的是,即使是该 /search 网址路径也会在不同类型的 Google 搜索结果之间共享,而网址查询参数则决定了显示哪种特定类型的搜索结果。其中一些风味版使用的代码库与传统的网页搜索结果页面完全不同。例如,图片搜索和购物搜索都通过 /search 网址路径提供,但具有不同的查询参数,不过这两个界面都尚未准备好提供自己的 service worker 体验。

解决方案:创建调度和路由框架

虽然一些提案允许使用比网址路径前缀更强大的功能来确定 service worker 作用域,但 Google 搜索团队在部署 service worker 时遇到了困难,该 service worker 对其控制的一部分网页没有任何作用。

为了解决这个问题,Google 搜索团队构建了一个定制的调度和路由框架,该框架可配置为检查客户端网页的查询参数等条件,并使用这些条件来确定要采用哪个特定的代码路径。该系统并非对规则进行硬编码,而是具有灵活性,可让共享网址空间的团队(例如图片搜索和购物搜索)在日后决定实现自己的服务工作线程逻辑时,能够轻松添加该逻辑。

问题:个性化结果和指标

用户可以使用自己的 Google 账号登录 Google 搜索,并且可以根据自己的特定账号数据定制搜索结果体验。 已登录的用户通过特定的浏览器 Cookie(一种历史悠久且广泛支持的标准)进行识别。

不过,使用浏览器 Cookie 的一个缺点是,它们不会在 Service Worker 中公开,并且无法自动检查其值,也无法确保它们不会因用户退出或切换账号而发生更改。(目前正在努力让 Service Worker 能够访问 Cookie,但截至撰写本文时,该方法仍处于实验阶段,尚未得到广泛支持。)

如果服务工作线程对当前登录用户的视图与实际登录到 Google 搜索 Web 界面的用户不一致,可能会导致搜索结果个性化不正确,或者指标和日志记录归因错误。 对于 Google 搜索团队来说,上述任何一种故障情形都是严重的问题。

解决方案:使用 postMessage 发送 Cookie

Google 搜索团队没有等待实验性 API 启动并提供对 Service Worker 内浏览器 Cookie 的直接访问权限,而是采用了一种临时解决方案:每当加载由 Service Worker 控制的网页时,该网页都会读取相关 Cookie 并使用 postMessage() 将它们发送到 Service Worker。

然后,服务工作线程会将当前 Cookie 值与预期值进行比较,如果两者不一致,则会采取相应步骤,从其存储空间中清除所有用户专属数据,并重新加载搜索结果页面,而不会出现任何错误的个性化设置。

服务工作线程将哪些内容重置为基准状态取决于 Google 搜索的要求,但对于处理基于浏览器 Cookie 的个性化数据的其他开发者来说,这种总体方法可能也很有用。

问题:实验和动态性

如前所述,Google 搜索团队非常依赖在生产环境中运行实验,并在默认启用新代码和功能之前,在实际环境中测试其效果。对于严重依赖缓存数据的静态服务工作线程,这可能有点困难,因为让用户选择加入和退出实验通常需要与后端服务器通信。

解决方案:动态生成的 Service Worker 脚本

该团队最终选择的解决方案是使用由 Web 服务器为每位用户定制的动态生成的 Service Worker 脚本,而不是预先生成的单个静态 Service Worker 脚本。可能会影响服务工作线程行为或一般网络请求的实验的相关信息直接包含在此自定义服务工作线程脚本中。更改用户的有效实验集是通过传统技术(例如浏览器 Cookie)以及在注册的服务工作线程网址中提供更新后的代码来实现的。

使用动态生成的 Service Worker 脚本还可以更轻松地提供应急方案,以应对 Service Worker 实现中出现需要避免的致命 bug 这种不太可能发生的情况。动态服务器 worker 响应可能是无操作实现,从而有效地为部分或所有当前用户停用 service worker。

问题:协调更新

任何实际的服务工作线程部署面临的最严峻挑战之一,就是在避免使用网络而倾向于使用缓存的同时,确保现有用户在关键更新和更改部署到生产环境后不久就能获得这些更新和更改,从而在两者之间找到合理的平衡点。适当的平衡取决于多种因素:

  • 您的 Web 应用是否是用户会无限期保持打开状态的长期存在的单页应用,而不会导航到新页面。
  • 后端 Web 服务器更新的部署节奏。
  • 普通用户是否可以接受使用略微过时的 Web 应用版本,还是新鲜度才是最重要的。

在对 Service Worker 进行实验时,Google 搜索团队确保实验在多次预定的后端更新中持续运行,以确保指标和用户体验能够更贴近回访用户在现实世界中最终看到的实际情况。

解决方案:平衡新鲜度和缓存利用率

在测试了多种不同的配置选项后,Google 搜索团队发现以下设置可在新鲜度和缓存利用率之间实现适当的平衡。

服务工作线程脚本网址随 Cache-Control: private, max-age=1500(1500 秒,即 25 分钟)响应标头一起提供,并且注册时将 updateViaCache 设置为“all”以确保标头得到遵守。正如您可能想象的那样,Google 搜索 Web 后端是一组庞大的全球分布式服务器,需要尽可能接近 100% 的正常运行时间。部署会影响 service worker 脚本内容的更改时,会以滚动方式进行。

如果用户访问已更新的后端,然后快速前往另一个尚未收到更新的服务工作线程的后端,则最终会在多个版本之间反复切换。因此,告知浏览器仅在自上次检查以来经过 25 分钟后才检查更新的脚本,并不会带来明显的缺点。选择启用此行为的优势在于,可以大幅减少动态生成 service worker 脚本的端点收到的流量。

此外,系统会在 service worker 脚本的 HTTP 响应中设置 ETag 标头,确保在 25 分钟后进行更新检查时,如果在此期间部署的 service worker 没有进行任何更新,服务器可以高效地响应 HTTP 304 响应。

虽然 Google 搜索 Web 应用中的某些互动使用单页应用样式的导航(即通过 History API),但在大多数情况下,Google 搜索是一个使用“真实”导航的传统 Web 应用。当团队决定使用两个可加速 Service Worker 更新生命周期的选项(clients.claim()skipWaiting())时,此方法会发挥作用。点击 Google 搜索界面通常会导航到新的 HTML 文档。调用 skipWaiting 可确保更新后的 service worker 在安装后立即有机会处理这些新的导航请求。同样,调用 clients.claim() 意味着更新后的服务工作线程有机会在激活后开始控制任何不受控制的已打开的 Google 搜索网页。

Google 搜索采用的方法不一定适合所有人,这是经过仔细的 A/B 测试各种服务选项组合后,才找到最适合自己的方法。 如果开发者的后端基础架构允许他们更快地部署更新,他们可能会希望浏览器尽可能频繁地检查更新后的 service worker 脚本,方法是始终忽略 HTTP 缓存。如果您要构建一个用户可能会长时间保持打开状态的单页应用,那么使用 skipWaiting() 可能不是正确的选择,因为如果您允许新的 service worker 在存在长期运行的客户端时激活,则可能会遇到缓存不一致的问题

重点小结

默认情况下,Service Worker 不会保持性能不变

向 Web 应用添加服务工作线程意味着插入一段额外的 JavaScript 代码,该代码需要在 Web 应用收到请求的响应之前加载并执行。如果这些响应最终来自本地缓存而不是网络,那么与采用缓存优先策略带来的性能提升相比,运行 service worker 的开销通常可以忽略不计。但如果您知道服务工作线程在处理导航请求时必须始终查询网络,那么使用导航预加载将显著提升性能。

Service Worker 仍然是一种渐进式增强功能

如今,Service Worker 的支持情况比一年前要好得多。所有新型浏览器现在都至少支持 Service Worker,但遗憾的是,某些高级 Service Worker 功能(例如后台同步和导航预加载)尚未全面推出。检查您知道自己需要的特定功能子集,并且仅在这些功能存在时注册 service worker,仍然是一种合理的方法。

同样,如果您在实际环境中运行过实验,并且知道低端设备在增加了 Service Worker 的开销后性能会变差,那么您也可以在这些情况下避免注册 Service Worker。

您应继续将 Service Worker 视为一种渐进式增强功能,当满足所有前提条件时,可将其添加到 Web 应用中,并且 Service Worker 可为用户体验和整体加载性能带来积极影响。

衡量所有指标

若要了解提供 Service Worker 是否对用户体验产生了积极或消极影响,唯一的方法就是进行实验并衡量结果。

设置有意义的衡量指标的具体方式取决于您使用的分析提供商,以及您通常在部署设置中进行实验的方式。一种方法是使用 Google Analytics 收集指标,此案例研究详细介绍了在 Google I/O Web 应用中使用 Service Worker 的体验。

非目标

虽然 Web 开发社区中的许多人都将 Service Worker 与渐进式 Web 应用相关联,但构建“Google 搜索 PWA”并不是该团队的最初目标。Google 搜索 Web 应用不会在 Web 应用清单中提供元数据,也不会鼓励用户完成添加到主屏幕流程。搜索团队对用户通过 Google 搜索的传统入口点访问其 Web 应用感到满意。

最初的版本并未试图将 Google 搜索 Web 体验转变为与安装式应用相当的体验,而是侧重于逐步增强现有网站。

致谢

感谢整个 Google 搜索 Web 开发团队在实现 Service Worker 方面所做的工作,以及分享撰写本文所用的背景资料。特别感谢 Philippe Golle、Rajesh Jagannathan、R. Samuel Klatchko、Andy Martone、Leonardo Peña、Rachel Shearer、Greg Terrono 和 Clay Woolam。

更新(2021 年 10 月):自本文最初发布以来,Google 搜索团队重新评估了当前 Service Worker 架构的优势和缺点。上述 service worker 即将停用。随着 Google 搜索 Web 基础架构的不断发展,该团队可能会重新审视其 Service Worker 设计。