什么是虚拟列表
如果我们要将一个数组渲染为列表添加到页面中,我们可以很容易实现,无非就是循环遍历这个数组,然后依次创建 DOM 元素插入即可,但是如果数据量很庞大,比如有一万条数据,我们就要把一万个 DOM 结点插入到页面中,这显然会导致页面的卡顿。为了针对这个场景进行优化,让页面和我们这个列表不那么卡顿,就可以使用虚拟列表来解决。
虚拟列表说起来也很简单,由于视口高度是固定的,比如视口中只能容纳 10 个元素,那视口之外的 DOM 元素实际上是没有意义的,用户完全感知不到,我们就可以让其永远都只有 10 个元素,而不需要把所有的 DOM 元素全部插进去,可以大大优化性能,而至于这 10 个元素具体是什么,是完全可以根据滚动条的位置和每一项元素的位置来算出来的,计算这 10 个元素具体是什么这一操作是在 JS 引擎内部完成的,不涉及到 WEBAPI,更谈不上回流,所以哪怕数组里有上万项数据,我们也不必太担心性能问题。当然这里面其实涉及到很多细节,后面一一解释,这里只需要知道虚拟列表是什么即可。
虚拟列表的实现细节
接下来讨论虚拟列表具体如何实现,这里直接上图:
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上图
在这里,蓝色区域就是我们的视口,绿色区域就是总高度,这里不再将列表项全部添加到视口容器中,而是值添加可见项,这里需要注意一个细节,不能忽略滚动效果,比如我们视口只能容纳 5 项,那么到底是添加第一项到第五项,还是第五项到第十项,这是要根据滚动条的高度计算出来的。所以我们一样需要实现滚动效果,所以这里我们需要一个虚拟元素,也就是图中的绿色元素,这个绿色元素完全没有内容,绿色元素的高度实际上就是把列表项插入之后的总高度,举个例子,假设列表项定高 50 像素,列表项一共有 1000 项,那么绿色区域的高度就应该是 50000 像素,这个绿色区域的唯一用处就是用来撑开视口的内容区域实现滚动,没有别的作用。
这里也请注意,虽然看上去列表项好像是绿色区域的子元素,但实际上我在具体实现上将他们的 DOM 结构设置为平级了,这里只是用定位把他们叠在一起了,因为这个绿色区域本身就是一个虚拟的元素,他并没有实际的功能,不应该成为列表的父元素。
为了实现这个效果,我们在实现的时候应该考虑以下几个问题
- 要渲染的第一个元素是什么
- 要渲染的最后一个元素是什么
- 第一个元素距离顶部的偏移量是多少
- 虚拟元素的高度应该设置为多少
如果把这样几个问题考虑清楚了,实际上一切都很好说了,我们如果知道了第一个元素和最后一个元素的索引,那么就可以将数据中要显示的元素用 slice 切下来,然后渲染到页面中去,这里还存在一个问题,当我们将元素插入到列表中后,此时应该是插入到列表的顶部了,但是我们的滚动条有可能已经滚动了很多距离了,这个时候插入到列表顶部的话我们是看不到的,所以我们就需要给列表设置一个向下的偏移量,确保列表永远都显示在视口内部。而虚拟元素的总高度主要就是用来撑开视口的,这里我们前面已经介绍过。
我们这里分两种情况来考虑虚拟列表的具体实现,分别是定高和不定高,即数据项高度固定和数据项高度不固定。
定高的虚拟列表
定高的虚拟列表实现相对比较简单,因为我们很容易计算出虚拟元素的高度以及要渲染的第一个元素应该是什么。
首先,由于每一项高度是固定的,我们不妨设为 itemHeight,假设数据有 n 项,那么虚拟元素的总高度就应该是 itemHeight * n,这应该没有难度。
然后就是要渲染的第一项了,要渲染的第一项也很简单,假设我们目前滚动的距离是 scrollTop,那么要渲染的第一项应该就是 Math.floor(scrollTop/itemHeight)。
最后一项也并不难,由于视口高度和每一项高度都是固定的,我们直接用视口高度除以每一项高度向上取整,就得到了视口内可容纳的最大元素数量,然后加上要渲染的第一个元素下标,就计算出要渲染最后一个元素的下标。
最后要考虑的问题就是列表距离顶部的偏移量,这个偏移量不应该直接设为 scrollTop,应该是 scrollTop-(scrollTop % itemHeight),如果读者想不通为什么,可以自行使用 scrollTop 测试一下,看看会出现什么问题。
此时我们有了所有数据,要做的就很简单,无非设置一个滚动事件,当滚动的时候,根据 scrollTop 计算出要渲染的第一项下标,根据第一项下标、容器尺寸、每一项高度计算出要渲染的最后一项下标,然后根据 scrollTop 和每一项的高度计算出列表的偏移量,设置给列表即可。
对虚拟元素(前面图里面的绿色区域)高度设置应该才初始化就完成,因为对于固定高度的列表来说,列表总高度是不会改变的。
接下来给出具体的实现代码:
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><link rel="stylesheet" href="./style.css">
</head><body><div class="container"><div class="phantom"></div><div class="list"></div></div>
</body>
<script src="./script.js"></script>
</html>
* {margin: 0;padding: 0;}.container {height: 300px;border: 1px solid black;width: 300px;margin: 50px auto;overflow: auto;position: relative;
}.list {height: 300px;width: 100%;position: absolute;
}.phantom {height: 100%;width: 100%;position: absolute;
}.list>div {height: 50px;width: 100%;text-align: center;box-sizing: border-box;line-height: 50px;border: 1px solid black;
}
// 初始化一个空数组,用于存储大量数据
const data = [];
for (let i = 0; i < 100000; i++) {data.push(i);
}
// 获取列表容器和幽灵元素(用于模拟数据加载效果)
const list = document.querySelector('.list');
const phantom = document.querySelector('.phantom');
// 定义每个元素的高度为50px
const itemHeight = 50;
// 定义容器的高度为300px
const containerHeight = 300;
// 获取容器元素
const container = document.querySelector('.container');
// 计算所有元素的总高度
const totalHeight = data.length * itemHeight;
// 使用幽灵元素撑开容器,以适应内容的高度
phantom.style.height = totalHeight + 'px';
// 计算容器能容纳的最大元素个数
const maxCount = Math.ceil(containerHeight / itemHeight);
/*** 渲染函数,根据滚动位置动态加载数据*/
function render() {// 清空列表内容,准备重新渲染list.innerHTML = '';// 获取容器的滚动高度const scrollTop = container.scrollTop;// 计算当前滚动位置对应的起始数据索引const start = Math.floor(scrollTop / itemHeight);// 计算当前滚动位置对应的结束数据索引const end = Math.ceil(start + maxCount);// 从数据数组中截取当前需要显示的数据片段const temp = data.slice(start, end + 1);// 计算列表顶部的偏移高度,使其与最近的数据元素对齐const offsetTop = scrollTop - scrollTop % itemHeight;// 遍历数据片段,创建并添加到列表中temp.forEach(item => {const div = document.createElement("div");div.innerText = item;list.append(div);});// 应用偏移高度,使列表内容与滚动位置对齐list.style.transform = `translateY(${offsetTop}px)`;
}
// 初始加载
render();
// 监听容器滚动事件,实时调整列表内容
container.addEventListener("scroll", render);
点我看效果
读者可以自行测试一下,哪怕这个列表项有十万项,性能也相当好,怎么拖都不会出现卡顿的情况。
不定高的虚拟列表
不定高的虚拟列表会稍微复杂一丢丢,主要也是集中在计算上,只要有了前面所说的四个东西,即
- 要渲染的第一个元素是什么
- 要渲染的最后一个元素是什么
- 第一个元素距离顶部的偏移量是多少
- 虚拟元素的高度应该设置为多少
只要计算出这四个东西,那么不定高虚拟列表也是可以很轻松实现的。
接下来考虑如何计算,由于虚拟列表每一项高度是不固定的,实际上是根据内容来动态撑开的,具体来说,如果元素内容多就会很高,如果元素内容少,就会很低。由于此时元素的高度是不确定的,所以我们有必要使用一个高度缓存列表将每一个元素的高度都缓存下来。
我们要计算的这四个东西,实际上都要用到每个元素的高度,这是绕不开的,但是这里的问题是,我们只能获取到数据,我们并不知道元素的高度是多少,或者说,对于一个 DOM 元素而言,我们无法在不将元素插入到页面的情况下拿到这个元素的高度,我们当然可以将元素插入到一个隐藏的 DOM 中,插入之后就可以获取到每一个元素的高度了,但这样显然失去了使用虚拟 DOM 的意义,这里我使用的方案是先给一个相对较小的值,也就是说将高度缓存中每一项的默认值都只给一个很小的值,可以认为这就是一个预估高度,当该元素被渲染的时候更新缓存数据即可。
由于此时虚拟列表中的元素高度不固定,在计算上面提到的四个属性的时候也会存在区别。
- 虚拟元素的高度:也就是所有 item 的总高度,在定高的虚拟列表中,总高度可以直接根据每一项的高度和总项数相乘得到。但是在不定高的虚拟列表中这样显然是不可以的,我们需要遍历高度缓存列表,高度缓存列表的和就是总高度,这里需要注意,由于虚拟元素的高度是依赖于缓存列表高度的,所以如果缓存列表更新之后,我们也需要重新设置虚拟元素高度。
- 第一项要渲染的元素下标:我们可以获取到 scrollTop,也可以根据缓存列表获取到每一项的高度,哪怕前面的项目可能没有高度,在缓存列表中我们依然是给他初始化了一个预估高度的,所以不用担心这一点。我们只需要遍历高度缓存列表,将高度缓存列表的数据依次累加,如果累加数值大于了 scrollTop,那么上一项就应该是我们要渲染的第一项。同时,这个累加值减去当前项的高度(这是由于这里的逻辑是先累加再判断的)就可以得到列表的偏移高度。
- 最后一项要渲染的元素下标:我们可以继续从第一项要渲染元素的下一项开始遍历高度缓存列表,一样是从 0 累加每一项的高度,如果高度之和大于了视口高度,那么这一项就是最后一项要渲染的元素下标。
其实这里存在一个小问题,但是问题并不大,完全可以接受。
以上我们得到了我们想要的所有数据,我们只需要在滚动的时候动态获取和渲染即可。
具体实现参见以下代码:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><style>* {margin: 0;padding: 0;}.container {width: 500px;height: 500px;border: 1px solid red;margin: 50px auto;position: relative;overflow: auto;}.container .list,.container .virtualElement {position: absolute;left: 0;top: 0;width: 100%;}.container .list>div {border: 1px solid black;}</style>
</head><body><div class="container"><div class="virtualElement"></div><div class="list"></div></div>
</body>
<script src="mock.js"></script><script>//获取DOMconst container = document.querySelector(".container");const list = document.querySelector(".list");const virtualElement = document.querySelector(".virtualElement");const containerHeight = container.clientHeight;//容器高度const forecastItemHeight = 50;//预测每一项的高度,这里最好是最小高度const data = Mock.mock({//模拟出数据"data|50-100": ["@paragraph(1,5)"]}).data.map((item, index) => index + item);const itemsHeight = Array(data.length).fill(forecastItemHeight);//保存每一项高度的缓存refreshAllheight();//刷新总高度let firstIndex;//当前从哪个元素开始渲染let offsetTop = 0;//顶部偏移量,用于设置list的下偏移let scrollTop = container.scrollTop;//当前container元素的scrollTopfunction refreshAllheight() {//根据itemsHeight数组刷新总高度const allHeight = itemsHeight.reduce((accumulate, item) => accumulate + item, 0);virtualElement.style.height = allHeight + "px";}function render() {//用于根据scrollTop进行列表渲染//清空list数据list.innerHTML = "";let accumulate = 0;//累计高度//遍历itemsHeight找到firstIndexfor (let i = 0; i < itemsHeight.length; i++) {accumulate += itemsHeight[i];if (accumulate > scrollTop) {firstIndex = i;offsetTop = accumulate - itemsHeight[i] - itemsHeight[i - 1] - itemsHeight[i - 2];//更新上偏移量,这里设置了缓存,还要减去前两项的高度break;}}//根据上偏移量设置list的偏移量list.style.transform = `translateY(${offsetTop}px)`;//根据firstIndex,container.height,itemsHeight获得显示项数let lastIndex;//当前的最后一项accumulate = 0;//清空累加器for (let i = firstIndex; i < itemsHeight.length; i++) {accumulate += itemsHeight[i];//高度累加if (accumulate > containerHeight) {//大于容器高度lastIndex = i;break;}}lastIndex ??= itemsHeight.length - 1;//如果没有,赋值为最后一项下标//解决白屏问题lastIndex += 2;if (lastIndex > itemsHeight.length - 1)lastIndex = itemsHeight.length - 1firstIndex -= 2;if (firstIndex < 0)firstIndex = 0;// console.log(firstIndex, lastIndex);const currentArray = data.slice(firstIndex, lastIndex + 1);//裁剪出应该显示的数据//将裁剪的数据显示到list中currentArray.forEach((item, index) => {const div = document.createElement("div");div.innerText = item;list.append(div);//插入后获取高度,更新缓存const trueHeight = div.offsetHeight;itemsHeight[firstIndex + index] = trueHeight;});//根据缓存重新刷新高度refreshAllheight();}render();//第一次渲染数据container.addEventListener("scroll", function () {//绑定滚动事件scrollTop = this.scrollTop;//修改当前scrollToprender();//重新渲染});</script></html>
这里用到了 mockjs 来生成不定高度的模拟数据,可以参考 Mock.js
点我看效果