首页 / 文章

保持页面响应性:何时将CPU任务移至Web Worker中处理

了解为何复杂的循环会导致浏览器界面卡顿,Web Worker消息机制如何分担这些工作负载,以及如何判断某项任务是否真的需要使用Worker。

发布者:REACTAPP.TOP阅读约 12 分钟2703 词

来源: JavaScript Workers: How to Run Code Without Freezing Your UI

用户按下按钮后,标签会变为“正在处理…”,随后便没有任何反应:动画停止,滚动功能失效,后续点击也被忽略。其实标签页并未崩溃,只是有段 JavaScript 代码占用了主线程。本指南将解释这种现象产生的原因,展示将耗时较长的计算任务转移到其他线程的基本 Web Worker 实现方式,并提供实用方法帮助你判断何时使用 Worker 能解决问题,何时只会增加复杂性。

为何单个耗时任务会导致一切冻结

浏览器整体功能远不止执行脚本,但页面中的 JavaScript 是在一条主线程上运行的。若想了解这条线程如何融入浏览器的多进程设计,可参阅JavaScript 的单线程与浏览器的多进程引擎。其实际后果很简单:这条线程可能成为瓶颈。

以一个计算十亿以下所有整数之和的函数为例,它本身没有任何问题,引擎也会顺利地将其执行完毕:

function calculate() {
  let total = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
      total += i;
    }
    return total;
  }
calculate();

问题在于此期间会发生什么。只要循环仍在运行,主线程就无法处理其他依赖它的任务。点击事件处理、输入处理以及屏幕内容更新的大部分工作都得排队等待同一线程的处理。正是这种排队现象让用户感觉界面卡住了。

事件流程如下:

User clicks button
       ↓
JavaScript starts heavy calculation
       ↓
Main thread stays busy
       ↓
UI work has to wait
       ↓
Page feels frozen

这里并没有什么问题。只是你的代码让线程占用时间过长,而浏览器出于公平考虑让它完成工作。这也解释了为什么在开发环境中看似正常的代码,在生产环境中会变成严重的性能问题:函数本身没有变化,但需要处理的数据集或计算量却增加了。

为繁重任务提供独立的执行上下文

Web Worker是一种独立的JavaScript执行环境,与页面的主脚本分开运行。无需将所有任务都放在同一线程上:

MAIN THREAD
     │
     ├── UI
     ├── Events
     ├── JavaScript
     └── Heavy calculation  ← problem

可以通过划分职责,让页面负责处理UI、用户输入及页面逻辑,而Worker则承担耗时的计算任务:

MAIN THREAD                  WORKER
     │                         │
     ├── UI                    ├── Heavy calculation
     ├── User input            ├── Data processing
     └── Page logic            └── Result
             │                  │
             └── messages ─────┘

该图中标示出两者之间联系的线条是关键所在。Worker不会共享页面中的变量或对象,双方通过消息进行交互,这一限制决定了使用Worker时的所有操作方式。

逐步创建第一个Worker

这是之前从主线程中移出的求和循环。页面脚本从单独的文件中创建Worker,将其所需的上限值发送过去,并为接收结果注册处理函数:

const worker = new Worker("worker.js");

worker.postMessage(1_000_000_000);

worker.onmessage = (event) => {
  console.log("Result:", event.data);
};

Worker 文件会监听传入的消息,使用分配给它的数字执行循环运算,然后将最终结果发回:

self.onmessage = (event) => {
  let total = 0;
  for (let i = 0; i < event.data; i++) {
    total += i;
  }
  self.postMessage(total);
};

逐次跟踪每次调用的往返过程有助于理解流程。页面通过 postMessage 将输入数据传递给 Worker:

worker.postMessage(...)

在 Worker 内部,self 指的是 Worker 的全局作用域,而输入数据正是通过其 onmessage 处理函数传入的:

self.onmessage = (...)

计算完成后,Worker 会通过自身的 postMessage 发送回复:

self.postMessage(...)

最后,页面中针对 Worker 对象的处理器会通过 event.data 接收该回复:

worker.onmessage = (...)

请注意,在页面脚本中先调用 postMessage 再赋值 onmessage 是没有问题的:回复只能以异步方式在当前脚本执行完毕之后到达,因此处理函数总能及时就位。

哪些会改变,哪些不会

一个便于理解的方式是委托机制。主线程实际上是在说“数据在这里,你来处理”,工作线程则负责执行任务并在完成后反馈。如果没有工作线程,流程将是严格的顺序执行,UI也会随之等待:

Main thread
     ↓
Heavy calculation
     ↓
UI waits

有了工作线程后,主线程会将任务交出去,继续处理页面内容,而计算则会在后台并行进行:

Main thread ──→ Worker
     │             │
     │             └── Heavy calculation
     │
     └── continues handling the page

要明确自己能获得什么好处。将任务放到 Worker 中通常并不会让该任务更快完成。循环的执行时间大致与之前相同,而消息的往返传输还会增加少量开销。其优势在于计算不再与主线程争夺时间,因此任务运行时页面仍能保持交互性。

与 Worker 通信:使用消息而非 DOM

最需要转变观念的一点是 Worker 无法访问页面内容。Worker 的全局作用域中不存在 document,因此类似这样的调用会在那里失败:

document.querySelector("#result");

所有跨边界传递的数据都必须通过消息来实现。你可以用以下方式发送数据:

worker.postMessage(data);

并用以下方式接收返回的数据:

worker.onmessage = (event) => {
  console.log(event.data);
};

这种限制实际上反而成了一种优势。由于 Worker 只能接收输入并输出结果,因此两侧之间的数据流始终是明确的:

Main Thread
     │
     │ postMessage()
     ▼
  Worker
     │
     │ postMessage()
     ▼
Main Thread

对于大多数应用而言,“负责渲染的部分”与“负责计算的部分”之间以消息形式存在的明确边界正是人们所期望的设计。Worker 从不直接操作 DOM,页面则负责决定如何处理每个计算结果。

重构阻塞式的点击处理程序

典型的起点是一个会同步处理大量数据集并立即渲染结果的点击处理程序:

button.addEventListener("click", () => {
  const result = processHugeDataset(data);
  renderResult(result);
});

如果 processHugeDataset() 的执行时间较长,主线程就会一直被占用,页面在它返回之前将无法响应。

基于 Worker 的版本将处理逻辑分为两部分。点击操作仅负责发送数据,而渲染则会在结果消息到达时再进行:

const worker = new Worker("worker.js");

button.addEventListener("click", () => {
  worker.postMessage(data);
});

worker.onmessage = (event) => {
  renderResult(event.data);
};

复杂的处理工作现在在 Worker 内部完成,主线程则可在等待期间继续处理页面内容。采用这种架构时需要注意两点:首先,Worker 只会在处理逻辑之外创建一次,因此多次点击会复用同一个 Worker,而不会每次都生成新的实例;其次,如果用户快速多次点击,结果可能在用户继续操作之后才送达;在实际代码中,通常会给每条消息添加编号,或忽略过时的回复,以避免旧结果覆盖新结果。

确定 Worker 中应包含的内容

人们很容易过于热衷,试图将所有功能都放到 Worker 中去实现。但这通常并非明智之举。只有当任务计算量极大,需要与主线程争夺处理时间时,使用 Worker 才有意义。典型的应用场景包括:

  • 处理或汇总大型数据集
  • 复杂的数值或算法计算
  • 大规模 JSON 数据转换
  • 解析大文件
  • 图像处理
  • 音频或视频处理
  • 压缩及其他数据转换操作
  • 某些加密或数学运算
  • 代码高亮显示等耗资源的文本处理

这些场景的共同特点都是:需要大量计算,且几乎不需要直接操作 DOM。

何时使用 Worker 是过度设计

对于像两数相乘这样简单的事情,使用 Worker 只会带来额外的开销:

const total = price * quantity;

创建 Worker 需要一定的启动成本,每条消息的传输都会产生通信开销,而且还会多出一个具有独立生命周期的文件。对于小型操作,使用主线程上的普通 JavaScript 是更好的选择。不要去考虑是否“可以”使用 Worker,而应思考这项任务是否足够繁重,以至于将其留在主线程上会真正影响用户体验。通过这个标准就能排除大多数只会增加复杂度的使用 Worker 的情况。

架构层面的好处:清晰的边界

Worker 不仅是提升性能的工具,还能实现职责分离。你的 UI 代码可以专注于将输入转换为状态,再将状态呈现为界面:

Input
↓
State
↓
Display

而 Worker 的职责则是将输入数据转换为输出数据:

Input data
↓
Processing
↓
Output data

通过这种方式将耗时的处理过程隔离开来,可以更方便地进行测试和理解,因为 Worker 在消息接口背后表现得就像一个纯函数。不过这也存在一定的权衡——现在你必须管理基于消息的异步流程、Worker 的启动与停止机制、故障的出现方式以及数据在边界之间的传输方式。当任务较为繁重时,这是合理的代价。优秀的工程实践并非要消除所有复杂性,而是要将复杂性放在能够产生价值的地方。

专用 Worker、共享 Worker 与服务 Worker 是不同的工具

“Web Worker”常被误认为是指某个单一的 API,但实际上存在多个与 Worker 相关的 API,它们各自承担不同的功能:

  • 专用 Worker:代表某个特定页面或上下文运行 JavaScript。本指南中使用的即为此类型。
  • 共享 Worker:一个单一的 Worker,多个浏览上下文(例如同一源的多个标签页)可以连接到它并通过它进行通信。
  • 服务 Worker:位于页面与网络之间,可实现请求拦截、缓存及离线支持等功能。

若要处理占用 CPU 资源的任务,首先需要了解专用 Worker。服务 Worker 并不能直接替代它,二者解决的是不同的问题。如需更深入地比较这三种类型,请参阅专用 Worker、共享 Worker与服务 Worker:选择合适的浏览器线程。

生产环境中的关键细节

这个最小示例刻意设计得较为简单。实际应用在处理故障、清理工作以及控制数据大小方面需要更加谨慎。

监听错误

Worker 内部未捕获的异常会以 error 事件的形式显示在页面上。应为其添加处理程序,以便将故障记录下来,而不会让用户界面无限期地等待结果:

worker.onerror = (error) => {
  console.error("Worker error:", error);
};

终止不再需要的 Worker

Worker 会一直运行直到被停止。如果某个 Worker 只用于执行一次性任务,那么在任务完成后应从页面上将其关闭:

worker.terminate();

这一点对生命周期短暂的 Worker 至关重要;而负责为整个应用提供服务的长期运行的 Worker 则可以继续存在。

数据传输并非免费

每条消息都涉及在不同上下文之间传输数据。对于大多数 JavaScript 值,浏览器会使用结构化克隆算法,在接收端生成副本。而对于一些较大的二进制数据,比如 ArrayBuffer 实例,可以将其作为可传输对象传递,这样无需复制即可将所有权交给对方;发送方之后便不能再使用该缓冲区了。你不必立即了解所有细节,但需记住一条规则:将计算任务从主线程中移开并不意味着传输数据到主线程是免费的。克隆一个非常大的对象本身就会在主线程上耗费相当多的时间。

Worker 与 React

React 并不会改变浏览器调度 JavaScript 的方式。如果某个 React 事件处理程序在更新状态之前执行了大量的同步计算,那么这些计算仍会在页面的主执行上下文中运行,与其他脚本并无不同:

function handleClick() {
  const result = expensiveCalculation();
  setResult(result);
}

没有任何框架能够让耗 CPU 资源的 JavaScript 代码消失。你可以做的是将 Worker 视为组件背后的计算层:UI 将数据发送出去,Worker 对其进行处理,处理结果再返回来存储在状态中并进行渲染:

React UI
   │
   │ send data
   ▼
Web Worker
   │
   │ process data
   ▼
React UI

在用户需要处理大型数据集、编辑器、可视化界面或其他计算密集型视图的界面中,这种方式尤其有用。框架本身并非关键,重要的是要清楚是哪条线程在执行耗时操作。在组件中,这通常意味着在效应中创建 Worker,通过其消息处理程序调用setResult,并在效应的清理阶段终止该 Worker,以避免已卸载的组件仍让 Worker 继续运行。

Worker 并不能普遍提升性能

最常见的错误是将 Worker 当作通用的性能解决方案。如果应用程序因不必要的重新渲染、过多的网络请求、过大的资源文件、低效的 React 更新或混乱的代码而变慢,Worker 并无法解决这些问题。应首先使用浏览器的性能分析工具找出真正的瓶颈,然后再判断是否适合使用 Worker。

以下简短的检查清单会有所帮助:

  1. 该任务确实需要大量 CPU 资源吗?
  2. 它的运行时间是否长到会影响响应速度?
  3. 它需要直接访问 DOM 吗?
  4. 它的输入和输出能否以消息的形式传递?
  5. 额外的复杂性是否值得?

当答案指向“需要大量资源、运行时间长、无需访问 DOM、适合通过消息传递、值得增加复杂性”这些方向时,Worker 很可能是个合适的选择。

这也有助于区分两种容易混淆的问题。第一种是JavaScript执行的操作超出了必要范围,解决方法是通过更优的算法或减少整体工作量来解决。第二种则是某些操作本身确实耗时,但没有理由因此阻塞界面;这时Worker就能发挥作用。Worker并不能将低效的代码变成高效的代码,它只是为这些耗时的代码提供了另一个运行环境。

关键要点

你无需记住整个Worker API。核心概念可以用一张图概括:

MAIN THREAD
     │
     │ "Here is the work."
     ▼
  WORKER
     │
     │ "Here is the result."
     ▼
MAIN THREAD
  • 主线程掌控着页面;任何在主线程上执行的长时间同步任务都会延迟输入处理和屏幕更新。
  • Dedicated Worker运行独立的JavaScript代码,仅通过postMessage和onmessage进行通信,且无法访问DOM。
  • 将任务卸载到后台可以保持用户界面的响应速度,但通常不会让计算本身更快,而且传输大量数据仍然会有成本。
  • 处理onerror事件,对临时Worker调用terminate()方法,对于较大的二进制数据则考虑使用可传输对象。
  • 首先进行性能分析。如果计算过程存在浪费,再优化算法;如果任务虽有必要但不应在主线程上执行,则使用Worker。
  • 下次页面在处理大型计算时卡住时,不仅要考虑如何让函数运行得更快,还要思考它是否真的需要在主线程上执行。