Published on
约 7337 字

主线程是稀缺资源:前端性能优化的两条路

Authors
  • avatar
    Name
    小辉辉
    Twitter

原文地址:https://kciter.so/posts/the-expensive-main-thread/en/

以下为个人整理翻译内容:

一提到前端优化,你会想到什么?大多数人会先想到减少网络请求、压缩打包体积、做好缓存。再往下数,可能是减少重复渲染、调整资源加载时机。主线程通常排不上号——因为在绝大多数页面上,它压根不是瓶颈。

但在交互密集的页面里,情况就完全不同了:数据实时涌入,滚动、动画、输入交织在一起。这时候,不管你在网络和体积上省下多少,只要主线程被堵住,页面就是卡。你应该见过这样的网站——滚动时偶尔顿一下,按钮点下去要等半拍,在搜索框打字时字母慢半拍才蹦出来。还没到让人愤怒的程度,但会持续地让人烦躁。这种卡顿的根源,就是主线程被阻塞了。

作为开发者,遇到这种卡顿,第一反应往往是怀疑"是不是我代码写慢了",然后开始优化算法、删掉多余计算。但多数时候,问题根本不在代码跑得快不快,而在于这段代码占住了主线程——只这一点就足以出事。

浏览器确实是多线程的,但我们在代码里能碰到的几乎一切,都挤在主线程上:计算、渲染、事件处理、网络回调、框架内部逻辑,全在这一个线程排队。一个资源,扛一座山。主线程很贵。 大多数时候它不声不响,可一旦你想做点有野心的事,就必须正视它。这篇文章聊的就是怎么对付这个昂贵的资源。

主线程到底在干什么

先看清楚主线程的工作内容。大致分两类。

第一类是跑 JavaScript。 我们写的代码、事件处理器、定时器、网络响应回调、框架内部逻辑,都跑在这。它们按入队顺序执行,一有缝隙就执行,跟屏幕刷新周期没有直接关系。

第二类是绘制屏幕。 当 DOM 或样式变了、屏幕需要更新时,浏览器大致按下面的步骤产出一帧:

  • 执行 requestAnimationFrame 回调——在绘制前注册执行的 JS
  • 样式计算——算出每个元素最终的 CSS 值
  • 布局——算出每个元素的位置和尺寸(也就是重排)
  • 绘制——生成"在哪个位置画什么颜色"的绘制指令

如果这一帧什么都没变,这些步骤会被整体跳过,所以它们不是每帧必跑。只有最后一步"合成"——把绘制结果拼到屏幕上——会交给合成器线程。也就是说,绘制流水线的前半段,基本都是主线程的活。

要让画面看着流畅,帧必须跟上显示器的刷新率。最常见的 60Hz 屏意味着每秒 60 帧,每帧约 16.6 毫秒。而且这点时间还不能全用——扣掉浏览器自身的处理开销,真正可用的预算通常认为在 10 毫秒左右。如果是 120Hz 设备,这个预算还得再对半砍。

问题来了:上面这两类工作,是在同一个线程上排队的。JavaScript 的模型是单线程事件循环,主线程一次只处理一个任务,任务在跑的时候,其他什么都干不了。如果一个 JS 函数跑了 200 毫秒,那这 200 毫秒里,浏览器既不能重绘屏幕,也不能响应用户点击。对着 10 毫秒的帧预算,这是灾难级的时长。运行时间这么长、占住主线程的任务,叫做长任务,一般超过 50 毫秒就得警惕。

光说没感觉,看个演示。下面的 demo 里,按下按钮会让 JS 占住主线程一段时间。按下之后你会发现,JS 驱动的动画停了,往输入框里打字也没反应;而旁边的 CSS 动画照样转。主线程被长期占住,等于屏幕冻结。CSS 动画为什么不受影响,后面会讲。

这件事直接对应到 Web 性能指标上。INP 衡量的是用户操作到屏幕响应之间的时长,TBT 衡量的是页面加载期间主线程被阻塞的总时长——本质上都是在说同一件事:主线程被堵了多久。性能优化的很大一部分工作,就是盘算怎么把这一条线程的时间花得值。

花得值的方式分两大类:一类是在主线程内部把时间切好、排好;另一类是把活干脆挪出主线程。下面按顺序讲。

把昂贵资源用聪明

第一类思路:活儿还在主线程上干,但花得聪明。这里有四个核心动作:

  • 太长的任务,怎么拆?
  • 太频繁的任务,怎么合并?
  • 多个任务排队,谁先上?
  • 不着急的任务,怎么往后放?

分别对应拆分、批处理、优先级排序、延迟。前两个决定任务的大小,后两个决定任务的时机。四者当中,拆分是地基——任务得先有边界,你才能在边界之间插进其他事情、把某些事情往后挪。所以从拆分说起。

拆分

想象一个直播间的聊天面板。热门直播间里,聊天消息每秒能涌进几百条。这种场景下消息不可能一条一条彬彬有礼地来——流量高峰时服务端会几十条一批地推,你一进房间,几百条积压消息哗一下全砸下来。如果你在收到消息的当下就整批渲染,会怎样?每条消息的绘制都伴随 DOM 创建、样式计算、布局、绘制,几百次迭代在一个任务里连着跑。与此同时,想打字的用户面对的是一个卡顿的输入框,屏幕上的动画也跟着一顿一顿。别人的聊天,把主线程霸占了,影响的是你自己的操作。

解法呼之欲出:把整批切成小块,每块之间把主线程让出去一小会儿。就在这些空隙里,浏览器得以处理排队的屏幕更新和输入事件。

下面的 demo 模拟一个直播聊天面板。点 "Flood the chat",消息开始涌入。一边在输入框里打字,一边观察顶部的流畅度指标和 fps,对比 "Immediate render" 和 "Yielding render" 两种模式。Immediate 模式下,每条消息到达就直接改 DOM,流畅度指标一顿一顿,输入框明显滞后。仔细看还会发现,聊天消息本身的显示也变慢了——因为接收和处理消息的回调同样是主线程队列里的一个任务,和别的任务一起被拖住了。切到 Yielding render 之后,消息依然一条条画出来,但输入恢复了,屏幕也重新动了起来。区别只有一个:每处理 20 条消息,把主线程让出去一下。

但别误会这个 demo。让出并不会让工作变快。 总工作量没变,每次让出等的那几毫秒还是额外开销,按墙钟时间算反而更慢。那为什么渲染和输入都恢复了?前面说过,任务在跑的时候主线程什么都做不了,渲染流水线也插不进任务中间,只能在任务之间跑。让出,就是在制造任务之间的空隙。 积压的输入和帧生产在这些空隙里拿到了执行机会,对用户来说,体感性能就上去了。

代码层面,最经典的让出方式是 setTimeout,它把后续操作推入下一个任务:

// 一批聊天消息同时到达
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 让函数停住,等排好的任务轮回来,再从暂停处继续。

上面的例子是按数量拆分。但如果页面上动画正在跑,或者用户正在滚动,按时间拆分比按数量拆分更稳妥。动画每帧都要占用一点主线程,所以繁重的工作必须不停看表,在吃掉这一帧剩余预算之前主动刹车。

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 还会把帧的开始时间戳传给回调,上面的代码拿它当预算基准——因为这个函数并不独占这一帧。如果同一帧里其他动画回调先跑了,我们这份份额就得相应缩水,否则整帧预算就破了。锚定帧的开始时间,就把"用 5 毫秒"变成了"用到帧开始后第 5 毫秒",当多个动画共享一帧时,代码之间就能自然协作。

为什么是 5 毫秒?没什么玄机。前面说实际预算约 10 毫秒,拿一半给后台工作,剩下一半留给动画回调、样式、布局、绘制,是个合理的经验值。如果你的动画很重,就往下调。用这种方式,哪怕繁重工作正在跑,每帧也还有空间绘制屏幕,工作和动画可以平滑并行。

下面的 demo 里,移动鼠标会把 4000 个粒子从光标处推开,粒子之间也会互相排斥,所以要确定一个粒子的方向,得算它和所有其他粒子的距离。一轮下来约 1600 万次距离计算,每帧全量重算的话帧预算直接爆炸。对比 "Compute all at once" 和 "5ms per frame" 两种模式。

拆分是把主线程时间用聪明的最基本手段。 它给用户带来的是他们真正在乎的感知性能:点得动、滑得顺。

最后几点注意。

第一,拆得太碎会适得其反。让出和恢复本身有成本,块太小的话,开销可能比你正在干的活还大。

第二,用 setTimeout 让出会有最小延迟,每一块都可能白白多等几毫秒。通常无所谓,但对响应性要求极高的场景,这个延迟就是问题。所以有些代码改用 MessageChannel 发消息来调度下一个任务,React 的调度器就是这么干的。最近还出了一个标准 API scheduler.yield(),好处是让出之后原任务会排在队列里其他任务前面恢复,而不是被踢到队尾。不过各浏览器支持程度还不齐。

第三,不同的让出工具,恢复时机不一样。setTimeout 和 scheduler.yield() 不理会渲染周期;requestAnimationFrame 则在帧绘制前恢复,所以那些需要跟屏幕刷新保持节奏的工作,用 rAF 更合适。如果你想做更细粒度的优先级控制,也可以在 MessageChannel 上自己搭一个队列,自己管让出和恢复。

最后,拆分不是永远可行。比如用 JSON.parse 解析一个几兆的响应体,这是一个原子的同步调用,中途停不下来让出。解析完之前,主线程就是卡着。这种拆不动的重活,就是"用聪明"这条路的明确边界。到了这一步,你得换前提——别在主线程上干它。这一点放到后面"不走主线程"一节讲。

批处理

但光靠拆分解决不了所有问题。回头看直播聊天那个例子:让出救了输入和渲染,可它并没有让聊天绘制得更快。真要说,吞吐量——单位时间内画出来的消息数——反而因为让出的开销降了。那么问题来了:如果聊天涌进来的速度超过了吞吐量呢?到达比处理快,积压越来越多,显示到屏幕上的消息越来越旧。这种情况叫背压。要提升吞吐量,得换个工具,不是拆分能解决的。比如,别一条一条画消息,把攒下来的一批一次性画出来。每条消息的固定成本被摊薄,同样的时间就能渲染更多聊天。

刚说完要拆,转头又说要合,听着矛盾。但两者的目标是一致的:把任务修剪到合适的粒度。批处理专门对付那些"频繁到让流水线固定成本被反复支付"的任务。

最适合批处理的对象是事件。滚动、resize、输入,短时间内可能触发几十上百次。如果每个事件都跑一个重处理器,主线程就被吃光了。所以我们要把大量事件合并成一次执行,方式是两种:"安静下来后跑一次"或者"每个间隔最多跑一次"。前者叫防抖,后者叫节流。

下面的 demo 是一个开着超长 CHANGELOG 的 Markdown 编辑器。要出预览,得解析整份文档(约 2000 行)并从头重建 DOM,每次按键都这么干太贵了。在 "No debounce" 模式下快速打字,预览每敲一个字符就重建一次,输入就跟不上了。切到 "Debounce 300ms" 后,停止输入后才渲染一次,打字立刻顺了。

对于视觉更新,可以用 requestAnimationFrame。屏幕反正每帧只画一次,所以不管有多少更新请求堆着,每帧画一次就够了。

let scheduled = false;
socket.on('tick', (tick) => {
  chart.push(tick); // 保留每个数据点——什么都不丢弃
  if (scheduled) return; // 这一帧的绘制已经预约
  scheduled = true;
  requestAnimationFrame(() => {
    renderBoard(); // 每帧绘制一次
    scheduled = false;
  });
});

下面的 demo 以每秒上千条消息的频率更新一个包含 60 个代码的看板。"Render every tick" 模式每来一条消息就重绘整个看板。每来一条就调一次图表库的 update()——这是一个很常见的错误,效果就长这样。切到 "Once per frame" 后,每个到达的数据点依然会被反映出来,但 fps 回来了。

DOM 写入同样可以批处理。 一次操作追加一百个节点,而不是循环里一个个追加;切换一个 class,而不是逐个改样式属性——把多次变更合成一次,性能就能受益。老办法"拼好 HTML 字符串一次性赋给 innerHTML"本质也是这个思路:把写操作聚到一起,让渲染流水线的固定成本只付一次。React 的虚拟 DOM 生来就是干这个的——不管状态变了多少次,变化都先在虚拟树里累积、做 diff,只有真正的差异才一次性打到真实 DOM 上。在同一个事件处理器里把多次状态更新合并成一次重渲染,或者把分析事件排队后一次性上报而不是逐条发,也是同一个道理。原本每个条目付一遍的固定成本,现在每批只付一遍。

优先级排序

如果说拆分和批处理决定任务的大小,优先级排序决定任务的顺序。用户刚按下的按钮,响应必须马上发生;而屏幕外内容的统计预计算,可以往后放。优先级排序就是把紧急的活排在紧急度低的活前面。顺序为什么重要?因为主线程不能被打断,顺序就是用户感知到的响应性。

要控制顺序,通常是建一个队列,任务推进去、从里面取。队列是先进先出,但紧急任务来了,就把它拽到队首。

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 的时候,内部就会启动一个调度器,用 MessageChannel 让出,用自己的优先级队列给工作排序。

延迟

最后一个动作是延迟:不着急的活,往后推。听着像偷懒,实际上是在保护主线程,不让当前屏幕用不到的工作侵占它。技术上,延迟的收益来自把工作移出关键路径。

最直接的形式是代码分割。没必要一上来就把所有 JS 都下载并执行。先跑当前屏幕需要的那部分,其余的用到再加载,主线程一开始就不会被拖慢。

渲染本身也可以延迟。 想想社交信息流。有些应用在通知标签页待久了,攒了几百条帖子,切回来时会卡那么一下。即使切走的时候信息流的 DOM 一直活着,等它再次可见,浏览器会把几百条帖子的样式和布局一口气重算一遍——包括那些离视口很远、根本看不见的。那么,如果把屏幕外的帖子先做成只占高度的空壳,等它快进屏幕时再填真内容呢?告诉你"快进来了"的工具就是 IntersectionObserver,图片懒加载库用的也是它。

const io = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) fill(entry.target); // 接近时填充
      else empty(entry.target); // 离开时清空,保留占位
    }
  },
  { rootMargin: '400px' } // 在滚动到达前提前填充的余量
);
feed.querySelectorAll('.feed-item').forEach((el) => io.observe(el));

下面的 demo 是堆了 1500 条帖子的信息流。一开始就开着 "Render only when visible"。去通知标签页待一会儿再切回来,不管攒了多少,返回都是瞬时的。切到 "Render everything" 再往返一次——每次返回都要冻住几百毫秒,因为 1500 条帖子全都要重新布局。除了快速滚动时占位空槽会短暂闪一下,两种模式看起来一样。

这种方式延迟的不只是渲染。屏幕外的帖子压根不构建 DOM,维护它的成本也一并被推迟了。如果某个组件带有很重的初始化,初始化也可以等它快进屏幕时再做。还有一个 CSS 属性 content-visibility: auto 想用一行代码实现类似效果。但截至本文写作时,各引擎的实现还不统一,Safari 上还有个性能 bug,会让返回页面更慢而不是更快——所以目前来说,IntersectionObserver 在各处行为都可预测,是更稳妥的选择。

那些一直在跑的工作——轮播图、动画促销横幅、实时图表——在屏幕外的时候纯粹是浪费。你每帧都在花主线程的时间去重绘没人看得到的画面。可见时再跑,离开就停。本文里十几个 demo 能同处一页,也是靠每个 demo 在滚出视野后就停下。

不走主线程

到目前为止讲的都是怎么用主线程,只是用得省。第二类思路是:干脆别在主线程上干这些活。

回到长任务那个 demo 里悬而未决的问题:主线程被完全堵死,CSS 动画为什么还能照常转?因为那个动画压根就不在主线程上跑。

浏览器内部有多个线程分担工作,值得留意的主要有这几个:

  • 主线程:跑 JS、操作 DOM、计算样式、执行布局、处理事件。
  • 合成器线程:把已经画好的图层合成到屏幕上,负责滚动和某些动画。
  • 光栅线程:把绘制指令变成真正的像素。
  • 工作线程(Worker):我们自己创建的独立 JS 执行环境。

麻烦在于,这些线程不是想指挥就能指挥的。合成器和光栅线程归浏览器管,我们没法直接下命令;工作线程虽然能创建,却有个硬限制——不能访问 DOM。所以"不走主线程"不是随便把活扔出去,而是挑出那些能以别的线程能处理的形式存在的活,送出去。主要有两条路。

把工作交给合成器

合成器线程就是 CSS 动画停不下来的原因。transform 和 opacity 不会改变元素在文档里的位置、尺寸或颜色,它们移动的是一张已经画好的图层,或者调整它的透明度——所以既不需要重排,也不需要重绘。浏览器完全可以在合成器线程上处理它们,一步都不经过主线程。主线程再忙,合成器也独立运转,动画该多顺还多顺。

要是你改用 top、left、width、height 这类属性来挪元素,布局每帧都要重算,那就是主线程的活。下面的 demo 里,两个盒子以相同方式左右滑动,一个用 transform,一个用 left。按下按钮给主线程上压力,主线程一忙,只有底下那个用 left 的盒子开始卡;上面那个 transform 盒子归合成器管,负载再高也照样顺滑。同样的"左右滑动",因为动画属性不同,最后落在了完全不同的线程上。所以从性能角度说,移动动画用 transform: translate 而不是 left,缩放动画用 transform: scale 而不是 width。

可那些布局真的需要变的动画呢?想象一个列表,删掉一项后,下面的项平滑地滑上来。这不是装饰性动效,位置是真的变了。可动画 top 就意味着每帧都要布局。破局的技术叫 FLIP(First, Last, Invert, Play):只触发一次布局变化,整个运动过程交给 transform。

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: 让它看起来还在旧位置 → Play: 释放它
el.animate(
  [
    { transform: `translate(${dx}px, ${dy}px)` },
    { transform: 'none' }
  ],
  { duration: 300, easing: 'ease-in-out' }
);

在用户眼里,元素是从旧位置滑到新位置的;但实际上元素早就到新位置了,transform 只是把它短暂地拖回去,然后释放归位。动画播放期间,每帧唯一要干的活就是合成器。Vue 的 TransitionGroup、Framer Motion 的布局动画,底层都是 FLIP。

下面的 demo 一眼就能看出差别。点 "Play rank shuffle",两个排行榜以相同方式重排。左边列表用 top 配合 transition 做动画,右边列表用 FLIP 只动 transform。没有负载时,两边看着都顺。现在打开 "Load the main thread" 再播放——左边一路卡到终点,右边哪怕在高负载下也稳得很。

关于把活交给合成器,还有两点。一是 will-change: transform,它给浏览器一个提示:"这个元素马上要变了,提前把它备成独立图层",能让动画起步更平滑。但滥用会让图层数量暴涨,反而浪费内存。二是读取布局值,你刚才在 FLIP 代码里已经见过了。如果读布局值的代码(getBoundingClientRect、offsetWidth)和写样式的代码顺序不对,就会撞上布局抖动:

// 读写交错——每次迭代都强制重新计算布局
for (const el of elements) {
  const width = el.offsetWidth;      // 读(需要布局)
  el.style.width = width + 10 + 'px'; // 写(使布局失效)
}

// 先完成所有读取,再统一写入
const widths = elements.map((el) => el.offsetWidth); // 全部读取
elements.forEach((el, i) => {                       // 然后统一写入
  el.style.width = widths[i] + 10 + 'px';
});

像上面这样把读写拆开,布局每批只算一次,而不是每个元素算一次。在性能分析器里,反复重排的代码会标成"强制重排"——这是主线程上最昂贵的浪费之一。

把工作移出主线程

就算有合成器,有些事情还是只能在主线程做,或者压根不该在主线程做——比如重计算。这时候就轮到工作线程登场了。前面说过,Worker 访问不了 DOM,但它可以把繁重的计算完全从主线程卸下来。

典型例子是图像处理。下面的 demo 给一张大图做模糊。在主线程上跑模糊算法会阻塞渲染和输入;挪到 Web Worker 之后,主线程空闲下来,UI 保持响应。

// main.js
const worker = new Worker('blur-worker.js');
worker.postMessage({ imageData, radius: 8 });
worker.onmessage = (e) => {
  ctx.putImageData(e.data, 0, 0); // 只有把结果放回画布这一步在主线程上
};

// blur-worker.js
onmessage = (e) => {
  const { imageData, radius } = e.data;
  const result = blur(imageData, radius); // 繁重计算在工作线程上运行
  postMessage(result);
};

图像数据通过 postMessage 传过去,浏览器用的是结构化克隆或转移(transfer)语义。注意:结果必须回到主线程,因为只有主线程能操作 DOM 和画布。但繁重的计算从此不再占用主线程。

在 Worker 之上,WebAssembly(Wasm)可以在工作线程里跑接近原生速度的代码。视频解码、物理模拟、加密这类计算密集型任务,把代码编译成 Wasm 放到 Worker 里跑,主线程就能完全绕开这些工作。

有几点限制要清楚:

  • Worker 有启动成本。 创建一个 Worker 和它的运行环境不是免费的。所以要么复用一个长期存在的 Worker,要么对一次性任务用 Worker 池。
  • 通信有开销。 postMessage 传的数据会被序列化(或转移),频繁的小消息来回可能把收益吃光。要批量发。
  • 不能直接访问 DOM。 Worker 碰不到 DOM、window 或大多数浏览器 API。它只能做纯计算,通过消息把结果传回来。

小结

收个尾。主线程是浏览器里绝大多数工作排队等待的单一资源。JS 执行、样式计算、布局、绘制,全在这一条线程上。帧预算约 10 毫秒,任务占住主线程超过这个数,页面就会卡,用户就会察觉。

本文讲了省主线程的两条路。

把主线程用聪明,有四个动作:

  • 拆分:把长任务切块,块与块之间让出,让输入和渲染有缝可钻。
  • 批处理:把高频事件和多次 DOM 写入合并成一次,减少每项的固定开销。
  • 优先级排序:紧急的活排前面,顺序就是响应性。
  • 延迟:不着急的活往后放——代码分割、懒加载、屏幕外渲染、屏幕外动画停掉。

不走主线程:

  • 合成器:用 transform 和 opacity 让动画在合成器线程上跑;用 FLIP 把布局动画也变成 transform 动画。
  • 工作线程:把繁重的纯计算挪到 Web Worker,必要时配合 Wasm。

最要紧的是先测量再优化。用 Chrome DevTools 的 Performance 面板和 Lighthouse,确认主线程是不是真的瓶颈、瓶颈在哪里。没有数据就乱拆乱合,可能为了感知性能牺牲了实际性能,也可能换来一堆没必要的复杂度和开销。

主线程很贵。能省着花,就别大手大脚。