滚动性能优化的那些坑我帮你避开了

慕容炳光 交互 阅读 1,445
赞 96 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

最近做了一个商品列表页面,本来以为很简单的需求,结果上线后发现滚动卡得要死。用户滑动几下屏幕就开始掉帧,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 = &lt;div class=&quot;item-content&quot;&gt;${data[i]?.title || &#039;&#039;}&lt;/div&gt;;
            
            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

最关键的是用户体验完全不同了,滚动变得非常顺滑,几乎感觉不到延迟。用户反馈也好了不少,之前总有人说页面卡,现在基本上没听到过抱怨。

当然,这个方案也不是完美的。比如快速滚动时偶尔会出现短暂的内容空白(因为还没来得及渲染),不过通过增加缓冲区域的数量可以缓解这个问题。

以上是我这次滚动性能优化的经验总结,主要就是虚拟滚动的实现和相关优化技巧。如果有更好的方案或者改进意见,欢迎在评论区交流讨论。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论