页面切换卡顿的根源:重绘、回流与线程阻塞

做前端这么多年,我见过太多页面切换卡顿的案例。有些是动画掉帧,有些是白屏闪烁,还有的是点击后半天没反应。说实话,这些问题背后的原因其实很集中。今天我就把最核心的三个元凶拆开来讲。

你想想看,浏览器渲染页面就像工厂流水线。任何一个环节卡住,整个流程都得等。而页面切换,恰恰是这条流水线压力最大的时刻。

核心结论:页面切换卡顿 = 回流重绘频繁触发 + 帧率低于阈值 + JS线程长时间占用。

2.1 重绘与回流:浏览器的“体力活”

先说重绘和回流。这两个概念很多人混着用,但它们的代价完全不同。

回流(Reflow),也叫重排。只要元素的几何属性变了——宽高、位置、边距、字体大小——浏览器就得重新计算布局。这就像你重新摆家具,所有东西都得挪一遍。

重绘(Repaint),只是外观变化。比如改了背景色、文字颜色、阴影。元素尺寸没变,浏览器只需要重新画一下,不用重新算布局。

我在项目中遇到过最典型的场景:一个弹窗组件,每次打开都触发整个页面的回流。为什么?因为弹窗用了 position: fixed 配合 top: 50% 和 transform: translateY(-50%),但父容器没设宽高,导致浏览器每次都得重新计算弹窗位置对父容器的影响。

操作 触发回流 触发重绘 性能代价
修改 width / height 是 是 高
修改 margin / padding 是 是 高
修改 font-size 是 是 高
修改 color / background 否 是 低
修改 transform / opacity 否 是(合成层) 极低

我的习惯:能用 transform 和 opacity 实现的动画,绝不用 left、top、width。前者只触发合成,后者会触发回流。这个选择能省掉 80% 的布局计算开销。

页面切换时,新旧两个页面的 DOM 结构差异越大,回流范围就越广。如果切换时还伴随大量样式变更,那浏览器可能得把整个文档流重新算一遍。嗯,这就是卡顿的源头之一。

2.2 帧率与刷新率:流畅的“及格线”

为什么 60fps 是黄金标准?因为大多数显示器的刷新率是 60Hz,也就是每秒刷新 60 次。浏览器如果能在这 16.67ms 内完成一帧的渲染,用户看起来就是流畅的。

一旦帧率掉到 30fps 以下,人眼就能明显感觉到卡顿。页面切换时如果掉帧,你会看到动画一顿一顿的,或者页面像“闪”过去一样。

我曾经调试过一个 H5 活动页,在低端机上切换 tab 时帧率直接掉到 15fps。查了半天,发现是每个 tab 切换时都重新创建了 200 多个 DOM 节点,而且每个节点都加了 box-shadow 和 border-radius。这两个属性在重绘时特别吃 GPU 资源。

避坑指南:我曾经在项目里用 requestAnimationFrame 做动画循环,但忘了在页面隐藏时暂停。结果页面切到后台后,rAF 还在跑,白白消耗 CPU。后来我养成了习惯:页面可见性变化时,主动暂停/恢复动画。

帧率掉落的常见原因就这几个:

  • 回流范围太大——整个文档树重新计算
  • 重绘区域太多——比如全屏渐变背景
  • 合成层过多——每个 will-change 或 transform: translateZ(0) 都会创建独立层,层太多 GPU 也扛不住
  • JS 执行时间过长——占用了主线程,浏览器没时间渲染

这里我画了一张图,帮你理清渲染流水线的每个阶段:

浏览器渲染流水线(关键路径) DOM 构建 CSSOM 构建 布局(回流) 绘制(重绘) 合成 DOM + CSSOM = 渲染树 由 HTML 和 CSS 解析而来 计算元素几何位置(宽高/边距/定位) 触发条件:修改布局属性 填充像素(颜色/背景/阴影/文字) 触发条件:修改外观属性 分层合并 → 提交 GPU 显示 transform/opacity 在此阶段完成

从图里你能看到,回流和重绘发生在流水线的中段。一旦触发,后面的绘制和合成都得等。而 transform 和 opacity 直接跳到合成阶段,绕过了回流和重绘。这就是它们快的原因。

2.3 JS 线程阻塞:主线程的“交通堵塞”

浏览器的主线程是单线程的。它既要跑 JS,又要处理渲染。说白了,JS 和渲染是排队执行的。

如果 JS 执行时间超过 16.67ms,渲染就得等。等多久?等 JS 跑完。这就是为什么长循环、大计算、频繁的 DOM 操作会让页面卡死。

页面切换时,如果 JS 正在执行一个耗时的任务——比如解析 JSON 数据、渲染列表、执行动画回调——那切换动作就得排队。用户点击切换按钮后,页面可能过几百毫秒才有反应。

关键数据:

  • 16.67ms = 1 帧的预算(60fps)
  • 50ms = 用户能感知到延迟的阈值
  • 100ms+ = 用户会认为页面“卡”了
  • 500ms+ = 用户可能关闭页面

我建议你在开发时养成一个习惯:用 Chrome DevTools 的 Performance 面板录制一次页面切换操作。看看主线程上是不是有一大块紫色(JS 执行)占满了帧与帧之间的空隙。如果有,那就是阻塞了。

我曾经接手过一个后台管理系统,左侧菜单切换时卡得不行。录了一次 Performance,发现每次切换都触发了一个全量数据的深拷贝,耗时 80ms。而那个数据对象有 3000 多个字段。后来改成按需拷贝,切换时间降到了 5ms。

我的习惯:把耗时超过 10ms 的 JS 任务拆成微任务或使用 requestIdleCallback 延迟执行。页面切换这种高优先级操作,必须保证主线程在 16ms 内空闲出来。

2.4 三个元凶的联动效应

这三个问题不是孤立的。它们经常一起出现,互相放大。

举个例子:页面切换时,你执行了一段 JS 来更新 DOM(线程阻塞),DOM 更新触发了回流(布局计算),回流又触发了重绘(像素填充)。如果这个过程超过 16ms,下一帧就丢了。如果超过 50ms,用户就能感觉到卡顿。

说白了,页面切换卡顿 = JS 占着主线程不放 + 回流重绘反复触发 + 帧率掉到阈值以下。这三个问题只要有一个没控制住,体验就会崩。

避坑指南:我曾经在切换页面时用 innerHTML 直接替换整个容器内容。这个操作会触发一次大规模回流,因为浏览器得销毁旧 DOM 树、解析新 HTML、构建新 DOM 树、重新布局。后来我改用 documentFragment 分批插入,或者用虚拟 DOM 做 diff 更新,回流范围就小多了。