仓库地址
github.com/react-compo… 具体使用可以参考:这里
目录说明
src/
├── index.tsx // 入口文件
├── Filter.tsx // 滚动内容的wrap,里面展示真正内容的高度。
├── List.tsx // 列表组件核心实现。 包括Filter租金,自定义滚动条组件
├── Scrollbar.tsx // 自定义滚动条组件
├── Item.tsx // 每个元素的clone组件,增加ref属性。
├── hooks/ // 自定义 hooks
├── utils/ // 工具函数
├── styles/ // 样式文件
└── interface.ts // TypeScript 类型定义
入口文件: src/index.tsx。
整体实现流程:
在说明整体流程前,先看下整体的dom结构。
虚拟列表分为两部分:一部分为滚动区域holder, 一部分为自定义实现的滚动条Scollbar
holder组件: 高度为当前可视区域的高度。注意其overflow属性是hidden
- outer组件: 为列表区域的真实高度
- inner组件: 为真正渲染列表的壳子
- list-item组件: 为每个单个元素的真正组件效果,其在Item.tsx文件中进行clone渲染的。增加ref属性,方便记录每条的高度
Scrollbar组件: 为自定义的滚动条组件。模拟滚动条的滑块(thumb)和轨道(track)
模拟滚动实现
在holder容器组件增加了wheel事件。
useLayoutEffect(() => {
// Firefox only
function onMozMousePixelScroll(e: Event) {
if (useVirtual) {
e.preventDefault();
}
}
const componentEle = componentRef.current;
componentEle.addEventListener('wheel', onRawWheel);
componentEle.addEventListener('DOMMouseScroll', onFireFoxScroll as any);
componentEle.addEventListener('MozMousePixelScroll', onMozMousePixelScroll);
return () => {
componentEle.removeEventListener('wheel', onRawWheel);
componentEle.removeEventListener('DOMMouseScroll', onFireFoxScroll as any);
componentEle.removeEventListener('MozMousePixelScroll', onMozMousePixelScroll as any);
};
}, [useVirtual]);
具体滚动逻辑 在useFrameWheel中定义
function onWheel(event: WheelEvent) {
if (!inVirtual) return;
// Wait for 2 frame to clean direction
raf.cancel(wheelDirectionCleanRef.current);
wheelDirectionCleanRef.current = raf(() => {
wheelDirectionRef.current = null;
}, 2);
const { deltaX, deltaY, shiftKey } = event;
let mergedDeltaX = deltaX;
let mergedDeltaY = deltaY;
if (
wheelDirectionRef.current === 'sx' ||
(!wheelDirectionRef.current && (shiftKey || false) && deltaY && !deltaX)
) {
mergedDeltaX = deltaY;
mergedDeltaY = 0;
wheelDirectionRef.current = 'sx';
}
const absX = Math.abs(mergedDeltaX);
const absY = Math.abs(mergedDeltaY);
if (wheelDirectionRef.current === null) {
wheelDirectionRef.current = horizontalScroll && absX > absY ? 'x' : 'y';
}
if (wheelDirectionRef.current === 'y') {
onWheelY(event, mergedDeltaY);
} else {
onWheelX(event, mergedDeltaX);
}
}
通过syncScrollTop方法设置holder容器的滚动高度,达到滚动的效果。
function syncScrollTop(newTop: number | ((prev: number) => number)) {
setScrollTop((origin) => {
let value: number;
if (typeof newTop === 'function') {
value = newTop(origin);
console.log(origin, value)
} else {
value = newTop;
}
// 判断滚动距离是否在合法范围
const alignedTop = keepInRange(value);
// 设置容器 rc-virtual-list-holder 的滚动高度
componentRef.current.scrollTop = alignedTop;
return alignedTop;
});
}
setScrollTop为当前区域滚动top值,当这个值更改时,也会同步修改scrollbar组件滑块的位置
scrollbar实现
return (
<div
ref={scrollbarRef}
className={classNames(scrollbarPrefixCls, {
[`${scrollbarPrefixCls}-horizontal`]: horizontal,
[`${scrollbarPrefixCls}-vertical`]: !horizontal,
[`${scrollbarPrefixCls}-visible`]: visible,
})}
style={{ ...containerStyle, ...style }}
onMouseDown={onContainerMouseDown}
onMouseMove={delayHidden}
>
<div
ref={thumbRef}
className={classNames(`${scrollbarPrefixCls}-thumb`, {
[`${scrollbarPrefixCls}-thumb-moving`]: dragging,
})}
style={{ ...thumbStyle, ...propsThumbStyle }}
onMouseDown={onThumbMouseDown}
/>
</div>
);
scrollBar的实现比较简单,分成两层,外层是滑道(track)div,里面包含一个滑块儿(track)。 滑道的postion为absolute,加上top和bottom都为0所以滑道的高度就是父元素高度。
position: absolute;
width: 8px;
top: 0px;
bottom: 0px;
right: 0px;
visibility: hidden;
基于下面的逻辑
滑块滚动的top/(滑道的高度 - 滑块高度)= 内容滚动的top/(内容真实高度 - 内容显示高度) 所以每次内容滚动后,需要动态更改滑块滚动的top值
// ======================== Range =========================
const enableScrollRange = scrollRange - containerSize || 0;
const enableOffsetRange = containerSize - spinSize || 0;
const top = React.useMemo(() => {
if (scrollOffset === 0 || enableScrollRange === 0) {
return 0;
}
const ptg = scrollOffset / enableScrollRange;
return ptg * enableOffsetRange;
}, [scrollOffset, enableScrollRange, enableOffsetRange]);
确定可视区域起始位置、结束位置
看下计算startIndex, endIndex代码
// ========================== Visible Calculation =========================
const {
scrollHeight,
start,
end,
offset: fillerOffset,
} = React.useMemo(() => {
if (!useVirtual) {
return {
scrollHeight: undefined,
start: 0,
end: mergedData.length - 1,
offset: undefined,
};
}
// Always use virtual scroll bar in avoid shaking
if (!inVirtual) {
return {
scrollHeight: fillerInnerRef.current?.offsetHeight || 0,
start: 0,
end: mergedData.length - 1,
offset: undefined,
};
}
let itemTop = 0;
let startIndex: number;
let startOffset: number;
let endIndex: number;
const dataLen = mergedData.length;
for (let i = 0; i < dataLen; i += 1) {
const item = mergedData[i];
const key = getKey(item);
// heigths存放真正每个list-item渲染高度
const cacheHeight = heights.get(key);
// 记录当前距离顶部的距离
const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
// 判断距离顶部的距离是不是大于当前的top值,当大于时则当前的i为起始位置
if (currentItemBottom >= offsetTop && startIndex === undefined) {
startIndex = i;
startOffset = itemTop;
}
// Check item bottom in the range. We will render additional one item for motion usage
if (currentItemBottom > offsetTop + height && endIndex === undefined) {
endIndex = i;
}
itemTop = currentItemBottom;
}
// When scrollTop at the end but data cut to small count will reach this
if (startIndex === undefined) {
startIndex = 0;
startOffset = 0;
endIndex = Math.ceil(height / itemHeight);
}
if (endIndex === undefined) {
endIndex = mergedData.length - 1;
}
// Give cache to improve scroll experience
endIndex = Math.min(endIndex + 1, mergedData.length - 1);
return {
scrollHeight: itemTop,
start: startIndex,
end: endIndex,
offset: startOffset,
};
}, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]);
代码中有个heights对象,存放了每个元素的高度。具体存放的逻辑在useHeight.tsx这个钩子函数中。
const heightsRef = useRef(new CacheMap());
function collectHeight(sync = false) {
cancelRaf();
const doCollect = () => {
instanceRef.current.forEach((element, key) => {
if (element && element.offsetParent) {
const htmlElement = findDOMNode<HTMLElement>(element);
const { offsetHeight } = htmlElement;
if (heightsRef.current.get(key) !== offsetHeight) {
heightsRef.current.set(key, htmlElement.offsetHeight);
}
}
});
// Always trigger update mark to tell parent that should re-calculate heights when resized
setUpdatedMark((c) => c + 1);
};
if (sync) {
doCollect();
} else {
collectRafRef.current = raf(doCollect);
}
}
instanceRef.current中存放了每个真实list-item元素,会遍历这里的元素,记录每个元素的高度。
instanceRef.current存放真实元素主要是下面setInstanceRef这个方法,
function setInstanceRef(item: T, instance: HTMLElement) {
const key = getKey(item);
const origin = instanceRef.current.get(key);
if (instance) {
instanceRef.current.set(key, instance);
collectHeight();
} else {
instanceRef.current.delete(key);
}
// Instance changed
if (!origin !== !instance) {
if (instance) {
onItemAdd?.(item);
} else {
onItemRemove?.(item);
}
}
}
// Item组件 refFun -> setRef ->setInstanceRef 调用流程
export function Item({ children, setRef }: ItemProps) {
const refFunc = React.useCallback(node => {
setRef(node);
}, []);
return React.cloneElement(children, {
ref: refFunc,
});
}
其中有个raf(doCollect),rag为rc-util/lib/raf提供的方法,是一个用于处理浏览器中的 requestAnimationFrame (简称 raf) 方法的小工具库。在浏览器空闲时才会记录元素的offsetHeight值。