浏览器主线程代价高昂

Hacker News Top 新闻

摘要

本文阐述了在交互式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`解析数兆字节的响应,这是一个原子性的同步调用,无法中途暂停让步。在解析完成前,主线程一直处于卡死状态。无法拆分的繁重工作是“智慧使用”方法的明确限制。此时必须改变前提——根本不在主线程上执行这些工作。我们将在“不使用昂贵资源”部分探讨这一点。 ## 合并 仅靠拆分并不能解决所有问题。回想流式聊天的例子。让步拯救了输入和渲染,但并未加快聊天消息的绘制速度。实际上,吞吐量——单位时间内绘制的消息数量——因让步开销反而降低了。 那么如果聊天消息的涌入速度快于处理速度会怎样?到达速度超过处理速度,积压越来越多,最终显示在屏幕上的消息越来越旧。这种情况称为**背压**。 提高吞吐量需要不同于拆分的工具。例如,可以不逐条绘制消息,而是一次绘制累积的批次。这样每条消息固定的开销被合并,相同的

相似文章

@Cander_zhu: 这又是一篇值得认真读的文章:《How modern browsers work》。 读完后我有两个强烈感受: 1. 浏览器其实是现代最被低估的“操作系统”。 2. 如果前端/Agent 开发者还把浏览器当黑盒,只会越来越吃亏。 从搞产品和…

X AI KOLs Timeline

一条推文总结了对《How modern browsers work》一文的读后感,强调浏览器作为现代操作系统的价值,并为前端和Agent开发者提供了5个关键洞察,包括多进程架构、JS引擎编译管线、性能优化等。