Web Worker 实战:用多线程解决主线程阻塞问题

为什么你的页面会“卡死”?

先想象一个场景:用户点击按钮触发一个计算量很大的任务,比如解析一个 10MB 的 CSV 文件、对图片做像素级滤镜、或者跑一段复杂的加密算法。你写下了这样的代码:

button.addEventListener('click', () => {
  const result = heavyComputation(data); // 耗时 3 秒
  render(result);
});

点击之后,页面立刻失去响应——按钮按不动、滚动卡顿、动画冻结。浏览器甚至可能弹出“页面无响应”的提示。原因很简单:JavaScript 是单线程的。主线程既要执行 JS,又要负责样式计算、布局、绘制和事件响应。当一段同步代码长时间占用主线程时,其他所有事情都得排队等待。

解决这个问题的标准方案就是 Web Worker。它允许你在后台线程中运行脚本,与主线程并行工作,互不阻塞。

Web Worker 的核心限制

在动手之前,先明确 Worker 的边界:

  • 不能直接操作 DOM。Worker 运行在独立上下文中,没有 windowdocumentparent 等对象。
  • 不能使用部分 Web API,比如 alert()localStorage(同步版本)。
  • 同源限制。Worker 脚本必须与主页面同源,除非使用 Blob URL 或 data: URL 创建内联 Worker。
  • 通信靠消息传递。主线程和 Worker 之间通过 postMessageonmessage 交换数据,数据会被结构化克隆(Structured Clone),而不是共享内存(除非使用 SharedArrayBuffer)。

这些限制听起来很多,但换个角度看:Worker 本来就不该碰 UI,它只负责“算”,算完把结果丢回主线程,由主线程更新界面。职责清晰反而更安全。

实战一:把耗时计算搬进 Worker

假设我们有一个函数 calculatePrimes(limit),用来找出某个范围内的所有质数。当 limit 很大时,主线程会卡住。

主线程代码(main.js):

const worker = new Worker('prime-worker.js');

document.querySelector('#start').addEventListener('click', () => {
  const limit = 5_000_000;
  worker.postMessage({ command: 'calculate', limit });
});

worker.onmessage = (event) => {
  const { primes, duration } = event.data;
  document.querySelector('#result').textContent =
    `找到 ${primes.length} 个质数,耗时 ${duration}ms`;
};

Worker 代码(prime-worker.js):

function calculatePrimes(limit) {
  const primes = [];
  for (let i = 2; i <= limit; i++) {
    let isPrime = true;
    for (let j = 2; j * j <= i; j++) {
      if (i % j === 0) { isPrime = false; break; }
    }
    if (isPrime) primes.push(i);
  }
  return primes;
}

self.onmessage = (event) => {
  const { command, limit } = event.data;
  if (command === 'calculate') {
    const start = performance.now();
    const primes = calculatePrimes(limit);
    const duration = performance.now() - start;
    self.postMessage({ primes, duration });
  }
};

现在点击按钮,主线程依然可以响应滚动、点击和其他交互。计算完成后,Worker 把结果发回来,主线程只做一次轻量的 DOM 更新。

实战二:处理大文件而不冻结界面

另一个典型场景是文件解析。用户上传一个大型 JSON 或 CSV 文件,如果直接在主线程 JSON.parse 或逐行处理,页面会僵住几秒甚至更久。

主线程:

const fileInput = document.querySelector('#file');
const worker = new Worker('file-worker.js');

fileInput.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const text = await file.text(); // 读取文件本身是异步的
  worker.postMessage({ text });
});

worker.onmessage = (e) => {
  const { rows, summary } = e.data;
  renderTable(rows);
  document.querySelector('#summary').textContent = summary;
};

Worker:

self.onmessage = (e) => {
  const { text } = e.data;
  const lines = text.split('\n');
  const rows = lines.slice(1).map(line => {
    const [id, name, value] = line.split(',');
    return { id: Number(id), name, value: Number(value) };
  });
  const total = rows.reduce((sum, r) => sum + r.value, 0);
  self.postMessage({
    rows,
    summary: `共 ${rows.length} 行,总值 ${total}`
  });
};

注意:file.text() 返回 Promise,不会阻塞主线程。真正耗时的解析和计算被完全移到了 Worker 中。

进阶技巧:Worker 池与可转移对象

Worker 池

如果频繁创建和销毁 Worker,开销会累积。更好的做法是维护一个 Worker 池,复用固定数量的 Worker 来处理任务队列。简单实现思路:

class WorkerPool {
  constructor(size, script) {
    this.workers = Array.from({ length: size }, () => new Worker(script));
    this.idle = [...this.workers];
    this.queue = [];
  }
  run(data) {
    return new Promise((resolve) => {
      const task = { data, resolve };
      const worker = this.idle.pop();
      if (worker) this._execute(worker, task);
      else this.queue.push(task);
    });
  }
  _execute(worker, task) {
    worker.onmessage = (e) => {
      task.resolve(e.data);
      const next = this.queue.shift();
      if (next) this._execute(worker, next);
      else this.idle.push(worker);
    };
    worker.postMessage(task.data);
  }
}

可转移对象(Transferable Objects)

默认情况下,postMessage 会结构化克隆数据,大数组或 ArrayBuffer 的拷贝成本很高。如果数据不再需要留在发送方,可以用转移的方式零拷贝移交:

// 主线程
const buffer = new ArrayBuffer(1024 * 1024 * 50); // 50MB
worker.postMessage({ buffer }, [buffer]);
// 此后主线程的 buffer 变为不可用(byteLength 为 0)

在 Worker 中接收后,这块内存就归 Worker 所有。处理完再转移回来即可。对于图像处理、音视频分析等场景,这能显著降低内存占用和延迟。

什么时候不该用 Web Worker?

Web Worker 不是银弹。以下情况不建议使用:

  • 任务非常轻量(几毫秒内完成)。创建 Worker 和消息传递的开销可能比直接执行还大。
  • 需要频繁访问 DOM。Worker 无法操作 DOM,来回通信反而增加复杂度。
  • 需要共享复杂状态。Worker 之间不共享内存(除非用 SharedArrayBuffer),状态同步需要额外设计。

一个实用的判断标准:如果任务耗时超过 50ms,就值得考虑放进 Worker。

总结

Web Worker 的本质是把“计算”和“渲染”分离到不同线程。主线程专注于用户交互和界面更新,Worker 专注于纯计算任务。通过 postMessage 通信、可转移对象优化、Worker 池复用,你可以构建出流畅且响应迅速的前端应用。

下次遇到页面卡顿,先问自己:这段代码真的必须跑在主线程上吗?如果答案是否定的,就把它交给 Worker。

未经允许不得转载:任鹏个人博客 » Web Worker 实战:用多线程解决主线程阻塞问题

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏