跳到主要内容

模拟

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

一、认识


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

Preview

1.1 相关变量

  1. start: 根据 offsetoverscan 计算得出的可视区域开始索引

  2. end: 根据 overscanoffsetvisibleCount 计算出的可视区域结束索引

  3. offset: 根据 containerTargetscrollTop 计算出已经滚动过多少项

  4. visibleCount: 根据 containerTargetclientHeight 以及当前的开始索引, 获取到 containerTarget 能够承载的个数

  5. offsetTop: 根据开始索引获取到其距离最开始的距离, 用于 margin-top 偏移值

  6. totalHeight: wrapperTarget 容器高度

1.2 开始索引计算

const start = Math.max(0, offset - overscan);

1.3 结束索引计算

const end = Math.min(list.length, offset + visibleCount + overscan);

1.4 已经滚动项计算

if (isNumber(itemHeightRef.current)) {
return Math.floor(scrollTop / itemHeightRef.current) + 1;
}
let sum = 0;
let offset = 0;
for (let i = 0; i < list.length; i++) {
const height = itemHeightRef.current(i, list[i]);
sum += height;
if (sum >= scrollTop) {
offset = i;
break;
}
}
return offset + 1;

1.5 开始索引偏移值计算

if (isNumber(itemHeightRef.current)) {
const height = index * itemHeightRef.current;
return height;
}
const height = list
.slice(0, index)
.reduce((sum, _, i) => sum + (itemHeightRef.current as ItemHeight<T>)(i, list[i]), 0);
return height;

1.6 可视区域显示数计算

if (isNumber(itemHeightRef.current)) {
return Math.ceil(containerHeight / itemHeightRef.current);
}

let sum = 0;
let endIndex = 0;
for (let i = fromIndex; i < list.length; i++) {
const height = itemHeightRef.current(i, list[i]);
sum += height;
endIndex = i;
if (sum >= containerHeight) {
break;
}
}
return endIndex - fromIndex;

二、实现


2.1 /useVirtualList.tsx

import useSize from './useSize';
import useLatest from './useLatest';
import useMemoizedFn from './useMemoizedFn';
import useUpdateEffect from './useUpdateEffect';
import useEventListener from './useEventListener';
import { useEffect, useMemo, useRef, useState } from 'react';

function getTargetElement(target, defaultElement) {
if (!target) {
return defaultElement;
}

let targetElement;

if (typeof target === 'function') {
targetElement = target();
} else if ('current' in target) {
targetElement = target.current;
} else {
targetElement = target;
}

return targetElement;
}

function useVirtualList(list, options) {
const { overscan = 5, itemHeight, wrapperTarget, containerTarget } = options;

const size = useSize(containerTarget);
const itemHeightRef = useLatest(itemHeight);
const [targetList, setTargetList] = useState([]);
const scrollTriggerByScrollToFunc = useRef(false);
const [wrapperStyle, setWrapperStyle] = useState({});

const getVisibleCount = (containerHeight, fromIndex) => {
if (typeof itemHeightRef.current === 'number') {
return Math.ceil(containerHeight / itemHeightRef.current);
}

let sum = 0;
let endIndex = 0;
for (let i = fromIndex; i < list.length; i++) {
const height = itemHeightRef.current(i, list[i]);
sum += height;
endIndex = i;
if (sum >= containerHeight) {
break;
}
}
return endIndex - fromIndex;
};

const getOffset = scrollTop => {
if (typeof itemHeightRef.current === 'number') {
return Math.floor(scrollTop / itemHeightRef.current) + 1;
}

let sum = 0;
let offset = 0;
for (let i = 0; i < list.length; i++) {
const height = itemHeightRef.current(i, list[i]);
sum += height;
if (sum >= scrollTop) {
offset = i;
break;
}
}
return offset + 1;
};

const getDistanceTop = index => {
if (typeof itemHeightRef.current === 'number') {
const height = index * itemHeightRef.current;
return height;
}

const height = list
.slice(0, index)
.reduce((sum, _, i) => sum + itemHeightRef.current(i, list[i]), 0);
return height;
};

const totalHeight = useMemo(() => {
if (typeof itemHeightRef.current === 'number') {
return list.length * itemHeightRef.current;
}
return list.reduce(
(sum, _, index) => sum + itemHeightRef.current(index, list[index]),
0
);
}, [list]);

const calculateRange = () => {
const container = getTargetElement(containerTarget);

if (container) {
const { scrollTop, clientHeight } = container;

const offset = getOffset(scrollTop);
const visibleCount = getVisibleCount(clientHeight, offset);

const start = Math.max(0, offset - overscan);
const end = Math.min(list.length, offset + visibleCount + overscan);

const offsetTop = getDistanceTop(start);

setWrapperStyle({
height: totalHeight - offsetTop + 'px',
marginTop: offsetTop + 'px'
});

setTargetList(
list.slice(start, end).map((ele, index) => ({
data: ele,
index: index + start
}))
);
}
};

useUpdateEffect(() => {
const wrapper = getTargetElement(wrapperTarget);
if (wrapper) {
Object.keys(wrapperStyle).forEach(
key => (wrapper.style[key] = wrapperStyle[key])
);
}
}, [wrapperStyle]);

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;
container.scrollTop = getDistanceTop(index);
calculateRange();
}
};

return [targetList, useMemoizedFn(scrollTo)];
}

export default useVirtualList;

2.2 /hooks/useSize.tsx

import useRafState from './useRafState';
import ResizeObserver from 'resize-observer-polyfill';
import useLayoutEffectWithTarget from './useLayoutEffectWithTarget';

function getTargetElement(target, defaultElement) {
if (!target) {
return defaultElement;
}

let targetElement;

if (typeof target === 'function') {
targetElement = target();
} else if ('current' in target) {
targetElement = target.current;
} else {
targetElement = target;
}

return targetElement;
}

function useSize(target) {
const [state, setState] = useRafState(() => {
const el = getTargetElement(target);
return el ? { width: el.clientWidth, height: el.clientHeight } : undefined;
});

useLayoutEffectWithTarget(
() => {
const el = getTargetElement(target);

if (!el) {
return;
}

const resizeObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
const { clientWidth, clientHeight } = entry.target;
setState({ width: clientWidth, height: clientHeight });
});
});
resizeObserver.observe(el);
return () => {
resizeObserver.disconnect();
};
},
[],
target
);

return state;
}

export default useSize;

2.3 /hooks/useLatest.tsx

import { useRef } from 'react';

function useLatest(value) {
const ref = useRef(value);
ref.current = value;
return ref;
}

export default useLatest;

2.4 /hooks/useMemoizedFn.tsx

import { useMemo, useRef } from 'react';

function useMemoizedFn(fn) {
const fnRef = useRef(fn);
const memoizedFn = useRef();
fnRef.current = useMemo(() => fn, [fn]);

if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
};
}

return memoizedFn.current;
}

export default useMemoizedFn;

2.5 /hooks/useUpdateEffect.tsx

import { useEffect, useRef } from 'react';

function useUpdateEffect(effect, deps) {
const isMounted = useRef(false);

useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);

useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
} else {
return effect();
}
}, deps);
}

export default useUpdateEffect;

2.6 /hooks/useEventListener.tsx

import useLatest from './useLatest';
import useEffectWithTarget from './useEffectWithTarget';

function getTargetElement(target, defaultElement) {
if (!target) {
return defaultElement;
}

let targetElement;

if (typeof target === 'function') {
targetElement = target();
} else if ('current' in target) {
targetElement = target.current;
} else {
targetElement = target;
}

return targetElement;
}

function useEventListener(eventName, handler, options) {
const handlerRef = useLatest(handler);

useEffectWithTarget(
() => {
const targetElement = getTargetElement(options.target, window);
if (!targetElement?.addEventListener) {
return;
}

const eventListener = (event: Event) => {
return handlerRef.current(event);
};

targetElement.addEventListener(eventName, eventListener, {
capture: options.capture,
once: options.once,
passive: options.passive
});

return () => {
targetElement.removeEventListener(eventName, eventListener, {
capture: options.capture
});
};
},
[eventName, options.capture, options.once, options.passive],
options.target
);
}

export default useEventListener;

三、测试


import { useMemo, useRef } from 'react';
import useVirtualList from './hooks/useVirtualList';

function App() {
const wrapperRef = useRef(null);
const containerRef = useRef(null);
const originalList = useMemo(() => Array.from(Array(99999).keys()), []);

const [list, scrollTo] = useVirtualList(originalList, {
overscan: 10,
wrapperTarget: wrapperRef,
containerTarget: containerRef,
itemHeight: i => (i % 2 === 0 ? 42 + 8 : 84 + 8)
});

return (
<div>
<h2>My Virtual List</h2>
<h2>
<button onClick={() => scrollTo(50)}>scroll to 50</button>
</h2>

<div ref={containerRef} style={{ height: '300px', overflow: 'auto' }}>
<div ref={wrapperRef}>
{list.map(ele => (
<div
style={{
marginBottom: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #e8e8e8',
height: ele.index % 2 === 0 ? 42 : 84
}}
key={ele.index}
>
Row: {ele.data} size: {ele.index % 2 === 0 ? 'small' : 'large'}
</div>
))}
</div>
</div>
</div>
);
}

export default App;