虚拟列表是解决大数据量渲染列表的一种方案。原理简述就是只渲染当前视口内的列表项。初次接触容易联想到浏览器的栅格化(raster),但是实现原理和类似于懒加载的栅格化不同,它始终只渲染当前视口的内容,而不是即将看到哪,就增加那块部分的渲染。
通常,解决大数据量渲染问题有两种方案,一种是采用时间分片,另一种就是本文要谈的虚拟列表。在正文开始前,不妨简单了解一下第一种方案,时间分片。
在什么优化方案都不用的单次渲染中,随着数据量的增多,页面会越来越卡顿。一次性把全部的数据都渲染了,导致了性能问题。思索一下,其实不需要把所有的数据都在第一时间渲染出来,只需要保障首屏的数据一次呈现,后面的逐渐渲染,不就看不出来卡顿了吗。这个渐渐渲染的方案就叫做时间分片
直接说目前的最优方案,用requestAnimationFrame
api,在浏览器空闲帧的间隙去渲染。 贴一段代码理解一下:
//需要插入的容器
let ul = document.getElementById('container');
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
if(curTotal <= 0){
return false;
}
//每页多少条
let pageCount = Math.min(curTotal , once);
window.requestAnimationFrame(function(){
for(let i = 0; i < pageCount; i++){
let li = document.createElement('li');
li.innerText = curIndex + i + ' : ' + ~~(Math.random() * total)
ul.appendChild(li)
}
loop(curTotal - pageCount,curIndex + pageCount)
})
}
loop(total,index);
todo: 使用setTimeout会导致闪屏,原因是时间循环机制导致的执行时间不确定,以及屏幕刷新率的影响
未来的优化更新: DocumentFragment
可以用append方法加入元素样式表的计算,性能更好
听上去按照时间逐渐分片渲染已经是很不错的方案了,但是数据量如果更大更多,用户大概率看不到下面的数据。这时候渲染的性能就浪费了,有些不划算。 因此,更优秀的虚拟列表方案来了,看到哪,渲染到哪,无敌~。但是虚拟列表进行渲染的时候也有一定的前置条件,不然也就没有时间分片渲染什么事了。
虚拟列表初次渲染时,只渲染当前可视区域的列表项。当发生滚动时,通过计算不断的更新列表项的Dom,展示滚动后的Dom,整体随着滚动条的滚出呈现高速更新的状态。
核心的原理就是如何根据滚动条计算,得到应该渲染哪些列表项。实现计算的前提有以下几点:
- 固定的可视内容区域(高度固定) 后文简称
curHeight
- 列表每一项的高度已知(本文中他们是相同的)。后文简称
itemHeight
- 一个额外的用来滚动的元素。它的高度是所有列表项高度 * 列表项的数量 后文简称
allHeight
大致的html结构如下:
<div class="list">
<div class="list-scroll-placeholder"></div>
<div class="list-content">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
list-scroll-placeholder
表示需要滚动的占位元素,高度是allHeight
list-content
表示列表渲染的主体,也就是可视内容区域,高度是curHeight
。其中的每一项高度是itemHeight
- 用
scrollTop
表示当前滚动距离顶部的高度 - 用
startIndex
和endIndex
分别表示渲染列表项的开头索引和结束索引
有了这些信息,我们尝试着进行一下滚动的计算。
首先初始的scrollTop为0,随着滚动条的滚动,渲染列表的开始索引和结束索引也跟着一起变化。
脑补一下可以想到 startIndex 始终等于 scrollTop 除以 itemHeight
我们向下取整得到开始索引: startIndex
=== Math.floor(scrollTop / itemHeight)
/** 滚动距离顶部的距离 */
const scrollTop = e.target.scrollTop;
startIndex === Math.floor(scrollTop / ITEM_HEIGHT);
由于endIndex等于startIndex加上中间渲染的列表项数。
因此向上取整得到结束索引: endIndex
=== startIndex + Math.ceil(curHeight / itemHeight)
/** 每一项的高度 */
const ITEM_HEIGHT = 40;
/** 可是区域的高度 */
const CUR_HEIGHT = 650;
/** 列表项的数量 */
const ITEM_NUM = Math.floor(CUR_HEIGHT / ITEM_HEIGHT);
endIndex === startIndex + ITEM_NUM;
有了这些计算逻辑,就已经可以实现一个简易的虚拟列表了!
编写一个测试列表
/** 模拟列表数据 */
const BASE_ARR = Array.from(
{ length: 500 },
(_, k) => `${BASE_ITEM.content}---第${k}次`
);
以react为例,根据起始索引和结束索引,可以维护出一个实际渲染的数组
/** 实际渲染的数组 */
const realList = useMemo(() => {
return BASE_ARR.slice(startIndex, startIndex + ITEM_NUM);
}, [startIndex]);
增加监听滚动条的逻辑
document.querySelector(".list").addEventListener("scroll", (e) => {
const scrollTop = e.target.scrollTop;
const newIndex = Math.floor(scrollTop / ITEM_HEIGHT);
setStartIndex(newIndex);
});
将这个数组和占位滚动条渲染到页面上,虚拟列表的简易版就出现了
<div className="wrap">
<div
className="list"
style={{
height: `${CUR_HEIGHT}px`,
}}
>
{/* 占位滚动区域,高度为allHeight */}
<div
className="list-scroll-placeholder"
style={{ height: `${BASE_ARR?.length * 40}px` }}
/>
{/* 可视区域,高度固定为curHeight */}
<div className="list-content">
{/* 每一项的高度,高度为itemHeight */}
{realList.map((item, index) => (
<div className="list-item" key={index}>
{item.content}-第{index + startIndex}次
</div>
))}
</div>
</div>
</div>
虽然简易版的列表已经”虚拟“起来了,但是很明显,它的更新频率非常高,性能不客观。于是开始学习网上大神们的优化方案
在以上实现的demo中,我们是预设了一些固定的值去计算使用。
但是在增加了后续一些优化后,我们会发现需要控制的值变多了。(放在这里是因为声明了变量,照顾后续的阅读体验)
这时候个人想法将是一些值改为props传参,毕竟大部分都是可配置的。
网上给出了我不曾设想的方案,原来useState也可以变成响应式的...
在demo2中,增加了响应式的状态管理 useReactive
const state = useReactive({
data: [], //渲染的数据
scrollAllHeight: "650px", // 容器的初始高度
listHeight: 0, //列表高度
itemHeight: 0, // 子组件的高度
renderCount: 0, // 需要渲染的数量
bufferCount: 6, // 缓冲的个数
start: 0, // 起始索引
end: 0, // 终止索引
currentOffset: 0, // 偏移量
});
在页面渲染时,通过计算取更新state里的值(计算原理是一样的)
useEffect(() => {
// 子列表高度
const ItemHeight = 40;
// 容器的高度
const scrollAllHeight = allRef.current.offsetHeight;
// 列表高度
const listHeight = ItemHeight * list.length;
//渲染节点的数量
const renderCount = Math.ceil(scrollAllHeight / ItemHeight) + state.bufferCount;
state.renderCount = renderCount;
state.end = renderCount + 1;
state.listHeight = listHeight;
state.itemHeight = ItemHeight;
state.data = list.slice(state.start, state.end);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allRef]);
以上我们的监听滚动条计算,是借助了useEffect,这种情况下如果组件渲染了两次,它就执行创建了两个”监听“。
使用useEventListener
可以避免这种情况(内部借助了useRef,阻止了多次设置监听)
useEventListener(
"scroll",
() => {
// 顶部高度
const { scrollTop } = scrollRef.current;
state.start = Math.floor(scrollTop / state.itemHeight);
state.end = Math.floor(
scrollTop / state.itemHeight + state.renderCount + 1
);
state.currentOffset = scrollTop - (scrollTop % state.itemHeight);
},
{ target: scrollRef }
);
据说相当于升级版的useMemo
听着牛逼唬人,其实就是用useRef的特性不重复render了呗
useCreation(() => {
state.data = list.slice(state.start, state.end);
}, [state.start]);
附 useCreation
源码
import type { DependencyList } from 'react';
import { useRef } from 'react';
import depsAreSame from '../utils/depsAreSame';
export default function useCreation<T>(factory: () => T, deps: DependencyList) {
const { current } = useRef({
deps,
obj: undefined as undefined | T,
initialized: false,
});
if (current.initialized === false || !depsAreSame(current.deps, deps)) {
current.deps = deps;
current.obj = factory();
current.initialized = true;
}
return current.obj as T;
}
优化前的版本很粗糙,可以看出随着滚动条滚动,整个列表都在高速更新。网上给出了缓存节点 的方案,原理很像懒加载,在没显示的区域预留几个缓存节点。
很牛,自己没想到这一点
增加它的原理很简单,只要在计算的时候多计算几个缓存节点就行了
//渲染节点的数量
const renderCount = Math.ceil(scrollAllHeight / ItemHeight) + state.bufferCount;
// state.bufferCount 即为缓存节点的数量,它的数量是自定义的
触底的时候触发请求,将数据拼接到list当中
useEventListener('scroll', () => {
// 顶部高度
const { clientHeight, scrollHeight } = scrollRef.current
// 滚动条距离的高度
const button = scrollHeight - clientHeight - scrollTop
if(button === 0 && onRequest){
onRequest()
}
}, scrollRef)
不定高很麻烦,因为无法计算出每个高度的情况,导致列表的整体高度
、偏移量
都无法正常的计算
解决思路:
- 第一种,将
ItemHeight
作为参数传递过来,我们可以根据传递数组
来控制,但这种情况需要我们提前将列表的高度算出来,算每个子列表的高度很麻烦,其次这个高度还要根据屏幕的大小去变化,这个方法明显不适合 - 第二种,
预算高度
,我们可以假定子列表的高度也就是虚假高度(initItemHeight
),当我们渲染的时候,在更新对应高度,这样就可以解决子列表高度的问题
参考链接原文中有实例
<完>
优化方案参考: https://juejin.cn/post/7121551701731409934#heading-16