背景
在公司项目中,需要给商品配置大量的属性值,可能其中一个属性的值数量就有成百上千条。
一个商品会有很多属性,如果把这些属性和属性值同时都渲染出来,就会导致在一个页面上渲染很多的节点,导致浏览页面时卡顿
从而导致应用性能和用户体验都不好。
为了解决上述原因导致的应用性能和用户体验问题
可能比较常见的解决方法就是:分页。
另一个就是近两年常听到的一个解决方法:虚拟列表。
下面简单分析下虚拟列表的实现方式
分析
简单分析下,虚拟列表主要由以下几个部分构成:
- 实际列表: 指你所需要展示的全部数据渲染成的列表
- 可视区: 指数据需要展示时能看到的最大区域
- 已加载区
- 未加载区
如下图所示:
由图可以看出
我们实际所能看到的数据(即可视区展示的内容)只占所有的数据的很小一部分
还有很大一部分是我们暂时不需要渲染出来的
如果我们一次性把其他不需要的数据也同时渲染到页面上,会造成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()
最后
这只是一个简单实现,要在实际项目中应用还需要深入研究。
感谢您的阅读 ❤️