跳到主要内容

模拟

2024年01月28日
柏拉文
越努力,越幸运

一、认识


虚拟列表 分为可视区域非可视区域虚拟列表 只渲染可视区域列表项, 当滚动发生时, 通过计算获得可视区域内的列表项。架构如下所示:

Preview

1.1 相关变量

  1. height: 可视区域高度

  2. itemHeight: 列表项高度

  3. offsetTop: 当前滚动位置

  4. scrollHeight: 列表项总高度

  5. startIndex: 可视区域起始索引

  6. endIndex: 可视区域结束索引

  7. 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 函数执行, 重新收集、缓存每项元素的真实高度, 并重新计算 startOffsetscrollHeight 等值。

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;