ahooks 源码浅析 — Scene
如上图所示,描述了useFusionTable、useAntdTable、usePagination、useRequest四个hook的调用依赖关系。
usePagination
基于useRequest封装了常用的分页逻辑,针对入参和出参做了处理。
- 入参:defaultParams,设置了current和pageSize,会传递给service接口使用。
- 出参:扩展了pagination,额外返回分页信息以及分页操作函数以便给组件层使用。
const usePagination = (service, options) => {
const { defaultPageSize = 10, ...rest } = options;
const result = useRequest(service, {
defaultParams: [{ current: 1, pageSize: defaultPageSize }],
refreshDepsAction: () => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
changeCurrent(1);
},
...rest,
});
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);
}
const [oldPaginationParams = {}, ...restParams] = result.params || [];
result.run(
{
...oldPaginationParams,
current: toCurrent,
pageSize: toPageSize,
},
...restParams,
);
};
// changeCurrent、changePageSize逻辑
return {
...result,
pagination: {
current,
pageSize,
total,
totalPage,
onChange: useMemoizedFn(onChange), // 页码或 pageSize 改变的回调
changeCurrent: useMemoizedFn(changeCurrent),
changePageSize: useMemoizedFn(changePageSize),
},
} as PaginationResult<TData, TParams>;
};
useAntdTable
基于 useRequest 实现,封装了常用的 Ant Design Form 与 Ant Design Table 联动逻辑,并且同时支持 antd v3 和 v4。
- 出参:扩展了tableProps 和 search 字段,管理表格和表单。提供submit,reset,onTableChange提供给组件层
- Form与Table联动,需要传递form实例,以便内部处理表单相关行为。例如提交、重置、校验,进一步触发请求逻辑。
- useUpdateEffect可以忽略首次渲染执行,只在依赖更新的
const useAntdTable = (service, options) => {
const {
form, //接收form实例,实现Form与Table联动
defaultType = 'simple',
defaultParams,
manual = false,
refreshDeps = [],
ready = true,
...rest
} = options;
const result = usePagination<TData, TParams>(service, {
manual: true,
...rest,
});
const { params = [], run } = result; //params记录缓存的数据
//省略 reset、 submit、 onTableChange 等函数
return {
...result,
tableProps: {
dataSource: result.data?.list || defaultDataSourceRef.current,
loading: result.loading,
onChange: useMemoizedFn(onTableChange),
pagination: {
current: result.pagination.current,
pageSize: result.pagination.pageSize,
total: result.pagination.total,
},
},
search: {
submit: useMemoizedFn(submit),
type,
changeType: useMemoizedFn(changeType),
reset: useMemoizedFn(reset),
},
} as AntdTableResult<TData, TParams>;
};
useFusionTable
上面说到在调用hook依赖中用到了useAntdTable,这里比较巧妙的是用到了适配器模式,针对入参和出参进行了适配转换。
const useFusionTable = (service, options): FusionTableResult<TData, TParams> => {
const ret = useAntdTable<TData, TParams>(service, {
...options,
form: options.field ? fieldAdapter(options.field) : undefined,
});
return resultAdapter(ret);
};
// 表单实例适配器,操作表单相关函数做映射
export const fieldAdapter = (field: Field) =>
({
getFieldInstance: (name: string) => field.getNames().includes(name),
setFieldsValue: field.setValues,
getFieldsValue: field.getValues,
resetFields: field.resetToDefault,
validateFields: (fields, callback) => {
field.validate(fields, callback);
},
} as AntdFormUtils);
// 结果适配器,磨平Fusion和Antd差异
export const resultAdapter = (result: any) => {
const tableProps = {
dataSource: result.tableProps.dataSource,
loading: result.tableProps.loading,
onSort: (dataIndex: string, order: string) => {
result.tableProps.onChange(
{ current: result.pagination.current, pageSize: result.pagination.pageSize },
result.params[0]?.filters,
{
field: dataIndex,
order,
},
);
},
onFilter: (filterParams: Object) => {
result.tableProps.onChange(
{ current: result.pagination.current, pageSize: result.pagination.pageSize },
filterParams,
result.params[0]?.sorter,
);
},
};
const paginationProps = {
onChange: result.pagination.changeCurrent,
onPageSizeChange: result.pagination.changePageSize,
current: result.pagination.current,
pageSize: result.pagination.pageSize,
total: result.pagination.total,
};
return {
...result,
tableProps,
paginationProps,
};
};
useInfiniteScroll
封装了常见的无限滚动逻辑,会自动帮忙整合多次请求的数据结果。
- 依赖的hook有useRequest、useUpdateEffect、useMemoizedFn、useEventListener。
const scrollMethod = () => {
const el = getTargetElement(target);
if (!el) {
return;
}
// 元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。
const scrollTop = getScrollTop(el);
// 元素内容高度的度量
const scrollHeight = getScrollHeight(el);
// 元素内部的高度(单位像素),包含内边距,但不包括水平滚动条、边框和外边距。
const clientHeight = getClientHeight(el);
if (scrollHeight - scrollTop <= clientHeight + threshold) {
loadMore();
}
};
useEventListener(
'scroll',
() => {
if (loading || loadingMore) {
return;
}
scrollMethod();
},
{ target },
);
useDynamicList
管理动态列表状态,并能生成唯一 key 。
- 内部维护了一个计数器counterRef,和 唯一key的列表。
- 计数器是不断累加的,移除操作不会改变计数器的当前记录值。只是删除对应的key值。再次添加,会在计数器记录值上继续累加。
- 增删逻辑转换,以便返回新数组进行赋值。
- push(item: T): number ===> concat([item]): T[]
- pop(): T | undefined ===> slice(start?: number, end?: number): T[];
- unshift(item: T): number ===> concat([item]): T[]
- shift(): T | undefined ===> slice(start?: number, end?: number): T[];
const useDynamicList = <T>(initialList: T[] = []) => {
// 计数器
const counterRef = useRef(-1);
// 唯一key列表
const keyList = useRef<number[]>([]);
const setKey = useCallback((index: number) => {
counterRef.current += 1;
keyList.current.splice(index, 0, counterRef.current);
}, []);
// 基于入参List,记录数量和keyList。
const [list, setList] = useState(() => {
initialList.forEach((_, index) => {
setKey(index);
});
return initialList;
});
const push = useCallback((item: T) => {
setList((l) => {
setKey(l.length);
return l.concat([item]);
});
}, []);
const pop = useCallback(() => {
// remove keys if necessary
try {
keyList.current = keyList.current.slice(0, keyList.current.length - 1);
} catch (e) {
console.error(e);
}
setList((l) => l.slice(0, l.length - 1));
}, []);
}
useVirtualList
提供虚拟化列表能力的 Hook,用于解决展示海量数据渲染时首屏渲染缓慢和滚动卡顿问题。
- 首先需要清楚几个概览:可视区域、可滚动区域、滚动元素。(如下图所示)
- 计算当前可见区域起始数据的 startIndex(包含上面区域额外的节点数overscan)
- 计算当前可见区域结束数据的 endIndex(包含下面区域额外的节点数overscan)
- 计算内容高度,以及marginTop高度,来抵消上部移除的节点所占据高度。
- 计算当前可见区域的数据,并渲染到页面中
const useVirtualList = <T = any>(list: T[], options: Options<T>) => { const { const useVirtualList = <T = any>(list: T[], options: Options<T>) => {
const { containerTarget, wrapperTarget, itemHeight, overscan = 5 } = options;
const totalHeight = useMemo(() => {
if (isNumber(itemHeightRef.current)) {
return list.length * itemHeightRef.current;
}
// @ts-ignore
// Item 高度不一致时,计算逻辑
return list.reduce((sum, _, index) => sum + itemHeightRef.current(index, list[index]), 0);
}, [list]);
// 计算需要渲染Item的索引范围
const calculateRange = () => {
const container = getTargetElement(containerTarget); // 可视区域
const wrapper = getTargetElement(wrapperTarget); // 可滚动区域
if (container && wrapper) {
const { scrollTop, clientHeight } = container;
const offset = getOffset(scrollTop); // 顶部偏移的Item数
const visibleCount = getVisibleCount(clientHeight, offset); //可见Item数
const start = Math.max(0, offset - overscan);
const end = Math.min(list.length, offset + visibleCount + overscan);
const offsetTop = getDistanceTop(start);// 获取上部高度
// 不渲染真实的节点,用距离来控制。
// @ts-ignore
wrapper.style.height = totalHeight - offsetTop + 'px';
// @ts-ignore
wrapper.style.marginTop = offsetTop + 'px';
setTargetList(
list.slice(start, end).map((ele, index) => ({
data: ele,
index: index + start,
})),
);
}
};
useEffect(() => {
if (!size?.width || !size?.height) {
return;
}
calculateRange();
}, [size?.width, size?.height, list]);
useEventListener(
'scroll',
(e) => {
if (scrollTriggerByScrollToFunc.current) {
scrollTriggerByScrollToFunc.current = false;
return;
}
e.preventDefault();
calculateRange();
},
{
target: containerTarget,
},
);
const scrollTo = (index: number) => {
const container = getTargetElement(containerTarget);
if (container) {
scrollTriggerByScrollToFunc.current = true; // 标记位,避免二次触发scroll事件
container.scrollTop = getDistanceTop(index);
calculateRange();
}
};
return [targetList, useMemoizedFn(scrollTo)] as const;
};
export default useVirtualList;
useHistoryTravel
管理状态历史变化记录,方便在历史记录中前进与后退。例如撤销,恢复的场景。
- 维护了三个变量present、past、future分别记录了当前值,过去及未来的值列表。
- 更新值时:记录当前值,把原值追加到past中;
- 回退:从past中进行拆分,同时把原值插入到future中;
- 前进:从future中进行拆分,同时把原值插入到future中;
如下表格展示了Input输入后状态的变化,以及前进,回退操作后状态变化。
input初始值 | present: undefined, past: [], future: [] |
---|---|
输入1 | present: "1", past: [undefined], future: [] |
追加输入2 | present: "12", past: [undefined, "1"], future: [] |
追加输入3 | present: "123", past: [undefined, "1", "12"], future: [] |
回退 | present: "12", past: [undefined, "1"], future: ["123"] |
回退 | present: "1", past: [undefined], future: ["12", "123"] |
前进 | present: "12", past: [undefined, "1"], future: ["123"] |
export default function useHistoryTravel<T>(initialValue?: T) {
const [history, setHistory] = useState<IData<T | undefined>>({
present: initialValue, // 记录当前值
past: [], // 记录过去的值
future: [], // 记录未来的值
});
const updateValue = (val: T) => {
setHistory({
present: val,
future: [],
past: [...past, present], // 更新当前值,把上一次值追加到past数组中
});
};
const _backward = (step: number = -1) => {
if (past.length === 0) {
return;
}
const { _before, _current, _after } = split(step, past);
console.log(_before, _current, _after);
setHistory({
past: _before,
present: _current,
future: [..._after, present, ...future],
});
};
}
useNetwork
管理网络连接状态。监听online 、 offline、 change事件。初始网络状态通过navigator.onLine获取
function useNetwork(): NetworkState {
const [state, setState] = useState(() => {
// 初始化状态值
return {
since: undefined,
online: navigator?.onLine,
...getConnectionProperty(),
};
});
useEffect(() => {
const onOnline = () => {
setState((prevState) => ({
...prevState,
online: true,
since: new Date(),
}));
};
const onOffline = () => {
setState((prevState) => ({
...prevState,
online: false,
since: new Date(),
}));
};
const onConnectionChange = () => {
setState((prevState) => ({
...prevState,
...getConnectionProperty(),
}));
};
window.addEventListener(NetworkEventType.ONLINE, onOnline); // 监听在线状态
window.addEventListener(NetworkEventType.OFFLINE, onOffline); // 监听离线状态
const connection = getConnection();
connection?.addEventListener(NetworkEventType.CHANGE, onConnectionChange);
// 组件卸载,移除事件监听
return () => {
window.removeEventListener(NetworkEventType.ONLINE, onOnline);
window.removeEventListener(NetworkEventType.OFFLINE, onOffline);
connection?.removeEventListener(NetworkEventType.CHANGE, onConnectionChange);
};
}, []);
return state;
}
useSelections
联动 Checkbox 逻辑封装,支持多选,单选,全选逻辑,还提供了是否选择,是否全选,是否半选的状态。
- 将原始数组转为Set集合,非常方便进行增删查,避免指定唯一ID。思路巧妙
- Set 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。
- Set对象是值的集合,你可以按照插入的顺序迭代它的元素。 Set中的元素只会出现一次,即 Set 中的元素是唯一的。
export default function useSelections<T>(items: T[], defaultSelected: T[] = []) {
const [selected, setSelected] = useState<T[]>(defaultSelected);
// 这里将原始数据转为Set数组,采用has方法方便判断唯一性。例如是对象数组,同样支持
const selectedSet = useMemo(() => new Set(selected), [selected]);
// 判断是否选中
const isSelected = (item: T) => selectedSet.has(item);
// 选择
const select = (item: T) => {
selectedSet.add(item);
return setSelected(Array.from(selectedSet));
};
// 取消选择
const unSelect = (item: T) => {
selectedSet.delete(item);
return setSelected(Array.from(selectedSet));
};
const toggle = (item: T) => {
if (isSelected(item)) {
unSelect(item);
} else {
select(item);
}
};
const selectAll = () => {
items.forEach((o) => {
selectedSet.add(o);
});
setSelected(Array.from(selectedSet));
};
const unSelectAll = () => {
items.forEach((o) => {
selectedSet.delete(o);
});
setSelected(Array.from(selectedSet));
}
// 省略n行代码
return {
selected,
noneSelected,
allSelected,
partiallySelected,
setSelected,
isSelected,
select: useMemoizedFn(select),
unSelect: useMemoizedFn(unSelect),
toggle: useMemoizedFn(toggle),
selectAll: useMemoizedFn(selectAll),
unSelectAll: useMemoizedFn(unSelectAll),
toggleAll: useMemoizedFn(toggleAll),
} as const;
}
范例:
const result: Result= useSelections<T>(items: T[], defaultSelected?: T[]);
useSelections([1, 2, 3, 4, 5, 6, 7, 8]);
useSelections([{ value: 1, label: "苹果" }, { value: 2, label: "梨" }]);
useCountDown
用于管理倒计时的 Hook。
- 动态变更配置项, 适用于验证码或类似场景,时间结束后会触发 onEnd 回调。
- 借助于setInterval实现,由于间隔不一定精准,以及程序执行需要时间,会存在一定误差。
- 停止计时并没有提供类似stop函数,而是采用setTargetDate(undefined)更新targetDate
const calcLeft = (t?: TDate) => {
if (!t) {
return 0;
}
// https://stackoverflow.com/questions/4310953/invalid-date-in-safari
const left = dayjs(t).valueOf() - new Date().getTime();
if (left < 0) {
return 0;
}
return left;
};
const parseMs = (milliseconds: number): FormattedRes => {
return {
days: Math.floor(milliseconds / 86400000),
hours: Math.floor(milliseconds / 3600000) % 24,
minutes: Math.floor(milliseconds / 60000) % 60,
seconds: Math.floor(milliseconds / 1000) % 60,
milliseconds: Math.floor(milliseconds) % 1000,
};
};
const useCountdown = (options?: Options) => {
// targetDate 截止时间
const { targetDate, interval = 1000, onEnd } = options || {};
// 截止时间和当前时间 的差值
const [timeLeft, setTimeLeft] = useState(() => calcLeft(targetDate));
const onEndRef = useLatest(onEnd);
useEffect(() => {
if (!targetDate) { //
// for stop
setTimeLeft(0);
return;
}
// 立即执行一次
setTimeLeft(calcLeft(targetDate));
const timer = setInterval(() => {
const targetLeft = calcLeft(targetDate);
setTimeLeft(targetLeft);
if (targetLeft === 0) {
clearInterval(timer);
onEndRef.current?.();
}
}, interval);
return () => clearInterval(timer);
}, [targetDate, interval]);
const formattedRes = useMemo(() => {
return parseMs(timeLeft);
}, [timeLeft]);
return [timeLeft, formattedRes] as const;
};
useCounter
管理计数器的 Hook。
- 对于临界值的处理。如果指定target不在[min,max]范围内,怎么处理。
function getTargetValue(val: number, options: Options = {}) {
const { min, max } = options;
let target = val;
if (isNumber(max)) {
target = Math.min(max, target);
}
if (isNumber(min)) {
target = Math.max(min, target);
}
return target;
}
function useCounter(initialValue: number = 0, options: Options = {}) {
const { min, max } = options;
const [current, setCurrent] = useState(() => {
return getTargetValue(initialValue, {
min,
max,
});
});
const setValue = (value: ValueParam) => {
setCurrent((c) => {
const target = isNumber(value) ? value : value(c);
return getTargetValue(target, {
max,
min,
});
});
};
const inc = (delta: number = 1) => {
setValue((c) => c + delta);
};
const dec = (delta: number = 1) => {
setValue((c) => c - delta);
};
const set = (value: ValueParam) => {
setValue(value);
};
const reset = () => {
setValue(initialValue);
};
return [
current,
{
inc: useMemoizedFn(inc),
dec: useMemoizedFn(dec),
set: useMemoizedFn(set),
reset: useMemoizedFn(reset),
},
] as const;
}
useTextSelection
实时获取用户当前选取的文本内容及位置。
- 监听目前元素的down和up事件。鼠标按下,清空原始数据;鼠标抬起重新获取新的选中的值
- Window.getSelection 返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置。如果想要将 selection 转换为字符串,可通过连接一个空字符串("")或使用 String.toString() 方法
- Selection.getRangeAt()返回一个包含当前选区内容的区域对象 Range。
- Range.getBoundingClientRect()返回一个DOMRect对象,{ height, width, top, left, right, bottom }
- 高阶函数createEffectWithTarget,包装了useEffect,返回一个新函数useEffectWithTarget,扩展了对target的依赖对比。
export default function depsAreSame(oldDeps: DependencyList, deps: DependencyList): boolean {
if (oldDeps === deps) return true;
for (let i = 0; i < oldDeps.length; i++) {
if (!Object.is(oldDeps[i], deps[i])) return false;
}
return true;
}
const createEffectWithTarget = (useEffectType: typeof useEffect | typeof useLayoutEffect) => {
/**
*
* @param effect
* @param deps
* @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom
*/
const useEffectWithTarget = (
effect: EffectCallback,
deps: DependencyList,
target: BasicTarget<any> | BasicTarget<any>[],
) => {
const hasInitRef = useRef(false);
const lastElementRef = useRef<(Element | null)[]>([]);
const lastDepsRef = useRef<DependencyList>([]);
const unLoadRef = useRef<any>();
useEffectType(() => {
const targets = isArray(target) ? target : [target];
const els = targets.map((item) => getTargetElement(item));
// init run
if (!hasInitRef.current) {
hasInitRef.current = true;
lastElementRef.current = els;
lastDepsRef.current = deps;
unLoadRef.current = effect();
return;
}
if (
els.length !== lastElementRef.current.length ||
!depsAreSame(els, lastElementRef.current) ||
!depsAreSame(deps, lastDepsRef.current)
) {
unLoadRef.current?.();
lastElementRef.current = els;
lastDepsRef.current = deps;
unLoadRef.current = effect();
}
});
useUnmount(() => {
unLoadRef.current?.();
// for react-refresh
hasInitRef.current = false;
});
};
return useEffectWithTarget;
};
useWebSocket
用于处理 WebSocket 的 Hook
- 使用useLatest,返回当前最新值的 Hook,避免闭包问题。
export default function useWebSocket(socketUrl: string, options: Options = {}): Result {
const {
reconnectLimit = 3,
reconnectInterval = 3 * 1000,
manual = false,
onOpen,
onClose,
onMessage,
onError,
protocols,
} = options;
// 返回当前最新值的 Hook,可以避免闭包问题。
const onOpenRef = useLatest(onOpen);
const onCloseRef = useLatest(onClose);
const onMessageRef = useLatest(onMessage);
const onErrorRef = useLatest(onError);
const reconnectTimesRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
const websocketRef = useRef<WebSocket>();
const unmountedRef = useRef(false);
const [latestMessage, setLatestMessage] = useState<WebSocketEventMap['message']>();
const [readyState, setReadyState] = useState<ReadyState>(ReadyState.Closed);
// error, close 情况下进行重新连接
const reconnect = () => {
if (
reconnectTimesRef.current < reconnectLimit &&
websocketRef.current?.readyState !== ReadyState.Open
) {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
reconnectTimerRef.current = setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
connectWs();
console.log(2);
reconnectTimesRef.current++;
}, reconnectInterval);
}
};
const connectWs = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
if (websocketRef.current) {
websocketRef.current.close();
}
const ws = new WebSocket(socketUrl, protocols);
setReadyState(ReadyState.Connecting);
ws.onerror = (event) => {
if (unmountedRef.current) {
return;
}
console.log(event);
reconnect();
onErrorRef.current?.(event, ws);
setReadyState(ws.readyState || ReadyState.Closed);
};
ws.onopen = (event) => {
if (unmountedRef.current) {
return;
}
onOpenRef.current?.(event, ws);
reconnectTimesRef.current = 0;
setReadyState(ws.readyState || ReadyState.Open);
};
ws.onmessage = (message: WebSocketEventMap['message']) => {
if (unmountedRef.current) {
return;
}
onMessageRef.current?.(message, ws);
setLatestMessage(message);
console.log(message);
};
ws.onclose = (event) => {
console.log(event);
if (unmountedRef.current) {
return;
}
reconnect();
onCloseRef.current?.(event, ws);
setReadyState(ws.readyState || ReadyState.Closed);
};
websocketRef.current = ws;
};
const sendMessage: WebSocket['send'] = (message) => {
if (readyState === ReadyState.Open) {
websocketRef.current?.send(message);
} else {
throw new Error('WebSocket disconnected');
}
};
const connect = () => {
reconnectTimesRef.current = 0;
connectWs();
};
const disconnect = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
reconnectTimesRef.current = reconnectLimit;
websocketRef.current?.close();
};
useEffect(() => {
if (!manual) {
connect();
}
}, [socketUrl, manual]);
useUnmount(() => {
unmountedRef.current = true;
disconnect();
});
return {
latestMessage,
sendMessage: useMemoizedFn(sendMessage),
connect: useMemoizedFn(connect),
disconnect: useMemoizedFn(disconnect),
readyState,
webSocketIns: websocketRef.current,
};
}
参考资料
- ahook源码:github.com/alibaba/hoo…
- ahook官方文档:ahooks.js.org/zh-CN/hooks…
转载自:https://juejin.cn/post/7136468963961077797