JavaScript原生实现简单虚拟列表

作者:重庆崽儿Brand

背景

在公司项目中,需要给商品配置大量的属性值,可能其中一个属性的值数量就有成百上千条。

一个商品会有很多属性,如果把这些属性和属性值同时都渲染出来,就会导致在一个页面上渲染很多的节点,导致浏览页面时卡顿

从而导致应用性能用户体验都不好。

为了解决上述原因导致的应用性能和用户体验问题

可能比较常见的解决方法就是:分页

另一个就是近两年常听到的一个解决方法:虚拟列表

下面简单分析下虚拟列表的实现方式

分析

简单分析下,虚拟列表主要由以下几个部分构成:

  • 实际列表: 指你所需要展示的全部数据渲染成的列表
  • 可视区: 指数据需要展示时能看到的最大区域
  • 已加载区
  • 未加载区

如下图所示:

虚拟列表示意图

由图可以看出

我们实际所能看到的数据(即可视区展示的内容)只占所有的数据的很小一部分

还有很大一部分是我们暂时不需要渲染出来的

如果我们一次性把其他不需要的数据也同时渲染到页面上,会造成DOM的浪费。

所以我们考虑:能不能只渲染可视区的DOM节点,其他的节点等需要用到的时候再渲染出来,使页面上始终只存在可视区的节点,这样就大大减少了DOM节点的数量。

实现

页面代码如下

  • #listView: 表示你需要展示列表内容的区域,即可视区,本文中为 body 高度
  • #zhanwei: 用来表示占位,是滚动条出现,它的高度就是所有数据渲染到页面时的高度,需要在js中计算
  • #listContent: 是实际列表内容的容器。
<div id="listView" class="list-view">
    <div id="zhanwei" class="zhanwei"></div>
    <ul id="listContent" class="list-content">

    </ul>
</div>

body{
  height: 400px;
}

.list-view {
  height: 400px;
  position: relative;
  overflow-y: auto;
}

.zhanwei {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: -1;
}

.list-content li {
  height: 100px;
}

.list-content li:nth-of-type(odd) {
  background: #00ccff;
}

.list-content li:nth-of-type(even) {
  background: #ffcc00;
}
// 模拟常列表的数据
function setLongData() {
    let data = [];
    for (let i = 0; i < 1000000; i++) {
        data.push({id: "item" + i, value: Math.random() * i})
    }
    return data;
}

function selectDom(selector) {
    return document.querySelector(selector)
}

// 加载数据并插入DOM到页面
function loadData(start, end) {
    // 截取数据
    let sliceData = setLongData().slice(start, end)
    // 创建虚拟DOM
    let F = document.createDocumentFragment();
    for (let i = 0; i < sliceData.length; i++) {
        let li = document.createElement("li");
        li.innerText = JSON.stringify(sliceData[i])
        li.className = sliceData[i].id
        F.appendChild(li)
    }
    selectDom(".list-content").innerHTML = "";
    selectDom(".list-content").appendChild(F)
}

// 设置占位DOM的高度
document.getElementById("zhanwei").style.height = `${100 * setLongData().length}px`

// 计算可视区能展示几列数据,此处假设一列的高度为 100 px
let count = Math.ceil(document.body.clientHeight / 100) 
let startIndex = 0; // 可视区第一列的索引
let endIndex = count; // 可视区最后一列的索引
loadData(startIndex, endIndex)

// 滚动加载数据方法
function scrollFunction() {
    // 获取滚动条距离可视区顶部的距离
    let scrollTop = document.getElementById("listView").scrollTop;
    startIndex = Math.floor(scrollTop / 100);
    endIndex = startIndex + count;
    loadData(startIndex, endIndex)
    // 滚动时内容区会发生偏移
    // 通过 transform:translate3d将偏移的内容移回可视区
    document.getElementById("listContent").style.transform = `translate3d(0, ${startIndex * 100}px, 0)`
}

document.getElementById("listView").addEventListener("scroll", scrollFunction)

最终结果如图,可以看出DOM节点的数量始终没有变化

虚拟列表结果

2025-04 优化版

上一个版本在滚动时,会出现闪动的问题 😅 ,现在优化下,代码如下,里面基本都有注释 ✌

    <div class="container">
        <div class="zhanwei"></div>
    </div>
       .container {
            border: 1px solid #eee;
            height: 300px;
            width: 300px;
            overflow: auto;
            position: relative;
            box-sizing: border-box;
        }
        .zhanwei {
            position: relative;
        }
        .item {
            position: absolute;
            top: 0;
            width: 100%;
            height: 50px;
            border: 1px solid #eee;
            will-change: transform; 
            box-sizing: border-box;
        }
        .item:nth-of-type(odd) {
            background: #00ccff;
            }

            .item:nth-of-type(even) {
            background: #ffcc00;
            }
let container = document.querySelector('.container');
        let zhanwei = document.querySelector('.zhanwei');

        let itemList = []; // 假设有10000条数据

        for (let i = 0; i < 10000; i++) {// 生成10000条数据
            itemList.push({
                index: i,
                content: `Item ${i}`
            });
        };
        let buffer = 5; // 多渲染几条,避免滚动时看着异常

        let itemHeight = 50; // 每条数据的一个默认最小高度

        let heights = new Map();// 记录渲染的每个 item 的高度,不定高版本需要
        let offsets = new Map(); // 记录每个 item 的偏移量,即每个item距离顶部的距离
        let rendered = new Map(); // 存储已渲染的数据
        

        // 更新偏移量, 根据item高度,计算 zhanwei 元素的高度,好让container出现滚动条
        function updateOffsets() {
            let offset = 0
            for (let i = 0; i < itemList.length; i++) {
                let h = heights.get(i) ?? itemHeight; // ?? 是空值合并运算符,当左边为null或者undefined时使用右边值,和三元运算符相比,排除了 0 的干扰
                offsets[i] = offset;
                offset += h + 5; // 加上了5个像素的间距
                
            }
            zhanwei.style.height = offset + 'px';
        }
        // 渲染数据
        function render() {
            let scrollTop = container.scrollTop;
            let viewHeight = container.clientHeight;

            let start = 0; // 查找视口第一个item的索引
            while (start < itemList.length && offsets[start + 1] < scrollTop) {
                start++;
            }
            let end = start ;// 查找视口最后一个item的索引
            while (end < itemList.length && offsets[end] < scrollTop + viewHeight) {
                end++;
            }
            start = Math.max(0, start - buffer);
            end = Math.min(itemList.length, end + buffer);
            let nextRendered = new Map(); // 当前渲染的数据
            for (let i = start; i < end; i++) {
                if (!rendered.has(i)) {
                    let item = document.createElement('div')
                    item.className = 'item'
                    item.style.transform = `translateY(${offsets[itemList[i].index] + 'px'})`
                    item.textContent = itemList[i].content
                    container.appendChild(item)
                    rendered.set(i, item)
                }
                nextRendered.set(i, rendered.get(i))
                
            }
            // 不可见的区域 移除
            for (const [i, el] of rendered.entries()) {
                if (!nextRendered.has(i)) {
                 container.removeChild(el);
                }
            }
          // 更新 rendered
            rendered.clear();
            for (const [i, el] of nextRendered.entries()) {
                rendered.set(i, el);
            }

        }
        container.addEventListener("scroll", render);
        updateOffsets()
        render()

最后

这只是一个简单实现,要在实际项目中应用还需要深入研究。

感谢您的阅读 ❤️

文章归类于: 学习笔记

文章标签: #Javascript

版权声明: 自由转载-署名-非商用