import React, {
useEffect,
useImperativeHandle,
useRef,
useMemo,
useState,
ReactNode,
CSSProperties,
UIEvent,
useReducer,
ReactElement,
cloneElement,
isValidElement,
ComponentState,
PropsWithoutRef,
} from 'react';
import ResizeObserver from 'resize-observer-polyfill';
import lodashThrottle from 'lodash/throttle';
import {
Key,
getValidScrollTop,
getCompareItemRelativeTop,
getItemAbsoluteTop,
getItemRelativeTop,
getNodeHeight,
getRangeIndex,
getScrollPercentage,
GHOST_ITEM_KEY,
getLongestItemIndex,
getLocationItem,
} from './utils/itemUtil';
import { raf, caf } from '../../_util/raf';
import { isNumber, supportRef } from '../../_util/is';
import { callbackOriginRef, findDOMNode } from '../../_util/react-dom';
// import usePrevious from '../../_util/hooks/usePrevious';
import { findListDiffIndex, getIndexByStartLoc } from './utils/algorithmUtil';
import Filler from './Filler';
// import useStateWithPromise from '../../_util/hooks/useStateWithPromise';
// import useIsFirstRender from '../../_util/hooks/useIsFirstRender';
// import useForceUpdate from '../../_util/hooks/useForceUpdate';
// import ResizeObserver from '../../_util/resizeObserver';
import useIsomorphicLayoutEffect from '../../_util/hooks/useIsomorphicLayoutEffect';
function usePrevious<T>(value: PropsWithoutRef<T> | ComponentState) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function useIsFirstRender() {
const isFirst = useRef<boolean>(true);
useEffect(() => {
isFirst.current = false;
}, []);
return isFirst.current;
}
function useForceUpdate() {
const [, dispatch] = useReducer((v) => v + 1, 0);
return dispatch;
}
function useStateWithPromise<T>(defaultVal: T): [T, (updater: any) => Promise<T>] {
const [state, setState] = useState({
value: defaultVal,
resolve: (e) => {
// eslint-disable-next-line no-unused-expressions
e;
},
});
useEffect(() => {
state.resolve(state.value);
}, [state]);
return [
state.value,
(updater) => {
return new Promise((resolve) => {
setState((prevState) => {
let nextVal = updater;
if (typeof updater === 'function') {
nextVal = updater(prevState.value);
}
return {
value: nextVal,
resolve,
};
});
});
},
];
}
export type RenderFunc<T> = (
item: T,
index: number,
props: { style: React.CSSProperties; itemIndex: number }
) => ReactNode;
type Status = 'NONE' | 'MEASURE_START' | 'MEASURE_DONE';
export interface VirtualListProps<T>
extends Omit<React.HTMLAttributes<any>, 'children' | 'onScroll'> {
children: RenderFunc<T>;
data: T[];
/* Viewable area height (`2.11.0` starts support `string` type such as `80%`) */
height?: number | string;
/* The element height used to calculate how many elements are actually rendered */
itemHeight?: number;
/* HTML tags for wrapping */
wrapper?: string | React.FC<any> | React.ComponentClass<any>;
/* Threshold of the number of elements that auto enable virtual scrolling, use `null` to disable virtual scrolling */
threshold?: number | null;
/* Whether it's static elements of the same height */
isStaticItemHeight?: boolean;
/* Key of the specified element, or function to get the key */
itemKey?: Key | ((item: T, index: number) => Key);
/* Whether need to measure longest child element */
measureLongestItem?: boolean;
/* Configure the default behavior related to scrolling */
scrollOptions?: ScrollIntoViewOptions;
needFiller?: boolean;
/** Custom filler outer style */
outerStyle?: CSSProperties;
innerStyle?: CSSProperties;
onScroll?: (event: UIEvent<HTMLElement>, info: { index: number }) => void;
wrapperChild?: string | React.FC<any> | React.ComponentClass<any>;
}
export type AvailableVirtualListProps = Pick<
VirtualListProps<any>,
| 'height'
| 'itemHeight'
| 'threshold'
| 'isStaticItemHeight'
| 'scrollOptions'
| 'onScroll'
| 'wrapperChild'
>;
interface RelativeScroll {
itemIndex: number;
relativeTop: number;
}
interface VirtualListState {
status: Status;
startIndex: number;
endIndex: number;
itemIndex: number;
itemOffsetPtg: number;
startItemTop: number;
scrollTop: number;
}
export type VirtualListHandle = {
dom: HTMLElement;
scrollTo: (
arg:
| number
| {
index: number;
options?: ScrollIntoViewOptions;
}
| {
key: Key;
options?: ScrollIntoViewOptions;
}
) => void;
};
// map for height of each element
type ItemHeightMap = { [p: string]: number };
// height of the virtual element, used to calculate total height of the virtual list
const DEFAULT_VIRTUAL_ITEM_HEIGHT = 32;
const KEY_VIRTUAL_ITEM_HEIGHT = `__virtual_item_height_${Math.random().toFixed(5).slice(2)}`; // 所有itme高度的平均值
// after collecting the real height of the first screen element, calculate the virtual ItemHeight to trigger list re-rendering
// 收集第一个屏幕元素的实际高度后,计算虚拟ItemHeight触发列表重新渲染
const useComputeVirtualItemHeight = (refItemHeightMap: React.MutableRefObject<ItemHeightMap>) => {
const forceUpdate = useForceUpdate(); // 触发state执行更新的标记
const { current: heightMap } = refItemHeightMap;
useEffect(() => {
// virtual item height should be static as possible, otherwise it is easy to cause jitter
if (Object.keys(heightMap).length && !heightMap[KEY_VIRTUAL_ITEM_HEIGHT]) {
heightMap[KEY_VIRTUAL_ITEM_HEIGHT] = Object.entries(heightMap).reduce(
(sum, [, currentHeight], currentIndex, array) => {
const nextSum = sum + currentHeight;
return currentIndex === array.length - 1 ? Math.round(nextSum / array.length) : nextSum;
},
0
);
forceUpdate();
}
}, [Object.keys(heightMap).length]);
};
// cache the constructed results of child nodes to avoid redrawing of child nodes caused by re-construction during drawing
const useCacheChildrenNodes = (children: VirtualListProps<any>['children']) => {
const refCacheMap = useRef<{ [key: number]: ReactNode }>({});
const refPrevChildren = useRef(children); // children 生成子元素的函数
useEffect(() => {
refPrevChildren.current = children;
}, [children]);
// children change means state of parent component is updated, so clear cache
if (children !== refPrevChildren.current) {
refCacheMap.current = {};
}
return (item, index, props) => {
if (!refCacheMap.current.hasOwnProperty(index)) {
refCacheMap.current[index] = children(item, index, props);
}
return refCacheMap.current[index];
};
};
interface ResizeProps {
throttle?: boolean;
onResize?: (entry: ResizeObserverEntry[]) => void;
children?: React.ReactNode;
getTargetDOMNode?: () => any;
}
class ResizeObserverComponent extends React.Component<ResizeProps> {
resizeObserver: ResizeObserver;
rootDOMRef: any;
getRootElement = () => {
const { getTargetDOMNode } = this.props;
return findDOMNode(getTargetDOMNode?.() || this.rootDOMRef, this);
};
getRootDOMNode = () => {
return this.getRootElement();
};
componentDidMount() {
console.log('ResizeObserverComponent componentDidMount');
if (!React.isValidElement(this.props.children)) {
console.warn('The children of ResizeObserver is invalid.');
} else {
this.createResizeObserver();
}
}
componentDidUpdate() {
if (!this.resizeObserver && this.getRootElement()) {
this.createResizeObserver();
}
}
componentWillUnmount = () => {
if (this.resizeObserver) {
this.destroyResizeObserver();
}
};
createResizeObserver = () => {
const { throttle = true } = this.props;
const onResize = (entry) => {
this.props.onResize?.(entry);
};
const resizeHandler = throttle ? lodashThrottle(onResize) : onResize;
let firstExec = true; // 首次监听时,立即执行一次 onResize,之前行为保持一致,避免布局类组件出现闪动的情况
console.log('1===');
this.resizeObserver = new ResizeObserver((entry) => {
if (firstExec) {
firstExec = false;
console.log('2===');
onResize(entry);
}
resizeHandler(entry);
});
const targetNode = this.getRootElement();
targetNode && this.resizeObserver.observe(targetNode as Element);
};
destroyResizeObserver = () => {
this.resizeObserver && this.resizeObserver.disconnect();
this.resizeObserver = null;
};
render() {
const { children } = this.props;
if (supportRef(children) && isValidElement(children) && !this.props.getTargetDOMNode) {
return cloneElement(children as ReactElement, {
ref: (node) => {
this.rootDOMRef = node;
callbackOriginRef(children, node);
},
});
}
this.rootDOMRef = null;
return this.props.children;
}
}
const VirtualList: React.ForwardRefExoticComponent<
VirtualListProps<any> & React.RefAttributes<VirtualListHandle>
> = React.forwardRef((props: VirtualListProps<any>, ref) => {
const {
style,
className,
children,
data = [],
itemKey,
threshold = 100,
wrapper: WrapperTagName = 'div',
height: propHeight = '100vh',
isStaticItemHeight = true,
itemHeight: propItemHeight,
measureLongestItem,
scrollOptions,
onScroll,
needFiller = true,
outerStyle,
innerStyle,
wrapperChild: WrapperChildTagName = React.Fragment,
...restProps
} = props;
// Compatible with setting the height of the list through style.maxHeight
const styleListMaxHeight = (style && style.maxHeight) || propHeight; // table最大高度
const refItemHeightMap = useRef<ItemHeightMap>({}); // 每个table子项的高度Map
const [stateHeight, setStateHeight] = useState(200); // table的默认高度
const renderChild = useCacheChildrenNodes(children); // 渲染子项,但是会缓存table item的每个子项的渲染结果
useComputeVirtualItemHeight(refItemHeightMap); // 计算虚拟ItemHeight 也就是 refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT]
// Elements with the same height, the height of the item is based on the first rendering
// 具有相同高度的元素,项目的高度基于第一次渲染
const itemCount = data.length; // 数据总数
const itemHeight =
propItemHeight ||
refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT] ||
DEFAULT_VIRTUAL_ITEM_HEIGHT; // item的高度 refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT]是所有item的平均高度
const viewportHeight = isNumber(styleListMaxHeight) ? styleListMaxHeight : stateHeight; // table的高度
const itemCountVisible = Math.ceil(viewportHeight / itemHeight); // table的高度最多可以放几个item (一页面可以放几个item)
const itemTotalHeight = itemHeight * itemCount; // 内容的总高度
const isVirtual =
threshold !== null && itemCount >= threshold && itemTotalHeight > viewportHeight; // 是否需要虚拟滚动
const refList = useRef<HTMLElement>(null); // table子项外层包的div
const refRafId = useRef(null);
const refLockScroll = useRef(false);
const refIsVirtual = useRef(isVirtual);
// The paddingTop of the record scrolling list is used to correct the scrolling distance
// 记录滚动列表的paddingTop用于校正滚动距离
const scrollListPadding = useMemo<{ top: number; bottom: number }>(() => {
if (refList.current) {
const getPadding = (property) =>
+window.getComputedStyle(refList.current)[property].replace(/\D/g, '');
return {
top: getPadding('paddingTop'),
bottom: getPadding('paddingBottom'),
};
}
return { top: 0, bottom: 0 };
}, [refList.current]);
// status 数据更新状态,startIndex渲染数据的开始位置 endIndex渲染数据的结束位置
const [state, setState] = useStateWithPromise<VirtualListState>({
// measure status
status: 'NONE',
// render range info
startIndex: 0,
endIndex: 0,
itemIndex: 0,
itemOffsetPtg: 0,
// scroll info
startItemTop: 0,
scrollTop: 0,
});
const prevData = usePrevious(data) || []; // ref保存数据源
const isFirstRender = useIsFirstRender(); // 是否第一次渲染
const getItemKey = (item, index) => {
return typeof itemKey === 'function'
? itemKey(item, index)
: typeof itemKey === 'string'
? item[itemKey]
: item.key || index;
};
const getItemKeyByIndex = (index, items = data) => {
if (index === items.length) {
return GHOST_ITEM_KEY;
}
const item = items[index];
return item !== undefined ? getItemKey(item, index) : null;
};
const getCachedItemHeight = (key: Key): number => {
return refItemHeightMap.current[key] || itemHeight;
};
const internalScrollTo = (relativeScroll: RelativeScroll): void => {
const { itemIndex: compareItemIndex, relativeTop: compareItemRelativeTop } = relativeScroll;
const { scrollHeight, clientHeight } = refList.current;
const originScrollTop = state.scrollTop;
const maxScrollTop = scrollHeight - clientHeight;
let bestSimilarity = Number.MAX_VALUE;
let bestScrollTop: number = null;
let bestItemIndex: number = null;
let bestItemOffsetPtg: number = null;
let bestStartIndex: number = null;
let bestEndIndex: number = null;
let missSimilarity = 0;
for (let i = 0; i < maxScrollTop; i++) {
const scrollTop = getIndexByStartLoc(0, maxScrollTop, originScrollTop, i);
const scrollPtg = getScrollPercentage({ scrollTop, scrollHeight, clientHeight });
const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex(
scrollPtg,
itemCount,
itemCountVisible
);
if (startIndex <= compareItemIndex && compareItemIndex <= endIndex) {
const locatedItemRelativeTop = getItemRelativeTop({
itemHeight: getCachedItemHeight(getItemKeyByIndex(itemIndex)),
itemOffsetPtg,
clientHeight,
scrollPtg,
});
const compareItemTop = getCompareItemRelativeTop({
locatedItemRelativeTop,
locatedItemIndex: itemIndex,
compareItemIndex,
startIndex,
endIndex,
itemHeight,
getItemKey: getItemKeyByIndex,
itemElementHeights: refItemHeightMap.current,
});
const similarity = Math.abs(compareItemTop - compareItemRelativeTop);
if (similarity < bestSimilarity) {
bestSimilarity = similarity;
bestScrollTop = scrollTop;
bestItemIndex = itemIndex;
bestItemOffsetPtg = itemOffsetPtg;
bestStartIndex = startIndex;
bestEndIndex = endIndex;
missSimilarity = 0;
} else {
missSimilarity += 1;
}
}
if (missSimilarity > 10) {
break;
}
}
if (bestScrollTop !== null) {
refLockScroll.current = true;
refList.current.scrollTop = bestScrollTop;
setState({
...state,
status: 'MEASURE_START',
scrollTop: bestScrollTop,
itemIndex: bestItemIndex,
itemOffsetPtg: bestItemOffsetPtg,
startIndex: bestStartIndex,
endIndex: bestEndIndex,
});
}
refRafId.current = raf(() => {
refLockScroll.current = false;
});
};
// Record the current element position when the real list is scrolled, and ensure that the position is correct after switching to the virtual list
const rawListScrollHandler = (event) => {
const { scrollTop: rawScrollTop, clientHeight, scrollHeight } = refList.current;
const scrollTop = getValidScrollTop(rawScrollTop, scrollHeight - clientHeight);
const scrollPtg = getScrollPercentage({
scrollTop,
clientHeight,
scrollHeight,
});
const { index, offsetPtg } = getLocationItem(scrollPtg, itemCount);
setState({
...state,
scrollTop,
itemIndex: index,
itemOffsetPtg: offsetPtg,
});
event && onScroll?.(event, { index });
};
// 滚动计算
// Modify the state and recalculate the position in the next render
const virtualListScrollHandler = (event, isInit = false) => {
// Do NOT use refList.current.scrollHeight
// We should use Filler's height as total scroll height
// Filler's translate style may make refList.current.scrollHeight larger than Filler's height
const scrollHeight = itemTotalHeight; // 总高度
const { scrollTop: rawScrollTop, clientHeight } = refList.current; // rawScrollTop 滚动具体 clientHeight容器高度
const scrollTop = getValidScrollTop(rawScrollTop, scrollHeight - clientHeight); // 计算滚动距离,最大滚动距离不能超过scrollHeight - clientHeight(因为不能超出去)
// Prevent jitter
if (!isInit && (scrollTop === state.scrollTop || refLockScroll.current)) {
return;
}
// table已经滚动的距离占总共可以滚动距离的比例
const scrollPtg = getScrollPercentage({
scrollTop,
clientHeight,
scrollHeight,
});
// 计算需要渲染的元素的开始下标、结束下标和用于定位的元素下标 itemIndex==当前item下标 , itemOffsetPtg==偏移量, startIndex==渲染数据开始下标, endIndex==渲染数据结束下标
const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex(
scrollPtg,
itemCount,
itemCountVisible
);
setState({
...state,
scrollTop,
itemIndex,
itemOffsetPtg,
startIndex,
endIndex,
status: 'MEASURE_START',
});
event && onScroll?.(event, { index: itemIndex });
};
useEffect(() => {
return () => {
refRafId.current && caf(refRafId.current);
};
}, []);
// rerender when the number of visible elements changes
useEffect(() => {
if (refList.current) {
if (isFirstRender) {
// 第一次渲染scrollTop为0
refList.current.scrollTop = 0;
}
// 第一次渲染时候主动触发一次滚动计算
virtualListScrollHandler(null, true);
}
}, [itemCountVisible]);
// Handle additions and deletions of list items or switching the virtual state
useEffect(() => {
if (!refList.current) return;
let changedItemIndex: number = null;
const switchTo = refIsVirtual.current !== isVirtual ? (isVirtual ? 'virtual' : 'raw') : '';
refIsVirtual.current = isVirtual;
if (viewportHeight && prevData.length !== data.length) {
const diff = findListDiffIndex(prevData, data, getItemKey);
changedItemIndex = diff ? diff.index : null;
}
// No need to correct the position when the number of elements in the real list changes
if (switchTo || (isVirtual && changedItemIndex)) {
const { clientHeight } = refList.current;
const locatedItemRelativeTop = getItemRelativeTop({
itemHeight: getCachedItemHeight(getItemKeyByIndex(state.itemIndex, prevData)),
itemOffsetPtg: state.itemOffsetPtg,
scrollPtg: getScrollPercentage({
scrollTop: state.scrollTop,
scrollHeight: prevData.length * itemHeight,
clientHeight,
}),
clientHeight,
});
if (switchTo === 'raw') {
let rawTop = locatedItemRelativeTop;
for (let index = 0; index < state.itemIndex; index++) {
rawTop -= getCachedItemHeight(getItemKeyByIndex(index));
}
refList.current.scrollTop = -rawTop;
refLockScroll.current = true;
refRafId.current = raf(() => {
refLockScroll.current = false;
});
} else {
internalScrollTo({
itemIndex: state.itemIndex,
relativeTop: locatedItemRelativeTop,
});
}
}
}, [data, isVirtual]);
useIsomorphicLayoutEffect(() => {
// 这里加上debugger会造成不会触发ResizeObserver的onResize,因为它加上debugger之后默认个数就会被渲染出来。
setTimeout(() => {
if (state.status === 'MEASURE_START' && refList.current) {
const { scrollTop, scrollHeight, clientHeight } = refList.current;
const scrollPtg = getScrollPercentage({
scrollTop,
scrollHeight,
clientHeight,
});
// Calculate the top value of the first rendering element
// 计算第一个渲染元素的顶值
let startItemTop = getItemAbsoluteTop({
scrollPtg,
clientHeight,
scrollTop: scrollTop - (scrollListPadding.top + scrollListPadding.bottom) * scrollPtg,
itemHeight: getCachedItemHeight(getItemKeyByIndex(state.itemIndex)),
itemOffsetPtg: state.itemOffsetPtg,
});
for (let index = state.itemIndex - 1; index >= state.startIndex; index--) {
startItemTop -= getCachedItemHeight(getItemKeyByIndex(index));
}
setState({
...state,
startItemTop,
status: 'MEASURE_DONE',
});
}
}, 0);
}, [state]);
useImperativeHandle<any, VirtualListHandle>(
ref,
() => ({
dom: refList.current,
getRootDOMNode: () => refList.current,
// Scroll to a certain height or an element
scrollTo: (arg) => {
refRafId.current && caf(refRafId.current);
refRafId.current = raf(() => {
if (!refList.current) return;
if (typeof arg === 'number') {
refList.current.scrollTop = arg;
return;
}
const index =
'index' in arg
? arg.index
: 'key' in arg
? data.findIndex((item, index) => getItemKey(item, index) === arg.key)
: 0;
const item = data[index];
if (!item) {
return;
}
let align: ScrollIntoViewOptions['block'] =
typeof arg === 'object' && arg.options?.block
? arg.options.block
: scrollOptions?.block || 'nearest';
const { clientHeight, scrollTop } = refList.current;
if (isVirtual && !isStaticItemHeight) {
if (align === 'nearest') {
const { itemIndex, itemOffsetPtg } = state;
if (Math.abs(itemIndex - index) < itemCountVisible) {
let itemTop = getItemRelativeTop({
itemHeight: getCachedItemHeight(getItemKeyByIndex(itemIndex)),
itemOffsetPtg,
clientHeight,
scrollPtg: getScrollPercentage(refList.current),
});
if (index < itemIndex) {
for (let i = index; i < itemIndex; i++) {
itemTop -= getCachedItemHeight(getItemKeyByIndex(i));
}
} else {
for (let i = itemIndex; i < index; i++) {
itemTop += getCachedItemHeight(getItemKeyByIndex(i));
}
}
// When the target element is within the field of view, exit directly
if (itemTop < 0 || itemTop > clientHeight) {
align = itemTop < 0 ? 'start' : 'end';
} else {
return;
}
} else {
align = index < itemIndex ? 'start' : 'end';
}
}
setState({
...state,
startIndex: Math.max(0, index - itemCountVisible),
endIndex: Math.min(itemCount - 1, index + itemCountVisible),
}).then(() => {
const itemHeight = getCachedItemHeight(getItemKey(item, index));
internalScrollTo({
itemIndex: index,
relativeTop:
align === 'start'
? 0
: (clientHeight - itemHeight) / (align === 'center' ? 2 : 1),
});
});
} else {
const indexItemHeight = getCachedItemHeight(getItemKeyByIndex(index));
let itemTop = 0;
for (let i = 0; i < index; i++) {
itemTop += getCachedItemHeight(getItemKeyByIndex(i));
}
const itemBottom = itemTop + indexItemHeight;
const itemMiddle = itemTop + indexItemHeight / 2;
// If item is visible, skip scrolling
if (itemMiddle > scrollTop && itemMiddle < clientHeight + scrollTop) {
return;
}
if (align === 'nearest') {
if (itemTop < scrollTop) {
align = 'start';
} else if (itemBottom > scrollTop + clientHeight) {
align = 'end';
}
}
const viewportHeight = clientHeight - indexItemHeight;
refList.current.scrollTop =
itemTop - (align === 'start' ? 0 : viewportHeight / (align === 'center' ? 2 : 1));
}
});
},
}),
[data, itemHeight, state]
);
const renderChildren = (list, startIndex: number) => {
return list.map((item, index) => {
const originIndex = startIndex + index;
const node = renderChild(item, originIndex, {
style: {},
itemIndex: index,
}) as React.ReactElement;
const key = getItemKey(item, originIndex);
return React.cloneElement(node, {
key,
ref: (ele: HTMLElement) => {
const { current: heightMap } = refItemHeightMap;
// Minimize the measurement of element height as much as possible to avoid frequent triggering of browser reflow
// Method getNodeHeight get the clientHeight from the DOM referred by React ref. If result is wrong, check the ref of this element
if (
ele &&
state.status === 'MEASURE_START' &&
(!isStaticItemHeight || heightMap[key] === undefined)
) {
if (isStaticItemHeight) {
if (!heightMap[KEY_VIRTUAL_ITEM_HEIGHT]) {
heightMap[KEY_VIRTUAL_ITEM_HEIGHT] = getNodeHeight(ele, true);
}
heightMap[key] = heightMap[KEY_VIRTUAL_ITEM_HEIGHT];
} else {
heightMap[key] = getNodeHeight(ele, true);
}
}
},
});
});
};
// Render the widest element to provide the maximum width of the container initially
const refLongestItemIndex = useRef<number>(null);
// Don't add `renderChild` to the array dependency, it will change every time when rerender
useEffect(() => {
refLongestItemIndex.current = null;
}, [data]);
const renderLongestItem = () => {
if (measureLongestItem) {
const index =
refLongestItemIndex.current === null
? getLongestItemIndex(data)
: refLongestItemIndex.current;
const item = data[index];
refLongestItemIndex.current = index;
return item ? (
<div style={{ height: 1, overflow: 'hidden', opacity: 0 }}>
{renderChild(item, index, { style: {} })}
</div>
) : null;
}
return null;
};
// 打断点不会触发ResizeObserver的onResize,等他第一次渲染出来的时候如果还断点中那么就不会触发onResize,他会默认第一次触发,但是页面有断点时候,不知道为什么它就不触发了。
return (
<ResizeObserverComponent
onResize={() => {
if (refList.current && !isNumber(styleListMaxHeight)) {
const { clientHeight } = refList.current;
setStateHeight(clientHeight);
}
}}
getTargetDOMNode={() => refList.current}
>
<WrapperTagName
ref={refList}
style={{
overflowY: 'auto',
overflowAnchor: 'none',
...style,
maxHeight: styleListMaxHeight,
}}
className={className}
onScroll={isVirtual ? virtualListScrollHandler : rawListScrollHandler}
{...restProps}
>
{isVirtual ? (
<>
<Filler
height={itemTotalHeight}
outerStyle={outerStyle}
innerStyle={innerStyle}
offset={state.status === 'MEASURE_DONE' ? state.startItemTop : 0}
>
<WrapperChildTagName>
{renderChildren(data.slice(state.startIndex, state.endIndex + 1), state.startIndex)}
</WrapperChildTagName>
</Filler>
{renderLongestItem()}
</>
) : needFiller ? (
<Filler height={viewportHeight} outerStyle={outerStyle} innerStyle={innerStyle}>
<WrapperChildTagName>{renderChildren(data, 0)}</WrapperChildTagName>
</Filler>
) : (
<WrapperChildTagName>{renderChildren(data, 0)}</WrapperChildTagName>
)}
</WrapperTagName>
</ResizeObserverComponent>
);
});
VirtualList.displayName = 'VirtualList';
export default VirtualList;
const styleListMaxHeight = (style && style.maxHeight) || propHeight; // table最大高度
const refItemHeightMap = useRef<ItemHeightMap>({}); // 每个table子项的高度Map
const [stateHeight, setStateHeight] = useState(200); // table的默认高度
const renderChild = useCacheChildrenNodes(children); // 渲染子项,但是会缓存table item的每个子项的渲染结果
useComputeVirtualItemHeight(refItemHeightMap); // 计算虚拟ItemHeight 也就是 refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT]
// Elements with the same height, the height of the item is based on the first rendering
// 具有相同高度的元素,项目的高度基于第一次渲染
const itemCount = data.length; // 数据总数
const itemHeight =
propItemHeight ||
refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT] ||
DEFAULT_VIRTUAL_ITEM_HEIGHT; // item的高度 refItemHeightMap.current[KEY_VIRTUAL_ITEM_HEIGHT]是所有item的平均高度
const viewportHeight = isNumber(styleListMaxHeight) ? styleListMaxHeight : stateHeight; // table的高度
const itemCountVisible = Math.ceil(viewportHeight / itemHeight); // table的高度最多可以放几个item (一页面可以放几个item)
const itemTotalHeight = itemHeight * itemCount; // 内容的总高度
const isVirtual =
threshold !== null && itemCount >= threshold && itemTotalHeight > viewportHeight; // 是否需要虚拟滚动
const refList = useRef<HTMLElement>(null); // table子项外层包的div
// 记录滚动列表的paddingTop用于校正滚动距离, 滚动要把组件本身的top算进去
const scrollListPadding = useMemo<{ top: number; bottom: number }>(() => {
if (refList.current) {
const getPadding = (property) =>
+window.getComputedStyle(refList.current)[property].replace(/\D/g, '');
return {
top: getPadding('paddingTop'),
bottom: getPadding('paddingBottom'),
};
}
return { top: 0, bottom: 0 };
}, [refList.current]);
这里为什么是top,是因为Filler组件的内部实现决定的。
第一次渲染完成之后执行
useEffect(() => {
debugger;
if (refList.current) {
if (isFirstRender) {
// 第一次渲染scrollTop为0
refList.current.scrollTop = 0;
}
// 第一次渲染时候主动触发一次滚动计算
virtualListScrollHandler(null, true);
}
}, [itemCountVisible]);
const virtualListScrollHandler = (event, isInit = false) => {
const scrollHeight = itemTotalHeight; // 总高度
const { scrollTop: rawScrollTop, clientHeight } = refList.current;
// rawScrollTop 滚动具体 clientHeight容器高度
const scrollTop = getValidScrollTop(rawScrollTop, scrollHeight - clientHeight); // 计算滚动距离,最大滚动距离不能超过scrollHeight - clientHeight(因为不能超出去)
// Prevent jitter
if (!isInit && (scrollTop === state.scrollTop || refLockScroll.current)) {
return;
}
// table已经滚动的距离占总共可以滚动距离的比例
const scrollPtg = getScrollPercentage({
scrollTop,
clientHeight,
scrollHeight,
});
// 计算需要渲染的元素的开始下标、结束下标和用于定位的元素下标 itemIndex==当前item下标 , itemOffsetPtg==偏移量, startIndex==渲染数据开始下标, endIndex==渲染数据结束下标
const { itemIndex, itemOffsetPtg, startIndex, endIndex } = getRangeIndex(
scrollPtg,
itemCount,
itemCountVisible
);
setState({
...state,
scrollTop,
itemIndex,
itemOffsetPtg,
startIndex,
endIndex,
status: 'MEASURE_START',
});
event && onScroll?.(event, { index: itemIndex });
};
/**
* 计算需要渲染的元素的开始下标、结束下标和用于定位的元素下标
*/
export function getRangeIndex(scrollPtg: number, itemCount: number, visibleCount: number) {
const { index, offsetPtg } = getLocationItem(scrollPtg, itemCount);
const beforeCount = Math.ceil(scrollPtg * visibleCount);
const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);
return {
itemIndex: index,
itemOffsetPtg: offsetPtg,
startIndex: Math.max(0, index - beforeCount),
endIndex: Math.min(itemCount - 1, index + afterCount),
};
}
/**
* 根据滚动条当前的滚动百分比,计算出基准元素
* 在基准元素的上方和下方渲染可见区域的其他元素
*/
export function getLocationItem(scrollPtg: number, total: number): LocationItemResult {
const itemIndex = Math.floor(scrollPtg * total);
const itemTopPtg = itemIndex / total;
const offsetPtg = (scrollPtg - itemTopPtg) / (1 / total);
return {
index: itemIndex,
// scrollPtg >= itemTopPtg,计算结果为元素应当补充的滚动距离相对自身高度的偏移量
offsetPtg: Number.isNaN(offsetPtg) ? 0 : offsetPtg,
};
}
理解getLocationItem:
scrollPtg是滚动距离占总高度的百分比,所以scrollPtg * total就是当前滚动具体对应的item下标
const itemIndex = Math.floor(scrollPtg * total);
这里使用Math.floor,那么计算出来的itemIndex就要比实际的要小,因为实际滚动的距离会有小数点
当前滚动到的item下标占总个数的百分比
const itemTopPtg = itemIndex / total;
同理itemTopPtg计算出来的也是偏小
const offsetPtg = (scrollPtg - itemTopPtg) / (1 / total); 等价于
const offsetPtg = (scrollPtg - itemTopPtg) * total;
计算出偏差几个item
const beforeCount = Math.ceil(scrollPtg * visibleCount);
const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);
理解:
这段代码用于计算虚拟滚动列表中可见区域之前和之后的元素数量。虚拟滚动是一种优化技术,用于提高长列表的渲染性能,特别是在移动设备上。通过只渲染当前可视区域内的元素,而不是整个列表,可以显著减少 DOM 操作的数量,从而提高性能。
代码解释
在这段代码中:
scrollPtg
表示滚动比例,即当前滚动位置占整个可滚动区域的比例。visibleCount
表示当前可视区域内可以显示的元素数量。
作用
-
计算
beforeCount
:beforeCount
计算的是当前可视区域之前(即未进入可视区域的部分)应渲染的元素数量。Math.ceil(scrollPtg * visibleCount)
计算了滚动比例与可视元素数量的乘积,并向上取整,确保至少渲染一个完整的元素。
-
计算
afterCount
:afterCount
计算的是当前可视区域之后(即超出可视区域的部分)应渲染的元素数量。Math.ceil((1 - scrollPtg) * visibleCount)
计算了剩余比例与可视元素数量的乘积,并向上取整,同样确保至少渲染一个完整的元素。
目的
-
提高性能:
- 通过仅渲染当前可视区域附近的元素,可以减少 DOM 操作的数量,从而提高性能。
- 特别是在长列表中,这种方法可以显著减少内存消耗和渲染时间。
-
平滑滚动体验:
- 通过预加载可视区域前后的一些元素,可以确保用户在滚动时看到平滑的过渡效果。
- 这样做可以避免用户在快速滚动时出现空白区域或闪烁现象。
示例代码
下面是一个简单的示例,展示了如何使用 beforeCount
和 afterCount
来实现虚拟滚动列表:
import React, { useState, useEffect, useRef } from 'react';
function VirtualList({ items }) {
const containerRef = useRef(null);
const [scrollPtg, setScrollPtg] = useState(0);
const [visibleCount, setVisibleCount] = useState(10); // 假设每个元素的高度相同
useEffect(() => {
const container = containerRef.current;
const handleScroll = () => {
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
setScrollPtg(scrollTop / (scrollHeight - clientHeight));
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
const container = containerRef.current;
setVisibleCount(container.clientHeight / 50); // 假设每个元素的高度为 50px
}, []);
const beforeCount = Math.ceil(scrollPtg * visibleCount);
const afterCount = Math.ceil((1 - scrollPtg) * visibleCount);
const startIndex = Math.max(0, beforeCount - visibleCount);
const endIndex = Math.min(items.length, startIndex + visibleCount + afterCount);
return (
<div ref={containerRef} style={{ height: `${items.length * 50}px`, overflow: 'auto' }}>
{items.slice(startIndex, endIndex).map((item, index) => (
<div key={index} style={{ height: '50px' }}>
{item}
</div>
))}
</div>
);
}
export default function App() {
const items = Array.from({ length: 1000 }, (_, i) => `Item ${i}`);
return (
<div>
<VirtualList items={items} />
</div>
);
}
总结
这段代码中的 beforeCount
和 afterCount
用于计算虚拟滚动列表中可视区域前后应渲染的元素数量。通过这种方式,可以确保只渲染当前可视区域附近的元素,从而提高性能并提供流畅的滚动体验。
因为刚开始滚动时候上面需要预渲染的元素为0,所以就需要用scrollPtg * visibleCount去控制,不能设置死。
startIndex: Math.max(0, index - beforeCount),这样计算就包含了上面预渲染元素
endIndex: Math.min(itemCount - 1, index + afterCount), 包含了下面需要预渲染元素,但是不能超过总数。
计算完成之后设置
setState({
...state,
scrollTop,
itemIndex,
itemOffsetPtg,
startIndex,
endIndex,
status: 'MEASURE_START',
});
附加:
在 useIsomorphicLayoutEffect
中添加 debugger
语句时,确实会影响到 ResizeObserver 的触发。这是因为 debugger
语句会导致浏览器暂停执行,这可能会影响到某些事件的触发,包括 ResizeObserver 的回调。
原因分析
-
执行暂停:
- 当
debugger
语句被执行时,浏览器会暂停执行当前脚本,直到开发者手动继续执行。 - 这意味着在暂停期间,浏览器不会继续执行后续的 JavaScript 代码,包括 ResizeObserver 的回调。
- 当
-
事件队列:
- ResizeObserver 的回调是在微任务队列中执行的。
- 当浏览器暂停执行时,微任务队列中的回调也不会被执行,因此 ResizeObserver 的回调不会被触发。
解决方案
-
移除
debugger
语句:- 最简单的方法是直接移除
debugger
语句,这样就不会影响到 ResizeObserver 的正常工作。
- 最简单的方法是直接移除
-
使用
console.log
替代:- 可以使用
console.log
替代debugger
来输出调试信息,这样不会导致执行暂停。
- 可以使用
-
使用 DevTools 的条件断点:
- 如果需要在特定条件下调试,可以使用 Chrome DevTools 的条件断点功能。
- 在 DevTools 中设置条件断点,只在满足特定条件时暂停执行。
-
使用
setTimeout
模拟异步:- 如果需要在
debugger
语句之后继续执行 ResizeObserver 的回调,可以使用setTimeout
来模拟异步行为。 -
useIsomorphicLayoutEffect(() => { // 这里加上debugger会造成不会触发ResizeObserver的onResize,因为它加上debugger之后默认个数就会被渲染出来。 setTimeout(() => { debugger; if (state.status === 'MEASURE_START' && refList.current) { const { scrollTop, scrollHeight, clientHeight } = refList.current; const scrollPtg = getScrollPercentage({ scrollTop, scrollHeight, clientHeight, }); // Calculate the top value of the first rendering element // 计算第一个渲染元素的顶值 let startItemTop = getItemAbsoluteTop({ scrollPtg, clientHeight, scrollTop: scrollTop - (scrollListPadding.top + scrollListPadding.bottom) * scrollPtg, itemHeight: getCachedItemHeight(getItemKeyByIndex(state.itemIndex)), itemOffsetPtg: state.itemOffsetPtg, }); for (let index = state.itemIndex - 1; index >= state.startIndex; index--) { startItemTop -= getCachedItemHeight(getItemKeyByIndex(index)); } setState({ ...state, startItemTop, status: 'MEASURE_DONE', }); } }, 0); }, [state]);
- 这样可以确保
debugger
语句之后的代码不会阻塞 ResizeObserver 的回调。
- 如果需要在
实现思路
1 获取item的高度
2
根据容器高度viewportHeight计算铺满容器可以放下几个itemCountVisible,同时可以计算总高度 const itemTotalHeight = itemHeight * itemCount;
3
根据容器滚动的距离scrollTop来计算当前滚动到了第几个item,计算完成之后对总数据进行截取{renderChildren(data.slice(state.startIndex, state.endIndex + 1), state.startIndex)} 渲染item。