教程·阅读约 3 分钟·
浏览器主线程为什么贵?一份系统的前端性能指南:拆、批、排、延、绕

浏览器主线程为什么贵?一份系统的前端性能指南:拆、批、排、延、绕

从'代码不慢,只是恰好占着主线程'出发,系统讲解浏览器主线程的运作机制与帧预算,给出五大优化策略:拆分长任务、批处理高频事件、优先级队列、延迟非必要工作,以及把工作移出主线程(合成器线程、Worker)甚至直接消除。每个策略都附代码与交互示例。

原文来源:kciter.so — 韩国开发者 So Yeon Lim 的深度长文,用十几个可交互 demo 讲透浏览器主线程为什么是稀缺资源,以及系统性的应对策略。

一说前端优化,多数人想到的是减少网络请求、压缩 bundle、用好缓存,再进一步是减少重渲染。但有一个维度很少被提起:主线程。在普通页面上它确实不惹事,可一旦页面交互密集、数据实时涌入、滚动动画输入搅在一起,无论网络和体积优化得多好,主线程一卡,屏幕就冻结。

滚动偶尔顿一下、按钮响应慢半拍、输入框里的字总是晚一步出现——这种恼人的 jank,就是主线程被阻塞的样子。遇到 jank,开发者的第一反应通常是"我的代码是不是太慢",然后去抠算法。但大多数时候,代码并不慢,它只是恰好跑在了主线程上

主线程到底在干什么

主线程的工作分两大类。

第一类是运行 JavaScript:你写的代码、事件处理器、定时器、网络响应回调、框架内部逻辑,全在这里排队执行。这些任务按入队顺序处理,跟屏幕刷新周期没有关系。

第二类是画屏。DOM 或样式变化需要更新屏幕时,浏览器按顺序走完这些步骤产出一帧:执行 requestAnimationFrame 回调(注册在绘制前的 JS)→ 样式计算(算出每个元素的最终 CSS 值)→ 布局/重排(计算每个元素的位置和大小)→ 绘制(生成绘制指令)。只有最后一步合成(把产物组装上屏)交给合成器线程,前半段流水线基本都是主线程的活。

要画面流畅,帧必须按显示器刷新率产出:最常见的 60Hz 屏是每秒 60 帧,也就是每帧约 16.6 毫秒,扣除浏览器自身开销后,实际可用预算通常按 10 毫秒算,120Hz 设备上还要再砍半。

问题在于两类工作在同一条线程上排队。JS 是单线程事件循环模型:一次只处理一个任务,任务运行期间其他什么都干不了。一个函数跑 200 毫秒,这 200 毫秒里浏览器既无法重绘也无法响应点击——对着约 10 毫秒的帧预算,这是致命的。运行时间超过 50 毫秒的任务就被视为问题(long task)。你感知到的性能指标 INP(交互到下一帧绘制)和 TBT(总阻塞时间),本质都是在表达主线程被阻塞了多久。性能优化的一大部分,就是如何省着花这一条线程。

策略分两大派:留在主线程上,把时间花聪明(拆、批、排、延);或者干脆把工作挪出主线程(移去合成器、丢给 Worker、消除工作本身)。

—— 广告 ——

拆分:给长任务制造间隙

直播间的聊天窗是典型场景。热门直播里聊天消息每秒能涌来几百条,流量尖峰时服务器一次性砸来几十条,进房间时积压的历史消息更是成百上千。如果到达时一次性全渲染:每条消息都带来 DOM 创建、样式计算、布局、绘制,几百次迭代在一个任务里背靠背跑完,你自己打字时输入框卡顿,页面其他动画全部掉帧——别人的聊天垄断了主线程。

修复办法就是拆分:把一大坨切成小块,块与块之间把主线程控制权交还一瞬,让浏览器处理积压的屏幕更新和输入。经典实现是 setTimeout 让出:

code
socket.on('messages', (chats) => {
  renderChats(chats);
});
 
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)); // 让出主线程
    }
  }
}

每隔 20 条让出一次,聊天再怎么涌,DOM 工作也不会一次性占满主线程,间隙里用户的输入和动画得到了处理机会。注意让出并不会让总工作量变少,反而增加了几毫秒开销,只是把输入和渲染"插队"进了间隙——用户感受到的却是性能变好了。

如果页面上有动画在跑,按条数拆不如按时间拆:动画每帧都要用一点主线程,重活必须不断看表,在吞掉帧预算前自我截断:

code
async function processDuringAnimation(items) {
  let i = 0;
  let frameStart = performance.now();
  while (i < items.length) {
    while (i < items.length && performance.now() - frameStart < 5) {
      doWork(items[i++]); // 一帧内最多干 5ms
    }
    frameStart = await new Promise(requestAnimationFrame); // 下一帧开始再继续
  }
}

performance.now() 是秒表,requestAnimationFrame 是闹钟——用 rAF 而非 setTimeout 让出,是因为恢复点与帧周期对齐。作者演示了 4000 个粒子互相排斥的模拟:决定一个粒子的方向要算它到其余 3999 个粒子的距离,每轮约 1600 万次距离计算,"每帧 5ms"模式下画面依旧流畅。

拆分的注意点:切太细会适得其反(让出本身有开销);setTimeout 嵌套超过 5 层后有至少 4ms 的最小延迟,所以 React 的调度器用 MessageChannel 投递消息来排任务,新标准 API scheduler.yield() 则能让你让出后插回队首;还有一类工作根本没法拆——JSON.parse 一个几 MB 的响应是原子的同步调用,无法中途让出,这种只能换思路,见下文"不用主线程"。

批处理:把高频事件折叠起来

拆分解决"太长",批处理解决"太频繁"。滚动、resize、input 事件短时间内能触发几十上百次,每次都跑重 handler 主线程就没了。把多次事件折叠成一次执行,就是防抖(debounce,安静下来后跑一次)和节流(throttle,每间隔至多跑一次)。作者演示了一个每次按键都要解析 2000 行文档重建 DOM 的 Markdown 预览器:不防抖时输入全面落后,防抖 300ms 后打字丝滑。

视觉更新用 rAF 天然批处理——屏幕反正每帧只画一次:

code
let scheduled = false;
socket.on('tick', (tick) => {
  chart.push(tick); // 数据全保留
  if (scheduled) return; // 本帧已预约绘制
  scheduled = true;
  requestAnimationFrame(() => {
    renderBoard(); // 每帧只画一次
    scheduled = false;
  });
});

每秒 1000+ 条消息刷新 60 个行情 ticker 的 demo 里,"每条消息都重绘"掉到个位数帧率,"每帧画一次"满帧。DOM 写入也能批:一百个节点一次 append 而非逐个加、改一个 class 而非逐个改 style、拼好 HTML 一次赋值 innerHTML。React 虚拟 DOM 本质就是这个装置:状态改多少次都先在虚拟树累积、diff,最后一次性应用到真实 DOM。

优先级:决定谁先走

既然主线程无法被抢占,任务的顺序就是用户感受到的响应速度。做法是维护一个队列:普通任务 FIFO,紧急任务插队到队首。经典实现还是 MessageChannel(一条消息 = 一个任务):

code
const queue = [];
const channel = new MessageChannel();
 
channel.port1.onmessage = () => {
  const job = queue.shift();
  if (!job) return;
  job();
  if (queue.length > 0) channel.port2.postMessage(null);
};
 
function postJob(job, urgent = false) {
  if (urgent) queue.unshift(job);
  else queue.push(job);
  if (queue.length === 1) channel.port2.postMessage(null);
}

优先级不是固定值。用户给一条帖子附了几十张照片,客户端为省流量要在上传前逐张生成缩略图——这是可以排队慢慢做的活(idle-until-urgent 模式)。但用户突然点开某张照片想确认有没有传对,这张的预览立刻变成最高优先级,把它从队列中间拽到队首即可。React 的 startTransition / useDeferredValue 背后就是这套机制加防饿死、批处理等更精密的机器。现代浏览器也提供了 Scheduler API 和 TaskController 标准,只是支持还不完整,实践中常配 polyfill 或自己建队列。

延迟:现在不做,不代表不做

初始加载是延迟策略的经典场景:代码分割让当前屏幕需要的代码先跑,其余按需加载;渲染本身也能延迟——社交 feed 滚动几百条后切走再切回来,浏览器要给全部几百条帖子重新算样式和布局,哪怕它们都不在视口内。用 IntersectionObserver 让离屏帖子只保留占位高度、接近视口才填充真实内容,切回来就是瞬间的事:

code
const io = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) fill(entry.target);
      else empty(entry.target);
    }
  },
  { rootMargin: '400px' } // 提前 400px 预填充
);
feed.querySelectorAll('.feed-item').forEach((el) => io.observe(el));

1500 条帖子的 demo 里,"全部渲染"模式每次切回冻结几百毫秒,"仅渲染可见"模式秒回。CSS 的 content-visibility: auto 想用一行实现类似效果,但目前各引擎实现不一,Safari 还有让返回变慢的性能 bug,IntersectionObserver 是跨浏览器行为最可预测的选择。轮播图、促销 banner、实时图表这类持续运行的工作,滚出视口就该停——本文自带的十几个 demo 能共存于一页,全靠各自在滚出视野后自我暂停。

绕开一:把动画交给合成器线程

回到开头的疑问:主线程被彻底阻塞时,为什么 CSS 动画还在跑?因为它压根不在主线程上。浏览器内部有合成器线程(合成已绘制的图层、处理滚动和部分动画)和栅格化线程(把绘制指令变成像素),这两者归浏览器自己管理,开发者无法直接指挥;能主动创建的 Worker 线程又碰不到 DOM。

transform 和 opacity 不改变元素在文档中的位置、尺寸和颜色,只是移动已绘制的图层或调整透明度,不需要重算布局和绘制,浏览器直接在合成器线程处理——所以主线程再忙,transform 动画依然流畅。换成 top、left、width、height 就得每帧重算布局,那是主线程的活。作者用两个并排滑动的盒子演示:一个用 transform,一个用 left,主线程一忙,只有 left 那个开始掉帧。结论:动效请用 transform: translate 代替 left,用 transform: scale 代替 width。

但布局真的必须变的动画怎么办?比如删除列表项后下面各项平滑上移。答案是 FLIP(First-Last-Invert-Play):只触发一次布局变化,把整段运动交给 transform:

code
const first = el.getBoundingClientRect(); // First:现在的位置
list.prepend(el); // 唯一一次布局变化
const last = el.getBoundingClientRect(); // Last:结束的位置
const dx = first.left - last.left;
const dy = first.top - last.top;
 
// Invert:用 transform 假装还在原位 → Play:释放它
el.animate(
  [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
  { duration: 300, easing: 'ease-in-out' }
);

用户眼里元素从旧位置滑到新位置,实际上它早已就位,只是 transform 先把它拽回旧位再松手。动画期间每帧只有合成器插值一个 transform,Vue 的 TransitionGroup 和 Framer Motion 的布局动画底层都是 FLIP。

两个配套知识:will-change: transform 提前告诉浏览器"这元素要变了,先给它单独建层",能消除动画起手顿挫,但滥用会让图层数量爆炸、白费内存;另一个是读写交错引发的 layout thrashing——循环里读一个布局值立刻改样式,浏览器只能当场重算布局,一帧内重排几十次。习惯上把读聚在一起、写聚在一起就没事。

绕开二:重计算丢给 Worker

大 payload 解析、图像处理、复杂计算这类没法用 transform 重表达的纯计算,可以整体交给 Web Worker 在独立线程跑,主线程专心保 UI 响应:

code
const worker = new Worker('parser.js');
worker.postMessage(hugeRawData);
worker.onmessage = (e) => {
  render(e.data); // 只收结果上屏
};

代价是 Worker 碰不到 DOM、只能算完把结果传回;且主线程与 Worker 只通过 postMessage 通信,数据默认拷贝(序列化),数据大时成本可观。作者演示了 seam carving(接缝裁剪,一种逐条移除图像最低能量路径的算法):裁 250 条缝是数亿次运算,主线程跑整页冻结一两秒,Worker 里跑能实时看到图片变窄。

Worker 内部每帧回传像素缓冲如果靠拷贝也会累积成本,所以 postMessage 支持转移所有权:Transferable 对象(如 ArrayBuffer)只移交引用、成本近乎为零,代价是交出方不能再碰这块缓冲。

绕开三:让工作根本不存在

最好的性能优化是不做这件事。前面批处理聊到 backpressure(背压):入流超过最大吞吐时积压会无限增长,浏览器又没有好办法让服务器减速,这时只能放弃"处理收到的所有东西"。三种消解法:丢弃(dropping)——实时日志这类流过的数据,落后了就悄悄丢掉最旧的,跟上当下比展示全部更重要;合并(merging)——排行榜这类只有最新值有意义的,积压更新合并后只应用最终值,工作量被钉死在屏幕消化能力上;跳过(skipping)——相同输入算出相同结果就别算第二遍,也就是记忆化 memoization。

回头看,防抖跳过了打字期间的执行,feed demo 跳过了不可见帖子的渲染——这篇文章的一半其实都在讲消除工作。学优化时注意力总放在"如何把活干好",但最大的收益通常来自"把活去掉"。 让某段代码变快之前,先问:这件事真的需要现在、在这里、发生吗?

对直播、图像编辑器、地图、游戏这类主线程常年吃紧的应用,优化不是锦上添花而是生死线。而解决这些问题的钥匙不是把代码写快,而是理解浏览器怎么工作、省着花主线程的时间、以及不做不需要做的事。

分享到
微博Twitter

© 2026 四月

原文链接:https://www.aprilzz.com/tutorials/browser-main-thread-optimization