使用简单口语来回答,因为简单口语容易记忆,同时要显示出这个组件最复杂、我解决这个最复杂的组件是很不容易的。
“What is the most complex component you’ve built?”
The most complex component I’ve worked on is a large-scale Virtualized Tree component, something like a file explorer used in a dashboard system.
At first glance, it looks simple, but it actually combines several hard problems together.
The main challenges were two things:
First, it’s a tree structure, not a flat list. So we need to handle nested nodes, expand and collapse states, and maintain hierarchy.
Second, we also need virtual scrolling, because the tree can contain tens of thousands of nodes. So rendering everything would completely break performance.
The hardest part was how to combine these two things efficiently.
What I did was redesign the data model. Instead of rendering the tree directly, I flattened it into a visible linear(/ˈlɪniər/) list, where each node carries its depth information.
Then I treated it like a normal virtual list, and only rendered the visible window based on scroll position.
On top of that, I optimized expand and collapse operations so they only update a small part of the visible list instead of recomputing the entire tree.
This reduced unnecessary re-rendering significantly and made the interaction very smooth even with large datasets.
Honestly, it wasn’t a trivial(/ˈtrɪviəl/ 琐碎的,容易解决的) component. It forced me to think deeply about data structure design, performance trade-offs, and rendering strategy, not just React implementation.
是的,我做过最复杂的组件,是一个大规模虚拟化 Tree 组件,类似文件管理器的左侧目录结构。
一开始看起来不复杂,但实际上它同时包含几个难点:
第一,它是一个树结构,不是普通 list,需要处理层级关系、展开/收起状态。
第二,它还要支持虚拟滚动,因为节点数量可能非常大,如果全部渲染会直接卡死。
最难的地方在于:如何把 Tree 和 Virtual List 合在一起。
我当时的做法是重新设计数据结构,不直接渲染 Tree,而是把它转换成一个带层级信息的线性数组(visible list)。
然后用普通虚拟列表的方式,只渲染当前可见区域。
同时在 expand / collapse 时,我没有重新遍历整棵树,而是只对受影响的子树做局部更新,从而避免了 O(N) 的全量计算。
这个优化让大数据量下的交互非常流畅。
说实话,这个组件不只是 UI 问题,而是一个涉及数据结构设计 + 性能优化 + 渲染模型设计的综合问题。
你的直觉是对的,但真正的难点不是 Virtualization 本身,而是:
Tree 不是连续的数据,而 Virtual List 要求是连续的数据。
所以 Tree Virtualization = Tree Flattening + List Virtualization。
假设一个 list
xxxxxxxxxx012345678...99999Virtual List 很容易。因为
xxxxxxxxxxscrollTop = 800pxstartIndex = Math.floor(scrollTop / itemHeight)endIndex = startIndex + visibleCount例如
xxxxxxxxxx显示404142434445index 是天然存在的。
Tree 是这种结构:
xxxxxxxxxxA ├── A1 │ ├── A11 │ └── A12 ├── A2 │ ├── A21 │ └── A22 └── A3B ├── B1 └── B2问题来了:假设
xxxxxxxxxxA 是展开A1 是展开A2 是收起B 是收起
真正应该显示的是
xxxxxxxxxxAA1A11A12A2A3B而不是
xxxxxxxxxxA21A22B1B2
因为它们不可见。所以:Virtual Scroll 根本不知道第 50 个 node 是谁。
几乎所有 Tree Virtualization 都会先做
xxxxxxxxxxTree↓Flatten↓Visible List
例如:Tree
xxxxxxxxxxA ├── A1 │ ├── A11 │ └── A12 ├── A2 └── A3B变成
xxxxxxxxxx[ A, A1, A11, A12, A2, A3, B]注意:这里只放 visible node。
DFS (depth-First Search 深度优先搜索)即可。例如
xxxxxxxxxxfunction flattenTree( nodes: TreeNode[], expandedSet: Set<string>): VisibleNode[] { const result: VisibleNode[] = []; function dfs(node: TreeNode, depth: number) { result.push({ node, depth, }); if ( node.children && expandedSet.has(node.id) ) { node.children.forEach((child) => dfs(child, depth + 1) ); } } nodes.forEach((node) => dfs(node, 0)); return result;}最后得到
xxxxxxxxxx[ {id:'A', depth:0}, {id:'A1', depth:1}, {id:'A11', depth:2}, {id:'A12', depth:2}, {id:'A2', depth:1}, {id:'A3', depth:1}, {id:'B', depth:0},]这个数组就是 Virtual List 的数据源。
这里的expandedSet是什么?
就是当前展开节点的 id。
为什么需要它?
expandedSet 用来记录 Tree 当前的展开状态,因为原始 Tree 数据只描述“结构”,不描述“用户当前看到了什么”。如果一个节点有children,但是它并没有展开,那么它的children节点就不需要计算进入 visibleNodes 里面去。
刚开始它是什么样的?
刚开始它可能只包含root节点,这样第一层的子节点可以看到;或者是特定的节点。反正 expandedSet 不用担心初始值,可以自定义。
整个流程如下:
xxxxxxxxxxTree Data||用户点击节点|↓update expandedSet|↓flattenTree(tree, expandedSet)|↓visibleNodes|↓Virtual List
xxxxxxxxxxresult.push({node,depth,});result里面直接加入了node节点,如果树结构的node节点很大、很深,那么result也会相应的很大,对性能会有影响吗?
实际上不会。因为 JavaScript 的展开运算符(Spread)是浅拷贝(Shallow Copy)。只是复制了一个引用。
但是生产环境还是只将必要的属性复制过来即可,不复制整个node。
假设
xxxxxxxxxxflatten.length = 20000
Virtual List 根本不知道这是 Tree。它只知道
xxxxxxxxxx012345
例如
xxxxxxxxxxstart = 150end = 170
直接
xxxxxxxxxxvisible = flatten.slice(start, end)就结束了。
这是第二个难点。假设
xxxxxxxxxxA├── A1├── A2└── A3
点击展开 A
以前
xxxxxxxxxxflattenAB
展开后
xxxxxxxxxxflattenAA1A2A3B
其实就是:重新 flatten。
很多人以为要:
xxxxxxxxxxinsertremoveshift index
实际上没必要。现代框架:
xxxxxxxxxx用户点击节点↓更新 expandedSet↓重新 DFS↓得到新的 flatten↓React diff
已经足够快。
假设
xxxxxxxxxx100 万 node
每次 DFS?那就炸了。所以真正的大型 Tree(例如 IDE、文件管理器)不会每次重新 DFS 全树。
通常会维护:
xxxxxxxxxxTree+Visible List
例如
xxxxxxxxxxvisibleAA1A2B
点击
xxxxxxxxxxExpand A2
直接
xxxxxxxxxxsplice()插进去A21A22A23
而不是重新 DFS。
例如
xxxxxxxxxxvisible.splice( index + 1, 0, children)Collapse
就是
xxxxxxxxxxsplice( index + 1, descendantCount)这样复杂度从
xxxxxxxxxxO(N)
变成
xxxxxxxxxxO(k)
其中
xxxxxxxxxxk = 被展开的子树大小
真正困难的其实不是 Tree。而是:
xxxxxxxxxxTree+Virtual+Variable Height
例如
xxxxxxxxxxFolderFileFileFile↓Folder 展开以后高度变化
Virtual List 需要知道:
xxxxxxxxxx第 1500 个 item 的 top 是多少?
如果高度不同:
xxxxxxxxxx20202038415219
就不能再简单计算:
xxxxxxxxxxindex * itemHeight
需要维护:
xxxxxxxxxxheight cache↓prefix sum↓binary search
很多虚拟列表库(例如基于动态尺寸的实现)都会维护每个节点的测量高度和累计偏移,滚动定位时通过二分查找找到当前可见项。
如果面试官问:
Tree 能不能做 Virtualization?
比较完整的回答可以是:
可以。核心思路不是直接虚拟化 Tree,而是先根据当前展开状态把 Tree 扁平化(Flatten)成一个只包含可见节点的线性数组,每个节点记录自己的
depth用于缩进显示。然后 Virtual List 就可以像处理普通数组一样,根据滚动位置只渲染可见区间的节点。如果数据规模不大,每次展开或收起时重新 DFS 生成扁平数组即可;如果节点数量非常大,为了避免每次都遍历整棵树,通常会维护一个可见节点列表,在展开时插入对应子树、收起时删除对应后代,将更新范围限制在受影响的子树,从而把更新成本降低到与子树大小相关,而不是与整棵树大小相关。
The main challenge is that a tree is not a flat structure like a list.
For a normal virtualized list, every item already has an index, so it's easy to calculate which items should be rendered based on the scroll position.
A tree is different because some nodes are expanded while others are collapsed. That means not every node is actually visible.
So the first step is to flatten the tree into a visible list. I usually perform a DFS traversal(/trəˈvərs(ə)l/ 遍历) and only include the nodes whose parents are expanded. During this process, I also record each node's depth so the UI knows how much indentation(缩进) to apply.
After that, virtualization becomes exactly the same as a normal list. The virtual list only works with the flattened array and renders the visible range.
If the dataset isn't huge, I simply regenerate the flattened list whenever the user expands or collapses a node.
For very large trees, rebuilding the whole list every time can be expensive. In that case, I'd maintain the visible list incrementally, inserting or removing only the affected subtree when a node is expanded or collapsed.
可以,完全可以。
最大的区别是,Tree 不是一个连续的 List。
普通 List 本来就有 index,所以很容易根据 scroll position 算出应该渲染哪些 item。
但是 Tree 有展开和收起的状态,所以很多节点其实并不会显示出来。
因此第一步通常会先把 Tree Flatten 成一个 Visible List。我一般会用 DFS,只把当前可见的节点放进数组,同时记录每个节点的 depth,用来控制缩进。
得到这个数组以后,Virtualization 就和普通 List 完全一样了,只需要根据 scroll position 渲染当前可见范围即可。
如果数据量不是特别大,每次展开或收起时重新生成这个数组就可以。
如果 Tree 非常大,我会维护一个可见节点列表,只更新受影响的那部分子树,而不是重新遍历整棵树。
Tree virtualization is basically "Tree Flattening + List Virtualization". Once the tree is flattened into a visible list, the virtualization logic is almost identical to a normal virtualized list.
中文:
Tree Virtualization 本质上就是:先把 Tree 扁平化成 Visible List,再使用普通的 List Virtualization。 这是很多成熟组件库(例如 IDE 文件树或大型文件管理器)采用的核心思路。
把 Tree 变成一个“带层级信息的线性数组”,然后当普通 List 做 Virtualization。
中文:
先“压平 Tree”,再用普通虚拟列表渲染。
Tree 是:
xxxxxxxxxxsrc ├── components │ ├── Button │ └── Modal ├── pages │ ├── Home │ └── About └── package.json但是 Virtual List 需要索引:
xxxxxxxxxxindex = 0..N那么因为 Tree:
所以:
❌ 不能直接 virtualize Tree
我们不再直接用 Tree。我们先问一个问题:
“当前页面真正显示了哪些节点?”
例如:
xxxxxxxxxxsrc (展开) components (展开) Button.tsx Modal.tsx pages (收起)package.json👉 UI 实际是:
xxxxxxxxxx[src, components, Button, Modal, pages, package.json]
我们把 Tree 转换成:
xxxxxxxxxxVisibleNode[] = Linear Array每个节点带一个关键字段:
xxxxxxxxxxdepth: number示例结果:
xxxxxxxxxxsrc depth 0components depth 1Button depth 2Modal depth 2pages depth 1package.json depth 0
核心就是 DFS,但有一个关键规则:
xxxxxxxxxxif node is expanded → traverse childrenelse skip childrenxxxxxxxxxxvisit node 点击节点 ↓update expandedSet ↓if expanded → dfs(children)
它不是 Tree:
xxxxxxxxxx❌ Tree structure而是:
xxxxxxxxxx✅ Linear visible array
现在:
xxxxxxxxxxvisibleNodes = flat array我们直接:
xxxxxxxxxxstartIndex = scrollTop / rowHeightendIndex = startIndex + visibleCount然后:
xxxxxxxxxxslice(startIndex, endIndex)
xxxxxxxxxxTree ↓flattenTree(expandedSet) ↓visibleNodes[] ↓Virtual List ↓render
Expand:
xxxxxxxxxxupdate expandedSetre-run flattenTreeCollapse:
xxxxxxxxxxupdate expandedSetre-run flattenTree注意:
❗ 每次变更都会重新 flatten
👍 优点:
👎 缺点:
目录结构:
xxxxxxxxxxsrc/│├── App.tsx├── VirtualTree.tsx├── flatten.ts├── types.ts└── data.ts
xxxxxxxxxxexport interface TreeNode { id: string; name: string; children?: TreeNode[];}export interface VisibleNode extends TreeNode { depth: number;}
xxxxxxxxxximport { TreeNode } from "./types";export const treeData: TreeNode[] = [ { id: "src", name: "src", children: [ { id: "components", name: "components", children: [ { id: "button", name: "Button.tsx", }, { id: "modal", name: "Modal.tsx", }, ], }, { id: "pages", name: "pages", children: [ { id: "home", name: "Home.tsx", }, { id: "about", name: "About.tsx", }, ], }, { id: "package", name: "package.json", }, ], },];
这是整个 Tree Virtualization 最重要的一步。
xxxxxxxxxximport { TreeNode, VisibleNode } from "./types";export function flattenTree( nodes: TreeNode[], expanded: Set<string>): VisibleNode[] { const result: VisibleNode[] = []; function dfs(node: TreeNode, depth: number) { result.push({ node, depth, }); if ( node.children && expanded.has(node.id) ) { node.children.forEach((child) => dfs(child, depth + 1) ); } } nodes.forEach((node) => dfs(node, 0)); return result;}
这个组件里面包含:
xxxxxxxxxxflatten↓virtual↓render
xxxxxxxxxximport { useMemo, useState } from "react";import { TreeNode } from "./types";import { flattenTree } from "./flatten";const ROW_HEIGHT = 32;const HEIGHT = 220;interface Props { tree: TreeNode[];}export default function VirtualTree({ tree,}: Props) { const [expanded, setExpanded] = useState( new Set(["src"]) ); const [scrollTop, setScrollTop] = useState(0); const visibleNodes = useMemo( () => flattenTree(tree, expanded), [tree, expanded] ); const totalHeight = visibleNodes.length * ROW_HEIGHT; const startIndex = Math.floor( scrollTop / ROW_HEIGHT ); const visibleCount = Math.ceil(HEIGHT / ROW_HEIGHT); const endIndex = startIndex + visibleCount + 1; const items = visibleNodes.slice( startIndex, endIndex ); function toggle(id: string) { const next = new Set(expanded); if (next.has(id)) { next.delete(id); } else { next.add(id); } setExpanded(next); } return ( <div style={{ height: HEIGHT, overflow: "auto", border: "1px solid #ddd", }} onScroll={(e) => setScrollTop( e.currentTarget.scrollTop ) } > <div style={{ height: totalHeight, position: "relative", }} > {items.map((node, index) => { const realIndex = startIndex + index; return ( <div key={node.id} onClick={() => if(!node.children) return; toggle(node.id) } style={{ position: "absolute", top: realIndex * ROW_HEIGHT, height: ROW_HEIGHT, lineHeight: `${ROW_HEIGHT}px`, left: node.depth * 20, cursor: "pointer", }} > {node.children ? expanded.has(node.id) ? "📂" : "📁" : "📄"}{" "} {node.name} </div> ); })} </div> </div> );}
xxxxxxxxxximport VirtualTree from "./VirtualTree";import { treeData } from "./data";export default function App() { return ( <div style={{ width: 400, margin: "40px auto", }} > <VirtualTree tree={treeData} /> </div> );}
第一次:
xxxxxxxxxx📂 src📁 components📁 pages📄 package.json
点击 components
xxxxxxxxxx📂 src📂 components📄 Button.tsx📄 Modal.tsx📁 pages📄 package.json
点击 pages
xxxxxxxxxx📂 src📂 components📄 Button.tsx📄 Modal.tsx📂 pages📄 Home.tsx📄 About.tsx📄 package.json
整个过程中,真正渲染的只有:
xxxxxxxxxxconst items = visibleNodes.slice(startIndex, endIndex);这就是 Virtualization。
真正生产环境的 Virtual Tree(例如 VS Code Explorer、Chrome DevTools、IDE 文件树)还会继续优化,例如:
这些才是真正接近 react-arborist、VS Code Explorer 等专业 Tree Virtualization 实现的核心技术。如果你准备高级前端或 Staff 级面试,我也可以带你把这个 Demo 一步步演进成接近生产级的实现。
如果让我设计一套面试学习路线,我会按下面的版本逐步演进,每一步都对应一个真实工程问题。
xxxxxxxxxxv1 Tree + Flatten + Fixed Virtual List ⭐⭐(我们已经完成)↓v2 Overscan ⭐⭐⭐↓v3 Variable Height ⭐⭐⭐⭐↓v4 Incremental Visible List ⭐⭐⭐⭐⭐↓v5 Async Load Children ⭐⭐⭐⭐⭐↓v6 Production Virtual Tree ⭐⭐⭐⭐⭐⭐
现在我们的代码有一个问题。
假设:
xxxxxxxxxxviewport--------------------Item 100Item 101Item 102Item 103--------------------
滚动 1px
以前:
xxxxxxxxxx卸载100挂载104
再滚
xxxxxxxxxx卸载101挂载105
React 一直在:
xxxxxxxxxxmountunmountmountunmount
所以滚动会有一点点抖。
真正 Virtual List 都会这样:
xxxxxxxxxxviewport--------------------9899100101102103104105--------------------
也就是说:
xxxxxxxxxx多渲染上下几行
这就是 Overscan。
代码改动非常小。
以前:
xxxxxxxxxxconst startIndex = Math.floor(scrollTop / ROW_HEIGHT)const endIndex = startIndex + visibleCount + 1改成:
xxxxxxxxxxconst overscan = 5const startIndex = Math.max( 0, Math.floor(scrollTop / ROW_HEIGHT) - overscan)const endIndex = Math.min( visibleNodes.length, startIndex + visibleCount + overscan * 2)是不是一下就流畅很多。
startIndex有点难理解。
仔细看,startIndex其实变小了,startIndex 变小,并不是为了"向上缓存",而是为了"用户已经滚过去但马上可能又滚回来"。
最小也就是0。
xxxxxxxxxx↑ 已经滚过去--------------------012...89--------------------10 ← viewport开始11121314--------------------151617...如果startIndex不变小一点,那么用户向上滚动的时候,可能就会出现空白。
不用管。
这是 Google、VS Code 最喜欢问的。我们现在:
点击:
xxxxxxxxxx展开 src
会:
xxxxxxxxxxDFS整个 Tree
哪怕:
xxxxxxxxxx100 万节点
也重新 DFS。
复杂度:
xxxxxxxxxxO(N)
这就是 Demo。
真正生产不会这样。
生产环境会维护:
xxxxxxxxxxvisibleNodes
例如:
xxxxxxxxxxsrccomponentspagespackage.json
点击:
xxxxxxxxxxcomponents
不会重新 DFS。
而是:
xxxxxxxxxx找到 components
然后:
xxxxxxxxxxsplice()
变成:
xxxxxxxxxxsrccomponentsButtonModalpagespackage.json
是不是只改了一小段。
复杂度:
xxxxxxxxxxO(k)
其中:
xxxxxxxxxxk = 子树大小
不是:
xxxxxxxxxx整个 Tree
例如 GitHub:
xxxxxxxxxx▶ node_modules
根本不知道:
xxxxxxxxxxchildren
点击:
xxxxxxxxxx展开
才:
xxxxxxxxxxfetch()↓API↓children
所以 Tree 会变成:
xxxxxxxxxxchildrenundefined
不是:
xxxxxxxxxx[]
展开:
xxxxxxxxxxLoading...
回来:
xxxxxxxxxxchildren有数据
重新插进去。
这时候你的组件其实已经接近:
架构会变成:
xxxxxxxxxxTree↓TreeStore│├── expandedSet├── visibleNodes├── heightCache├── nodeMap├── parentMap└── childrenMap↓Virtual Engine↓Render
这里你会发现,Virtual List 已经不是重点了。
真正复杂的是:
xxxxxxxxxxTreeStore
所有状态都在里面。
React Component:
几乎只是:
xxxxxxxxxxTreeStore↓visibleNodes↓render()
我不会让候选人写 TanStack Virtual 那种几千行源码。
我会希望看到下面这个演进过程:
xxxxxxxxxx✅ v1 Flatten Tree↓✅ v2 Fixed Virtual List↓✅ v3 Overscan↓✅ v4 Incremental Update(⭐⭐⭐⭐⭐)
v4 是最有价值的。
因为它体现的是数据结构和算法能力,而不是会不会用虚拟列表库。
不要继续优化滚动,而是重构数据结构。
我们会把现在每次都执行:
xxxxxxxxxxflattenTree(tree, expanded)改造成维护一个长期存在的 visibleNodes 数组:
xxxxxxxxxxvisibleNodes├── src├── components├── Button├── Modal├── pages└── package.json展开 components 时,我们只在数组中插入对应的子节点;收起时,只删除它的后代,而不再重新 DFS 整棵树。
这一版是真正生产级 Tree Virtualization 的核心,也是很多高级前端面试最希望听到的实现思路。
太好了,我们开始做 v4:Incremental Visible List。
这一版开始,我们的思路会发生一个很大的变化。
不再每次都
flattenTree(),而是把visibleNodes当作真正的数据源来维护。
这也是 VS Code、Chrome DevTools、很多 Tree 组件的核心思想。
我们之前的代码:
xxxxxxxxxxconst visibleNodes = useMemo( () => flattenTree(tree, expanded), [tree, expanded])假设 Tree 是这样的:
xxxxxxxxxxsrc├── components│ ├── Button│ └── Modal├── pages│ ├── Home│ └── About└── package.json第一次:
xxxxxxxxxxvisibleNodessrccomponentspagespackage.json点击:
xxxxxxxxxxcomponentsReact:
xxxxxxxxxx重新 DFS↓重新生成visibleNodes得到
xxxxxxxxxxsrccomponentsButtonModalpagespackage.json如果 Tree 有:
xxxxxxxxxx100 万 node每点一次:
xxxxxxxxxxDFS()100 万是不是很浪费?
生产环境不会这样。
它会一直维护:
xxxxxxxxxxconst [visibleNodes, setVisibleNodes] = useState()以后:只修改这个数组。
所以:
xxxxxxxxxxTree变成:
xxxxxxxxxxTree+VisibleNodes两个同时存在。
以前:
xxxxxxxxxxinterface VisibleNode { id name depth}现在不够。因为:点击
xxxxxxxxxxcomponents我要知道:
xxxxxxxxxxchildren 是谁?所以:
xxxxxxxxxxexport interface VisibleNode { id: string name: string depth: number children?: TreeNode[]}其实最简单就是:
xxxxxxxxxxVisibleNode extends TreeNode这个我们之前已经这么做了。
注意:第一次还是要 Flatten。
例如:
xxxxxxxxxxsrc展开。得到:
xxxxxxxxxxsrccomponentspagespackage.json以后:再也不用 DFS 整棵 Tree。
这是最重要的。
假设现在:
xxxxxxxxxx0 src1 components2 pages3 package.json点击:
xxxxxxxxxxcomponents应该变成:
xxxxxxxxxx0 src1 components2 Button3 Modal4 pages5 package.json注意:
Button
Modal
是插进去的。
不是重新生成。
所以找到:
xxxxxxxxxxcomponentsindex:
xxxxxxxxxxconst index = visibleNodes.findIndex()例如:
xxxxxxxxxxindex = 1然后DFS:
但是:只 DFS components。
得到:
xxxxxxxxxxButtonModal注意:不是 DFS 整棵 Tree。
只是:
xxxxxxxxxxcomponents.children然后:
xxxxxxxxxxvisibleNodes.splice( index + 1, 0, children)是不是完成了?
现在:
xxxxxxxxxxsrccomponentsButtonModalpagespackage.json点击:
xxxxxxxxxxcomponents应该删掉:
xxxxxxxxxxButtonModal怎么删?
很多人第一反应:DFS。
其实不用。
因为数组已经是:
xxxxxxxxxxcomponentsButtonModalpages注意:Button Modal 都有
xxxxxxxxxxdepth=2而pages是:
xxxxxxxxxxdepth=1所以一直删到:
xxxxxxxxxxdepth <= 当前 depth停止。
例如:
当前:
xxxxxxxxxxcomponentsdepth=1后面:
xxxxxxxxxxButtondepth=2删。
继续:
xxxxxxxxxxModaldepth=2删。
继续:
xxxxxxxxxxpagesdepth=1停止。
是不是根本不用 DFS?
所以 Collapse 非常漂亮。
下面代码的目的,就是得到应该删除的个数 removeCount。
xxxxxxxxxxconst index = visibleNodes.findIndex(node => node.id === id);const current = visibleNodes[index]let end = index + 1;// 只要是index之后,并且 depth > 当前节点的 depth 的节点,都要让 end++ ,这个其实很好理解。while ( end < visibleNodes.length && visibleNodes[end].depth > current.depth) { end++;}let removeCount = end - index - 1;visibleNodes.splice( index + 1, removeCount);复杂度:
xxxxxxxxxxO(k)k:
就是:
xxxxxxxxxxButtonModal两项。
不是:
xxxxxxxxxx100 万
以后 Expand:
xxxxxxxxxx点击↓找到 index↓DFS 当前 children↓splice 插进去↓setVisibleNodes()Collapse:
xxxxxxxxxx点击↓找到 index↓找到所有 descendant↓splice 删除↓setVisibleNodes()
这一节我们主要理解了算法。
下一节我建议直接重构我们的 VirtualTree.tsx,把:
xxxxxxxxxxuseMemo(() => flattenTree())彻底删掉。
改成维护:
xxxxxxxxxxconst [visibleNodes, setVisibleNodes] = useState()然后实现两个真正的生产级函数:
xxxxxxxxxxexpandNode(nodeId)collapseNode(nodeId)你会看到整个组件从“Demo 思路”升级为“生产级架构”,这也是很多成熟 Tree 组件内部采用的核心实现方式。
直接把 VirtualTree.tsx 改造成生产环境更接近的架构。
我们以前是:
xxxxxxxxxxTree ↓flattenTree() ↓visibleNodes ↓render也就是说,每次点击都会重新:
xxxxxxxxxxflattenTree(tree, expanded)
改成:
xxxxxxxxxxTree(永远不变) │ │(初始化一次) ▼visibleNodes(State) │ ├── expand() ├── collapse() ▼render以后:render 永远使用 visibleNodes。Tree 只是数据仓库(Source of Truth)。
以前:
xxxxxxxxxxconst [expanded, setExpanded] = useState(new Set(["src"]))const visibleNodes = useMemo( () => flattenTree(tree, expanded), [tree, expanded])现在改成:
xxxxxxxxxxconst [expanded, setExpanded] = useState( new Set(["src"]))const [visibleNodes, setVisibleNodes] = useState(() => flattenTree(tree, new Set(["src"])) )以后:render 全部来自
xxxxxxxxxxvisibleNodes而不是
xxxxxxxxxxflattenTree()
我们写一个函数:
xxxxxxxxxxfunction expandNode(id: string) {}第一件事:找到点击的是谁。
xxxxxxxxxxconst index = visibleNodes.findIndex( node => node.id === id)if (index === -1) return例如:
xxxxxxxxxx0 src1 components2 pages3 package.json
点击:
xxxxxxxxxxcomponents
得到:
xxxxxxxxxxindex = 1
xxxxxxxxxxconst current = visibleNodes[index]if (!current.children?.length) { return}例如:
xxxxxxxxxxcomponents↓children
就是:
xxxxxxxxxxButtonModal
注意!
不是:
xxxxxxxxxxflattenTree(tree)而是:
xxxxxxxxxxflattenTree( current.children, expanded)这里还有一个小细节。
因为 Button 应该:
xxxxxxxxxxdepth=2
所以以前:
xxxxxxxxxxflattenTree()固定:
xxxxxxxxxxdepth=0现在需要改一下。
我们修改 flattenTree() 函数,增加 baseDepth:
xxxxxxxxxxflattenTree( children, expanded, current.depth + 1)DFS 从 depth=2 开始。
所以 flatten.ts 变成:
xxxxxxxxxxexport function flattenTree( nodes, expanded, depth = 0)DFS:
xxxxxxxxxxdfs(node, depth)
得到:
xxxxxxxxxxButtonModal直接:
xxxxxxxxxxconst next = [visibleNodes]next.splice( index + 1, 0, children)setVisibleNodes(next)是不是结束了?
整个过程:
xxxxxxxxxxcomponents↓ButtonModal↓splice()↓render
没有重新 DFS 整棵树。
Collapse 更漂亮。
现在:
xxxxxxxxxx0 src1 components2 Button3 Modal4 pages5 package.json
点击:
xxxxxxxxxxcomponents
当前:
xxxxxxxxxxdepth=1开始:
xxxxxxxxxxlet end = index + 1循环:
xxxxxxxxxxButtondepth=2
继续。
xxxxxxxxxxModaldepth=2
继续。
xxxxxxxxxxpagesdepth=1
停。
所以:
xxxxxxxxxxconst removeCount = end - index - 1这里:
xxxxxxxxxxremoveCount=2
然后:
xxxxxxxxxxnext.splice( index + 1, removeCount)完成。
最后:
xxxxxxxxxxfunction toggle(node) { if(expanded.has(node.id)) { collapseNode(node.id) } else { expandNode(node.id) }}
假设:
整个 Tree:
xxxxxxxxxx1000000 nodes
点击:
xxxxxxxxxxcomponents
里面:
xxxxxxxxxxButtonModal
以前:
xxxxxxxxxxDFS()1000000
现在:
xxxxxxxxxxDFS()2
因为只处理:
xxxxxxxxxxchildren
是不是一下:
xxxxxxxxxxO(N)↓O(k)
来看下面这个 Tree:
xxxxxxxxxxsrc└── components├── Button│ ├── index.ts│ └── styles.css└── Modal
假设 Button 已经展开。
也就是说:
xxxxxxxxxxexpanded=srccomponentsButton
收起components文件夹之后,再次展开:
应该出现:
xxxxxxxxxxcomponentsButtonindex.tsstyles.cssModal
而不是:
xxxxxxxxxxcomponentsButtonModal
现有的代码是做不到的。
为什么?因为 Button 本来就在 expanded 集合里。
也就是说:
我们插入
components.children时,不能只插入第一层,而要递归展开那些已经处于 expanded 状态的子节点。
这也是为什么我前面坚持保留 flattenTree(children, expanded, baseDepth) 的原因。
它不仅能生成第一层,还能自动把那些“已经展开”的子树一起展开。
注意:这里假设的是 Button 文件夹曾经展开过,如果Button文件夹本来就没有展开过,那么展开 components 文件夹,默认是不展开里面的子文件夹的。
当然,具体效果还是要看产品的需求。
到这里,我们已经实现了:
visibleNodes下一步我建议继续升级,实现 NodeMap + ParentMap。
好,下面这一步开始,就真的进入生产级 Tree 组件了。
其实我们刚刚的版本还有一个问题。
现在我们的代码里,每次点击都会:
xxxxxxxxxxconst index = visibleNodes.findIndex( node => node.id === id)复杂度:
xxxxxxxxxxO(N)
如果:
xxxxxxxxxxvisibleNodes100000
每点击一次:
xxxxxxxxxxfindIndex()100000
其实还是有点浪费。
真正的 Tree 一般都会维护一个 Store。
例如:
xxxxxxxxxxTreeStore├── nodeMap├── parentMap├── childrenMap├── visibleNodes├── expandedSet└── selectedSet
React Component 只是:
xxxxxxxxxxTreeStore↓visibleNodes↓render
你会发现:React 根本不关心 Tree。Tree 全部在 Store 里面。
treeStore里面的nodeMap、parentMap、childrenMap会在初始化时一次性构建完成,虽然刚开始会花一点时间,但是后续操作会很快,还是很值得的。
如果 Tree 只有几千到几万个节点,是没有问题的。但是,如果 Tree 的规模达到几十万甚至上百万个节点,比如 IDE 的文件系统、Git 仓库或者日志目录,那么就不会一次性初始化全部数据了。
像 VS Code 就采用了 Lazy Loading(懒加载) 的方式。刚进入页面时,只加载根节点;当用户展开某个文件夹时,再向后端请求它的子节点,同时把这些新节点加入 nodeMap、parentMap 等数据结构,而不是一次性建立整个 Tree 的索引。
为什么需要?
Tree:
xxxxxxxxxxsrccomponentsButtonModal
以前想找到:
xxxxxxxxxxButton
需要 DFS 整棵 Tree。
生产不会。
初始化一次:
xxxxxxxxxxconst nodeMap = new Map()nodeMap.set("src", src)nodeMap.set("components", components)nodeMap.set("button", button)nodeMap.set("modal", modal)以后:
xxxxxxxxxxnodeMap.get("button")直接:
xxxxxxxxxxO(1)
是不是比 DFS 快很多。
为什么?
例如:
xxxxxxxxxxButton
我要知道:
xxxxxxxxxx父是谁?
以前 DFS。
现在:
xxxxxxxxxxparentMapbutton↓components直接:
xxxxxxxxxxparentMap.get("button")得到:
xxxxxxxxxxcomponents
很多功能都会用。
例如:
xxxxxxxxxxBreadcrumbsrc↓components↓Button
整个 Tree 只 DFS 一次。
xxxxxxxxxxfunction buildMaps(tree){}DFS:
xxxxxxxxxxfunction dfs(node,parent){ nodeMap.set(node.id,node) parentMap.set( node.id, parent?.id ) node.children?.forEach()}以后 Tree 再也不用 DFS。
以前:
xxxxxxxxxxconst node =visibleNodes[index]现在直接:
xxxxxxxxxxconst node =nodeMap.get(id)是不是:
xxxxxxxxxxO(1)
Collapse 还是扫描:
xxxxxxxxxxvisibleNodes
为什么?
因为 Visible List 就是:
xxxxxxxxxxDFS 顺序
例如:
xxxxxxxxxxsrccomponentsButtonModalpages
Button 后面一定还是 Modal 。不会跑到 src 前面。
所以扫描:
xxxxxxxxxxdepth
就是最快。
如果面试官问:
为什么还要保留原始 Tree?为什么不只有 visibleNodes?
这是一个非常好的问题。
你的回答可以是:
English
The original tree is the source of truth. The visible list is only a projection of the tree based on the current expansion state. If I only keep the visible list, collapsing and re-expanding a node becomes difficult because I'd lose the hidden subtree structure. So in production, I keep both: the original tree stores the complete hierarchy, while the visible list is optimized for rendering.
中文
原始 Tree 是数据源(Source of Truth),而
visibleNodes只是根据当前展开状态计算出来的一个视图。如果只保留visibleNodes,收起节点后就丢失了被隐藏子树的层级关系,之后再展开会很麻烦。因此生产环境通常会同时保留完整 Tree 和visibleNodes:前者负责保存完整结构,后者负责高效渲染。
好,这里给你一份真正完整的 v4(生产思路版)Virtual Tree。
这一版的核心是:
❗ 不再 DFS whole tree ❗ 不再 flatten whole tree ❗ 只维护 visibleNodes + maps ❗ expand / collapse 是局部更新(O(k))
xxxxxxxxxxsrc/├── App.tsx├── types.ts├── data.ts├── treeStore.ts├── VirtualTree.tsx
xxxxxxxxxxexport interface TreeNode { id: string; name: string; children?: TreeNode[];}export interface VisibleNode { id: string; name: string; depth: number; children?: TreeNode[];}
xxxxxxxxxximport { TreeNode } from "./types";export const treeData: TreeNode[] = [ { id: "src", name: "src", children: [ { id: "components", name: "components", children: [ { id: "button", name: "Button.tsx" }, { id: "modal", name: "Modal.tsx" }, ], }, { id: "pages", name: "pages", children: [ { id: "home", name: "Home.tsx" }, { id: "about", name: "About.tsx" }, ], }, { id: "pkg", name: "package.json" }, ], },];
这一层就是“VS Code 思路”。
xxxxxxxxxximport { TreeNode, VisibleNode } from "./types";export class TreeStore { private nodeMap = new Map<string, TreeNode>(); private childrenMap = new Map<string, TreeNode[]>(); private visibleNodes: VisibleNode[] = []; private expanded = new Set<string>(); constructor(tree: TreeNode[]) { this.build(tree); // 初始展开 root const root = tree[0]; this.expanded.add(root.id); this.visibleNodes = this.buildVisible(tree, 0); } // -------- init -------- private build(tree: TreeNode[]) { const dfs = (node: TreeNode) => { this.nodeMap.set(node.id, node); this.childrenMap.set(node.id, node.children ?? []); node.children?.forEach(dfs); }; tree.forEach(dfs); } // -------- build visible (only first time) -------- private buildVisible( nodes: TreeNode[], depth: number ): VisibleNode[] { const res: VisibleNode[] = []; const dfs = (node: TreeNode, d: number) => { res.push({ node, depth: d, }); // 这里是关键,如果当前node有children,也要将其全部加入到 if ( this.expanded.has(node.id) && node.children ) { node.children.forEach((c) => dfs(c, d + 1) ); } }; nodes.forEach((n) => dfs(n, depth)); return res; } // -------- public API -------- getVisible() { return this.visibleNodes; } // -------- expand (O(k)) -------- expand(id: string) { if (this.expanded.has(id)) return; this.expanded.add(id); const index = this.visibleNodes.findIndex( (n) => n.id === id ); if (index === -1) return; const node = this.nodeMap.get(id); if (!node?.children) return; const insertNodes = this.buildVisible( node.children, this.visibleNodes[index].depth + 1 ); this.visibleNodes.splice( index + 1, 0, insertNodes ); } // -------- collapse (O(k)) -------- collapse(id: string) { if (!this.expanded.has(id)) return; this.expanded.delete(id); const index = this.visibleNodes.findIndex( (n) => n.id === id ); if (index === -1) return; const baseDepth = this.visibleNodes[index].depth; let end = index + 1; while ( end < this.visibleNodes.length && this.visibleNodes[end].depth > baseDepth ) { end++; } this.visibleNodes.splice( index + 1, end - index - 1 ); } toggle(id: string) { if (this.expanded.has(id)) { this.collapse(id); } else { this.expand(id); } }}
xxxxxxxxxximport { useEffect, useMemo, useState } from "react";import { TreeNode } from "./types";import { TreeStore } from "./treeStore";const ROW_HEIGHT = 32;const HEIGHT = 240;const OVERSCAN = 5;export default function VirtualTree({ tree,}: { tree: TreeNode[];}) { const store = useMemo( () => new TreeStore(tree), [tree] ); const [visible, setVisible] = useState( store.getVisible() ); const [scrollTop, setScrollTop] = useState(0); const refresh = () => setVisible([store.getVisible()]); const start = Math.max( 0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN ); const end = start + Math.ceil(HEIGHT / ROW_HEIGHT) + OVERSCAN * 2; const slice = visible.slice(start, end); return ( <div style={{ height: HEIGHT, overflow: "auto", border: "1px solid #ddd", }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop) } > <div style={{ height: visible.length * ROW_HEIGHT, position: "relative", }} > {slice.map((node, i) => { const realIndex = start + i; const hasChildren = !!node.children?.length; return ( <div key={node.id} onClick={() => { store.toggle(node.id); refresh(); }} style={{ position: "absolute", top: realIndex * ROW_HEIGHT, height: ROW_HEIGHT, lineHeight: `${ROW_HEIGHT}px`, left: node.depth * 16, cursor: "pointer", userSelect: "none", }} > {hasChildren ? "📁" : "📄"}{" "} {node.name} </div> ); })} </div> </div> );}
xxxxxxxxxximport VirtualTree from "./VirtualTree";import { treeData } from "./data";export default function App() { return ( <div style={{ padding: 40 }}> <VirtualTree tree={treeData} /> </div> );}
你现在这个 Tree 已经具备:
✅ 生产级结构思想
✅ 性能优化
✅ Virtualization
你可以这样收尾:
This implementation separates the tree into a data layer (TreeStore) and a view layer (visibleNodes). Instead of recomputing the whole tree on every interaction, we incrementally update the visible list in O(k) based on expand/collapse operations, and then apply standard list virtualization on top of it.
我可以再带你做这三个“真正高级版本”:
🔥 v5(真实 IDE 级)
🔥 v6(超大规模优化)
🔥 v7(Figma / VSCode级)