只渲染「必要的部分」:从 DepartmentTree 和 VirtualList 看前端的两种裁剪哲学

0 阅读10分钟

最近在做面试复习,遇到两道看起来毫不相关的题:一道是递归渲染的组织树,一道是固定行高的虚拟列表。

乍一看,一个是树形 UI 组件,一个是性能优化 Hook,思路应该截然不同。但写着写着,我发现它们解决的是同一个根问题:当数据量超过视觉或性能承受范围时,如何决定「渲染哪些、跳过哪些」?

只是裁剪的依据不同——Tree 靠用户交互,VirtualList 靠滚动位置。这篇文章是我整理这两道题的学习笔记。


两道题的问题本质

先把两道题的核心约束提炼出来:

DepartmentTree

  • 任意层级递归,不限深度
  • 展开/收起状态统一在顶层 useState 管理(不能在子节点内 useState
  • 非叶节点的费用 = 子树费用之和(递归计算派生值)

useVirtualList

  • 给定 containerHeightitemHeight,根据 scrollTop 计算当前应渲染哪些项
  • 返回 visibleData(裁切后的数据切片)、totalHeight(撑开滚动区域)、offsetY(定位偏移)

本质上,两者都在回答同一个问题:当全量渲染代价太高时,我应该渲染哪一部分?

Tree     → 用户说「我要看这个节点」→ 展开那个分支
VList    → 滚动位置说「视口在这里」→ 渲染那个区间

一个是主动裁剪(交互驱动),一个是被动裁剪(位置驱动)。理解这个差异,有助于在面试中说清楚设计思路。


DepartmentTree:状态提升 + 递归派生

最难的不是递归,是「状态放在哪里」

题目有一个明确要求:expandedSet<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>
  1. 外层:固定高度,overflow: auto,接收滚动事件
  2. 中层height = totalHeight,撑开滚动条
  3. 内层translateY(offsetY),把渲染区域移到正确位置

一个容易混淆的点

为什么 offsetY = startIndex * itemHeight,而不是直接用 scrollTop

因为 startIndexoverscan 修正过了。假设:

  • scrollTop = 120rawStart = 3
  • startIndex = 3 - 3 = 0(往前缓冲 3 项)

如果用 scrollTop 偏移,第 0 项会从 120px 开始,位置就错了。 用 startIndex * itemHeight = 0,第 0 项从顶部开始,才对齐。


两种「裁剪哲学」的对比

整理完两道题,做一个对比:

维度DepartmentTreeuseVirtualList
裁剪依据用户展开/收起交互滚动位置(数学计算)
裁剪时机离散(每次点击)连续(每次滚动)
不渲染的代价节点完全不挂载(省内存)节点不存在 DOM(省渲染)
状态管理位置集中在树根(状态提升)集中在 Hook 内(封装)
核心难点递归 + 状态提升 + 派生计算索引边界计算 + overscan

两者都在说同一件事:不要让 DOM 承担数据结构应该承担的工作


面试时值得主动提的点

复习这两道题时,我整理了一些「回答加分项」:

DepartmentTree

  • expanded 为什么用 Set 而不是数组?因为 Set.has() 是 O(1),数组 includes() 是 O(n),节点多的时候差距明显
  • toggle 为什么要返回 new Set(prev) 而不是修改 prev?React state 需要不可变更新才能触发重渲染
  • 如果需要「全部展开/全部收起」功能,集中管理的 expanded Set 会非常方便

useVirtualList

  • 为什么需要 overscan?快速滚动时缓冲几项可以避免白屏
  • startIndex * itemHeight 计算 offsetY 而非 scrollTop,是因为 overscan 修正了 startIndex,需要保持对齐
  • 动态行高的场景怎么处理?需要预测或缓存每项高度,复杂度显著提升——这是可以展开讨论的延伸方向

延伸与发散

几个在复习过程中产生的新问题,还没想清楚,记下来后续探索:

  1. Tree 的性能优化:当树节点非常多时,每次展开/收起都会导致所有节点重渲染。React.memo + 稳定的 onToggle 引用(useCallback)能解决这个问题吗?还是需要更细粒度的状态拆分?
  2. VirtualList 的高度不固定场景useVirtualList 的 Hook 里默认 itemHeight 是固定的。如果每个 item 高度不同(比如内容是动态的),计算逻辑会复杂很多。react-window 和 react-virtual 是怎么处理这个问题的?
  3. 两者的结合:如果是一个「可展开的虚拟树」(比如大型文件目录),同时需要递归渲染和虚拟化,状态管理会是什么形态?这类组件在实际库(如 AG Grid、Tree Select)里是如何实现的?

小结

这两道面试题表面看差异很大,但放在「如何只渲染必要部分」这个视角下,有一套共通的思维脉络:

先建模,再裁剪,最后优化。

  • 建模:搞清楚数据结构(树 or 列表),以及衍生状态的计算逻辑
  • 裁剪:决定什么时候渲染、什么时候跳过(交互驱动 or 位置驱动)
  • 优化:useMemouseCallbackReact.memo——在正确的地方加,而不是到处加

这篇文章更多是我整理思路的过程记录,实现细节可能还有待打磨。如果你有不同的实现思路或发现了什么问题,欢迎交流讨论。


参考资料