虚拟列表

虚拟列表(Virtual List)是一种优化长列表性能的技术,它通过只渲染可视区域内的元素来大幅减少DOM节点数量,从而提高页面性能。基本原理如下

  1. 计算可视区域:确定容器高度和滚动位置
  2. 计算可见项:根据滚动位置确定哪些列表项应该显示
  3. 动态渲染:只渲染可见项,移除不可见项的DOM
  4. 占位元素:使用占位元素保持滚动条的正确性

DOM 结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<div
{/* 可视区域-滚动容器 */}
className={'scroll-container'}
ref={scrollContainerRef}
onScroll={handleScroll}
>
{/* scroll-runway 撑开内容的高度,保证出现滚动条 */}
<div className={'scroll-runway'} style={{ height: actualHeight }}>
{/* 占位元素,保证当前渲染的内容出现在可视区域内*/}
<div
className={'scroll-placement'}
style={{ transform: `translateY(${translateY}px)` }}
>
{/* renderList 动态渲染内容,只渲染可视区域的一小部分 */}
{renderList.map((item, index) => (
<ListItem
key={item.id}
{...item}
onHeightChange={height => {
if (height !== cachedHeightMap.get(item.id)) {
cachedHeightMap.set(item.id, height);
setRefreshTime(Date.now())
}
}}
/>
))}
</div>
</div>
</div>

ResizeObserver

对于不定高的虚拟列表,由于我们不知道每一项的高度,所以我们给一个每一项的预估高度 estimateHeight
通过 ResizeObserver 来监听每一行的高度,后面将每一行的高度缓存起来。
ListItem 组件如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import React, { useEffect, useRef } from 'react';
import './index.css'
const ListItem = (props) => {
const itemRef = useRef(null)
useEffect(() => {
// 通过 ResizeObserver 来监听每一项的高度,最后将高度缓存起来取代 estimateHeight
const observer = new ResizeObserver((entries) => {
const { borderBoxSize: [{ blockSize }] } = entries[0]
if (blockSize) {
props.onHeightChange?.(blockSize)
}
})
observer.observe(itemRef.current)
return () => {
if (itemRef.current) {
observer.unobserve(itemRef.current)
}
}
}, []);
return (
<div className={`list-item list-item-${props.id}`} ref={itemRef}>
<h4>
{props.id} / {props.title}
</h4>
<p>
{props.content}
</p>
</div>
)
}
export default ListItem

滚动监听

滚动时监听 scrollTop 的变化实时计算当前需要渲染的元素索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 获取某一行的高度
const getItemHeight = (item) => {
// 取真实高度值或者预估高度值
return cachedHeightMap.get(item.id) || estimatedHeight
}

const handleScroll = (e) => {
const { scrollTop } = e.target;
getRenderData(originalList, scrollTop)
}

// 获取可视区域的内渲染的数据
const getRenderData = (list, scrollTop) => {
let anchorIndex = 0;
let height = 0;
// 通过从第一个元素开始累加与当前的 scrollTop 对比从而计算出来当前锚点元素的索引 anchorIndex
while (scrollTop >= height) {
height += getItemHeight(originalList[anchorIndex]);
anchorIndex++;
}
// 起始索引 = anchorIndex - bufferSize
const startIndex = Math.max(0, anchorIndex - bufferSize)
// 结束索引 = 起始索引 + visibleCount + bufferSize
const endIndex = Math.min(startIndex + visibleCount.current + bufferSize, list.length - 1)
setRenderList(list.slice(startIndex, endIndex))
setStartIndex(startIndex)
}

偏移计算

通过 startIndex 计算出当前 scroll-placement translateY 的值

1
2
3
4
5
6
7
8
9
10
11
const translateY = useMemo(() => {
let y = 0
originalList.forEach((item, index) => {
if (index >= startIndex) {
return
}
y += getItemHeight(item)
})
return y
}, [startIndex])

预览

点击去预览 https://webengineerli.github.io/react-virtual-list
源码地址 https://github.com/WebEngineerLi/react-virtual-list