Loading... # 虚拟列表(Virtual List)实现逻辑指 虚拟列表的核心目标是:**在处理海量数据时,只渲染用户当前可见范围内的 DOM 元素**,从而极大地提升页面性能。 --- ## 1. 核心 DOM 结构布局 要实现虚拟列表,你需要构建一个三层嵌套的 DOM 结构: 1. **可视区域容器 (Container)** - 这是一个具有固定高度(如 600px)的容器。 - 必须设置 `overflow-y: auto`,用于监听滚动事件并产生滚动条。 - 它是所有内容的“窗口”。 2. **撑高占位层 (Phantom/Spacer)** - 位于容器内部的第一层。 - **高度计算**:`总列表项数量 * 单个列表项高度`。 - 它的唯一作用是撑开容器,模拟出完整列表的滚动条高度,让用户感觉列表是完整的。 3. **实际渲染层 (List/Content)** - 位于容器内部的第二层,采用绝对定位(`position: absolute`)置于顶部。 - 它负责存放当前需要显示的少量 DOM 节点。 - **关键点**:它需要根据滚动距离进行位移(`transform`),确保自己始终出现在用户的视线内。 --- ## 2. 所需参数与状态清单 在编写组件时,你需要考虑以下两部分数据: ### A. 外部传入的属性 (Props) - **listData**: 原始数据源(通常是一个包含成千上万条数据的数组)。 - **itemHeight**: 每个列表项的高度(固定值,例如 50px)。 - **containerHeight**: 可视区域的高度(例如 600px)。 - **bufferCount** (可选): 缓冲区数量。为了防止滚动过快白屏,在可视区上下额外渲染的数量(建议 3-10)。 ### B. 内部维护的状态 (Internal State) - **startIndex**: 当前可视区域第一条数据的索引。 - **endIndex**: 当前可视区域最后一条数据的索引。 - **startOffset**: 偏移量。用于控制渲染层(List)随滚动条向下平移的距离。 - **visibleData**: 实际被截取并渲染到 DOM 中的数据片段。 --- ## 3. 关键变量与参数 (数学公式) 当用户触发滚动事件(`onScroll`)时,需要实时计算并更新以下状态: ### A. 计算起始索引 (Start Index) 通过滚动距离 `scrollTop` 确定当前第一条数据应该是谁。 - 公式:`floor(scrollTop / itemHeight)`。 ### B. 计算结束索引 (End Index) 在起始索引的基础上,加上可视区域能容纳的数量。 - 公式:`startIndex + visibleCount`。 ### C. 计算偏移量 (Start Offset) 由于列表是滚动容器内的绝对定位,如果不做处理,渲染层会随着滚动条滑出视线。需要给渲染层一个向下的偏移,抵消滚动的位移。 - 公式:`scrollTop - (scrollTop % itemHeight)`。 - 作用:让渲染层始终对齐在当前滚动位置的“格子上”。 --- ## 4. 渲染流程 1. **截取数据**:从原始长数组中,利用 `slice(startIndex, endIndex)` 截取出一小段数据。 2. **更新 DOM**:将截取的数据渲染到“实际渲染层”中。 3. **应用位移**:将计算出的“偏移量”通过 CSS 的 `transform: translate3d` 应用到“实际渲染层”。 --- ## 5. 引入缓冲区的逻辑调整 (Buffer) 为了防止快速滚动时出现白屏,建议引入缓冲区逻辑。这会改变原本的计算流程: ### A. 计算渲染范围 (Render Range) 1. 计算可见起始索引:`startIndex = floor(scrollTop / itemHeight)`。 2. 计算可见结束索引:`endIndex = startIndex + visibleCount`。 3. **计算渲染起始索引**:`renderStart = max(0, startIndex - bufferCount)`。 4. **计算渲染结束索引**:`renderEnd = min(total, endIndex + bufferCount)`。 ### B. 修正偏移量 (Render Offset) **这是实现缓冲区的核心要点**: - 原本的 `startOffset` 是基于 `startIndex` 的。 - 引入缓冲区后,渲染层的偏移量必须改为:`renderOffset = renderStart * itemHeight`。 - **原因**:因为你现在是从 `renderStart` 开始渲染的,渲染层必须定位到这第一个“影子项目”原本所在的位置,否则列表会发生位移跳动。 ### C. 渲染数据 - 使用 `listData.slice(renderStart, renderEnd)` 获取渲染数据。 - 在遍历数据渲染 DOM 时,记得 `key` 仍然需要基于原始索引(即 `renderStart + index`)。 --- ```tsx /// 简单虚拟列表 import { FC, useRef, useState } from "react"; interface VirtualListProps { // 数据 listData: Array<any>; // 配置每个高度 itemHeight: number; // 可视区高度 containerHight: number; // 缓冲区避免白屏 bufferCount: number; } export const VirtualList: FC<VirtualListProps> = ({ listData, itemHeight, containerHight, bufferCount, }) => { const currentRef = useRef<HTMLDivElement>(null); const [start, setStart] = useState(0); const visibleCount = Math.ceil(containerHight / itemHeight); const renderStart = Math.max(0, start - bufferCount); const renderEnd = Math.min( listData.length, start + visibleCount + bufferCount, ); const renderData = listData.slice(renderStart, renderEnd); const offset = renderStart * itemHeight; const handleScroll = () => { const scrollTop = currentRef.current?.scrollTop || 0; setStart(Math.floor(scrollTop / itemHeight)); }; return ( <div ref={currentRef} style={{ width: "100%", height: `${containerHight}px`, overflow: "auto", position: "relative", background: "var(--bg-card)", }} onScroll={handleScroll} > <div style={{ height: `${listData.length * itemHeight}px`, }} ></div> <div style={{ position: "absolute", top: 0, width: "100%", transform: `translate3d(0, ${offset}px, 0)`, }} > {renderData.map((item, index) => ( <div key={item.id} style={{ height: `${itemHeight}px`, background: "var(--bg-card)", }} > <div style=<ruby>marginBottom<rp> (</rp><rt>"8px"</rt><rp>) </rp></ruby>> <span style=<ruby>background<rp> (</rp><rt>"var(--accent-color)", color: "var(--text-on-dark)", padding: "2px 8px", borderRadius: "4px", fontSize: "12px",</rt><rp>) </rp></ruby> > INDEX: {index} </span> </div> {item.content} </div> ))} </div> </div> ); }; ``` ## 6. 动态高度虚拟列表总结实现步骤 1. 定义三层 DOM 结构并设置对应的 CSS。 2. 初始化计算可视区域能容纳多少条数据。 3. 监听容器的滚动事件。 4. 在滚动回调中更新: - 当前的 `startIndex`。 - 当前的 `endOffset`(用于定位渲染层)。 5. 根据 `startIndex` 截取数据并重新渲染。 6. 核心思想:预估高度与物理位置映射 (Estimation & Position Mapping) - 痛点:由于列表项高度不固定,且 10 万条数据不可能一次性渲染到 DOM 中,浏览器无法提前得知每一项的真实像素高度。 - 解决方案:建立一张“位置缓存表 (Positions Cache)”。在真实渲染前,给每一项分配一个“预估高度 (Estimated Height)”(如 50px),并据此初始化所有项的 top(起点)和 bottom(终点)。 2. 三大核心机制 ##### A. 查找机制:二分查找 (Binary Search) - 逻辑:在固定高度下,通过 scrollTop / height 计算 startIndex;但在动态高度下,由于每项高度不一,必须在位置缓存表中寻找第一个 bottom 大于 scrollTop 的项。 - 性能:由于 bottom 是随索引递增的(有序数组),采用二分查找可将查找复杂度从 $O(n)$ 降至 $O(\log n)$。 ##### B. 测量机制:后置测量 (Post-render Measurement) - 逻辑:当项目真正渲染到屏幕上后,利用 useLayoutEffect 钩子配合 getBoundingClientRect().height 获取 DOM 节点的真实物理高度。 - 时机:必须在浏览器绘图前(useLayoutEffect)完成测量,以防止修正位置时出现视觉闪烁(Flicker)。 ##### C. 修正机制:多米诺骨牌效应 (Coordinate Correction) - 逻辑:一旦测得某项(索引为 $i$)的真实高度与其预估高度存在差值($\Delta h$),则该项及其之后的所有项($i+1$ 到 $n$)的物理位置都必须同步平移 $\Delta h$。 - 公式: - item[i].height = realHeight - item[i].bottom = item[i].top + realHeight - item[i+1].top = item[i].bottom... 依此类推。 3. 关键布局参数的计算 - 撑高层高度 (Phantom Height):等于位置缓存表中最后一个元素的 bottom 值,动态撑开滚动条。 - 渲染层偏移 (Start Offset):等于当前可视区域第一个渲染项的 top 值(即 positions[renderStart].top),确保渲染层始终对齐在滚动条对应的逻辑位置。 4. 高级优化与挑战(面试加分项) - 性能陷阱:避免使用 useState 存储 10 万条位置信息(会造成全量 Diff 导致的卡顿),建议使用 useRef 存储位置缓存,仅在 start 索引变化时触发渲染。 - 闪烁处理:确保使用 useLayoutEffect 进行测量。 - 浏览器极限:理解浏览器对单一 DOM 元素的最大高度限制(约 3355 万像素),在百万级数据下需引入“物理高度与逻辑滚动比例映射”方案。 - 滚动抖动:跳转到未测量的深层索引时,由于预估高度与真实高度的误差,滚动条在测量后会发生细微跳动,可通过“滚动差值补偿”逻辑解决。 ```tsx import React, { useState, useRef, useLayoutEffect } from "react"; /** * 【核心哲学:地图索引思想】 * 在固定高度的列表中,靠“算”;在动态高度的列表中,靠“查表”。 * 每一条数据在页面上的【起点、终点、高度】都会被记录在一张“地图”里(即 positions 数组)。 */ // 1. 定义单条数据的原始结构 interface ListItem { id: number | string; // 唯一标识,React 渲染 key 的关键 content: string; // 文本内容,长短不一会导致高度变化 } // 2. 定义组件接收的外部参数 interface DynamicVirtualListProps { listData: ListItem[]; // 10万条原始数据源 estimatedItemHeight: number; // 预估高度:在还没测量出真实高度前,先“猜”一个值(如50px) containerHeight: number; // 可视窗口高度:比如 600px,超过这个高度就出滚动条 bufferCount: number; // 缓冲区:在可视区上下额外多画几条,防止滚动太快看到白屏 } // 3. 【核心数据结构】地图信息 interface Position { index: number; // 索引,对应原始数据的下标 height: number; // 这一项当前的物理高度(初始为预估值,测量后变为真实值) top: number; // 这一项距离【整个列表最顶端】的距离(即起点) bottom: number; // 这一项距离【整个列表最顶端】的距离(即终点,bottom = top + height) } const DynamicVirtualList: React.FC<DynamicVirtualListProps> = ({ listData, estimatedItemHeight = 50, containerHeight = 600, bufferCount = 5, }) => { // 引用最外层的滚动容器 DOM const containerRef = useRef<HTMLDivElement>(null); // 【状态】当前屏幕上能看到的第一个元素的索引 // 只用这个状态来控制重绘,因为滚动时它变了,才意味着需要换一批数据展示 const [start, setStart] = useState(0); /** * 【性能陷阱提示】 * 为什么 positions 用 useRef 而不是 useState? * 因为要频繁修改 10 万个位置信息。如果用 useState,每次修改哪怕一丁点, * React 都会尝试去 Diff 这个巨大的数组,浏览器会瞬间卡死。 * useRef 允许直接修改内存里的数据,而不会惊动 React 的渲染引擎。 */ const positions = useRef<Position[]>([]); /** * 【步骤 1:初始化地图(盲猜阶段)】 * 既然还没开始画,不知道谁高谁矮,就假设大家都是 50px 高。 * 这样就能先算出每个人的 top 和 bottom,把滚动条的长度撑出来。 */ const initPositions = () => { positions.current = listData.map((_, index) => ({ index, height: estimatedItemHeight, top: index * estimatedItemHeight, bottom: (index + 1) * estimatedItemHeight, })); }; // 第一次加载时,如果地图是空的,就去初始化它 if (positions.current.length === 0) { initPositions(); } /** * 【步骤 2:二分查找(高性能检索)】 * 逻辑:用户滚动了 1000px,得立刻知道“地图”里谁跨在了 1000px 这个点上。 * 为什么用二分?:对于 10 万条数据,循环查找要找 10 万次,二分只需要找 17 次左右。 * 目标:找到第一个 bottom 大于 scrollTop 的元素索引。 */ const getStartIndex = (scrollTop: number = 0) => { let left = 0; let right = positions.current.length - 1; let tempIndex = -1; while (left <= right) { // 算出中间位置 const midIndex = Math.floor((left + right) / 2); // 获取中间那个人的底边位置 const midBottom = positions.current[midIndex].bottom; if (midBottom === scrollTop) { // 刚好对齐,那么下一项就是要找的起始项 return midIndex + 1; } else if (midBottom < scrollTop) { // 中间这项还在屏幕上方看不见的地方,往右边找 left = midIndex + 1; } else { // 中间这项的底边已经超过了滚动高度,说明它可能就是起始项 // 但要追求完美,继续向左看还有没有更靠上的项也满足条件 if (tempIndex === -1 || tempIndex > midIndex) { tempIndex = midIndex; } right = midIndex - 1; } } return tempIndex; }; /** * 【步骤 3:滚动响应】 * 每当用户滚动,就去查“地图”,看看 start 索引变了没。 */ const handleScroll = () => { if (!containerRef.current) return; const scrollTop = containerRef.current.scrollTop; // 查地图,获取新的起始索引 const currentStart = getStartIndex(scrollTop); // 【性能优化】只有当索引真的变了(比如从第 5 行滚到了第 6 行),才去更新状态触发 React 重绘 if (currentStart !== start) { setStart(currentStart); } }; /** * 【步骤 4:确定渲染范围】 * 不能只画屏幕里的那几条,得加上缓冲区。 */ // 粗略算下一屏能放几个(用预估高度算) const visibleCount = Math.ceil(containerHeight / estimatedItemHeight); // 向上扩充 bufferCount 条 const renderStart = Math.max(0, start - bufferCount); // 向下扩充 bufferCount 条 const renderEnd = Math.min( listData.length, start + visibleCount + bufferCount, ); // 从 10 万条里精准切割出这一小块(比如只剩下 20 条) const visibleData = listData.slice(renderStart, renderEnd); /** * 【步骤 5:核心——位置修正(多米诺骨牌效应)】 * 当子组件 Item 渲染后,发现自己真实高度是 80 而不是 50,就会调用这个函数。 */ const updatePositions = (index: number, height: number) => { const item = positions.current[index]; if (!item) return; const oldHeight = item.height; const dValue = oldHeight - height; // 预估高度与真实高度的差值(正值代表变矮了,负值代表变高了) // 如果高度真的有变化,那就得改地图了 if (dValue !== 0) { // 1. 先改自己 item.height = height; item.bottom = item.top + height; // 2. 【连环修正】关键:因为变高/矮了,后面所有的邻居都要跟着往下/上挪动位置 // 从当前项的下一项开始,一直修正到 10 万项的末尾 for (let i = index + 1; i < positions.current.length; i++) { // 这一项的起点 = 前一项的终点 positions.current[i].top = positions.current[i - 1].bottom; // 这一项的终点 = 这一项的起点 + 这一项原本的高度 positions.current[i].bottom = positions.current[i].top + positions.current[i].height; } } }; /** * 【步骤 6:物理布局计算】 */ // 【撑高层高度】:整个列表到底有多长?看地图里最后一个人的底边在哪 const phantomHeight = positions.current.length > 0 ? positions.current[positions.current.length - 1].bottom : 0; // 【渲染层偏移】:渲染层必须定位到 renderStart 对应的那一项的 top 位置 // 这样才能保证:无论上面的元素怎么变高变矮,当前的元素都能准确出现在它该出现的位置 const startOffset = positions.current[renderStart] ? positions.current[renderStart].top : 0; return ( <div ref={containerRef} onScroll={handleScroll} style={{ height: `${containerHeight}px`, overflow: "auto", // 产生原生滚动条 position: "relative", // 方便内部元素绝对定位 }} > {/* 【撑高层 Phantom】 它没有任何内容,只是一块透明的“板子”。 它的作用是把外层容器的滚动条撑开,告诉浏览器:这个列表其实有 5 万像素高。 */} <div style={{ height: `${phantomHeight}px`, position: "absolute", top: 0, left: 0, right: 0, zIndex: -1, }} /> {/* 【渲染层 List】 它装着真正看到的 DOM。它必须使用绝对定位。 通过 translate3d 把这一小块内容平移到用户当前的视线范围内。 */} <div style={{ transform: `translate3d(0, ${startOffset}px, 0)`, position: "absolute", top: 0, left: 0, width: "100%", }} > {visibleData.map((item, index) => ( <Item key={item.id} // 必须使用稳定 ID,不能用 index,否则复用 DOM 时高度测量会乱 index={renderStart + index} // 告诉子组件它在 10 万条数据里的真实排名 content={item.content} onSizeChange={updatePositions} // 传给子组件的回调,用于上报真实高度 /> ))} </div> </div> ); }; /** * 【子组件:高度测量仪】 * 每一个 Item 渲染出来后,都要第一时间量一下自己多高。 */ const Item: React.FC<{ index: number; content: string; onSizeChange: (index: number, height: number) => void; }> = ({ index, content, onSizeChange }) => { // 引用当前项的 DOM 节点 const nodeRef = useRef<HTMLDivElement>(null); /** * 【为什么用 useLayoutEffect?】 * 它在 DOM 更新后、浏览器“涂色”前同步执行。 * 如果在里面修正了位置,浏览器会直接画出修正后的结果。 * 如果用 useEffect,用户会先看到旧位置,然后看到闪跳。 */ useLayoutEffect(() => { if (nodeRef.current) { // getBoundingClientRect().height 获取的是带小数的精确像素高度 const height = nodeRef.current.getBoundingClientRect().height; // 测量完毕,上报给父组件去修改“地图” onSizeChange(index, height); } // 依赖项包含内容和索引。只要文字变了,或者这一行被复用来展示别的数据了,就重新测量 }, [content, index, onSizeChange]); return ( <div ref={nodeRef} style=<ruby>padding<rp> (</rp><rt>"16px", borderBottom: "1px solid var(--border-color)", boxSizing: "border-box", background: "var(--bg-card)", wordBreak: "break-all", // 核心:允许长文本换行,从而撑开动态高度</rt><rp>) </rp></ruby> > <div style=<ruby>marginBottom<rp> (</rp><rt>"8px"</rt><rp>) </rp></ruby>> <span style=<ruby>background<rp> (</rp><rt>"var(--accent-color)", color: "var(--text-on-dark)", padding: "2px 8px", borderRadius: "4px", fontSize: "12px",</rt><rp>) </rp></ruby> > INDEX: {index} </span> </div> <div style=<ruby>color<rp> (</rp><rt>"var(--text-main)", lineHeight: "1.5"</rt><rp>) </rp></ruby>>{content}</div> </div> ); }; export default DynamicVirtualList; ``` END 最后修改:2026 年 03 月 23 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 如果觉得我的文章对你有用,请随意赞赏 下一篇 上一篇 发表评论 取消回复 使用cookie技术保留您的个人信息以便您下次快速评论,继续评论表示您已同意该条款 评论 * 私密评论 名称 * 🎲 邮箱 * 地址 发表评论 提交中...