Ant Design 树形表格渲染原理

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 惰性初始函数)时执行一次

典型异步场景:

  1. Table 首次挂载,dataSource = []
  2. findAllChildrenKeys([])[]
  3. 接口返回后 dataSource 更新,但默认展开状态不会重算

因此异步树数据应使用受控 expandedRowKeys:数据到达后自行收集可展开节点的 key 并写入。

4.3 点击展开

用户点击展开图标 → onTriggerExpand(record)

  1. getRowKey 取当前行 key
  2. 在 Set 中增删该 key
  3. 调用 onExpand / onExpandedRowsChange
  4. 触发 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,
      )
    }
  }
}

规则:

  1. 当前节点始终入列
  2. 仅当 key ∈ expandedKeys 且存在 children 时,继续递归子节点
  3. 子节点 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(非虚拟)对 flattenDatamap,每个元素渲染一个 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>

默认 indentSize15(像素)。


七、与虚拟滚动的关系

开启 virtual 后:

  1. 仍然先走 useFlattenRecords 得到完整扁平列表
  2. 列表交给 @rc-component/virtual-list,只挂载可视区内的行
  3. 单元格侧复用同一套 getCellProps(缩进 + 展开图标)

要求(antd 文档):

  • 设置 virtual
  • scroll.xscroll.y 必须同时为 number 类型

流程对比:

text 复制代码
dataSource (树)
  → expandedKeys
  → useFlattenRecords(完整扁平数组)
  → 非 virtual:Body map 全部行
  → virtual:VirtualList 只渲染可视行
  → BodyRow:缩进 + 展开图标 + 单元格
flowchart TD A[dataSource 树] --> B{expandType} B -->|nest| C[useExpand: expandedKeys] C --> D[useFlattenRecords 拍平] D --> E{virtual?} E -->|否| F[Body: map 全部 BodyRow] E -->|是| G[VirtualList: 只渲染可视区] F --> H[缩进 + 展开图标 + 单元格] G --> H

八、筛选 / 排序 / 选择如何对待树

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 足够

十、小结

  1. 树形表不是嵌套表,而是 拍平后的普通行列表
  2. 展开状态是 Set<rowKey>;拍平只展开 Set 中的节点路径。
  3. 层级靠 indent * indentSize + 展开图标 视觉伪装。
  4. defaultExpandAllRows 仅初始化生效 ,异步数据需受控 expandedRowKeys
  5. virtual 优化的是 DOM,不能消除全量拍平的 CPU 成本。
相关推荐
paopaokaka_luck1 小时前
基于springboot3+vue3的音乐推荐系统(协同过滤算法、Echarts图形化分析)
前端·echarts
八号当铺2 小时前
使用 Figma Agent Kit:插件 + MCP + 还原 Skill,打通本地设计协作
前端·人工智能·ai编程
一心只读圣贤书2 小时前
AI 辅助前端国际化实践:从文案梳理到多语言资源治理
前端
无责任此方_修行中2 小时前
搓了一个国产大模型与 AI Agent 比价工具
前端·后端·ai编程
szephyr3 小时前
腾讯云 ADP 智能体的 Skills 配置变更后没有走灰度,直接全量替换出了问题怎么定位?
java·前端·腾讯云·腾讯云adp
夏雪coding3 小时前
nginx SPA 回落把 CSS 变成了 HTML:一个返回 200 却让样式失效的坑
前端·nginx
光影少年3 小时前
React Native网络、存储与原生能力
前端·react native·react.js
bonibabi3 小时前
基于 CopilotKit + Java SSE 构建 AI Agent 的前端实践指南
前端·agent
砺能3 小时前
谷歌浏览器中出现部分字体乱码问题
前端