欢迎来到 DOM 的地狱与天堂:React 大规模虚拟滚动的高级艺术
各位前端同仁,下午好!
把手里的咖啡放下,深呼吸。我知道,你们中的很多人现在正盯着屏幕上那个转了又转、停了又转、永远停不下来的加载圈圈,内心在咆哮:“为什么我的列表只有 100 条数据,渲染出来却像个瘫痪的老人家?”
今天,我们不谈什么“Hello World”,不谈什么简单的 map 循环。今天,我们要聊聊一个让无数初学者脱发、让资深架构师抓狂,但一旦掌握就能让你在面试中傲视群雄(或者至少能保住你的发际线)的高级话题——React 虚拟滚动。
特别是那种嵌套结构复杂、数据异步加载、大小不一的“核弹级”长列表。
准备好了吗?我们要开始解剖浏览器了。
第一部分:为什么你的浏览器要“吐”出来?
首先,我们要明白一个残酷的事实:DOM 节点不是免费的。它们是昂贵的。
想象一下,你有一个包含 10,000 条数据的列表。如果你用普通的 div 把它们全部渲染在页面上,你的 DOM 树里会有 10,000 个节点。这就像是让你的浏览器去背 10,000 块石头。
当你滚动鼠标滚轮时,浏览器需要做的事情是:
- 计算布局:重新计算这 10,000 个
div的位置、宽高、边距。 - 重绘:把这些
div画在屏幕上。 - 合成:把像素层叠起来。
如果列表是 100,000 条呢?你的浏览器会直接给你一记耳光,或者直接卡死。这就是所谓的“DOM 神经症”。
虚拟滚动,就是那个神奇的“幽灵列表”。
它的核心思想很简单:你只需要渲染你眼睛能看到的那些。 其他的?让它们在内存里待着,或者干脆不要生成 DOM 节点。
就像你透过窗户看风景。你不需要把整个山都搬进屋子里,你只需要看到窗户框住的那一小块风景。当你滚动时,你只是把窗户往前挪了一步。
第二部分:1D 虚拟滚动的“Hello World”
让我们先从一个最基础的版本开始。假设我们有 10,000 个高度固定的项目,每个高度 50px。
// 简单的 1D 虚拟滚动逻辑
const VirtualList = ({ items }) => {
// 1. 计算视口高度
const containerHeight = 600;
// 2. 计算每个项目的高度
const itemHeight = 50;
// 3. 计算可视区域内的起始和结束索引
// 比如:scrollTop 是 100,那么 start 就是 2
const start = Math.floor(scrollTop / itemHeight);
const end = start + Math.ceil(containerHeight / itemHeight);
// 4. 截取数据
const visibleItems = items.slice(start, end);
// 5. 计算偏移量,让它们归位
const offsetY = start * itemHeight;
return (
<div
style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}
onScroll={(e) => { /* 这里需要更新 scrollTop 状态 */ }}
>
<div style={{ height: items.length * itemHeight, position: 'absolute', top: 0, left: 0 }}>
{visibleItems.map((item, index) => (
<div key={item.id} style={{ height: itemHeight, top: offsetY }}>
{item.content}
</div>
))}
</div>
</div>
);
};
看,这很简单,对吧?我们只渲染了大概 12-20 个 div,而不是 10,000 个。
但是,现实世界从来不会这么温柔。现实世界充满了嵌套和异步。
第三部分:嵌套地狱——当俄罗斯套娃遇上虚拟滚动
现在,假设你的列表不是平铺直叙的,而是一个树状结构,或者是一个手风琴组件。每个父级下面可能有子级,子级下面还有孙级。
如果父级折叠了,子级应该消失吗?如果父级展开了,子级的高度突然增加了,怎么办?
普通的 1D 虚拟滚动在这里会崩溃。因为“滚动”的概念变得模糊了。你是滚动父级列表?还是滚动子级列表?
策略:坐标变换
要解决这个问题,我们需要把“嵌套结构”扁平化,或者更准确地说,我们需要给每个节点分配一个全局坐标。
想象一下,你把所有的子节点都拉直了,像一条长龙一样排成一排。虽然它们在 DOM 结构上还是嵌套的,但在计算逻辑上,它们是一个连续的流。
// 伪代码:扁平化数据结构
function flattenTree(items) {
const result = [];
items.forEach(item => {
result.push({ type: 'parent', data: item, depth: 0 });
if (item.children) {
item.children.forEach(child => {
result.push({ type: 'child', data: child, depth: 1 });
// 如果还有孙级...
});
}
});
return result;
}
但是,直接扁平化会破坏 React 的渲染结构。我们不能直接 map 一个扁平数组,然后强行塞进嵌套的 JSX 里,那会让代码变得不可读且难以维护。
高级策略:
我们需要一种“混合渲染”模式。
- 外层容器:负责处理滚动事件,计算全局的
scrollTop。 - 递归渲染器:遍历数据树,但根据全局的
scrollTop和containerHeight来决定是否渲染当前节点及其子节点。
代码示例:嵌套虚拟滚动
这是一个简化的示例,展示了如何处理嵌套。
const NestedVirtualList = ({ data }) => {
const containerRef = React.useRef(null);
const [scrollTop, setScrollTop] = React.useState(0);
const containerHeight = 800;
const itemHeight = 50; // 假设基础高度
// 辅助函数:计算节点在全局流中的起始位置
const getGlobalOffset = (node, items) => {
// 这里需要遍历 items 来找到当前节点的位置
// 在实际生产中,这通常在 useMemo 中预先计算好
return 0; // 简化演示
};
// 核心渲染逻辑
const renderItem = (node, globalIndex) => {
// 1. 检查是否在视口内
// 我们假设每个节点至少占 itemHeight
const nodeStart = globalIndex * itemHeight;
const nodeEnd = nodeStart + itemHeight;
if (nodeEnd < scrollTop || nodeStart > scrollTop + containerHeight) {
return null; // 完全不可见,不渲染
}
return (
<div key={node.id} style={{ height: itemHeight, position: 'relative' }}>
{/* 渲染节点内容 */}
<div style={{ padding: node.depth * 20 }}>
{node.title} (Depth: {node.depth})
</div>
{/* 递归渲染子节点 */}
{node.children && node.children.length > 0 && (
<div style={{ marginLeft: 20, borderLeft: '1px solid #ccc' }}>
{node.children.map((child, i) =>
renderItem(child, globalIndex + 1 + i)
)}
</div>
)}
</div>
);
};
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.target.scrollTop)}
>
{/* 这是一个占位符,确保总高度足够滚动 */}
<div style={{ height: data.length * itemHeight, position: 'relative' }}>
{data.map((node, i) => renderItem(node, i))}
</div>
</div>
);
};
注意看上面的代码,这其实是一个“偷懒”的实现。它并没有真正优化子节点的渲染。如果你展开一个子节点,它下面的所有孙节点都会被渲染出来,哪怕它们不可见。
真正的高级策略是:当父节点不可见时,直接跳过渲染它的子节点。
第四部分:异步加载——数据不是送上门的
现在,我们有了嵌套结构,接下来是异步加载。
想象一下,你正在加载一个包含 100,000 条数据的论坛帖子列表。你不能一次性把所有帖子都拉下来,那会撑爆你的内存。你只能“按需加载”,比如每次只拉 50 条。
挑战:
当数据还没拉到的时候,你怎么知道这一项有多高?如果你不知道高度,你就无法计算它在列表中的位置,也就无法滚动!
策略:动态测量
我们需要一个 measureItem 函数。当数据返回时,我们使用 ResizeObserver 来测量实际渲染后的大小,然后更新虚拟滚动的状态。
这就像你去餐厅点餐。服务员不知道汉堡有多大,直到汉堡端上来,你量一下,告诉他:“嘿,这个汉堡 15cm,下一个汉堡 20cm。”
代码示例:处理异步高度
const AsyncVirtualList = ({ fetchData }) => {
const [visibleItems, setVisibleItems] = useState([]);
const [scrollTop, setScrollTop] = useState(0);
const [itemSizes, setItemSizes] = useState({}); // 缓存测量结果
const containerHeight = 600;
const buffer = 5; // 缓冲区,多渲染几个防止闪烁
const loadMoreData = async () => {
// 模拟异步获取数据
const newData = await fetchData(10);
setVisibleItems(prev => [...prev, ...newData]);
};
// 当数据加载完成,我们需要重新计算布局
React.useEffect(() => {
if (visibleItems.length > 0) {
// 这里是一个简化的测量逻辑
// 实际中,我们需要遍历 DOM 节点获取 offsetHeight
const newSizes = {};
visibleItems.forEach((item, index) => {
// 假设我们有一个 ref 到每个 item
const el = document.getElementById(`item-${index}`);
if (el) {
newSizes[index] = el.offsetHeight;
}
});
setItemSizes(newSizes);
}
}, [visibleItems]);
// 计算可视窗口
const startIndex = Math.max(0, Math.floor(scrollTop / 100) - buffer);
const endIndex = Math.min(
visibleItems.length,
Math.floor((scrollTop + containerHeight) / 100) + buffer
);
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.target.scrollTop)}
>
{/* 这里的 100 是估算值,用于初始滚动 */}
<div style={{ position: 'relative' }}>
{visibleItems.map((item, index) => {
// 如果我们还没测量过这个 item 的高度,先用估算值
const height = itemSizes[index] || 100;
// 如果 index 不在可视范围内,就不渲染 DOM(高级优化)
if (index < startIndex || index >= endIndex) return null;
return (
<div
key={item.id}
id={`item-${index}`}
style={{
height: `${height}px`,
position: 'absolute',
top: `${index * 100}px` // 这里的 100 应该是累计高度
}}
>
{item.content}
</div>
)
})}
</div>
</div>
);
};
等等,上面的代码有个大坑!
如果你在 useEffect 里测量高度,DOM 已经渲染完了。测量之后,height 变了,但 top 没变,列表会瞬间跳动。这是虚拟滚动中的“重排闪烁”。
高级策略:延迟渲染与批量更新
不要在每次数据返回时都触发重渲染。我们需要一个“测量队列”。
- 初始渲染:使用估算高度(比如 100px)渲染所有可见项。
- 测量阶段:遍历可见项,获取真实高度。
- 更新阶段:收集所有变化的高度,一次性更新状态,让 React 批量处理 DOM 变化。
第五部分:核心算法——视口管理器
好了,现在我们有了嵌套,有了异步。我们需要一个真正的“视口管理器”。这通常是 react-window 或 react-virtualized 这类库的核心。
让我们来手写一个稍微复杂一点的逻辑,处理动态高度和缓冲区。
关键概念:虚拟缓冲区
你不能只渲染视口内的元素。为什么?因为当你滚动到列表边缘时,浏览器需要一点时间去计算下一个元素的位置。如果视口边缘正好有一个元素,你会看到它在闪烁。
所以,我们多渲染 buffer 个元素(比如 5 个)。
完整的高级实现逻辑
const AdvancedVirtualScroller = ({ data, renderItem }) => {
const containerRef = React.useRef(null);
const [scrollTop, setScrollTop] = React.useState(0);
const [itemHeights, setItemHeights] = React.useState(new Map());
const [estimatedHeights, setEstimatedHeights] = React.useState(new Map());
const containerHeight = 800;
const buffer = 5;
// 计算累计高度
const getOffsetTop = (index) => {
let top = 0;
for (let i = 0; i < index; i++) {
top += itemHeights.get(i) || estimatedHeights.get(i) || 50; // 使用缓存或估算值
}
return top;
};
// 滚动处理
const handleScroll = (e) => {
const newScrollTop = e.target.scrollTop;
// 防抖动,避免高频滚动触发重计算
requestAnimationFrame(() => {
setScrollTop(newScrollTop);
});
};
// 测量元素
const handleItemResize = (index, height) => {
setItemHeights(prev => new Map(prev).set(index, height));
};
// 核心计算:确定渲染范围
const getRenderRange = () => {
let start = 0;
let end = data.length;
let currentOffset = 0;
// 向下查找起始索引
for (let i = 0; i < data.length; i++) {
const h = itemHeights.get(i) || estimatedHeights.get(i) || 50;
if (currentOffset + h > scrollTop) {
start = Math.max(0, i - buffer);
break;
}
currentOffset += h;
}
// 向上查找结束索引
currentOffset = 0;
for (let i = 0; i < data.length; i++) {
const h = itemHeights.get(i) || estimatedHeights.get(i) || 50;
if (currentOffset + h > scrollTop + containerHeight + buffer * 50) { // buffer 估算
end = i;
break;
}
currentOffset += h;
}
return { start, end };
};
const { start, end } = getRenderRange();
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}
onScroll={handleScroll}
>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
{data.map((item, index) => {
if (index < start || index >= end) return null; // 超出范围,不渲染
const top = getOffsetTop(index);
const height = itemHeights.get(index) || estimatedHeights.get(index) || 50;
// 我们传入一个 ref 给 renderItem,以便测量
const itemRef = (node) => {
if (node) {
handleItemResize(index, node.offsetHeight);
}
};
return (
<div
key={item.id || index}
ref={itemRef}
style={{
position: 'absolute',
top: `${top}px`,
height: `${height}px`,
width: '100%'
}}
>
{renderItem(item, index)}
</div>
);
})}
</div>
</div>
);
};
这个实现处理了:
- 动态高度:通过
ResizeObserver的思想(这里简化为 ref 回调)来更新高度。 - 缓冲区:多渲染了一点点内容。
- 性能:通过
position: absolute排除掉不可见元素,避免它们参与布局计算。
第六部分:React 的陷阱——重渲染
写完了算法,我们还要面对 React 本身。
如果你在一个大列表里,每个 item 都是一个复杂的组件,当你滚动时,VirtualScroller 父组件更新了 scrollTop,导致整个列表重新渲染。
问题: 即使 VirtualScroller 很聪明地只渲染了可见的 div,但如果每个 div 里面包裹的是一个 React.memo 优化不好的组件,那个组件依然会重新渲染!
策略:Item 组件优化
const MemoizedItem = React.memo(({ content }) => {
console.log(`Rendering item: ${content}`); // 只有当 props 变化时才会打印
return <div>{content}</div>;
});
// 在 VirtualScroller 中使用
{data.map((item, index) => (
<MemoizedItem key={item.id} content={item.content} />
))}
策略:保持引用稳定
如果你的 item 内容会频繁变化,而 React 认为它引用变了,它就会重渲染。确保你的 key 是稳定的(通常是 ID),并且尽量减少 renderItem prop 的变化。
策略:使用 window.requestAnimationFrame
滚动事件是高频触发的。不要在 onScroll 里做任何昂贵的 DOM 操作。onScroll 是由浏览器触发的,非常快。
const handleScroll = (e) => {
// 1. 保存滚动位置到 state
setScrollTop(e.target.scrollTop);
// 2. 使用 requestAnimationFrame 进行节流或防抖
// 这样可以避免在滚动的一瞬间疯狂计算
window.requestAnimationFrame(() => {
// 这里可以做一些计算
});
};
第七部分:终极奥义——无限滚动与瀑布流
现在,我们有了处理嵌套和异步的基础。让我们看看更高级的玩法。
无限滚动
当用户滚动到底部时,自动加载更多数据。
在虚拟滚动里,这很简单。我们只需要在 end 接近 data.length 时,触发数据获取函数。
const handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
// 如果滚动到底部附近 (比如距离底部还有 200px)
if (scrollTop + clientHeight >= scrollHeight - 200) {
loadMoreData(); // 触发加载
}
};
瀑布流布局
这是最难搞的。因为每列的高度不同,你不能简单地用 top 来定位。
高级策略:分列渲染
不要把所有东西渲染在一个巨大的绝对定位的 div 里。我们可以把列表分成几列(比如 3 列)。
- 数据分配:把数据按顺序分配给列 1、列 2、列 3。
- 独立滚动:每列有自己的滚动条(或者共用一个滚动条,但内部计算独立)。
- 视口计算:计算每一列在视口内渲染哪些数据。
这会大大降低单列的数据量,从而让计算变得轻松。
// 简化的分列逻辑
const columns = 3;
const columnData = data.reduce((acc, item, index) => {
const colIndex = index % columns;
if (!acc[colIndex]) acc[colIndex] = [];
acc[colIndex].push(item);
return acc;
}, []);
return (
<div style={{ display: 'flex' }}>
{columnData.map((colItems, i) => (
<div key={i} style={{ flex: 1, overflow: 'auto' }}>
{/* 这里可以套用上面的 AdvancedVirtualScroller 逻辑 */}
{colItems.map(item => renderItem(item))}
</div>
))}
</div>
);
结语:不要重复造轮子,但要懂轮子
好了,各位。
我们今天聊了虚拟滚动的原理,聊了如何处理嵌套结构的“俄罗斯套娃”,聊了如何应对异步加载的“不确定性”,甚至聊了瀑布流这种“硬骨头”。
写一个像 react-window 那样的库,需要深厚的数学功底和对浏览器渲染引擎的深刻理解。在实际工作中,不要为了炫技而完全自己造轮子。
但是,一定要理解这些原理。
为什么你的列表会卡?为什么展开一个折叠面板会导致整个列表重排?为什么你的异步数据加载后列表会跳动?
当你理解了“视口”、“坐标映射”、“重排与重绘”这些概念时,你就不再是一个只会写 map 的初级开发者,而是一个真正掌控 DOM 的架构师。
记住,虚拟滚动不是魔法,它是数学和性能优化的胜利。它让浏览器从“负担”变成了“引擎”。
现在,去优化你的列表吧。让你的用户在滚动 100,000 条数据时,依然能感受到丝般顺滑的快感。
谢谢大家!