1、卡顿的本质:什么是页面切换卡顿?

大家好,我是你们这趟性能优化之旅的向导。今天咱们聊点最根本的东西——卡顿到底是个啥?

很多同学一遇到页面切换卡顿,第一反应就是“代码写得烂”、“DOM 操作太多”。嗯,这些确实有关系,但没说到根上。我个人习惯是,遇到任何性能问题,先回到浏览器最底层的渲染机制去思考。说白了,你得先搞清楚浏览器是怎么把代码变成像素的,才能知道它到底在哪个环节“卡了壳”。

1.1 从“流畅”与“卡顿”的直观感受说起

你想想看,为什么 60fps 的视频你觉得流畅,而 15fps 的视频你觉得像幻灯片?

人眼的视觉暂留效应决定了,当画面更新频率低于某个阈值时,我们就能感知到“一顿一顿”的跳跃感。对于网页交互来说,这个阈值就是 60 帧/秒(60fps)。

核心结论:页面切换卡顿,本质上是浏览器在 16.67ms(1000ms / 60)内,没能完成一帧画面的渲染和提交。

我在项目中遇到过好几次这样的情况:产品经理说“这个页面切换有点慢”,开发同学说“我接口返回才 200ms,不慢啊”。你看,这就是典型的认知错位。接口快不等于渲染快。卡顿的根源,往往不在网络,而在浏览器主线程的渲染流水线上。

1.2 帧率(FPS)—— 卡顿的“体温计”

FPS(Frames Per Second)是衡量页面流畅度的最直接指标。它表示浏览器每秒能绘制多少帧画面。

FPS 范围 用户体验 典型场景
60fps 丝滑流畅,无感知 原生 App 列表滑动、游戏
30-60fps 偶尔轻微卡顿,可接受 复杂 SPA 页面切换、懒加载
15-30fps 明显卡顿,体验差 大量 DOM 操作、未优化的动画
< 15fps 几乎不可用,像死机 内存泄漏、无限循环重排

我的小技巧:在 Chrome DevTools 的 Performance 面板里,把 FPS 图表打开。如果看到红色长条,或者 FPS 掉到 30 以下,那基本就是卡顿现场了。我曾经靠这个工具,定位到一个因为 requestAnimationFrame 嵌套调用导致主线程被占满的 bug。

1.3 渲染流水线(Critical Rendering Path)—— 卡顿的“解剖图”

光知道 FPS 低还不够,你得知道为什么低。这就得请出渲染流水线了。

浏览器把 HTML/CSS/JS 变成屏幕上的像素,大致要经过这么几个步骤:

  1. DOM 树构建: 解析 HTML,生成 DOM 节点树。
  2. CSSOM 树构建: 解析 CSS,生成样式规则树。
  3. Render Tree 合成: 把 DOM 和 CSSOM 合并,只包含可见元素。
  4. 布局(Layout/Reflow): 计算每个可见元素的几何位置(宽、高、坐标)。
  5. 绘制(Paint): 把元素绘制成像素位图(填充颜色、边框、阴影等)。
  6. 合成(Composite): 把多个图层合并到屏幕上。

页面切换卡顿,通常就发生在这条流水线的某个环节“超时”了。

为什么会超时?我举个例子。假设你切换页面时,用 JS 改了 100 个元素的 left 和 top 属性。浏览器就得重新计算布局(Layout),然后重新绘制(Paint)。这一套下来,如果超过 16.67ms,下一帧就赶不上了,卡顿就来了。

避坑指南:我曾经在一个后台管理系统里,看到有人用 offsetTop 和 offsetLeft 来获取元素位置,然后紧接着又修改了元素的样式。这会导致浏览器强制进行“同步布局”(Forced Reflow),性能直接崩盘。记住,读布局属性和写样式属性,一定要分开!

1.4 核心知识体系:卡顿的底层逻辑

下面这张图,是我自己总结的“卡顿根因分析框架”。每次遇到性能问题,我都会拿它来对照排查。

页面切换卡顿根因分析框架 页面切换卡顿根因分析框架 卡顿的本质 帧率 (FPS) 下降 渲染流水线阻塞 主线程 JS 执行过长 强制同步布局 (FSL) Layout 重排开销大 Paint 绘制区域过大 长任务 (Long Task) 布局抖动 (Layout Thrashing) 重排 + 重绘连锁反应 图层合成瓶颈 排查路径:先看 FPS 是否掉帧 → 再看 Performance 面板的 Main 线程 → 定位是 JS 执行慢还是渲染流水线阻塞

1.5 一个简单的代码示例:感受卡顿

光说不练假把式。咱们写一小段代码,感受一下卡顿是怎么产生的。

// 模拟一个导致卡顿的页面切换操作
function switchPageWithLag() {
  // 假设这里有 1000 个 DOM 元素需要更新
  const items = document.querySelectorAll('.list-item');
  
  // 错误做法:在循环中同时读写布局属性
  for (let i = 0; i < items.length; i++) {
    // 读:强制触发布局
    const width = items[i].offsetWidth;  
    // 写:再次触发布局
    items[i].style.width = (width + 10) + 'px';  
  }
  // 结果:浏览器被迫进行了 1000 次布局计算!
}

// 优化后的做法
function switchPageSmooth() {
  const items = document.querySelectorAll('.list-item');
  const widths = [];
  
  // 先统一读取
  for (let i = 0; i < items.length; i++) {
    widths.push(items[i].offsetWidth);
  }
  
  // 再统一写入
  for (let i = 0; i < items.length; i++) {
    items[i].style.width = (widths[i] + 10) + 'px';
  }
  // 结果:浏览器只进行 1 次布局计算!
}

你看,同样的功能,只是把读写顺序调整了一下,性能差距就是天壤之别。这就是理解渲染流水线带来的好处。

1.6 小结:卡顿的底层公式

最后,我给大家一个非常实用的公式,可以帮你快速判断卡顿风险:

卡顿风险 = 单帧渲染耗时 / 16.67ms

如果这个比值大于 1,卡顿必然发生。如果接近 1,说明你的页面在“刀尖上跳舞”,稍微加点负载就崩。

嗯,这一章的内容就到这里。记住,理解卡顿的本质,是解决一切性能问题的起点。后面我们会深入每一个环节,看看怎么把那个 16.67ms 的“蛋糕”切得更合理。