滚动性能优化的那些坑我帮你避开了
优化前:卡得不行
最近做了一个商品列表页面,本来以为很简单的需求,结果上线后发现滚动卡得要死。用户滑动几下屏幕就开始掉帧,FPS直接掉到个位数,那种卡顿感简直让人想砸手机。Chrome DevTools显示每次滚动都有大量的重排重绘,而且JavaScript执行时间也长得出奇。查了下数据,优化前页面滚动时平均FPS只有12左右,最高不超过20,这用户体验完全没法看。
问题最明显的表现就是:手指滑动时页面响应迟钝,有时候甚至要停顿一两秒才开始滚动,而且滚动过程中明显能看到元素在跳动。刚开始还以为是数据量太大导致的,毕竟有几百个商品项,但后来发现问题没那么简单。
找到瓶颈了!
用Performance面板录了一段滚动操作,发现问题出在几个地方:
- 每次滚动都会触发大量DOM查询,getBoundingClientRect、offsetTop这些API被频繁调用
- CSS样式变化导致的重排重绘太频繁,特别是position: relative的元素
- 事件监听器没有防抖,touchmove事件每帧都触发多次
其中最致命的是,我用了一个自定义的滚动容器,每次滚动都要计算所有可见元素的位置,然后动态设置它们的样式。这就导致每滚动一点点就要遍历整个列表,几百个元素依次执行一遍计算和渲染,能不卡吗?
核心优化:虚拟滚动搞起来
试了几个方案,最后决定用虚拟滚动来解决这个问题。简单说就是只渲染当前可视区域内的元素,而不是把所有数据都渲染出来。这个方案看起来复杂,但实现起来其实就几个核心逻辑。
优化前的代码大概是这样的:
// 优化前:渲染全部数据
function renderAllItems() {
const container = document.querySelector('.list-container');
let html = '';
data.forEach((item, index) => {
html +=
<div class="list-item" style="height: 100px;">
<div class="item-content">${item.title}</div>
</div>
;
});
container.innerHTML = html;
}
这种写法在数据量小的时候没问题,但数据量一大就直接卡死。现在改成虚拟滚动,只需要渲染可视区域内的项:
class VirtualScroll {
constructor(container, itemHeight, totalItems) {
this.container = container;
this.itemHeight = itemHeight;
this.totalItems = totalItems;
this.containerHeight = container.clientHeight;
// 计算可视区域内的项目数量
this.visibleCount = Math.ceil(this.containerHeight / this.itemHeight) + 2;
this.startIndex = 0;
this.endIndex = this.visibleCount;
this.init();
}
init() {
this.renderPlaceholder();
this.bindEvents();
}
renderPlaceholder() {
// 渲染一个占位容器,高度等于总内容高度
const placeholder = document.createElement('div');
placeholder.style.height = (this.totalItems * this.itemHeight) + 'px';
placeholder.className = 'scroll-placeholder';
const content = document.createElement('div');
content.className = 'scroll-content';
this.container.appendChild(placeholder);
this.container.appendChild(content);
this.placeholder = placeholder;
this.content = content;
}
bindEvents() {
let ticking = false;
this.container.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
this.updateVisibleItems();
ticking = false;
});
ticking = true;
}
});
}
updateVisibleItems() {
const scrollTop = this.container.scrollTop;
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = Math.min(startIndex + this.visibleCount, this.totalItems);
// 防止索引超出范围
if (startIndex === this.startIndex && endIndex === this.endIndex) {
return; // 没有变化就不重新渲染
}
this.startIndex = startIndex;
this.endIndex = endIndex;
this.renderVisibleItems();
}
renderVisibleItems() {
const visibleItems = [];
for (let i = this.startIndex; i < this.endIndex; i++) {
if (i >= this.totalItems) break;
const item = document.createElement('div');
item.className = 'list-item';
item.style.position = 'absolute';
item.style.top = (i * this.itemHeight) + 'px';
item.style.width = '100%';
item.style.height = this.itemHeight + 'px';
item.innerHTML = <div class="item-content">${data[i]?.title || ''}</div>;
visibleItems.push(item);
}
this.content.innerHTML = '';
visibleItems.forEach(item => this.content.appendChild(item));
// 设置content的transform来保持正确的视觉位置
this.content.style.transform = translateY(${this.startIndex * this.itemHeight}px);
}
}
// 使用方式
const scrollContainer = document.querySelector('.list-container');
const virtualScroll = new VirtualScroll(scrollContainer, 100, data.length);
这里的关键在于几个地方:
1. 用一个占位符div来撑起整个滚动区域的高度,这样滚动条才能正常工作
2. 只渲染可视区域内和缓冲区域的元素,大大减少了DOM节点数量
3. 用CSS transform而不是修改top值来调整位置,利用GPU加速
4. 用了requestAnimationFrame来控制渲染频率,避免过度渲染
CSS优化也不能忘
除了JavaScript层面的优化,CSS也要配合着改。主要是减少重排重绘的发生:
.list-container {
height: 400px;
overflow-y: auto;
/* 启用硬件加速 */
transform: translateZ(0);
will-change: scroll-position;
}
.scroll-content {
position: relative;
/* GPU加速 */
transform: translateZ(0);
}
.list-item {
width: 100%;
height: 100px;
border-bottom: 1px solid #eee;
/* 避免不必要的重排 */
contain: layout style paint;
}
/* 减少复杂选择器 */
.item-content {
padding: 10px;
line-height: 1.5;
}
contain属性是个好东西,可以让浏览器知道这个元素的变化不会影响其他元素,从而减少重排范围。
事件处理也要防抖
滚动事件的处理函数如果太重,也会导致卡顿。所以要合理使用防抖:
// 之前的做法:每次滚动都触发
// container.addEventListener('scroll', handleScroll);
// 现在的做法:结合requestAnimationFrame
let isScrolling = false;
container.addEventListener('scroll', function() {
if (!isScrolling) {
window.requestAnimationFrame(function() {
handleScroll();
isScrolling = false;
});
}
isScrolling = true;
});
性能数据对比
优化后的效果非常明显。滚动时的FPS从原来的12提升到了55以上,基本能达到60FPS的流畅水平。内存占用也从原来的一百多MB降到了十几MB。JavaScript执行时间从每次滚动的几十毫秒降低到几毫秒。
具体数据对比:
- 优化前:滚动FPS 12-20,内存占用 120MB,JavaScript执行时间 40-80ms
- 优化后:滚动FPS 55-60,内存占用 15MB,JavaScript执行时间 2-8ms
最关键的是用户体验完全不同了,滚动变得非常顺滑,几乎感觉不到延迟。用户反馈也好了不少,之前总有人说页面卡,现在基本上没听到过抱怨。
当然,这个方案也不是完美的。比如快速滚动时偶尔会出现短暂的内容空白(因为还没来得及渲染),不过通过增加缓冲区域的数量可以缓解这个问题。
以上是我这次滚动性能优化的经验总结,主要就是虚拟滚动的实现和相关优化技巧。如果有更好的方案或者改进意见,欢迎在评论区交流讨论。

暂无评论