【组件实现篇】定高、不定高、动态高度虚拟列表实现

🧑‍💻 写在开头

点赞 + 收藏 === 学会🤣🤣🤣

【实战】前端工程化,技术栈react + ts + storybook 这个专栏我们已经完整的搭建完成了一个组件库项目,配置了自动化部署组件库文档,自动发包等流程,接下来的几篇我们将会实现常见的组件,包括但不限于瀑布流、虚拟列表、拖拽排序、穿梭框、弹窗、下拉选择、通用右键菜单、轮播图等常见组件入手。本篇我们研究一下react中几种虚拟列表的实现,定高、不定高、动态高度虚拟列表

🥑 实现效果&代码

在线demo体验

定高

不定高

动态高度

一、虚拟列表

什么是虚拟列表

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染,也就是设置缓冲区,从而达到极高的渲染性能。如下图所示,未滚动的时候上缓冲区是没有的,虚拟列表有三种情况

  • 定高虚拟列表,每一项的高度固定,可以很容易计算
  • 不定高虚拟列表,每一项的高度是不固定的,但是我们知道整个列表的高度数组
  • 动态高度虚拟列表,每一项的高度在渲染时确认

对于所有虚拟列表而言,我们的实现考虑的都是如何计算出可视区域的节点索引范围,即[startIndex, endIndex],然后加上缓冲区的大小,将此范围内的节点渲染到容器中,我们最根本的目的是计算起始索引startIndex和终止索引endIndex

1.定高虚拟列表

图示

原理

定高是最简单的,itemSize值是固定的我们可以直接求出我们需要的几个值,即如下几个值:

  • 列表总高度: 列表总高度是用于显示滚动条的
js 复制代码
totalListHeight = data.length * itemSize
  • 可视区域能显示的item数量: 主要是用于,在计算出startIndex后计算出endIndex
js 复制代码
visibleAreaItemsNum = Math.ceil(listHeight / itemSize)
  • 数据的起始索引: 由于高度固定,起始索引可以根据滚动掉的高度scrollTop直接计算出
js 复制代码
startIndex = Math.floor(scrollTop / itemSize)
  • 数据的结束索引: 根据起始索引和可视区域显示的item数,我们可以直接计算出结束索引
js 复制代码
endIndex = startIndex + visibleAreaItemsNum
  • 列表显示数据为,加上了缓冲区域
js 复制代码
visibleData = data.slice(startIndex - bufferNum, endIndex + bufferNum)

实现

虚拟列表的基本结构

fixedHeightContainer容器自定义宽高,contentContainer容器用于撑起滚动条

tsx 复制代码
// 定高
const fixedHeightRender = (options: VirtualListProps) => {
	const {itemHeight = 50, itemSumCount = 1000, listWidth, listHeight} = options;
	// 记录滚动掉的高度
	const [scrollTop, setScrollTop] = useState<number>(0);
	// 自定义的列表高度
	const customContainerStyle: CSSProperties = {
		position: 'relative',
		width: listWidth ?? 400,
		height: listHeight ?? 600,
	};
	// 1000个元素撑起盒子的实际高度
	const contentStyle: CSSProperties = {
		height: itemHeight * itemSumCount,
		width: '100%',
	};
	// 当触发滚动就重新计算
	const scrollHandle = (event: React.UIEvent<HTMLElement>) => {
		const {scrollTop} = event.currentTarget;
		setScrollTop(scrollTop);
	};

	return (
		<div
                className={style.fixedHeightContainer}
                style={customContainerStyle}
                onScroll={scrollHandle}>
                <div className={style.contentContainer} style={contentStyle}>
                        {getCurShowChild(options)}
                </div>
		</div>
	);
};

样式

less 复制代码
.fixedHeightContainer {
	display: block;
	width: 400px;
	height: 600px;
	overflow: auto;
	border: 1px solid tan;

	.contentContainer {
		display: block;
	}
}

实现核心方法

解释都在注释中,就是实现原理中的描述,纯计算

js 复制代码
const getCurShowChild = (options: VirtualListProps) => {
    const {itemHeight = 50, itemSumCount = 1000, listHeight = 600} = options;
    // 可视区起始索引
    const startIndex = Math.floor(scrollTop / itemHeight);
    // 上缓冲区起始索引
    const finialStartIndex = Math.max(0, startIndex - 2);
    // 可视区能展示的元素的最大个数
    const numVisible = Math.ceil(listHeight / itemHeight);
    // 下缓冲区结束索引
    const finialEndIndex = Math.min(itemSumCount, startIndex + numVisible + 2);
    const items = [];
    // 根据上面计算的索引值,不断添加元素给container
    for (let i = finialStartIndex; i < finialEndIndex; i++) {
            items.push(
                    ChildItem({
                            childHeight: itemHeight,
                            childIndex: i,
                    }),
            );
    }
    return items;
};

效果

在线demo体验

2.不定高虚拟列表

图示

因为每一项的高度不固定,所以我们需要的startIndexvisibleAreaItemsNum无法直接根据项目高度直接计算出来,也就意味着endIndex我们也计算不出来

原理

我们有什么?

  • 一个包含所有容器高度的高度列表?

知道这个能做什么?

  • 可以知道总容器的高度

我们需要什么?

  • 第一:可视区域:我们的目标仍然是如何计算出可视区域的节点索引范围,即[startIndex, endIndex],然后加上缓冲区的大小,将此范围内的节点渲染到容器中,我们最根本的目的是计算起始索引startIndex和终止索引endIndex
  • 第二:容器的总高度:我们无需精确计算全部数据容器到底有多高,需要需要这个是、是因为需要滚动条,滚动区域。我们可以简单给出一个粗略的高度

如何计算 我们可以设计一个数据结构保存已经展示过的item的高度,偏移值信息如下,这样我们就可以计算出起始索引,终止索引了

js 复制代码
// 每一项的数据结构
interface MeasuredDataMap {
	height: number;
	topOffset: number;
}

// 缓存映射的数据结构
interface MeasuredDataList {
	measuredDataMap: MeasuredDataMap[];
        // 当前已经记录的最后一个index
	LastMeasuredItemIndex: number;
}

const measuredData: MeasuredDataList = {
        measuredDataMap: [],
        LastMeasuredItemIndex: -1,
};
  • 预测列表总高度: 我们无需获取精确的全部数量的容器高度,仅需要通过当前已记录的总高度+当前未记录的总高度,预估列表总高度
js 复制代码
const totalEstimatedHeight =
			measuredHeight + unMeasuredItemsCount * defaultItemHeight;
  • 最大索引偏移值: 现在我们无法通过列表项高度直接算出,但是我们可以通过起始项的偏移值加上列表的总高度,计算出最大偏移值,从而计算出结束索引。
js 复制代码
const maxOffset = startItem.topOffset + listHeight;
  • 数据的起始索引: 我们可以通过二分法在已经记录的项中找到topOffset >= srcollTop的项就是第一项
js 复制代码
const getStartIndex = (options: VirtualListProps, scrollOffset: number) => {
    const {itemSumCount = 1000} = options;
    let low = 0;
    let high = itemSumCount - 1;

    while (low <= high) {
            const mid = Math.floor((low + high) / 2);
            const currentOffset = getItemMetaData(mid).topOffset;
            if (currentOffset === scrollOffset) {
                    return mid;
            } else if (currentOffset < scrollOffset) {
                    low = mid + 1;
            } else {
                    high = mid - 1;
            }
    }
    return low;
};
  • 数据的结束索引: 从startItem一直遍历加上每一项的height,直到topOffset >= maxOffset,就是结束项的索引
js 复制代码
// 查找终止索引:通过起始item的topOffset+自定义的listHeight可以计算出当前能显示的最大偏移值maxOffset
// 从startItem一直遍历加上height,直到offset >= maxOffset
const getEndIndex = (options: VirtualListProps, startIndex: number) => {
    const {listHeight = 600, itemSumCount = 1000} = options;
    const startItem = getItemMetaData(startIndex);
    const maxOffset = startItem.topOffset + listHeight;
    let offset = startItem.topOffset + startItem.height;
    let endIndex = startIndex;

    while (offset <= maxOffset && endIndex < itemSumCount - 1) {
            endIndex++;
            const currentItem = getItemMetaData(endIndex);
            offset += currentItem.height;
    }
    return endIndex;
};

实现

定义缓存数据结构

js 复制代码
// 使用useRef保存itemHeightList,确保在组件生命周期内不变
const itemHeightListRef = useRef<number[]>(
        new Array(itemSumCount).fill(0).map(() => {
                return itemHeight + Math.round(Math.random() * itemHeight);
        }),
);
const getItemHeightByIndex = (index: number) =>
        itemHeightListRef.current[index];

const measuredData: MeasuredDataList = {
        measuredDataMap: [],
        LastMeasuredItemIndex: -1,
};

获取每一项的数据

根据LastMeasuredItemIndex

  • 如果小于LastMeasuredItemIndex的都是被缓存过的项,直接获取
  • 如果大于LastMeasuredItemIndex则是未被缓存过的项,从LastMeasuredItemIndex开始,遍历到index,将遍历的所有项的height相加,即为当前项的index
js 复制代码
// LastMeasuredItemIndex是已经被记录的滚动到的最大索引,如果是大于LastMeasuredItemIndex,
// 则可以通过一项一项的相加,加到index就是当前项的最大偏移值
const getItemMetaData = (index: number) => {
        const {measuredDataMap, LastMeasuredItemIndex} = measuredData;
        if (index > LastMeasuredItemIndex) {
                let topOffset = 0;
                // 先获取到最底部新增的一项的topOffset
                if (LastMeasuredItemIndex >= 0) {
                        const lastMeasuredItem = measuredDataMap[LastMeasuredItemIndex];
                        topOffset += lastMeasuredItem.topOffset + lastMeasuredItem.height;
                }
                // 遍历记录,直到index
                for (let i = LastMeasuredItemIndex + 1; i <= index; i++) {
                        const currentItemSize = getItemHeightByIndex(i);
                        measuredDataMap[i] = {height: currentItemSize, topOffset};
                        topOffset += currentItemSize;
                }
                measuredData.LastMeasuredItemIndex = index;
        }
        return measuredDataMap[index];
};

获取开始索引

查找起始索引,从记录的数据结构中查找,第一项就是topOffset >= srcollTop 这里我们用二分法进行优化

js 复制代码
const getStartIndex = (options: VirtualListProps, scrollOffset: number) => {
    const {itemSumCount = 1000} = options;
    let low = 0;
    let high = itemSumCount - 1;

    while (low <= high) {
            const mid = Math.floor((low + high) / 2);
            const currentOffset = getItemMetaData(mid).topOffset;
            if (currentOffset === scrollOffset) {
                    return mid;
            } else if (currentOffset < scrollOffset) {
                    low = mid + 1;
            } else {
                    high = mid - 1;
            }
    }
    return low;
};

获取结束索引

我们虽然不知道可视区能显示多少个列表项,但是我们可以根据第一个的偏移值加上虚拟列表的高度,计算出最大偏移值,这样我们就可以查找终止索引:通过起始item的topOffset+自定义的listHeight可以计算出当前能显示的最大偏移值maxOffset,从startItem一直遍历加上height,直到offset >= maxOffset就是结束索引的位置

js 复制代码
const getEndIndex = (options: VirtualListProps, startIndex: number) => {
    const {listHeight = 600, itemSumCount = 1000} = options;
    const startItem = getItemMetaData(startIndex);
    const maxOffset = startItem.topOffset + listHeight;
    let offset = startItem.topOffset + startItem.height;
    let endIndex = startIndex;

    while (offset <= maxOffset && endIndex < itemSumCount - 1) {
            endIndex++;
            const currentItem = getItemMetaData(endIndex);
            offset += currentItem.height;
    }
    return endIndex;
};

可视区项目获取

js 复制代码
// 获取当前可现实的范围
const getChildShowRange = (
        options: VirtualListProps,
        scrollOffset: number,
) => {
        const {itemSumCount = 1000, bufferNum = 4} = options;
        const startIndex = getStartIndex(options, scrollOffset);
        const endIndex = getEndIndex(options, startIndex);
        return {
                bufferStartIndex: Math.max(0, startIndex - bufferNum),
                bufferEndIndex: Math.min(itemSumCount - 1, endIndex + bufferNum),
                startIndex,
                endIndex,
        };
};

const getCurShowChild = (options: VirtualListProps, scrollTop: number) => {
        const items = [];
        const {bufferStartIndex, bufferEndIndex} = getChildShowRange(
                options,
                scrollTop,
        );
        for (let i = bufferStartIndex; i <= bufferEndIndex; i++) {
                const item = getItemMetaData(i);
                const itemStyle: CSSProperties = {
                        position: 'absolute',
                        height: item.height,
                        width: '100%',
                        top: item.topOffset,
                };
                items.push(<ChildItem key={i} childIndex={i} itemStyle={itemStyle} />);
        }
        console.log('✅ ~ measuredData:', measuredData);

        return items;
};

效果

在线demo体验

3.动态高度虚拟列表

图示

大部分原理是跟不定高一致的,区别是我们连一个高度列表都没有,无法知道每一项的高度是多少,每一项的高度是在渲染时确定的,为了防止抖动,我们设置了一个默认高度。

原理

原理主要是设置先设置一个默认的高度,并记录到缓存数据结构中,我们去监听节点的高度变换,在发生变化后重新计算缓存数据,然后更新列表。

实现

定义默认高度

在不定高的基础上我们更改代码如下 这里我们用的都是默认高度,因为后面渲染时监听节点变化会更新缓存数据

js 复制代码
// 获取每一项的元数据
const getItemMetaData = (index: number) => {
    const {measuredDataMap, LastMeasuredItemIndex} = measuredData;
    // 如果index大于当前记录的最大值,挨个计算到index去,用top+height一个一个计算
    if (index > LastMeasuredItemIndex) {
        let topOffset =
                LastMeasuredItemIndex >= 0
                        ? measuredDataMap[LastMeasuredItemIndex].topOffset +
                                measuredDataMap[LastMeasuredItemIndex].height : 0;
        for (let i = LastMeasuredItemIndex + 1; i <= index; i++) {
                // 这里用的都是默认高度,因为后面渲染时会更新缓存数据
                measuredDataMap[i] = {height: 50, topOffset};
                topOffset += 50;
        }
        measuredData.LastMeasuredItemIndex = index;
    }
    return measuredDataMap[index];
};

动态获取任意高度的项目

这里所有项目的高度都是通过getOneChildItem在渲染的时候动态获取到的。

js 复制代码
// 获取任意高度的item
const getRandomHeightItem = (() => {
	let items: ReactNode[] | null = null;
	return () => {
		if (items) return items;
		items = [];
		const itemCount = 1000;
		for (let i = 0; i < itemCount; i++) {
			const height = 30 + Math.floor(Math.random() * 30);
			const style = {
				height,
				width: '100%',
			};
			items.push(<ChildItem key={i} childIndex={i} itemStyle={style} />);
		}
		return items;
	};
})();

// 动态获取子集
const DynamicChildItem = (options: DynamicChildItemProps) => {
	const {itemStyle, getChildItem, onSizeChange, childIndex} = options;
	const childRef = useRef(null);
	const resizeObserverRef = useRef<ResizeObserver | null>(null);

	useEffect(() => {
		const domNode = childRef.current;
		if (domNode) {
			if (!resizeObserverRef.current) {
				resizeObserverRef.current = new ResizeObserver(() => {
					onSizeChange(childIndex, domNode);
				});
			}
			resizeObserverRef.current.observe(domNode);
		}
		return () => {
			if (resizeObserverRef.current && domNode) {
				resizeObserverRef.current.unobserve(domNode);
			}
		};
	}, [childIndex, onSizeChange]);

	return (
		<div ref={childRef} style={itemStyle}>
			{getChildItem(childIndex)}
		</div>
	);
};

const getOneChildItem = (index: number) => getRandomHeightItem()[index];

调用

js 复制代码
// 根据当前显示范围,插入节点,节点通过getOneChildItem动态获取
const getCurShowChild = (scrollTop: number) => {
    const items = [];
    const {bufferStartIndex, bufferEndIndex} = getChildShowRange(scrollTop);
    for (let i = bufferStartIndex; i <= bufferEndIndex; i++) {
            const item = getItemMetaData(i);
            const itemStyle: CSSProperties = {
                    position: 'absolute',
                    height: item.height,
                    width: '100%',
                    top: item.topOffset,
            };
            items.push(
                    <DynamicChildItem
                            key={`${i}${item.topOffset}`}
                            childIndex={i}
                            getChildItem={getOneChildItem}
                            onSizeChange={sizeChangeHandle}
                            itemStyle={itemStyle}
                    />,
            );
    }
    return items;
};

监听节点宽高变化

通过监听节点的尺寸变化去更新缓存数据,然后触发虚拟列表重新渲染,实现动态高度渲染

js 复制代码
useEffect(() => {
    const domNode = childRef.current;
    if (domNode) {
            if (!resizeObserverRef.current) {
                    resizeObserverRef.current = new ResizeObserver(() => {
                            onSizeChange(childIndex, domNode);
                    });
            }
            resizeObserverRef.current.observe(domNode);
    }
    return () => {
            if (resizeObserverRef.current && domNode) {
                    resizeObserverRef.current.unobserve(domNode);
            }
    };
}, [childIndex, onSizeChange]);


// 监听节点尺寸变化,更新measuredDataMap,触发重新渲染
const sizeChangeHandle = (index: number, domNode: HTMLDivElement) => {
    const height = (domNode.children[0] as HTMLDivElement).offsetHeight;
    const {measuredDataMap, LastMeasuredItemIndex} = measuredData;
    measuredDataMap[index].height = height;
    let offset = 0;
    // 重新计算偏移值
    for (let i = 0; i <= LastMeasuredItemIndex; i++) {
            measuredDataMap[i].topOffset = offset;
            offset += measuredDataMap[i].height;
    }
    domNode.style.height = height + 'px';
    // 触发列表的一次更新
    setNeedUpdate(true);
};

效果

在线demo体验

参考文章

🍎 推荐阅读

工程化

本系列是一个从0到1的实现过程,如果您有耐心跟着实现,您可以实现一个完整的react18 + ts5 + webpack5 + 代码质量&代码风格检测&自动修复 + storybook8 + rollup + git action实现的一个完整的组件库模板项目。如果您不打算自己配置,也可以直接clone组件库仓库切换到rollup_comp分支即是完整的项目,当前实现已经足够个人使用,后续我们会新增webpack5优化、按需加载组件、实现一些常见的组件封装:包括但不限于拖拽排序、瀑布流、穿梭框、弹窗等

常见组件实现

面试手写系列

react实现原理系列

其他

🍋 写在最后

如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!

感兴趣的同学可以关注下我的公众号ObjectX前端实验室

🌟 少走弯路 | ObjectX前端实验室 🛠️「精选资源|实战经验|技术洞见」

相关推荐
weixin_382395232 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__2 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.3 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~4 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华5 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子7 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅7 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni7 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学9 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端