Service WorkerAPI是网络平台的Dremel。它提供了令人难以置信的广泛效用,同时也产生了弹性和更好的性能。如果你还没有使用Service Worker--这也不能怪你,因为截至2020年,它还没有被广泛采用--它是这样的。
- 在最初访问一个网站时,浏览器会注册一个相当于客户端代理的东西,该代理由数量相当少的JavaScript驱动,就像一个Web Worker一样,在自己的线程上运行。
- 在服务工作者注册之后,你可以拦截请求,并决定如何在服务工作者的
fetch()事件中对其进行响应。
你决定如何处理你拦截的请求,a)是你的电话,b)取决于你的网站。你可以重写请求,在安装过程中预存静态资产,提供离线功能,以及--这将是我们最终的重点--为重复访问者提供更小的HTML有效载荷和更好的性能。
走出森林
Weekly Timber是我的一个客户,在威斯康星州中部提供伐木服务。对他们来说,一个快速的网站是至关重要的。他们的业务位于Waushara县,和美国的许多农村地区一样,网络质量和可靠性并不高。

图1.威 斯康星州沃沙拉县的无线覆盖图。地图上的棕褐色区域表示下行速度在3至9.99Mbps之间。红色区域的速度更慢,而苍白和深蓝色区域的速度更快。
威斯康星州有成天的农田,但它也有大量的森林。当你需要一家切割原木的公司时,谷歌可能是你的第一站。如果你在一个蹩脚的网络连接上等待太久,一个特定的伐木公司的网站有多快可能足以让你寻找其他地方。
我最初认为,对于Weekly Timber的网站来说,服务工人是没有必要的。毕竟,如果事情一开始就足够快了,为什么还要把事情复杂化呢?另一方面,我知道我的客户不仅服务于沃沙拉县,而且服务于威斯康星州中部的大部分地区,即使是一个简陋的服务工作程序,也可能是一种进步的增强,在可能最需要的地方增加弹性。
我为我客户的网站编写的第一个服务工作器--我把它称为 "标准 "服务工作器--使用了三种有据可查的缓存策略。
- 当窗口的加载事件发生时,安装服务工作器时,对所有页面的CSS和JavaScript资产进行预缓存。
- 将静态资产从
CacheStorage如果有的话。如果一个静态资产不在CacheStorage,从网络中检索,然后缓存起来,供以后访问。 - 对于HTML资产,先打入网络并将HTML响应放入
CacheStorage。如果在访问者下次到达时网络不可用,则从CacheStorage中提供缓存的标记。
这些既不是新的也不是特别的策略,但它们提供了两个好处。
- 离线能力,这在网络条件不稳定时很方便。
- 装载静态资产的性能提升。
这种性能的提升转化为第一份内容丰富的油漆(FCP)和最大的内容丰富的油漆(LCP)的中位时间分别减少42%和48%。更好的是,这些洞察力是基于真实用户监控(RUM)的。这意味着这些收益并不只是理论上的,而是对真实的人来说是真正的改善。

图2.Chrome浏览器的开发者工具中描述的请求/响应时间的细分。该请求是对来自CacheStorage 的静态资产的请求。由于服务工作器不需要访问网络,因此从CacheStorage"下载 "资产需要大约23毫秒。
这一性能提升是由于完全绕过了网络来处理已经在CacheStorage中的静态资产--尤其是渲染受阻的样式表。当我们依靠HTTP缓存时,也能实现类似的好处,只是我刚才描述的FCP和LCP的改进是与没有安装Service Worker的、有HTTP缓存的页面相比的。
如果你想知道为什么CacheStorage 和HTTP缓存不一样,那是因为HTTP缓存--至少在某些情况下--可能还需要到服务器上验证资产的新鲜度。Cache-Control的immutable 标志可以解决这个问题,但是immutable 还没有得到很好的支持。一个长的最大年龄值也可以,但服务工作者API和CacheStorage 的结合给了你更大的灵活性。
撇开细节不谈,我们的收获是,最简单、最完善的Service Worker缓存实践可以提高性能。有可能比配置良好的Cache-Control headers所能提供的更多。即便如此,Service Worker是一项不可思议的技术,具有更多的可能性。我们有可能走得更远,我将告诉你如何走。
一个更好、更快的服务工作程序
网络喜欢"创新",这也是我们同样喜欢的一个词。对我来说,真正的创新不是我们仅仅为了开发者的利益而创造新的框架或模式,而是这些发明是否有利于最终使用我们在网络上拍打的东西的人。选民的优先权是我们应该尊重的事情。用户高于一切,永远如此。
Service Worker API的创新空间是相当大的。你如何在这个空间里工作,会对网络的体验产生很大影响。像导航预加载和 ReadableStream已经将服务工作程序从伟大变成了杀手。我们可以利用这些新功能分别做以下事情。
- 通过并行化服务工作站的启动时间和导航请求来减少服务工作站的延迟。
- 将内容从
CacheStorage和网络上流进来。
此外,我们要把这些能力结合起来,再拿出一个技巧:预先缓存页眉和页脚部分,然后把它们与来自网络的内容部分结合起来。这不仅减少了我们从网络下载的数据量,而且还提高了重复访问的感知性能。这就是帮助大家的创新。
灰头土脸的我转身对你说:"我们来做这件事吧。"
奠定基础
如果把预存的页眉和页脚部分与网络内容即时结合起来的想法看起来像一个单页应用程序(SPA),那么你就离得不远了。像SPA一样,你需要在你的网站上应用 "应用外壳 "模型。只是,你必须把你的网站看成是三个独立的部分,而不是由客户端路由器把内容植入一块最小的标记中。
- 头部。
- 内容。
- 页脚。
对于我的客户的网站,它看起来像这样。

图3.木材周刊网站的不同部分的颜色编码。页脚和页眉部分被存储在CacheStorage ,而内容部分则从网络上检索,除非用户离线。
这里需要记住的是,各个部分不必是有效的标记,即每个部分中的所有标签都需要关闭。唯一重要的是,这些部分的组合必须是有效的标记。
首先,你需要在安装服务工作器时预存单独的页眉和页脚部分。对于我的客户的网站,这些参数是由/partial-header 和/partial-footer 路径名提供的。
self.addEventListener("install", event => {
const cacheName = "fancy_cache_name_here";
const precachedAssets = [
"/partial-header", // The header partial
"/partial-footer", // The footer partial
// Other assets worth precaching
];
event.waitUntil(caches.open(cacheName).then(cache => {
return cache.addAll(precachedAssets);
}).then(() => {
return self.skipWaiting();
}));
});
每一个页面都必须是可获取的内容部分,除去页眉和页脚,以及带有页眉和页脚的完整页面。这是关键,因为对一个页面的初始访问不会被服务工作器控制。一旦服务工作者接管,那么你就会提供内容分片,并将它们与来自CacheStorage 的页眉和页脚分片组装成完整的响应。
如果你的网站是静态的,这就意味着要生成一大堆其他的标记参数,你可以在服务工作者的fetch() 事件中重写请求。如果你的网站有一个后端--就像我的客户那样--你可以使用HTTP请求头来指示服务器交付完整的页面或内容分片。
困难的部分是把所有的碎片放在一起--但我们要做的就是这样。
把所有的东西放在一起
编写一个基本的服务工作程序也是很有挑战性的,但是当把多个响应组合在一起时,事情就会变得非常复杂。其中一个原因是,为了避免服务工作器的启动惩罚,我们需要设置导航预加载。
实现导航预加载
导航预加载解决了服务工作器启动时间的问题,它延迟了对网络的导航请求。你最不想做的事情就是耽误服务工作器的演出。
必须明确地启用导航预加载。一旦启用,服务工作器就不会在启动期间耽误导航请求。导航预载在服务工作器的activate 事件中被启用。
self.addEventListener("activate", event => {
const cacheName = "fancy_cache_name_here";
const preloadAvailable = "navigationPreload" in self.registration;
event.waitUntil(caches.keys().then(keys => {
return Promise.all([
keys.filter(key => {
return key !== cacheName;
}).map(key => {
return caches.delete(key);
}),
self.clients.claim(),
preloadAvailable ? self.registration.navigationPreload.enable() : true
]);
}));
});
因为导航预载并不是所有地方都支持的,所以我们必须做通常的功能检查,在上面的例子中我们将其存储在preloadAvailable 变量中。
此外,我们需要用 Promise.all()来解决服务工作器激活前的多个异步操作。这包括修剪那些旧的缓存,以及同时等待 clients.claim()(它告诉服务工作器立即断言控制,而不是等到下一次导航)和导航预加载被启用。
三元运算符用于在支持的浏览器中启用导航预加载,避免在不支持的浏览器中抛出错误。如果preloadAvailable 是true ,我们就启用导航预加载。如果不是,我们传递一个布尔值,它不会影响Promise.all() 的解析方式。
启用导航预加载后,我们需要在服务工作器的fetch() 事件处理程序中编写代码,以利用预加载的响应。
self.addEventListener("fetch", event => {
const { request } = event;
// Static asset handling code omitted for brevity
// ...
// Check if this is a request for a document
if (request.mode === "navigate") {
const networkContent = Promise.resolve(event.preloadResponse).then(response => {
if (response) {
addResponseToCache(request, response.clone());
return response;
}
return fetch(request.url, {
headers: {
"X-Content-Mode": "partial"
}
}).then(response => {
addResponseToCache(request, response.clone());
return response;
});
}).catch(() => {
return caches.match(request.url);
});
// More to come...
}
});
虽然这不是服务工作者的fetch() 事件代码的全部,但有很多需要解释的地方。
- 预加载的响应可在
event.preloadResponse。然而,正如Jake Archibald所指出的,在不支持导航预加载的浏览器中,event.preloadResponse的值将是undefined。因此,我们必须把event.preloadResponse传给Promise.resolve()以避免兼容性问题。 - 我们在产生的
then回调中进行调整。如果事件。preloadResponse被支持,我们使用预装的响应,并通过addResponseToCache()辅助函数将其添加到CacheStorage。如果不支持,我们向网络发送一个fetch()请求,使用一个值为partial的自定义X-Content-Mode头来获取部分内容。 - 如果网络不可用,我们就退回到最近访问的内容部分,即
CacheStorage。 - 响应--无论它是从哪里获得的--然后被返回到一个名为
networkContent的变量,我们在后面使用。
如何获取部分内容是很棘手的。在启用导航预加载的情况下,一个特殊的Service-Worker-Navigation-Preload 标头,其值为true ,被添加到导航请求中。然后我们在后端使用该头,以确保响应是一个内容部分,而不是整个页面的标记。
然而,由于导航预载并不是在所有的浏览器中都可用,所以我们在这些情况下发送一个不同的头。在Weekly Timber的情况下,我们回落到一个自定义的X-Content-Mode header。在我客户的PHP后端,我已经创建了一些方便的常量。
<?php
// Is this a navigation preload request?
define("NAVIGATION_PRELOAD", isset($_SERVER["HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD"]) && stristr($_SERVER["HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD"], "true") !== false);
// Is this an explicit request for a content partial?
define("PARTIAL_MODE", isset($_SERVER["HTTP_X_CONTENT_MODE"]) && stristr($_SERVER["HTTP_X_CONTENT_MODE"], "partial") !== false);
// If either is true, this is a request for a content partial
define("USE_PARTIAL", NAVIGATION_PRELOAD === true || PARTIAL_MODE === true);
?>
从那里,USE_PARTIAL 常量被用来调整响应。
<?php
if (USE_PARTIAL === false) {
require_once("partial-header.php");
}
require_once("includes/home.php");
if (USE_PARTIAL === false) {
require_once("partial-footer.php");
}
?>
这里需要注意的是,你应该为HTML响应指定一个Vary 标头,以便将Service-Worker-Navigation-Preload (以及在这种情况下,X-Content-Mode 标头)纳入HTTP缓存的考虑范围--假设你在缓存HTML,但对你来说可能不是这样的。
随着我们对导航预加载的处理完成,我们就可以开始从网络上传输部分内容,并将它们与来自CacheStorage 的页眉和页脚部分缝合在一起,成为服务工作者将提供的单一响应。
流化部分内容和缝合响应
虽然页眉和页脚部分几乎是即时可用的,因为它们自服务工作器安装以来一直在CacheStorage ,但我们从网络上检索的部分内容才是瓶颈所在。因此,我们必须流式响应,以便我们能够尽快开始向浏览器推送标记。ReadableStream 可以为我们做这个。
这个ReadableStream 的业务是一个头脑风暴。任何告诉你这很 "容易 "的人都是在对你说甜言蜜语。这很难。在我写了我自己的函数来合并流式响应,并搞砸了一个关键步骤之后--注意,这最终并没有提高页面性能--我修改了Jake Archibald的mergeResponses() 函数来满足我的需求。
async function mergeResponses (responsePromises) {
const readers = responsePromises.map(responsePromise => {
return Promise.resolve(responsePromise).then(response => {
return response.body.getReader();
});
});
let doneResolve,
doneReject;
const done = new Promise((resolve, reject) => {
doneResolve = resolve;
doneReject = reject;
});
const readable = new ReadableStream({
async pull (controller) {
const reader = await readers[0];
try {
const { done, value } = await reader.read();
if (done) {
readers.shift();
if (!readers[0]) {
controller.close();
doneResolve();
return;
}
return this.pull(controller);
}
controller.enqueue(value);
} catch (err) {
doneReject(err);
throw err;
}
},
cancel () {
doneResolve();
}
});
const headers = new Headers();
headers.append("Content-Type", "text/html");
return {
done,
response: new Response(readable, {
headers
})
};
}
像往常一样,有很多事情要做。
mergeResponses()接受一个名为 的参数,该参数是一个数组,其中包括responsePromisesResponse对象的数组,该数组由导航预加载、fetch()、或caches.match().假设网络是可用的,这将总是包含三个响应:两个来自caches.match(),(希望)一个来自网络。- 在我们能够流式处理
responsePromises数组中的响应之前,我们必须将responsePromises映射到一个数组,其中包含每个响应的一个阅读器。每个读取器稍后会在ReadableStream()构造函数中使用,以流化每个响应的内容。 - 一个名为
done的承诺被创建。在其中,我们将承诺的resolve()和reject()函数分别分配给外部变量doneResolve和doneReject。这些将被用于ReadableStream(),以提示流是否已经完成或遇到了障碍。 - 新的
ReadableStream()实例被创建,名称为readable。当响应从CacheStorage和网络流进来时,它们的内容将被追加到readable。 - 流的
pull()方法将数组中的第一个响应的内容流化。如果流没有以某种方式取消,当响应被完全流化时,每个响应的阅读器会被调用阅读器阵列的shift()方法丢弃。这样反复进行,直到没有更多的阅读器需要处理。 - 当所有的工作完成后,合并的响应流被作为一个单一的响应返回,并且我们用
Content-Type头部的值text/html。
如果你使用 TransformStream,但取决于你何时读到这篇文章,这可能不是每个浏览器的选择。目前,我们必须坚持使用这种方法。
现在让我们重新审视一下前面的服务工作者的fetch() 事件,并应用mergeResponses() 函数。
self.addEventListener("fetch", event => {
const { request } = event;
// Static asset handling code omitted for brevity
// ...
// Check if this is a request for a document
if (request.mode === "navigate") {
// Navigation preload/fetch() fallback code omitted.
// ...
const { done, response } = await mergeResponses([
caches.match("/partial-header"),
networkContent,
caches.match("/partial-footer")
]);
event.waitUntil(done);
event.respondWith(response);
}
});
在fetch() 事件处理程序的末尾,我们将CacheStorage 的头和脚部分传递给mergeResponses() 函数,并将结果传递给fetch() 事件的respondWith() 方法,该方法代表服务工作者提供合并的响应。
这样的结果值得这么麻烦吗?
这有很多事情要做,而且很复杂!你可能会弄乱一些东西,或者可能会弄错。你可能会搞砸一些事情,或者你的网站的架构并不适合这种确切的方法。因此,重要的是要问:性能上的好处是否值得这些工作?在我看来?是的!合成的性能收益一点也不差。

图4.每周木材网站的各种服务工作者类型的FCP和LCP合成性能数据的柱状图。
合成测试除了在特定的设备和互联网连接上进行外,并不衡量任何性能。即便如此,这些测试是在我的客户网站的暂存版本上进行的,使用的是低端的诺基亚2号安卓手机,在Chrome的开发者工具中使用节流的 "快速3G "连接。每个类别都在主页上测试了10次。这里的收获是。
- 完全没有服务工作器比 "标准 "服务工作器稍快,它的缓存模式比流式变体更简单。比如,稍微快一点。这可能是由于Service Worker启动所带来的延迟,然而,我所要讨论的RUM数据显示了一个不同的情况。
- 在没有服务工作器或使用 "标准 "服务工作器的情况下,LCP和FCP都是紧密耦合的。这是因为页面的内容相当简单,CSS也相当小。最大的Contentful Paint通常是页面上的开头段落。
- 然而,流式服务工作器将FCP和LCP解耦,因为标题内容部分直接从
CacheStorage。 - 在流式服务工作器中,FCP和LCP都比其他情况下低。

图5.在Weekly Timber网站的各种服务工作器类型中,FCP和LCP RUM性能数据的柱状图。
流式服务工作器对真实用户的好处是显而易见的。对于FCP来说,与没有服务工作器相比,我们得到了79%的改进,与 "标准 "服务工作器相比,则有63%的改进。LCP的好处则更为微妙。与完全没有服务工作者相比,我们在LCP方面实现了41%的改进--这是令人难以置信的!然而,与 "标准 "服务工作者相比,我们在LCP方面的改进则更为微妙。然而,与 "标准 "服务工作器相比,LCP的速度稍慢。
因为性能的长尾很重要,让我们看看FCP和LCP性能的第95个百分点。

图6:Weekly Timber网站各种服务工作者类型的第95百分位FCP和LCP RUM性能数据的柱状图。
RUM数据的第95百分位数是评估最慢经验的一个好地方。在这种情况下,我们看到流媒体服务工在FCP和LCP方面分别比没有服务工的情况下有40%和51%的改善。与 "标准 "服务工作器相比,我们看到FCP和LCP分别减少了19%和43%。如果这些结果与合成指标相比显得有点诡异,请记住:这就是RUM的数据你永远不知道谁会在什么网络的哪个设备上访问你的网站。
虽然FCP和LCP都得到了流媒体、导航预加载(在Chrome的情况下)以及通过拼接来自CacheStorage 和网络的部分而发送更少标记的无数好处的推动,但FCP是明显的赢家。从感觉上讲,这种好处是显而易见的,正如这段视频所显示的。
图7.三段WebPageTest的视频,重复观看《每周木材》的主页。左边是没有被服务工作器控制的页面,有一个原始的HTTP缓存。右边的是由流式服务工作器控制的同样的页面,CacheStorage已经启动。
现在问问你自己。如果这是我们在这样一个小而简单的网站上可以期待的改进,那么在一个有更大的页眉和页脚标记有效载荷的网站上,我们可以期待什么?
注意事项和结论
在开发方面是否有取舍?哦,是的。
正如Philip Walton所指出的-set-the-correct-title),一个缓存的标题部分意味着在每次导航时必须在JavaScript中更新文档标题,通过改变 document.title.这也意味着你需要在JavaScript中更新导航状态,以反映当前页面,如果这是你在网站上做的事情的话。请注意,这不应该导致索引问题,因为Googlebot在抓取网页时,会使用未刷新的缓存。
在有认证的网站上也可能会有一些挑战。例如,如果你的网站的页眉在登录时显示当前的认证用户,你可能必须在每次导航时更新由CacheStorage 提供的页眉部分标记,以反映谁是认证的。你也许可以通过将基本的用户数据存储在localStorage ,并从那里更新用户界面来实现这一目标。
当然还有其他的挑战,但这将取决于你如何权衡面向用户的好处与开发成本的关系。在我看来,这种方法在博客、营销网站、新闻网站、电子商务和其他典型用例等应用中具有广泛的适用性。
总而言之,它类似于你从SPA中得到的性能改进和效率提高。唯一的区别是,你不是在取代久经考验的导航机制,也不是在处理所有由此产生的混乱,而是在增强 它们。这就是我认为在客户端路由大行其道的世界里,真正需要考虑的部分。
你可能会问:"那Workbox呢?"--你这样问是对的。当涉及到使用服务工作者API时,Workbox简化了很多东西,而且你使用它也没有错。就我个人而言,我更喜欢尽可能地接近金属,这样我就能更好地理解像Workbox这样的抽象概念之下的东西。即便如此,Service Worker也很难。如果适合你,就使用Workbox吧。就框架而言,其抽象成本非常低。
不管这种方法如何,我认为在使用Service Worker API来减少你所发送的标记量方面有令人难以置信的效用和力量。这对我的客户和所有使用他们网站的人都有好处。由于Service Worker和围绕其使用的创新,我的客户的网站在威斯康星州遥远的地方变得更快。这让我感觉很好。
特别感谢 杰克-阿奇博尔德 他提出的宝贵的编辑建议,说句不好听的,这大大地提高了本文的质量。