如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。
这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。
这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢?
答案很简单:把这些耗时的计算任务,从主线程上挪开,交给一个新的线程去处理。主线程继续轻松地负责人机交互,计算任务在另一个线程里默默进行,算完了再把结果通知主线程。这样,页面不就不会卡顿了吗?
这,就是 Web Worker 诞生的核心思想。
Web Worker:把计算交给“后台”
Web Worker 是 W3C 和 WHATWG 制定的一项标准,允许我们在后台创建一个独立的线程来执行脚本。这个后台线程和主线程是完全隔离的,它们之间通过 postMessage
和 onmessage
API 来通信。
特点:
- 独立线程:Worker 运行在自己的线程里,不会阻塞主线程。
- 环境隔离:Worker 线程无法访问主线程的
window
、document
等 DOM 对象,也没有alert
、confirm
这类 UI 方法。它拥有一个独立的全局对象self
。 - 受限的 API:出于安全考虑,Worker 内部可以使用的 API 是有限的,但一些常用的 Web API,如
fetch
、XMLHttpRequest
、setTimeout
、indexedDB
等还是可以使用的。 - 同源策略:Worker 加载的脚本文件必须与主页面同源。
如何使用?
使用 Web Worker 非常直观。假设我们有一个耗时的阶乘求和计算。
main.js
(主线程)
1// 创建一个新的 Worker
2const worker = new Worker("worker.js");
3
4// 向 Worker 发送消息,开始计算
5console.log("主线程:开始计算...");
6worker.postMessage({ number: 40 });
7
8// 监听来自 Worker 的消息
9worker.onmessage = function (event) {
10 // event.data 是 Worker 返回的结果
11 console.log("主线程:收到计算结果 ->", event.data);
12};
13
14// 监听错误
15worker.onerror = function (error) {
16 console.error("主线程:Worker 发生错误 ->", error.message);
17};
worker.js
(Worker 线程)
1// 监听来自主线程的消息
2self.onmessage = function (event) {
3 console.log("Worker:收到计算任务 ->", event.data.number);
4
5 // 执行耗时计算
6 let result = 0;
7 for (let i = 0; i < 2000000000; i++) {
8 result += i; // 模拟一个耗时操作
9 }
10
11 // 将结果发送回主线程
12 self.postMessage(result);
13
14 // 计算完成后,可以关闭自己
15 self.close();
16};
通过这种方式,即使用户在等待计算结果时,依然可以流畅地与页面进行交互。Web Worker 就像是主线程雇佣的一个计算专家,脏活累活都交给他,自己则专注于“门面工作”。
Service Worker:网络请求的“代理”
Web Worker 解决了计算密集型任务的问题,但现代 Web 应用面临的另一个巨大挑战是网络。不稳定的网络环境(比如在地铁上)常常导致应用无法使用。我们希望能像原生 App 一样,即使在离线状态下,也能展示一些基本内容,或者在网络恢复后自动同步数据。
Service Worker 应运而生。
你可以把它理解为一个位于浏览器与网络之间的可编程代理服务器。它也是一个运行在后台的独立线程,但它的职责不是计算,而是拦截和处理网络请求。这赋予了它强大的能力,比如:
- 离线缓存:拦截页面的网络请求,如果发现网络断开,它可以从缓存中返回预先存储好的资源(HTML、CSS、JS、图片等),让应用具备离线访问能力。这就是 PWA (Progressive Web App) 的核心技术之一。
- 消息推送:即使在浏览器关闭的情况下,也能接收从服务器推送过来的消息,并在桌面弹出通知。
- 后台同步:在网络恢复时,自动将用户在离线期间产生的数据同步到服务器。
生命周期:
Service Worker 的生命周期比 Web Worker 复杂,主要包括三个阶段:install
(安装)、activate
(激活)和 fetch
(拦截请求)。
- 注册 (Register):在主线程中注册 Service Worker 文件。
- 安装 (Install):注册成功后,浏览器会下载并解析 Service Worker 脚本,触发
install
事件。这通常是我们缓存核心资源的最佳时机。 - 激活 (Activate):安装成功后,进入
activate
状态。这个事件通常用于清理旧版本的缓存。 - 空闲 (Idle) / 拦截 (Fetch):激活后,Service Worker 便会控制其作用域下的页面,监听
fetch
事件来拦截网络请求。为了节省资源,如果一段时间没有事件,浏览器可能会终止它,并在下次需要时再唤醒。
示例:一个简单的离线缓存
main.js
(主线程)
1if ("serviceWorker" in navigator) {
2 navigator.serviceWorker
3 .register("/sw.js")
4 .then((registration) => {
5 console.log("Service Worker 注册成功,作用域:", registration.scope);
6 })
7 .catch((error) => {
8 console.log("Service Worker 注册失败:", error);
9 });
10}
sw.js
(Service Worker 线程)
1const CACHE_NAME = "my-cache-v1";
2const urlsToCache = ["/", "/styles/main.css", "/script/main.js"];
3
4// 安装阶段,缓存核心资源
5self.addEventListener("install", (event) => {
6 event.waitUntil(
7 caches.open(CACHE_NAME).then((cache) => {
8 console.log("缓存已打开");
9 return cache.addAll(urlsToCache);
10 })
11 );
12});
13
14// 拦截网络请求
15self.addEventListener("fetch", (event) => {
16 event.respondWith(
17 caches.match(event.request).then((response) => {
18 // 如果在缓存中找到了匹配的资源,则返回它
19 if (response) {
20 return response;
21 }
22 // 否则,正常发起网络请求
23 return fetch(event.request);
24 })
25 );
26});
Service Worker 的出现,极大地模糊了 Web 应用和原生应用的界限,让 Web 的体验更加可靠和强大。
Worklet:更轻量、更底层的“插件”
Web Worker 和 Service Worker 功能强大,但它们也有自身的局限。它们是相对“重”的实现,与主线程通信是异步的,这意味着存在一定的延迟。在某些需要高频和低延迟的场景下,比如实时处理音频、在渲染流程中绘制动画,这种延迟是不可接受的。
为了解决这个问题,社区提出了 Worklet 的概念。
Worklet 是一种非常轻量、高度专用的 Worker。你可以把它想象成浏览器渲染管线上的一个“插件”或“钩子”(Hook),允许开发者将一小段 JavaScript 代码注入到浏览器渲染引擎的底层流程中。
核心特点:
- 轻量:它们的创建开销很小,生命周期也更短。
- 与主线程不在同一个事件循环:它们运行在渲染引擎的特定阶段,独立于主线程的事件循环,因此不会被主线程阻塞。
- 上下文受限:它的 API 限制比 Web Worker 更严格,通常只能访问其特定任务所需的 API。
- 同步执行:在某些场景下,它的执行是与渲染管线同步的,从而保证了低延迟。
目前,已经落地或正在标准化的 Worklet 主要有以下几种:
-
PaintWorklet (CSS Painting API):允许你用 JavaScript 来绘制 CSS
background-image
、border-image
等。当元素的样式需要重绘时,浏览器会调用你的 PaintWorklet 代码,你可以使用一个类似 Canvas 的 API 在指定的区域内进行绘制。 -
AnimationWorklet:允许你创建不依赖主线程、与设备刷新率同步的高性能动画。即使用户的主线程卡顿,这些动画依然能流畅运行。
-
AudioWorklet:在 Web Audio API 中,它允许开发者直接在音频处理管线中编写 JavaScript 代码来生成、处理或分析音频,实现更复杂的自定义音频效果,而不会因为主线程的延迟导致声音卡顿或爆音。
-
LayoutWorklet (CSS Layout API):这是一个实验性的 API,允许开发者用 JavaScript 自定义元素的布局方式,相当于用代码实现类似 Flexbox 或 Grid 的新布局模式。
示例:使用 PaintWorklet 创建一个波点背景
main.js
(主线程)
1// 注册 PaintWorklet
2if ("paintWorklet" in CSS) {
3 CSS.paintWorklet.addModule("houdini-checkerboard.js");
4}
style.css
1textarea {
2 background-image: paint(checkerboard);
3}
houdini-checkerboard.js
(PaintWorklet)
1// 定义一个名为 'checkerboard' 的绘制器
2registerPaint(
3 "checkerboard",
4 class {
5 paint(ctx, size) {
6 // ctx 是一个类似 Canvas 2D 的上下文
7 // size 包含了要绘制的区域的宽和高
8 ctx.fillStyle = "#f0f0f0";
9 ctx.fillRect(0, 0, size.width / 2, size.height / 2);
10 ctx.fillRect(
11 size.width / 2,
12 size.height / 2,
13 size.width / 2,
14 size.height / 2
15 );
16
17 ctx.fillStyle = "#ccc";
18 ctx.fillRect(size.width / 2, 0, size.width / 2, size.height / 2);
19 ctx.fillRect(0, size.height / 2, size.width / 2, size.height / 2);
20 }
21 }
22);
Worklet 将 Web 的可编程性带入了一个新的维度,它让我们有能力去干预和定制浏览器最底层的渲染行为,这是过去无法想象的。
总结
回顾一下今天我们认识的这些“工人们”:
- Web Worker:是计算工人。适合处理与 UI 无关的、密集的计算任务,避免主线程卡顿。
- Service Worker:是网络代理。负责拦截和处理网络请求,赋予 Web 应用离线能力和消息推送能力。
- Worklet:是渲染插件。它更轻量、更底层,用于在渲染管线的特定阶段执行高性能、低延迟的代码,如图形绘制、动画和音频处理。
现在,我们再回过头看“JS 是单线程的”这句话,就会有更深刻的理解。
主线程确实是单线程的,它依然遵循着事件循环的机制来处理任务。但浏览器这个平台,通过提供 Worker 和 Worklet 这些机制,为我们打开了通往多线程协作的大门。
它们就像一个分工明确的团队:主线程是“项目经理”,负责统筹全局、与用户打交道;Web Worker 是“数据分析师”,埋头处理复杂计算;Service Worker 是“后勤总管”,保障网络和离线资源;而 Worklet 们则是“美术和音效专家”,专注于优化最终的视听呈现。
正是有了这些“工人”的协同工作,我们才能在小小的浏览器窗口中,构建出越来越复杂、体验越来越接近原生应用的 Web 世界。