各位同仁,各位技术爱好者,大家好。
今天,我们将深入探讨一个在前端性能优化领域至关重要的技术:虚拟列表(Virtual List),并特别关注它在 React 环境下,如何与 Fiber 架构协同工作,实现 Fiber 节点的动态挂载与卸载。这不仅仅是一个性能优化的技巧,更是对 React 渲染机制深层理解的体现。
序言:为何需要虚拟列表?
设想一个场景:您正在开发一个数据表格,其中包含数千甚至上万条数据记录。如果您选择一次性将所有数据渲染到 DOM 中,您会立即遇到以下问题:
- 内存消耗剧增: 每条记录对应一个或多个 DOM 节点,海量的 DOM 节点会占用大量内存。
- 渲染性能下降: 浏览器需要解析、布局和绘制所有这些 DOM 节点,导致页面加载缓慢,滚动卡顿。
- JavaScript 执行负担: React 在进行初始渲染和后续更新时,需要为所有组件创建 Fiber 节点,执行和比较大量的 Diff 算法,这会显著增加 JavaScript 的执行时间。
虚拟列表,正是为了解决这些问题而生。其核心思想是:只渲染用户当前可见区域内(包括少量缓冲区)的列表项,而将不可见区域的列表项从 DOM 中移除,同时通过占位符维持滚动条的正确位置。 这样,无论列表数据量有多大,DOM 中实际存在的节点数量始终保持在一个可控的、较小的范围内。
React Fiber 架构简述:虚拟列表的舞台
在深入虚拟列表的实现之前,我们有必要简要回顾一下 React 的 Fiber 架构。理解 Fiber,是理解虚拟列表如何影响 React 渲染周期的关键。
React 16 引入的 Fiber 架构是对核心协调算法(Reconciliation)的重写。它将协调过程从一个递归的、不可中断的过程,转变为一个可中断、可恢复的异步过程。
Fiber 节点的本质:
- Fiber 是一个 JavaScript 对象,代表了一个组件实例、DOM 元素或其他 React 元素的“工作单元”。
- 它构成了组件树的链表结构,每个 Fiber 节点都包含其对应的组件信息(类型、props、state)、优先级、副作用(effect tag)以及指向其父节点、子节点和兄弟节点的指针。
Fiber 架构的两个阶段:
-
Render/Reconciliation 阶段 (可中断):
- 在这个阶段,React 会遍历 Fiber 树,执行组件的
render方法,计算出新的状态和 props,并与旧的 Fiber 节点进行对比(Diff 算法)。 - 它会找出需要进行的 DOM 操作(添加、删除、更新属性等),并将这些操作标记在 Fiber 节点的
effect tag上。 - 这个阶段不会触及真实的 DOM。
- 在这个阶段,React 会遍历 Fiber 树,执行组件的
-
Commit 阶段 (不可中断):
- 一旦 Render 阶段完成,React 就会进入 Commit 阶段。
- 在这个阶段,React 会遍历所有带有
effect tag的 Fiber 节点,将 Render 阶段计算出的 DOM 操作批量、同步地应用到真实的 DOM 上。 - 这个阶段是浏览器进行实际 DOM 更新、触发生命周期方法(如
componentDidMount、componentDidUpdate)和useLayoutEffect的地方。
虚拟列表与 Fiber 的关系:
虚拟列表的工作原理,恰恰是在 Render 阶段发挥作用。通过智能地控制 render 方法返回的 JSX 结构,我们能够告知 React 只为可见区域的列表项创建 Fiber 节点。对于那些不可见的列表项,React 根本就不会在 Fiber 树中为它们分配工作单元,从而避免了大量不必要的 Diff 比较、内存分配和后续的 DOM 操作。这正是“动态计算 Fiber 节点的挂载与卸载”的根本机制。
虚拟列表的核心原理与计算
虚拟列表的实现主要围绕以下几个核心计算:
totalHeight(总高度): 模拟所有列表项完全展开时的总高度,用于设置一个占位元素的高度,以维持滚动条的正确范围。startIndex(起始索引): 当前可见区域的第一个列表项的索引。endIndex(结束索引): 当前可见区域的最后一个列表项的索引。offsetY(偏移量): 为了让可见区域的列表项在滚动容器中正确显示,需要一个垂直偏移量,来模拟其上方被“虚拟化”的列表项的高度。
我们通常会有一个包含所有数据的 items 数组。虚拟列表组件的 render 方法将不再遍历整个 items 数组,而是只遍历 items.slice(startIndex, endIndex + 1) 这个子数组。
场景一:固定高度列表项 (Fixed Item Height)
这是最简单、最常见的虚拟列表实现。当所有列表项的高度都相同时,计算变得非常直观。
核心思想:
totalHeight=items.length * itemHeightstartIndex=Math.floor(scrollTop / itemHeight)endIndex=startIndex + Math.ceil(containerHeight / itemHeight) + bufferSizeoffsetY=startIndex * itemHeight
bufferSize 的作用:
为了优化用户体验,减少滚动时出现空白区域的情况,我们通常会额外渲染一些在可见区域上方和下方的列表项。这个额外的数量就是 bufferSize。
让我们通过一个 React 组件来演示这个过程。
import React, { useRef, useState, useEffect, useCallback, useMemo } from 'react';
// 假定列表项组件,接受数据和样式
const FixedHeightListItem = React.memo(({ index, data, style }) => {
return (
<div
style={{
...style,
borderBottom: '1px solid #eee',
padding: '10px 15px',
display: 'flex',
alignItems: 'center',
backgroundColor: index % 2 === 0 ? '#f9f9f9' : '#fff',
}}
>
<span style={{ fontWeight: 'bold', marginRight: '10px' }}>{index + 1}.</span>
<span>{data.text}</span>
</div>
);
});
// 虚拟列表组件
function FixedHeightVirtualList({
itemCount, // 总列表项数量
itemHeight, // 每个列表项的高度 (固定)
renderItem, // 渲染单个列表项的函数
height, // 容器高度
bufferSize = 5, // 上下缓冲区大小
}) {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
// 计算当前可见区域的起始和结束索引
const { startIndex, endIndex } = useMemo(() => {
// 基于当前滚动位置计算起始索引
const startIdx = Math.floor(scrollTop / itemHeight);
// 可视区域需要渲染的项数
const visibleCount = Math.ceil(height / itemHeight);
// 加上缓冲区
const endIdx = Math.min(itemCount - 1, startIdx + visibleCount + bufferSize);
return {
startIndex: Math.max(0, startIdx - bufferSize), // 确保startIndex不小于0
endIndex: endIdx,
};
}, [scrollTop, itemHeight, height, itemCount, bufferSize]);
// 计算上方偏移量,用于定位可视区域的第一个元素
const offsetY = startIndex * itemHeight;
// 虚拟列表的总高度,用于撑开滚动条
const totalHeight = itemCount * itemHeight;
// 滚动事件处理函数
const handleScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
// 注册/注销滚动事件监听器
useEffect(() => {
const container = containerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => {
container.removeEventListener('scroll', handleScroll);
};
}
}, [handleScroll]);
// 渲染可视区域的列表项
const itemsToRender = [];
for (let i = startIndex; i <= endIndex; i++) {
if (i < itemCount) { // 确保索引在有效范围内
itemsToRender.push(
renderItem({
index: i,
style: {
position: 'absolute',
top: i * itemHeight,
left: 0,
width: '100%',
height: itemHeight,
},
})
);
}
}
return (
<div
ref={containerRef}
style={{
height: height,
overflowY: 'auto',
position: 'relative',
border: '1px solid #ccc',
}}
>
{/* 这是一个撑开滚动条的占位元素,其高度等于所有列表项的总高度 */}
<div style={{ height: totalHeight, position: 'relative' }}>
{/* 实际渲染的列表项容器,通过 transform 属性进行定位 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${offsetY}px)`,
}}
>
{itemsToRender}
</div>
</div>
</div>
);
}
// ------------------- 使用示例 -------------------
const ALL_DATA = Array.from({ length: 10000 }).map((_, i) => ({
id: i,
text: `这是第 ${i + 1} 条数据,内容很长很长,用于填充列表项。`,
}));
function App() {
return (
<div style={{ maxWidth: '600px', margin: '50px auto' }}>
<h1>固定高度虚拟列表示例</h1>
<FixedHeightVirtualList
itemCount={ALL_DATA.length}
itemHeight={50} // 假设每个列表项高度为50px
renderItem={({ index, style }) => (
<FixedHeightListItem
key={ALL_DATA[index].id}
index={index}
data={ALL_DATA[index]}
style={style}
/>
)}
height={400} // 容器显示高度
/>
</div>
);
}
export default App;
Fiber 节点的挂载与卸载 (固定高度列表):
在这个例子中,FixedHeightVirtualList 组件的 render 方法的核心是 itemsToRender 数组。当用户滚动页面时,scrollTop 状态会更新,进而重新计算 startIndex 和 endIndex。
- 挂载: 当一个列表项从
startIndex - bufferSize到endIndex的范围之外进入这个范围时,itemsToRender数组中会新增一个对应的FixedHeightListItem组件。React 的协调器会发现这个新的组件,为其创建一个新的 Fiber 节点,并在 Commit 阶段将其挂载到 DOM 上。 - 卸载: 相反,当一个列表项从可见范围(包括缓冲区)移出时,它将不再被包含在
itemsToRender数组中。React 的协调器会发现这个组件不再存在于新的itemsToRender数组中,因此会标记其对应的 Fiber 节点为删除(Deletion),并在 Commit 阶段将其从 DOM 中卸载。 - 更新: 对于那些始终保持在可见范围内的列表项,它们的
key保持不变。React 会重用它们的 Fiber 节点,并根据新的props(例如,style中的top属性可能会因offsetY的变化而间接改变)来更新其在 DOM 中的位置或样式。
这里的关键在于 itemsToRender 数组,它直接控制了哪些 React 元素会被传递给 React 进行处理。只有被包含在这个数组中的元素,才有可能拥有对应的 Fiber 节点并被渲染到 DOM。
场景二:可变高度列表项 (Variable Item Height)
当列表项的高度不固定时,情况变得复杂。我们无法简单地通过 itemHeight * index 来计算 offsetY 或 totalHeight。
核心挑战:
- 高度未知: 在初次渲染前,我们不知道每个列表项的真实高度。
totalHeight计算: 需要累加所有列表项的高度。startIndex查找: 不能直接除以itemHeight,需要通过查找来确定哪个列表项位于scrollTop位置。offsetY计算: 也需要累加startIndex之前所有列表项的高度。
解决方案:
- 高度缓存: 在第一次渲染时,测量并缓存每个列表项的实际高度及其在容器中的
top和bottom位置。 - 预估高度: 在高度信息尚未完全获取时,使用一个预估高度进行计算,以提供平滑的滚动体验。
- 二分查找: 利用缓存的高度信息,通过二分查找等高效算法快速定位
scrollTop对应的startIndex。
缓存结构:
我们通常会维护一个数组,例如 itemPositions,其中每个元素包含列表项的 index、height、top 和 bottom 信息。
interface ItemPosition {
index: number;
height: number;
top: number;
bottom: number;
}
测量时机:
在 React 中,DOM 测量通常发生在组件挂载后或更新后,通过 useEffect 钩子并在其中访问 ref 指向的 DOM 元素。为了准确测量,我们需要确保列表项已经渲染到 DOM 中。
下面是一个可变高度虚拟列表的实现。由于其复杂性,我们会分解为几个关键部分。
import React, { useRef, useState, useEffect, useCallback, useMemo } from 'react';
// 假设的列表项组件,内容可变,导致高度可变
const VariableHeightListItem = React.memo(({ index, data, style, innerRef }) => {
return (
<div
ref={innerRef} // 传递ref以便测量实际高度
style={{
...style,
borderBottom: '1px solid #eee',
padding: '10px 15px',
display: 'flex',
flexDirection: 'column',
backgroundColor: index % 2 === 0 ? '#f9f9f9' : '#fff',
}}
>
<span style={{ fontWeight: 'bold' }}>{index + 1}. {data.title}</span>
<p style={{ margin: '5px 0 0 0', fontSize: '0.9em', color: '#555' }}>{data.description}</p>
</div>
);
});
// 可变高度虚拟列表组件
function VariableHeightVirtualList({
itemCount, // 总列表项数量
estimatedItemHeight = 50, // 预估的列表项高度
renderItem, // 渲染单个列表项的函数
height, // 容器高度
bufferSize = 5, // 上下缓冲区大小
}) {
const containerRef = useRef(null);
const itemRefs = useRef([]); // 用于存储每个可见列表项的ref,以便测量
const [scrollTop, setScrollTop] = useState(0);
// 存储所有列表项的位置和高度信息
// { index: number, height: number, top: number, bottom: number }
const [itemPositions, setItemPositions] = useState([]);
// 用于在数据变化时重置itemPositions
useEffect(() => {
// 当itemCount变化时,重置所有缓存的高度信息
// 实际应用中可能需要更精细的控制,例如只重置新增或删除的部分
setItemPositions(
Array(itemCount).fill(null).map((_, i) => ({
index: i,
height: estimatedItemHeight, // 初始使用预估高度
top: 0, // 初始为0,待测量后更新
bottom: 0, // 初始为0,待测量后更新
}))
);
}, [itemCount, estimatedItemHeight]);
// 测量可见区域内列表项的实际高度
const measureItems = useCallback(() => {
if (!containerRef.current || itemRefs.current.length === 0) return;
// 创建一个新的itemPositions数组,避免直接修改state
const newPositions = [...itemPositions];
let dirty = false; // 标记是否有高度发生变化
itemRefs.current.forEach(itemNode => {
if (itemNode) {
const index = parseInt(itemNode.dataset.index, 10);
const actualHeight = itemNode.offsetHeight;
if (newPositions[index] && newPositions[index].height !== actualHeight) {
newPositions[index].height = actualHeight;
dirty = true;
}
}
});
if (dirty) {
// 重新计算所有item的top和bottom,因为某个高度变化会影响后续所有item的位置
let currentTop = 0;
for (let i = 0; i < newPositions.length; i++) {
const item = newPositions[i];
item.top = currentTop;
item.bottom = currentTop + item.height;
currentTop += item.height;
}
setItemPositions(newPositions);
}
}, [itemPositions]);
// 使用ResizeObserver来监听列表项尺寸变化
useEffect(() => {
const observer = new ResizeObserver(entries => {
// 当可见的列表项尺寸变化时,重新测量
measureItems();
});
itemRefs.current.forEach(node => {
if (node) observer.observe(node);
});
return () => {
observer.disconnect();
};
}, [measureItems, startIndex, endIndex]); // 重新观察可见项,当startIndex/endIndex变化时
// 在组件挂载和更新后,测量一次可见项的高度
useEffect(() => {
measureItems();
}); // 注意这里没有依赖,每次渲染后都会尝试测量,但只会更新有变化的
// 滚动事件处理函数
const handleScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
// 注册/注销滚动事件监听器
useEffect(() => {
const container = containerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => {
container.removeEventListener('scroll', handleScroll);
};
}
}, [handleScroll]);
// ------------------- 核心计算逻辑 -------------------
// 1. 计算总高度 (totalHeight)
const totalHeight = useMemo(() => {
if (itemPositions.length === 0) {
return 0;
}
// 如果所有item都测量过了,直接取最后一个item的bottom
if (itemPositions[itemCount - 1] && itemPositions[itemCount - 1].bottom !== 0) {
return itemPositions[itemCount - 1].bottom;
}
// 否则,使用预估高度计算总高
return itemCount * estimatedItemHeight;
}, [itemCount, itemPositions, estimatedItemHeight]);
// 2. 查找 startIndex
// 使用二分查找来高效定位scrollTop对应的startIndex
const findStartIndex = useCallback((currentScrollTop) => {
let low = 0;
let high = itemPositions.length - 1;
let targetIndex = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = itemPositions[mid];
if (item.bottom > currentScrollTop) {
targetIndex = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
return targetIndex;
}, [itemPositions]);
const { startIndex, endIndex } = useMemo(() => {
if (itemPositions.length === 0) {
return { startIndex: 0, endIndex: 0 };
}
const startIdx = findStartIndex(scrollTop);
// 可视区域预估的项数
const visibleCount = Math.ceil(height / estimatedItemHeight);
const endIdx = Math.min(itemCount - 1, startIdx + visibleCount + bufferSize);
return {
startIndex: Math.max(0, startIdx - bufferSize),
endIndex: endIdx,
};
}, [scrollTop, itemCount, height, estimatedItemHeight, bufferSize, itemPositions, findStartIndex]);
// 3. 计算上方偏移量 (offsetY)
const offsetY = useMemo(() => {
if (itemPositions.length === 0 || startIndex === 0) {
return 0;
}
// 如果startIndex对应item的top已测量,直接使用
if (itemPositions[startIndex] && itemPositions[startIndex].top !== 0) {
return itemPositions[startIndex].top;
}
// 否则,使用预估高度累加
return startIndex * estimatedItemHeight;
}, [startIndex, itemPositions, estimatedItemHeight]);
// ------------------- 渲染逻辑 -------------------
const itemsToRender = [];
// 清空 itemRefs 数组,每次重新渲染时只包含当前可见的 ref
itemRefs.current = [];
for (let i = startIndex; i <= endIndex; i++) {
if (i < itemCount) {
const item = itemPositions[i];
if (!item) continue; // defensive check
// 动态计算每个item的top位置,基于itemPositions的缓存
// 注意这里item.top是相对于整个totalHeight的,而不是translateY的0点
const itemTop = item.top;
itemsToRender.push(
renderItem({
index: i,
style: {
position: 'absolute',
top: itemTop,
left: 0,
width: '100%',
height: item.height, // 使用缓存的高度
},
// 传递ref给子组件,以便测量
innerRef: (el) => {
if (el) itemRefs.current[i] = el;
},
})
);
}
}
return (
<div
ref={containerRef}
style={{
height: height,
overflowY: 'auto',
position: 'relative',
border: '1px solid #ccc',
}}
>
{/* 这是一个撑开滚动条的占位元素 */}
<div style={{ height: totalHeight, position: 'relative' }}>
{/* 实际渲染的列表项容器,通过 transform 属性进行定位 */}
{/* 在可变高度中,每个item的top属性是绝对定位,而非通过transform整体偏移 */}
{/* 这里我们直接让每个item的top属性去定位,而不是用一个transformY包裹 */}
{itemsToRender}
</div>
</div>
);
}
// ------------------- 使用示例 -------------------
const ALL_VARIABLE_DATA = Array.from({ length: 10000 }).map((_, i) => ({
id: i,
title: `数据项 ${i + 1}`,
description: i % 3 === 0
? `这是一条很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长的文字。`
: `这是第 ${i + 1} 条数据,内容短小精悍。`
}));
function App() {
return (
<div style={{ maxWidth: '600px', margin: '50px auto' }}>
<h1>可变高度虚拟列表示例</h1>
<VariableHeightVirtualList
itemCount={ALL_VARIABLE_DATA.length}
estimatedItemHeight={50} // 预估每个列表项高度为50px
renderItem={({ index, style, innerRef }) => (
<VariableHeightListItem
key={ALL_VARIABLE_DATA[index].id}
index={index}
data={ALL_VARIABLE_DATA[index]}
style={style}
innerRef={innerRef}
/>
)}
height={400} // 容器显示高度
/>
</div>
);
}
export default App;
Fiber 节点的挂载与卸载 (可变高度列表):
这里的原理与固定高度列表是相同的,但实现细节更复杂:
itemPositions状态管理:itemPositions数组作为核心缓存,存储了每个列表项的真实高度和位置。它在itemCount变化时初始化(使用预估高度),在measureItems函数中根据实际 DOM 测量结果进行更新。setItemPositions的调用会触发VariableHeightVirtualList组件的重新渲染。startIndex和endIndex的动态计算:findStartIndex函数利用itemPositions缓存进行二分查找,精确确定startIndex。endIndex也相应计算。useMemo用于缓存这些计算结果,只有当相关依赖项(scrollTop,itemCount,itemPositions等)变化时才重新计算。itemsToRender: 同样,itemsToRender数组只包含startIndex到endIndex范围内的列表项。当这个范围变化时,React 的协调器会根据key属性来判断哪些 Fiber 节点需要创建(挂载)、哪些需要删除(卸载)、哪些需要更新。- 挂载: 当
startIndex向前或endIndex向后移动时,新的列表项进入itemsToRender数组。React 会为这些新的VariableHeightListItem组件创建 Fiber 节点。 - 卸载: 当
startIndex向后或endIndex向前移动时,列表项移出itemsToRender数组。React 会标记其对应的 Fiber 节点为删除。 - 更新: 对于仍在
itemsToRender数组中的列表项,React 会重用它们的 Fiber 节点,并根据itemPositions中缓存的最新top和height信息,更新它们的styleprops。
- 挂载: 当
measureItems 的作用:
measureItems 函数在 useEffect 中被调用,它会在浏览器完成布局和绘制后,通过 offsetHeight 获取列表项的真实高度。这个过程会更新 itemPositions 状态,从而触发组件的再次渲染。在这次渲染中,itemsToRender 会使用最新的真实高度和位置信息来渲染列表项,使得滚动更加平滑和准确。ResizeObserver 进一步增强了弹性,确保当列表项内容动态变化导致高度改变时,也能及时重新测量。
为什么每个 ListItem 要绝对定位?
在可变高度列表中,我们不能简单地用 transformY 来偏移整个可见区域。因为每个列表项的高度不同,它们在容器中的 top 位置是累加计算出来的。因此,每个列表项需要独立地通过 position: 'absolute' 和 top: item.top 来精确地定位。
性能优化与注意事项
除了上述核心逻辑,为了构建一个健壮且高性能的虚拟列表,还需要考虑以下几点:
-
事件节流/防抖 (Throttling/Debouncing): 滚动事件
onScroll触发频率非常高,直接在每次滚动时更新scrollTop状态并触发重渲染会带来性能问题。应使用节流或防抖函数来限制handleScroll的执行频率。在我们的示例中,setScrollTop的调用频率已经通过 React 的批处理机制得到一定程度的优化,但对于更复杂的计算,手动节流/防抖仍然是推荐的。// 简单的节流实现 const throttle = (func, limit) => { let inThrottle; return function() { const args = arguments; const context = this; if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(() => (inThrottle = false), limit); } }; }; // 在 useEffect 中使用 const throttledHandleScroll = useMemo(() => throttle(handleScroll, 100), [handleScroll]); // ... // container.addEventListener('scroll', throttledHandleScroll); -
key属性的正确使用: 在renderItem函数中,为每个列表项提供一个稳定且唯一的key属性至关重要。React 依靠key来高效地识别列表中哪些项被添加、删除或重新排序,从而优化 Fiber 节点的重用和 DOM 更新。如果没有key或key不稳定,React 会在列表项顺序变化时销毁并重建所有 Fiber 节点,导致性能下降。 -
列表项组件的优化:
React.memo: 使用React.memo包裹列表项组件(如FixedHeightListItem和VariableHeightListItem),可以防止在父组件重新渲染时,如果它们的props没有发生变化,它们自身也重新渲染。这减少了不必要的 Fiber 节点更新工作。- 避免在
renderItem中创建新组件:renderItemprop 应该返回一个 JSX 元素,而不是一个直接定义组件的函数,以避免每次渲染都创建新的组件类型。
-
动态数据处理: 当
items数组发生变化时(例如,添加、删除、排序数据),需要:- 重置或更新
itemPositions缓存: 如果数据量或顺序变化较大,可能需要清空itemPositions并重新测量。 - 重新计算
totalHeight、startIndex、endIndex和offsetY。
- 重置或更新
-
容器/窗口尺寸变化: 如果虚拟列表的容器或浏览器窗口尺寸发生变化,
height和containerHeight(通过containerRef.current.offsetHeight获取)会改变,这会影响可见区域的计算。需要监听resize事件,并更新相关状态。ResizeObserver是一个现代且高效的解决方案,如我们在可变高度示例中所示。 -
滚动到指定位置: 有时我们需要程序化地滚动到某个特定的列表项。这可以通过设置
containerRef.current.scrollTop来实现。对于可变高度列表,需要根据itemPositions缓存来计算目标索引的top值。 -
SSR (服务器端渲染) 兼容性: 在 SSR 环境下,初次渲染时无法进行 DOM 测量。此时,
itemPositions将只能依赖estimatedItemHeight。一旦客户端 JS 接管,再进行实际测量。
总结
虚拟列表是处理大量数据的有效武器,它通过智能地控制 React 组件的渲染输出,直接影响了 React Fiber 节点的创建、更新和销毁。我们并非直接操作 Fiber 节点,而是通过我们的组件 render 方法返回的 JSX 结构,向 React 协调器发出指令。当 startIndex 和 endIndex 范围变化时,React 发现组件树的差异,进而动态地挂载或卸载对应的 Fiber 节点及其关联的 DOM 元素。
无论是固定高度还是可变高度列表,核心挑战都在于精确计算可见区域的范围和位置偏移,并在此基础上,通过 slice 或类似的机制,仅将这部分数据传递给 React 进行渲染。深入理解 React 的 Fiber 架构,能够帮助我们更好地设计和调试高性能的虚拟列表组件,确保用户获得流畅的交互体验。