首页 / 资讯中心 / 文章详情

虚拟列表原理与实现:固定高度模型从零搭建高性能滚动列表

虚拟列表原理与实现:固定高度模型从零搭建高性能滚动列表 ★ FEATURED ARTICLE
1. 为什么非用虚拟列表不可渲染巨量数据前的最后一道防线做前端的人迟早会遇到一个经典场景后端一次性给你甩了 10000 条甚至 100000 条数据要求在页面上做成列表展示。第一次遇到的人多半直接map一把梭结果浏览器立刻教做人——滚动卡顿、内存暴涨、页面白屏十几秒运气差一点直接掉进无响应状态用户刷新了两次就转投竞品了。这里有一个很多人容易忽略的事实卡顿的根源不是数据量大而是 DOM 节点数量失控。浏览器构建布局树、样式计算、绘制合成每一个环节的耗时都跟节点规模强相关。10000 条数据渲染成 10000 个真实 DOM 节点哪怕每个节点极其轻量它们的累计消耗也足以让主线程忙不过来。虚拟列表的思路就是釜底抽薪——不管底层有多少数据我只往页面上渲染用户当前看得见的那几十个节点用一个小窗口去窥探整个大数据集。对于固定数据这个前提虚拟列表的实现难度会大幅下调。数据总量一开始就确定不涉及无限滚动加载更多这意味着我们不需要处理数据还没到齐列表中途变长的动态边界。配合固定高度这个更严格的条件——每条列表项高度一致——连渲染位置的精确计算都能简化成一道小学除法。这类需求在真实业务里极其常见操作日志、交易流水、成员列表、批量资源管理页数据量稳定、行高统一正是虚拟列表最适合落地的场景。固定高度模型之所以受青睐是因为它有一个很优雅的性质只要知道滚动的偏移量任何人都能精准算出当前屏幕应该显示哪几条。不会有需要实时测量行高、逐行累计偏移的麻烦整个计算链路稳定可控。那些说虚拟列表很难的人多半是直接上手了不定高度的复杂方案但从固定高度起步把核心机制吃透之后再去做动态高度反而会轻松得多。1.1 先搞清楚虚拟列表到底解决什么问题再说一遍本质虚拟列表解决问题的方式是用占位换滚动条用子集换全量。外层是一个设置了固定高度、overflow: auto的滚动容器用户滚动的其实是这个容器。容器里面先放一个高度等于总条数 × 每条高度的占位元素这个元素不包含真实内容纯粹为了让浏览器算出正确的滚动条比例它决定了你还能不能滚、滚动范围是多大。然后在占位元素内部通过绝对定位或者transform把一个“窗口”移动到对应的滚动位置窗口内只渲染当前可视区域需要的那些列表项再加上一小段缓冲区。这个设计的精妙之处在于无论数据是 100 条还是 100 万条真实渲染的 DOM 数量始终维持在一个固定的小数字上。把 10 万条数据的滚动条做出来让用户感觉上在浏览一个超长列表实际上每一帧浏览器都在处理几十个节点——这就是虚拟列表的全部魔法。理解了这个模型后面所有代码都只是它的具体表达。1.2 固定数据、固定高度模型的选型舒适区固定数据、固定高度这八个字是虚拟列表世界里的简单模式。数据固定意味着不需要考虑列表尾部动态追加、总数随时变化的场景一次渲染边界恒定。高度固定意味着每个列表项在垂直方向上占据的空间完全一致所以任何一条数据对应的垂直偏移都能通过索引 × 行高直接算出来。相比之下动态高度方案需要测量每一条数据的真实高度、按累计值构建区间表、处理高度变化时缓存失效的问题复杂度不是一个量级。选择固定高度模型还有一个隐性收益计算性能足够做逐帧优化。因为换算关系是一元一次方程我们可以很从容地在滚动回调里做startIndex Math.floor(scrollTop / itemHeight)这样的计算每次滚动只触发一次setState不会给 React 带来额外负担。我自己在业务里的经验是很多看似需要高级方案的需求其实砍掉花哨的交互、统一行高之后用固定高度模型就能稳得一批。1.3 核心计算链路scrollTop 如何决定渲染区间假设列表总数据量是total每条高度是itemHeight可视区高度是viewportHeight。那么一个完整的渲染区间计算只有三步总高度 total × itemHeight startIndex Math.floor(scrollTop / itemHeight) endIndex Math.ceil((scrollTop viewportHeight) / itemHeight)startIndex决定从哪一条开始渲染endIndex决定渲染到哪一条结束。窗口内的列表项全部用绝对定位放在占位容器内整体通过transform: translateY(startIndex × itemHeight)下移到正确位置这样每一帧视觉位置和滚动位置都是严格对齐的。缓冲区overscan的加入则是为了让上下边缘快速滚过时也不至于白屏这个后面细说。这个模型有一个非常容易验证正确性的方法假设scrollTop 0那么渲染的一定是从第 0 条开始假设把滚动条拖到底部那么渲染的一定是最后几条。沿着这条链路检查代码逻辑基本不会跑偏。2. 从零搭建固定高度虚拟列表核心实现拆解理论基础摆好了现在直接进入实现。我会用一个最小可运行的 React 组件来拆解不依赖任何第三方库只用useState、useRef、useEffect和原生 DOM 事件。理解这套实现之后换成思维模型完全一致的成熟库都只是一行命令的事。2.1 基础结构容器、占位层、绝对定位可视区三个层次必须先搭出来外层滚动容器一个固定height、overflow-y: auto的 div它是所有滚动事件的来源。占位层一个高度为total * itemHeight的普通 div不渲染任何列表内容只为撑起滚动条。可视窗口层放在占位层内部的 div绝对定位或者用transform移动里面放实际渲染的列表项。很多第一次写的同学会漏掉占位层直接把列表项铺满整个容器结果发现滚动条根本不出来虚拟化自然也就无从谈起。占位层的存在是整个方案的地基丢了它后面一切都是空中楼阁。实际代码里我会把三个层叠出这样的结构div classNameviewport ref{containerRef} onScroll{handleScroll} div style{{ height: totalHeight, position: relative }} div style{{ transform: translateY(${offsetY}px) }} {renderItems} /div /div /div2.2 数据切片与渲染区间计算渲染区间由滚动位置实时驱动。定义一个renderRangestate里面存startIndex和endIndex每次滚动事件触发时就重新计算一次。在固定高度的前提下计算过程不需要遍历任何数据纯粹是数值运算const [range, setRange] useState({ startIndex: 0, endIndex: 0 }); const calculateRange (scrollTop, viewportHeight) { const startIndex Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); const endIndex Math.min( data.length, Math.ceil((scrollTop viewportHeight) / itemHeight) overscan ); return { startIndex, endIndex }; };这里有一个细节值得强调startIndex要往前扣掉overscanendIndex要往后加上overscan这个缓冲区是虚拟列表手感的关键。如果不加当你以较快速度滚动时新露出来的那一批列表项还没来得及渲染用户会看到明显的白屏闪烁加上了却能保证每次渲染的列表比可视区多出一截为快速滚动留出供 React 渲染的时间。缓冲区的大小一般取可视区高度 / itemHeight的 1/3 或者 1/2具体可以根据实际滚动体验微调。2.3 React Hooks 实现useState useRef 的协作细节真正在 React 里做的时候有一个惯性陷阱每次滚动都把scrollTop存进 state。这其实没有必要还会带来多余的渲染。滚动事件里只有“渲染区间变化”这件事值得触发 React 更新滚动偏移本身可以在事件处理函数里直接计算后立刻读出不需要进入 React 的更新循环。我的做法是维护一个scrollTopRef用 ref 存放最新的滚动位置仅在需要更新渲染区间时才setRange。这样每次滚动事件里如果发现新的区间和旧区间完全一致就什么都不做React 不会白白执行一次render。注意滚动的偏移、容器的尺寸这些“布局数据”本质上都属于 DOM 的即时状态用 ref 访问是更贴合浏览器运行时的做法那些最终会反映到页面 UI 上的数据比如渲染区间才应该交给 state 管理。const containerRef useRef(null); const scrollTopRef useRef(0); const [range, setRange] useState({ startIndex: 0, endIndex: INITIAL_RANGE }); const handleScroll () { const container containerRef.current; if (!container) return; const newScrollTop container.scrollTop; scrollTopRef.current newScrollTop; const nextRange calculateRange(newScrollTop, container.clientHeight); setRange((prevRange) prevRange.startIndex nextRange.startIndex prevRange.endIndex nextRange.endIndex ? prevRange : nextRange ); };这里我把calculateRange里已经包含了clientHeight作为可视区高度。每一个细节都不能省容器高度直接影响endIndex如果container.clientHeight在初始渲染时拿不到比如容器还没挂载需要加一层判断或用一个useEffect在挂载后补充初始区间。整个组件的渲染函数也非常直白先从range里取出区间然后从原始数据数组里切开对应的一段逐一渲染列表项const renderedItems data .slice(range.startIndex, range.endIndex) .map((item, index) ( div key{item.id} style{{ position: absolute, top: 0, left: 0, width: 100%, height: itemHeight, transform: translateY(${(range.startIndex index) * itemHeight}px), }} {/* 列表项的内容 */} /div ));用absolute逐条定位有两层考量第一列表项的绝对定位和占位层彼此独立不会因为内容高度意外撑大布局导致原本设定好的高度等式被打破第二translateY的位移是纯 GPU 合成操作比修改top属性更平滑。每条数据渲染时的高度必须严格等于itemHeight任何一条超过就会导致后续所有项目位置错乱这个约束在固定高度模型里是必须遵守的底线。我在实际项目里会把data、itemHeight、overscan这些值封装成配置项传入一个统一的VirtualList组件。使用方只需要关心列表项渲染函数renderItem内部逻辑完全黑盒。这样团队里其他人接手时不需要了解虚拟列表内部细节只需要遵守行的内容高度必须等于 itemHeight这一个约定交付效率会高很多。3. 性能打磨滚动丝滑背后的几个关键实践一个能跑起来的虚拟列表和手感极佳的虚拟列表差别就在性能细节上。这一节我把自己在项目里踩过的性能问题和对应的处理手段整理出来这些都是常规文档里不会写的东西。3.1 使用 rAF 合并滚动事件避免 setState 风暴浏览器的滚动事件触发频率远高于帧率在部分设备上一次快速滚动可能连续触发几十上百次回调。如果每次回调都setStateReact 会在一个事件循环里被反复调用 render即使渲染的内容不多也会造成主线程堵塞滚动手感变得黏滞。正确的做法是给滚动事件加一层按帧合并。浏览器每渲染一帧理想情况下是 16.6ms我们只需要在这一帧里执行一次状态更新。我的惯用方案是维护一个requestAnimationFrame令牌const rafIdRef useRef(null); const handleScroll () { if (rafIdRef.current) return; rafIdRef.current requestAnimationFrame(() { const container containerRef.current; if (container) { const nextRange calculateRange(container.scrollTop, container.clientHeight); setRange((prevRange) prevRange.startIndex nextRange.startIndex prevRange.endIndex nextRange.endIndex ? prevRange : nextRange ); } rafIdRef.current null; }); };核心逻辑是如果当前帧已经有一个 RAF 在等待中后续滚动事件直接跳过RAF 执行完之后无论这一帧里来了多少次滚动都只会触发一次渲染区间计算。Rendering 和 scroll 位置通过 ref 同步读取不会存在读到过期值的问题。要注意在组件卸载时取消 RAF否则会有不必要的内存泄漏useEffect(() { return () { if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current); } }; }, []);把这段代码套上之后滚动手感会有肉眼可见的提升尤其在大数据量快速滚动的场景下卡顿和掉帧明显减少。3.2 缓冲区 overscan 的取舍前面提到过overscan是渲染范围在可视区基础上外扩的行数。这个值并非越大越好需要平衡两个反面缓冲区太小快速滚动时会看到短暂的白屏区域缓冲区太大渲染的 DOM 数量变多又回到了性能浪费的老路上。我一般这样定初始值overscan等于可视区内能显示的列表行数的一半。假设可视区高度 800px、行高 40px能显示 20 行那么overscan取 10 行。再根据实际体验微调如果滚动时白屏多就往上加如果卡顿明显就往下减。这个参数可以直接暴露成 props方便不同业务页面自行调节。还有一个很多人忽略的点overscan应该在startIndex和endIndex两端同时生效而不是只加在 end 侧。向下滚动时需要提前渲染下方内容向上滚动时也同理需要提前渲染上方内容否则往回滚时照样会出现空白闪烁。3.3 translate3d 与合成层优化方向虚拟列表的每一次滚动渲染出来的列表项其实都在通过位移跟随滚动位置。位移操作选择transform比修改top或scrollTop之外的布局属性更高效因为transform不会触发 layout 和 paint只触发合成。如果列表项中包含大量图片、阴影、渐变等绘制成本较高的内容把位移写成translate3d(0, y, 0)可以主动提示浏览器把该元素提升到合成层让位移完全在 GPU 上完成。不过无脑给每个元素加translate3d并不是一个好习惯。合成层也是内存资源大量元素各自独立合成反而会拖垮低端设备。更合理的做法是给可视窗口那一层加transform: translate3d(0, ${offsetY}px, 0)让所有列表项作为一个整体被位移。这时候列表项内部再出现相对位移依然会流畅得多。在列表项内容本身非常重、比如有视频或者高分辨率图时可以考虑使用content-visibility: auto做进一步的渲染跳过优化。但这个属性对虚拟列表来说属于锦上添花对固定高度模型影响不大真正决定性能上限的还是渲染了多少个 DOM这件事本身。4. 实战踩坑与渲染健壮性细节虚拟列表看着简单真正拿到业务场景里去用会有不少看起来不明显、一踩一个准的坑。这里直接把我在多个项目里遇到的高频问题整理成速查表配合排查思路和解决办法。4.1 滚动条宽度差异导致的可视区误判一个隐蔽的坑container.clientHeight不包含滚动条的宽度但container.scrollHeight计算滚动范围时也不受滚动条影响。不同浏览器、不同系统下滚动条宽度不同在某些场景下会出现内容高度计算没错但滚动条没有占满容器的视觉偏差。更现实的坑是外容器设置了overflow-y: scroll之后即使内容不超过高度滚动条也会占据一部分宽度。如果列表项内部宽度是按 100% 计算的那么实际内容宽度会小于容器内容区宽度导致布局轻微偏移。这个问题的解法很简单给列表项或者内部内容加上box-sizing: border-box并预留合理的内边距或者直接隐藏滚动条样式用覆盖式滚动条方案规避宽度差异。排查方法也简单打开开发者工具把列表项背景色设成半透明观察背景色是否覆盖了整个可视区。如果右侧有一条底色透出基本可以确定是滚动条宽度把内容挤窄了。4.2 为什么 scrollTop 取整和浮点误差会带来字幕抖动固定高度模型里scrollTop和itemHeight在部分浏览器上是浮点数。当scrollTop不是itemHeight的整数倍时startIndex向下取整、offsetY用startIndex * itemHeight计算本该和容器内真实滚动位置严格对齐。但Math.floor和浮点乘法的误差会在某些值上造成 0.5px 甚至 1px 的偏移于是快速滚动时列表项表现出细微的抖动。解决思路是允许一个小容差计算startIndex时先做四舍五入或者直接加 0.5 再取整然后让offsetY跟随scrollTop而不是跟随startIndex。最稳的策略是窗口层的位移直接用scrollTop本身而列表项里的每一条内容高度保持严格一致这样视觉上始终贴合容器的滚动位置。如果必须用startIndex计算位移那么至少保证itemHeight是整数并且列表项不使用会产生子像素偏移的 CSS 属性。这个问题的典型表现特别迷惑人列表不闪不白屏但滚起来文字像是有一层微弱的呼吸感。排查时可以把overscan调到 0如果抖动依旧问题基本就在浮点计算上。4.3 数据量突变、刷新、回到顶部等边界场景处理固定数据模型虽然不涉及动态加载但数据量在初始渲染后发生变化依然会出现在一些业务里筛选条件变更、列表刷新、重置。这时候容易出现三类问题数据变短之后当前的scrollTop超过了新的滚动范围上限浏览器会自动 clamp但窗口位移没有同步更新导致短暂的白屏。数据清空后组件内部还保留着旧的range渲染出越界索引直接报错。列表数据刷新为全新数据集时没有重置滚动位置用户看到的是列表中间位置的内容而不是第一屏。我的处理习惯是把data的变化当成切页信号useEffect(() { const container containerRef.current; if (container) { container.scrollTop 0; } scrollTopRef.current 0; setRange({ startIndex: 0, endIndex: INITIAL_RANGE }); }, [data]);这个 effect 把滚动位置、ref 里的缓存、渲染区间三者一次性重置避免了状态不一致的隐患。还有一种更稳的做法是让父组件在关键操作后用key重新挂载整个虚拟列表组件从根上杜绝脏状态残留。两种方案根据业务复杂度选简单场景用前者复杂场景直接提升 key 的粒度。4.4 移动端与桌面端的差异化注意点移动端虚拟列表有一个桌面端没有的明显差异滚动的驱动方式不同。桌面端鼠标滚轮每格滚动距离可预期移动端手指滑动则更依赖惯性平滑而且部分浏览器没有scroll事件的默认防抖机制事件频率会高得离谱。这意味着移动端更需要 rAF 合并滚动事件否则低端手机会直接在滚动时卡死。另外移动端的viewport高度变化频繁地址栏收起、键盘弹出、横竖屏切换都会改变可视区高度。固定高度模型里endIndex依赖container.clientHeight如果可视区高度变了而endIndex还是旧的就会导致底部列表渲染不出来。严谨的做法是监听resize事件并重新计算当前区间虚拟列表里的可视区高度变化本来就是常态不只在移动端需要桌面端折叠侧栏、窗口缩放也一样。还有一个容易忽略的头疼问题移动端点击延迟和列表项的触摸反馈。虚拟列表里的列表项虽然数量少但快速滑动的过程中,点击事件可能被浏览器判定为滚动手势而取消。需要精确点击时可以使用pointerup事件配合移动距离判断或者干脆依赖系统自带的 click 延迟处理不要额外封装复杂的手势库。| 问题现象 | 根因 | 解决思路 | | --------------------------- | --------------------------- | ---------------------------------------------- | | 快速滚动白屏 | overscan 过小或缺失 | 调大 overscan或确保两端都加缓冲区 | | 滚动卡顿、CPU 占用高 | 滚动事件未按帧合并 | 使用 rAF 合并滚动回调 | | 列表项文字抖动 | scrollTop 浮点误差 | 位移统一跟随 scrollTop保持 itemHeight 为整数 | | 滚动条比例不对 | 占位层高度计算错误 | 检查 total 与 itemHeight 的乘积 | | 数据刷新后位置错乱 | 状态未重置 | 重置 scrollTop、range或提升 key 强制重挂载 | | 底部渲染不全 | clientHeight 变化未处理 | 监听 resize 并重新计算区间 |5. 固定高度模型的边界与下一步能做什么固定高度虚拟列表已经能覆盖大量真实业务场景但它有一道绕不开的门槛列表项高度一旦不确定所有基于高度一致的简化算法都会失效。比如文章摘要列表、评论列表、服务卡片这些内容高度天然不同强行统一行高要么导致内容被裁切要么浪费大量空白区域。动态高度虚拟列表的主流思路是估算 实测 缓存。先用一个估算高度算出初始位置渲染后通过真实 DOM 的offsetHeight刷新实际高度缓存索引与高度的累计表。滚动位置由累计表换算而来而不是直接乘一个常量。实测之后你会发现动态高度方案的计算和缓存复杂度明显提升但核心的分区渲染思想没有变化依然是把可视区映射到数据切片上。如果项目里不愿手写动态方案也可以考虑成熟的第三方库比如react-window和react-virtualized。react-window定位更轻量API 干净适合新项目react-virtualized能力更全面但体积偏大。两种库对固定高度的处理方式本质和我上面写的完全一致把这一篇的内容吃透之后读这些库的源码是一件很轻松的事情。固定数据的场景还有个隐藏红利因为不涉及异步加载和动态追加虚拟列表组件可以做成纯函数组件稳定性极高只要输入的数据和行高不变渲染结果永远可预期。我在实际项目里把列表操作都收敛到一个VirtualList组件之后后续需求迭代很少再碰这块代码省下来的维护精力比预想多得多。最后分享一个我自己的习惯虚拟列表上线前我一定会用六倍速快速滚动测一遍然后打开性能面板观察长任务和帧率曲线。只要长任务数量少、每帧耗时平均基本可以放心交付。这个度量的标准其实不复杂——用户感知到的丝滑其实就是主线程每一帧都能有充足的时间处理滚动和渲染虚拟列表做的一切优化本质上都在守护这个预算。
阅读完成 · 觉得有帮助?
咨询建站