React 长列表虚拟滚动实战:手写可视区渲染,10 万条数据不卡顿
React 长列表虚拟滚动实战:手写可视区渲染,10 万条数据不卡顿后台管理系统里经常遇到这种需求:一次性拉回来几万条日志、几万个 SKU,产品说不想分页,就要滚动加载全部。你老老实实list.map()渲染出来,浏览器直接卡死——10 万个 DOM 节点,首屏白屏两秒,滚动时掉帧到个位数。问题的根源很简单:用户视口里只能看到十几行,但你把 10 万个节点全塞进了 DOM。虚拟滚动(virtual scrolling)的思路就是——只渲染可视区内的那几十条,滚动时动态替换。这篇我们不套第三方库,手写一个定高虚拟列表,把原理彻底讲透。朴素写法:先看看它怎么卡的function PlainList({ items }) { return ( div style{{ height: 600, overflow: auto }} {items.map((item) ( div key{item.id} style{{ height: 40 }} {item.text} /div ))} /div ); } // items 有 100000 条时,React 要 diff 十万个 fiber 节点, // 首次挂载卡顿,之后每次滚动触发的重排也拖垮帧率。打开 DevTools 的 Performance 面板录一段,你会看到 Scripting 和 Rendering 两条长得吓人。DOM 节点数直接爆到十万级。核心思路:算出该渲染哪几条定高虚拟列表的数学非常直白。假设每行高度固定itemHeight,容器高度containerHeight,当前滚动距离scrollTop:总高度 items.length * itemHeight,用一个撑高的占位 div 保证滚动条长度正确。可视区第一条的索引 Math.floor(scrollTop / itemHeight)。可视区能放下的行数 Math.ceil(containerHeight / itemHeight)。把这一段切出来,用transform: translateY推到正确的位置。关键点:不要用margin-top或改变元素高度来定位,用transform——transform 不触发重排(reflow),只走合成层,滚动才跟手。正确写法:一个可用的 VirtualListimport { useState, useRef, useMemo } from react; function VirtualList({ items, itemHeight 40, containerHeight 600 }) { const [scrollTop, setScrollTop] useState(0); const containerRef useRef(null); // 多渲染几条做缓冲,避免快速滚动时上下边缘露白 const overscan 3; const { startIndex, endIndex, offsetY } useMemo(() { const visibleCount Math.ceil(containerHeight / itemHeight); const start Math.floor(scrollTop / itemHeight); // 上下各多切 overscan 条,并夹住边界 const startWithBuffer Math.max(0, start - overscan); const endWithBuffer Math.min( items.length, start visibleCount overscan ); return { startIndex: startWithBuffer, endIndex: endWithBuffer, // 偏移量必须按实际渲染的第一条算,否则内容会错位 offsetY: startWithBuffer * itemHeight, }; }, [scrollTop, itemHeight, containerHeight, items.length]); const visibleItems items.slice(startIndex, endIndex); const totalHeight items.length * itemHeight; return ( div ref{containerRef} style{{ height: containerHeight, overflow: auto, position: relative }} onScroll{(e) setScrollTop(e.currentTarget.scrollTop)} {/* 占位层:撑出真实滚动高度,让滚动条比例正确 */} div style{{ height: totalHeight }} {/* 内容层:用 transform 推到可视区,不触发重排 */} div style{{ transform: translateY(${offsetY}px) }} {visibleItems.map((item) ( div key{item.id} style{{ height: itemHeight }} {item.text} /div ))} /div /div /div ); }用 10 万条数据测:DOM 节点常驻只有二十几个(可视 缓冲),首屏瞬开,滚动稳定 60fps。const items Array.from({ length: 100000 }, (_, i) ({ id: i, text: 第 ${i} 行数据, })); VirtualList items{items} /;坑一:offsetY 一定要按缓冲后的起点算新手最容易写错的一行:offsetY start * itemHeight,用的是没加 overscan 的start。结果就是往上滚动时内容整体错位overscan * itemHeight像素,列表抖一下。记住——你 slice 从哪条开始,translateY 就得推到那条的位置,两者必须用同一个索引。坑二:onScroll 高频触发要不要节流onScroll每帧可能触发多次,直觉上想throttle。但这里恰恰不要节流:setScrollTop触发的重渲染已经被 React 批处理,而且我们只改了少量 DOM。真正该做的是把滚动状态更新交给浏览器的渲染节奏——如果你发现掉帧,用requestAnimationFrame包一层,而不是用定时器节流(定时器会让内容滞后于滚动条,露白更明显)。const rafId useRef(null); const handleScroll (e) { const top e.currentTarget.scrollTop; if (rafId.current) return; // 一帧内只更新一次 rafId.current requestAnimationFrame(() { setScrollTop(top); rafId.current null; }); };坑三:不定高怎么办上面的方案要求每行等高。真实业务里很多是不定高(比如带图片、可折叠的评论)。定高方案直接失效,因为你算不出scrollTop对应第几条。这时候的通用做法是动态测量 位置缓存:先用估算高度渲染,挂载后用ResizeObserver量出真实高度,存进一个Mapindex, height,再据此累加算偏移。工程量陡增,这也是为什么生产环境不定高场景直接上tanstack/react-virtual或react-window——它们把测量、缓存、滚动锚定都处理好了。定高场景手写完全够用,不定高才值得引库。小结长列表卡顿的本质是DOM 节点太多,虚拟滚动只渲染可视区 缓冲的几十条。定高列表三件套:撑高的占位层(保证滚动条比例)、translateY 定位的内容层(不触发重排)、按滚动距离算出的切片区间。offsetY必须和slice用同一个起点索引,否则内容错位。滚动更新别用定时器节流,要么直接 setState 让 React 批处理,要么用requestAnimationFrame对齐渲染帧。一句话记忆:用户只看得到一屏,你就只渲染一屏——剩下的用高度撑着、用 transform 骗过去。