这个系列是将 ahooks 里面的所有 hook 源码都进行解读,通过解读 ahooks 的源码来熟悉自定义 hook 的写法,提高自己写自定义 hook 的能力,希望能够对大家有所帮助。
为了和代码原始注释区分,个人理解部分使用 ///
开头,此处和 三斜线指令没有关系,只是为了做区分。
往期回顾
useSelections
常见联动 checkbox 逻辑封装,支持多选,单选,全选逻辑,还提供了是否选择,是否全选,是否半选的状态。
/* eslint-disable no-shadow */
import { useState, useMemo } from 'react';
export default function useSelections<T>(items: T[], defaultSelected: T[] = []) {
const [selected, setSelected] = useState<T[]>(defaultSelected);
const selectedSet = useMemo(() => new Set<T>(selected), [selected]); /// 使用 set 作为选中项的容器,对象、简单值都可以放入
const singleActions = useMemo(() => {
const isSelected = (item: T) => selectedSet.has(item);
const select = (item: T) => {
selectedSet.add(item);
return setSelected(Array.from(selectedSet)); /// setSelected 返回的是 undefined ,这个 return 比较迷惑
};
const unSelect = (item: T) => {
selectedSet.delete(item);
return setSelected(Array.from(selectedSet));
};
const toggle = (item: T) => {
if (isSelected(item)) {
unSelect(item);
} else {
select(item);
}
};
return { isSelected, select, unSelect, toggle };
}, [selectedSet]);
const allActions = useMemo(() => {
const selectAll = () => {
items.forEach((o) => {
selectedSet.add(o);
});
setSelected(Array.from(selectedSet));
};
const unSelectAll = () => {
items.forEach((o) => {
selectedSet.delete(o);
});
setSelected(Array.from(selectedSet));
};
const noneSelected = items.every((o) => !selectedSet.has(o)); /// 空数组永远返回真
const allSelected = items.every((o) => selectedSet.has(o)) && !noneSelected; /// && !noneSelected 是为了排除空数组
const partiallySelected = !noneSelected && !allSelected;
const toggleAll = () => (allSelected ? unSelectAll() : selectAll());
return { selectAll, unSelectAll, noneSelected, allSelected, partiallySelected, toggleAll };
}, [selectedSet, items]);
return {
selected,
setSelected,
...singleActions,
...allActions,
} as const;
}
useVirtualList
虚拟滚动列表的抽象实现
import { useEffect, useState, useMemo, useRef, MutableRefObject } from 'react';
import useSize from '../useSize';
export interface OptionType {
itemHeight: number | ((index: number) => number);
overscan?: number;
}
export default <T = any>(list: T[], options: OptionType) => {
const containerRef = useRef<HTMLElement | null>();
const size = useSize(containerRef as MutableRefObject<HTMLElement>);
// 暂时禁止 cache
// const distanceCache = useRef<{ [key: number]: number }>({});
const [state, setState] = useState({ start: 0, end: 10 });
const { itemHeight, overscan = 5 } = options;
if (!itemHeight) {
console.warn('please enter a valid itemHeight');
}
/// 计算当前容器内可视的列表项数量
/// 如果列高度是固定值则直接相除向上取整,如果是函数则挨个计算出高度并计数
const getViewCapacity = (containerHeight: number) => {
if (typeof itemHeight === 'number') {
return Math.ceil(containerHeight / itemHeight);
}
const { start = 0 } = state;
let sum = 0;
let capacity = 0;
for (let i = start; i < list.length; i++) {
const height = (itemHeight as (index: number) => number)(i);
sum += height;
if (sum >= containerHeight) {
capacity = i;
break;
}
}
return capacity - start;
};
/// 计算指定滚动高度的项在列表中的偏移量(索引+1)
const getOffset = (scrollTop: number) => {
if (typeof itemHeight === 'number') {
return Math.floor(scrollTop / itemHeight) + 1;
}
let sum = 0;
let offset = 0;
for (let i = 0; i < list.length; i++) {
const height = (itemHeight as (index: number) => number)(i);
sum += height;
if (sum >= scrollTop) {
offset = i;
break;
}
}
return offset + 1;
};
/// 计算当前列表应该渲染的项的索引范围
const calculateRange = () => {
const element = containerRef.current;
if (element) {
const offset = getOffset(element.scrollTop);
const viewCapacity = getViewCapacity(element.clientHeight);
const from = offset - overscan;
const to = offset + viewCapacity + overscan;
setState({
start: from < 0 ? 0 : from,
end: to > list.length ? list.length : to,
});
}
};
useEffect(() => {
calculateRange();
}, [size.width, size.height]);
/// 列表所有项的高度和
const totalHeight = useMemo(() => {
if (typeof itemHeight === 'number') {
return list.length * itemHeight;
}
return list.reduce((sum, _, index) => sum + itemHeight(index), 0);
}, [list.length]);
/// 计算指定索引项应该距离列表顶部的距离
const getDistanceTop = (index: number) => {
// 如果有缓存,优先返回缓存值
// if (enableCache && distanceCache.current[index]) {
// return distanceCache.current[index];
// }
if (typeof itemHeight === 'number') {
const height = index * itemHeight;
// if (enableCache) {
// distanceCache.current[index] = height;
// }
return height;
}
const height = list.slice(0, index).reduce((sum, _, i) => sum + itemHeight(i), 0);
// if (enableCache) {
// distanceCache.current[index] = height;
// }
return height;
};
/// 将指定索引项滚动到容器最上方
const scrollTo = (index: number) => {
if (containerRef.current) {
containerRef.current.scrollTop = getDistanceTop(index);
calculateRange();
}
};
const offsetTop = useMemo(() => getDistanceTop(state.start), [state.start]);
return {
list: list.slice(state.start, state.end).map((ele, index) => ({
data: ele,
index: index + state.start,
})), /// 根据当前计算出来的渲染索引范围返回需要实际渲染的项的列表,结果是实时计算出来的,所以每次重新渲染都会重新计算并触发列表项的重新渲染
scrollTo,
containerProps: {
ref: (ele: any) => {
containerRef.current = ele;
},
onScroll: (e: any) => {
e.preventDefault();
calculateRange(); /// 在容器内触发滚动事件时重新计算需要渲染的索引范围
},
style: { overflowY: 'auto' as const }, /// 1 设置了滚动 2 产生 bfc 避免了 margin 合并
},
wrapperProps: {
style: {
width: '100%',
height: totalHeight - offsetTop,
/// 使用 margin 和 height 来实现虚拟滚动,所以布局的时候要注意 margin 合并
/// 也就是说实际渲染内容上部是 margin,下部是空白
/// 如果使用 padding 来实现,在怪异盒模型下,height会包含padding,导致出问题
marginTop: offsetTop,
},
},
};
};
参考资料
以上内容由于本人水平问题难免有误,欢迎大家进行讨论反馈。