Ant Design 树形表格渲染原理
树形表格看起来像「嵌套表格」,但 antd 内部并不是递归渲染子
<table>。真正的模型是:树数据 → 按展开状态拍平成一维行列表 → 用缩进和图标伪装层级。
一、整体结论
| 表象 | 实际实现 |
|---|---|
树形 dataSource(带 children) |
展开后拍平成 { record, indent, rowKey }[] |
| 层级缩进 | 第一列(展开列)左侧插入 indentSize * indent 的空白 |
| 展开 / 收起 | 维护 Set<rowKey>,增删 key 后重新拍平 |
| 虚拟滚动 | 对拍平后的一维列表做可视区渲染,不跳过拍平计算 |
一句话:树表 = 受控展开 Set + 按 Set 拍平 + 缩进伪装层级。
二、分层架构
text
antd Table (InternalTable)
└─ 判定 expandType: nest | row | null
└─ 透传 expandable / childrenColumnName / rowKey
│
▼
@rc-component/table
├─ useExpand → expandedKeys: Set
├─ useFlattenRecords → 拍平树为行列表
├─ Body / VirtualTable → map 或虚拟列表渲染 BodyRow
└─ BodyRow / getCellProps → 缩进 + 展开图标
业务侧只感知 dataSource 的树结构和 expandable 配置;拍平和行渲染都在 @rc-component/table 内完成。
三、展开模式判定:nest vs row
antd InternalTable 会根据数据与配置决定展开类型:
js
// antd/es/table/InternalTable.js(逻辑摘要)
const expandType = React.useMemo(() => {
if (rawData.some(item => item?.[childrenColumnName])) {
return 'nest' // 树形数据优先
}
if (expandedRowRender || expandable?.expandedRowRender) {
return 'row' // 展开行渲染额外内容
}
return null
}, [childrenColumnName, rawData])
| 模式 | 条件 | 行为 |
|---|---|---|
nest |
数据中存在 children(默认字段名,可用 childrenColumnName 修改) |
子节点作为普通表格行插入 |
row |
配置了 expandedRowRender |
展开后额外渲染一行自定义内容 |
null |
两者都没有 | 普通表格 |
互斥关系 :有 children 时优先走 nest,此时 expandedRowRender 会被静默忽略。底层 useExpand 在同时存在两者时还会打出开发环境 warning。
@rc-component/table 侧对应命名为 expandableType('nest' | 'row' | false),语义一致。
四、展开状态:expandedKeys
4.1 来源
useExpand 把展开行收敛成 Set:
js
const mergedExpandedKeys = React.useMemo(
() => new Set(expandedRowKeys || innerExpandedKeys || []),
[expandedRowKeys, innerExpandedKeys],
)
- 受控 :传入
expandable.expandedRowKeys,变更走onExpandedRowsChange - 非受控 :用内部
innerExpandedKeys,初始值来自:defaultExpandedRowKeys,或defaultExpandAllRows === true时调用findAllChildrenKeys(mergedData, ...)
4.2 defaultExpandAllRows 为何常「无效」
defaultExpandAllRows 只在组件初始化(useState 惰性初始函数)时执行一次。
典型异步场景:
- Table 首次挂载,
dataSource = [] findAllChildrenKeys([])→[]- 接口返回后
dataSource更新,但默认展开状态不会重算
因此异步树数据应使用受控 expandedRowKeys:数据到达后自行收集可展开节点的 key 并写入。
4.3 点击展开
用户点击展开图标 → onTriggerExpand(record):
- 用
getRowKey取当前行 key - 在 Set 中增删该 key
- 调用
onExpand/onExpandedRowsChange - 触发
useFlattenRecords重新拍平
五、核心:useFlattenRecords 拍平
树表渲染的关键一步:按 expandedKeys 把树压成一维数组。
5.1 算法
js
// @rc-component/table/es/hooks/useFlattenRecords.js(逻辑摘要)
function fillRecords(list, record, indent, childrenColumnName, expandedKeys, getRowKey, index) {
const key = getRowKey(record, index)
list.push({ record, indent, index, rowKey: key })
const expanded = expandedKeys?.has(key)
if (record && Array.isArray(record[childrenColumnName]) && expanded) {
for (let i = 0; i < record[childrenColumnName].length; i += 1) {
fillRecords(
list,
record[childrenColumnName][i],
indent + 1,
childrenColumnName,
expandedKeys,
getRowKey,
i,
)
}
}
}
规则:
- 当前节点始终入列
- 仅当
key ∈ expandedKeys且存在children时,继续递归子节点 - 子节点
indent = 父 indent + 1
若 expandedKeys 为空,则只输出顶层节点(indent: 0),不进入子树。
5.2 拍平结果结构
ts
type FlattenData<T> = {
record: T // 原始节点
indent: number // 层级深度,用于缩进
index: number // 在兄弟列表中的下标
rowKey: Key // 行唯一键
}
5.3 性能含义
全量展开一棵「约 1 万叶子」的树时:
useFlattenRecords会同步遍历并生成约 1 万+ 项的数组- 这一步的卡顿与是否开启
virtual无关 virtual只减少后续 DOM 挂载量,不能省掉拍平计算
六、行渲染:缩进 + 展开图标
Body(非虚拟)对 flattenData 做 map,每个元素渲染一个 BodyRow:
js
rows = flattenData.map((item, idx) => (
<BodyRow
key={item.rowKey}
record={item.record}
indent={item.indent}
// ...
/>
))
在 getCellProps 中,当列为展开列且模式为 nest 时,向单元格前置追加节点:
js
appendCellNode = (
<>
<span
style={{ paddingLeft: `${indentSize * indent}px` }}
className={`${prefixCls}-row-indent indent-level-${indent}`}
/>
{expandIcon({
prefixCls,
expanded,
expandable: hasNestChildren, // 是否有 children
record,
onExpand: onTriggerExpand,
})}
</>
)
视觉层级完全由 CSS 缩进模拟,DOM 上仍是平铺的 <tr>。
默认 indentSize 为 15(像素)。
七、与虚拟滚动的关系
开启 virtual 后:
- 仍然先走
useFlattenRecords得到完整扁平列表 - 列表交给
@rc-component/virtual-list,只挂载可视区内的行 - 单元格侧复用同一套
getCellProps(缩进 + 展开图标)
要求(antd 文档):
- 设置
virtual scroll.x、scroll.y必须同时为 number 类型
流程对比:
text
dataSource (树)
→ expandedKeys
→ useFlattenRecords(完整扁平数组)
→ 非 virtual:Body map 全部行
→ virtual:VirtualList 只渲染可视行
→ BodyRow:缩进 + 展开图标 + 单元格
八、筛选 / 排序 / 选择如何对待树
antd 上层在树数据上也会递归处理 children:
| 能力 | 行为概要 |
|---|---|
排序 getSortData |
对当前层排序,并对子列表递归排序 |
筛选 getFilterData |
过滤时保留匹配节点及祖先路径(实现上会克隆并处理 children) |
选择 useSelection |
nest 模式下可按树联动勾选;内部也会把数据拍平做 key 映射 |
useLazyKVMap |
按 rowKey 懒建 key → record 映射,遍历时下钻 children |
这些逻辑发生在「交给 rc-table 渲染」之前;渲染层看到的仍是(可能已排序/筛选过的)树,再由 useFlattenRecords 按展开状态拍平。
九、实践清单(结合本项目)
9.1 异步默认全展开
tsx
// ❌ 无效:defaultExpandAllRows 只在空数据挂载时初始化一次
<Table expandable={{ defaultExpandAllRows: true }} dataSource={asyncList} />
// ✅ 受控:数据到达后写入全部可展开 key
setDataSource(res.list)
setExpandedRowKeys(collectExpandableKeys(res.list))
<Table
expandable={{
expandedRowKeys,
onExpandedRowsChange: (keys) => setExpandedRowKeys([...keys]),
}}
/>
9.2 大数据量
| 问题 | 手段 |
|---|---|
| DOM 过多导致滚动卡顿 | virtual + 数值型 scroll.x / scroll.y |
| 全展开瞬间卡顿 | 拍平成本;可考虑默认不全开、按需展开、或服务端分页/懒加载子节点 |
| 列宽 | 虚拟表建议给列明确 width,并保证 scroll.x 足够 |
十、小结
- 树形表不是嵌套表,而是 拍平后的普通行列表。
- 展开状态是
Set<rowKey>;拍平只展开 Set 中的节点路径。 - 层级靠
indent * indentSize+ 展开图标 视觉伪装。 defaultExpandAllRows仅初始化生效 ,异步数据需受控expandedRowKeys。virtual优化的是 DOM,不能消除全量拍平的 CPU 成本。