滚动性能优化实战:提升页面流畅度的关键技术
先看效果,再看代码
最近做了一个长列表页,用户一滑到底,页面卡得像PPT。我一开始以为是数据量太大,后来发现其实是滚动事件没处理好。折腾了两天,终于把帧率从15拉到60,亲测有效。今天就把实战经验分享出来,核心就一句话:别让滚动事件直接触发重排重绘。
最简单的优化方式,就是用 requestAnimationFrame 包裹你的滚动逻辑。比如你想监听滚动位置做懒加载:
let ticking = false;
function updateScrollPosition() {
const scrollTop = window.scrollY;
// 你的业务逻辑,比如判断是否要加载新数据
console.log('当前滚动位置:', scrollTop);
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(updateScrollPosition);
ticking = true;
}
});
这个模式我用了快五年了,稳定可靠。关键点在于 ticking 标志位,避免在同一个帧内重复执行。很多人直接写 requestAnimationFrame(() => { ... }),结果还是每帧都跑,等于没优化。
又踩坑了,touchmove滚动失效
在移动端,光靠 scroll 事件不够,尤其是那种局部滚动区域(比如弹窗里的列表)。这时候就得上 touchmove。但这里有个大坑:默认的 touchmove 会触发浏览器的默认滚动行为,而且很难控制。
我之前在一个项目里,想在弹窗里实现下拉刷新,结果手指一动整个页面都在滚。后来发现必须加 passive: false,否则 preventDefault() 会失效(Chrome 56+ 默认 passive 为 true):
const scrollContainer = document.querySelector('.scroll-list');
scrollContainer.addEventListener('touchmove', (e) => {
// 阻止默认行为,只让容器内部滚动
e.preventDefault();
// 你的自定义滚动逻辑
}, { passive: false });
但注意!passive: false 会带来性能损失,因为浏览器不能提前知道你是否会阻止默认行为。所以只在确实需要阻止默认行为时才用,比如自定义滚动条、下拉刷新这种场景。普通列表滚动,建议直接用 CSS 的 overflow: auto,别自己造轮子。
三种主流滚动处理方案
根据项目复杂度,我一般分三种情况处理:
- 简单页面:直接用
scroll+requestAnimationFrame,够用又省事 - 复杂交互(如虚拟列表):用 Intersection Observer API 监听元素进出视口,完全避开滚动事件
- 高性能需求(如游戏、动画):用
transform+will-change做硬件加速,滚动时只改 transform,不触发布局
比如虚拟列表,我常用 Intersection Observer:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 加载数据或渲染内容
loadMoreData();
}
});
}, {
rootMargin: '100px' // 提前100px触发
});
observer.observe(document.querySelector('#trigger-element'));
这种方式比监听 scroll 轻量太多,浏览器原生支持,性能开销极小。我在一个商品列表页用这个方案,滚动流畅度直接起飞。
踩坑提醒:这三点一定注意
第一,别在滚动回调里写复杂计算。我见过有人在 scroll 里跑 for 循环遍历几百个 DOM 元素,帧率直接掉到个位数。如果必须做复杂操作,要么节流,要么移到 Web Worker 里(虽然麻烦,但值得)。
第二,CSS 动画尽量用 transform 和 opacity。比如滚动时 header 渐隐,别改 top 或 height,用 transform: translateY()。这样能走 GPU 加速,不触发重排。实测差距非常明显。
.header {
will-change: transform;
transition: transform 0.2s ease;
}
.header.hidden {
transform: translateY(-100%);
}
第三,移动端慎用 fixed 定位。iOS 上 fixed 元素在滚动时会频繁重绘,特别卡。我的解决方案是:用 absolute + JS 动态更新 top 值,或者干脆用 sticky(现代浏览器支持不错)。
谁更灵活?谁更省事?
如果你只是做个普通页面,别折腾太多。用原生 scroll 事件 + rAF 就够了。但如果你在做类似 feed 流、聊天窗口这种高频滚动场景,强烈建议上虚拟列表。我用过 react-window 和 vue-virtual-scroll-list,效果拔群,DOM 节点从上千个降到几十个,内存占用直降 80%。
不过虚拟列表也有缺点:首屏渲染可能略慢(因为要计算高度),而且滚动条长度不准。但这些小问题比起卡顿,根本不算事。我现在的项目基本都默认上虚拟列表,除非列表少于 20 条。
核心代码就这几行
最后贴个完整的小例子,包含防抖、rAF、和基础边界检测:
class ScrollHandler {
constructor() {
this.ticking = false;
this.lastScrollTop = 0;
this.init();
}
init() {
window.addEventListener('scroll', () => {
if (!this.ticking) {
requestAnimationFrame(() => this.update());
this.ticking = true;
}
});
}
update() {
const scrollTop = window.scrollY;
const isScrollingDown = scrollTop > this.lastScrollTop;
// 示例:向下滚动时隐藏 header
const header = document.querySelector('.header');
if (header) {
header.classList.toggle('hidden', isScrollingDown && scrollTop > 100);
}
this.lastScrollTop = scrollTop;
this.ticking = false;
}
}
// 启动
new ScrollHandler();
这段代码我复制粘贴到新项目里至少十次了,改改就能用。关键是结构清晰,扩展方便。比如你想加 lazy load,就在 update 里加判断逻辑就行。
结尾碎碎念
滚动优化说难不难,说易也不易。核心思路就两个:减少工作量(节流/防抖/虚拟列表)、减少重排重绘(transform/opacity)。我踩过的坑基本都列在这了,希望你能少走弯路。
以上是我个人对滚动优化的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多(比如结合 ResizeObserver 处理窗口缩放),后续会继续分享这类博客。毕竟前端性能优化,永远是个进行时。

暂无评论