前言
很久没更新了,本文是 ahooks 源码(v3.7.4)系列的第十三篇------【解读 ahooks 源码系列】 Scene 篇(三)
往期文章:
- 【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget
- 【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag
- 【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover
- 【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress
- 【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin
- 【解读 ahooks 源码系列】Dev 篇------useTrackedEffect 和 useWhyDidYouUpdate
- 【解读 ahooks 源码系列】Advanced 篇:useControllableValue、useCreation、useIsomorphicLayoutEffect、useEventEmitter、useLatest、useMemoizedFn、useReactive
- 【解读 ahooks 源码系列】State 篇(一):useSetState、useToggle、useBoolean、useCookieState、useLocalStorageState、useSessionStorageState、useDebounce、useThrottle
- 【解读 ahooks 源码系列】State 篇(二):useMap、useSet、usePrevious、useRafState、useSafeState、useGetState、useResetState
- 【解读 ahooks 源码系列】Effect 篇(一):useUpdateEffect、useUpdateLayoutEffect、useAsyncEffect、useDebounceFn、useDebounceEffect、useThrottleFn、useThrottleEffect
- 【解读 ahooks 源码系列】Effect 篇(二):useDeepCompareEffect、useDeepCompareLayoutEffect、useInterval、useTimeout、useRafInterval、useRafTimeout、useLockFn、useUpdate、useThrottleEffect
- 【解读 ahooks 源码系列】LifeCycle 篇 与 Scene 篇(一):useMount、useUnmount、useUnmountedRef、useCounter、useNetwork、useSelections、useHistoryTravel
- 【解读 ahooks 源码系列】LifeCycle 篇 与 Scene 篇(二):useTextSelection、useCountdown、useDynamicList、useWebSocket
本文主要解读 useVirtualList
、usePagination
的源码实现
useVirtualList
提供虚拟化列表能力的 Hook,用于解决展示海量数据渲染时首屏渲染缓慢和滚动卡顿问题。
基本用法
渲染大量数据
ts
import React, { useMemo, useRef } from 'react';
import { useVirtualList } from 'ahooks';
export default () => {
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,
overscan: 10,
});
return (
<>
<div ref={containerRef} style={{ height: '300px', overflow: 'auto', border: '1px solid' }}>
<div ref={wrapperRef}>
{list.map((ele) => (
<div
style={{
height: 52,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
border: '1px solid #e8e8e8',
marginBottom: 8,
}}
key={ele.index}
>
Row: {ele.data}
</div>
))}
</div>
</div>
</>
);
};
核心实现
虚拟列表不直接显示和渲染所有列表项,而是渲染可视区域内的一部分列表元素,来解决海量数据渲染时产生的卡顿问题
useVirtualList 实现原理:监听外面容器的 scroll 和 size 事件,当发生变化时,触发计算逻辑。
关于虚拟列表的原理在这就不详细展开了,这里直接看代码,该 hook 传入参数有 list 列表 和 options 选项, options 选项包括:
ts
interface Options<T> {
// 外面容器,支持 DOM 节点或者 Ref 对象
containerTarget: BasicTarget;
// 内部容器,支持 DOM 节点或者 Ref 对象
wrapperTarget: BasicTarget;
// 行高度,静态高度可以直接写入像素值,动态高度可传入函数
itemHeight: number | ItemHeight<T>;
// 视区上、下额外展示的 DOM 节点数量
overscan?: number;
}
useVirtualList 执行后的返回结果:
js
// 返回 [当前需要展示的列表内容, 快速滚动到指定 index]
return [targetList, useMemoizedFn(scrollTo)] as const;
一、监听容器的 scroll 和 size 事件
js
// 监听外面容器的 scroll 事件
useEventListener(
'scroll',
(e) => {
// 滚动由 scrollTo 函数触发,则不处理
if (scrollTriggerByScrollToFunc.current) {
scrollTriggerByScrollToFunc.current = false;
return;
}
e.preventDefault();
calculateRange(); // 触发计算逻辑
},
{
target: containerTarget,
},
);
// 当外面容器的 size 发生变化时触发计算逻辑
useEffect(() => {
if (!size?.width || !size?.height) {
return;
}
calculateRange();
}, [size?.width, size?.height, list]);
二、各个辅助方法的实现
getVisibleCount
:计算可视区域内的列表项数量
js
const itemHeightRef = useLatest(itemHeight);
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;
};
getOffset
:获取外面容器可视范围外上面的偏移(计算上面偏移能容下多少 DOM 节点)
js
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
:获取上部高度
js
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;
};
totalHeight
:总高度
js
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]);
三、 calculateRange
:计算列表变化范围
实现思路:
- 确定可视区能显示的列表项数量 visibleCount
- 向上滚动的当前位置开始下标与结束下标,确定显示的列表范围
- 确定每个元素的 top,当向上滑动时,确定当前的位置与最后元素的位置索引,根据当前位置与最后元素位置,渲染可视区域
计算逻辑:
- 根据外面容器的 scrollTop 属性计算滚过多少列表项个数,记为 offset
- 计算可视区的列表数量,记为 visibleCount
- 根据 overscan(视区上、下额外展示的 DOM 节点数量)计算出开始下标 start 和结束下标 end
- 设置内部容器总高度 = totalHeight(总高度) - offsetTop(开始下标到上方高度)
- 设置内部容器 marginTop 为 offsetTop(开始下标 start 获取其到最上方的距离)
- 使用开始下标 start 和结束下标 end 区间来更新列表
overscan
作用可以理解为缓冲,在可视区外额外展示的 DOM 节点数量,减少出现滚动过快导致白屏闪屏的现象发生
四、另外提供了 scrollTo
的方法
scrollTo
函数实现原理:传入滚动要滚动到列表项 index 索引,然后把前面所有项的高度累加,这个累加值就是该 index 距离顶部的距离,设置为外面容器的 scrollTop 属性值,触发重新计算逻辑
js
// 快速滚动到指定 index
const scrollTo = (index: number) => {
const container = getTargetElement(containerTarget);
if (container) {
scrollTriggerByScrollToFunc.current = true;
container.scrollTop = getDistanceTop(index); // 计算该 index 距离顶部的高度
calculateRange(); // 触发计算逻辑
}
};
usePagination
usePagination
基于 useRequest
实现,封装了常见的分页逻辑。与 useRequest
不同的点有以下几点:
- service 的第一个参数为
{ current: number, pageSize: number }
- service 返回的数据结构为
{ total: number, list: Item[] }
- 会额外返回
pagination
字段,包含所有分页信息,及操作分页的函数。 refreshDeps
变化,会重置 current 到第一页,并重新发起请求,一般你可以把pagination
依赖的条件放这里
基本用法
默认用法与 useRequest
一致,但会多返回一个 pagination
参数,包含所有分页信息,及操作分页的函数。
ts
import { usePagination } from 'ahooks';
import { Pagination } from 'antd';
import Mock from 'mockjs';
import React from 'react';
interface UserListItem {
id: string;
name: string;
gender: 'male' | 'female';
email: string;
disabled: boolean;
}
const userList = (current: number, pageSize: number) =>
Mock.mock({
total: 55,
[`list|${pageSize}`]: [
{
id: '@guid',
name: '@name',
'gender|1': ['male', 'female'],
email: '@email',
disabled: false,
},
],
});
async function getUserList(params: {
current: number;
pageSize: number;
}): Promise<{ total: number; list: UserListItem[] }> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(userList(params.current, params.pageSize));
}, 1000);
});
}
export default () => {
const { data, loading, pagination } = usePagination(getUserList);
return (
<div>
{loading ? (
<p>loading</p>
) : (
<ul>
{data?.list?.map((item) => (
<li key={item.email}>
{item.name} - {item.email}
</li>
))}
</ul>
)}
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={data?.total}
onChange={pagination.onChange}
onShowSizeChange={pagination.onChange}
showQuickJumper
showSizeChanger
style={{ marginTop: 16, textAlign: 'right' }}
/>
</div>
);
};
核心实现
usePagination
是基于 useRequest
实现的,它规定了 useRequest
第一个参数 service
的 TS 参数类型和 service
的返回结果类型。
ts
// 响应类型
type Data = { total: number; list: any[] };
// 参数类型
type Params = [{ current: number; pageSize: number; [key: string]: any }, ...any[]];
type Service<TData extends Data, TParams extends Params> = (
...args: TParams
) => Promise<TData>;
const usePagination = <TData extends Data, TParams extends Params>(
service: Service<TData, TParams>,
options: PaginationOptions<TData, TParams> = {},
) => {
// 默认分页数量为10,初次请求时的页数为1
const { defaultPageSize = 10, defaultCurrent = 1, ...rest } = options;
// 请求接口
const result = useRequest(service, {
defaultParams: [{ current: defaultCurrent, pageSize: defaultPageSize }],
// refreshDeps 变化
refreshDepsAction: () => {
// refreshDeps 依赖变更后重置当前页数为1
// eslint-disable-next-line @typescript-eslint/no-use-before-define
changeCurrent(1);
},
...rest, // useRequest 支持的其余参数
});
// ...
}
核心逻辑在 onChange
方法,当修改页码或每页条数时会触发该方法,在该方法会重新计算参数然后重新发起请求。
ts
const usePagination = <TData extends Data, TParams extends Params>(
service: Service<TData, TParams>,
options: PaginationOptions<TData, TParams> = {},
) => {
// ...
/**
* 页码或 pageSize 改变的回调
* @param c currentPage 当前页码
* @param p pageSize 每页条数
*/
const onChange = (c: number, p: number) => {
let toCurrent = c <= 0 ? 1 : c;
const toPageSize = p <= 0 ? 1 : p;
// 计算总页数
const tempTotalPage = Math.ceil(total / toPageSize);
// 当前页数大于总页数,则取最大值
if (toCurrent > tempTotalPage) {
toCurrent = Math.max(1, tempTotalPage); // 当前页数 Math.max 用1兜底异常数据
}
const [oldPaginationParams = {}, ...restParams] = result.params || [];
// 使用最新的 current 和 pageSize 参数去请求
result.run(
{
...oldPaginationParams,
current: toCurrent,
pageSize: toPageSize,
},
...restParams,
);
};
// 改变当前页数
const changeCurrent = (c: number) => {
onChange(c, pageSize);
};
// 改变每页条数
const changePageSize = (p: number) => {
onChange(current, p);
};
return {
...result,
// 分页信息与操作分页方法都集成到 pagination 对象
pagination: {
current,
pageSize,
total,
totalPage,
onChange: useMemoizedFn(onChange),
changeCurrent: useMemoizedFn(changeCurrent),
changePageSize: useMemoizedFn(changePageSize),
},
} as PaginationResult<TData, TParams>;
}