1、卡顿的本质:从用户感知到帧率分析

页面切换卡顿,说白了就是用户觉得「不跟手」。我做了这么多年前端性能优化,发现很多人一上来就扎进代码细节,却忽略了最根本的问题——我们到底在优化什么?

今天咱们就从用户感知出发,一路挖到帧率分析。把这个根儿捋顺了,后面的优化才有方向。

1.1 用户感知:卡顿的「体感」从哪来?

用户说「卡」,其实是在说两件事:

  • 响应慢:点了个按钮,页面半天没反应
  • 画面不流畅:切换动画一顿一顿的,像幻灯片

我记得有一次做移动端 H5 项目,用户反馈说「页面像在游泳」。后来一查,是路由切换时主线程被 JS 计算堵死了。用户感知到的,就是那几百毫秒的「空白期」。

核心结论:用户感知到的卡顿,本质上是视觉反馈与用户操作之间的时间差。这个时间差超过 100ms,人就会觉得「不对劲」;超过 300ms,基本就判定为「卡」了。

1.2 帧率:衡量流畅度的硬指标

为什么是 60fps?你想想看,人眼对画面变化的感知有个临界点。每秒 60 帧,每帧约 16.67ms,这个节奏下画面看起来是连续的。

我习惯把帧率比作「心跳」。正常心跳是 60 次/分钟,页面正常心跳就是 60fps。一旦掉到 30fps,用户立刻就能感觉到「心律不齐」。

帧率范围 用户感知 典型场景
60fps(16.67ms/帧) 流畅,无感知 原生应用、优秀 Web 应用
30-60fps 偶尔轻微卡顿 复杂列表滚动、动画密集页面
15-30fps 明显卡顿,体验差 低端设备、未优化的大页面
<15fps 几乎不可用 内存泄漏、主线程长时间阻塞

一个小技巧:Chrome DevTools 的 Performance 面板里,把帧率显示打开。看到红色长条就说明掉帧了。我每次做性能评审,第一件事就是看帧率曲线。

1.3 页面切换为什么会卡?

页面切换不是简单的「隐藏 A 显示 B」。浏览器要干一堆活:

  1. 卸载旧页面:触发 beforeunload、清理定时器、解绑事件
  2. 加载新页面:请求数据、解析 HTML/CSS/JS
  3. 渲染新页面:构建 DOM 树、计算样式、布局、绘制、合成
  4. 执行动画:过渡效果、路由动画

这些步骤里,任何一个环节超过 16.67ms,就会造成掉帧。我遇到过最典型的情况——某个页面在 componentWillUnmount 里做了大量数据清理,直接把下一帧给拖没了。

避坑指南:我曾经在一个 SPA 项目里,发现路由切换时有个诡异的 200ms 空白。查了半天,是某个组件在卸载时同步执行了 localStorage 的批量写入。嗯,这个操作在主线程上阻塞了渲染。后来改成异步 + requestIdleCallback,问题就解决了。

1.4 帧的生命周期:一帧里发生了什么?

要理解卡顿,就得知道一帧里浏览器在忙什么。我画了张图,你看完就明白了。

一帧的生命周期(16.67ms) 输入事件 JS 执行 样式计算 布局 绘制 合成 ⚠ 任何一个阶段超过 16.67ms,就会导致掉帧 最常见的是 JS 执行和布局阶段超时 各阶段耗时参考 • 输入事件:通常 <1ms,但事件处理函数可能拖慢 • JS 执行:最容易超时的环节,尤其是 DOM 操作和重计算 • 样式计算 + 布局:触发回流时耗时剧增 • 绘制 + 合成:GPU 加速可大幅降低耗时

你看,一帧里要做这么多事。其中任何一个环节超时,都会导致浏览器无法在 16.67ms 内完成渲染,下一帧就被跳过了。这就是掉帧。

1.5 卡顿的三大元凶

根据我的经验,页面切换卡顿 90% 的情况可以归到这三类:

  • 主线程阻塞:JS 执行时间过长,比如大量计算、同步网络请求、复杂的 DOM 操作。我见过有人把 10 万条数据的排序放在路由切换时做,直接卡死 2 秒。
  • 强制回流:在布局完成后又去读/写 DOM 的几何属性(比如 offsetHeight、getBoundingClientRect),导致浏览器被迫重新布局。嗯,这个坑我踩过不止一次。
  • 渲染管线过长:页面 DOM 层级太深、CSS 选择器太复杂、图层过多。尤其是移动端低端设备,GPU 性能有限,合成阶段很容易超时。

记住这个公式:
卡顿 = 主线程忙碌 + 渲染管线超时 + 帧率下降 → 用户感知延迟

1.6 如何量化卡顿?

光说「卡」不够,得用数据说话。我常用的几个指标:

指标 含义 可接受阈值
FPS 每秒帧数 ≥50fps 为良好,≥30fps 为可接受
Long Task 主线程上超过 50ms 的任务 每 10 秒内不超过 3 个
TTI (Time to Interactive) 页面可交互时间 ≤3.5s(移动端)
FCP (First Contentful Paint) 首次内容绘制 ≤1.8s

我个人习惯在开发环境打开 Chrome 的「Rendering」面板,勾选「FPS Meter」和「Layer Borders」。这样能实时看到帧率和图层情况。发现问题后,再用 Performance 面板录制一段操作,逐帧分析。

一个小工具:用 requestAnimationFrame 写个简单的帧率监控,打印到控制台。我经常在项目里加这么一段,上线前再删掉。代码很简单:

let lastTime = performance.now();
let frames = 0;

function checkFPS(time) {
  frames++;
  if (time - lastTime >= 1000) {
    console.log(`当前帧率: ${frames} fps`);
    frames = 0;
    lastTime = time;
  }
  requestAnimationFrame(checkFPS);
}
requestAnimationFrame(checkFPS);

1.7 本章小结

卡顿的本质,说白了就是帧率低于用户感知阈值。而页面切换卡顿,是因为切换过程中触发了大量计算和渲染工作,导致主线程超负荷运转。

记住几个关键数字:

  • 16.67ms —— 一帧的生死线
  • 50ms —— Long Task 的警戒线
  • 100ms —— 用户感知的临界点

后面我们会一步步拆解,怎么把这几个数字压下来。嗯,先从「帧率分析」这个基本功开始。