最近在学习ahooks的源码,打算写些文章记录并分享,讲解ahooks中的一些方法是如何实现的,并实现一个mini版本。如果你对该文章或者系列有任何建议,欢迎打出来我们一起探讨。
前言
在本篇文章中,我们将通过注释逐行讲解 ahooks 中的 useVirtualList 方法的源码。useVirtualList 是一个非常实用的自定义 hooks,它可以帮助我们优化长列表的性能,通过虚拟滚动的方式,只渲染可视区域内的列表项,而不必渲染整个列表。
源码解读
虚拟列表的原理,其实2024年了现在大家也都比较清楚了,无非就是根据当前可视区域的大小和位置,动态地渲染列表中的部分内容而非一次性渲染整个列表。
那么ahooks是如何把上述行为封装到一个hooks里的呢?
我们来看一下官方示例中的基础用法
ts
const containerRef = useRef(null);
const wrapperRef = useRef(null);
const originalList = useMemo(() => Array.from(Array(99999).keys()), []);
const [list] = useVirtualList(originalList, {
containerTarget: containerRef,
wrapperTarget: wrapperRef,
itemHeight: 60, // 下方div的height与marginBottom总和
overscan: 10,
});
return (
<>
<div ref={containerRef} style={{ height: '300px', overflow: 'auto', border: '1px solid' }}>
<div ref={wrapperRef}>
{list.map((ele) => (
<div key={ele.index} style={{ height: 52, marginBottom: 8 }}>
Row: {ele.data}
</div>
))}
</div>
</div>
</>
);
那么我们已经可以大致推测 useVirtualList
是如何实现的
- 根据容器的尺寸和滚动位置计算可见区域的起始索引和结束索引 :通过传入的
containerTarget
和wrapperTarget
,useVirtualList
可以获取到容器元素和列表包裹元素的引用。然后根据容器的尺寸和滚动位置,计算出当前可见区域的起始索引和结束索引。 - 动态生成可见区域的列表项 :根据计算得到的可见区域的起始索引和结束索引,从原始列表数据中截取对应的数据,然后将这些数据渲染成列表项。在提供的示例中,通过遍历
list
数组,并将每个列表项渲染成一个<div>
元素,其中ele.data
包含列表项的数据,ele.index
包含列表项的索引。 - 利用预渲染区域进行性能优化 :在
useVirtualList
中,可以通过设置overscan
参数来控制预渲染的区域大小。预渲染区域可以在用户滚动时提前渲染,以提升用户体验。比如说,设置overscan为5,就可以在距离顶部/底部还有5个元素的时候就开始提前进行渲染而不需要等待白屏了。 - 监听容器的滚动事件并及时更新列表内容 :通过
useEventListener
自定义 hook 监听容器的滚动事件,当滚动事件触发时,调用相应的更新函数,动态地更新可见区域的列表内容。
下面,是整体源码的解读,如果你想看简单的实现,也可以直接跳到动手实现的部分:
ts
import { useEffect, useMemo, useState, useRef } from 'react';
import type { CSSProperties } from 'react';
import useEventListener from '../useEventListener'; // 导入自定义 hook,用于监听事件
import useLatest from '../useLatest'; // 导入自定义 hook,用于获取最新的数据
import useMemoizedFn from '../useMemoizedFn'; // 导入自定义 hook,用于记忆函数
import useSize from '../useSize'; // 导入自定义 hook,用于获取元素大小
import { getTargetElement } from '../utils/domTarget'; // 导入工具函数,用于获取目标元素
import type { BasicTarget } from '../utils/domTarget'; // 导入类型声明,用于目标元素的基本类型
import { isNumber } from '../utils'; // 导入工具函数,用于检查是否为数字
import useUpdateEffect from '../useUpdateEffect'; // 导入自定义 hook,用于在依赖更新后执行副作用
type ItemHeight<T> = (index: number, data: T) => number; // 定义列表项高度类型
export interface Options<T> {
containerTarget: BasicTarget; // 容器元素的目标
wrapperTarget: BasicTarget; // 列表包裹元素的目标
itemHeight: number | ItemHeight<T>; // 列表项高度或计算函数
overscan?: number; // 为了减少js计算带来的白屏的影响,设置预渲染元素的数量,默认为5
}
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); // 用于标记是否由 scrollTo 函数触发的滚动事件
const [targetList, setTargetList] = useState<{ index: number; data: T }[]>([]); // 可见列表项数据的状态
const [wrapperStyle, setWrapperStyle] = useState<CSSProperties>({}); // 列表包裹元素的样式状态
// 计算可见列表项数量
const getVisibleCount = (containerHeight: number, fromIndex: 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; // 返回可见数量
};
// 计算偏移量
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; // 返回偏移量
};
// 获取上部高度
const getDistanceTop = (index: number) => {
if (isNumber(itemHeightRef.current)) { // 如果列表项高度为数字
const height = index * itemHeightRef.current; // 直接计算上部高度
return height;
}
const height = list
.slice(0, index)
.reduce((sum, _, i) => sum + (itemHeightRef.current as ItemHeight<T>)(i, list[i]), 0); // 计算上部高度
return height;
};
const totalHeight = useMemo(() => {
if (isNumber(itemHeightRef.current)) { // 如果列表项高度为数字
return list.length * itemHeightRef.current; // 直接计算总高度
}
return list.reduce(
(sum, _, index) => sum + (itemHeightRef.current as ItemHeight<T>)(index, list[index]), // 计算总高度
0,
);
}, [list]); // 当列表变化时重新计算
// 计算可见区域的列表项
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(() => {
const wrapper = getTargetElement(wrapperTarget) as HTMLElement; // 获取列表包裹元素
if (wrapper) {
Object.keys(wrapperStyle).forEach((key) => (wrapper.style[key] = wrapperStyle[key])); // 更新样式
}
}, [wrapperStyle]); // 当样式变化时重新执行
// 当容器大小或列表变化时,重新计算可见区域的列表项
useEffect(() => {
if (!size?.width || !size?.height) {
return;
}
calculateRange();
}, [size?.width, size?.height, list]); // 当容器大小或列表变化时重新执行
// 监听容器滚动事件
useEventListener(
'scroll',
(e) => {
if (scrollTriggerByScrollToFunc.current) { // 如果是 scrollTo 函数触发的滚动事件
scrollTriggerByScrollToFunc.current = false;
return;
}
e.preventDefault(); // 阻止默认滚动行为
calculateRange(); // 计算可见区域的列表项
},
{
target: containerTarget, // 监听容器元素
},
);
// 滚动到指定索引位置的列表项
const scrollTo = (index: number) => {
const container = getTargetElement(containerTarget); // 获取容器元素
if (container) {
scrollTriggerByScrollToFunc.current = true; // 标记为 scrollTo 函数触发的滚动事件
container.scrollTop = getDistanceTop(index); // 滚动到指定位置
calculateRange(); // 计算可见区域的列表项
}
};
return [targetList, useMemoizedFn(scrollTo)] as const; // 返回可见列表项数据和记忆化的 scrollTo 函数
};
export default useVirtualList;
动手实现
在大概了解了 useVirtualList
的源码之后,我们也可以尝试着自己不引入任何依赖,手动去实现一个mini版本。
我们只需要专注于计算可见区域的列表,返回当前的列表这一点即可,故不考虑动态高度之类的场景以得到简化的代码。
整理一下思路大概如下:
- 监听container滚动,并根据当前的滚动位置,以及overscan的预加载量计算可见的列表item
- 更新可见的list
- 根据传入的list和itemHeight去更新wrapper的高度以及上边距
实现的mini版本大概如下所示(源码仓库):
ts
import { CSSProperties, useEffect, useMemo, useState } from "react";
// 定义虚拟列表的参数接口
interface VirtualListOptions {
// 容器的引用
containerTarget: React.MutableRefObject<HTMLElement | null>;
// 包裹列表项的引用
wrapperTarget: React.MutableRefObject<HTMLElement | null>;
// 单个列表项的高度
itemHeight: number;
// 预加载数量
overscan?: number;
}
// 定义虚拟列表项的接口
interface VirtualListItem<T> {
// 在原始列表中的索引
index: number;
// 对应的数据
data: T;
}
export function useVirtualList<T>(originalList: T[], options: VirtualListOptions): [VirtualListItem<T>[]] {
const { containerTarget, wrapperTarget, itemHeight, overscan = 5 } = options;
const [visibleList, setVisibleList] = useState<VirtualListItem<T>[]>(
originalList.slice(0, 10).map((data, index) => ({
index,
data,
})) || []
);
const [wrapperStyle, setWrapperStyle] = useState<CSSProperties>({}); // 列表包裹元素的样式状态
const totalHeight = useMemo(() => originalList.length * itemHeight, [originalList]); // wrapper总高度
const getOffset = (scrollTop: number) => Math.floor(scrollTop / itemHeight) + 1;
const getDistanceTop = (index: number) => index * itemHeight;
const getVisibleCount = (containerHeight: number) => Math.ceil(containerHeight / itemHeight);
// 核心代码
const updateListAndStyle = () => {
if (containerTarget.current) {
// 1. 监听container滚动,并根据当前的滚动位置,以及overscan的预加载量计算可见的列表item
const { scrollTop, clientHeight } = containerTarget.current; // 获取容器滚动位置和高度
const offset = getOffset(scrollTop); // 获取偏移量
const visibleCount = getVisibleCount(clientHeight); // 获取可见列表项数量
const start = Math.max(0, offset - overscan); // 结合overscan计算起始index
const end = Math.min(originalList.length, offset + visibleCount + overscan); // 结合overscan计算结束index
// 2. 根据传入的list和itemHeight去更新wrapper的高度以及上边距
const offsetTop = getDistanceTop(start); // 获取上部高度
setWrapperStyle({
height: totalHeight - offsetTop + "px", // 设置高度
marginTop: offsetTop + "px", // 设置上边距
});
// 3. 更新可见列表
setVisibleList(
originalList.slice(start, end).map((ele, index) => ({
data: ele,
index: index + start,
}))
);
}
};
useEffect(() => {
if (!containerTarget.current || !wrapperTarget.current) return;
const container = containerTarget.current;
container.addEventListener("scroll", updateListAndStyle);
return () => container.removeEventListener("scroll", updateListAndStyle);
}, [containerTarget, wrapperTarget, originalList, itemHeight]);
useEffect(() => updateListAndStyle, []); // 挂载后更新一次,确保高度正确
useEffect(() => {
if (wrapperTarget.current) {
// @ts-expect-error object key typical error
Object.keys(wrapperStyle).forEach((key) => (wrapperTarget.current!.style[key] = wrapperStyle[key])); // 更新样式
}
}, [wrapperStyle]);
// 返回当前可见的列表项
return [visibleList];
}
效果预览:
总结
通过分析源码和示例,我们了解了虚拟列表的工作原理,以及如何利用预渲染区域进行性能优化。
在源码解读部分,我们逐步分析了 useVirtualList 方法的实现细节,包括计算可见区域的列表项、动态生成列表项、利用预渲染区域进行性能优化以及监听容器的滚动事件等。此外,我们还尝试手动实现了一个简化版的虚拟列表功能,通过自定义 Hook 来实现相似的功能。
虚拟列表的核心无外乎是计算可视区域的列表项,这是一切的基础。记住这点就好啦。
如果这篇文章对你有帮助的话,还请你不吝小手点一个免费的赞,这会给我很大的鼓励,谢谢你!