在业务中,我是如何实现虚拟滚动的(源码和解决方案) 下
我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第6篇文章,点击查看活动详情
我们承接前两篇文章
继续来说说rc-virtual-list
的滚动是如何实现的。不要忘记原来提出的问题!
如何计算滚动条的高度?
如何滚动?谁在滚动?
滚动条模块
在这里,我们需要注意一点,我们所说的滚动条模块,仅仅是样式上的滚动条!!
样式
我们将滚动条样式的代码注释掉后,其还是可以实现滚动加载的,只是滚动条样式没有了而已
所以我们来看看滚动条的样式是如何搞定的吧!!
//...
const MIN_SIZE = 20;
export interface ScrollBarProps {
prefixCls: string;
// 列表内容滚动高度
scrollTop: number;
// scrollHeight这里指所有的item的高度之和
scrollHeight: number;
// 可视区域的高度
height: number;
// 所有数据的长度
count: number;
// 滚动时,通过syncScrollTop方法设置容器rc-virtual-list-holder的滚动高度
onScroll: (scrollTop: number) => void;
}
//...
// 组件更新后立即调用
componentDidUpdate(prevProps: ScrollBarProps) {
if (prevProps.scrollTop !== this.props.scrollTop) {
this.delayHidden();
}
}
//...
// 两秒没有滚动,滚动条将会消失
delayHidden = () => {
clearTimeout(this.visibleTimeout);
this.setState({ visible: true });
this.visibleTimeout = setTimeout(() => {
this.setState({ visible: false });
}, 2000);
};
//...
// ======================= Clean =======================
patchEvents = () => {
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('mouseup', this.onMouseUp);
this.thumbRef.current.addEventListener('touchmove', this.onMouseMove);
this.thumbRef.current.addEventListener('touchend', this.onMouseUp);
};
removeEvents = () => {
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
this.scrollbarRef.current?.removeEventListener('touchstart', this.onScrollbarTouchStart);
if (this.thumbRef.current) {
this.thumbRef.current.removeEventListener('touchstart', this.onMouseDown);
this.thumbRef.current.removeEventListener('touchmove', this.onMouseMove);
this.thumbRef.current.removeEventListener('touchend', this.onMouseUp);
}
raf.cancel(this.moveRaf);
};
// ======================= Thumb =======================
// 保持鼠标状态在滚动条上
onMouseDown = (e: React.MouseEvent | TouchEvent) => {
const { onStartMove } = this.props;
this.setState({
dragging: true,
pageY: getPageY(e),
startTop: this.getTop(),
});
onStartMove();
this.patchEvents();
e.stopPropagation();
e.preventDefault();
};
onMouseMove = (e: MouseEvent | TouchEvent) => {
const { dragging, pageY, startTop } = this.state;
const { onScroll } = this.props;
raf.cancel(this.moveRaf);
if (dragging) {
const offsetY = getPageY(e) - pageY;
const newTop = startTop + offsetY;
const enableScrollRange = this.getEnableScrollRange();
const enableHeightRange = this.getEnableHeightRange();
const ptg = enableHeightRange ? newTop / enableHeightRange : 0;
const newScrollTop = Math.ceil(ptg * enableScrollRange);
this.moveRaf = raf(() => {
onScroll(newScrollTop);
});
}
};
onMouseUp = () => {
const { onStopMove } = this.props;
this.setState({ dragging: false });
onStopMove();
this.removeEvents();
};
// ===================== Calculate =====================
// 计算滚动条的高度
getSpinHeight = () => {
const { height, count } = this.props;
// 基本高度 = 可视高度/数量总长度*10;
let baseHeight = (height / count) * 10;
// 最小20
baseHeight = Math.max(baseHeight, MIN_SIZE);
// 最大可视区域高度的一半
baseHeight = Math.min(baseHeight, height / 2);
// 向下取整
return Math.floor(baseHeight);
};
getEnableScrollRange = () => {
const { scrollHeight, height } = this.props;
// 所有item的高度和 - 可视区域高度
return scrollHeight - height || 0;
};
getEnableHeightRange = () => {
const { height } = this.props;
const spinHeight = this.getSpinHeight();
// 可视区域高度 - 滚动条高度
return height - spinHeight || 0;
};
getTop = () => {
// 列表内容滚动高度
const { scrollTop } = this.props;
// 启用滚动范围
const enableScrollRange = this.getEnableScrollRange();
// 使高度范围
const enableHeightRange = this.getEnableHeightRange();
// 列表滚动高度或者可以滚动的范围是0
if (scrollTop === 0 || enableScrollRange === 0) {
return 0;
}
// 组件滚动的高度 / 可以滚动的范围
const ptg = scrollTop / enableScrollRange;
// 乘以 可以滚动的区域
return ptg * enableHeightRange;
};
// Not show scrollbar when height is large than scrollHeight
//什么时候展示滚动条
showScroll = (): boolean => {
const { height, scrollHeight } = this.props;
return scrollHeight > height;
};
// ====================== Render =======================
render() {
const { dragging, visible } = this.state;
const { prefixCls } = this.props;
const spinHeight = this.getSpinHeight();
const top = this.getTop();
const canScroll = this.showScroll();
const mergedVisible = canScroll && visible;
return (
// 滚动条轨道
<div
ref={this.scrollbarRef}
className={classNames(`${prefixCls}-scrollbar`, {
[`${prefixCls}-scrollbar-show`]: canScroll,
})}
style={{
width: 8,
top: 0,
bottom: 0,
right: 0,
position: 'absolute',
//如果超过两秒滚动条没有移动,则滚动条隐藏
display: mergedVisible ? null : 'none',
}}
//鼠标按住 滚动条保持不消失
onMouseDown={this.onContainerMouseDown}
//鼠标离开 开启滚动条消失倒计时
onMouseMove={this.delayHidden}
>
{/* 真正的滚动条 */}
<div
ref={this.thumbRef}
className={classNames(`${prefixCls}-scrollbar-thumb`, {
[`${prefixCls}-scrollbar-thumb-moving`]: dragging,
})}
style={{
height: spinHeight,
top,
//...
}}
onMouseDown={this.onMouseDown}
/>
</div>
);
}
}
这里,我着重关注的是我前面提出的问题
1. 滚动条的高度是如何计算的
2. 如何知道滚动条滚到了什么地方
我们可以查阅上述代码,并搜索spinHeight
和top
,关注一下Calculate模块下的代码,
// 计算滚动条的高度
getSpinHeight = () => {
const { height, count } = this.props;
// 基本高度 = 可视高度/数量总长度*10;
let baseHeight = (height / count) * 10;
// 最小20
baseHeight = Math.max(baseHeight, MIN_SIZE);
// 最大可视区域高度的一半
baseHeight = Math.min(baseHeight, height / 2);
// 向下取整
return Math.floor(baseHeight);
};
getEnableScrollRange = () => {
const { scrollHeight, height } = this.props;
// 所有item的高度和 - 可视区域高度
return scrollHeight - height || 0;
};
getEnableHeightRange = () => {
const { height } = this.props;
const spinHeight = this.getSpinHeight();
// 可视区域高度 - 滚动条高度
return height - spinHeight || 0;
};
getTop = () => {
// 列表内容滚动高度
const { scrollTop } = this.props;
// 启用滚动范围
const enableScrollRange = this.getEnableScrollRange();
// 使高度范围
const enableHeightRange = this.getEnableHeightRange();
// 列表滚动高度或者可以滚动的范围是0
if (scrollTop === 0 || enableScrollRange === 0) {
return 0;
}
// 组件滚动的高度 / 可以滚动的范围
const ptg = scrollTop / enableScrollRange;
// 乘以 使高度范围
return ptg * enableHeightRange;
};
我们用一个数学计算,结束这里,来通过数字,走一遍儿top
是如何得出的
假设:
我们拥有一个可视高度heignt
600,共有item
30个,并且所有item
高度总和3000;
滚动条高度 = (600 / 300) * 10 = 20;
滚动范围 = (3000 - 600) || 0 = 2400;
使用高度范围 = (600 - 20) || 0 = 580;
🌰1:
此时,滚动距离0;
top = 0;
🌰2:
此时,滚动距离100;
top = (100 / 2400) * 580 = 24.1667
🌰3:
此时,滚动距离2400;
top = (2400 / 2400) * 580 = 580
注意这个滚动高度(scrollTop),也是有范围的,就是0 <--> (scrollHeight - heignt)
。
看到这里,我们滚动条的样式也知道了,但是我们前面说过,样式消失,还是可以实现逻辑,滚动的逻辑是什么实现的呢?
逻辑
是否还记得,我们前面一直提到的Component
也就是我们的rc-virtual-list-holder
第二层,其实真正的滚动逻辑就在它身上挂着呢。所以我们再想下前面的结构,第二层,同时拥有内容区和滚动条区。滚动条区仅仅是样式展示、所以所有的东东就给到了内容区了。
经过查找,发现滚动的核心是通过监听wheel
(翻译过来'轮')事件进行自定义滚动,通过监听这个事件,来实现这一整套逻辑。
我们查看下面的代码,我们可以得知onRawWheel
这个是关键,其对应着onWheel
方法,经过对改方法的查看,我们知道了,改方法用来听过监听wheel
方法,然后通过onWheelDelta
也就是外边传入的对syncScrollTop
方法的调用,然后动态改变rc-virtual-list-holder
的滚动高度。
所以虚拟滚动的一切交互,都是基于rc-virtual-list-holder
这一层滚动高度的变化,进而执行的其他逻辑。
const [onRawWheel, onFireFoxScroll] = useFrameWheel(
// 是否处于虚拟滚动中 使用虚拟滚动,且有值,并且每一项的高度*数据长度大于可视窗口的高度
useVirtual,
// 滚动条是否在顶端
isScrollAtTop,
// 滚动条是否在底部
isScrollAtBottom,
(offsetY) => {
// offsetY 是滑动的距离
// top之前的高度
//通过syncScrollTop方法设置容器rc-virtual-list-holder的滚动高度
syncScrollTop((top) => {
const newTop = top + offsetY;
return newTop;
});
},
);
function onWheel(event: WheelEvent) {
if (!inVirtual) return;
raf.cancel(nextFrameRef.current);
const { deltaY } = event;
// 获取到滚轮滑动距离,并认为该距离是元素滚动高度 每次滚动的距离之和
offsetRef.current += deltaY;
wheelValueRef.current = deltaY;
// Do nothing when scroll at the edge, Skip check when is in scroll
// ???
if (originScroll(deltaY)) return;
// Proxy of scroll events
if (!isFF) {
event.preventDefault();
}
nextFrameRef.current = raf(() => {
// Patch a multiple for Firefox to fix wheel number too small
// isMouseScrollRef.current != false 是火狐
const patchMultiple = isMouseScrollRef.current ? 10 : 1;
onWheelDelta(offsetRef.current * patchMultiple);
offsetRef.current = 0;
});
}
总结
还记得我前面提的几个问题吗
1. 第三层是哪里来的呢?仅仅查看list代码是无法体现出来的?
2. 如何渲染出当前可视窗口的dom元素,依据是什么?
3. 如何计算滚动条的高度?
4. 如何滚动?谁在滚动?
我们此时经过源码查看后,好像都解决了!
整个rc-virtual-list
组件依托于监听wheel
事件、动态改变rc-virtual-list-holder
这一层也就是第二层的scrollTop
滚动高度,然后其他组件ScrollBar
根据这过程中的其他状态和传入的值进行页面显示的滚动条的计算。Filter
组件根据状态,计算出需要在可视区域dom渲染的listChildren
方法。
其中,一共查阅了三个自定义hooks
,useChildren
计算要渲染的item
,并通过一个巧妙的方法,在useHeights
中依托于useChildren
保留的item
的信息,来计算维护几个高度相关的map
对象供其他使用。
useFrameWheel
是一切源头,整个组件的滚动要依托它来实现。
最后
以上,是我对rc-virtual-list
部分源码阅读后的个人见解。如有不对的地方,欢迎指出。你的每一句,都是我成长的动力!
资源引用
转载自:https://juejin.cn/post/7146070462870028301