页面切换卡顿的根源:重绘、回流与线程阻塞
做前端这么多年,我见过太多页面切换卡顿的案例。有些是动画掉帧,有些是白屏闪烁,还有的是点击后半天没反应。说实话,这些问题背后的原因其实很集中。今天我就把最核心的三个元凶拆开来讲。
你想想看,浏览器渲染页面就像工厂流水线。任何一个环节卡住,整个流程都得等。而页面切换,恰恰是这条流水线压力最大的时刻。
核心结论:页面切换卡顿 = 回流重绘频繁触发 + 帧率低于阈值 + 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 执行时间过长——占用了主线程,浏览器没时间渲染
这里我画了一张图,帮你理清渲染流水线的每个阶段:
从图里你能看到,回流和重绘发生在流水线的中段。一旦触发,后面的绘制和合成都得等。而 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 更新,回流范围就小多了。