ahooks源码之useVirtualList

前言

对于海量数据渲染起来很耗费性能,并且会造成页面卡顿,下面结合 ahooks 的 useVirtualList 源码,看一下如何实现一个虚拟列表。

结构

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 useVirtualList = <T = any>(list: T[], options: Options<T>) => {
const {containerTarget, wrapperTarget, itemHeight, overscan = 5} = options;
const itemHeightRef = useLatest(itemHeight);
const size = useSize(containerTarget);
const scrollTriggerByScrollToFunc = useRef(false);
const [targetList, setTargetList] = useState<{ index: number; data: T }[]>([]);
const [wrapperStyle, setWrapperStyle] = useState<CSSProperties>({});

const getVisibleCount = (containerHeight: number, fromIndex: number) => {/*...*/};

const getOffset = (scrollTop: number) => {/*...*/};

const getDistanceTop = (index: number) => {/*...*/};

const totalHeight = useMemo(() => {/*...*/}, [list]);

const calculateRange = () => {/*...*/}

useUpdateEffect(() => {/*...*/}, [wrapperStyle]);

useEffect(() => {/*...*/}, [size?.width, size?.height, list]);

useEventListener(/*...*/);
const scrollTo = (index: number) => {/*...*/};

return [targetList, useMemoizedFn(scrollTo)] as const;
};

先来看下每个方法是做什么用的,后面再详细看实现

itemHeightRef:获取最新行高
size: 外部容器的宽高
scrollTriggerByScrollToFunc:是否使用跳转(在跳转时会用到)
targetList:真正需要渲染的列表
wrapperStyle:样式信息
getVisibleCount:计算可见的行数量
getOffset:计算滚动上去了多少项
getDistanceTop:根据index获取它上部的高度
totalHeight:总高度
calculateRange:计算并设置真正要渲染的列表
scrollTo:根据 index 跳转

其中最重要的就是 calculateRange 方法。

getVisibleCount

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const getVisibleCount = (containerHeight: number, fromIndex: number) => {
// 如果 itemHeightRef.current 是 Number 类型,说明每项高度相同
if (isNumber(itemHeightRef.current)) {
return Math.ceil(containerHeight / itemHeightRef.current);
}
// 行高不同,就遍历加每项高度,直到溢出
let sum = 0;
let endIndex = 0;
for (let i = fromIndex; i < list.length; i++) {
const height = itemHeightRef.current(i, list[i]);
sum += height;
endIndex = i;
if (sum >= containerHeight) {
break;
}
}
return endIndex - fromIndex;
};

getOffset

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const getOffset = (scrollTop: number) => {
// 等高直接除
if (isNumber(itemHeightRef.current)) {
return Math.floor(scrollTop / itemHeightRef.current) + 1;
}
// 不等高就遍历去加
let sum = 0;
let offset = 0;
for (let i = 0; i < list.length; i++) {
const height = itemHeightRef.current(i, list[i]);
sum += height;
if (sum >= scrollTop) {
offset = i;
break;
}
}
return offset + 1;
};

getDistanceTop

1
2
3
4
5
6
7
8
9
10
11
12
const getDistanceTop = (index: number) => {
// 如果 itemHeightRef.current 是 Number 类型,说明每项高度相同
if (isNumber(itemHeightRef.current)) {
const height = index * itemHeightRef.current;
return height;
}
// 如果高度不同, itemHeightRef 应该是 function,通过 reduce 累加计算高度
const height = list
.slice(0, index)
.reduce((sum, _, i) => sum + (itemHeightRef.current as ItemHeight<T>)(i, list[i]), 0);
return height;
};

totalHeight

1
2
3
4
5
6
7
8
9
10
11
const totalHeight = useMemo(() => {
// 如果每项等高,直接高度*数量
if (isNumber(itemHeightRef.current)) {
return list.length * itemHeightRef.current;
}
// 不等高再通过 reduce 累加计算高度
return list.reduce(
(sum, _, index) => sum + (itemHeightRef.current as ItemHeight<T>)(index, list[index]),
0,
);
}, [list]);

calculateRange

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
const calculateRange = () => {
// 获取外部容器
const container = getTargetElement(containerTarget);

if (container) {
// 获取滚动上去的高度,可见范围的高度
const {scrollTop, clientHeight} = container;
// 滚动上去的数量
const offset = getOffset(scrollTop);
// 可见的数量
const visibleCount = getVisibleCount(clientHeight, offset);
// 计算真正需要渲染的开始行索引
const start = Math.max(0, offset - overscan);
// 计算真正需要渲染的结束行索引
const end = Math.min(list.length, offset + visibleCount + overscan);

const offsetTop = getDistanceTop(start);

setWrapperStyle({
height: totalHeight - offsetTop + 'px',
marginTop: offsetTop + 'px',
});
// 真正需要渲染的数据
setTargetList(
list.slice(start, end).map((ele, index) => ({
data: ele,
index: index + start,
})),
);
}
};

计算的核心代码,通过该方法来计算真正要渲染的列表并让滚动条滚动。

useUpdateEffect

1
2
3
4
5
6
7
8
useUpdateEffect(() => {
// 获取内部容器
const wrapper = getTargetElement(wrapperTarget) as HTMLElement;
if (wrapper) {
// 添加样式
Object.keys(wrapperStyle).forEach((key) => (wrapper.style[key] = wrapperStyle[key]));
}
}, [wrapperStyle]);

只有在 wrapperStyle 变化时才触发,只有在重新计算的时候才改变了 wrapperStyle,主要作用是设置滚动条。

useEventListener 监听滚动事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
useEventListener(
'scroll',
(e) => {
// 如果直接根据 index 跳转的,关闭跳转标志然后 return
if (scrollTriggerByScrollToFunc.current) {
scrollTriggerByScrollToFunc.current = false;
return;
}
e.preventDefault();
// 重新计算
calculateRange();
},
{
target: containerTarget,
},
);

scrollTo 滚动跳转

1
2
3
4
5
6
7
8
9
10
11
const scrollTo = (index: number) => {
// 获取外部容器
const container = getTargetElement(containerTarget);
if (container) {
// 开启跳转标志
scrollTriggerByScrollToFunc.current = true;
// 设置滚动
container.scrollTop = getDistanceTop(index);
calculateRange();
}
};

总结

可以看到主要实现就是通过监听滚动事件,计算滚动上去的高度 + margin 实现让滚动条滚动,计算出可显示的行数量加上缓冲区的数量,成为真正要渲染的列表。


ahooks源码之useVirtualList
https://l1ushun.github.io/2023/07/12/ahooks-useVirtualList/
作者
liu shun
发布于
2023年7月12日