在业务中,我是如何实现虚拟滚动的(源码和解决方案) 中
我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第5篇文章,点击查看活动详情
承接上一章节在业务中,我是如何实现虚拟滚动的(源码和解决方案) 上,我们来详细介绍rc-virtual-list
五层结构。
时刻铭记我们提出的几个问题
1. 第三层是哪里来的呢?仅仅查看list代码是无法体现出来的?
2. 如何渲染出当前可视窗口的dom元素,依据是什么?
3. 如何计算滚动条的高度?
4. 如何滚动?谁在滚动?
详细介绍
在这里,提供一份带注释的源码
我们根据组件的dom结构,一层一层的看:
第一层
这一层,如前面所说,就是组件的一个大盒子,设置了positon:relative;box-sizing: border-box
属性,用来盛放组件。
第二层
第二层是组件的主体组件,内容和滚动条组件都在这一层。
内容
我们可以看到rc-virtual-list-holder
这一层的样式,有我们设置的height = 500
,加上溢出省略属性发,这也就是代码中Component
的功能
滚动条
我们根据上面这张图,可以看到滚动条的效果,这里,
rc-virtual-list-scrollbar rc-virtual-list-scrollbar-show
这个div
仅仅是滚动条的轨道,实际的滚动条还在这一层的下边儿
我们想不管滚动条在做什么,继续回头看内容区更深层次的组件。
第三层
前面提到过,第三层是所有内容高度总和的div
,并且直接查看list的return的代码是没有体现出来,所以可以肯定,其一定是某一个组件提供的,我们来找找看。
最终发现在src/Filler.tsx
。
高度总和是如何计算的呢,我们查看Filter
组件的height
属性,对接外面的scrollHeight
。这里我们先不探究,如何获取height,在下一层,一起探究!!!
第四层
第四层是这个组件的可视区域
在我们进行滚动时,发现
在实时变更,每滚动一个
item
,translateY
会增加或者减少该item
的高度。我们滚动前和滚动后对比,发现
滚动前:可视区域在所有内容的最上面
滚动后:可视区域在所有内容的最下边
那么是如何实现的呢?
我们一起查看
Filter
组件的offset
属性以及第三层需要的scrollHeight
属性。
因为这一段代码实在是太长了,这里一些简单的逻辑我直接就省略掉了。
这一段代码,详细介绍了如何计算应该显示到可视区域的元素范围,其大概使用,是主要通过监听scrollTop
也就是列表内容滚动高度,来进行计算。
计算规则如下:
// currentItemBottom = 当前item和之前的所有item的高度,
// 如你在第二个item,则currentItemBottom就是前两个item的高度和
// 开始下标计算:如果currentItemBottom >=组件滚动的高度且startIndex = undefined
// 则渲染的内容的开始下标是当前item的下标,偏移值是当前item的所有item的高度和
if (currentItemBottom >= scrollTop && startIndex === undefined) {
startIndex = i;
startOffset = itemTop;
}
// 结束下标计算: 如果如果currentItemBottom > 组件滚动的高度和可视区域之和且endIndex = undefined
// 则渲染的内容的结束下标是当前item的下标
if (currentItemBottom > scrollTop + height && endIndex === undefined) {
endIndex = i;
}
const { scrollHeight, start, end, offset } = React.useMemo(() => {
// ...
let itemTop = 0;
//将要在可视区域渲染的内容数组的下标头
let startIndex: number;
let startOffset: number;
//将要在可视区域渲染的内容数组的下标尾
let endIndex: number;
//所有数据的长度
const dataLen = mergedData.length;
// 通过该for循环,来判断可视化组件要展示的内容的起止下标
for (let i = 0; i < dataLen; i += 1) {
// 每一个item
const item = mergedData[i];
// 每一个item的key
const key = getKey(item);
// heights的获取是通过useHeights的hook获取的
//TODO:jiangniao 由collectHeight赋值 cacheHeight是用来存放各个item的实际高度,如果存在,使用这个高度,如果不存在,使用传进来的item的高度
const cacheHeight = heights.get(key);
// 当前item的底部
const currentItemBottom = itemTop + (cacheHeight === undefined ? itemHeight : cacheHeight);
// Check item top in the range
// 如果当前的item底部 >= 列表内容滚动高度并且startIndex === undefined
// 设置当前可视组件要渲染的开始内容下标
if (currentItemBottom >= scrollTop && startIndex === undefined) {
startIndex = i;
startOffset = itemTop;
}
// Check item bottom in the range. We will render additional one item for motion usage
// 设置当前可是组建要渲染的内容结束的下标
if (currentItemBottom > scrollTop + height && endIndex === undefined) {
// 第i个元素(含)之前所有元素的高度 超过了 滚动高度+可视区域的高度,结束索引设为i
endIndex = i;
}
itemTop = currentItemBottom;
}
// ...
return {
// 这里的itemTop是for循环每一个item的高度之和
scrollHeight: itemTop,
start: startIndex,
end: endIndex,
// 偏移的高度,是当前渲染组件内容的起始item的前面的item的高度之和,比如当前从第三个内容开始展示,那么该值就是前两个item的高度合
offset: startOffset,
};
// 每次scrollTop变化时,重新计算
}, [inVirtual, useVirtual, scrollTop, mergedData, heightUpdatedMark, height]);
终于和Filter
组件相关的关键属性结束了,我们到现在也知道了如何计算应该显示到可视区域的元素范围。
第五层
视区域渲染dom元素的方法,由一个名为useChildren
的自定义hooks
提供。
/**
* mergedData item所有数据
* start 可视区域item开始下标
* end 可视区域item末尾下标
* setInstanceRef 由useHeights提供
* children 子组件结构,item的壳
* sharedConfig item key的对象
*/
const listChildren = useChildren(mergedData, start, end, setInstanceRef, children, sharedConfig);
我们先不管setInstanceRef
是做什么的,我们先来看看useChildren
是如何实现的。
看了类型定义后,才知道为什么sharedConfig
传递参数这么奇怪。因为该部分比较简单,就是通过起始和末尾的下标,来进行数据截取,将截取下来的数据进行通过传递过来的children
的值进行渲染。
/**
*
* @param list item所有数据
* @param startIndex 可视区域item开始下标
* @param endIndex 可视区域item末尾下标
* @param setNodeRef
* @param renderFunc 子组件结构,item的壳
* @param param5 item key的对象
* @returns
*/
export default function useChildren<T>(
list: T[],
startIndex: number,
endIndex: number,
setNodeRef: (item: T, element: HTMLElement) => void,
renderFunc: RenderFunc<T>,
{ getKey }: SharedConfig<T>,
) {
return list.slice(startIndex, endIndex + 1).map((item, index) => {
const eleIndex = startIndex + index;
const node = renderFunc(item, eleIndex, {
// style: status === 'MEASURE_START' ? { visibility: 'hidden' } : {},
}) as React.ReactElement;
const key = getKey(item);
return (
<Item key={key} setRef={ele => setNodeRef(item, ele)}>
{node}
</Item>
);
});
}
但是,这里我们还是要注意一下Item
组件,我们需要了解setRef
是做什么用的,毕竟我们作为入参传入的setInstanceRef
不知道是做什么的!
我们发现Item
组件clone返回了包裹了外部传入的列表元素的JSXElement
并给其加上了ref
属性,这样,当通过ref
获取子节点时将会调用refFunc -> setRef -> setInstanceRef
。这也是为什么当元素高度可变时需要用React.forwardRef
进行列表元素的包裹
export function Item({ children, setRef }: ItemProps) {
const refFunc = React.useCallback(node => {
setRef(node);
}, []);
return React.cloneElement(children, {
ref: refFunc,
});
}
接下来,我们再回过头看我们欠过的债,setInstanceRef
、collectHeight
、heights
、heightUpdatedMark
。让我们来看看useHeights
。
我们前面知道了,setInstanceRef
会在useChildren
渲染时触发,这里,collectHeight
在每次滚动时触发,用来计算每一个item
的真实高度;heights
作为一个map
对象,存放了当前渲染的dom
的item
们,其以item
的key
为key
,以item
的高为value
。
setInstanceRef
用来维护一个以item的key
为key
,以item
渲染出的dom
为value
的map
对象,其根据判断在useChildren
中,某一个element
实例是否还存在,进行对map
对象的新增或者删除。
export default function useHeights<T>(
getKey: GetKey<T>,
// ...
): [(item: T, instance: HTMLElement) => void, () => void, CacheMap, number] {
//是否更新
const [updatedMark, setUpdatedMark] = React.useState(0);
// 一个存储了当前所有可视区域的dom的map,
const instanceRef = useRef(new Map<React.Key, HTMLElement>());
// value是每一个dom的高,key是每一个dom的key
const heightsRef = useRef(new CacheMap());
const collectRafRef = useRef<number>();
// ...
// TODO: 滚动条滚动时,该方法触发
function collectHeight() {
cancelRaf();
collectRafRef.current = raf(() => {
instanceRef.current.forEach((element, key) => {
if (element && element.offsetParent) {
const htmlElement = findDOMNode<HTMLElement>(element);
const { offsetHeight } = htmlElement;
// 计算每一个item的实际高度,如果不等于当前dom的高度,则直接赋值
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);
});
}
/**
*
* @param item 当前item的值
* @param instance 当前渲染的dom元素
* @return 通过该方法进行删除和添加,干掉后,会不准,原因未知!
*/
// TODO:通过key来判断当前可视区域dom的增或删,如果instance为null,则说明该dom在useChildren中没有了,删除;
// 如果instance不为null,索命在useChildren中该dom被渲染了,需要添加。
function setInstanceRef(item: T, instance: HTMLElement) {
const key = getKey(item);
if (instance) {
// 需要添加的dom元素
instanceRef.current.set(key, instance);
collectHeight();
} else {
//不需要渲染的dom元素删除
instanceRef.current.delete(key);
}
//...
}
}
// ...
return [setInstanceRef, collectHeight, heightsRef.current, updatedMark];
}
总结
本章根据源码详细介绍了rc-virtual-list
的五层结构,每一个组件的功能,对于滚动,因为篇幅问题,放到了最后一部分。
资源引用
转载自:https://juejin.cn/post/7146068387200925727