最近在做面试复习,遇到两道看起来毫不相关的题:一道是递归渲染的组织树,一道是固定行高的虚拟列表。
乍一看,一个是树形 UI 组件,一个是性能优化 Hook,思路应该截然不同。但写着写着,我发现它们解决的是同一个根问题:当数据量超过视觉或性能承受范围时,如何决定「渲染哪些、跳过哪些」?
只是裁剪的依据不同——Tree 靠用户交互,VirtualList 靠滚动位置。这篇文章是我整理这两道题的学习笔记。
两道题的问题本质
先把两道题的核心约束提炼出来:
DepartmentTree :
- 任意层级递归,不限深度
- 展开/收起状态统一在顶层
useState管理(不能在子节点内useState) - 非叶节点的费用 = 子树费用之和(递归计算派生值)
useVirtualList :
- 给定
containerHeight和itemHeight,根据scrollTop计算当前应渲染哪些项 - 返回
visibleData(裁切后的数据切片)、totalHeight(撑开滚动区域)、offsetY(定位偏移)
本质上,两者都在回答同一个问题:当全量渲染代价太高时,我应该渲染哪一部分?
Tree → 用户说「我要看这个节点」→ 展开那个分支
VList → 滚动位置说「视口在这里」→ 渲染那个区间
一个是主动裁剪(交互驱动),一个是被动裁剪(位置驱动)。理解这个差异,有助于在面试中说清楚设计思路。
DepartmentTree:状态提升 + 递归派生
最难的不是递归,是「状态放在哪里」
题目有一个明确要求:expanded 用 Set<string> 在树根组件用 useState 管理,不能在递归子组件内 useState。
这个要求背后的原因值得想清楚:如果每个子节点自己管理展开状态,那么树的整体状态就是碎片化的——你无法从外部获取「整棵树的展开快照」,也无法实现「全部展开/收起」这类操作。
这是状态提升(lifting state up) 的经典场景:把多个子组件共享或需要被父组件感知的状态,集中到最近公共祖先管理。
// 环境:React
// 场景:DepartmentTree 根组件,集中管理 expanded 状态
function DepartmentTree({ data }) {
// collect all root node IDs as initially expanded
const rootIds = data.map((node) => node.id);
const [expanded, setExpanded] = useState(new Set(rootIds));
const toggle = (id) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<div>
{data.map((node) => (
<TreeNode key={node.id} node={node} expanded={expanded} onToggle={toggle} />
))}
</div>
);
}
注意 toggle 里用 new Set(prev) 创建新集合——Set 是引用类型,直接 prev.add() 不会触发重渲染,需要返回新对象。这是一个容易踩的细节。
递归渲染:把「自己」当成「子树的入口」
递归组件的思维模式是:当前节点只负责渲染自己,并把子节点交给「同一个组件」处理。
// 环境:React
// 场景:树节点递归组件
function TreeNode({ node, expanded, onToggle }) {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expanded.has(node.id);
const totalExpense = calcTotalExpense(node);
return (
<div data-testid={`node-${node.id}`}>
<span onClick={() => hasChildren && onToggle(node.id)}>
{hasChildren ? (isExpanded ? '▼' : '▶') : '·'} {node.name}
</span>
<span data-testid={`expense-${node.id}`}>{totalExpense}</span>
{/* only render children when expanded */}
{hasChildren && isExpanded && (
<div style={{ paddingLeft: 16 }}>
{node.children.map((child) => (
<TreeNode key={child.id} node={child} expanded={expanded} onToggle={onToggle} />
))}
</div>
)}
</div>
);
}
这里有个「裁剪」在悄悄发生:isExpanded && <children>——当节点收起时,子树完全不渲染,节点也不挂载。这正是 Tree 的「主动裁剪」策略。
派生值的计算:递归还是 useMemo?
费用聚合是一个经典的「树上递归求和」问题:
// 场景:递归计算子树费用总和
// 叶子节点直接返回自身 totalExpense
// 非叶节点返回所有子节点费用之和
function calcTotalExpense(node) {
if (!node.children || node.children.length === 0) {
return node.totalExpense;
}
return node.children.reduce((sum, child) => sum + calcTotalExpense(child), 0);
}
这个计算放在渲染函数里会有一个潜在问题:每次渲染都重新遍历子树。如果树很深、节点很多,可以考虑 useMemo 缓存。不过面试中我的理解是,先写出正确的逻辑,再提「可以用 useMemo 优化」,比直接写带优化的复杂代码更能展示思路清晰度。
useVirtualList:数学建模 + 窗口映射
核心思想:用数学代替 DOM
虚拟列表的出发点是:如果列表有 10000 项,真正进入视口的只有 Math.ceil(containerHeight / itemHeight) 项(大约 20-30 项)。其余 9970+ 项的 DOM 节点是浪费。
虚拟列表的方案是:只渲染视口内的项,用总高度撑开滚动条,用偏移量定位渲染区域。
虚拟列表的本质是欺骗用户的眼睛:要实现这个"骗局",必须解决:
| 问题 | 解决方案 |
|---|---|
| 滚动条怎么看起来完整? | 用一个超高的占位元素撑开 |
| 渲染哪几项? | 根据 scrollTop 算索引 |
| 怎么让它们出现在正确位置? | 用 transform: translateY 偏移 |
totalHeight = data.length * itemHeight // 撑开滚动区域,让滚动条看起来「完整」
startIndex = Math.floor(scrollTop / itemHeight) // 第一个可见项的索引
endIndex = startIndex + visibleCount // 最后一个可见项的索引
offsetY = startIndex * itemHeight // 渲染区域的起始偏移
用图来理解这个映射关系:
┌─────────────────────────┐ ← scrollTop = 0
│ item 0 (不可见,已划过) │
│ item 1 │
│ ... │
├─────────────────────────┤ ← scrollTop(视口顶部)
│ item N ← startIndex │ ↑
│ item N+1 │ │
│ item N+2 │ containerHeight(视口)
│ item N+3 │ │
│ item N+4 ← endIndex │ ↓
├─────────────────────────┤
│ item N+5(不可见,未到) │
│ ... │
└─────────────────────────┘ ← totalHeight
逐步拆解实现
1. 计算总高度(撑开滚动条)
const totalHeight = data.length * itemHeight;
// 10000 项 × 40px = 400000px
这个 totalHeight 用来创建一个"幽灵"容器,让浏览器以为列表真的有那么长,滚动条才会正确显示。
2. 计算当前该渲染哪些项
// 视口能装下多少项(向上取整)
const visibleCount = Math.ceil(containerHeight / itemHeight);
// 500px / 40px = 12.5 → 13 项
// 第一个可见项的索引
const rawStart = Math.floor(scrollTop / itemHeight);
// 滚动 120px / 40px = 3 → 从第 3 项开始
// 加缓冲,防止快速滚动白屏
const startIndex = Math.max(0, rawStart - overscan); // 往前多渲染 3 项
const endIndex = Math.min(data.length - 1, rawStart + visibleCount + overscan); // 往后多渲染 3 项
为什么需要 overscan?
想象你快速往下滑,如果刚好渲染视口内的项,浏览器来不及渲染新的,用户就会看到白屏。多渲染几项作为缓冲,让滚动更顺滑。
3. 切片数据
const visibleData = data.slice(startIndex, endIndex + 1);
// 只取 [startIndex, endIndex] 这个区间的数据
4. 计算偏移量(关键!)
const offsetY = startIndex * itemHeight;
// 第 3 项 × 40px = 120px
这个 offsetY 决定了渲染区域从哪开始。看下图:
┌─────────────────────────────┐ ← scrollTop = 120px
│ 第 0 项(在视口上方,不渲染)│
│ 第 1 项 │
│ 第 2 项 │
├─────────────────────────────┤ ← 实际视口顶部
│ 第 3 项 ← startIndex │ ↑
│ 第 4 项 │ │
│ ... │ containerHeight (500px)
│ 第 15 项 ← endIndex │ ↓
├─────────────────────────────┤
│ 第 16 项(在视口下方,不渲染)│
│ ... │
└─────────────────────────────┘ ← totalHeight = 400000px
渲染区域用 transform: translateY(120px) 往下推,让第 3 项刚好出现在视口顶部。
useVirtualList 的完整实现
// 环境:React
// 场景:固定行高虚拟列表 Hook
function useVirtualList({ data, itemHeight, containerHeight, overscan = 3 }) {
const [scrollTop, setScrollTop] = useState(0);
// 1. 总高度 → 撑开滚动条
const totalHeight = data.length * itemHeight;
// 2. 计算可见索引范围
const visibleCount = Math.ceil(containerHeight / itemHeight);
const rawStart = Math.floor(scrollTop / itemHeight);
const startIndex = Math.max(0, rawStart - overscan);
const endIndex = Math.min(data.length - 1, rawStart + visibleCount + overscan);
// 3. 切片数据
const visibleData = useMemo(
() => data.slice(startIndex, endIndex + 1),
[data, startIndex, endIndex]
);
// 4. 计算偏移
const offsetY = startIndex * itemHeight;
return {
visibleData, // 要渲染的数据(约 16 项)
totalHeight, // 400000px(撑开滚动条)
offsetY, // 120px(往下推,让第 3 项对齐视口顶部)
onScroll: setScrollTop
};
}
overscan 是什么? 是在视口边缘额外渲染的缓冲项数。如果没有 overscan,快速滚动时视口边缘会出现短暂的白屏(渲染还没跟上滚动速度)。默认 3 是一个常见的经验值。
消费端的用法
Hook 设计为纯计算层,UI 层负责真正的定位和渲染:
// 环境:React
// 场景:虚拟列表容器组件示例
function VirtualListContainer({ items }) {
const containerRef = useRef(null);
const { visibleData, totalHeight, offsetY, onScroll } = useVirtualList({
data: items,
itemHeight: 40,
containerHeight: 500,
overscan: 3,
});
const handleScroll = (e) => {
onScroll(e.currentTarget.scrollTop);
};
return (
// outer container: fixed height, overflow scroll
<div
ref={containerRef}
style={{ height: 500, overflowY: 'auto' }}
onScroll={handleScroll}
>
{/* inner container: full virtual height to enable real scrollbar */}
<div style={{ height: totalHeight, position: 'relative' }}>
{/* render area: offset from top */}
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleData.map((item) => (
<div key={item.id} style={{ height: 40 }}>
{item.name}
</div>
))}
</div>
</div>
</div>
);
}
这里三层 div 的嵌套关系是虚拟列表的标准结构,值得记清楚:
<div
style={{ height: 500, overflowY: 'auto' }} // 外层:固定高度,接收滚动
onScroll={(e) => onScroll(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: 'relative' }}> // 中层:撑开滚动条
<div style={{ transform: `translateY(${offsetY}px)` }}> // 内层:偏移定位
{visibleData.map(item => <div key={item.id} style={{ height: 40 }}>{item.name}</div>)}
</div>
</div>
</div>
- 外层:固定高度,
overflow: auto,接收滚动事件 - 中层:
height = totalHeight,撑开滚动条 - 内层:
translateY(offsetY),把渲染区域移到正确位置
一个容易混淆的点
为什么
offsetY = startIndex * itemHeight,而不是直接用scrollTop?
因为 startIndex 被 overscan 修正过了。假设:
scrollTop = 120,rawStart = 3startIndex = 3 - 3 = 0(往前缓冲 3 项)
如果用 scrollTop 偏移,第 0 项会从 120px 开始,位置就错了。
用 startIndex * itemHeight = 0,第 0 项从顶部开始,才对齐。
两种「裁剪哲学」的对比
整理完两道题,做一个对比:
| 维度 | DepartmentTree | useVirtualList |
|---|---|---|
| 裁剪依据 | 用户展开/收起交互 | 滚动位置(数学计算) |
| 裁剪时机 | 离散(每次点击) | 连续(每次滚动) |
| 不渲染的代价 | 节点完全不挂载(省内存) | 节点不存在 DOM(省渲染) |
| 状态管理位置 | 集中在树根(状态提升) | 集中在 Hook 内(封装) |
| 核心难点 | 递归 + 状态提升 + 派生计算 | 索引边界计算 + overscan |
两者都在说同一件事:不要让 DOM 承担数据结构应该承担的工作。
面试时值得主动提的点
复习这两道题时,我整理了一些「回答加分项」:
DepartmentTree:
expanded为什么用Set而不是数组?因为Set.has()是 O(1),数组includes()是 O(n),节点多的时候差距明显toggle为什么要返回new Set(prev)而不是修改prev?React state 需要不可变更新才能触发重渲染- 如果需要「全部展开/全部收起」功能,集中管理的
expandedSet 会非常方便
useVirtualList:
- 为什么需要
overscan?快速滚动时缓冲几项可以避免白屏 startIndex * itemHeight计算offsetY而非scrollTop,是因为 overscan 修正了 startIndex,需要保持对齐- 动态行高的场景怎么处理?需要预测或缓存每项高度,复杂度显著提升——这是可以展开讨论的延伸方向
延伸与发散
几个在复习过程中产生的新问题,还没想清楚,记下来后续探索:
- Tree 的性能优化:当树节点非常多时,每次展开/收起都会导致所有节点重渲染。
React.memo+ 稳定的onToggle引用(useCallback)能解决这个问题吗?还是需要更细粒度的状态拆分? - VirtualList 的高度不固定场景:
useVirtualList的 Hook 里默认itemHeight是固定的。如果每个 item 高度不同(比如内容是动态的),计算逻辑会复杂很多。react-window 和 react-virtual 是怎么处理这个问题的? - 两者的结合:如果是一个「可展开的虚拟树」(比如大型文件目录),同时需要递归渲染和虚拟化,状态管理会是什么形态?这类组件在实际库(如 AG Grid、Tree Select)里是如何实现的?
小结
这两道面试题表面看差异很大,但放在「如何只渲染必要部分」这个视角下,有一套共通的思维脉络:
先建模,再裁剪,最后优化。
- 建模:搞清楚数据结构(树 or 列表),以及衍生状态的计算逻辑
- 裁剪:决定什么时候渲染、什么时候跳过(交互驱动 or 位置驱动)
- 优化:
useMemo、useCallback、React.memo——在正确的地方加,而不是到处加
这篇文章更多是我整理思路的过程记录,实现细节可能还有待打磨。如果你有不同的实现思路或发现了什么问题,欢迎交流讨论。
参考资料
- React 官方文档 - Lifting State Up - 状态提升的官方说明
- React 官方文档 - Rendering Lists - 列表渲染与 key
- MDN - Set - Set 数据结构与时间复杂度
- react-virtual GitHub - TanStack Virtual,现代虚拟列表库的实现参考