浏览器的主线程很昂贵
文章摘要
一篇由韩国工程师 kciter 撰写、配了十几个可交互 demo 的长文(原文韩语,作者同步发布英文版)。它把「前端性能优化」的注意力从大家熟悉的网络请求、bundle 体积、缓存,扭转到一个经常被忽略的稀缺资源上:浏览器主线程。
主线程在做什么。 作者把它的工作分成两类。一是跑 JavaScript——我们写的代码、事件处理器、定时器、网络响应回调、框架内部逻辑全在这里,按入队顺序执行,与屏幕刷新周期无关。二是画屏幕——DOM 或样式变化后,浏览器依次执行 requestAnimationFrame 回调、样式计算、布局(reflow)、绘制(paint),只有最后的合成步骤交给合成器线程。如果什么都没变,前面这些步骤会被整个跳过。
关键在于这两类工作排在同一条队里。JS 是单线程事件循环模型,一个任务运行时其他什么都做不了。60Hz 屏幕每帧约 16.6ms,扣掉浏览器自身开销后实际预算约 10ms,120Hz 直接砍半。一个跑 200ms 的函数就意味着 200ms 内既不能重绘也不能接收点击——超过 50ms 的任务通常就被认定为 long task。作者指出,INP 和 TBT 这两个 Web 性能指标,本质上就是在度量主线程被阻塞了多久。
作者把对策分成两大家族。
家族一:把主线程的时间用好(四招)
-
切分(Splitting):以直播弹幕为例,高峰时每秒几百条、进房间瞬间几百条积压消息一次涌入。一口气全渲染,几百次 DOM 创建加样式、布局、绘制挤在一个任务里,用户打字就会卡。做法是每处理 20 条就
await一个setTimeout(0)把主线程交还出去。作者强调一个容易误读的点:yield 并不会让工作变快,总工作量没变,每次让出还多了几毫秒开销,墙钟时间反而更长;之所以体感变好,是因为渲染管线只能在任务之间运行,yield 制造了这些缝隙。如果已经有动画在跑,按时间切比按数量切更安全——用performance.now()当秒表、requestAnimationFrame当闹钟,每帧只干 5ms(约为 10ms 预算的一半),而且要以 rAF 回调收到的帧起始时间戳为基准,这样多个动画共享一帧时能自然协作。切分也有陷阱:切太细反而让 yield 开销盖过实际工作;setTimeout嵌套超过 5 层后规范强制最少 4ms 延迟,所以 React 的调度器改用MessageChannel投递消息来让出,新的标准 APIscheduler.yield()的好处是恢复时排在其他队列任务之前(但浏览器支持还不齐)。最后,切分并非万能——JSON.parse解析几 MB 的响应是一次原子同步调用,中途没法停。 -
批处理(Batching):切分救了输入和渲染,却没让弹幕画得更快,反而因 yield 开销降低了吞吐。当流入速度超过吞吐,积压无限增长,这就是背压(backpressure)。对策是攒一批一起画,摊薄每条消息的固定成本。事件是最好的批处理对象——scroll、resize、input 短时间内能触发几十上百次,于是有了 debounce(安静下来才跑一次)和 throttle(每段时间最多跑一次)。作者澄清「又要切分又要批处理」并不矛盾:两者目的都是把任务修剪到合适大小,切分对付「长到渲染插不进去」,批处理对付「频繁到固定成本反复支付」。
-
优先级(Prioritizing):切分和批处理决定任务的大小,优先级决定顺序——而在一个不可中断的主线程上,顺序就是用户感受到的响应性。作者用
MessageChannel手搓了一个队列,紧急任务unshift插队。例子是用户上传几十张照片,客户端在后台按顺序生成缩略图;一旦用户点了某张图,那张图的预览瞬间变成最紧急的任务,把它的 job 从队列中间抽到最前面。这就是 Philip Walton 提出的 idle-until-urgent 模式。作者提到 React 内部也是类似机制加上更精巧的饥饿保护、批处理和续跑——startTransition/useDeferredValue背后就是一个用 MessageChannel 让出、按自有优先队列排序的调度器。标准侧有 Scheduler API 和 TaskController,但支持尚不完整。 -
推迟(Deferring):最可靠的一招是「现在不必做的就别现在做」。首屏用代码分割只加载当前页面需要的 JS。渲染本身也能推迟——社交 feed 滚了几百条后切到通知 tab 再切回来会卡顿,因为浏览器要为全部几百条(包括远在视口外的)重算样式和布局。解法是让屏幕外的帖子只保留占位高度的空壳,快进入视口时再填充真实内容,靠
IntersectionObserver配rootMargin: '400px'提前量。作者提到 CSS 的content-visibility: auto想一行达成类似效果,但各引擎实现有差异,且 Safari 有个性能 bug 会让返回页面变得更慢而非更快,所以目前 IntersectionObserver 才是各处行为可预期的选项。轮播、动图 banner、实时图表这类持续运行的工作在屏幕外就是纯浪费——本文自己那十几个 demo 就是靠滚出视口即停止才能共存于一页。
家族二:干脆不用主线程
作者回答了开篇 demo 埋的伏笔:主线程被完全阻塞时,CSS 动画为什么还在转?因为它压根就没跑在主线程上。浏览器里主线程之外还有合成器线程(合成已绘制的图层,负责滚动和某些动画)、光栅线程、以及我们能显式创建的 worker 线程。但我们不能随意指挥它们——合成器和光栅是浏览器自管的地盘,而能创建的 worker 又有无法访问 DOM 这个大限制。所以「不用主线程」不等于把任意工作扔出去,而是挑出能被改写成其他线程能处理的形式的那部分工作。
-
交给合成器:
transform和opacity不改变元素在文档中的位置、尺寸或颜色,只是移动已绘制好的图层或调整透明度,无需重做布局和绘制,浏览器可以直接在合成器线程上处理。所以移动要用transform: translate而非left,缩放要用transform: scale而非width。那布局真的必须改变的动画怎么办(比如删除列表项后下面的项目滑上来)?答案是 FLIP(First 测量移动前位置、Last 真正改布局并测量新位置——布局在这里只发生一次、Invert 给已在新位置的元素加一个 transform 让它看起来还在旧位置、Play 把这个 transform 动画掉)。Vue 的 TransitionGroup 和 Framer Motion 的 layout 动画底层都是 FLIP。两个附带提醒:will-change: transform能提前把元素准备成独立图层让动画起步更顺,但滥用会导致图层数暴涨浪费内存;以及布局抖动(layout thrashing)——读布局值(getBoundingClientRect、offsetWidth)和写样式交错在循环里,会逼浏览器每次迭代都重算布局,正确做法是读的和读的凑一起、写的和写的凑一起。 -
交给 Worker:解析大 payload、图像处理、复杂计算这类纯计算可以整个丢给 web worker。但它不免费:worker 摸不到 DOM,只能算完把结果传回;主线程和 worker 只能通过
postMessage通信,默认会拷贝(序列化)数据,数据大时成本可观。所以 worker 不是万能药,只有当计算重到足以盖过通信成本、且与 DOM 无关时才划算。作者用 seam carving(接缝裁剪,找出图片中能量最低的竖直路径逐条删除以在保留主体的前提下缩窄图片)做演示:在主线程跑,整个页面冻结一两秒,结果一次性蹦出来,连中间过程都没法展示,因为没有任务边界让 paint 插进去;丢给 worker,就能实时看着图片一点点变窄。这里还有一个关键细节——如果每帧都拷贝几 MB 的像素缓冲,成本同样会累积,所以postMessage提供了拷贝之外的另一条路:转移所有权。ArrayBuffer这类 Transferable 对象只按引用移动,不论多大成本都接近零,代价是交出去的一方不能再用它。
最后一招:把工作本身消灭掉。 作者给了三条路:丢弃(实时日志这类流过即弃的数据,处理跟不上就默默扔掉最旧的,用户不会察觉,跟上当下比展示全部更重要)、合并(排行榜这类只有最新值有意义的数据,把积压更新合并成只应用最终值,这样工作量被钉死在屏幕能消化的范围内,无论流入多快)、跳过(针对重复工作而非流入工作,同样输入给出同样结果就没必要算第二次,也就是记忆化)。作者点破:这个思路其实贯穿全文——debounce 是跳过打字期间的执行,feed demo 是跳过不可见帖子的渲染,「这篇文章有一半其实是在讲消灭工作」。
结语部分作者说,学优化时注意力容易跑到「怎么把活干好」上,但最大的收益通常来自移除工作;在让某个任务变快之前,先问一句:这活到底需不需要干、需不需要现在干、需不需要在这里干?
HN 评论精华
423 分、149 条评论。讨论分成几条清晰的支线,其中有一条相当刺眼。
一、「你根本不该需要这些技巧」派 vs「有些站点真的需要」派
ahartmetz 开了个火药味十足的头:「如果你那个有文字之类东西的网站不干蠢事和没必要的破事,那么那些不存在的、没必要却昂贵的代码在哪运行根本无所谓。」这条被大量点踩,也引出了整场最长的对线。another-dave 顺着调侃「如果你压根没网站,加载耗时就是零秒」,但他也补了一句有意思的观察:移动端开发社区一直非常在意「别占用主线程」,而这是他第一次在 Web 开发领域看到同类文章。Tepix 举 HN 和 earth.nullschool.net 作对比反问:你是说所有动画和渲染都没必要吗?ahartmetz 澄清自己的意思是很多网站用的技术量远超实际需要。
dspillett 给出了讨论中最公允的调停:如果页面部分是艺术品而非信息转储,或者出于目的必须做交互式渲染(编辑器、实时状态显示、游戏和交互玩具),那这些技术就是必要的,这篇文章就有用。当 manphone 说「那种站点不到 1% 但这套技术被广泛部署得多」时,dspillett 又反过来说了句很到位的话:「『不是真的需要就别这么干』这句话说了等于没说,所以才被点踩——已经足够明智的人不需要听,而不明智的人也不会听。这条评论就是噪音,跟它抨击的那些没必要的动画一样没必要。」adzm 的态度类似:这文章给出的是当你确实需要图片、动画、高频更新时该怎么办的好综述,并说自己学到了「React 用 postMessage 到 MessageChannel 来让出主线程」这个知识点。
nicoburns 插了个反直觉的点:文字渲染其实相当昂贵,只是页面不大时在现代机器上看不出来。ahartmetz 回应说他反而惊讶于浏览器把文字渲染做得这么便宜——他知道这事不容易,浏览器是真的做得极好,所以纯文字渲染很少成为问题。
二、真正的瓶颈是 bundle 和 hydration,不是交互
martinald 提出了最有分量的技术性异议:文章太聚焦于交互了,现实中 90% 以上的慢站点不是慢在交互,而是慢在它们发了巨大的 React/Next.js bundle 和极重的 hydration 工作。他见过很多超过 10MB 的 bundle 需要下载、解析、hydrate,甚至见过多个 SPA 叠在同一个站里;在慢网络或慢 CPU 上,页面几十秒基本不可用,hydrate 之后再怎么 yield 也救不回来。他后面还补充说,即便 bundle 很小,hydration 也可能极差,并自嘲「看得出来我被 Next.js 的 bundle 优化留下心理阴影了吧」。
这条引出了 SPA 存废之争。jcelerier 挑战说:拿一台 Amazon 畅销榜前十的廉价 HP 笔记本、配千兆光纤上网一周,再来说这是不是问题(言下之意 CPU 才是瓶颈)。cryptonym 主张回到 HTML、重新评估每一个第三方脚本、加缓存。doix 给出了权衡:慢网络下频繁交互时他反而更喜欢正经的 SPA,尤其买东西的时候,宁可初次加载久一点然后一切都顺——但读一篇博客文章时预加载一切就毫无意义,理论最优是首屏服务端渲染加渐进增强的混合站点。phoghed 泼了盆冷水:这些人对过去有过于玫瑰色的看法,现在慢又难用的站点在服务端渲染 + jQuery 面条代码的年代同样慢同样难用——「提交一个巨大表单结果拿到个随机错误,整个状态全丢」,那在过去极其常见。cryptonym 反驳说状态丢失在 SPA 上照样发生、而且没有反馈的随机错误恰恰是典型的 SPA 体验,并搬出 RUM 数据(HTTP Archive 技术报告)说这不只是管理层的问题。
RetroTechie 贡献了一段很具体的慢网络苦水,落点是一条值得裱起来的 UI 原则:在处理某个 UI 元素的代码就绪之前,不要把这个元素呈现出来。他描述的是 cookie 弹窗——页面加载好、你开始读,然后脚本弹出 cookie 对话框把一切冻住,滚不动、按钮没反应、原本能读的内容还被压暗;20 多秒后终于看到「接受/拒绝/设置」,但按了都没用(除了那个「接受」经常比「拒绝」和「设置」响应更快的暗黑模式)。他指出开发者机器快网速快,可能根本意识不到这个问题。kevin_thibedeau 的做法更彻底:用 NoScript,站点坏了就白名单几个可能必需的域名,试几次不行就走人;看到十几个域名根本懒得管——「把没用的 JS 拿掉之后,Web 快多了」。
三、虚拟滚动的副作用
mschuster91 对文章推荐的「屏幕外只留空壳」做法极为不满:「所有这么干的人都去死吧,说的就是你 Reddit、Outlook Web、Bluesky」——这让浏览器自带的页内搜索(Ctrl+F)彻底没法用,而这些平台自己的搜索功能又蠢得要命。prodigycorp 反问那你怎么解决 DOM 塞满一堆东西带来的性能损失,并指出站点其实可以自己实现搜索来弥补,只是它们不做,但怪虚拟化是不对的。charcircuit 认为解法应该是改进浏览器去掉这个性能损失,现代计算机极其强大,没有理由处理不了海量 DOM。mr_toad 给了个有意思的观察:浏览器渲染一百万个 DOM 元素快到你察觉不到,问题是你一秒钟要做几百次;讽刺的是,正是对流畅度和帧率的执念才制造了这个问题。mschuster91 自己给出的答案是:Outlook Web 里邮件列表的一「行」嵌在字面意义上约 30 层 div 的迷宫里,性能必然崩;00 年代的电脑处理几千封邮件的收件箱、滚动都毫无问题,而 Outlook 连保持 30 封在活动内存里都费劲——表格式的东西就该用 table 渲染,而不是一堆假装自己是表格的 div,因为那条路径已经被优化到死了。
四、「这是 AI 写的吗」的争论
这是本次讨论里最不愉快的一支。Lebenita 抛出「AI;DR」——内容看起来有价值,但为什么不自己动手写;shevy-java 更直接:「所以这是 AI 垃圾?为什么有人觉得我想读 AI 垃圾邮件?」还阴阳了一句「哇,还有这么多吹捧账号,这是 HN 的新玩法吗?」DuckConference 说自己一读就觉得像 AI,丢给 Pangram 检测也认同,然后感慨「一堆人吹得跟氧气似的……死他妈的互联网」。
ricardobeat 站出来反问:大家到底在这篇文章里看到了什么 slop 的迹象?还是仅仅因为看到一大段文字就条件反射? 他说自己入行很久,看到的是一篇仔细、正确、平静的文章,措辞和句式都没有明显的 AI 痕迹,只是有点长——可能是受 ciechanow.ski 那种风格启发。Lebenita 列了五个他认为可疑的句式片段作为「证据」,并承认作者母语可能不是英语,但坚称「这不是翻译腔,这是 AI 写作腔」。ricardobeat 回击说这是过度解读,那几段完整段落里没有 AI 的痕迹(而他每天要读数千行 AI 生成文本),这些不是常见的 tell,就是写得不错而已,读起来就像十年前所有写得好的技术文章,Quillbot 给出的 AI 生成概率是 0%。
最有力的澄清来自 prodigycorp:「我要给所有说这是 AI slop 的人一个统一回复:这篇文章是用韩语写的,然后翻译成英文。把它叫 slop,这才是判断力 slop。」duskdozer 顺势指出了一个更微妙的现象——「不知从什么时候起,『机器翻译』和『机器翻译 + LLM 润色』被混为一谈了」,他个人觉得用 Firefox 自带的「翻译此页」读韩语原文反而更顺。cafebabbe 的表态或许最有代表性:「老实说……我平时也是 AI;DR 阵营的,但这篇是真的好 :/」
五、零散但有价值的技术补充
skobes指出讲 FLIP 那节应该提一下 View Transitions API,它基本就是为了把这个技巧自动化而生的。chrismorgan做了几处扎实的挑刺:其一,文章说「大多数时候代码本身不慢,只是它恰好占着主线程」——他认为多数时候代码就是慢,因为你选了 React,React 天真地用扩展性极差,得跳过一堆有时会着火的火圈(记忆化、React Compiler 之类)才能让它不比某些替代方案慢太多。其二,文章那张把 Paint 归给主线程的图不对,Firefox 从 2017 年起 Paint 就不在主线程上了(他给了 Mozilla 图形团队的博文链接),并说 Firefox 在这些领域通常是领跑者。其三,那个粒子 demo 的对比「深层次地不能令人满意」——「一次算完」模式下丢失了大部分鼠标移动事件,而这个模拟是按移动事件而非固定 tick 频率驱动的,所以两种模式模拟的根本不是同一件事;他猜是指针事件被浏览器合并了而代码只取了最终状态,没有遍历getCoalescedEvents()。gioandthemachin针对piker(在 WASM 里做文档渲染、正考虑用 worker)的提问给出实战建议:canvas 渲染场景下transferControlToOffscreen()比搬运 ArrayBuffer 更好用,直接把 canvas 交给 worker 绘制,一个像素都不用过 postMessage。bastawhiz补充:ArrayBuffer 默认是拷贝的,只有作为 transferable 传递才是移动,而且只有当前所有者能用;如果需要两个线程同时读写,得看 SharedArrayBuffer。bob1029指出:如果你正在做一个类 ChatGPT 的 Web 客户端,这篇文章里的东西基本全是必需品——流式响应的 UI/UX 要做得顺极其噩梦,得想出聪明的启发式规则来决定在哪里切分变更流、怎么相对帧更新来批处理,否则流式输出看起来就像撒彩纸屑。bee_rider顺势问了个大家都好奇的问题:为什么所有 LLM 聊天客户端都收敛到「一个字一个字往外蹦」这种 UI?第一次看挺好玩(像哈利波特里自己书写的魔法书),但按句或按段发也完全没问题,感觉是 token 生成速度还很慢、需要让人知道机器在干活的那个年代的遗留物。bob1029给出了技术侧的解释:内容是多模态的而且经常是递归嵌套的——你可能正在 markdown 里面,然后冒出一段 Python 代码块,光是检测这些边界就已经很难了。duskdozer则猜测另一个原因是微妙地让用户感觉更像在跟一个人交互。jkhdigital提出了一个学术角度的批评:文章结尾把调度问题归结为「经验和判断力」,这有点落后了——把工作分配给稀缺资源是计算机科学中最古老、研究得最透彻的问题之一,动手重新发明轮子之前应该先翻教科书。spockz部分反驳:那些研究大多假定你能控制环境,而在浏览器里你只能用它给的原语(compute 管理、通信方式、有没有 yield 都受限),他甚至怀疑在 JS/worker 里搞一套绿色线程是否可能——但这不意味着该忘掉那些既有成果。morning-coffee的感慨更宽泛:自从发表想法比研究前人工作更容易之后,我们进入了一个「要在层层叠叠的重新发现博客里刨很久才能找到开创性的、真正讲透的材料」的时代(他也强调自己认为文章很好,只是希望那些被这些内容惊到的 JS 训练营毕业生能去翻翻 60 年代以来的任何一本 CS 或 OS 教科书)。gwbas1c提醒这不是浏览器独有的事:Windows、Mac、iOS、Android 所有平台的 UI 通常都是单线程的。Asmod4n补充说为了响应性这正是你想要的,上下文切换会带来大量抖动和感知上的迟缓,哪怕跑在 120fps 也会让 app 感觉慢。nerdralph对「必须匹配显示器刷新率」做了修正:144Hz 显示器上 72FPS 对绝大多数人已经够顺,48FPS 对多数人也够,高帧率有边际递减。bgirard(Firefox 性能工具背景)认为这个 nitpick 不错,但重点在于尊重用户的偏好——他个人宁可 60Hz 也不要 120Hz 白白给房间加热,但如果有人把显示器设成 1000Hz,你就该尽力去满足。jjk166反驳说用户的硬件代表的是他们希望能应付的最坏情况,而不是偏好的常规设置——大多数人不会在打游戏和读维基百科之间切换刷新率。Me1000和Dylan16807都强调帧率一致性比绝对数值更重要:达不到 120 就老老实实按 60 来,稳定的 30fps 也好过频繁掉帧的 60fps;不过可变刷新率屏幕或性能远高于典型负载的高刷屏会大幅削弱不一致带来的伤害。kccqzy讲了个自己的悬案:项目需要在用户每次按键后立刻解析字符串做语法高亮,但他一直隐隐担心万一语法设计错了导致某些解析超过线性时间就会卡住主线程;又不想先渲染黑色文字、扔到 worker 解析、再重渲染上色,那视觉上太跳。pedrovhb给出了正解:用增量解析器,它们就是为小编辑高效设计的,大名鼎鼎的是 tree-sitter(但输出是编译产物,上 Web 要折腾 wasm),他个人推荐 CodeMirror 生态的lezer。- 有几条对 demo 本身的实操吐槽:
nairboon和eviks都问第一个 demo 里「按按钮之后焦点就离开输入框了,还怎么打字」,lintfordpickle解释意思是按了 2.0s 那个按钮后你在那 2 秒内根本没法重新聚焦输入框。nightpool说弹幕 demo 在他那儿无论什么设置都稳定 32 FPS,怀疑是 Chrome 或 M3 的问题。andai在一台 2024 年的中低端手机(三星 A15)上测出慢版 11fps、快版 30fps(滚动时 15fps),问这是不是文中说的背压。 monxer指出文章漏了一个明显的工具:Chrome 的性能面板——文中很多技术只有在你能验证确实慢了之后才需要动用。stwrt补充说 Web Inspector 常被忽视,Network 面板和 Lighthouse 也值得看。adzm许了个愿:希望 JS 的await能有条件地让出,现在只能写if (shouldYield()) await yield()这种绕法。silverwind问这不就是 AsyncGenerator 吗,adzm回答不是,那个每次调用都会让出。baxuz说已经有一两个生成线程的 RFC 在推进,很期待,「现在这套 worker + 消息的方式繁琐得要命」。vbezhenar提出了更激进的看法:这个错误在每一个 GUI 框架里反复重演——就该直接上多线程,每个库跑在独立线程里,用 actor 模型传数据;「用单线程干所有事、指望开发者自己为重活开线程」是显而易见的做法,但它从来没奏效过,因为开发者不在乎,结果是用户承受劣质体验。CommonGuy回忆起自己刚学编程时在 VBA 代码里到处撒DoEvents保持「跟手」,正是同一个道理。jonathanlydall分享了 15 年前的实践:他在一个 canvas 绘图的业余项目里用了 yield,也试过用 worker 线程后台渲染,但当时线程间没有高效传数据的办法,只能把数据编码成 base64 PNG 传,编解码加拷贝到 canvas 的开销让它比全在主线程做还慢得多;他还讲了个额外的教训——为了让一个同步的 gzip 解码库能每 200ms 让出一次,他改代码时因为 if 语句没写花括号,导致新加的语句其实不在 if 块内,为此调试了极久,从此再也不省略花括号。mikemarsh记得十年前见过一个 JS 密码学库提供异步的哈希函数,正是为了把本该是简单同步计算的东西切开、更频繁地让出主线程;当时觉得非常巧妙,很高兴看到这个概念被正面讲清楚。Dwedit提了个哲学味的问题:当浏览器跑着 30 多个进程时,「浏览器的主线程」到底指什么?thataccount则认为标题应该改成「浏览器的主线程被搞得很昂贵」,因为浏览器上被堆了一大堆运行代价高昂的功能。