模拟
一、认识
虚拟列表 分为可视区域和非可视区域。虚拟列表 只渲染可视区域列表项, 当滚动发生时, 通过计算获得可视区域内的列表项。架构如下所示:
1.1 相关变量
-
height
: 可视区域高度 -
itemHeight
: 列表项高度 -
offsetTop
: 当前滚动位置 -
scrollHeight
: 列表项总高度 -
startIndex
: 可视区域起始索引 -
endIndex
: 可视区域结束索引 -
startOffset
: 可视区域起始项startIndex
在整个列表中的偏移位置
1.2 起始索引计算
let startIndex;
let itemTop = 0;
const length = list.length;
for(let i=0; i<length; i++){
const item = list[i];
const key = getKey(item);
const cacheHeight = heights.get(key); // 获取 DOM 被渲染后的真实高度
const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
if (currentItemBottom >= offsetTop && startIndex === undefined) {
startIndex = i;
}
itemTop = currentItemBottom;
}
if (startIndex === undefined) {
startIndex = 0;
}
1.3 结束索引计算
let endIndex;
let itemTop = 0;
const length = list.length;
for(let i=0; i<length; i++){
const item = list[i];
const key = getKey(item);
const cacheHeight = heights.get(key); // 获取 DOM 被渲染后的真实高度
const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
if (currentItemBottom > offsetTop + height && endIndex === undefined) {
endIndex = i;
}
itemTop = currentItemBottom;
}
if (endIndex === undefined) {
endIndex = mergedData.length - 1;
}
endIndex = Math.min(endIndex + 1, mergedData.length - 1);
结束索引 endIndex
计算时不可以计算的刚刚好, 需要额外的加一条, 防止滚动到半个条目的情况。
1.4 起始项偏移值计算
当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量 startOffset
,通过样式控制将渲染区域偏移至可视区域中。
let startOffset;
let itemTop = 0;
const length = list.length;
for(let i=0; i<length; i++){
const item = list[i];
const key = getKey(item);
const cacheHeight = heights.get(key); // 获取 DOM 被渲染后的真实高度
const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
if (currentItemBottom >= offsetTop && startIndex === undefined) {
startOffset = itemTop;
}
itemTop = currentItemBottom;
}
if (startIndex === undefined) {
startOffset = 0;
}
1.5 列表项总高度计算
虚拟滚动一般都会需要配置一下 itemHeight
作为基本高度,然后乘以列表项数量获得一个临时高度作为整个容器的高度。一旦元素被真实渲染后,则重新计算整体高度。
let itemTop = 0;
const length = list.length;
for(let i=0; i<length; i++){
const item = list[i];
const key = getKey(item);
const cacheHeight = heights.get(key); // 获取 DOM 被渲染后的真实高度
const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
itemTop = currentItemBottom;
}
1.6 可视区域数据计算
return list.slice(startIndex, endIndex + 1).map((item, index) => {
const key = getKey(item);
const eleIndex = startIndex + index;
const node = renderFunc(item, eleIndex, {
style: {}
});
return (
<Item key={key} setRef={ele => setNodeRef(item, ele)}>
{node}
</Item>
);
});
结束索引 endIndex
计算时不可以计算的刚刚好, 需要额外的加一条, 防止滚动到半个条目的情况。
1.6 缓存元素真实高度
rc-virtual-list
通过 ResizeObserver
监听 virtual-list-holder
元素, 每项元素完成渲染后, 真实高度发生变化, 导致 virtual-list-holder
尺寸发生变化, 触发 collectHeight
函数执行, 重新收集、缓存每项元素的真实高度, 并重新计算 startOffset
、scrollHeight
等值。
1.7 指定滚动距离或节点
rc-virtual-list
提供滚动距离或者滚动到某索引的功能, 主要由 useScrollTo
自定义 Hooks
提供。 该 Hooks
通过 syncState
记录缓存元素真实高度、更新 offsetTop
的信息。如果发现还需要收集真实高度, 改变 syncState
状态, 重新收集高度、重新计算结果。直到执行次数 >= 10
。
之所以要多次收集元素高度、重新计算, 目的是要显示在可视区域中的元素节点之前必须加载过, 并拿到缓存高度, 再进行确认。这样重新计算始末节点、可滚动高度、偏移值的之后最准确。多次执行的函数放到了 raf
中, raf
基于 window.requestAnimationFrame
实现的(如果没有window
对象就直接延时16ms
)。也就是说这个放在这个函数里的回调会在浏览器重新绘制后被触发。等节点真实加载完毕后,再调用 collectHeight()
,来获取缓存高度,然后再调用下一次的滚动。
二、实现
2.1 index.tsx
import VirtualList from './list';
export default VirtualList;
2.2 list.tsx
import Filler from './filler';
import useEvent from './hooks/useEvent';
import useHeights from './hooks/useHeights';
import useScrollTo from './hooks/useScrollTo';
import useChildren from './hooks/useChildren';
import {
useRef,
useMemo,
useState,
forwardRef,
useCallback,
useImperativeHandle
} from 'react';
function List(props, ref) {
const {
data,
style,
height,
itemKey,
onScroll,
children,
className,
innerProps,
itemHeight,
onVirtualScroll,
prefixCls = 'virtual-list'
} = props;
let holderStyle = null;
let maxScrollHeight = 0;
const holderRef = useRef();
const mergedData = data || [];
const holderInnerRef = useRef();
const maxScrollHeightRef = useRef(0);
const verticalScrollBarRef = useRef();
const horizontalScrollBarRef = useRef();
const [offsetTop, setOffsetTop] = useState(0);
const lastVirtualScrollInfoRef = useRef({ x: 0, y: 0 });
const mergedClassName = `${prefixCls || ''} ${className || ''}`;
const getKey = useCallback(
item => {
if (typeof itemKey === 'function') {
return itemKey(item);
}
return item?.[itemKey];
},
[itemKey]
);
const [setInstanceRef, collectHeight, heights, heightUpdatedMark] =
useHeights({ getKey });
const visibleCalculation = () => {
let itemTop = 0;
let startIndex;
let startOffset;
let endIndex;
const dataLen = mergedData.length;
for (let i = 0; i < dataLen; i += 1) {
const item = mergedData[i];
const key = getKey(item);
const cacheHeight = heights.get(key);
const currentItemBottom =
itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
if (currentItemBottom >= offsetTop && startIndex === undefined) {
startIndex = i;
startOffset = itemTop;
}
if (currentItemBottom > offsetTop + height && endIndex === undefined) {
endIndex = i;
}
itemTop = currentItemBottom;
}
if (startIndex === undefined) {
startIndex = 0;
startOffset = 0;
endIndex = Math.ceil(height / itemHeight);
}
if (endIndex === undefined) {
endIndex = mergedData.length - 1;
}
endIndex = Math.min(endIndex + 1, mergedData.length - 1);
return {
end: endIndex,
start: startIndex,
offset: startOffset,
scrollHeight: itemTop
};
};
const {
end,
start,
scrollHeight,
offset: fillerOffset
} = useMemo(visibleCalculation, [
height,
offsetTop,
mergedData,
heightUpdatedMark
]);
const listChildren = useChildren({
getKey,
endIndex: end,
list: mergedData,
startIndex: start,
renderFunc: children,
setNodeRef: setInstanceRef
});
function keepInRange(newScrollTop) {
let newTop = newScrollTop;
if (!Number.isNaN(maxScrollHeightRef.current)) {
newTop = Math.min(newTop, maxScrollHeightRef.current);
}
newTop = Math.max(newTop, 0);
return newTop;
}
function syncScrollTop(newTop) {
setOffsetTop(origin => {
let value;
if (typeof newTop === 'function') {
value = newTop(origin);
} else {
value = newTop;
}
const alignedTop = keepInRange(value);
holderRef.current.scrollTop = alignedTop;
return alignedTop;
});
}
const getVirtualScrollInfo = () => ({
x: 0,
y: offsetTop
});
const triggerScroll = useEvent(() => {
if (onVirtualScroll) {
const nextInfo = getVirtualScrollInfo();
if (
lastVirtualScrollInfoRef.current.x !== nextInfo.x ||
lastVirtualScrollInfoRef.current.y !== nextInfo.y
) {
onVirtualScroll(nextInfo);
lastVirtualScrollInfoRef.current = nextInfo;
}
}
});
const onFallbackScroll = e => {
const { scrollTop: newScrollTop } = e.currentTarget;
if (newScrollTop !== offsetTop) {
syncScrollTop(newScrollTop);
}
onScroll?.(e);
triggerScroll();
};
const delayHideScrollBar = () => {
verticalScrollBarRef.current?.delayHidden();
horizontalScrollBarRef.current?.delayHidden();
};
const scrollTo = useScrollTo({
getKey,
heights,
itemHeight,
syncScrollTop,
data: mergedData,
containerRef: holderRef,
triggerFlash: delayHideScrollBar,
collectHeight: () => collectHeight(true)
});
useImperativeHandle(ref, () => ({
getScrollInfo: getVirtualScrollInfo,
scrollTo: config => {
scrollTo(config);
}
}));
console.log('scrollHeight', scrollHeight);
maxScrollHeight = scrollHeight - height;
maxScrollHeightRef.current = maxScrollHeight;
lastVirtualScrollInfoRef.current = getVirtualScrollInfo();
if (height) {
holderStyle = { height, overflowY: 'auto', overflowAnchor: 'none' };
}
return (
<div
style={{
...style,
position: 'relative'
}}
className={mergedClassName}
>
<div
ref={holderRef}
style={holderStyle}
onScroll={onFallbackScroll}
className={`${prefixCls}-holder`}
>
<Filler
ref={holderInnerRef}
prefixCls={prefixCls}
height={scrollHeight}
offsetY={fillerOffset}
innerProps={innerProps}
onInnerResize={collectHeight}
>
{listChildren}
</Filler>
</div>
</div>
);
}
const ListWrapper = forwardRef(List);
export default ListWrapper;
2.3 filter.tsx
import { forwardRef } from 'react';
import ResizeObserver from '../RcResizeObserver';
function Filler(props, ref) {
const { height, offsetY, children, prefixCls, innerProps, onInnerResize } =
props;
const mergeClassName = `${prefixCls}-holder-inner`;
let outerStyle = {};
let innerStyle = {
display: 'flex',
flexDirection: 'column'
};
if (offsetY !== undefined) {
outerStyle = {
height,
overflow: 'hidden',
position: 'relative'
};
innerStyle = {
...innerStyle,
top: 0,
left: 0,
right: 0,
position: 'absolute',
transform: `translateY(${offsetY}px)`
};
}
return (
<div style={outerStyle}>
<ResizeObserver
onResize={({ offsetHeight }) => {
if (offsetHeight && onInnerResize) {
onInnerResize();
}
}}
>
<div
ref={ref}
style={innerStyle}
className={mergeClassName}
{...innerProps}
>
{children}
</div>
</ResizeObserver>
</div>
);
}
const FillerWrapper = forwardRef(Filler);
export default FillerWrapper;
2.4 item.tsx
import { cloneElement, useCallback } from 'react';
function Item(props) {
const { setRef, children } = props;
const refFunc = useCallback(node => {
setRef(node);
}, []);
return cloneElement(children, {
ref: refFunc
});
}
export default Item;
2.5 /hooks/useChildren.tsx
import Item from '../item';
export default function useChildren(params) {
const { list, getKey, endIndex, renderFunc, setNodeRef, startIndex } = params;
return list.slice(startIndex, endIndex + 1).map((item, index) => {
const key = getKey(item);
const eleIndex = startIndex + index;
const node = renderFunc(item, eleIndex, {
style: {}
});
return (
<Item key={key} setRef={ele => setNodeRef(item, ele)}>
{node}
</Item>
);
});
}
2.6 /hooks/useEvent.tsx
import { useCallback, useRef } from 'react';
export default function useEvent(callback) {
const fnRef = useRef<any>();
fnRef.current = callback;
const memoFn = useCallback((...args: any) => fnRef.current?.(...args), []);
return memoFn;
}
2.7 /hooks/useHeights.tsx
import raf from '../utils/raf';
import findDOMNode from '../utils/dom';
import CacheMap from '../utils/cacheMap';
import { useEffect, useRef, useState } from 'react';
export default function useHeights(params) {
const { getKey } = params;
const collectRafRef = useRef();
const instanceRef = useRef(new Map());
const heightsRef = useRef(new CacheMap());
const [updatedMark, setUpdatedMark] = useState(0);
const cancelRaf = () => {
raf.cancel(collectRafRef.current);
};
const collectHeight = (sync = false) => {
cancelRaf();
const doCollect = () => {
instanceRef.current.forEach((element, key) => {
if (element && element.offsetParent) {
const htmlElement = findDOMNode(element);
const { offsetHeight } = htmlElement;
if (heightsRef.current.get(key) !== offsetHeight) {
heightsRef.current.set(key, htmlElement.offsetHeight);
}
}
});
setUpdatedMark(c => c + 1);
};
if (sync) {
doCollect();
} else {
collectRafRef.current = raf(doCollect);
}
};
const setInstanceRef = (item, instance) => {
const key = getKey(item);
if (instance) {
instanceRef.current.set(key, instance);
collectHeight();
} else {
instanceRef.current.delete(key);
}
};
useEffect(() => {
return cancelRaf;
}, []);
return [setInstanceRef, collectHeight, heightsRef.current, updatedMark];
}
2.8 /hooks/useScrollTo.tsx
import raf from '../utils/raf';
import { useLayoutEffect, useRef, useState } from 'react';
const MAX_TIMES = 10;
export default function useScrollTo(params) {
const {
data,
getKey,
heights,
itemHeight,
triggerFlash,
containerRef,
collectHeight,
syncScrollTop
} = params;
const scrollRef = useRef();
const [syncState, setSyncState] = useState(null);
useLayoutEffect(() => {
if (!syncState || syncState.times >= MAX_TIMES) {
return;
}
if (!containerRef.current) {
setSyncState(ori => ({ ...ori }));
return;
}
collectHeight();
const { targetAlign, originAlign, index, offset } = syncState;
const height = containerRef.current.clientHeight;
let needCollectHeight = false;
let newTargetAlign = targetAlign;
let targetTop = null;
if (height) {
const mergedAlign = targetAlign || originAlign;
let stackTop = 0;
let itemTop = 0;
let itemBottom = 0;
const maxLen = Math.min(data.length - 1, index);
for (let i = 0; i <= maxLen; i += 1) {
const key = getKey(data[i]);
itemTop = stackTop;
const cacheHeight = heights.get(key);
itemBottom =
itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
stackTop = itemBottom;
}
let leftHeight = mergedAlign === 'top' ? offset : height - offset;
for (let i = maxLen; i >= 0; i -= 1) {
const key = getKey(data[i]);
const cacheHeight = heights.get(key);
if (cacheHeight === undefined) {
needCollectHeight = true;
break;
}
leftHeight -= cacheHeight;
if (leftHeight <= 0) {
break;
}
}
switch (mergedAlign) {
case 'top':
targetTop = itemTop - offset;
break;
case 'bottom':
targetTop = itemBottom - height + offset;
break;
default: {
const { scrollTop } = containerRef.current;
const scrollBottom = scrollTop + height;
if (itemTop < scrollTop) {
newTargetAlign = 'top';
} else if (itemBottom > scrollBottom) {
newTargetAlign = 'bottom';
}
}
}
if (targetTop !== null) {
syncScrollTop(targetTop);
}
if (targetTop !== syncState.lastTop) {
needCollectHeight = true;
}
}
if (needCollectHeight) {
setSyncState({
...syncState,
lastTop: targetTop,
times: syncState.times + 1,
targetAlign: newTargetAlign
});
}
}, [syncState, containerRef.current]);
return arg => {
if (arg == undefined) {
triggerFlash();
return;
}
raf.cancel(scrollRef.current);
if (typeof arg === 'number') {
syncScrollTop(arg);
} else if (arg && typeof arg === 'object') {
let index;
let { align } = arg;
if ('index' in arg) {
index = arg.index;
} else {
index = data.findIndex(item => getKey(item) === arg.key);
}
const { offset = 0 } = arg;
setSyncState({
index,
offset,
times: 0,
originAlign: align
});
}
};
}
2.9 /utils/cacheMap.tsx
class CacheMap {
maps: Record<string, number>;
id: number = 0;
constructor() {
this.maps = Object.create(null);
}
set(key, value) {
this.maps[key] = value;
this.id += 1;
}
get(key) {
return this.maps[key as string];
}
}
export default CacheMap;
2.10 /utils/dom.tsx
import React from 'react';
import ReactDOM from 'react-dom';
export function isDOM(node: any) {
return node instanceof HTMLElement || node instanceof SVGElement;
}
export default function findDOMNode(node) {
if (isDOM(node)) {
return node;
}
if (node instanceof React.Component) {
return ReactDOM.findDOMNode(node);
}
return null;
}
2.11 /utils/raf.tsx
let caf = (num: number) => clearTimeout(num);
let raf = (callback: FrameRequestCallback) => +setTimeout(callback, 16);
if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
raf = (callback: FrameRequestCallback) =>
window.requestAnimationFrame(callback);
caf = (handle: number) => window.cancelAnimationFrame(handle);
}
let rafUUID = 0;
const rafIds = new Map();
function cleanup(id: number) {
rafIds.delete(id);
}
const wrapperRaf = (callback, times = 1) => {
rafUUID += 1;
const id = rafUUID;
function callRef(leftTimes) {
if (leftTimes === 0) {
cleanup(id);
callback();
} else {
const realId = raf(() => {
callRef(leftTimes - 1);
});
rafIds.set(id, realId);
}
}
callRef(times);
return id;
};
wrapperRaf.cancel = id => {
const realId = rafIds.get(id);
cleanup(id);
return caf(realId);
};
export default wrapperRaf;
三、测试
import { forwardRef, useRef } from 'react';
import VirtualList from './components/RcVirtualList';
const data = new Array(100).fill(0).map((_value, index) => ({
id: index,
height: 30 + (index % 2 ? 70 : 0)
}));
const Item = (props, ref) => {
const { id, index: _index, height, style } = props;
return (
<div
ref={ref}
style={{
...style,
height,
padding: '0 16px',
lineHeight: '30px',
boxSizing: 'border-box',
border: '1px solid gray'
}}
>
{id}
</div>
);
};
const ItemWrapper = forwardRef(Item);
function App() {
const listRef = useRef(null);
const onScroll = e => {
// console.log('scroll:', e.currentTarget.scrollTop);
};
const onScrollTopTo = value => {
listRef.current.scrollTo({
index: value,
align: 'top'
});
};
const onScrollBottomTo = value => {
listRef.current.scrollTo({
index: value,
align: 'bottom'
});
};
return (
<div>
<h2>My Virtual List</h2>
<h2>
<button onClick={() => onScrollTopTo(50)}>scroll to top 50</button>
<button onClick={() => onScrollBottomTo(50)}>
scroll to bottom 50
</button>
</h2>
<VirtualList
data={data}
height={500}
itemKey="id"
ref={listRef}
itemHeight={30}
onScroll={onScroll}
style={{
border: '1px solid red',
boxSizing: 'border-box'
}}
>
{(item, index, others) => (
<ItemWrapper {...item} index={index} {...others} />
)}
</VirtualList>
</div>
);
}
export default App;