打开一个稍微有点年头的项目,最让人头疼的往往不是业务逻辑,而是滚动列表一卡一卡的,像老式胶片放映机。这里的核心矛盾在于:浏览器的渲染管线(Render Pipeline)是按帧执行的,而scroll事件却像一个永远不知疲倦的循环,每个像素的位移都可能引发一次完整的渲染流程。前端性能优化中的“高性能滚动”和“页面渲染优化”,本质上就是在回答一个问题:如何在滚动这种高频、连续、不可预测的场景里,用最少的渲染代价,让画面以 60fps 的节奏稳定输出。
我做过不少长列表、地图拖拽、横向滚动墙这类交互,踩过的坑足够写满一屏。这篇东西不讲大而全的理论,只讲我在实际调优中用到的、真正能落地的高性能滚动方案和页面渲染优化手段。无论你是刚接手一个卡顿严重的项目,还是准备从零设计一个数据密集的滚动页面,这套思路应该都能给你一个明确的排查和优化方向。
1. 滚动卡顿的根源:渲染管线与帧预算
1.1 你只有 16.7ms 去完成一帧
先建立最基本的认知。现在的显示器普遍是 60Hz,也就是每秒刷新 60 次画面。浏览器为了配合这个节奏,会尽可能把 JavaScript 执行、样式计算、布局、绘制、合成压缩到每一帧里。一帧的总时间预算是 1000ms / 60 ≈ 16.7ms。也就是说,从你手指触发滚动到屏幕上出现新画面,这个时间段里如果你做了太多事,浏览器就只能把当前的工作推迟到下一帧,视觉上就是掉帧、卡顿、滚动不跟手。
很多人以为滚动性能优化的重点是“让 scroll 事件回调跑得更快”,这个认知是片面的。真正要盯的是整条渲染管线的开销。scroll 事件只是一个导火索,它把各种操作炸成重排(Reflow)、重绘(Repaint)、合成(Composite)这些成本不同的阶段。好比一个流水线,你只优化了第一道工序,后面几道工序照样把你辛辛苦苦省下来的时间吃掉。
在浏览器里,一帧的典型流程是这样的:
- JavaScript 执行:事件回调、动画逻辑、数据请求处理。
- 样式计算(Style):匹配 CSS 选择器,确定元素最终的计算样式。
- 布局(Layout):根据样式计算元素的几何位置、尺寸。
- 绘制(Paint):把文本、颜色、图片等绘制到图层上。
- 合成(Composite):把多个图层按正确顺序合并到屏幕上。
优化目标就是把前面的阶段尽量跳过。最理想的状态是每一帧只做“JavaScript 动画属性”+“合成”,因为布局和绘制一旦涉及,成本会指数级上升。
1.2 滚动到底触发了什么级别的渲染
要理解页面渲染优化,必须先知道哪些属性变化会让浏览器走完整个渲染管线。我整理了一个常用属性开关速查表:
| 属性 | 触发阶段 | 成本评估 | 备注 |
|---|---|---|---|
width、height、top、left | Layout + Paint + Composite | 极高 | 修改几何尺寸一定会重排 |
margin、padding、border | Layout + Paint + Composite | 极高 | 影响周围元素布局 |
color、background | Paint + Composite | 中等 | 不触发布局,但仍重绘 |
opacity | Composite | 低 | 只要不直接改父级透明度,通常只合成 |
transform | Composite | 低 | 平移、缩放、旋转都走合成器 |
filter、box-shadow | Paint + Composite | 高 | 阴影和滤镜很容易消耗 GPU 资源 |
我拿自己的一个项目举例:要做一个横向拖动的卡片墙,最初实现时直接改style.left。拖动时每一帧都要重新计算卡片的位置,一旦卡片数量超过 40 张,Android 低端机上就能明显感觉到“拖着拖着卡一下”。到后来改成transform: translateX(),配合will-change: transform,同时把布局抽离成独立图层,同样的卡片数量,帧率恢复稳定。这不是玄学,而是浏览器内部的渲染路径确实不一样。
理解这些,才能理解高性能滚动到底要压什么、省什么。接下来我们把 scroll 事件本身这个“发动机”拆开看。
2. scroll 事件降频:节流、防抖与 requestAnimationFrame
2.1 scroll 的触发频率远高于你的预期
先说一个易被忽略的事实:scroll事件在鼠标滚轮和触摸板上不是一格触发一次,而是连续触发,甚至一帧触发好几次。这意味着如果你在回调里做 DOM 查询、数据拼接、图片懒加载判断,这些操作都会挤进同一帧,跟渲染抢时间。
一个很典型的错误代码长这样:
window.addEventListener('scroll', () => { const top = document.querySelector('.header').offsetTop; // 强制同步布局 document.querySelector('.sidebar').style.top = top + 'px'; // 再次改布局 checkLazyLoad(); updateActiveNav(); });这段代码每一帧都不能够完成,因为offsetTop会强制浏览器立刻计算布局,而改style.top又让它后面再算一次。你等于让浏览器不断“问一个问题,马上又把答案推翻”,这比单纯的慢还要致命。
2.2 三种降频方案的取舍
先讲成熟的降频方案,下面是它们的核心语义:
throttle(节流):固定时间间隔执行一次,比如 200ms 执行一次。debounce(防抖):高频触发后,停止触发 N 毫秒才执行一次。requestAnimationFrame(rAF):跟随浏览器帧率,在每次重绘之前执行。
我在实际优化中总结的选型规律是:需要跟随滚动手势实时反馈的(如下划线跟随、吸顶判断),用requestAnimationFrame最合适;需要滚动结束后再做收尾工作(如记录位置、埋点、懒加载计算),用debounce更稳妥;而throttle适合“不管你滚动多快,我只保证每 100ms 确认一次状态”的场景。
举个例子,吸顶效果如果只用scroll事件判断window.scrollY,然后修改类名和样式,很容易出现盒子“追不上滚动速度”的现象。用 rAF 可以把这个判断和浏览器帧对齐:
let ticking = false; window.addEventListener('scroll', () => { if (!ticking) { window.requestAnimationFrame(() => { updateStickyState(window.scrollY); ticking = false; }); ticking = true; } }, { passive: true });这段代码的关键在于ticking标志位。无论 scroll 事件触发多少次,一帧内最多执行一次回调。它不像throttle有固定延时,也不像debounce要等滚动停下来,而是在浏览器准备渲染之前给你一次反馈机会。这是滚动场景下性价比极高的一种模式。
2.3 别忘了 passive: true
还有一个很隐蔽的性能漏洞:如果scroll事件监听器没有标记为被动(passive),浏览器会担心你的回调里调用preventDefault()来阻止滚动,所以它必须等到事件回调执行完,才能去执行滚动。这会徒增每一帧的等待时间。
加上{ passive: true }之后,浏览器可以放心大胆地立刻开始滚动,你的回调在后台异步执行。现代浏览器中,如果你给window和document绑定普通的scroll事件,很多浏览器默认已经按 passive 处理,但 Chrome 对touchstart、touchmove等事件的处理就不一样。为了统一,建议在一切不需要取消默认行为的触摸和滚动监听里,主动写清楚第三个参数:
element.addEventListener('touchmove', handler, { passive: true });踩过一趟之后,我对所有滚动和触摸事件都会默认加passive: true,把“会不会取消默认行为”这层担心在代码层面明确排除。
3. 真正的性能杀手:重排与布局抖动
3.1 能走合成器,就不要走布局
上一节只是降低了事件回调的频率,但真正让页面卡顿的往往是代码里不经意的布局读写。我重点想说一个动作:滚动过程中动态修改元素几何位置。
常见的实现是使用top或left:
box.style.top = scrollY * 0.5 + 'px';修改top会立刻让浏览器把该元素标记为脏,并在下一个渲染帧执行完整布局。更难受的是,如果该元素周围还有依赖它位置的兄弟节点,布局范围会扩大到子树甚至整个文档。
改成transform之后情况完全不同:
box.style.transform = `translate3d(0, ${scrollY * 0.5}px, 0)`;transform不会影响文档流中的其他元素,浏览器可以把这个元素提升到独立图层,由合成器处理。合成器通常由 GPU 接管,GPU 处理平移是它的老本行,根本不需要重新计算任何文档流关系。
这就是为什么滚动动画、视差滚动、抽屉抽屉这些和手势强相关的交互,我统统建议用transform而不是定位属性。需要注意,translate3d里的3d只是用来触发 GPU 加速的常见写法,它本身不代表视觉上一定有 3D 透视。使用时要克制,不要整天给所有元素都加translate3d(0, 0, 0),那是透支内存的降智优化。
3.2 读写交替造成的 Layout Thrashing
比单个属性开销更隐蔽的是读写交替导致的“强制同步布局”。浏览器有一个渲染队列优化机制:你连续修改多个样式,它不会立即逐个重新布局,而是攒到最后一次性算。但是,当修改样式后立刻读取需要布局才能拿到的属性时,浏览器被迫提前清算队列,来给你一个准确答案。这就是 Layout Thrashing。
典型的例子:
function resizeItems() { items.forEach(item => { const width = item.offsetWidth; // 强制先进一次布局 item.style.width = (width + 10) + 'px'; // 再标记一次脏 }); }这里每循环一次就强制刷新一次布局,性能瞬间退化到可怕的地步。解决办法是“先统一读,再统一写”:
const widths = items.map(item => item.offsetWidth); // 批量读,只触发一次布局 items.forEach((item, index) => { item.style.width = (widths[index] + 10) + 'px'; // 批量写,随后浏览器统一改 });把读到的东西先存进内存,后续写操作不再去碰任何会触发布局的属性。这种“读写分离”是我做高性能滚动时最常用也最有效的工程手段。
3.3 will-change 与 contain 的正确打开方式
既然合成器那么快,是不是给所有滚动元素都加上will-change: transform就万事大吉了?不是。will-change的作用是提前告诉浏览器“这个元素接下来要变化,最好先给它准备一个独立图层”。但每个独立图层都要占用内存,几个、几十个都没问题,如果是几百个元素同时加,内存可能先崩。
我自己的经验是:对少数真正会一直动的元素用will-change,对大量静态元素千万别加。比如滚动吸顶的头部、拖拽的手柄、视差层的背景,这些是合适的使用对象。等动画结束后,应该把will-change移除掉,让浏览器回收图层资源。
另一个实战中很少被提及但很有效的属性是content-visibility,它可以让视口外的元素跳过渲染工作。但要注意,它更适合长页面或长列表的初始加载优化,而不是高频滚动过程中的实时优化。在滚动过程中,它需要浏览器不断判断元素是否进入视口,这个判断本身也有成本。我的建议是:优先用虚拟滚动解决列表问题,用content-visibility解决“初次渲染超大文档”的场景。
4. 实战:长列表高性能滚动方案
4.1 懒加载:把 IntersectionObserver 用起来
滚动性能优化躲不开一个场景,就是长列表。最原始的做法是用scroll事件判断图片是否出现在视口,然后给img标签塞src。但这个判断放在 scroll 回调里,等于是布局抖动和不必要 DOM 操作的温床。
更干净的做法是使用IntersectionObserver。它由浏览器原生实现,回调触发是异步的,而且完全不需要监听 scroll 事件,也就没有降频问题:
const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: '200px' }); document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));加一个rootMargin: '200px',相当于提前 200px 开始加载,体验上基本无感。这张图片一旦进入视口就立刻移除观察,避免后续回调打扰。用这种方案之后,一个 300 张图片的瀑布流页面,滚动过程的卡顿率肉眼可见地下降。
4.2 虚拟滚动:只渲染看得见的节点
当列表不断滚动,DOM 节点数量一大,懒加载只能让图片不卡,但没法阻止 DOM 本身拖慢布局。一个 10000 条数据的列表,就算每条只有 20 个节点,加起来也是 20 万个节点,这就不可能有流畅的 JS 计算和布局。
虚拟滚动的思路非常粗暴:不管数据有多少条,我只渲染视口内能看到的那十几条。通过计算scrollTop和每行高度,得到一个起始索引和一个结束索引,然后只渲染这个动态子集,并给容器加一个 padding 或占位高度,让滚动的滚动条长度看起来和真实列表一样。
一个最简的固定行高虚拟滚动逻辑大致是这样:
const rowHeight = 64; const totalCount = 10000; let start = 0; let end = 10; function onScrollUpdate() { const scrollTop = container.scrollTop; start = Math.floor(scrollTop / rowHeight) - reserve; end = start + visibleCount + reserve * 2; content.style.transform = `translateY(${start * rowHeight}px)`; content.innerHTML = items.slice(start, end).map(renderItem).join(''); }注意这里又用了transform来整体平移内容区,配合绝对定位的占位容器,就能实现“滚到哪渲染到哪”。这个方案对固定高度行极度高效,但行高不固定时需要额外测量,复杂度会上去。我遇到动态高度时,通常会采用“先按估计高度渲染,测到真实高度后再修正”的二次调整策略,而不是一开始就精确测量。
4.3 给浏览器喘息:分层与保护区
在长列表之外,还有一个实战技巧是“分层”。把不变的内容和滚动变化的内容拆到不同的图层中,避免每次滚动都把整块区域重绘。比如一个卡片墙,背景渐变、头像、文案这些不变的部分,如果和滚动位移放在同一个图层,每次滚都要跟着重画。把它抽到一个固定的position: fixed罩层下面,变成独立合成层,就能大幅减少滚动时的重绘面积。
另一个值得说的点是“动态渲染的保护区”。我在一个数据可视化大屏项目里,滚动区域会不断插入新 DOM,这种操作容易引起布局抖动。后来我把新节点的插入时机改为requestIdleCallback,在浏览器空闲时批量挂载,而不是滚动过程里同步插。这听起来有点跨界,但确实是高性能滚动里很重要的一环:不要把滚动的每一帧都安排满,给浏览器留一点闲置时间,它才能稳定地输出画面。
5. 性能排查与避坑实录
5.1 用 Performance 面板定位掉帧点
优化做得再多,没有量化手段都是感觉流。我会在 Chrome DevTools 的 Performance 面板里录制一段滚动操作,然后盯着“帧”面板看有没有红色的长任务。正常情况下,每一帧的绿色高度应该在 16.7ms 的辅助线附近,如果出现连续超过这个高度的任务块,说明滚动过程中的某个操作超预算了。
随后看“Meal”里的调用栈,重点找两个痕迹:
- 出现大量“Update Layer Tree”或者“Layout”记录,说明有属性改动触发重排。
- 出现“Forced Reflow”的黄色警告,说明你的代码里写到了读取布局属性。
我常用的排查步骤是:先关掉所有 JS 特效,看帧率是否恢复;恢复后再逐个打开特效,二分定位到具体代码。这种手段比看一屏控制台日志高效得多。
5.2 常见问题速查表
| 症状 | 可能原因 | 建议 |
|---|---|---|
| 滚动时列表上下抖动 | 读取offsetTop/getBoundingClientRect()后立即写样式 | 统一读、统一写 |
| 滚动一卡一卡但不掉帧 | scroll 回调里有重 DOM 操作 | 用 rAF 合并操作,或移到requestIdleCallback |
| 滚动到较深位置变慢 | 列表 DOM 节点过多 | 上虚拟滚动,或content-visibility: auto |
| 图片加载时页面跳动 | 图片无占位尺寸 | 给img设置宽高比,用aspect-ratio占位 |
| 吸顶元素出现“拖影” | 使用了top动画 | 改成transform |
| 低端安卓机滚动骤卡 | 大量will-change占用图层内存 | 只对动态元素启用,结束后及时移除 |
5.3 几个真实场景的坑
单独说说我遇到过的两个坑,都很有复盘价值。
第一个是“只降频不解决绘制”。我一度以为把 scroll 回调节流到 100ms 一次就够了,结果帧率没什么变化。后来一查,发现节流后的回调虽然变少,但它每次改动导致的重绘面积特别大,尤其是改了一个带动画背景的容器。节流只减少了执行次数,没有减少单次执行的成本。从那以后,我做滚动优化会同时盯着“回调频率”和“单次回调的渲染代价”两个维度。
第二个是“fixed 元素在 iOS 上的抖动”。iOS Safari 的滚动机制和桌面端不同,position: fixed在滚动过程中偶尔会有延迟。后来我把固定元素改成了position: sticky方案,或者在外层容器上用transform: translateZ(0)提前把层提到 GPU 处理,情况才稳定下来。这种问题在桌面上完全复现不出来,一旦上真机就暴露,所以做移动端滚动性能优化一定要准备一台低端机实测。
还有一个容易被忽略的小技巧:滚动容器最好设置明确的height和overflow-y: scroll。如果容器高度依赖auto,浏览器无法预知内容滚动范围,就可能在滚动过程中反复计算实际高度。固定高度能让滚动过程的可预测性大幅提升。
写到最后,说一条我自己的习惯:每次完成一轮滚动优化,我会立刻用手机真机上滑三屏、再快速回顶,同时开着 DevTools 的 FPS Meter 盯帧率。代码逻辑再漂亮,都抵不过真机上一双手的实测。高性能滚动没有银弹,把渲染管线拆细、把事件频率降下来、把布局读写理顺,这三件事做到位,页面至少能稳定地跑在 60fps 的节奏上。