浏览器主线程代价高昂
摘要
本文阐述了在交互式Web应用中,浏览器主线程如何变得代价高昂,导致诸如卡顿之类的性能问题,并探讨了优化前端代码以避免阻塞这一关键资源的策略。
暂无内容
查看缓存全文
缓存时间: 2026/09/03 08:55
# 浏览器主线程成本高昂
来源:https://kciter.so/posts/the-expensive-main-thread/en/
提到“前端优化”时你想到什么?对我们大多数人来说,会想到减少网络请求、压缩打包体积,或者充分利用缓存。除此之外,可能还会考虑减少重复渲染或调整资源加载时机。但主线程通常不会被提及,原因很简单:在大多数页面上,它不会成为问题。
但在交互密集的页面中——实时数据流与滚动、动画、输入等操作交织时——情况就不同了。无论你在网络和打包体积上节省多少,一旦主线程被阻塞,页面就会立刻卡顿。
你可能遇到过这样的网站:滚动时不时卡顿,按钮响应稍有延迟,或者搜索框里输入的字符总是慢半拍显示。虽然不至于让人恼火,但这种细微的卡顿会悄悄影响体验。这种“卡顿”正是主线程被阻塞的典型表现。
作为开发者遇到这种情况时,第一反应往往是“我的代码是不是太慢了?”,然后开始分析算法或寻找不必要的计算。但多数情况下,问题的关键并非代码本身的速度——**代码本身并不慢,只是它恰好占据了主线程。**
浏览器拥有多线程架构,但我们在代码中能触及的几乎所有操作都集中在主线程上:计算、渲染、事件处理、网络响应处理以及框架内部逻辑。一个线程,承担着堆积如山的工作。
浏览器的主线程成本高昂。多数时候它不会惹麻烦,但当你试图实现复杂功能时,如何与主线程打交道就成为了关键。本文将探讨如何管理这个昂贵的资源。
## 主线程承担哪些工作?
首先来看看主线程的实际职责。它的工作大致分为两类:
**第一类是运行JavaScript代码。**我们编写的代码、事件处理函数、定时器、网络响应回调以及框架内部逻辑,都在这里执行。这些任务按照进入队列的顺序依次执行,只要有空闲就会运行,与屏幕刷新周期无关。
**第二类是绘制屏幕。**当DOM或样式发生变化需要更新屏幕时,浏览器会大致按以下步骤生成一帧画面:
- 执行`requestAnimationFrame`回调——在绘制前一刻注册的JavaScript代码
- 样式计算——计算每个元素的最终CSS值
- 布局——计算每个元素的位置和尺寸(也称为回流)
- 绘制——生成绘制命令,描述用何种颜色绘制何处内容
如果没有任何变化,这些步骤会被完全跳过,因此它们并不一定会在每一帧都运行。只有最终的合成步骤——将生成的输出组装到屏幕上——才会交给合成线程¹。
换句话说,绘制屏幕的大部分前端流程都由主线程负责。
## 更新屏幕的渲染流程
为了呈现流畅的画面,必须按照显示器的刷新率绘制帧。在最常见的60Hz显示器上,这意味着每秒60帧,即每帧**约16.6毫秒**。但你不能完全利用这段时间。扣除浏览器自身的处理开销后,实际可用的时间预算通常被认为是10毫秒²左右,而在120Hz设备上,这个预算直接减半。
问题在于上述两类工作**在同一个线程上排队执行**。JavaScript基于**单线程事件循环**模型设计。主线程一次只处理一个任务,**当任务运行时,其他一切都必须等待。**如果一个JavaScript函数运行了200毫秒,那么在这200毫秒内,浏览器无法重绘屏幕或响应用户的点击操作。面对大约10毫秒的帧预算,这已经是致命的时间消耗。
这种长时间占据主线程的任务被称为“长任务”,通常超过50毫秒就被认为是有问题的。
文字说明终究有限,让我们通过实例感受一下。在下面的演示中,点击按钮会让JavaScript短暂占用主线程。
```
JS动画 · 主线程 · rAF
CSS动画 · 合成器 · transform
```
点击按钮时:JS动画和输入框会冻结,但CSS动画继续旋转
当你按下按钮时,JS动画停止,在输入框中打字也没有反应。而CSS动画则继续运行。稍后我们会解释这种差异的原因。现在需要记住的是:**长时间占据主线程就等同于冻结屏幕。**
这与网页性能指标直接相关。INP(交互到下一次绘制)衡量用户操作后屏幕响应所需时间,TBT(总阻塞时间)衡量页面加载期间主线程被阻塞的总时间,两者本质上都是表达主线程被阻塞时长的方式。
性能优化的很大一部分,在于如何精细地分配这个线程的使用时间。分配方式大致分为两类:一类是在主线程内部合理分配时间;另一类是将工作完全移出主线程。
我们按顺序来探讨。
## 智慧地使用昂贵资源
第一类方法是在保持使用主线程的前提下,**智能分配其时间**。核心有四种策略:
- 如何拆分运行时间过长的任务?
- 如何合并运行过于频繁的任务?
- 在多个任务中,哪个优先执行?
- 如何推迟那些不必立即完成的工作?
我们称之为拆分、合并、优先级排序和延迟。前两项塑造任务的**大小**,后两项决定任务的**执行时机**。其中,拆分是其他策略的基础。只有明确了任务的边界,才能决定在间隙中安排什么工作,以及推迟什么工作。因此我们从拆分开始。
## 拆分
想象直播聊天面板的场景。在热门直播中,聊天消息可能以每秒数百条的速度爆发。这种情况下,消息不会礼貌地逐条到达。流量高峰时,服务器会一次性发送数十条消息,当你进入聊天室时,数百条积压消息会同时涌来。
如果一次性渲染这整批消息会发生什么?每绘制一条消息都会引发DOM创建、样式计算、布局和绘制,这数百次迭代会在单个任务内连续执行。与此同时,用户尝试输入消息时会遇到输入框卡顿,屏幕上所有其他动画也会出现卡顿。别人的聊天消息占据了主线程,阻碍了你的操作。
解决方法正是我们之前提到的:将整批消息切分成小块,并在每块之间将主线程控制权交还片刻。在这些间隙中,浏览器可以处理积压的屏幕更新和输入事件。
下面的演示模拟了直播聊天面板。点击“灌入聊天消息”按钮,消息开始涌入。尝试在输入框中打字,同时观察顶部的流畅度指示器和fps值,对比“立即渲染”和“让步渲染”两种模式。
```
流畅度指示器(JS动画)60fps
```
在“立即渲染”模式中,每条消息到达时都会操作DOM。因此当聊天消息大量涌入时,fps急剧下降,流畅度指示器卡顿,输入框延迟响应。仔细观察会发现,聊天消息本身的出现速度也明显变慢,因为接收和处理消息的回调函数同样是在主线程队列中等待的任务,所以它和其他任务一起被延迟了。
现在切换到“让步渲染”。消息仍然逐条绘制,与之前一样,但输入框恢复了响应,屏幕再次流畅滚动。唯一的变化是:每处理20条消息后,主线程会短暂释放。
这里需要避免误解:**让步并不会让工作更快完成。**工作总量并未减少,每次让步时等待的几毫秒反而增加了开销,因此从实际时间来看,总耗时反而更长。那么为什么渲染和输入操作都恢复了呢?如前所述,主线程在任务运行期间无法做其他事情。生成帧的渲染流程也无法在任务执行中途插入。它只能在**任务间隙**运行。让步就是主动创造这些间隙。
积压的输入事件和帧生产工作得以在间隙中获得处理机会,因此用户会感觉性能提升了。在代码层面,经典的让步方式是使用`setTimeout`,它将后续工作推迟到下一个任务执行。看看以下代码:
```javascript
// 一批聊天消息同时到达
socket.on('messages', (chats) => {
renderChats(chats);
});
// 绘制消息,每20条后让出主线程
async function renderChats(chats) {
let count = 0;
for (const chat of chats) {
appendChatNode(chat); // 绘制一条消息
if (++count % 20 === 0) {
await new Promise((resolve) => setTimeout(resolve, 0)); // 此处让步
}
}
}
```
有了这样的设计,无论聊天消息涌入多快,DOM操作都不会长时间独占主线程,各块工作之间为用户的输入和动画处理留出了空间。这段代码的核心是`setTimeout`。当它将剩余工作安排为新任务恢复执行时,当前任务立即结束,在间隙中积压的输入和渲染工作得以处理。`await`会暂停函数,直到计划的任务再次运行,然后从上次中断处继续。
## 让步如何改变时间线
上面的例子是按**数量**拆分传入的工作。但如果动画正在运行,或者用户正在滚动页面,按**时间**拆分比按数量更安全。动画每帧都会占用少量主线程时间,因此繁重的工作必须不断检查时钟,在吞噬帧预算前及时中断。
```javascript
async function processDuringAnimation(items) {
let i = 0;
let frameStart = performance.now();
while (i < items.length) {
// 工作仅持续到帧开始后5ms
while (i < items.length && performance.now() - frameStart < 5) {
doWork(items[i++]);
}
frameStart = await new Promise(requestAnimationFrame); // 在下一帧开始时恢复
}
}
```
这里`performance.now()`充当秒表,检查是否超出预算;`requestAnimationFrame`则像闹钟,提醒“在下一帧绘制前唤醒我”。这也是按时间拆分时使用rAF而非`setTimeout`的原因——恢复执行时刻能与帧周期同步。
注意rAF会将帧的起始时间戳传递给回调函数,上述代码将其用作预算计算的参考点。原因在于该函数并不独占帧资源。如果其他动画回调在同帧内更早执行,我们分得的时间就必须相应减少,否则帧预算就会被打破。锚定帧起始时间将“使用5ms”转化为“使用帧开始后5ms”,这使得多个动画共享同一帧时能自然协作。
> 为什么是5毫秒?这个数字并无特殊含义。我们说过实际预算约10毫秒,因此将约一半分配给后台工作,剩余留给动画回调、样式计算、布局和绘制,是合理的启发式分配。如果动画繁重,可以进一步缩减。
即使繁重的工作正在进行,这种方式仍能确保每帧都有绘制屏幕的空间,工作和动画得以平稳并行运行。试试下面的演示。移动鼠标会使4000个粒子四散,附近粒子还会相互推开,因此确定一个粒子的方向需要检查它与其他所有粒子的距离。每次迭代约需1600万次距离计算,每帧重新计算所有这些数据会单独耗尽帧预算。对比“一次性计算所有”和“每帧5ms”两种模式:
```
4000粒子 · 本帧转向成本 0.0ms · 60fps
```
在此移动鼠标或手指,粒子会四散逃离光标
拆分是主线程时间分配最基本的手段。它为用户提供了他们关心的感知性能:快速响应和流畅屏幕。
最后需要注意几点:
首先,**拆分过于细碎会适得其反。**让步和恢复执行本身有开销,如果任务块太小,这种开销可能超过实际工作量。
其次,使用`setTimeout`让步涉及最小延迟³,因此每块工作可能无谓等待几毫秒。通常这无关紧要,但在需要极高响应性的场景中,这种延迟可能成为问题。因此有些代码通过`MessageChannel`发送消息来安排下一个任务。React的调度器就使用了这种方法。最近还出现了标准API `scheduler.yield()`来解决此问题,其优势在于让步后原始工作能优先于其他排队任务恢复执行,而不是被推到队列末尾。不过浏览器支持尚不统一。
第三,**不同让步工具的恢复时机不同。**`setTimeout`和`scheduler.yield()`不考虑渲染周期,而`requestAnimationFrame`会在帧绘制前一刻恢复。因此对于需要与屏幕更新节奏保持同步的工作,`requestAnimationFrame`更为合适。如果想更精细地控制优先级,也可以基于`MessageChannel`构建自己的队列,手动管理让步和恢复。
最后,拆分并不总是可行的。例如使用`JSON.parse`解析数兆字节的响应,这是一个原子性的同步调用,无法中途暂停让步。在解析完成前,主线程一直处于卡死状态。无法拆分的繁重工作是“智慧使用”方法的明确限制。此时必须改变前提——根本不在主线程上执行这些工作。我们将在“不使用昂贵资源”部分探讨这一点。
## 合并
仅靠拆分并不能解决所有问题。回想流式聊天的例子。让步拯救了输入和渲染,但并未加快聊天消息的绘制速度。实际上,吞吐量——单位时间内绘制的消息数量——因让步开销反而降低了。
那么如果聊天消息的涌入速度快于处理速度会怎样?到达速度超过处理速度,积压越来越多,最终显示在屏幕上的消息越来越旧。这种情况称为**背压**。
提高吞吐量需要不同于拆分的工具。例如,可以不逐条绘制消息,而是一次绘制累积的批次。这样每条消息固定的开销被合并,相同的
相似文章
“浏览器代理成本高昂且仍在成熟”这种表述可能忽略了架构方面的问题
讨论了当前使用无头Chrome加AI层的浏览器代理的架构问题,并介绍了Opera Neon的命令行界面作为替代方案,将AI集成到浏览器中,从而降低令牌开销并提高理解能力。
@Cander_zhu: 这又是一篇值得认真读的文章:《How modern browsers work》。 读完后我有两个强烈感受: 1. 浏览器其实是现代最被低估的“操作系统”。 2. 如果前端/Agent 开发者还把浏览器当黑盒,只会越来越吃亏。 从搞产品和…
一条推文总结了对《How modern browsers work》一文的读后感,强调浏览器作为现代操作系统的价值,并为前端和Agent开发者提供了5个关键洞察,包括多进程架构、JS引擎编译管线、性能优化等。
@vivekgalatage: https://x.com/vivekgalatage/status/2062944153697493155
一条推文线程解释了网页浏览器如何展现了各种计算机科学学科,包括数据结构、操作系统、编译器、网络、渲染、数据库、信息检索和机器学习,强调浏览器作为一个通用计算平台。
为什么对每次浏览器点击调用云LLM是一种反模式:我构建了BrowserClaw,一个带有Jev微循环(250ms/步)的轻量级双脑Chrome MCP
BrowserClaw是一个轻量级的Chrome自动化平台,使用双脑架构来优化AI代理的浏览器交互,显著降低延迟和令牌使用量。
WebWorld: 浏览器作为自我改进Web代码的世界模型
WebWorld引入了一个框架,利用浏览器作为可执行的世界模型,通过交互合同和验证监督来自主改进Web代码,相较于基线模型实现了显著的性能提升。