当 JS 阻塞主线程时,为什么你的 CSS 动画还在跑?

0 阅读5分钟

—— 从一个 React Demo 看浏览器渲染与合成线程

传送门:tools.mind-elixir.com/zh/learn-js…

动画阻塞实验.png

在前端开发中,我们常听到一句话:“不要阻塞主线程(Main Thread)”。我们也常听到另一条性能优化建议:“尽量使用 transformopacity 做动画”。

这两者之间有什么深层联系?为什么当一段死循环 JavaScript 代码把页面卡死时,某些动画却能像“幸存者”一样流畅运行,而其他的则瞬间冻结?

今天,我们通过一个具体的 React Demo 来通过实验揭开浏览器渲染机制的面纱。

实验现场:一个“卡顿”的按钮

我们构建了一个简单的 React 应用。核心逻辑在于一个名为“Block Main Thread”的按钮。点击它,会触发一段耗时 3 秒的同步 while 循环:

// 模拟主线程阻塞
const handleBlock = () => {
  setIsBlocking(true);
  setTimeout(() => {
    const start = Date.now();
    const duration = 3000; 
    // 💀 死循环:彻底霸占主线程 3000ms
    while (Date.now() - start < duration) {
      // Blocking main thread
    }
    setIsBlocking(false);
  }, 100);
};

在这 3 秒内,浏览器的主线程(Main Thread)被完全挂起。它无法响应点击、无法滚动页面、无法执行其他的 JavaScript 代码。

但在页面上,我们放置了四个正在运行的动画方块(Case A, B, C, D)。当我们点击阻塞按钮时,奇怪的现象发生了:

  1. 方块 C(JS 驱动):立即停止。
  2. 方块 D(Margin 动画):立即停止。
  3. 方块 B(Transform + Width):立即停止(或变得极度卡顿)。
  4. 方块 A(纯 Transform)居然还在流畅旋转!

为什么方块 A 能突破 JS 的封锁?

必须了解的两位主角:主线程 vs. 合成线程

要理解这个现象,我们需要知道浏览器内部也是“多线程”工作的。其中最重要的两个线程是:

  1. 主线程 (Main Thread)
  • 它是最忙碌的。负责运行 JavaScript、计算 HTML 元素的样式(Style)、计算页面布局(Layout)、绘制图层内容(Paint)。
  • 它是“单线程”的。如果你写了一个死循环,它就没空去算布局,也没空绘图。
  1. 合成线程 (Compositor Thread)
  • 它是主线程的助手,通常由 GPU 协助。
  • 它的工作是将主线程画好的各个图层(Layers)接收过来,进行合成(Composite),并最终显示在屏幕上。
  • 重点:它可以独立于主线程处理一些特定的视觉变换,比如位移(Translate)、旋转(Rotate)、缩放(Scale)和透明度(Opacity)

案件分析

让我们结合代码逐一分析四个方块的命运。

受害者一:Case C (JavaScript 驱动)

// JS 驱动的动画逻辑
const animateJS = (time: number) => {
  // ...计算位置 x ...
  if (jsBoxRef.current) {
    jsBoxRef.current.style.transform = `translateX(${x}px)`;
  }
  requestRef.current = requestAnimationFrame(animateJS);
};

死因:依赖主线程。 requestAnimationFrame 是请求浏览器在下一次重绘前调用指定的回调函数。这个回调函数是在主线程上执行的。当主线程被 while 循环卡死时,animateJS 函数根本得不到执行机会。所以动画瞬间冻结。

受害者二:Case D (Layout 属性 - Margin)

/* 改变 margin-left */
@keyframes pure-margin {
  0% { margin-left: 0; }
  50% { margin-left: 150px; } /* 触发 Layout */
  100% { margin-left: 0; }
}

死因:触发重排(Reflow/Layout)。 margin 属性决定了元素在文档流中的位置。修改它会影响周围元素的排版。浏览器必须重新计算布局(Layout 阶段),这个计算过程必须在主线程完成。主线程忙着跑死循环,没空算布局,所以动画停止。

受害者三:Case B (混合属性 - Transform + Width)

@keyframes mixed-transform {
  0% { transform: rotate(0deg); width: 48px; }
  50% { transform: rotate(180deg); width: 120px; } /* Width 触发 Layout */
  100% { transform: rotate(360deg); width: 48px; }
}

死因:被队友拖累。 虽然它包含 transform(由合成线程处理),但它同时也改变了 widthwidth 的改变会触发 Layout。浏览器通常需要每一帧都确认 Layout 的结果,才能进行后续的绘制和合成。因为 width 的计算被主线程阻塞了,整个动画帧的提交被延后,导致即使是旋转部分也被迫停止。

幸存者:Case A (纯 Transform)

/* 仅改变 transform */
@keyframes pure-transform {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

生还原因脱离主线程(Off Main Thread)。 这是性能优化的核心魔法。

  1. 当浏览器分析这个 CSS 动画时,它发现这个动画涉及 transform
  2. transform 不会改变元素的布局(Layout),也不需要重新绘制内容(Paint),它只需要把现有的图层拿来旋转一下。
  3. 主线程会将这个动画任务及其图层信息直接提交(Commit)给合成线程
  4. 合成线程说:“收到,剩下的交给我。”

此时,无论主线程是在跑死循环,还是在进行复杂的计算,合成线程都在独立工作。它不断地指示 GPU 旋转那个图层,并更新屏幕。

这就是为什么即便你的 JS 卡死了,Loading 转圈动画(如果写得对)依然在转。

总结与技术启示

通过这个 Demo,我们可以得出明确的性能优化原则:

  1. 避免主线程阻塞
  • 长耗时的计算任务(如大数组处理、复杂算法)不应在 UI 渲染路径(React Render, Event Handlers)中同步执行。
  • 考虑使用 Web Worker 将计算任务移出主线程。
  • 利用 React Concurrent ModeTime Slicing 切分任务。
  1. 动画性能黄金法则
  • **坚持使用 transformopacity**。只有这两个属性可以保证 100% 在合成线程运行,完全不受 JS 阻塞影响。
  • 避免动画化布局属性(如 width, height, margin, padding, left, top)。这些属性不仅开销大(触发 Layout),而且一旦主线程卡顿,动画就会掉帧。
  • 警惕“混合污染”:不要在一个 @keyframes 中同时包含合成属性(transform)和布局属性(width),否则高性能的属性也会被拖累。

下一步行动

检查你项目中的 Loading 组件或过渡动画。确保它们是基于 CSS transform 实现的,而不是 JavaScript 或 margin。这样,即使你的后端接口返回慢,或者前端数据处理卡顿,用户依然能看到一个丝般顺滑的加载指示器,从而减少“应用已崩溃”的错觉。