10 万条数据不卡顿:虚拟列表的滚动优化算法

3 阅读4分钟

你在开发后台管理系统时,是否遇到过这样的场景:

后端返回了 10 万条数据,你用 v-for 或 map 直接渲染到页面上,结果浏览器直接卡死,鼠标都动不了。

为什么?因为浏览器要同时创建 10 万个 DOM 节点,内存爆炸、重排重绘频繁触发,页面直接"休克"。

今天,我们用算法来解决这个问题——虚拟列表(Virtual List) 。它的核心思想是:只渲染用户看得见的部分

1. 虚拟列表的核心原理

想象一下你在坐高铁,窗外有 1000 根电线杆。你看到的永远是眼前的几根,而不是全部。虚拟列表就是这个道理:

  • 完整数据:10 万条(存在内存中,不渲染)
  • 可视区域:屏幕能显示的约 10 条(只渲染这部分 DOM)
  • 滚动时:动态计算应该显示哪几条,替换 DOM 内容

性能对比

方案DOM 节点数内存占用滚动流畅度
直接渲染100,000500MB+卡顿到无法使用
虚拟列表15-202MB丝滑如德芙

2. 核心算法:如何知道该渲染哪几条?

这是虚拟列表最关键的部分。我们需要根据滚动位置计算出起始索引结束索引

function getVisibleRange(scrollTop, containerHeight, itemHeight, bufferCount) {
    // 计算起始索引(向上扩展缓冲区)
    const startIndex = Math.max(
        0, 
        Math.floor(scrollTop / itemHeight) - bufferCount
    );
    
    // 计算结束索引(向下扩展缓冲区)
    const endIndex = Math.min(
        totalItems - 1,
        Math.ceil((scrollTop + containerHeight) / itemHeight) + bufferCount
    );
    
    return { startIndex, endIndex };
}

算法解析

  1. Math.floor(scrollTop / itemHeight) :计算出当前滚动到了第几项
  2. bufferCount:缓冲区,防止滚动过快时出现白屏(通常设 3-5)
  3. Math.max(0, ...) 和 Math.min(total - 1, ...) :边界保护,防止索引越界

3. 手写一个完整的虚拟列表

我们来实现一个不依赖任何框架的纯 JavaScript 版本:

class VirtualList {
    constructor(container, options) {
        this.container = container;
        this.itemHeight = options.itemHeight; // 每项高度
        this.data = options.data; // 完整数据
        this.bufferCount = options.bufferCount || 5; // 缓冲区
        
        // 计算可视区域能显示多少项
        this.visibleCount = Math.ceil(container.clientHeight / this.itemHeight);
        
        this.init();
    }
    
    init() {
        // 创建一个"占位容器",高度等于所有数据的总高度
        // 这样滚动条才能正确反映数据量
        this.scrollContainer = document.createElement('div');
        this.scrollContainer.style.height = `${this.data.length * this.itemHeight}px`;
        this.scrollContainer.style.position = 'relative';
        this.scrollContainer.style.overflowY = 'auto';
        
        // 创建实际渲染的容器
        this.visibleContainer = document.createElement('div');
        this.visibleContainer.style.position = 'absolute';
        this.visibleContainer.style.top = '0';
        this.visibleContainer.style.width = '100%';
        
        this.scrollContainer.appendChild(this.visibleContainer);
        this.container.appendChild(this.scrollContainer);
        
        // 监听滚动事件
        this.scrollContainer.addEventListener('scroll', () => {
            this.render();
        });
        
        // 初始渲染
        this.render();
    }
    
    render() {
        const scrollTop = this.scrollContainer.scrollTop;
        const { startIndex, endIndex } = this.getVisibleRange(scrollTop);
        
        // 计算偏移量(让滚动看起来是连续的)
        const offsetY = startIndex * this.itemHeight;
        this.visibleContainer.style.transform = `translateY(${offsetY}px)`;
        
        // 清空并重新渲染可视区域内的项
        this.visibleContainer.innerHTML = '';
        for (let i = startIndex; i <= endIndex; i++) {
            const item = document.createElement('div');
            item.style.height = `${this.itemHeight}px`;
            item.textContent = this.data[i];
            this.visibleContainer.appendChild(item);
        }
    }
}

4. 工业界实战:不定高虚拟列表

上面的实现假设每项高度固定。但实际项目中,列表项高度可能不一致(如朋友圈动态)。

解决方案:

  1. 预估高度:给每项一个初始预估高度
  2. 动态测量:渲染后用 ResizeObserver 测量真实高度
  3. 修正偏移:根据真实高度重新计算位置

这是 Vue Virtual Scroller 和 React Window 等成熟库的做法。

5. 面试考点

Q1: 虚拟列表和普通列表的性能差异在哪里?

A: 普通列表渲染 N 个 DOM,虚拟列表只渲染可视区域的 M 个(M << N)。时间复杂度从 O(N) 降到 O(M)。

Q2: 为什么需要 bufferCount(缓冲区)?

A: 防止用户滚动过快时,新内容还没渲染出来就出现了白屏。缓冲区提前渲染了屏幕外的几项。

Q3: 如何处理动态高度的列表项?

A: 使用 ResizeObserver 监听元素高度变化,动态维护一个高度数组,重新计算每项的位置偏移。

6. 总结

虚拟列表不是什么神秘的"黑科技",它只是一个聪明的数学计算 + DOM 复用的组合拳:

  1. 计算可视范围:根据滚动位置算出应该显示哪些项
  2. 占位容器:用一个大 div 撑起完整的高度,让滚动条正常工作
  3. 动态渲染:只创建和更新屏幕内的 DOM 节点

下次遇到长列表卡顿,别再傻乎乎地直接渲染了,用虚拟列表,性能提升几个数量级!


💡 提示:  完整代码已开源到 GitHub,欢迎 Star 支持!在公众号【Lee 的成长日记】回复"虚拟列表",可获取可运行的 HTML Demo 文件。

如果你觉得这篇关于"前端性能优化"的文章对你有帮助,欢迎点赞收藏!🚀