长列表渲染一直以来都是前端比较头疼的一个问题,如果想要在网页中放大量的列表项,纯渲染的话,对于浏览器性能将会是个极大的挑战,会造成滚动卡顿,整体体验非常不好,主要有以下问题:
对于长列表渲染,传统的方法是使用懒加载的方式,下拉到底部获取新的内容加载进来,其实就相当于是在垂直方向上的分页叠加功能,**但随着加载数据越来越多,浏览器的回流和重绘的开销将会越来越大
**
其核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分,然后使用padding或者translate来让渲染的列表偏移到可视区域中,给用户平滑滚动的感觉。
虚拟列表的核心步骤可以总结成五步:
startOffset
和endOffset
区域)列表项高度固定的话,就无需每次都计算当前应该渲染多少条数据,视口的数据量始终是固定的,只需要通过用户滚动的距离,来计算列表的开始结束索引即可。
1.根据容器的高度,计算出所需要渲染的列表项数,以及初始化列表高度 计算条数时,注意要使用Math.ceil(),而不是floor()
// 可视区域最多显示的条数
const limit = useMemo(
function () {
return Math.ceil(containerHeight / itemHeight);
},
[startIndex],
);
// 用于撑开Container的盒子,计算其高度
const wraperHeight = useMemo(
function () {
return list.length * itemHeight;
},
[list, itemHeight],
);
2.初始化开始和结束索引,更新渲染方法,设置缓冲区域
// 初始化开始索引
const [startIndex, setStartIndex] = useState(0);
// 列表的结束索引
const endIndex = useMemo(
function () {
return Math.min(startIndex + limit, list.length - 1);
},
[startIndex, limit],
);
// 根据索引渲染列表
const renderList = useCallback(
function () {
const rows = [];
// 多展示渲染1个,减少滑动过快的白屏
for (let i = startIndex; i <= endIndex + 1; i++) {
// 渲染每个列表项
rows.push(
<ItemBox
data={i}
key={i}
style={{
width: '100%',
height: itemHeight - 11 + 'px',
marginTop: '10px',
borderBottom: '1px solid #aaa',
position: 'absolute',
top: i * itemHeight + 'px',
left: 0,
right: 0,
backgroundColor: 'orange',
}}
/>,
);
}
return rows;
},
[startIndex, endIndex, ItemBox],
);
3.监听滚动事件,根据滚动后的scrollTop计算出新的开始和结束索引
// 监听滚动
const handleSrcoll = useCallback(
function (e: any) {
// 过滤页面其他滚动
if (e.target !== ContainerRef.current) return;
const scrollTop = e.target.scrollTop;
// 根据滚动距离计算开始项索引
let currentIndex = Math.floor(scrollTop / itemHeight);
if (currentIndex !== startIndex) {
setStartIndex(currentIndex);
}
},
[ContainerRef, itemHeight, startIndex],
);
4.对滚动事件做节流优化
// 利用请求动画帧做了一个节流优化
let then = useRef(0);
const boxScroll = (e:any) => {
const now = Date.now();
/**
* 这里的等待时间不宜设置过长,不然会出现滑动到空白占位区域的情况
* 因为间隔时间过长的话,太久没有触发滚动更新事件,下滑就会到padding-bottom的空白区域
* 电脑屏幕的刷新频率一般是60HZ,渲染的间隔时间为16.6ms,我们的时间间隔最好小于两次渲染间隔16.6*2=33.2ms,一般情况下30ms左右,
*/
if (now - then.current > 30) {
then.current = now;
// 重复调用scrollHandle函数,让浏览器在下一次重绘之前执行函数,可以确保不会出现丢帧现象
window.requestAnimationFrame(() => handleSrcoll(e));
}
};
这里滑动过快还是会存在一个白屏的现象,目前想到的办法有两个
当列表项的高度不固定的时候,我们就需要一个策略来得到需要渲染的列表项,就是先给没有渲染出来的列表项设置一个预估高度,等到这些数据渲染成真实dom元素了之后,再获取到他们的真实高度去更新原来设置的预估高度
,然后来获取列表项的开始索引。
1.初始化列表项数,开始结束索引,以及列表项缓存数组
首先我们需要给定一个初始的列表项高度,并初始化一个用于列表项高度以及位置信息的数组,这里存储位置信息的目的是可以直接通过比较scrollTop
值和列表项的top
来得出列表的开始索引。
// 初始化开始索引
const [startIndex, setStartIndex] = useState(0);
// 初始化缓存数组
// 先给没有渲染出来的列表项设置一个预估高度,等到这些数据渲染成真实dom元素了之后,再获取到他们的真实高度去更新原来设置的预估高度
// 高度尽量往小范围设置,避免出现空白
const [positionCache, setPositionCache] = useState(function () {
const positList: any = [];
list.forEach((_: any, i: number) => {
positList[i] = {
index: i,
height: estimatedItemHeight,
top: i * estimatedItemHeight,
bottom: (i + 1) * estimatedItemHeight, // 元素底部和容器顶部的距离
};
});
return positList;
});
// 根据缓存数组的高度,来设置展示条数
const limit = useMemo(
function () {
let sum = 0;
let i = 0;
for (; i < positionCache.length; i++) {
sum += positionCache[i].height;
if (sum >= containerHeight) {
break;
}
}
return i;
},
[positionCache],
);
// 获取结束索引
const endIndex = useMemo(
function () {
return Math.min(startIndex + limit, list.length - 1);
},
[startIndex, limit],
);
2.更新当前列表项的高度和位置
当用户滚动时,我们需要一直更新这个缓存数组中的列表项信息,目的是下次计算就能使用列表项的真实高度和位置,从而准确渲染出列表项。并且需要注意的是,不只是需要更新视图中的列表项,还需要更新之后的所有列表项
// 每次滚动,都去更新缓存数组中dom的高度和位置
useEffect(
function () {
// 获取当前视口中的列表节点
const nodeList = WraperRef.current.childNodes;
const positList = [...positionCache];
let needUpdate = false;
nodeList.forEach((node: any) => {
let newHeight = node.clientHeight;
// 获取节点id,映射缓存数组中的位置
const nodeID = Number(node.id.split('-')[1]);
const oldHeight = positionCache[nodeID]['height'];
// 高度发生变化,更新缓存数组
const dValue = oldHeight - newHeight;
if (dValue) {
needUpdate = true;
positList[nodeID].height = node.clientHeight;
// 当前节点与底部的距离 = 上一个节点与底部的距离 + 当前节点的高度
positList[nodeID].bottom = nodeID > 0 ? psitList[nodeID - 1].bottom + positList[nodeID].height : positList[nodeID].height;
// 当前节点与顶部的距离 = 上一个节点与底部的距离
positList[nodeID].top = nodeID > 0 ? positList[nodeID - 1].bottom : 0;
// 更改一个节点就需要更改之后所有的值,不然会造成空白
for (let j = nodeID + 1, len = positList.length; j < len; j++) {
positList[j].top = positList[j - 1].bottom;
positList[j].bottom += dValue;
}
}
});
// 相同节点不更新数组
if (needUpdate) {
setPositionCache(positList);
}
},
[scrollTop],
);
3.监听用户滚动,更新列表开始索引
这里我们需要在列表项里面去重新寻找开始索引,因为存了列表项的top
值,所以这里我们比较其scrollTop
的大小即可,并且数组中的列表项遵循从上往下排列,所以其top和bottom值必定也是线性变化的,所以这里我们可以使用二分
查找来进行性能优化。
// 滚动事件监听
const handleSrcoll = useCallback(
function (e: any) {
if (e.target !== ContainerRef.current) return;
const scrollTop = e.target.scrollTop;
setScrollTop(scrollTop);
// 根据当前偏移量,获取当前最上方的元素
// 因为滚轮一开始一定是往下的,所以上方的元素高度与顶部和底部的距离等都是被缓存的
const currentStartIndex = getStartIndex(scrollTop);
// console.log(currentStartIndex);
// 设置索引
if (currentStartIndex !== startIndex) {
setStartIndex(currentStartIndex);
// console.log(startIndex + '====--' + limit + '--====' + endIndex);
}
},
[ContainerRef, estimatedItemHeight, startIndex],
);
二分查找核心代码
// 如果滚轮从下往上滚动,我们就可以通过二分查找快速找到最上方的节点
const getStartIndex = function (scrollTop: any) {
let idx =
binarySearch(positionCache, scrollTop, (currentValue: any, targetValue: any) => {
// 传入的比较方法,通过比较顶部距离与最上方节点的bottom值来决定列表的第一个元素
const currentCompareValue = currentValue.bottom;
if (currentCompareValue === targetValue) {
return CompareResult.eq;
}
if (currentCompareValue < targetValue) {
return CompareResult.lt;
}
return CompareResult.gt;
}) || 0;
const targetItem = positionCache[idx];
if (targetItem.bottom < scrollTop) {
idx += 1;
}
return idx;
};
// 二分查找核心算法
const binarySearch = function (list: any, value: any, compareFunc: any) {
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while (start <= end) {
tempIndex = Math.floor((start + end) / 2);
const midValue = list[tempIndex];
const compareRes = compareFunc(midValue, value);
// 一般情况是找不到完全相等的值,只能找到最接近的值
if (compareRes === CompareResult.eq) {
return tempIndex;
}
if (compareRes === CompareResult.lt) {
start = tempIndex + 1;
} else if (compareRes === CompareResult.gt) {
end = tempIndex - 1;
}
}
return tempIndex;
};
这里有两种方式,可以通过translate
,也可以通过paddingTop paddingBottom
来实现
// 使用translate来校正滚动条位置
// 也可以使用paddingTop来实现,目的是将子节点准确放入视口中
const getTransform = useCallback(
function () {
// return `translate3d(0,${startIndex >= 1 ? positionCache[startIndex - 1].bottom : 0}px,0)`;
return {
// 改变空白填充区域的样式,起始元素的top值就代表起始元素距顶部的距离,可以用来充当paddingTop值
paddingTop: `${positionCache[startIndex].top}px`,
// 缓存中最后一个元素的bottom值与endIndex对应元素的bottom值的差值可以用来充当paddingBottom的值
paddingBottom: `${positionCache[positionCache.length - 1].bottom - positionCache[endIndex].bottom}px`,
};
},
[positionCache, startIndex],
);
感谢你能看到这里,文中实现了两种情况的虚拟列表,当然,所有的列表项数据还是都需要接口来进行请求的,所以在滚动的时候,我们还需要加上监听滚动条位置并且从接口拉取数据的逻辑,所以需要优化的地方还很多。
如果可以的话,不妨给笔者留个赞再走呢