众所周知,Javascript 由于它的单线程特性,任何“重量”操作都会阻塞主线程,让我们的应用变得卡顿、看起来没有响应。为了提升性能和体验,现代浏览器允许我们将一些工作交给 Worker,把原本的单线程应用变成多线程运行。
浏览器中提供了 3 种 Worker,分别是:
- Web worker—— 包含专用 worker及共享 worker
- Service worker
- Worklet—— 包含PaintWorklet、AudioWorklet、AnimationWorklet、LayoutWorklet。
Worklet 似乎是专为媒体之类的应用设计,且仍处于试验状态。因此,本篇内容将只探讨更通用的 Web worker 及 Service worker。
目录
Web worker
Web worker 特别适用于后台跑脚本。现在的网页都可以注册多个 Worker,让不同的任务在各自独立的环境中完成。
(图片来自Workers Overview)
用Worker API 创建一个页面专属线程的方法如下:
const myWorker = new Worker("worker.js");
如果同源的不同标签想共享一个线程,则可以用SharedWorker API:
const mySharedWorker = new SharedWorker("sharedWorker.js");
这两种 Worker 的用法近似,但在页面和 Worker 的连接上,共享 Worker 是通过端口来通信的。因此在注册共享 Worker 的时候必须指定一个可用的端口。然后在主线程中可以如下发送和接收消息:
mySharedWorker.port.postMessage("哈喽Worker!");
mySharedWorker.port.onmessage = (e) => {
console.log("收到消息:", e.data);
};
在共享 Worker 跑的脚本中可以如下处理消息:
onconnect = (e) => {
const port = e.ports[0];
port.onmessage = (e) => {
port.postMessage('收到消息 "${e.data}"`);
};
}
专属 Worker 的通信则比共享 Worker 的更直观简洁。在主线程中可以如下发送和接收消息:
myWorker.postMessage('哈喽Worker!');
myWorker.onMessage = (e) => {
console.log('收到消息:', e.data);
};
在专属 Worker 中可以如下处理消息:
onmessage = (e) => {
postMessage('收到消息:"${e.data}"`);
};
此外,你可能还注意到一个区别是专属 Worker 与页面绑定在一起,因此页面关闭的话专属 Worker 也会被终止。
Service worker
Service worker 相当于是浏览器在网页和服务器通信中插入的一个“中间层”。因此它可以操作服务器请求,但由于安全和隐私性的考虑,浏览器对可以操作的范围有非常大的限制。
(图片来自Workers Overview)
不同于 Web worker,它有一个更复杂的生命周期:
- 在主线程中用
navigator.serviceWorker.register()
注册 Service worker,然后浏览器异步下载 worker 用的脚本; - 如果这个 Service worker 是新注册的,安装下载脚本。否则就更新已存在的脚本;
- 如果在这个 Service worker 声明管辖的范围内没有其他旧 worker 正在控制客户端,激活 worker;
- 如果脚本已有新版本替代,弃置旧脚本。
Service worker 拥有一些列API 及事件来做如下的事情:
- 缓存数据
- 拦截请求
- 管理浏览器通知
基于这些能力,我们可以做很多有意思的事情。容我待会再聊。
他们的限制
Web worker 和 Service worker 功能强大,但也有不少限制。其中包括:
无权访问 DOM、window 对象和其它一些可能涉及隐私的 API,如 localStorage。这带来的有点事 Worker 的运行环境与页面的运行环境各自独立,互不打扰。然而这也意味着如果你想让 Worker 帮忙干些 DOM 相关的费力活是很麻烦的。
主线程和 Worker 之间通信的信息是个副本。这个副本用了一个算法叫the structured clone algorithm。这算法有点类似于JSON.stringify()
再JSON.parse()
,有很多数据类型在克隆的过程中被抹掉。且不同于 JSON 的接口,这个算法会直接让不支持的数据 key 都消失,甚至有时候抛出异常。
仅提供对 ES 模块的有限支持。虽说现代浏览器已大部分支持直接运行 ES 模块,但在 Worker 里它还是试验中的状态,支持的浏览器也很少。
As of Mar 1, 2019, only Chrome 80+ supports this feature, while Firefox has an open feature request. No other browsers are known to have support for production usage of worker scripts written as modules.
在支持的浏览器中,你可以如下打开一个有 ES 模块支持的 Worker:
new Worker("worker.js", {
type: "module",
});
但含有关键字import
和export
的脚本并不能很好地工作,甚至会报错终止 Worker 的运行。
还有一个槽点是在 Worker 里打开一个新 Worker 这个在文档中有所提及的功能几乎没法用。有些第三方库比如esbuild-wasm
内置了打开 Worker 运行的逻辑,那由这些库提供的功能都无法在 Worker 中调用的话,组织代码起来比较费力,需要让主线程帮忙转发消息。
用 Worker 提升性能
分担重活
在前面的介绍中,我提到可以让 Worker 分担一些重活,这样能保证主线程不会因为跑这些费时的脚本失去响应而影响用户体验。
比如当我们不得已要在前端处理大量数据的时候,我们可以让 Worker 在后台运算不影响前台交互。我们只需要在主线程中将脚本交给 Worker,并等待 Worker 发送回处理完的数据。像这样:
function getData() {
const myWorker = new Worker("processData.js");
return new Promise((resolve, reject) => {
myWorker.onMessage = ({ data }) => {
resolve(data);
myWorker.terminate();
};
myWorker.onError = (e) => {
reject(e);
myWorker.terminate();
};
myWorker.postMessage({ sortBy: "update_time" });
});
}
getData().then((data) => console.log(data));
在processData.js
脚本中我们处理数据,然后将处理完的数据发送回主线程:
function process(rawData, options) {
const processedData = rawData;
// 省略处理数据代码一万字
return processedData;
}
onmessage = ({ data: options }) => {
fetch("/get_raw_data")
.then((res) => res.json())
.then((rawData) => process(rawData, options))
.then((data) => postMessage(data));
};
另一个经典的用法是在浏览器中压缩/解压缩文件。这有无数的第三方库支持,而且在库中已经内置了交托任务给 Worker 的逻辑。举栗像unzipit、js-untar……
预处理数据
Service worker 提供了一系列用于缓存数据、拦截浏览器请求的接口。这给予了我们预先拉取数据的能力。
那在主线程种,我们只需要注册 Service worker 的脚本如下:
if ("serviceWorker" in navigator) {
// 注意脚本的路径决定了它控制的范围,如果要控制全局,就放在根目录下
navigator.serviceWorker.register("/sw.js");
}
然后照旧获取数据,比如用fetch
什么的:
fetch("/get_data").then((res) => {
// 如果Service worker已经针对该请求缓存了数据,那这个promise会立即被resolve
});
在 Service worker 脚本sw.js
中,我们则需要预先拉取数据并缓存:
const preFetchUrls = ["/get_data"];
caches.open("prefetchedData").then((prefetchedData) => {
preFetchUrls.forEach((requestUrl) => {
fetch(requestUrl).then((res) => prefetchedData.put(requestUrl, res));
});
});
并且利用对应的事件如fetch
来拦截主线程发出的请求,返回我们预先缓存的数据:
addEventlistener("fetch", (e) => {
const { method, url } = e.request;
if (method !== "GET" || !preFetchUrls.includes(url)) return;
e.respondWith(async () => {
const prefetchedData = await caches.open("prefetchedData");
const cachedResponse = await cache.match(event.request);
return cachedResponse || fetch(event.request);
});
});
用 Workbox 简化做缓存的步骤
在上节用例中,我们利用 Service worker 缓存和请求拦截的能力预先加载了一些数据。这个能力同样也可以帮我们缓存文件,以便下次用户访问更加快速。对此,我推荐使用Workbox,它能大大简化这些缓存策略的设置。
在主线程种,我们同之前一样注册Service worker:
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
但在 Service worker 的脚本sw.js
中,我们需要引入 Workbox 库。这也意味着你的脚本需要打包工具打包。
Workbox 官网上的示例代码可以覆盖大部分的用例,照搬就可以了:
import { registerRoute } from "workbox-routing";
import {
NetworkFirst,
StaleWhileRevalidate,
CacheFirst,
} from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { ExpirationPlugin } from "workbox-expiration";
// 用网络优先的策略缓存页面(html) 。网络优先策略会先请求最新数据并缓存,请求出错再返回已缓存的数据。
registerRoute(
({ request }) => request.mode === "navigate",
new NetworkFirst({
cacheName: "pages",
plugins: [
// 这里设置只有当返回结果是200的才缓存
new CacheableResponsePlugin({
statuses: [200],
}),
],
})
);
// 用SWR策略缓存CSS、JS及Worker的脚本。SWR策略会优先返回已缓存的数据,同时在后台更新缓存。
registerRoute(
({ request }) =>
request.destination === "style" ||
request.destination === "script" ||
request.destination === "worker",
new StaleWhileRevalidate({
cacheName: "assets",
plugins: [
new CacheableResponsePlugin({
statuses: [200],
}),
],
})
);
// 用缓存优先策略缓存图片。缓存优先策略会优先返回已缓存的数据,只有在没找到对应缓存的情况下才会请求网络数据并缓存。
registerRoute(
({ request }) => request.destination === "image",
new CacheFirst({
cacheName: "images",
plugins: [
new CacheableResponsePlugin({
statuses: [200],
}),
// 这里额外限制了缓存的最大数量,和过期的时长。
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30天
}),
],
})
);
假如这些是你在Service worker中只需要以上这些逻辑,那你甚至可以不写脚本,直接加个打包工具的插件就好。
Webpack用户可以用Workbox Webpack plugin。
Rollup用户可以尝试rollup-plugin-workbox。
你可能还没想到的一些其他用途
脱离Node.js服务器做API mock
有了拦截请求的能力,我们就可以玩点有意思的像API mock什么的。最近流行的一个库MSW就是一个很好的栗子。
原理也很简单,基本上就是:
- 监听"fetch"事件;
- 检查拦截到的请求是否满足已设定的规则;
- 如果满足规则,用事先缓存好的数据响应请求。
与前面在"预请求数据"一节中提到的雷同。
用作数据处理层
另一个有趣的用法是将数据处理从我们的UI层中抽离。比如说当我们追踪应用使用情况时,可以让UI层将所有数据一股脑交给Worker处理。在Worker中我们可以根据要求筛选出有意义的数据,并转换成合理的格式再发送给收集数据的服务器。
这层隔离开了相对来说没有那么重要却会影响用户体验的数据处理,即使有异常也能保证不让应用崩溃。
后台同步
你是否有遇到过像上传大量文件或服务器太忙之类的卡顿?在很多情况下用户必须等待服务器操作完成才能离开当前页面。然而Service worker的存在可以帮我们跨越这一限制!
只要浏览器还开着,Service worker就可以继续在一个独立的线程上活动。因此,我们可以先将用户操作的数据缓存,再交由Service worker在后台与服务端保持通信。用户就可以去做别的事情而不必等待数据发送完成才能离开了。
还有更多等你发现
除了以上提到的之外,worker还有更多的可能性。比如有名的PWA、推送通知、沙箱等等。发挥你的创意,让我们把Web应用再推上一层楼吧!