讲述所发布内容、影响衡量方式和所做的权衡的故事。
背景
在 Google 上搜索几乎任何主题,您都会看到一个一目了然的页面,其中包含有意义且相关的搜索结果。您可能并未意识到,在某些情况下,此搜索结果页是由一项名为服务工件的强大 Web 技术提供的。
为了在不对性能产生负面影响的情况下为 Google 搜索推出服务工件支持,我们需要多支团队的数十名工程师通力合作。本文将介绍我们发布了哪些内容、如何衡量性能以及做出了哪些权衡。
探索服务工件的关键原因
向 Web 应用添加服务工件就像对网站进行任何架构更改一样,都应有明确的目标。对于 Google 搜索团队来说,添加服务工件值得探索的原因有几个。
搜索结果缓存功能受限
Google 搜索团队发现,用户在短时间内多次搜索同一字词的情况很常见。搜索团队希望利用缓存功能,在本地执行这些重复请求,而不是仅仅为了获取可能相同的结果而触发新的后端请求。
新鲜度的重要性不容忽视。有时,用户会反复搜索相同的字词,因为这些字词所对应的主题在不断演变,而用户希望看到新鲜的搜索结果。借助服务工件,搜索团队可以实现精细的逻辑来控制本地缓存的搜索结果的生命周期,并在速度与新鲜度之间取得他们认为最适合用户的确切平衡。
富有意义的离线体验
此外,Google 搜索团队还希望提供有意义的离线体验。当用户想要了解某个主题时,希望直接前往 Google 搜索页面并开始搜索,而无需担心互联网连接是否畅通。
如果没有服务工件,在离线状态下访问 Google 搜索页面只会导致浏览器显示标准网络错误页面,并且用户必须记得在网络连接恢复后返回并重试。借助服务工件,您可以提供自定义离线 HTML 响应,并允许用户立即输入搜索查询。
在连接到互联网之前,系统不会显示搜索结果,但服务工件允许延迟搜索,并在设备使用 Background Sync API 重新上线后立即将搜索结果发送到 Google 服务器。
更智能的 JavaScript 缓存和传送
另一个动机是优化模块化 JavaScript 代码的缓存和加载,这些代码为搜索结果页上的各种类型的功能提供支持。JavaScript 捆绑提供了许多优势,在没有服务工件的情况下,这些优势非常有用,因此搜索团队不想完全停止捆绑。
搜索团队怀疑,通过使用服务工件的功能在运行时对 JavaScript 进行版本控制和缓存精细分块,他们可以减少缓存更改量,并确保日后重复使用的 JavaScript 可以高效缓存。其服务工件中的逻辑可以分析包含多个 JavaScript 模块的 bundle 的出站 HTTP 请求,并通过拼接多个本地缓存的模块来实现该请求,从而尽可能有效地“解包”。这可以节省用户带宽,并提高整体响应能力。
使用由服务工件提供的缓存 JavaScript 还有性能方面的好处:在 Chrome 中,系统会存储并重复使用该 JavaScript 的已解析的字节码表示法,从而减少在运行时执行网页上的 JavaScript 所需的工作量。
挑战和解决方案
以下是实现该团队所述目标时需要克服的一些障碍。虽然其中一些挑战仅适用于 Google 搜索,但其中许多挑战适用于可能考虑部署服务工件的各种网站。
问题:服务工件开销
在 Google 搜索上启动服务工件时,最大的挑战也是唯一真正的阻碍因素是确保它不会执行任何可能会增加用户感知的延迟时间的操作。Google 搜索非常重视性能,过去,如果新功能会导致给定用户群体的延迟时间增加几十毫秒,我们就会禁止发布该功能。
当该团队在最早期的实验中开始收集性能数据时,就发现了问题。系统在响应搜索结果页的导航请求时返回的 HTML 是动态的,并且因需要在 Google 搜索的网络服务器上运行的逻辑而异。服务工件目前无法复制此逻辑并立即返回缓存的 HTML;它能做的最好的事情就是将导航请求传递给后端 Web 服务器,这需要发出网络请求。
如果没有服务工件,系统会在用户导航后立即发出此网络请求。注册服务工件后,系统始终需要启动该工件并让其有机会执行其 fetch
事件处理脚本,即使这些提取处理脚本除了访问网络之外没有任何其他操作的机会也是如此。启动和运行服务 worker 代码所需的时间是每次导航之外的纯开销:
这会导致服务工件实现的延迟时间过长,以至于无法体现任何其他优势。此外,该团队还发现,根据在真实设备上测量服务工件启动时间,启动时间的分布范围很广,一些低端移动设备启动服务工件所花的时间几乎与发出对结果页 HTML 的网络请求所花的时间一样长。
解决方案:使用导航栏预加载
让 Google 搜索团队能够顺利发布服务工件的单一关键功能就是导航预加载。对于需要使用来自网络的响应来满足导航请求的任何服务工件,使用导航预加载是实现卓越性能的关键。它会在服务工件启动的同时,向浏览器提供一个提示,让浏览器立即开始发出导航请求:
只要服务工件启动所需的时间短于从网络获取响应所需的时间,服务工件就不会引入任何延迟开销。
搜索团队还需要避免在低端移动设备上使用服务工件,因为服务工件的启动时间可能会超过导航请求。由于没有关于“低端”设备的硬性规定,因此他们提出了检查设备上安装的总 RAM 的启发词语。内存低于 2 GB 的设备属于低端设备类别,在这种设备上,服务工件启动时间过长是不可接受的。
可用存储空间是另一个考虑因素,因为要缓存以供日后使用的一整套资源可能需要数兆字节。借助 navigator.storage
接口,Google 搜索页面可以提前确定其尝试缓存数据是否存在因存储空间配额不足而失败的风险。
这让搜索团队有了多项可用于确定是否使用服务工件的条件:如果用户使用支持导航预加载的浏览器访问 Google 搜索页面,并且具有至少 2 GB 的 RAM 和足够的空闲存储空间,则系统会注册服务工件。不符合上述条件的浏览器或设备将不会使用服务工作器,但用户仍会获得一如既往的 Google 搜索体验。
这种选择性注册的一个附带好处是,能够提供体积更小、效率更高的服务工件。以相当现代的浏览器为目标平台运行服务工件代码,可消除针对旧版浏览器进行转译和使用 polyfill 的开销。最终,这从服务工件实现的总大小中减少了约 8 千字节的未压缩 JavaScript 代码。
问题:Service Worker 范围
搜索团队开展了足够多的延迟时间实验,并确信使用导航预加载为他们提供了一种可行且延迟时间不变的服务工件使用途径,于是一些实际问题开始浮出水面。其中一个问题与服务工件的作用域规则有关。Service Worker 的范围决定了它可以控制哪些网页。
范围限定功能基于网址路径前缀运作。对于托管单个 Web 应用的网域,这不是问题,因为您通常只会使用范围最大为 /
的 Service Worker,它可以控制网域下的任何网页。但 Google 搜索的网址结构稍微复杂一些。
如果为该服务工件分配了 /
的最大范围,它最终将能够控制在 www.google.com
(或地区性等效网域)下托管的任何网页,而该网域下有一些网址与 Google 搜索毫无关系。更合理、更具限制性的范围是 /search
,这至少可以排除与搜索结果完全无关的网址。
遗憾的是,即使 /search
网址路径也被不同类型的 Google 搜索结果共享,网址查询参数决定了系统会显示哪种具体类型的搜索结果。其中一些变种使用的代码库与传统的网页搜索结果页完全不同。例如,图片搜索和购物搜索都通过 /search
网址路径提供,但查询参数不同,但这两个界面都尚未准备好提供自己的服务工件体验。
解决方案:创建调度和路由框架
虽然有一些提案允许使用比网址路径前缀更强大的功能来确定服务工件作用域,但 Google 搜索团队在部署服务工件时遇到了问题,因为该服务工件对其控制的部分网页没有任何作用。
为了解决此问题,Google 搜索团队构建了一个专门的调度和路由框架,该框架可配置为检查客户端网页的查询参数等条件,并使用这些条件来确定要沿哪条特定代码路径执行。该系统并非采用硬编码规则,而是旨在实现灵活性,让共享网址空间的团队(例如图片搜索和购物搜索)能够在日后(如果决定实现)插入自己的服务工件逻辑。
问题:个性化结果和指标
用户可以使用自己的 Google 账号登录 Google 搜索,系统可能会根据用户的具体账号数据为其定制搜索结果体验。系统会通过特定的浏览器 Cookie 来识别已登录的用户,这种方法是一种久经考验且广泛受支持的标准。
不过,使用浏览器 Cookie 的一个缺点是,它们不会在服务工件中公开,并且无法自动检查其值并确保它们不会因用户退出或切换账号而发生变化。(我们正在努力为服务工件提供 Cookie 访问权限,但在撰写本文时,此方法仍处于实验阶段,且未得到广泛支持。)
如果服务工件对当前登录用户的视图与实际登录 Google 搜索网页界面的用户不匹配,可能会导致个性化搜索结果不正确,或者指标和日志记录归因错误。上述任何失败场景对 Google 搜索团队来说都是严重问题。
解决方案:使用 postMessage 发送 Cookie
与其等待实验性 API 发布并提供对服务工件中浏览器 Cookie 的直接访问权限,Google 搜索团队选择了权宜解决方案:每当加载由服务工件控制的网页时,该网页都会读取相关 Cookie,并使用 postMessage()
将其发送到服务工件。
然后,该服务工件会将当前 Cookie 值与预期值进行比较,如果不匹配,则会采取措施从其存储空间中清除所有特定于用户的数据,并重新加载搜索结果页,而不会出现任何不正确的个性化设置。
服务工件为将一切重置为基准而采取的具体步骤取决于 Google 搜索的要求,但对于处理基于浏览器 Cookie 的个性化数据的其他开发者来说,同样的一般方法可能很有用。
问题:实验和动态性
如前所述,Google 搜索团队在默认启用新代码和功能之前,会先在生产环境中运行实验,并在真实环境中测试这些代码和功能的影响。对于严重依赖缓存数据的静态服务工件,这可能会带来一些挑战,因为用户选择加入或退出实验通常需要与后端服务器通信。
解决方案:动态生成的服务工件脚本
该团队采用的解决方案是使用动态生成的服务工件脚本(由网站服务器为每位用户单独定制),而不是预先生成的单个静态服务工件脚本。通常,与可能影响服务工件行为或网络请求的实验相关的信息会直接包含在此自定义服务工件脚本中。通过组合使用传统技术(例如浏览器 Cookie)以及在已注册的服务工件网址中提供更新后的代码,即可更改用户的一系列活跃体验。
此外,在极少数情况下,如果 Service Worker 实现存在需要避免的严重 bug,使用动态生成的 Service Worker 脚本还可以更轻松地提供应急方案。动态服务器工作器响应可以是无操作实现,从而有效地为部分或全部当前用户停用服务器工作器。
问题:协调更新
任何实际服务工件部署都面临着一个最棘手的挑战,即在避免使用网络而改用缓存之间做出合理的权衡,同时确保现有用户在关键更新和更改部署到正式版后立即收到。合适的平衡取决于许多因素:
- 您的 Web 应用是否为长效的单页应用,用户无需导航到新页面即可无限期保持打开状态。
- 后端 Web 服务器更新的部署节奏。
- 普通用户是否能接受使用略微过时的 Web 应用版本,还是新鲜度是首要考虑因素。
在对服务工件进行实验时,Google 搜索团队确保在多次预定的后端更新期间让实验保持运行,以确保指标和用户体验更贴近回访用户最终在现实中看到的情况。
解决方案:平衡新鲜度和缓存利用率
在测试了多种不同的配置选项后,Google 搜索团队发现,以下设置在新鲜度和缓存利用率之间取得了适当的平衡。
服务工件脚本网址随 Cache-Control: private, max-age=1500
(1500 秒或 25 分钟)响应标头一起提供,并且注册时将 updateViaCache 设置为“all”,以确保遵循该标头。正如您所想,Google 搜索 Web 后端是一组分布在全球各地的大型服务器,需要尽可能接近 100% 的正常运行时间。部署会影响服务工件脚本内容的更改是分批进行的。
如果用户命中已更新的后端,然后快速转到另一个页面,该页面命中尚未收到更新的服务 Worker 的后端,那么用户最终会在多个版本之间来回切换。因此,告知浏览器仅在距离上次检查过去 25 分钟后才检查是否有更新的脚本,不会带来明显的缺点。选择启用此行为的好处是,动态生成服务工件脚本的端点收到的流量会大幅减少。
此外,系统还会在服务工件脚本的 HTTP 响应中设置 ETag 标头,以确保在 25 分钟后进行更新检查时,如果在此期间部署的服务工件没有任何更新,服务器可以高效地返回 HTTP 304 响应。
虽然 Google 搜索 Web 应用中的某些互动会使用单页应用风格的导航(即通过 History API),但在大多数情况下,Google 搜索是一款使用“真实”导航的传统 Web 应用。当团队决定使用以下两个选项来加快服务工件更新生命周期时,就会用到此方法:clients.claim()
和 skipWaiting()
。在 Google 搜索界面中点击,通常会进入新的 HTML 文档。调用 skipWaiting
可确保更新后的服务工件在安装后立即有机会处理这些新的导航请求。同样,调用 clients.claim()
意味着,在服务工件激活后,经过更新的服务工件有机会开始控制所有未受控的打开的 Google 搜索页面。
Google 搜索采用的方法不一定适用于所有人,而是经过仔细的 A/B 测试各种广告投放选项组合,最终找到最适合自己的解决方案。如果开发者的后端基础架构支持更快地部署更新,他们可能希望浏览器始终忽略 HTTP 缓存,以尽可能频繁地检查更新后的服务工件脚本。如果您要构建的单页应用可能会被用户长时间打开,那么使用 skipWaiting()
可能不适合您。如果您允许在有长寿客户端的情况下激活新的服务工件,则可能会遇到缓存不一致的问题。
重点小结
默认情况下,服务工件不会对性能产生影响
向 Web 应用添加服务工件意味着插入一段额外的 JavaScript,该代码需要在 Web 应用收到其请求的响应之前加载和执行。如果这些响应最终来自本地缓存而非网络,那么与采用缓存优先策略带来的性能提升相比,运行服务工件的开销通常可以忽略不计。但是,如果您知道您的服务工件在处理导航请求时始终必须咨询网络,那么使用导航预加载功能可以显著提升性能。
服务工件(仍然)是一种渐进式增强功能
与一年前相比,服务工件支持情况如今已大有改观。所有新型浏览器现在都至少支持 Service Worker,但遗憾的是,某些高级 Service Worker 功能(例如后台同步和导航预加载)并未普遍推出。针对您知道需要的特定子集进行功能检查,并仅在存在这些功能时注册服务工件,仍然是一种合理的方法。
同样,如果您在真实环境中开展了实验,并且知道低端设备在增加服务工件开销后最终会出现性能不佳的问题,那么在这些情况下,您也可以选择不注册服务工件。
您应继续将服务工件视为一种渐进式增强功能,在满足所有前提条件且服务工件对用户体验和整体加载性能有积极影响时,将其添加到 Web 应用中。
全面的指标测量
若要了解发布服务工件对用户体验有何影响(是正面影响还是负面影响),唯一的方法就是进行实验并衡量结果。
设置有意义的衡量指标的具体方法取决于您使用的分析服务提供商,以及您通常在部署设置中如何开展实验。这项案例研究详细介绍了一种方法,即使用 Google Analytics 收集指标,该方法基于在 Google I/O 网站应用中使用服务工的经验。
非目标
虽然 Web 开发社区中的许多人会将 Service Worker 与渐进式 Web 应用相关联,但构建“Google 搜索 PWA”并非该团队的初始目标。Google 搜索 Web 应用目前不通过 Web 应用清单提供元数据,也不鼓励用户完成“添加到主屏幕”流程。搜索团队目前对用户通过 Google 搜索的传统入口点访问其 Web 应用感到满意。
在最初的发布阶段,我们并未尝试将 Google 搜索网站体验转变为与已安装的应用相当的体验,而是专注于逐步改进现有网站。
致谢
感谢 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 搜索团队重新评估了其当前服务工件架构的优势和权衡。上述服务工件即将弃用。随着 Google 搜索 Web 基础架构的不断演变,该团队可能会重新审视其 Service Worker 设计。