Service Worker 是网络请求的强大代理,它允许开发人员用少量 JavaScript 控制浏览器响应各个 HTTP 请求。本文将主要讲述 Slack 的Service Worker 实践,以实现更快的启动速度与离线支持。
我们最近更新了 Slack 的桌面版,新版最大的改进一就是启动速度更快了。在本文中,我们将回顾一下提升 Slack 运行速度,从而改善用户体验的探索工作。
改进方案是从一个名为“快速启动”的原型开始的,该原型旨在(你猜对了)尽快启动 Slack。我们使用了 CDN 缓存的 HTML 文件、持久化的 Redux 存储和一个服务 Worker,从而在不到一秒钟的时间内启动客户端的精简版本(当时,使用 1-2 个工作区的用户普遍需要大约 5 秒钟的启动时间)。Service Worker 是速度提升的最大功臣,它还带来了用户亟需的脱机运行 Slack 的能力。这个原型让我们看到了桌面客户端架构彻底重构后的潜力。基于这种潜力,我们开始基于提升启动速度并引入脱机支持的核心期望重建 Slack 客户端。下面就来深入谈谈背后的工作机制。
Service Worker 是网络请求的强大代理,它使开发人员可以用少量 JavaScript 控制浏览器响应各个 HTTP 请求。它们带有丰富而灵活的缓存 API,设计为将请求对象用作键,将响应对象用作值。像 Web Worker 一样,它们独立于单独运行的窗口,在自己的进程中运行。
Service Worker 是现已弃用的应用程序缓存功能的后续工具,后者是为网站提供离线功能的 API。AppCache 的工作机制是提供你要缓存下来供离线使用的静态文件清单……就是这样。它简单但不灵活,无法为开发人员提供控制权。当 W3C 编写 Service Worker 规范时非常重视之前的这些反馈,最后,Service Worker 对你的应用程序或网站进行的所有网络交互都提供了细微的控制权。
当我们第一次涉足这项技术时,支持它的浏览器只有 Chrome 而已,但是我们知道它很快就会广泛普及。现在,所有主要浏览器都开始支持 Service Worker 了。
当你首次启动新版本的 Slack 时,我们将获取全套资产(HTML、JavaScript、CSS、字体和声音)并将其放置在 Service Worker 的缓存中。我们还将获取你的内存 Redux 存储的副本,并将其推送到 IndexedDB。下次启动时,我们检测这些缓存是否存在。如果存在,我们将使用它们来启动应用程序;如果你在线,我们将在引导后获取新数据。如果不存在,你的客户端也还是可用的。
为了区分这两种路径,我们给它们分别命名为:暖启动和冷启动。通常用户第一次启动是冷启动,没有缓存资产,也没有持久数据。暖启动时我们启动 Slack 所需要的全部数据都存储在用户本地计算机上。请注意,大多数二进制资产(图像、PDF 和视频等)均由浏览器缓存处理(并由普通的缓存头控制)。他们不需要 Service Worker 的显式处理即可离线加载。
Service Worker 可以处理三个事件:安装、获取和激活。每个事件我们都会深入研究,但首先必须下载并注册 Service Worker。生命周期取决于浏览器处理Service Worker 文件更新的方式。根据 MDN 的 API 文档:
当下载的文件是新文件时尝试安装——所谓新文件既可能是与现有 Service Worker 不同(按字节比较),或者是此页面 / 站点遇到的第一个 Service Worker。
每次我们更新相关的 JavaScript、CSS 或 HTML 文件时,它都会通过自定义的 webpack 插件运行,该插件会生成这些文件的带有唯一哈希值的清单。它被嵌入到 Service Worker 中,即使实现本身未更改也将在下次启动时触发更新。
每当 Service Worker 更新时我们都会收到安装事件。作为响应,我们遍历嵌入式清单中的文件,获取每个文件并将它们放入共享的缓存桶中。文件使用新的缓存 API 存储,这个 API 是 Service Worker 规范的另一部分。它存储以以请求对象为键的响应对象:非常优雅直观,且与 Service Worker 事件接收请求并返回响应的方式完全一致。
我们通过部署时间为缓存桶设置键。时间戳嵌入在我们的 HTML 中,因此可以作为文件名的一部分传递给所有资产请求。分别缓存各个部署中的资产是很重要的,可以预防不匹配现象。通过这种设置,我们可以确保最初获取的 HTML 文件从缓存或网络中只能获取兼容的资产。
注册后,我们的 Service Worker 将被设置为处理来自同一来源的每个网络请求。你无法选择是否让 Service Worker 处理请求,但是你完全可以控制该请求的处理方式。
首先我们检查请求。如果它在清单中并且存在于缓存中,我们将返回已缓存的响应。如果没在清单里,我们将为相同的请求返回一个获取调用,将请求传递到网络,好像不存在 Service Worker 一样。以下是我们获取处理程序的简化版本:
self.addEventListener('fetch', (e) => {
if (assetManifest.includes(e.request.url) {
e.respondWith(
caches
.open(cacheKey)
.then(cache => cache.match(e.request))
.then(response => {
if (response) return response;
return fetch(e.request);
});
);
} else {
e.respondWith(fetch(e.request));
}
});
在实际的实现中有更多 Slack 特定的逻辑,但获取处理程序的核心就是这么简单。
从 Service Worker 返回的响应将在网络检查器的大小列中标记为“(ServiceWorker)”
成功安装新的 Service Worker 或更新 Service Worker 后,会触发 activate 事件。我们用它来回顾缓存资产,并让所有超过 7 天的缓存桶失效。它不仅能打扫好房间,也可以防止客户端使用过时的资产启动。
你可能已经注意到,我们的实现意味着在第一次启动 Slack 客户端后,所有人都将收到上次注册 Service Worker 时获取的资产,而不是启动时最新部署的资产。我们的初始实现尝试在每次引导后更新 Service Worker。但是,典型的 Slack 客户可能只在每天早晨启动一次,并且可能会发现自己整整一天都在用落后一个版本的应用(我们每天更新多次代码)。
与你快速浏览的典型网站不一样的是,Slack 在人们工作时会在他们的计算机上停留数小时之久。这使我们的代码具有较长的保存期限,并且需要一些不一样的方法来保持最新状态。
我们仍然希望用户使用最新的版本,以便获得最新的错误修复、性能改进和功能。发布新版客户端后不久,我们就在抖动的间隔中添加了注册过程以缩小间隔。如果自上次更新以来有新版本部署,我们将获取新资产以备下次启动。如果没有,注册机制什么都不会做。做出这项更改后,客户端启动资产的平均过时时间减少了一半。
定期获取新版本,但仅使用最新版本启动
功能标记是我们代码库中的条件,可让我们合并未完成的工作以准备公开发布。有了它,我们在完成功能之前很长时间就可以与应用程序的剩余部分一起自由测试功能,从而降低了风险。
Slack 的日常工作流程是发布新功能以及我们 API 中的相应更改。在引入 Service Worker 之前我们已经保证两者是同步的,但现在我们的缓存资产落后了一个版本,客户端很可能就和后端不同步了。为了解决这个问题,我们不仅要缓存 资产,还会缓存 一些 API 响应。
Service Worker 处理每个网络请求的能力简化了解决方案。每次 Service Worker 更新时,我们也会发出 API 请求,并将响应缓存在与资产相同的存储桶中。这会将我们的功能和实验与正确的资产相关联——可能它们都过时了,但一定要保持同步。
这是 Service Worker 可能引发问题的冰山一角。这个问题用 AppCache 要么完全没法解决,要么需要非常复杂的技术栈;但是有了 Service Worker 和缓存 API,解决方案就变得非常简单且顺其自然了。
Service Worker 将 Slack 的资产存储在本地以备下次启动时使用,从而加快了启动速度。我们最大的延迟和可变性来源就是网络。而且如果你可以将网络排除在外,那么你也很容易提供离线支持。现在我们对脱机功能的支持非常简单——你可以启动 Slack,从以前阅读过的对话中读取消息,并将未读标记设置为在线后再同步——但有了这个舞台,我们将来就能开发更高级的离线功能了。
经过几个月的开发、试验和优化,我们已经学到了很多有关 Service Worker 如何在实践中操作的知识。而且该技术已经得到了大规模应用的实证:在公开发布后不到一个月的时间里,我们就通过数百万已安装的 Service Worker 成功地为每天数千万的请求提供了服务。与传统客户端相比,这意味着启动时间减少了约 50%;与冷启动相比启动时间缩短了约 25%。
在浏览器中使用 p50 旧版、暖启动和冷启动的测试结果(越低越好)
领取专属 10元无门槛券
私享最新 技术干货