恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

React高性能虚拟列表实战:动态行高、精准定位与容器自适应

  • 首页
  • 资讯中心
  • /
  • React高性能虚拟列表实战:动态行高、精准定位与容器自适应

相关资讯

VSCode 插件装多了启动慢?TaoToken 这样配进 Codex 排查 Python/Java/Go 扩展 2026/9/20 0:19:37
Windows亮度调节全攻略:从快捷键到DDC/CI,解决滑块灰掉、失灵问题 2026/9/20 0:19:37
领域无关对比表示下的标签比例学习:SelfCLR-LLP 在 Criteo 与 MovieLens-1M 上的完整实战指南 2026/9/20 0:14:37

最新资讯

Spark 数据倾斜治理:提升大数据处理性能的实用技术方案
Spark RDD 血统与容错:Lineage、Checkpoint、Cache/Persist 的选择与性能权衡
安全运维应急演练全流程解析:从预案设计到Linux入侵排查实战
医院网络安全自查实操指南:从资产梳理到整改闭环
Windows 11数字许可证失效?四种方法教你搞定系统激活
2026来宾电气检测机构排名 TOP5 CMA 资质机构提供防爆设备检测+防爆安全检测 联系方式推荐

今日推荐

BrewUI:给Homebrew套上图形界面,让macOS软件包管理更简单
BrewUI:让Homebrew包管理变得可视化与高效
公式与文本对齐全攻略:从Word到LaTeX的实用技巧

本周热门

BrewUI:给Homebrew套上图形界面,让macOS软件包管理更简单
BrewUI:让Homebrew包管理变得可视化与高效
公式与文本对齐全攻略:从Word到LaTeX的实用技巧

本月精选

自研推理加速器Redwood:两周内实现PyTorch模型高效部署的实战教程
V4L2摄像头采集实战:从camera_client.rar到出图全流程解析
从“谁发明了钢琴键”到知识问答智能体:RAG与记忆工程实践

React高性能虚拟列表实战:动态行高、精准定位与容器自适应

发布时间:2026/9/20 0:19:37
React高性能虚拟列表实战:动态行高、精准定位与容器自适应 虚拟列表在技术社区里算是个老话题网上随便一搜就是 FixedSizeList 的例子。但真实项目里的场景远没有这么简单上万条动态数据行高不固定鼠标 hover 到某一行要展开详情区域展开后下面所有行都要跟着让位用户还可能从列表顶部直接跳到任意一条窗口拉伸后宽度变化还会导致换行和行高集体变化。这些需求单拆开都不算难组合在一起就是一场对缓存机制、测量时机和渲染策略的连环考验。这篇文章就围绕 React 实现高性能虚拟列表这件事把动态行高、Hover 展开详情、精准滚动定位、自适应容器尺寸四个能力逐个拆开讲清楚。适合正在做长列表、日志流、feed 流、聊天记录页面的同学也适合面试前想把这个高频考点彻底吃透的人。我不打算给一个只能跑 demo 的玩具组件而是尽量贴合真实项目的约束把每一步为什么这么做讲明白。1. 需求拆解这个需求到底难在哪1.1 为什么是虚拟列表而不是分页在大多数中后台页面里优化大数据量列表的第一反应是分页或基础滚动加载。但有些场景天然不适合分页日志系统要连续滚动查看聊天记录要定位到某条历史消息监控面板要走马灯一样快速浏览几千条报警。分页会打断浏览节奏而且列表还要支持上下自由滚动、跳到指定位置、hover 展开详情这些交互虚拟列表才是顺手的方案。虚拟列表的核心思路很朴素只渲染用户看得见的那些行。浏览器同一时刻只能显示一个窗口大小的内容页面里其余部分都处于视口外把它们全部铺进 DOM 既费内存又拖慢合成。React 在 reconciliation 阶段要 diff 的节点数量也随 DOM 增多而上升虚拟列表把实际渲染的行数从列表总长降到一屏容量这是它性能收益的根源。不过虚拟列表有一个天然障碍滚动条的高度得靠内容总高度撑起来。这就引出占位层的概念用一个高度等于全部行累计高度的 div 把滚动条撑住再让真实渲染的行通过 absolute 定位叠在对应的滚动位置上。这个模型固定行高时很简单行高一变就麻烦了。1.2 动态行高为什么是麻烦制造者固定行高的虚拟列表核心逻辑用一个公式就能算完startIndex floor(scrollTop / rowHeight)endIndex ceil((scrollTop viewportHeight) / rowHeight)totalHeight rowHeight * data.length。所有排序、滚动、定位都建立在每行都一样高这个前提上。可现实数据几乎都是不规则行高标题一行还是两行取决于字数图片的宽高比不固定hover 展开详情后行高瞬间长出一截。一旦行高成为变量上面的公式全部失效你面临两个选择一是给每行预估一个高度但预估永远不等于实际滚动过程中会出现行位置跳动二是在渲染后量出真实高度并缓存但量出来的时机、失效的触发、累计高度的维护都成了新的复杂度来源。动态行高的难点一直不在测量本身而在于测量结果与滚动状态之间的耦合。你想量得准就得等 DOM 渲染完但 DOM 渲染又依赖 scrollTop 计算出来的渲染范围而 scrollTop 又依赖累计高度这三者形成了一个循环依赖。打破这个循环需要在预估和实测之间不断校准这也是整篇文章的核心主线。1.3 四个需求放在一起的连锁反应把标题里的四个需求看成一个整体会发现它们不是独立功能而是环环相扣的连锁反应。Hover 展开详情是动态行高的主要来源。鼠标移上去行内多渲染一段详情 DOM行高变大移开详情卸载行高又缩回去。每一次展开折叠都意味着一次高度修正下面的行会被推下去或拉上来。精准滚动定位依赖一份可靠的累计高度表。如果每行高度都是已知的固定值定位就是一个乘法如果行高动态变化你必须维护一份第 i 行渲染到哪个位置的索引表并能在 O(log n) 时间内查到目标行。自适应容器尺寸则是压垮固定缓存的最后一根稻草。容器宽度一变文字换行位置就变之前缓存的所有行高全部失效视口高度一变当前可见区域的行数跟着变。ResizeObserver 是必要的补充但缓存失效策略必须提前设计好否则轻轻一拉窗口列表就乱了。2. 整体设计先把渲染模型定下来2.1 占位层 绝对定位的渲染模型我自己习惯把虚拟列表的渲染模型拆成三层外层滚动容器、占位层、真实渲染层。容器负责监听 scroll 和尺寸变化占位层是一个总高度等于所有行累计高度的空 div它的作用只是把滚动条撑出来真实渲染层使用 absolute 定位absolute top 的值等于该行在占位层中的偏移量。这种模型的好处是真实渲染层与占位层完全解耦。占位层只知道整体多高真实渲染层只知道每一行从哪里开始到哪里结束。无论行高怎么变占位层的高度更新和真实行的位置更新互不干扰避免了一个小变化导致整棵列表树重排。以我自己开发过的日志流组件为例容器高度 800px单行预估 40px未加缓冲区时一屏内只渲染 20 行。但是当用户按住滚轮快速滑动时浏览器一帧内可能滚动 300px 到 500px如果只渲染当前可见范围就必然出现上一批行刚卸载、下一批行还没挂载的白屏空窗。所以我实现的渲染模型一定是带缓冲区的——可视区上下各多渲染出一段一般取 1~2 屏的量。缓冲区本身不复杂但有一个容易被忽略的地方缓冲区的大小直接影响测量准确性。滚动到某一行时如果它已经被缓冲区提前渲染出来了那么它的真实高度早就通过 useLayoutEffect 测量并缓存了后续滚动定位用的是缓存结果精确度很高。反过来如果缓冲区太小行只有真正进入可视区时才第一次渲染测量结果就要等到下一帧才能反馈滚动和定位就会有一帧的延迟。2.2 预估 实测 缓存校准动态行高的三件套动态行高的处理我归纳成三个词预估、实测、缓存校准。预估指首次遇到某一行时在没有真实数据前先给一个默认高度。这个默认值挺关键设得太小大部分行的实际高度大于预估每次测量都要把后续行高度往下推滚动时容易出现越滚越抖设得太大占位层初始高度虚高滚动条一开始就不准。经验值是取当前数据样本中位数高度的 1.1 倍左右宁可稍微高一点也别太矮。中位数比平均值更抗偏态分布比如 1000 条日志里有 980 条短记录、20 条超长堆栈平均值会被堆栈拉得很高中位数才更贴近大多数行的真实高度。实测指渲染完成后通过 ref 拿到该行 DOM读取 offsetHeight 作为真实高度。读取的时机我坚持用 useLayoutEffect因为它在浏览器绘制前同步执行能避免用户看到旧高度和滚动条长度不一致的闪烁。useEffect 会晚一帧滚动快速时很容易出现先是白条然后才校正的视觉抖动。这个差别在本地开发模式下不明显打包上线后、尤其是复杂列表页面上效果差异会非常清晰。缓存校准指把实测结果回写到缓存表中并同步更新偏移量。具体做法是维护两个数组heights 记录每行实测高度offsets 记录每行累计偏移量。当第 i 行高度从旧值变成新值时只需要把 offsets 中 i1 及之后的所有项都加上差值 delta不需要重新遍历整个列表。这个增量更新的思路很关键——它会决定滚动过程中每帧的重新计算成本。2.3 缓存失效策略宽度一变全线推倒缓存设计最容易被忽视的是失效策略。行高的缓存与容器宽度强相关同样一条数据宽屏可能一行放得下窄屏要折成三行。如果不区分宽度那么在窄屏下测量的高度到宽屏下会被直接复用所有行位置错乱且很难排查。我的做法是把缓存按照宽度分组cacheByWidth new Mapnumber, HeightOffsetPair()。ResizeObserver 检测到宽度变化时先判断新宽度是否已在缓存中没有就在这个宽度维度下重新开始预估 → 实测 → 校准循环。这样做的好处是窗口反复拉伸时各宽度下的测量结果都能复用不会出现拉一次就重新量一次的卡顿。视口高度变化则只影响可见行数不影响行高本身因此只需要更新 viewportHeight不需要清空缓存。这两类变化的处理差异在实际项目中很容易被忽略值得在代码里通过注释明确区分。我见过不少线上故障debug 到最后发现都是窗口拉伸把缓存清空了、然后窄屏缓存和宽屏缓存混在一起用导致的。3. 实操实现核心组件一步步搭出来3.1 组件骨架和基础滚动逻辑下面代码基本是一个可运行的骨架重点是如何组织状态和缓存。我故意把代码精简到刚好覆盖核心逻辑完整业务代码一定比这长但核心机制就这些东西。import React, { useState, useRef, useMemo, useLayoutEffect, useCallback } from react; interface VirtualListPropsT { data: T[]; height?: number; estimatedItemHeight?: number; bufferCount?: number; renderRow: (item: T, index: number, expanded: boolean) React.ReactNode; } interface HeightCache { heights: number[]; offsets: number[]; } export function VirtualListT({ data, height 600, estimatedItemHeight 80, bufferCount 2, renderRow, }: VirtualListPropsT) { const containerRef useRefHTMLDivElement(null); const [scrollTop, setScrollTop] useState(0); const [viewportSize, setViewportSize] useState({ height, width: 0 }); const [version, setVersion] useState(0); const [expandedIndex, setExpandedIndex] useState(-1); const pendingScrollRef useRefnumber | null(null); // 宽度是缓存的分组 key const cacheWidthRef useRef(0); const cacheRef useRefHeightCache({ heights: [], offsets: [0] }); const getCache useCallback(() cacheRef.current, []); const baseline Math.round(estimatedItemHeight * 1.1); const totalHeight useMemo(() { const { heights, offsets } getCache(); // 已测量的行用缓存未测量的行用预估 let sum 0; for (let i 0; i data.length; i) { const h heights[i] ?? baseline; sum h; } // offsets 数组可能被截断这里用 sum 作为总高度 void offsets; return sum; }, [data.length, baseline, getCache, version]);这里有个细节值得说明totalHeight 的 useMemo 依赖必须包含 version这样每次测量更新后 totalHeight 才会重算。很多初写虚拟列表的同学会漏掉这一步导致行高明明变了滚动条总高度却不变。我习惯把 version 当作缓存版本号任何一次测量写入都让它自增驱动所有依赖缓存结果的计算重新执行。然后计算出渲染范围const startIndex Math.max(0, Math.floor(scrollTop / baseline) - bufferCount); const endIndex Math.min( data.length - 1, Math.ceil((scrollTop viewportSize.height) / baseline) bufferCount ); const visibleItems: { index: number; top: number }[] []; for (let i startIndex; i endIndex; i) { // 尽可能利用缓存中的精确 offset没有则用累计预估 const { offsets, heights } getCache(); let top offsets[i]; if (top undefined) { top 0; for (let j 0; j i; j) { top heights[j] ?? baseline; } } visibleItems.push({ index: i, top }); }这里我做了两步优化一是用 Math.floor/ceil 先按预估高度粗分片保证渲染范围不会因为个别行超高而漏掉二是计算 top 时优先读缓存 offsets读不到再从 0 开始累计预估高度。实际运行时绝大多数滚动场景都能命中缓存 offsets只有当用户一次性滚过很长距离、或者首次进入从未渲染过的区域时才会走累计预估的兜底分支。滚动事件绑定和容器尺寸监听放在一个 useEffect 里useEffect(() { const el containerRef.current; if (!el) return; const handleScroll () { setScrollTop(el.scrollTop); // 滚动时如果上一次 hover 行已经滚出视口自动收起详情 setExpandedIndex(-1); }; const resizeObserver new ResizeObserver((entries) { for (const entry of entries) { const { width, height: contentHeight } entry.contentRect; setViewportSize((prev) { if (Math.abs(prev.width - width) 1 Math.abs(prev.height - contentHeight) 1) { return prev; } // 宽度变化导致换行变化缓存全部失效 if (Math.abs(prev.width - width) 1) { cacheRef.current { heights: [], offsets: [0] }; } return { height: contentHeight, width }; }); } }); resizeObserver.observe(el); el.addEventListener(scroll, handleScroll, { passive: true }); return () { resizeObserver.disconnect(); el.removeEventListener(scroll, handleScroll); }; }, []);上面这段把滚动监听和 ResizeObserver 放在一起看起来简单但有一个意图要强调为什么宽窄变化要重置缓存而高矮变化不重置因为宽度的变化会改变每一行内部的换行布局本来一行能放下的长文本宽度缩一半就变成三四行行高自然完全不同而高度变化只是让可视区域变大变小每一行自身的布局没有受影响所以缓存可以继续用。这也是我在 2.3 节里反复强调的核心区别。3.2 动态行高测量与偏移量更新测量环节是整篇文章最核心的部分我单独拆出来讲。先看在真实代码里如何把渲染范围和测量反馈串起来。const handleHeightChange useCallback( (index: number, height: number) { const cache cacheRef.current; const prev cache.heights[index]; // 高度没变不需要触发重渲染 if (prev height) return; cache.heights[index] height; const delta height - (prev ?? baseline); const offsets cache.offsets; // 把 index 之后的所有 offset 都加上 delta for (let i index 1; i offsets.length i data.length; i) { offsets[i] (offsets[i] ?? 0) delta; } // 如果下一项还没有 offset就基于当前行高度补上 if (offsets[index 1] undefined index 1 data.length) { offsets[index 1] (offsets[index] ?? 0) height; } setVersion((v) v 1); }, [baseline, data.length] ); const renderInner () { const { heights } getCache(); return visibleItems.map(({ index, top }) { const item data[index]; const expanded expandedIndex index; return ( RowWrapper key{item.id} index{index} top{top} onHeightChange{handleHeightChange} onMouseEnter{() setExpandedIndex(index)} onMouseLeave{() setExpandedIndex(-1)} {renderRow(item, index, expanded)} /RowWrapper ); }); };RowWrapper 是一个独立的 memo 组件它的职责非常单一把传进来的内容包在一个绝对定位的 div 里渲染完成后用 useLayoutEffect 测高度并上报。const RowWrapper React.memo( function RowWrapper({ index, top, children, onHeightChange, onMouseEnter, onMouseLeave, }: { index: number; top: number; children: React.ReactNode; onHeightChange: (index: number, height: number) void; onMouseEnter: () void; onMouseLeave: () void; }) { const ref useRefHTMLDivElement(null); useLayoutEffect(() { if (ref.current) { const h ref.current.offsetHeight; onHeightChange(index, h); } }); return ( div ref{ref} style{{ position: absolute, top, left: 0, right: 0, willChange: transform }} onMouseEnter{onMouseEnter} onMouseLeave{onMouseLeave} {children} /div ); }, (prev, next) prev.index next.index prev.top next.top prev.children next.children );这个组件里有两处心血来潮但实测有效的设计第一个是 useLayoutEffect 没有写依赖数组意味着每次渲染完成后都会执行测量。很多人会担心这样是否太频繁实际上正是这种每次渲染后都测量的机制才能捕捉到 hover 展开、图片加载等引起的行高变化。第二个是 memo 的比较函数里对比了 children 引用所以只有当行内容真正变化时才会触发重渲染否则连 RowWrapper 都不会重新执行性能开销被压到很低。3.3 Hover 展开详情动态高度的主战场Hover 展开详情功能在列表组件里需要维护一个 expandedIndex 状态这个我在骨架代码里已经写进去了。当鼠标进入某一行时设置 expandedIndex离开时设回 -1。行内容里根据这个状态决定是否渲染详情区域。一行从收起变成展开内部多了一段 DOMoffsetHeight 自然变大。RowWrapper 的 useLayoutEffect 会在同一帧内测到新高度并通过 handleHeightChange 更新 offsets下面所有行的位置都会被推下去。这个过程是实时同步的用户感知是展开瞬间列表像瀑布一样往下让位体验非常自然。我踩过的一个大坑是onMouseLeave 立刻收起详情会导致鼠标在行内经过一个小 gap 时就触发收起比如详情区里有两个区块中间有 4px 间距鼠标经过间距时行高突然缩回去。处理办法是给收起动作加 100ms 延迟同时记录一个 timerconst leaveTimerRef useRefnumber | null(null); const handleMouseLeave useCallback(() { if (leaveTimerRef.current) window.clearTimeout(leaveTimerRef.current); leaveTimerRef.current window.setTimeout(() { setExpandedIndex(-1); }, 100); }, []); const handleMouseEnter useCallback((index: number) { if (leaveTimerRef.current) { window.clearTimeout(leaveTimerRef.current); leaveTimerRef.current null; } setExpandedIndex(index); }, []);这个延迟不会让用户觉得操作肉因为 100ms 刚好小于人类反应时间但又能避免快速滑过时反复展开收起的抖动。实际项目中如果详情区很重可以调到 150ms如果详情区很轻50ms 就够了。另一个必须处理的问题是滚动时 hover 行的状态。当用户滚动列表时原来的 hover 行可能已经滚出视口再次滚回来时鼠标并不在它上面如果还保持展开状态就很奇怪。所以我在 3.1 的 handleScroll 里加了一句 setExpandedIndex(-1)滚动一开始就收起所有展开项。这是产品交互上的细节但它直接影响用户对动态行高的感受——如果不收起滚动时展开行会顶着列表反复测量位置滚动的流畅度会明显下降。3.4 精准滚动定位从估位到校准scrollToIndex 定位有两种常见场景一种是从列表外部跳转到某条数据另一种是 hover 展开详情后用户点击查看更多需要让当前内容完整可见。第一步都是算出目标行的顶部偏移量直接设置容器 scrollTop。如果偏移量已经缓存直接读取即可。对于没有测量过的目标行我用预估高度计算一个粗略位置然后在渲染完成后的 useLayoutEffect 里做一次校准。具体做法是把 targetIndex 存进 ref渲染后比较实际 offset 与预设 scrollTop 的差值再通过一次 requestAnimationFrame 调整 scrollTop。const scrollToIndex useCallback( (index: number) { const target Math.max(0, Math.min(index, data.length - 1)); const targetTop getOffset(target); const el containerRef.current; if (el) el.scrollTop targetTop; pendingScrollRef.current target; }, [data.length, getOffset] );这里的 getOffset 需要单独实现。它应该优先查缓存 offsets查不到就从最后一个已知 offset 处继续推算const getOffset useCallback( (index: number): number { const cache cacheRef.current; const known cache.offsets[index]; if (known ! undefined) return known; // 找到最后一个已知 offset, 继续累加 let i cache.offsets.length - 1; let top cache.offsets[i] ?? 0; while (i index) { top cache.heights[i] ?? baseline; i; } return top; }, [baseline] );二次校准逻辑通过一个 useEffect 或者 useLayoutEffect 挂在容器上。我选 useLayoutEffect因为它能在绘制前完成纠正用户感知不到跳变。useLayoutEffect(() { const pending pendingScrollRef.current; if (pending null) return; const actualTop getOffset(pending); const el containerRef.current; if (el Math.abs(el.scrollTop - actualTop) 2) { el.scrollTop actualTop; } // 无论是否需要校准都清掉 pending pendingScrollRef.current null; }, [version, getOffset]);这里我对 2px 这个容差比较满意。如果每次渲染都去纠正 0.x 像素的误差反而可能在滚动过程中引发细微抖动。定位本身不需要像素级精确误差在几个像素以内用户完全感知不到但循环纠错的成本是实打实的。当目标行位于列表末尾时直接把 scrollTop 设成目标行的顶部偏移往往会让最后几行被容器底部遮住。解决的思路是判断目标行底部是否超出可视区范围如果超出就改为让目标行底部对齐容器底部。这里也需要把 hover 展开后的实际行高考虑进去因为展开后的行高很可能超过一屏高度需要计算目标行顶部的期望对齐位置 目标行底部偏移 - 视口高度来反向推导 scrollTop。const scrollToIndexAligned useCallback( (index: number, align: start | end start) { const target Math.max(0, Math.min(index, data.length - 1)); const targetTop getOffset(target); const targetBottom targetTop (cacheRef.current.heights[target] ?? baseline); const el containerRef.current; if (!el) return; if (align end) { el.scrollTop Math.max(0, targetBottom - el.clientHeight); } else { el.scrollTop targetTop; } pendingScrollRef.current target; }, [data.length, getOffset, baseline] );这个 align 参数在聊天记录回到底部、加载更多后回到原来位置这些场景里非常有用。聊天记录场景尤其特殊——回到底部不需要精确到某一行而是希望滚动条停在列表末尾让用户看到最新消息这时 scrollToIndexAligned(lastIndex, end) 正好合适。3.5 自适应容器尺寸ResizeObserver 的正确用法自适应容器尺寸用 ResizeObserver 监听容器本身的宽高变化代码在 3.1 里已经出现过。这里我想再补充两处容易出错的细节。第一处是 ResizeObserver 的回调会同时触发宽高变化但不要在一个 setViewportSize 里同时重置缓存和更新视口尺寸。正确做法是单独判断const handleResize (width: number, contentHeight: number) { setViewportSize((prev) { if (Math.abs(prev.width - width) 1 Math.abs(prev.height - contentHeight) 1) { return prev; } if (Math.abs(prev.width - width) 1) { // 宽度确实变了缓存失效 cacheRef.current { heights: [], offsets: [0] }; } return { height: contentHeight, width }; }); };这段逻辑里有一个隐藏细节缓存失效后我还需要把 pendingScrollRef 清掉否则一个正在等待校准的滚动定位会被失效的缓存带偏。这个 bug 排查了很久才定位到现象是窗口拉伸后点击跳转第一次跳到一个错误位置第二次才跳对。原因是第一次校准用的 offsets 已经因为宽度变化被重置了。第二处是 resize 触发的重渲染和滚动事件触发的重渲染需要合并。我在 handleScroll 里只有 setScrollTop在 handleResize 里只有 setViewportSize 和 reset cache这两类状态变化合并到一次 setState 里交给 React 批处理不需要额外的 debounce。实测在慢速拉伸窗口时帧率能稳定在 50~60fps没有明显的卡顿感。自适应容器还有一个边界情况当容器宽度从 1000px 拉伸到 1200px又立刻缩回 1000px如果缓存只在初始化时建一次那么 1000px 宽度下的测量结果其实还在重新利用它反而更快。这就是 2.3 里 cacheByWidth 按宽度分组的价值。实现时我建议把 cacheRef 从单个对象改成 Mapkey 是宽度取整后的值value 是 { heights, offsets }。这样宽度反复变化时能在有限个宽度档位之间来回切换而不是每次都从零开始测量。3.6 性能优化少渲染、少测量、少重排把功能做完只是第一步真正的虚拟列表还要扛住上千行数据下的流畅滚动。我总结了三项投入产出比最高的优化。第一项是行组件的 memo。每一行用 React.memo 包裹只有当该行的数据、hover 状态、渲染位置发生变化时才重新渲染。需要注意的是 renderRow 通常是内联函数每次父组件渲染都会生成新引用这会直接绕过 memo。解决办法是把 renderRow 通过 useCallback 稳定下来或者干脆让父组件传入一个 RowComponent 类型。我在实际项目里更倾向后者因为内联函数在多次滚动中会不断创建新闭包就算 memo 生效children 引用一换memo 也会失效。第二项是合并测量更新。前面提过把一次渲染周期里的多次高度变化合并成一次批量更新可以有效减少 React 的提交次数。实现上可以在 handleHeightChange 里把变化的 index 收集到一个 pendingHeights 数组然后用 requestAnimationFrame 统一处理const pendingHeightsRef useRefMapnumber, number(new Map()); const handleHeightChange useCallback((index: number, height: number) { pendingHeightsRef.current.set(index, height); if (rafRef.current null) { rafRef.current requestAnimationFrame(flushHeights); } }, []); const flushHeights useCallback(() { rafRef.current null; const pending pendingHeightsRef.current; pendingHeightsRef.current new Map(); const cache cacheRef.current; let changed false; pending.forEach((height, index) { const prev cache.heights[index]; if (prev height) return; const delta height - (prev ?? baseline); cache.heights[index] height; for (let i index 1; i cache.offsets.length; i) { cache.offsets[i] (cache.offsets[i] ?? 0) delta; } changed true; }); if (changed) setVersion((v) v 1); }, [baseline]);这个改动看起来多了一些代码但它把一次滚动帧内的 N 次高度上报压缩成一次 rAF 内的一次批量更新。实测当一屏同时出现 20 行首次测量时合并前 React 要 commit 20 次合并后只有 1 次 diff 和 commit滚动的帧率稳定性和内存 GC 压力都会明显改善。第三项是避免不必要的内联样式。每一行的 left/right/top 如果写成内联样式React 每次都要比较对象引用。虽然这点开销不算大但在每秒滚动几十帧的场景下会被放大。更稳的做法是把行位置放进样式对象时做好缓存或者用 transform: translateY(offset) 代替 top把布局计算交给合成器减少重排成本。我给的示例代码里同时保留了 top 和 willChange这是为了兼容某些需要真实布局位置的情况如果纯粹为了性能可以改成 transform。4. 常见问题与排查技巧实录4.1 白屏闪烁和行位置跳动白屏闪烁大多数是因为缓冲区太小滚动速度一快新一屏的行还没来得及挂载就滑入了视口。把 bufferCount 从 1 加到 3 基本能解决。如果还闪就要检查测量本身当行高是异步更新时浏览器可能先绘制了旧位置再绘制新位置视觉上就是闪。用 useLayoutEffect 同步测量、同步更新可以让渲染和测量发生在同一帧从根上消除这种错位。行位置跳动则大概率是缓存的失效没有处理好。最常见的情况是一行内容在滚动中发生了变化比如图片加载完成、文案截断逻辑变化但 heights 缓存没有同步更新。排查时可以在渲染的行上临时输出 index offsetHeight对比是否和缓存一致很快就能定位是哪一行出了问题。我遇到过一次特别隐蔽的跳动展开详情后将一个异步请求的结果 setState 进数据数组导致该行内容增加但 RowWrapper 的 useLayoutEffect 没有捕获到变化因为该行已经在 DOM 里、高度已经上报过了。后来我在 RowWrapper 里给 useLayoutEffect 加了无依赖数组每次渲染都重新测量这个 bug 就消失了。这个经验让我总结出一个原则只要行内容可能变化测量就不能只依赖挂载或卸载事件必须依赖每次渲染完成这个时机。4.2 Hover 展开后滚动条长度异常展开详情后如果滚动条总长度没有明显变化几乎可以断定是总高度公式里漏掉了展开行的高度增量。检查点有两个一是 offsets 数组是否从这个 index 1 开始更新二是 totalHeight 是否始终等于 offsets 数组的最后一个值。这两个值本质上应该一致如果出现偏差说明要么总高度用了旧数据的 length要么卷动偏移更新时漏掉了一些中间行。我排查这类问题的方法很笨但有效在控制台打印 offsets[lastIndex] 和 totalHeight找到偏差出现前最后一次正确的 index然后人工模拟它高度变化的那一次更新看看循环有没有漏项、delta 的符号有没有搞反。90% 的 bug 都是这两个原因。还有一种情况hover 展开时行高变大但 offsets 数组长度不够导致后续行位置没有同步更新。我前面代码里用offsets[i 1] (offsets[i] ?? 0) height来兜底就是为了防止这种越界。4.3 scrollToIndex 定位不准或跳过头定位不准主要有三种原因目标行尚未测量你用的是预估高度做计算实测量出来和预估差太多hover 展开的行没有参与偏移量更新导致目标行之上的真实高度少算了一块二次校准的条件写错导致滚到了错误位置之后没有纠正回来。我的建议是不要把二次校准做得太激进。第一次定位用预估高度就好渲染完成后在 useLayoutEffect 里检查目标行实际 top 与预期的差值超过 2px 才纠正一次。多次纠正之间留出至少一个 rAF 间隔让浏览器有足够时间处理滚动事件避免纠正-触发滚动-再纠正的循环。如果目标是末行还有一个常见的误解以为设 scrollTop 为 totalHeight 就能让最后一行贴底。实际上浏览器 scrollTop 的最大值等于 scrollHeight - clientHeight设得超过这个值会被自动钳制导致最后一行底部被容器底部遮住。所以我在 3.4 里专门实现了 scrollToIndexAligned 的 end 模式先把目标行底部算出来再减去容器可视高度得到一个真正能让行底部贴底的 scrollTop。4.4 容器尺寸变化后列表错乱窗口拉伸后行高错乱八成是缓存没有按宽度分组。如果只做了 cacheByWidth 而没做容差会出现另一个问题连续拉伸窗口的每一帧都触发缓存失效列表在拖动过程中持续抖动。容差阈值我这里用了 1px实际项目里可以根据列表文字密度调整越密的列表对宽度变化越敏感阈值要适当调小。我实际测过一个典型场景容器宽度 800px每行是一段长文本窄屏下最多折 5 行。当窗口以正常速度拉伸时宽度每帧变化 2~3px如果阈值设为 1px差不多每两帧就要重置一次缓存行位置一直处于重新测量状态视觉上就是列表在抖。把阈值调到 4~8px 后直到宽度累积变化到阈值才触发一次重置抖动完全消失感知精度几乎无损。还有一点经验竖屏应用的宽度变化通常伴随着移动端软键盘弹出弹起键盘弹出后可视高度骤减也会触发 ResizeObserver。如果列表在键盘弹出时乱了多半是高度变化也被错误地当成宽度变化处理了——高度变化只是视口变小行高不变不能清掉宽度缓存。4.5 高频问题速查表现象根因处理建议快速滚动白屏缓冲区不足将 bufferCount 提升到 2~3 屏滚动时行位置抖动测量发生在 useEffect 晚帧改用 useLayoutEffecthover 展开后滚动条长度不变偏移量更新遗漏 index 之后的行检查 offsets 循环是否从 index1 开始定位到末尾行被遮挡没有考虑目标行底部是否溢出视口按底部溢出情况反向计算 scrollTop窗口拉伸后行错乱缓存未按宽度分组用 cacheByWidth 阈值容差详情区图片加载后行高突变只在挂载时测了一次对展开行用无依赖数组的 useLayoutEffect 持续测量渲染行数很多但依旧卡每行 memo 失效稳定 renderRow 引用并包裹 React.memo首次跳转到未渲染区域定位不准目标行尚未测量采用预估 二次校准超 2px 才纠正5. 最后再分享一点体会这个功能的开发过程中我最强烈的一个感受是虚拟列表的性能瓶颈从来不在 React 本身而在你对 DOM 测量时机和缓存失效边界的掌控。React 的渲染管线已经把 diff 优化得很好了真正让长列表卡顿的是那些被重复执行的大规模 offset 循环、被忽略的异步高度变化还有那些看似无关紧要的宽高变化。我建议在收尾前一定要做一件事把列表的真实数据喂进组件在 Performance 面板里录一段 10 秒的快速滚动重点盯住 Layout、Recalculate Style、Scripting 这三项耗时。如果 Layout 和 Scripting 的耗时相近说明测量和重算逻辑还有优化空间如果某一行展开详情时掉帧严重优先检查展开后的详情区域是不是渲染了过多惰性内容。另外想说的是上面这套实现有一个天然的后续扩展方向列表项内部还嵌着二级列表。一级虚拟列表套二级虚拟列表时缓存模型要改成树状结构offets 数组要按父子关系维度维护测量时机也会更复杂。很多长列表产品最终都会遇到这种嵌套问题建议在写第一版的时候就预留好 cache 的分组能力后面扩展会轻松很多。好用的虚拟列表组件不是写一次就完事的它需要在真实数据、真实交互、真实容器环境下反复打磨。这篇内容更偏向把这些打磨过程中会踩的坑提前标记出来希望它能帮你少走一段弯路。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号