Reed's News
← 返回精选

何时在主线程执行任务更合理

Tech 69 hello@smashingmagazine.com (Victor Ayomipo) 2026/7/17 2230 字 原文 ↗

现代网页开发界有一条不容打破的金科玉律:永远不要阻塞主线程

作为前端开发者,你肯定对它耳熟能详——几乎每一份性能优化指南里都能看到这句话,平心而论,这确实是条好建议。我们都知道,浏览器的主线程是单线程的,同一时间只能处理一件事。

更重要的是,主线程并非开发者独占,它还要和浏览器的渲染引擎、输入处理器以及其他关键任务共享资源。因此,我们占用主线程的时间越短,应用的响应速度就越快。这促使我们养成了一种思维定式:把计算任务交给后台 Worker,在 UI 层与计算任务之间划一条不可逾越的界限。

这便是所谓的"推荐架构"。

但我想说,有时候把数据传到 Worker 处理,反而不如直接在主线程运行更快。

几个月前,我开发一款名为 Fastary 的 Chrome 截图扩展时就发现了这个问题。即便用了Offscreen Document(Chrome 扩展后台进程)来处理画布操作,测试时仍存在 2 到 3 秒的延迟。毕竟,截图功能本该是即时响应、毫无卡顿的。

颇具讽刺意味的是,我们出于本能将任务移出主线程以避免 UI 冻结,但有时候,"移走任务"这个动作本身(比如序列化、复制、反序列化数据)也会导致 UI 冻结。而且有些场景下,让后台处理的"推荐方案",反而不如直接在主线程处理更快。

我们来好好聊聊这个问题。

浏览器上下文隔离的架构

要理清这个问题,我们得先明白浏览器为什么要隔离上下文,以及这些上下文之间如何通信——重点就在"通信"这部分。

浏览器并非单一运行环境,而是同时存在多个独立环境,每个环境都有自己的内存空间、访问权限和运行规则:

  • 主线程:我们最熟悉的环境,JavaScript 逻辑执行、DOM 操作、样式渲染、用户交互都在这里进行。
  • Web Workers:独立于主线程的线程,可执行 JavaScript 但无法访问 DOM,主要用于处理大量数据计算任务。
  • Service Workers:与网络相关的代理,负责拦截网络请求,甚至能在页面关闭后继续运行。
  • Chrome 扩展上下文:包含后台 Service Worker、内容脚本和本文重点讨论的 Offscreen Document。

这些环境彼此完全隔离,Web Worker 或后台脚本与主线程拥有独立的内存空间,无法直接读取对方的变量或调用对方的逻辑,这就是所谓的**"无共享"架构**。

那么,隔离的环境之间如何通信?它们通过 API 显式地来回发送消息,比如使用 postMessage()

结构化克隆算法(SCA)

postMessage() 会让浏览器将一段数据传递给目标上下文,而实现这一过程的核心是结构化克隆算法(Structured Clone Algorithm,SCA)

你可能熟悉 JSON.stringify(),SCA 和它类似,但功能更强大、更智能。简单来说,SCA 是一种深度递归的复制操作:它遍历传入的整个数据结构,克隆每一个值,将其序列化为可传输的格式,把字节数据发送到目标上下文,最后在接收端重建出原始对象。

SCA 速度很快,或者说"还算快"——对于 {theme: "dark"} 这类小型配置对象,它的耗时几乎可以忽略不计。但处理大数据时情况就不同了:SCA 是同步阻塞的 O(n) 操作,耗时会随数据体量线性增长。

举个例子:用户点击按钮,内部要把 8MB 的图片数据发送给后台 Worker 处理。调用 postMessage() 时,主线程会立即暂停当前工作,转而执行序列化和复制操作。

如果打包、传输、解包数据的总耗时,比直接在主线程处理数据的时间还长,那为什么不直接在主线程处理呢?

可转移对象(Transferable Objects)呢?

肯定有人会问:"为什么不用可转移对象?"这确实是个合理的疑问,我们来聊聊它。

追求极致性能的开发者通常会用可转移对象(比如 ArrayBufferImageBitmapMessagePort)绕开结构化克隆算法。因为转移对象时,浏览器不会像 SCA 那样复制数据,而是直接将数据的所有权从一个上下文转移到另一个上下文。

这就像一次"移交":发送方会立即失去对数据的访问权,接收方则获得完全控制权,速度快得惊人。根据Chrome 开发者团队的基准测试,转移一个 32MB 的 ArrayBuffer 只需不到 7 毫秒,而用 SCA 克隆则需要约 300 毫秒,速度提升了 43 倍。

Chrome 开发者团队:结构化克隆 vs 可转移对象

Chrome 开发者官网

大图预览

但凡事都有两面性,可转移对象也有不少局限:

  • 一旦转移,发送方就无法再访问数据:如果 UI 还需要用到这些数据(比如显示图片预览),就会陷入困境。
  • 并非所有数据都可转移:普通 JavaScript 对象、Blob、甚至 Base64 字符串都无法转移。
  • API 限制:在浏览器扩展场景中,Chrome 的内部消息机制(chrome.runtime.sendMessage)传统上会强制所有数据走 JSON 序列化。

因此,对于我的截图扩展来说,可转移对象并不是可行方案。

我们为什么要隔离上下文?

既然如此,我们为什么还要费心隔离上下文?为什么不把所有任务都交给主线程?

将耗时的 CPU 任务转移到后台线程,无疑是正确的做法。浏览器需要每 16.6 毫秒渲染一帧画面才能保证流畅,也就是说,任何耗时超过 50 毫秒的任务都属于"长任务",这类任务确实应该交给后台处理。

问题在于,我们把"永远不要阻塞主线程"当成了绝对准则,却没有先问一句:这个任务是处理起来更耗时,还是转移数据更耗时?

当"正确架构"变成错误选择

我开发 Fastary 扩展的目标,是让它像原生应用一样流畅、即时。

一开始,我遵循推荐方案,用 Offscreen Document 在后台处理 DOM 相关工作,但结果却事与愿违。

Offscreen Document API 本身设计得很出色:它能创建一个隐藏的、不显示的文档,完全在后台运行,拥有 DOM 环境并支持 Canvas。裁剪截图、拼接多张截图、复杂图像处理、添加水印——这些场景似乎都是为它量身定做的。

但实际测试后我发现,这并不是最优解。当时的架构是这样的:

  1. 后台 Service Worker 调用 chrome.tabs.captureVisibleTab() 截取屏幕,返回 Base64 编码的 Data URL 字符串。
  2. 后台 Service Worker 通过 chrome.runtime.sendMessage() 将图片数据发送给 Offscreen Document。
  3. Offscreen Document 接收图片,加载到 <img> 元素中,再绘制到 Canvas 上,根据用户选择的坐标完成裁剪,最后将处理后的图片编码并发送回后台 Service Worker。

但测试时,截图操作毫无"即时感",始终存在 2-3 秒的延迟。

我发现,captureVisibleTab() 返回的 Base64 字符串,在 1080p 屏幕上就可能达到 1MB 以上,具体大小取决于画面细节。而在 Retina 显示屏(比如 MacBook)上,浏览器默认会将图片尺寸翻倍,数据量会变得更大。

别忘了,图片数据量可能翻倍,而截至本文撰写时,扩展的消息机制仍依赖 JSON 序列化——这意味着我们要面对大量的同步通信,而且是一来一回的完整往返。

图片字符串至少要经过两次 JSON 序列化:一次是从后台传到 Offscreen Document,另一次是处理完成后从 Offscreen Document 传回后台。Offscreen Document 内部的图片裁剪处理速度很快,但数据传输的开销却大得惊人。

Retina 高 DPI 难题

除了延迟,我还遇到一个更隐蔽的问题——现在想来,其实是我考虑不周。截图完成后,裁剪结果总是不对:要么图片比例奇怪,要么裁剪坐标完全错误。

原因在于:用户选择裁剪区域时,内容脚本通过 getBoundingClientRect() 获取坐标,这个方法返回的是CSS 像素,也就是 DOM 使用的像素单位。但 Chrome 原生截取的屏幕截图,使用的是物理硬件像素,浏览器会通过 devicePixelRatio(DPR,设备像素比)来确定 1 个 CSS 像素对应多少个物理像素。比如,在 DPR=2 的 Retina 显示屏上,用户选中 400x300 CSS 像素的区域,实际截取的图片区域是 800x600 物理像素。

注:标准显示器的 DPR 为 1,即 1 个 CSS 像素等于 1 个物理像素;而 Mac Retina 显示屏或现代 4K 显示器的 DPR 通常为 2 或 3。

要实现精准裁剪,我需要结合这两种像素单位,用正确的 DPR 值对裁剪坐标进行缩放。但 Offscreen Document 没有物理显示环境,处理图片时默认 DPR 为 1。要解决这个问题,我得先从当前标签页获取准确的 devicePixelRatio,将其序列化后和图片数据一起传给 Offscreen Document,再在里面手动完成缩放计算——复杂度一下子就上来了。

如果打破那条金科玉律,直接在主线程处理会怎样?

回到主线程处理

有些开发者认为,主线程只能处理 UI 任务,但我不完全认同。我个人认为,用户主动触发、需要即时反馈的操作,只要处理速度足够快(比如在 1 秒内完成),完全可以在主线程运行。

于是我这么做了:弃用 Offscreen Document,重新设计逻辑。原来的流程是: 后台 → [序列化] → Offscreen Document → [序列化] → 后台 → 内容脚本

现在我把整个图像处理流程放在当前标签页的主线程中:

  1. 后台 Service Worker 截取屏幕,获取 Base64 字符串(和之前一样)。
  2. 后台通过 chrome.scripting.executeScript() 将数据直接发送到当前标签页的内容脚本。
  3. 运行在主线程的内容脚本接收数据,绘制到 Canvas 上,用正确的 DPR 值完成裁剪,最后将结果复制到剪贴板。
// 后台脚本
const screenshotUrl = await chrome.tabs.captureVisibleTab(undefined, { format: "png" });
// 将处理函数注入当前标签页作为内容脚本执行
await chrome.scripting.executeScript({
target: { tabId: activeTab.id },
func: processAndCopyImage,
args: [{ base64Image: screenshotUrl, cropData: userSelection }]
});

这种方式彻底消除了多次上下文切换和 JSON 序列化的往返开销,唯一的跨上下文传输就是将 Data URL 从后台发送到内容脚本。

Retina 高 DPI 问题也迎刃而解:内容脚本直接运行在真实的当前标签页中,能直接获取显示器的真实 devicePixelRatio

不过,这里有个显而易见的问题:现在图片在主线程处理,相当于在当前标签页的主线程操作 Canvas,理论上可能阻塞主线程。对此,我对"不阻塞主线程"的规则做了修正:不要长时间阻塞主线程。在这个场景下,为响应用户主动请求而阻塞主线程约 1 秒是完全合理的。反过来想:如果数据传输的开销大于处理任务的开销,那就没必要隔离进程

结论:何时隔离,何时不隔离

我总结出一套判断逻辑,核心在于任务属于以下哪一类:

1. 计算密集型任务(CPU 绑定)

这类任务的主要开销在于计算本身,而非数据大小。大部分时间都花在计算或复杂转换上,比如图片压缩、音频分析、物理模拟等。

对于这类任务,数据传输的开销远低于实际计算的开销,交给后台处理更划算。

2. 数据密集型任务(数据绑定)

这类任务则完全相反:开销主要来自数据体量,处理时间几乎可以忽略,但数据传输成本很高,比如图片裁剪、数组过滤、浅拷贝等。

在我的截图扩展案例中,将任务移到后台处理属于负效率操作——为了执行一个仅需 50 毫秒的操作,却要传输数兆字节的数据,完全得不偿失。

我们可以用这个公式来衡量:

总耗时 = 序列化成本
+ 传输时间
+ 后台处理时间
+ 反序列化成本

如果**"后台处理时间"**在总耗时中占主导,那么隔离上下文是最优选择;但如果序列化、反序列化加上传输的总耗时超过后台处理时间,那就没必要隔离了。

如果无法判断任务是计算密集型还是数据密集型,不妨实际测量一下。比如用 performance.mark()performance.measure() 来分析 postMessage 调用的传输成本。

Smashing 编辑部 (gg, yk)

(gg, yk)