模拟
2024年01月29日
一、认识
虚拟列表 分为可视区域和非可视区域。虚拟列表 只渲染可视区域列表项, 当滚动发生时, 通过计算获得可视区域内的列表项。架构如下所示:
Preview
1.1 相关变量
-
start
: 根据offset
、overscan
计算得出的可视区域开始索引 -
end
: 根据overscan
、offset
、visibleCount
计算出的可视区域结束索引 -
offset
: 根据containerTarget
的scrollTop
计算出已经滚动过多少项 -
visibleCount
: 根据containerTarget
的clientHeight
以及当前的开始索引, 获取到containerTarget
能够承载的个数 -
offsetTop
: 根据开始索引获取到其距离最开始的距离, 用于margin-top
偏移值 -
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;