资讯动态

滚动卡顿优化实战:从渲染管线到scroll监听与长列表

发布时间:2026/10/13 13:30:14 来源:尧图企业网站定制
先抛一段最近的处理经历。某个信息流页面快速滑动时掉帧严重手指划过去页面像卡了齿轮。我的第一反应和大多数人一样先去看滚动监听函数把里面能省的逻辑全省一遍节流、防抖、状态标记全用上。结果折腾完一轮性能面板里录出来的长任务依然扎眼卡顿一点没少。直到后来把整条渲染链路拆开看才发现问题根本不在 scroll 事件函数本身而是每次滚动都触发了一整条昂贵的主线程渲染管线。这件事给我的教训很直接高性能滚动 scroll 及页面渲染优化不能只盯着监听器写代码得从浏览器渲染的底层流程去抠每一帧的成本。这篇文章就围绕滚动场景下的页面渲染优化展开讲清楚渲染管线、合成器、scroll 监听瘦身、长列表工程化方案最后给出一套可以直接贴到项目组里用的排障自查清单。适合前端开发、性能优化负责人以及被线上滚动卡顿折磨过的人。1. 一次滚动卡顿排障实录问题不全在 scroll 监听器身上1.1 卡顿现场与最初的排查方向那个信息流页面大概有几百个卡片节点每张卡片里还有图片、文本描述和一些交互按钮。快速滚动时页面出现了明显的“顿挫感”具体表现是滚动条像被黏住一样松手后页面还会再跳一下。业务方反馈说“代码太重了”所以最初的排查方向自然会指向 scroll 事件里的数据处理逻辑。我检查了代码里的滚动监听器确实不少。有的监听做懒加载有的监听做导航栏显隐还有的在滚动里计算进度条位置。每个监听器里都带着若干 DOM 查询和样式写入操作。从直觉上看这确实是可疑的点。但用开发者工具的性能录制面板跑了一段滚动操作之后结论并不支持直觉。放大其中一帧的主线程火焰图黄色脚本块只占了很小一段真正刺眼的是大片的紫色区块——样式计算和布局。也就是说scroll 事件处理函数本身没有被塞满重活但它调用了很多 DOM API这些 API 间接把渲染管线拖进了每帧的必然执行路径里。1.2 性能面板给出的真实结论强制同步布局拖垮了主线程继续深挖之后发现代码里有一个非常典型的坏毛病在滚动回调里反复读取布局属性比如offsetTop、offsetHeight、clientWidth紧接着又写入样式比如改top、height然后下一个循环继续读布局属性。这种“读-写-读-写”交替出现的模式会让浏览器在每次读取时强制完成一次同步布局术语叫Forced Synchronous Layout也就是前端圈常说的“布局抖动”。这类问题在火焰图里非常显眼。你会在脚本执行区域后面看到一根又细又长的紫色尾巴说明每次脚本执行都在等待布局完成。整个滚动过程里主线程做着大量重复的布局计算帧率自然掉到 40fps 以下。1.3 拆解滚动链路的三个角色事件、主线程、合成器把视角放大一点看一次滚动操作从头到尾要经过三个环节。第一环节是事件派发浏览器把滚动事件派发给注册的监听器如果你的监听器没有标记为被动模式浏览器必须等监听器执行完、确认不会调用preventDefault之后才允许页面继续滚动这一步就可能卡住滚动。第二环节是主线程执行 JavaScript 并完成样式、布局、绘制的计算和提交。改动 DOM、读写样式最终都体现在这个环节里。第三环节是合成器线程它独立于主线程运行负责把各个图层组合起来输出到屏幕。如果页面里的动画和位移只涉及合成器能单独处理的属性主线程甚至可以完全不被占用滚动自然丝滑。理解了这三个角色之后滚动优化的底层逻辑就变成了四个字能少则少。让事件处理函数更轻、让主线程少做布局和绘制、把能交给合成器的活尽量交出去。这篇文章后面的所有章节都在围绕这个逻辑展开。2. 渲染管线为什么每帧必须在 16.7 毫秒内走完全程2.1 渲染管线五步Script、Style、Layout、Paint、Composite要理解滚动为什么卡不能绕开浏览器最核心的渲染管线。一帧画面从用户操作到显示在屏幕上大体经过五个步骤JavaScript 执行、样式计算、布局、绘制、合成。JavaScript 执行很好理解改数据、操作 DOM、执行滚动回调都算这里。样式计算发生在你改了 CSS 类名或内联样式之后浏览器要根据新的类名和继承关系计算出每个元素最终的 Computed Style。布局阶段会基于计算样式确定每个元素的位置和尺寸这是非常消耗 CPU 的一个环节因为浏览器往往要递归遍历整棵渲染树。绘制阶段把每个元素的视觉表现绘制成像素记录它不直接输出到屏幕而是生成绘制指令你可以理解成先画了一叠草稿。最后的合成阶段把这叠草稿按照图层关系拼成最终画面交给屏幕。需要注意的是这五步并不是每次都必须走完。如果只是修改了某个元素背景颜色浏览器可能只需要进入绘制和合成阶段重新布局都可以跳过。高性能滚动优化的本质就是尽量让每次状态变化只发生在代价更小的步骤里。2.2 为什么 Layout 和 Paint 是滚动场景的致命瓶颈布局是五步里公认的“大头”。一个页面通常有成百上千个 DOM 节点只要有一个节点宽度变化浏览器就可能向上递归到根节点再向下重新计算所有子孙节点的几何信息。更难受的是布局结果存在依赖链父节点位置变了子节点必须跟着重新算兄弟节点位置变了后续节点也可能被推着重新排队。绘制虽然通常比布局快一些但它的代价跟绘制面积、样式复杂度强相关。大面积渐变、模糊滤镜、半透明叠加在每次滚动中都要重新生成绘制指令。移动端低性能设备上一次全屏大小的绘制可能直接吃掉好几毫秒。之前在压测一个活动页面时发现页面里有个覆盖全屏的毛玻璃遮罩层滚动时遮罩下面所有内容都要重新绘制帧耗时直接翻倍。后来把遮罩改成独立合成层并且用opacity控制显隐才把滚动拖拽感降下来。2.3 合成器与图层为什么只动 transform 和 opacity 可以很快合成器是一套独立于主线程的体系。页面里的元素默认都画在同一层上一旦某个元素被提升为独立合成层它就把自己的像素记录单独保存一份。之后如果想移动它、旋转它、调整它的透明度合成器只需要按照新的位置和透明度做一次图层拼接完全不需要通知主线程重新布局和绘制。合成器跑在 GPU 上擅长做这种“平移、旋转、缩放、透明度”的矩阵变换。所以业界一直强调动画和滚动跟手效果优先用transform和opacity而不是top、left这类会引发布局的属性。一旦牵动布局主线程就会进入全套流水线帧耗时的下限立刻被拉高。2.4 用一条装配流水线来理解管线瓶颈打个比方渲染管线就像装配一台手机。脚本阶段是在准备零配件清单样式计算是确认螺丝型号布局是给每个零件规定安装位置绘制是加工每块外壳表面合成是最后把整机拼起来贴膜。如果每次只需要换一张后盖膜理想情况是只走到加工和拼接阶段几分钟搞定。但如果有人每次换膜的时候都把整台机器先全部拆散再重新装配一遍生产效率就完全失控了。滚动优化要做的就是避免让一个简单的滚动位移去触发整条“拆机重装”的流水线。3. scroll 监听的三板斧passive、节流与 rAF 对齐3.1 passive 参数让浏览器松开“安全检查”的手所有 scroll 监听器优化里配置成本最低但收益最直接的就是passive: true。默认情况下浏览器在滚动事件派发时会先执行完监听器里所有代码确定没有人调用preventDefault()才会继续默认滚动行为。这个过程等于让滚动时刻等着 JavaScript 的回话就算监听器里只写一行console.log也会增加滚动延迟。而passive: true是在告诉浏览器我绝对不会取消默认滚动你可以第一时间继续滚动监听器里的代码在后台慢慢执行就行。对于页面滚动、触摸屏滚动这类高频事件只要监听器里没有preventDefault的需求都建议加上被动标记。实现方式也很简单window.addEventListener(scroll, handler, { passive: true, capture: false });有一点要注意如果某个监听器确实调用了preventDefault来阻止滚动就绝不能标成 passive否则浏览器的控制台会直接警告同时忽略掉它的preventDefault调用。3.2 节流与防抖的边界底部加载到底该用哪种滚动场景里经常有人把防抖和节流混着用。它们的思路完全不同选错会直接影响功能表现。节流是“限制执行频率”不管事件触发多少次每隔固定时间最多执行一次。滚动到底部判断加载更多、更新进度条这类需要持续响应滚动位置的需求适合节流频率控制住就行延迟一两百毫秒基本无感。防抖是“等事件停下来再执行”。适合滚动结束之后才需要触发的动作比如播放器关闭控制栏、导航吸附后的样式修正、切页时保存滚动位置等在滚动停止后统一执行一次就够了可以省掉滚动过程中大量无效计算。3.3 requestAnimationFrame把执行时机对齐到渲染帧比单纯的固定间隔节流更精致一点的方案是用requestAnimationFrame来对齐渲染帧。浏览器每帧渲染前都会执行一次requestAnimationFrame回调所以在这个回调里读取滚动位置、更新状态天然能和下一次渲染协同不会产生多余的中间态。固定节流有一个问题时间片不一定跟渲染帧对齐可能出现节流回调刚执行完下一帧又来了新滚动事件导致视觉更新不连续。而 rAF 的调度时机紧跟屏幕刷新率适合滚动进度条、贴纸跟随、视差效果这类跟手要求较高的场景。3.4 一个可复用的滚动监听封装综合 passive 和 rAF写一个实用的滚动监听封装并不复杂。核心思路是滚动事件触发后不管来多少波一帧里只采样一次位置执行一次业务回调。function initScrollHandler(handler) { let ticking false; let lastY window.scrollY || document.documentElement.scrollTop; function step(time) { ticking false; const currentY window.scrollY || document.documentElement.scrollTop; const delta currentY - lastY; lastY currentY; handler({ y: currentY, delta, time }); } window.addEventListener( scroll, () { if (ticking) return; ticking true; window.requestAnimationFrame(step); }, { passive: true } ); // 初始化触发一次 step(performance.now()); } initScrollHandler(({ y, delta }) { // 在这里更新滚动进度条、做懒加载判断等 // 例如document.querySelector(.progress).style.transform scaleX(${y / maxScroll}); });ticking标记保证一帧只看一次位置delta可以方便地实现方向判断。所有业务逻辑都收敛到一个回调里滚动事件本身只承担采样职责。实测下来这种写法能把高频滚动回调的执行次数直接压到原来的三分之一甚至更低。4. 渲染优化实操把 Layout 与 Paint 的成本按下去4.1 消除强制同步布局把“读读写写”改成“先读后写”前面提到的强制同步布局是滚动卡顿最常见的元凶但也是最容易通过代码审查看出来的问题。典型写法是这样// 错误示范每次循环里读写交替产生强制同步布局 function badUpdate(items) { for (let i 0; i items.length; i) { const width items[i].offsetWidth; // 读 items[i].style.width width * 2 px; // 写 } }第一次循环读取offsetWidth时浏览器可能还没完成布局更新所以会立刻执行一次布局。紧接着你改了元素宽度再次进入下一轮循环读取时浏览器发现样式又变了只好再做一次布局。一个循环下来布局次数等于元素个数性能完全失控。正确做法是先把所有需要的布局属性一次性读进普通变量再统一批量写入样式function goodUpdate(items) { 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] * 2 px; // 后批量写 } }如果在读和写之间必须做大量计算或者写入之后还有其他读取需求可以考虑把写入放进requestAnimationFrame让样式变更延迟到下一帧再应用进一步减小布局压力。4.2 transform 替代 top 和 left把移动交给合成器滚动过程中改变元素位置是很多页面动效的常规操作。问题是大部分实现都习惯用top和left来定位/* 不推荐 */ .box { left: 100px; top: 200px; transition: left 0.3s, top 0.3s; }left和top一旦变化浏览器要重新计算布局全链路走一遍主线程流水线。可如果目标是移动元素本身完全可以改用transform/* 推荐 */ .box { transform: translateX(100px) translateY(200px); transition: transform 0.3s; }transform只会触发合成阶段主线程里最多做一次样式计算后面的布局和绘制都能跳过。滚动时要做一个元素跟随光标移动的浮层用同一个道理window.addEventListener( scroll, () { const y window.scrollY; floating.style.transform translateY(${y * 0.8}px); }, { passive: true } );这里补充一句will-change不是必选项transform本身不会让元素自动变成合成层但浏览器往往会在动画过程中自动提升。如果需要手动指定再考虑后面提到的will-change。4.3 will-change 是双刃剑乱开的后果是内存爆炸will-change的价值在于提前告诉浏览器某个元素即将发生哪些变化让浏览器提前分配合成层。例如.card { will-change: transform, opacity; }这样写可以避免动画启动瞬间的“临时提层”卡顿看起来确实能优化性能。但它的副作用很隐蔽浏览器可能为了一个元素额外开辟一整张内存位图允许它独立于其他内容合成。如果页面上有几十个卡片都加了will-change每个都提升为合成层移动端的内存和 GPU 负载都会迅速攀升滚动反而更卡。我自己的习惯是只对“即将要移动或透明度变化”的元素短暂开启动画结束后在 JavaScript 里移除el.style.willChange transform; // 动画完成后 el.style.willChange auto;另一种情况是给“已知高频交互的滚动容器”加will-change: scroll-position用于提示浏览器滚动位置会经常变化。实际收益要看场景我建议保持保守能用transform解决的问题先别急着开will-change。4.4 contain 属性让局部变化不波及整页渲染contain是一个经常被忽略但很实用的 CSS 属性。它的作用是告诉浏览器某个元素内部的内容变化时不需要把影响范围扩大到这个元素以外的区域。.card { contain: layout style paint; }layout表示内部布局变化不会影响外部布局paint表示元素内的绘制内容不会溢出影响页面其他部分style则避免某些样式继承影响范围扩大。浏览器的布局和绘制计算范围会被主动收缩到这个容器内部不会递归波及到整棵文档树。做卡片列表时每张卡片都加contain: layout paint卡片内部图片加载或文案变化时外部页面的渲染成本能明显下降。需要注意contain对元素的布局环境有隐含要求不是所有元素都能随意加。想用contain: strict的话需要确认这个元素在页面里不会因为内容变化而改变尺寸否则可能出现内容被裁剪或者布局错乱的问题。5. 长列表与无限滚动让渲染内容跟着可视区走5.1 无限滚动不是加个到底部监听就万事大吉无限滚动是列表页的家常便饭但很多实现只关注“什么时候触底加载”忽略了滚动越来越久之后页面里积累的 DOM 节点数量。一条条塞进列表的卡片不会自动消失随着用户不断上滑DOM 节点数量一路涨到几千个滚动计算成本、内存占用都在悄无声息地翻倍。比较常见的处理思路是当列表项超过一定数量时把远离可视区的旧节点从 DOM 里卸载同时记录它们对应的滚动位置和状态保证用户回滚时能无缝恢复。这比单纯加个throttle更能从根本上缓解长列表滚动性能问题。5.2 IntersectionObserver让浏览器替我们判断“是否可见”与其在 scroll 回调里手动计算scrollTop clientHeight scrollHeight不如直接用IntersectionObserver监听一个哨兵元素。观察器是浏览器底层异步实现的不会跟主线程的滚动事件抢时间代码量也更少。const sentinel document.querySelector(#list-sentinel); const io new IntersectionObserver( (entries) { for (const entry of entries) { if (entry.isIntersecting) { loadNextPage(); } } }, { root: document.querySelector(#scroll-container), rootMargin: 0px 0px 200px 0px, // 底部还有 200px 就提前触发 } ); io.observe(sentinel);rootMargin在这里很关键它扩展了观察区域的边界让“即将触底”也能被提前捕捉到为数据请求预留缓冲。滚动到底部监听由 scroll 事件改为 IntersectionObserver 之后主线程主循环里少了一堆坐标计算帧率明显稳定。5.3 虚拟列表的核心思路只渲染看得见的那些项当数据量上升到几千甚至上万条时就算挨个卸载也扛不住更彻底的做法是虚拟列表把整个列表当成一个空壳容器容器高度等于总数据量对应的总高度然后只渲染当前可视区范围内的数据项。这是一个示意实现const container document.querySelector(.viewport); const inner document.querySelector(.inner); const itemHeight 40; const data Array.from({ length: 10000 }, (_, i) 条目- i); inner.style.height data.length * itemHeight px; function renderList() { const scrollTop container.scrollTop; const visibleCount Math.ceil(container.clientHeight / itemHeight); const start Math.floor(scrollTop / itemHeight); const end Math.min(data.length, start visibleCount 3); // 多渲染 3 个缓冲 let html ; for (let i start; i end; i) { html div classrow data[i] /div; } inner.innerHTML html; // 用 transform 把可视区那一段内容挪到正确位置 inner.style.transform translateY(${start * itemHeight}px); } container.addEventListener(scroll, renderList, { passive: true }); renderList();这种方式下不管数据条数是一千还是一万真实渲染在屏幕上的 DOM 数量始终只有可视区高度除以条目高度外加几行的缓冲滚动性能不会随着数据条数增长而线性劣化。实现虚拟列表要特别注意条目高度的确定性和一致性。固定高度是最简单的实现前提一旦高度不固定就需要提前测量每一条的真实高度或者用动态估算配合缓存修正复杂度会明显上升。另外大量操作innerHTML本身也有成本在实际项目里通常会用近代渲染方式或 DOM 复用技术核心思想不变始终让可视区决定渲染内容。5.4 window 滚动与内部容器滚动的差异长列表不一定都挂在页面滚动上很多情况是页面里有一个固定高度的滚动容器。两者的性能特征和优化策略有些出入。使用 window 滚动时滚动事件作用于整个文档。页面里任何一点位置变化都可能触发整个文档范围的滚动判定所以这类场景要特别注意把监听器标记为被动并且减少对位置变化的全局依赖。使用内部容器滚动时滚动行为被限制在一个区域内但是在 iOS 上滑动阻尼和惯性跟手程度本身偏弱所以在工程上更建议使用精确且贴合业务上下文的滚动容器而不是靠多层嵌套的 overflow 容器层层包裹。滚动容器嵌套过深会让浏览器在每次滚动时执行多层滚动链的命中检测增加额外开销。6. 滚动性能排障从性能录制面板到自查清单6.1 录一段滚动操作读懂主线程的每一帧排查滚动卡顿第一步永远是录制而不是猜。用浏览器开发者工具的性能面板录制一段大约十秒的滚动操作操作速度尽量贴近真实用户快速滑动的频率。停止录制后重点看帧谱上有没有明显的红色长任务然后点开任意一帧切换到主线程时间轴观察这段区域里各种颜色块的比例。黄色块代表脚本执行时间如果明显偏长说明业务代码太重需要从监听器瘦身或者算法优化入手。紫色块代表样式和布局计算如果占据绝大多数方向就是消除强制同步布局、减少布局属性变更。绿色块代表绘制如果反复出现大范围的绿色块方向是检查是否存在大面积重绘考虑提取独立合成层或裁剪绘制区域。很多前端团队习惯性忽略这个环节直接凭感觉去优化代码结果往往改完了也验证不了效果。录制一次改完再录一次帧耗时对比一目了然这才是滚动优化的正确闭环。6.2 低端机上才暴露的兼容性坑同样的滚动代码开发机上 60fps 跑得飞起客户现场的中端安卓机上就是一顿一顿的。这里面的差距来自几个方面。一是 CPU 性能差距主线程布局和绘制耗时在低端机上会被放大数倍。二是字体渲染和图片解码差异低配机处理大图解码和复杂文字阴影时特别吃力。三是内存压力页面里合成层数量稍微一多低端机 GPU 就会报警。实践建议是团队里准备一台性能相对落后的测试机作为滚动性能验收的基准设备。做完一轮优化之后在那台机器上反复快滑重点列表页如果帧体验能稳定下来这个页面在绝大部分用户设备上就都稳了。6.3 贴一份滚动性能自查清单到项目组带团队做前端项目时我习惯把滚动优化整理成一张表放在组件规范文档里每次上线前扫一遍效率比口头叮嘱高得多。检查点判断方法优化动作scroll 监听器是否标记 passive查看事件监听代码无 preventDefault 的监听器统一加{ passive: true }滚动回调是否高频执行性能面板中脚本块出现频率用 rAF 对齐渲染帧或加节流控制是否读写了布局属性并交替出现火焰图中出现 Forced reflow 提示改为先批量读、后批量写移动类动画是否用了 top/left代码审查或渲染面板观察换成 transform / translate 属性will-change 是否被滥用检查合成层数量观察内存占用动画前临时开启结束后移除长列表是否无限增长录制时观察 DOM 节点数量变化引入虚拟列表或卸载远离可视区的节点触底加载是否依赖 scroll 计算审查坐标判断逻辑改用 IntersectionObserver 监听哨兵元素是否存在大面积绘制性能面板中反复出现绿色块提取独立合成层、减少透明层与高级滤镜滚动容器是否多层嵌套文档结构检查减少 overflow 容器嵌套理清滚动路径这张表是一个拿去就能用的基线真正要排查更深的问题还是回到性能录制面板里去逐帧读数据。最后说点个人体会。滚动性能优化这件事最容易走偏的方向就是“听信某个特效方案能解决一切”。实际上没有哪种技巧能覆盖所有场景passive 救不了布局抖动transform 替代不了 DOM 卸载虚拟列表也解决不了强制同步布局。把渲染管线的基本原理吃透把性能录制工具用熟练再复杂的滚动卡顿都能一步步拆解到位。希望这篇内容能帮你在下一次面对“页面滑不动”的线上问题时少走一圈弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑