JS 计算卡死后,我用 Web Workers 让页面丝滑如初(附图片压缩实战)

91 阅读14分钟

前端开发者们,是时候直面JavaScript的单线程困境了!本文将带你深入探索Web Workers的魔法世界,让你的应用告别卡顿,拥抱流畅。

为什么 JS 单线程会 “卡死人”?底层逻辑藏在浏览器里

很多人说 “JS 是单线程”,但很少有人说清楚:JS 单线程不是语言设计的问题,而是浏览器的 “任务分配规则” 导致的

浏览器的主线程(Main Thread)是个 “全能打工人”,要同时干两件核心活:

  1. 处理 JS 执行:比如循环、计算、函数调用;
  2. 处理页面渲染:比如解析 HTML/CSS、绘制 DOM、响应滚动 / 点击。

这两件事共用一个 “任务队列”—— 主线程同一时间只能干一件事。如果 JS 执行占了太久(比如处理大图片的像素计算),渲染任务就会被 “堵在后面”。用户看到的就是:页面不动、按钮没反应,这就是 “卡顿” 的本质。

举个例子

如果在主线程写一段循环 100 万次的代码,执行要花 2 秒。这 2 秒里,浏览器连 “重绘页面” 的时间都没有 —— 哪怕用户疯狂滚动鼠标,页面也只会在 2 秒后 “跳一下”,因为渲染任务被 JS 计算堵死了。

这时候你可能会问:既然单线程这么坑,为什么不直接让 JS 支持多线程?

答案很简单:为了避免 “竞态条件” 。比如两个线程同时修改 DOM—— 线程 A 要把按钮改成红色,线程 B 要把按钮改成蓝色,最后按钮该是什么颜色?这种 “同时操作共享资源” 的问题,会让页面状态混乱。浏览器为了简化开发,干脆让 JS 主线程垄断 DOM 操作,把复杂的计算任务,交给另一个 “帮手”——Web Workers。

Web Workers:不是 JS 多线程,而是浏览器给的 “外挂线程”

先明确一个核心认知:Web Workers 没有让 JS 变成多线程,而是浏览器为 JS 主线程额外开了 “辅助线程”

你可以把浏览器想象成一个公司:

  • 主线程是 “前台客服”:负责接待用户(响应点击、滚动)、整理店面(渲染页面),不能离开岗位干重活;
  • Worker 线程是 “后台技术工”:不直接接触用户,专门干重活(比如计算、转码),干完活再把结果告诉前台。

Web Worker 是 HTML5 的一部分,是浏览器为了提高 JavaScript 应用性能而引入的一项重要特性。简单来说,Web Worker 允许你在后台运行 JavaScript 代码,而不影响主线程运行。这个“主线程”就是你浏览器中 UI 渲染、DOM 操作的核心线程。

1. Worker 线程的底层 “生存规则”:隔离性是关键

浏览器给 Worker 线程划了严格的 “禁区”,这些限制都是为了保证主线程安全,避免混乱:

  • ❌ 不能访问 DOM/BOM:没有documentwindowalert(),甚至不能用console.log(document.getElementById('box'))(你代码里试了会报错,就是这个原因);
  • ❌ 不能访问主线程变量:Worker 线程和主线程内存不共享,不能直接用主线程的变量、函数;
  • ✅ 能访问部分 JS API:比如fetchPromiseFileReaderOffscreenCanvas(这些都是无 DOM 依赖的 API,适合计算);
  • ✅ 有自己的全局对象:Worker 里的this指向WorkerGlobalScope,不是window,所以要用self.onmessage而不是window.onmessage

为什么要这么限制?因为 DOM 是 “共享资源”,如果 Worker 能改 DOM,就会和主线程抢着改,导致页面状态错乱。比如主线程刚把图片插入 DOM,Worker 又把它删掉,用户看到的就是 “闪一下消失” 的 bug。

Worker 线程的底层运作

Worker 是如何启动的?

你知道,所有 Web Worker 的任务,都源于一个简单的命令:

const worker = new Worker('./worker.js');

这行代码一执行,浏览器就会创建一个新的线程来运行你指定的 worker.js 文件。换句话说,这个文件就是“后台工作”的代码。

通信机制:消息(Message)机制

Worker 和主线程之间不能直接访问彼此的变量和函数,它们之间的交互只能通过“消息通道”来完成。

通信方式有以下两种:

📢 1. postMessage():发送数据

在主线程中,你可以使用 postMessage() 向 Worker 发送数据。这些数据可以是字符串、数字、对象、甚至 Blob。

worker.postMessage({ imgData: 'data:image/jpeg;base64,...', quality: 0.3 });

📢 2. onmessage():接收数据

在 Worker 代码中,你可以使用 self.onmessage 来监听主线程发送的消息。

self.onmessage = function(e) {
    console.log(e); // 获取消息内容
    self.postMessage('hello from worker'); // 向主线程返回数据
}

注意:self 在 Worker 中指的是 worker 所处的线程环境,过程和全局对象 window 类似,但 🚫 不能访问 DOM,也不用担心和主线程冲突。

线程之间的通信是单向的还是双向的?

  • 线程之间是双向通信的,主线程可以发送消息给 Worker,Worker 也可以回传消息给主线程。
  • 但是,Worker 不运行在浏览器主线程的环境里,所以它们不能操作 DOM 或浏览器 API,也不能访问 window

线程间通信:不是 “共享数据”,而是 “消息克隆”

既然 Worker 和主线程内存不共享,那怎么传递数据?靠的是结构化克隆算法(Structured Cloning Algorithm)  —— 简单说就是 “复制粘贴”,不是 “共享文件”。

比如你在主线程写:

// 主线程
const worker = new Worker('./worker.js');
worker.postMessage('hello from main'); // 发送消息

Worker 线程接收:

// Worker线程
self.onmessage = function(e) {
  console.log(e.data); // 输出"hello from main"——这是复制过来的字符串
  self.postMessage('hello from worker'); // 再复制一份消息发回去
}

这个过程里,“hello from main” 不是直接传给 Worker,而是浏览器把它 “克隆” 一份,再交给 Worker。主线程的原字符串没变,Worker 拿到的是副本 —— 这样就不会出现 “两个线程改同一个变量” 的问题。

注意:不是所有数据都能克隆!比如functionSymbol、循环引用的对象(a.b = a),克隆时会报错。所以传递给 Worker 的数据,最好是简单类型(字符串、数字)或可克隆对象(BlobArrayBuffer、Base64)。

实战:用 Web Workers 实现 “不卡页面的图片压缩”

光说理论不够,咱们用 Web Workers 做个实用功能:图片压缩。之前在主线程压缩大图片会卡死,现在用 Worker 线程处理,看看效果。

1. 整体流程

先理清楚整个逻辑,分 “主线程” 和 “Worker 线程” 分工:

  1. 主线程

    • 接收用户上传的图片文件;
    • 把图片转成 DataURL(方便传递);
    • 给 Worker 线程发 “图片数据 + 压缩质量”;
    • 接收 Worker 回传的 “压缩后图片”,渲染到页面。
  2. Worker 线程

    • 接收主线程的图片 DataURL;
    • 把 DataURL 转成位图(方便处理);
    • 用 OffscreenCanvas(Worker 专用画布)压缩图片;
    • 把压缩后的图片转成 DataURL,回传给主线程。

1. 主线程(main.js):只做 “跑腿活”,不碰 “重计算”

主线程的职责很明确:接收用户操作、传递数据、展示结果 —— 绝不碰图片转码、压缩这些重活。

第一步:监听文件选择事件(input change)

const oFile = document.getElementById('fileInput');
oFile.addEventListener('change', async function (e) {
  const file = e.target.files[0]; // 获取用户选择的文件
  if (!file) return;
  await compressFile(file); // 触发压缩逻辑
});
  • 底层细节:e.target.files[0]File对象,它是Blob(二进制大对象)的子类,存储了图片的二进制数据。但 Worker 不能直接访问 DOM 里的File对象(因为 Worker 没 DOM 权限),所以必须把它转成 “可传递的数据”。

第二步:用 FileReader 把 File 转成 Base64

function handleFile(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result); // reader.result是Base64字符串
    reader.readAsDataURL(file); // 把File转成Base64
  })
}

async function compressFile(file) {
  const imgDataUrl = await handleFile(file); // 拿到Base64
  worker.postMessage({ imgData: imgDataUrl, quality: 0.3 }); // 传给Worker
}
  • 为什么要转 Base64?
    因为File是 DOM 相关对象,Worker 不能直接用;而 Base64 是 “文本格式的二进制数据”,可以通过postMessage传递(结构化克隆算法支持字符串)。
    有人会问:直接传Blob行不行?理论上可以,但实际开发中,Base64 更方便后续在 Worker 里用fetch转成Blob(后面会讲)。

第三步:监听 Worker 的消息,展示压缩结果

const worker = new Worker('./compressWorker.js'); // 创建Worker
worker.onmessage = function (e) {
  if (e.data.success) {
    // 拿到压缩后的Base64,插入页面
    document.getElementById('output').innerHTML = `<img src="${e.data.data}"/>`;
  } else {
    alert('压缩失败:' + e.data.data); // 处理错误
  }
}
  • 底层细节:Worker 传回来的e.data.data是压缩后的 Base64,直接给imgsrc属性就能显示 —— 因为浏览器支持用 Base64 作为图片源(data:image/jpeg;base64,...)。

2. Worker 线程(compressWorker.js):专注 “重计算”,全程不碰 DOM

Worker 线程的核心是 “处理图片压缩的全流程计算”,每一步都是为了减少主线程负担。

第一步:接收主线程的消息,解析参数

self.onmessage = async function (e) {
  const { imgData, quality = 0.8 } = e.data; // imgData是Base64,quality是压缩质量
  try {
    // 压缩逻辑全在这里
  } catch (err) {
    // 错误捕获:Worker里的错误不会影响主线程,必须主动传回去
    self.postMessage({ success: false, data: err.message });
  }
}
  • 为什么用try-catch
    如果压缩过程中报错(比如图片格式不支持、Base64 无效),Worker 线程不会崩溃,但错误不会传到主线程 —— 必须在catch里用postMessage把错误信息发回去,主线程才能提示用户。

第二步:Base64 转 Blob,再转 ImageBitmap

// 1. Base64 -> Blob:fetch能把Base64转成Blob
const blob = await (await fetch(imgData)).blob();
// 2. Blob -> ImageBitmap:解码图片数据,准备绘图
const bitmap = await createImageBitmap(blob);
  • 这两步是底层关键,缺一不可:

    • 为什么用fetch(imgData)
      Base64 是文本,createImageBitmap需要 “二进制图片数据”(BlobImageData),所以用fetch把 Base64 转成Blobfetch支持data:协议的 URL)。
    • 为什么用createImageBitmap,不用new Image()
      new Image()需要挂载到 DOM 才能解码图片,而 Worker 里没有 DOM;createImageBitmap是浏览器提供的 “离线解码 API”,直接处理Blob,速度比new Image()快 30%+,专门为 Worker 设计。

第三步:用 OffscreenCanvas 绘制并压缩图片

// 1. 创建离线Canvas:Worker里不能用普通Canvas(需要DOM),OffscreenCanvas脱离DOM
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
// 2. 获取2D绘图上下文:和普通Canvas的ctx用法一样
const ctx = canvas.getContext('2d');
// 3. 把ImageBitmap画到Canvas上:准备压缩
ctx.drawImage(bitmap, 0, 0);
// 4. 压缩Canvas为Blob:核心压缩步骤
const compressedBlob = await canvas.convertToBlob({
  type: 'image/jpeg', // 压缩格式:JPG比PNG小,适合照片
  quality: quality // 压缩质量:0-1,越小文件越小,画质越差
});
  • 这里藏了两个底层知识点:

    1. OffscreenCanvas 的作用:它是 HTML5 新增的 “无 DOM Canvas”,可以在 Worker 里使用,专门用来做图像计算。如果没有它,Worker 里根本没法处理图片绘制,压缩就无从谈起。
    2. convertToBlob 的压缩原理:它通过 “丢弃图片的部分像素信息” 来减小体积(比如 JPG 会合并相似像素)。质量 0.3 意味着保留 30% 的原始信息,文件体积能缩小到原来的 1/5~1/10,且肉眼看不出明显模糊。

第四步:压缩后的 Blob 转 Base64,传回首线线程

const reader = new FileReader();
reader.onloadend = () => {
  // 把Blob转成Base64,发回主线程
  self.postMessage({ success: true, data: reader.result });
}
reader.readAsDataURL(compressedBlob); // Blob -> Base64
  • 为什么还要转 Base64?
    因为主线程需要把压缩后的图片显示在img标签里,而imgsrc不支持直接用Blob(需要转成URL.createObjectURL(blob),但会占用内存,不如 Base64 方便)。所以转成 Base64,直接插入src即可。

OIP-C.jpg image.png

3. 为什么这案例必须用 Worker?对比主线程压缩的差距

如果把 Worker 里的压缩逻辑放到主线程,会发生什么?
假设用户上传一张 10MB 的图片,主线程要做:File→Base64→Blob→ImageBitmap→Canvas 绘制→压缩 Blob→Base64,整个过程要处理几百万个像素,耗时 35 秒。这 35 秒里,用户点击按钮没反应、滚动页面不动,甚至浏览器会弹出 “页面无响应,是否关闭?”—— 用户体验直接崩盘。

而用 Worker 后,主线程只做 “接收文件→传数据→显示图片”,耗时不到 100ms,用户完全感觉不到卡顿,甚至可以在压缩时继续滚动页面、点击其他按钮。这就是 Web Workers 的核心价值:把计算密集型任务从主线程剥离,让主线程专注于用户交互和渲染

Web Workers 的进阶底层:那些你可能踩坑的细节

学会了基础用法,还要知道底层的 “坑”—— 这些细节决定了你的代码能不能在生产环境用。

1. Worker 的创建开销:别频繁 new Worker

创建一个 Worker,浏览器要做三件事:

  1. 加载 Worker 脚本(比如compressWorker.js);
  2. 创建一个新的线程;
  3. 初始化 WorkerGlobalScope 环境。

这个过程大约要消耗 10~50ms,虽然不长,但如果每次压缩图片都new Worker()(比如用户批量上传 10 张图),就会创建 10 个 Worker 线程,占用大量 CPU 和内存,反而导致页面卡顿。

解决方案:复用 Worker。创建一个 Worker,多次发送任务,任务完成后不销毁,等下一次任务:

// 主线程:复用Worker
const worker = new Worker('./compressWorker.js');
// 批量压缩图片
async function batchCompress(files) {
  for (const file of files) {
    const imgDataUrl = await handleFile(file);
    // 每次发送不同的任务,Worker会依次处理
    worker.postMessage({ imgData: imgDataUrl, quality: 0.3 });
  }
}

2. 数据传递的内存开销:用 Transferable Objects 优化

之前说过,postMessage用的是 “结构化克隆”,如果传递大对象(比如 10MB 的ArrayBuffer),会克隆一份相同的内存,导致内存占用翻倍。

解决方案:用Transferable Objects(可转移对象),把数据的 “所有权” 从主线程转移给 Worker,而不是克隆。转移后,主线程就不能再用这个对象了,避免内存浪费:

// 主线程:转移ArrayBuffer的所有权
const buffer = new ArrayBuffer(1024 * 1024 * 10); // 10MB的缓冲区
// 第二个参数是转移列表,把buffer的所有权转移给Worker
worker.postMessage({ buffer }, [buffer]);
// 转移后,主线程再用buffer会报错:Cannot use transferred ArrayBuffer

3. Worker 的数量限制:不是越多越好

浏览器对 Worker 的数量有限制,比如 Chrome 默认最多支持 20 个 Worker 线程,Firefox 是 16 个。如果创建超过限制的 Worker,浏览器会静默失败(不报错,但 Worker 不工作)。

原因:每个 Worker 线程都会占用 CPU 核心和内存,太多 Worker 会导致 “线程上下文切换” 频繁(CPU 在多个线程间来回切换),反而降低效率。

解决方案:根据 CPU 核心数创建 Worker。比如用navigator.hardwareConcurrency获取 CPU 核心数(比如 4 核),创建 3~4 个 Worker,组成 “线程池”,批量处理任务。

Web Workers 的未来:从图片压缩到浏览器端大模型

浏览器端跑大模型(比如 Llama 2、ChatGLM),这背后离不开 Web Workers 的支撑。

大模型的推理过程是典型的 “计算密集型任务”:要做大量的矩阵乘法(比如一次推理要处理几十万次计算),如果放主线程,页面会卡死几分钟。而用 Web Workers(甚至更先进的SharedWorkerServiceWorker),可以把推理任务放到多个 Worker 线程里并行处理,主线程只负责接收用户输入、显示模型输出。

比如现在的 “浏览器端 ChatGPT”(比如 llama.cpp 的 WebAssembly 版本),就是用 Web Workers 来跑模型推理,让用户在没有后端服务器的情况下,也能在浏览器里和大模型对话。这就是 Web Workers 的未来:让前端从 “页面渲染” 走向 “复杂计算”,承担更多以前只有后端能做的工作

六、总结:Web Workers 的本质是 “主线程的解放者”

看到这里,你应该明白:Web Workers 不是什么高深的 “黑科技”,而是浏览器为了解决 JS 单线程痛点,给我们的 “工具”。它的底层逻辑可以总结为三句话:

  1. JS 主线程负责 “用户交互 + 页面渲染”,不能干重活;
  2. Worker 线程是 “后台计算工”,负责干重活,不碰 DOM;
  3. 线程间靠 “消息克隆” 通信,避免数据混乱。

下次再遇到页面卡顿,先想想:是不是有计算密集型任务(比如图片压缩、数据加密、大列表排序)占了主线程?如果是,试着用 Web Workers 把它 “搬” 到后台 —— 这才是从底层优化用户体验的正确姿势。