最近在做一个客服工单流的页面一万多条工单记录每张卡片高度还不一样有的只有两行文字有的带图片鼠标悬停上去还要展开一排日志详情。最初我直接用data.map()渲染页面滚动起来像放幻灯片Chrome 直接掉帧。后来换成 React 虚拟列表又从固定行高版本一路改到动态行高中间踩了无数坑最终做出来的方案同时覆盖了动态行高测量、hover 展开详情、精准滚动定位和容器尺寸自适应这几个点。这篇文章就把完整方案的选型思路、核心代码和避坑经验都拆开讲清楚。如果你也在做聊天记录、日志流、动态信息流这类每条数据高矮不一、还要交互的长列表这篇内容可以直接当作参考实现。整套东西不用任何第三方虚拟列表库核心就是自己维护一个位置数组配合ResizeObserver做动态测量代码量不大但理解了之后灵活性非常高。1. 为什么固定行高虚拟列表满足不了真实业务场景网上大部分虚拟列表教程都建立在每行高度固定的基础上核心逻辑只有一个公式startIndex Math.floor(scrollTop / rowHeight)。这种方案在真实业务里基本撑不住我从两个角度说明原因。1.1 从一万条工单卡死页面说起当时我的页面结构大致是这样外层滚动容器里面一个列表每条工单是一张卡片卡片里有标题、摘要、标签、时间。摘要短的时候一行长的时候三四行而且内容里可能会插入截图。直接渲染一万个 DOM 节点Chrome 的 Layout 和 Paint 时间直接飙到几百毫秒滚动时帧率掉到个位数。固定行高的虚拟列表方案我一开始也试过把每行高度写成 96px渲染窗口只显示 20 个 DOM 节点滚动确实流畅了。但问题很快就暴露摘要长一点的卡片文字被截断整个列表出现大块空白区域摘要短的卡片下方又叠着大片空隙。无论行高设成多少总有一部分内容显示不全。这不是调参能解决的因为每行高度是运行时才确定的依赖内容、字体、容器宽度。所以动态行高不是锦上添花而是这类场景的刚需。1.2 固定行高方案的局限固定行高的虚拟列表在计算上确实简单总高度 行数 × 行高起始索引 Math.floor(scrollTop / rowHeight)渲染范围也就是几个乘法。但它的前提是业务数据 规整比如表格、图片墙、音乐列表。一旦数据变成内容驱动高度行高的不确定性就把这套计算全部击穿总高度不再能通过乘法算出来必须逐行累加起始索引不能用除法倒推得在位置数组里做查找某一行高度变化后它后面所有行的位置都要跟着平移这是增量重排问题数据前方插入新行时滚动位置需要重新锚定不然用户视角会跳。这些就是动态行高虚拟列表要解决的核心问题。我把它们拆成四个模块来做useVirtualList负责位置计算与状态维护VsItem负责单行渲染与高度上报scrollToRow负责精准定位容器尺寸的ResizeObserver单独管理。架构层面各司其职后面每个问题都可以独立扩展。1.3 我要实现的整体架构最终组件结构分三层模块职责关键点VsList外层滚动容器渲染 phantom 撑高占位通过transform对可见行做偏移VsItem单行容器挂ResizeObserver上报行高数据索引变化时重新测量useVirtualList核心逻辑位置数组、二分查找、重排、滚动定位状态全部集中在 hook 内数据流是单向的滚动事件 - 更新 scrollTop - 二分查找起始索引 - 渲染窗口内行 -VsItem的ResizeObserver上报真实高度 - 更新位置数组 - 触发一次重渲染。测量和渲染互相驱动形成了闭环。2. 位置数组与动态行高测量虚拟列表从估到准的关键动态行高虚拟列表和固定版本最大的区别就是维护一个positions数组而不是靠公式计算。2.1 先定数据结构positions 如何描述每一行positions数组里每个元素描述一行在列表中的绝对位置interface Position { top: number; // 行顶距离列表顶部的距离 height: number; // 行当前高度可能是预估的也可能是实测的 bottom: number; // 行底距离列表顶部的距离 }整个列表的总高度就是最后一个元素的bottom。因为每个渲染出来的行都通过transform: translateY(top)定位所以top就是这一行在整个列表中的真实 y 坐标。初始化的时候不知道每行真实高度就用一个estimate预估高度填满数组function createPositions(count: number, estimate: number): Position[] { const positions: Position[] []; let top 0; for (let i 0; i count; i) { positions.push({ top, height: estimate, bottom: top estimate }); top estimate; } return positions; }estimate怎么定我一般取业务数据的中位数高度比如工单卡片通常是 80~120px就填 100。预估偏差不要紧后面会被实测值逐步替换但预估越准滚动条拖动的体验越接近最终形态初始渲染的闪烁也越不明显。2.2 行高更新后的增量重排复杂度可控在哪当VsItem用ResizeObserver测到某一行真实高度后要更新positions。如果只是把这个元素自己的height改了那么它后面所有行的top和bottom都会错位。所以更新行高时必须做一次尾部重排function updateHeightAtIndex( positions: Position[], index: number, newHeight: number, totalHeight: { value: number } ) { const pos positions[index]; if (!pos) return; const delta newHeight - pos.height; if (Math.abs(delta) 0.5) return; // 高度差小于 0.5px 忽略掉避免无效重排 pos.height newHeight; pos.bottom delta; totalHeight.value delta; for (let i index 1; i positions.length; i) { positions[i].top delta; positions[i].bottom delta; } }这个 O(n) 的循环看起来吓人但实际运行有个天然边界动态测量只发生在视口内渲染出来的行上也就是说用户正在看的区域附近才会触发高度更新。如果你展开了第 500 行的详情那么重排也就是把第 501 行到最后一行的top都平移一段距离。一万行的列表展开一行循环也只是做一万次浮点加法耗时远小于一次 React 渲染。真正需要小心的是高频触发重排比如鼠标在列表上快速扫过连续 hover 多个卡片导致行高反复变化。我在实现里给重排加了一层节流通过requestAnimationFrame合并同一帧内的多次高度更新尽量减少重复的尾部循环。2.3 起始索引的二分定位与视口范围计算因为每行高度不固定scrollTop对应的起始行索引没法用除法要用二分查找function binarySearchBottom(positions: Position[], target: number): number { let low 0; let high positions.length - 1; while (low high) { const mid (low high) 1; if (positions[mid].bottom target) { low mid 1; } else { high mid; } } return low; }查到起始索引后再配合视口高度找结束索引最后加上overscan缓冲function getRenderRange( positions: Position[], scrollTop: number, viewportHeight: number, overscan: number ) { const start Math.max(0, binarySearchBottom(positions, scrollTop) - overscan); let end binarySearchBottom(positions, scrollTop viewportHeight); end Math.min(positions.length - 1, end overscan); return { start, end }; }overscan我一般设 5 到 8目的是让用户快速滚动时屏幕外附近的行提前渲染和测量避免滚动速度太快出现白屏。渲染层拿到范围后把绝对定位的幽灵容器高度设为totalHeight真实可见行塞在一个相对定位容器里用transform: translateY(positions[start].top)整体偏移。这样每一行的绝对定位就不会互相影响浏览器也能很好地复用合成层。2.4 为什么 positions 放进 ref而用版本号触发渲染很多初次写动态虚拟列表的人会把positions放进useState结果每次行高微调都触发整棵列表的 Diff。这个positions数组本质上是你手动维护的DOM 坐标系统它不属于 React 的渲染状态不该让 React 去代理它的变化。我的做法是positions存useRef每次更新完调用一个版本号自增来触发渲染const [, forceRender] useState(0); const positionsRef useRefPosition[]([]); // 更新完 positions 后 forceRender(v v 1);这样既能让 React 重新读取positions去计算渲染范围又避免了对大数组做引用比较的额外开销。实际跑下来一万行数据、一百个可见行每次版本号变化只 diff 这 100 个VsItem性能压力完全在可接受范围内。3. hover 展开详情状态外置 防抖交互 交给 ResizeObserver 收尾一开始我天真地以为 hover 展开很简单给每行加个onMouseEnter展开详情渲染出来高度自然变化ResizeObserver自动测量不就完了真做起来发现有两个坑一个在状态管理一个在交互防抖。3.1 展开状态为什么绝对不能放进行组件内部如果你把expanded这个布尔值放在VsItem组件内部那么当这一行滚出视口被回收React 重新渲染窗口时会怎么办组件卸载状态丢失。等你再滚回来这一行的展开状态早就没了用户会觉得非常诡异。所以展开状态必须提升到useVirtualList里用一个MaprowId, boolean或者SetrowId持有并且通过useState保存以保证展开时能触发渲染const [expandedIds, setExpandedIds] useStateSetstring(new Set()); function toggleExpanded(id: string) { setExpandedIds(prev { const next new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }渲染VsItem时把isExpanded作为 prop 传下去行组件内部只负责根据这个 prop 渲染详情区。这样行的 DOM 被回收重建时状态依然在 hook 里滚回来展开状态还在。3.2 hover/unhover 防抖以及延迟参数的经验值hover 和 click 不一样鼠标在行上稍微移动一下就会触发 mouseenter/mouseleave如果不做防抖列表会在短时间内疯狂展开收起高度重排全被打满。我的实现是展开延迟 120ms收起延迟 180msconst hoverTimer useRefRecordstring, number({}); function handleRowHover(id: string) { clearTimeout(hoverTimer.current[id]); hoverTimer.current[id] window.setTimeout(() { setExpandedIds(prev { if (prev.has(id)) return prev; const next new Set(prev); next.add(id); return next; }); }, 120); } function handleRowLeave(id: string) { clearTimeout(hoverTimer.current[id]); hoverTimer.current[id] window.setTimeout(() { setExpandedIds(prev { if (!prev.has(id)) return prev; const next new Set(prev); next.delete(id); return next; }); }, 180); }为什么收起延迟比展开长因为用户可能从卡片正文移到详情区域里继续浏览如果收起太快鼠标还没移动到详情上详情就消失了。180ms 的缓冲可以降低这种误操作概率。3.3 展开高度变化如何自然融入测量循环这是整个动态行高设计里我觉得最优雅的地方展开详情时不需要手动去计算展开后会变多高。你只需要把这一行的内容多渲染一段 DOMVsItem上的ResizeObserver会自动检测到高度变化触发updateHeightAtIndex然后从这一行往后做增量重排。function VsItem({ index, position, onMeasure, isExpanded, children, }: VsItemProps) { const ref useRefHTMLDivElement(null); useLayoutEffect(() { const el ref.current; if (!el) return; const ro new ResizeObserver(() { const height el.getBoundingClientRect().height; onMeasure(index, height); }); ro.observe(el); return () ro.disconnect(); }, [index, onMeasure]); return ( div ref{ref} style{{ position: absolute, top: 0, left: 0, right: 0, transform: translateY(${position.top}px), }} {children} /div ); }注意这里的useLayoutEffect而不是useEffect因为要在浏览器绘制之前完成 observer 注册避免首帧出现内容抖动。展开流程变成这样鼠标在行上停留 120msexpandedIds加进这条记录的 id触发重渲染行组件渲染详情 DOM高度变大ResizeObserver回调拿到新高度updateHeightAtIndex更新位置数组并重排后续行下一次渲染时后续行自动下移列表总高度也同步变化。整个过程计算逻辑只有一个高度更新函数不需要额外维护展开行的高度表。4. 精准滚动定位的两种路径与竞态处理标题里的精准滚动定位是另一个容易翻车的需求点。我遇到过两类场景一是外部传入某条工单 id要求滚动到它并高亮二是用户点了返回顶部旁边的一个回到上次位置按钮。这些都要求一个可靠的scrollToRow方法。4.1 scrollToRow 的常规实现目标行已测量时如果目标行已经渲染过并且高度是实测值那么positions[index]里的top就是精确坐标直接设scrollTop就好function scrollToRow(index: number, align: top | center | bottom top) { const el containerRef.current; if (!el) return; const positions positionsRef.current; if (index 0 || index positions.length) return; const target positions[index]; const viewportHeight el.clientHeight; const maxScrollTop Math.max(0, totalHeightRef.current - viewportHeight); let scrollTop target.top; if (align center) { scrollTop target.top - viewportHeight / 2; } else if (align bottom) { scrollTop target.bottom - viewportHeight; } el.scrollTop Math.min(Math.max(0, scrollTop), maxScrollTop); }这个版本的问题在于如果目标行从来没被渲染过它当前的height还是预估高度top是估算出来的直接滚动过去可能差出几十像素。4.2 目标行未测量时先估后校的两次滚动解决思路是先按预估滚过去等目标行真的渲染出来、ResizeObserver测到真实高度后再做一次校准滚动。因为滚过去之后目标行进入视口VsItem会挂上 observer测到的高度一定比预估准。实现里我用一个pendingScrollRef记住目标const pendingScrollRef useRef{ index: number; align: top | center | bottom; } | null(null); function scrollToRow(index: number, align: top | center | bottom top) { const el containerRef.current; const positions positionsRef.current; if (!el || !positions[index]) return; const target positions[index]; const maxScrollTop Math.max(0, totalHeightRef.current - el.clientHeight); let scrollTop target.top; if (align center) scrollTop target.top - el.clientHeight / 2; if (align bottom) scrollTop target.bottom - el.clientHeight; el.scrollTop Math.min(Math.max(0, scrollTop), maxScrollTop); // 如果这一行还没被测量过标记 pending等 onMeasure 时校准 if (target.height positionsRef.current[index].height) { // 这里还需要判断 height 是不是还是初始 estimate 值 // 更严谨的做法是给 Position 加一个 measured 布尔字段 pendingScrollRef.current { index, align }; } }然后在onMeasure回调里加一段校验逻辑function handleMeasure(index: number, height: number) { updateHeightAtIndex(positionsRef.current, index, height, totalHeightRef); const pending pendingScrollRef.current; if (pending pending.index index) { pendingScrollRef.current null; const el containerRef.current; if (!el) return; const target positionsRef.current[index]; const maxScrollTop Math.max(0, totalHeightRef.current - el.clientHeight); let scrollTop target.top; if (pending.align center) scrollTop target.top - el.clientHeight / 2; if (pending.align bottom) scrollTop target.bottom - el.clientHeight; el.scrollTop Math.min(Math.max(0, scrollTop), maxScrollTop); } }注意在Position上加一个布尔标记measured会更严谨否则初始高度如果是estimate 100某一行真实高度恰好也是 100就会漏掉校准。我是直接在接口里补了这个字段的interface Position { top: number; height: number; bottom: number; measured: boolean; }然后在createPositions时全部置为falseupdateHeightAtIndex里置为true。4.3 滚动事件为什么要 rAF 节流虚拟列表的滚动事件触发频率极高一秒几十次。如果每次onScroll都立刻 setState 更新渲染范围React 根本扛不住尤其还有ResizeObserver在旁边排队。我用一个简单的 tick 标记做 rAF 节流const ticking useRef(false); function handleScroll() { if (ticking.current) return; ticking.current true; requestAnimationFrame(() { ticking.current false; updateRenderRange(); }); }这样一帧最多做一次范围计算和渲染视觉上的滚动是完全连续感知不到的但性能会好非常多。实测原来每帧触发三次 React 渲染的卡顿列表改成 rAF 节流后稳定在 60fps。4.4 往前插入数据时如何锚定当前视口精准滚动定位还有一个容易被忽略的场景当用户滚到列表中间前端从接口拿到更早的数据要把新数据prepend到列表头部。此时如果直接更新data由于positions整体重建同样的scrollTop指向的行已经不是用户刚才看的那条了视口内容会跳变。解决思路是锚定当前视口内第一条可见数据的索引function prependData(newItems: Item[]) { const el containerRef.current; if (!el) return; const scrollTop el.scrollTop; const positions positionsRef.current; const oldFirstVisibleIndex binarySearchBottom(positions, scrollTop); const offset scrollTop - positions[oldFirstVisibleIndex].top; // 更新数据positions 整体重置为预估高度 setData(prev [...newItems, ...prev]); // 等数据渲染后把滚动位置恢复到锚定的那一行 queueMicrotask(() { const newPos positionsRef.current[oldFirstVisibleIndex newItems.length]; if (newPos) { el.scrollTop newPos.top offset; } else { el.scrollTop 0; } }); }之所以用queueMicrotask而不是直接算是因为setData触发重渲染是在 React 调度之后直接读positionsRef.current时可能还没重建。放到微任务里至少在一次渲染周期结束后执行更稳。实际项目中我还会结合加载完后保持锚定行可见的状态这里就不展开太多了。5. 容器尺寸自适应宽高变化要区别对待自适应容器尺寸听起来简单但很多实现把宽度和高度变化混在一起处理导致宽度一变整个列表高度全错位。5.1 用 ResizeObserver 监听容器而不是 window resizewindow.resize只能监听浏览器窗口变化如果页面里有可拖拽分隔栏、折叠面板、侧边栏容器宽度变化并不会触发 window resize。所以正确做法是直接观察容器 DOMuseLayoutEffect(() { const el containerRef.current; if (!el) return; const ro new ResizeObserver(() { const rect el.getBoundingClientRect(); // 区分宽度和高度分别处理 onContainerSizeChange(rect.width, rect.height); }); ro.observe(el); return () ro.disconnect(); }, [containerRef]);注意ResizeObserver在观察同一个元素时只要宽高任一变化就会回调所以回调里必须自己判断到底是宽度变了还是高度变了。5.2 宽度变化刷新全部行高的原因为什么宽度变化要把positions全部重置因为每行的高度是内容 宽度布局出来的。同一段文字在 800px 宽的容器里是两行在 600px 宽的容器里可能就是三行高度从 80px 变成 120px。这种变化是全局性的不是某一行单独的问题。如果不重置positions还是基于旧宽度算出来的新宽度下所有行都渲染到了错误的位置列表会出现大段重叠或空白。所以宽度变化时的处理是记录当前视口内第一条可见数据索引作为锚点用预估高度重建整个positions保持scrollTop然后重新渲染视口内的行陆续被ResizeObserver测量位置逐步校正回真实值。function resetPositionsForWidthChange(estimate: number) { const el containerRef.current; const positions positionsRef.current; if (!el || positions.length 0) return; const scrollTop el.scrollTop; const anchorIndex binarySearchBottom(positions, scrollTop); const anchorOffset scrollTop - positions[anchorIndex].top; positionsRef.current createPositions(positions.length, estimate); // 尽量维持锚点行的滚动位置 requestAnimationFrame(() { const el containerRef.current; if (!el) return; const newAnchor positionsRef.current[anchorIndex]; el.scrollTop newAnchor.top anchorOffset; }); }5.3 视口高度变化只影响渲染范围视口高度变化时已经测量过的每行高度并不会有影响只是当前视口内能容纳的行数变了。所以只需要更新viewportHeight再触发一次渲染范围重算即可不需要重置positions。function handleViewportHeightChange(height: number) { setViewportHeight(height); updateRenderRange(); }这个区分很关键。如果视口高度变化也走一遍重置 positions的逻辑就会白白丢掉所有已测量的行高整个列表会因为重测而闪动一次。我一开始没分开处理页面从窗口最大化切到半屏时列表总高度在预估值和实测值之间反复横跳视觉上就是上下抖动分开处理后这个问题彻底消失。6. 落地过程中遇到的性能和状态踩坑记录最后把我在实际项目中踩过、并且修复过的几个坑整理出来按严重程度排序。这些坑如果不在最初就规避后期排查会非常耗时。6.1 行组件内写死展开高度第一次展开就串位有同事接手后优化了一版在详情模板里写死了一个固定高度220px想省掉ResizeObserver的测量步骤。结果字体大小变化、详情内容多一行少一行整个列表错位得乱七八糟。原因是详情高度根本不是定值它随内容、宽度、字号变化。只要行高可能变化就一定要交给真实测量任何拍脑袋写死的做法都不可靠。这是这个方案里最核心的原则行高永远以测量值为准预估高度只用于初始化。6.2 ResizeObserver 回调风暴VsItem的 observer 在首次渲染时会全部触发一次回调如果视口内恰好有 60 个可见行就是 60 次onMeasure。如果每次回调立刻更新positions并调用版本号自增React 会在同一帧里被连续触发多次渲染这就是回调风暴。我的解决方式是给高度更新加个缓冲const batchUpdate useRefnumber | null(null); function requestHeightUpdate(index: number, height: number) { if (batchUpdate.current ! null) return; batchUpdate.current window.requestAnimationFrame(() { batchUpdate.current null; // 把这一帧内积压的所有高度变化统一处理 flushHeightUpdates(); }); }用 rAF 把同一帧内的更新合成一次要么不渲染要么一帧只渲染一次。6.3 清理 observer 和 ref 生命周期VsItem卸载时一定要ro.disconnect()否则大量不可见的行虽然从 DOM 卸载了但 observer 还活着内存会持续增长在长列表滚动场景能明显看到浏览器内存曲线一路上涨。还要注意containerRef本身可能在页面切换时销毁useVirtualList的所有useLayoutEffect清理函数里都要清掉 tick 标记和 pending 定时器避免在已卸载组件上 setState 的警告。6.4 小列表场景虚拟化的降级策略虚拟列表并不是任何时候都划算。当列表总高度小于视口高度时渲染全部行是更简单的选择也避免了positions初始化和测量带来的额外开销。我加了一个简单的降级判断const shouldVirtualize data.length RENDER_ALL_THRESHOLD;比如数据量小于 200 或总预估高度小于视口高度时直接全量渲染。这样既保住了大量列表的性能也不会在小列表上引入不必要的复杂度。if (!shouldVirtualize) { return ( div ref{containerRef} classNamevs-scroller {data.map((item, index) renderItem(item, index))} /div ); }顺带一提renderItem我设计成渲染回调而不是把整个行组件都写死在VsList里这样外部业务可以自由定制卡片样式和交互虚拟列表只负责位置和测量。最后说点个人维护体会整套方案做下来最值钱的部分不是写出来了而是知道哪些地方不能用投机取巧的方案替代。行高测量是这套系统的地基所有精准滚动、hover 展开、自适应都建立在真实测量之上。如果你也想在自己的项目里实现类似能力建议按这个顺序落地先跑通固定行高的虚拟列表再把动态测量接进去最后才做展开和滚动定位。基础稳了后面的交互都是往这个闭环里加需求而不是反复推翻重写。