前端页面滚动卡顿:从性能定位到优化落地

内容提要

从 Performance 录制入手,区分输入延迟、掉帧与布局跳动,系统梳理滚动事件、同步布局、长列表、图片、图层以及 React/Vue 更新的优化方法,并给出代码示例和复测清单。

页面能快速打开,不代表滚动时一定流畅。滚动卡顿常见于长列表、图片流、复杂仪表盘,以及包含吸顶、视差和入场动画的页面。真正有效的优化,需要先判断耗时发生在哪里,再减少对应的工作。

推荐的处理顺序是:录制滚动过程 → 判断瓶颈 → 修改一个相关因素 → 在同样条件下复测。给所有事件加节流、给所有元素加 GPU 加速,都不能替代定位。

1. 先识别“卡顿”的具体表现

表现优先检查
滑动后页面迟迟不开始移动阻塞滚动的 touchmove、wheel 监听器
页面移动断断续续JavaScript、布局、绘制和栅格化
页面在动,吸顶栏或进度条落后依赖主线程的滚动联动逻辑
滚动到某张图片或模块时卡一下图片解码、组件初始化、复杂视觉效果
越往下滚越卡DOM 持续增加、内存增长、监听器泄漏
页面突然上下跳动未预留图片尺寸、内容插入、行高估算错误
只在手机或高分屏卡设备性能、绘制面积、图层和纹理内存

部分原生滚动可以由合成线程处理。主线程繁忙时,页面仍可能移动,但 JavaScript 驱动的效果会滞后。因此,需要分别观察滚动本身、滚动联动效果和内容加载。Chrome 的 Performance 文档说明了主线程更新和合成线程滚动可能出现不同步的情况。

2. 用 Performance 录制真实滚动

使用接近线上环境的生产构建,打开 DevTools 的 Performance 面板,等页面稳定后录制 5~10 秒滚动,经过容易卡顿的位置,再选中问题附近的时间段。

先看 Frames 判断是否掉帧,再看 Main 的任务分布,使用 Bottom-up 或 Call tree 找到主要耗时函数。不要只看整个录制的平均值,短时间的集中开销往往更能解释体感问题。

主要耗时常见原因优化方向
Scripting高频回调、组件更新、数据计算减少执行次数和单次工作量
Recalculate Style / Layout样式变化、布局范围过大、同步测量集中读写、缓存尺寸、减少布局变化
Paint大面积重绘、阴影和模糊效果简化特效、缩小绘制范围
Raster / GPU 相关工作像素处理、纹理上传、合成成本控制图片像素、图层和视觉效果
GC临时对象频繁创建、内存压力减少分配,检查被长期保留的对象

Rendering 面板的 Paint flashing 可用于观察重绘范围,Frame rendering stats 可辅助观察帧表现。工具细节见 Chrome 渲染性能工具。

60Hz 屏幕每次刷新间隔约 16.7ms,120Hz 约 8.3ms。这些时间需要浏览器各环节共同使用,不能全部交给业务 JavaScript。连续出现 20ms 的工作也可能掉帧,即使它没有达到通常超过 50ms 才计为长任务的门槛。渲染性能说明解释了帧预算与渲染流程。

3. 减少滚动回调里的工作

滚动时遍历所有节点、反复读取元素位置、更新整个页面的状态,或者执行排序和序列化,都可能挤占渲染时间。先问清楚每段逻辑真正需要什么频率。

需求优先方案
判断元素进入视口IntersectionObserver
吸顶定位CSS position: sticky
滚动进度和视觉联动CSS 滚动驱动动画,或轻量 rAF 更新
记录阅读位置、普通统计按业务需求进行时间节流
滚动结束后保存防抖,或兼容范围内的 scrollend
大量纯计算提前计算、缓存、分片或 Web Worker

例如,记录阅读位置通常不需要逐帧执行,可以使用一个保存最新位置的时间节流:

function observeReadingProgress(report) {
  let timer = null;
  let latestY = window.scrollY;

  function onScroll() {
    latestY = window.scrollY;
    if (timer !== null) return;

    timer = window.setTimeout(() => {
      timer = null;
      report(latestY);
    }, 100);
  }

  window.addEventListener("scroll", onScroll);

  return () => {
    window.removeEventListener("scroll", onScroll);
    if (timer !== null) window.clearTimeout(timer);
  };
}

这里的 100ms 是业务参数,适合允许低频更新的统计或记录,不适合需要紧跟手势的动画。report 自身仍应保持轻量;如果需要网络上报,还要单独控制请求频率和并发。

requestAnimationFrame 的作用主要是安排视觉更新、合并待处理工作,它不会让重计算变便宜,也不保证降低 scroll 的触发频率。滚动事件本身就可能接近屏幕刷新频率,不能把包一层 rAF 当作已经完成节流。MDN 的 scroll 文档专门提醒了这一点。

4. 正确使用 passive,避免阻塞滚动启动

监听 touchstart、touchmove 或 wheel 时,如果不需要取消默认行为,可以明确声明 passive: true,让浏览器知道无需等待回调来判断是否允许滚动。

element.addEventListener("touchmove", onTouchMove, {
  passive: true,
});

element.addEventListener("wheel", onWheel, {
  passive: true,
});

如果业务确实需要 preventDefault(),例如某些自定义手势,就不能直接改成 passive: true,否则会改变交互行为。普通 scroll 事件不可取消,给它添加 passive 通常不是关键优化;回调如果很重,仍会占用主线程。参见 Chrome 的被动事件监听器说明。

5. 避免反复触发同步布局

典型问题是先修改样式,再立刻读取几何信息。浏览器为了给出最新结果,可能必须提前计算布局;在循环中反复交替读写,就会形成布局抖动。

// 容易反复触发布局:写入和测量交替发生
for (const item of items) {
  item.style.width = "200px";
  const height = item.offsetHeight;
  processHeight(height);
}

如果要获取修改宽度后的高度,可以集中写入,再集中读取:

for (const item of items) {
  item.style.width = "200px";
}

const heights = items.map((item) => item.offsetHeight);
heights.forEach(processHeight);

前提是 processHeight 不继续交替修改样式和测量页面。offsetHeight、getBoundingClientRect() 并非每次调用都强制布局,关键在于之前的操作是否让布局失效,以及当前是否必须同步取得结果。

实践中,应集中几何读取和样式写入,缓存稳定的尺寸;在窗口变化、图片加载和内容变化时更新缓存,不要每次滚动都重新测量全部节点。参见 避免复杂布局与布局抖动。

6. 长列表优先评估虚拟化

几千行表格、聊天记录或信息流会带来大量 DOM 和组件实例。虚拟列表保留完整数据,只挂载可见区域及其附近的内容,并通过占位尺寸维持整体滚动范围。

例如,10,000 条数据一次只展示 20 条时,可以只挂载可见项和上下缓冲项。实际缓冲数量应依据行复杂度、滚动速度和设备表现决定,而不是使用固定的万能阈值。Vue 官方性能指南也将大型列表虚拟化列为重要优化方式。

  • 使用稳定的数据 ID,避免无谓重建。
  • 动态行高需要测量和缓存,图片加载后可能需要修正。
  • 缓冲区太小,快速滚动可能出现空白。
  • 保留焦点、键盘导航、无障碍和滚动位置恢复能力。
  • 无限加载不等于虚拟列表;不断追加 DOM 仍会越滚越重。

对于长文章或相对独立的内容区块,也可以试验屏外渲染跳过:

.article-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 600px;
}

600px 是占位估计,应根据内容调整。content-visibility 可以帮助跳过部分屏外内容的布局和绘制,但不会移除 DOM,也不会自动停止组件中的 JavaScript。频繁测量屏外子树可能削弱收益,使用后应验证定位和布局稳定性。参见 MDN:content-visibility。

7. 用 IntersectionObserver 处理可见性需求

图片懒加载、内容入场和接近底部加载,一般无需在每次滚动时遍历所有元素。

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.classList.add("is-visible");
      observer.unobserve(entry.target);
    }
  },
  {
    root: null,
    rootMargin: "200px 0px",
    threshold: 0,
  }
);

document.querySelectorAll(".reveal").forEach((element) => {
  observer.observe(element);
});

// 页面或组件销毁时调用 observer.disconnect()

这里将观察区域向上下扩展约 200px。若目标位于内部滚动容器,应根据需求设置 root。观察器适合可见性阈值判断,不适合精确逐像素联动;回调仍在主线程运行,不能包含大量同步计算。无限加载还应控制并发、重复触发和结束状态。参见 Intersection Observer API。

8. 简化昂贵效果,谨慎使用图层

如果主要耗时在 Paint、Raster 或合成相关工作,继续给 JavaScript 加节流未必有明显收益。可以逐项关闭大面积背景模糊、复杂阴影、透明叠加、固定背景和动态图形,对照性能记录判断影响。

位移和淡入淡出通常优先使用 transform、opacity,避免逐帧修改可能引发布局的 top、left、width 等属性。不过,是否能主要走合成路径,仍取决于元素内容和其他效果。动画与性能提供了相关原则。

will-change 适合针对已确认的问题、少量需要提前准备的动画元素使用。为所有元素设置 will-change: transform 或 translateZ(0),可能增加资源占用和图层成本,尤其不适合低内存设备。参见 MDN:will-change。

阅读进度条等效果也可考虑 CSS 滚动驱动动画,减少 JavaScript 联动代码:

.progress {
  position: fixed;
  inset: 0 0 auto;
  height: 3px;
  background: #2563eb;
  transform: scaleX(0);
  transform-origin: left;
}

@supports (animation-timeline: scroll()) {
  .progress {
    animation: reading-progress linear both;
    animation-timeline: scroll(root block);
  }
}

@keyframes reading-progress {
  to { transform: scaleX(1); }
}

上线前应核对目标浏览器兼容性。animation-timeline 要放在 animation 简写之后,避免被重置;复杂动画自身的绘制成本仍需检查。参见 MDN:animation-timeline。

9. 控制图片像素尺寸,预留布局空间

压缩文件小,不代表解码后的图片占用小。4000 × 3000 的图片,按每像素 4 字节粗算,一份像素数据约为 48MB;实际总开销还取决于浏览器实现和资源副本。

  • 根据展示尺寸和设备像素比提供合适的图片,使用 srcset、sizes。
  • 设置正确的 width、height 或 aspect-ratio,避免加载后把页面撑开。
  • 屏外图片按需使用 loading="lazy",首屏关键图片需要及时加载。
  • 按场景使用 decoding="async";它不会消除解码工作。
  • 避免进入视口时集中初始化大量大图、GIF、视频和 Canvas。
<img
  src="/images/card-800.webp"
  width="800"
  height="500"
  loading="lazy"
  decoding="async"
  alt="内容描述"
/>

如果只是内容尚未加载,但已有页面能流畅滚动,应进一步区分网络等待和渲染卡顿,避免把所有问题都归因于下载速度。

10. 限制 React / Vue 的高频更新范围

为了控制一个返回顶部按钮,却把每个 scrollY 都放在页面顶层状态中,可能带动大量无关组件更新。优化时应让状态影响范围与实际功能范围一致。

  • 只关心是否超过 200px 时,只在这个布尔值发生变化时更新状态。
  • 将滚动状态放到实际使用它的小组件中,避免广播到全局。
  • React 中不参与渲染的高频临时值可放入 useRef;ref 变化不会触发重渲染。
  • 用 Profiler 确认更新昂贵的组件,再评估 memo 和稳定 props。
  • Vue 中同样要避免高频修改被大量组件依赖的响应式数据。

memo 不是越多越好:props 每次都变,缓存可能无法生效;比较成本也需要考虑。Vue 的 shallowRef 可用于特定的大型不可变数据结构,但需要采用对应的根级替换更新方式。细节见 React:useRef、React:memo和 Vue 性能指南。

11. 检查与滚动竞争主线程的其他任务

卡顿发生在滚动期间,不代表原因一定在 scroll 回调中。请求完成后的大批量 DOM 插入、数组排序、图表初始化、同步存储和第三方脚本,都可能在此时占用主线程。

处理大量纯计算可以评估 Web Worker;Worker 不能直接操作 DOM,也需要考虑消息传输成本。大量更新可分批执行,非必要初始化可推迟,并清理已经销毁页面的定时器、观察器、监听器和订阅。

需要特别避免两个误区:给函数加 async 不会自动把计算移出主线程;await Promise.resolve() 也不等于为渲染让出机会,连续微任务仍可能阻塞渲染。

有必要拆分任务时,可以在兼容范围内使用 scheduler.yield(),或采用定时任务分片。分片中的单个工作单元仍需足够小,不能在一次迭代里执行一整段重计算。参见 web.dev:优化长任务。

12. 用同样场景验收优化效果

固定设备、浏览器、数据量和滚动路径,每次只修改一组相关因素,再对照录制结果。优先关注问题时间段,不要只看 Lighthouse 分数或整个过程的平均 FPS。

  • 问题位置的掉帧是否减少,帧耗时分布是否改善。
  • 滚动相关函数、Layout 和 Paint 的耗时是否下降。
  • DOM 是否随无限滚动持续增长,返回页面后是否仍保留大量对象。
  • 快速滚动是否出现空白、跳位、重复请求或滚动位置丢失。
  • 吸顶、弹窗、触摸手势、键盘导航和无障碍是否正常。
  • 优化在目标手机和目标浏览器上是否仍然有效。

CPU 降速可以暴露部分主线程问题,但无法完整模拟手机的 GPU、内存、触摸输入和温控。真实设备上的持续滚动测试仍然必要。

实际排查时,先检查高频事件与框架更新,再检查反复布局和长列表,最后根据性能记录处理图片、视觉效果和图层。每项修改都对应一个明确的耗时来源,并用同样场景确认收益,才能建立可重复的优化方法。