简介面向需要实现层级数据交互界面的 Android 与前端开发者这份无限级树形菜单示例聚焦多选场景可用于文件管理、组织架构、导航菜单等深层结构的浏览与批量选择。压缩包共 1296 个文件约 8.12MB包含 xml 布局与资源、png 图片、json 配置、java 源码、class 与 jar 依赖以及 aidl、gradle、apk 等工程产物完整呈现一个可运行的 Android 项目结构。已有 1022 人学习下载说明其对递归建树、节点展开折叠、遍历渲染与多选状态管理等常见难点具有参考价值。读者可结合 MyApplication2 工程理解父子节点递归、全选反选与父子联动、事件处理和性能优化思路并借助现成组件与依赖快速搭建自己的无限级菜单原型。1. 从一张权限配置表说起无限级树形菜单要解决的三个硬需求后台权限配置页里的菜单层级永远比文档上写的深。产品给的需求很直白菜单树要支持任意层级展开勾选父节点得连带整棵子树后端只收叶子节点的 key回填时还得把半选的父节点原样还原出来。手头的组件库文档翻了三遍多选示例基本都停在两级再往下就得自己接。于是有了这个无限级树形菜单组件数据结构不限制层级多选支持父子联动、半选、禁用节点跳过同时能接懒加载和搜索过滤。它适合正在做权限配置、组织架构、商品分类这类深层级勾选场景的开发者也适合想把一棵树从数据到交互完整吃透的人。下面从选型、目录、核心算法一路拆到二次开发。2. 技术选型与目录结构为什么最后没用现成的 Tree 组件2.1 现成组件的三处不合手先说结论不是现成组件不能用是它的默认行为和业务预期差得太远补丁打到后期比自研还贵。当时评估了三个点每一点都对应一段返工。第一是半选回填。多数组件库的树在多选模式下对外只抛checkedKeys和halfCheckedKeys回填时却要求你只传叶子节点的 key父节点的半选状态靠组件内部重新推导。问题在于权限系统存的是历史快照某个父节点下有 20 个子菜单用户当年只勾了 3 个这个信息以什么形式落库、回填时怎么还原成半选组件不关心全压在业务侧。我一开始图省事把halfCheckedKeys直接存了结果组件版本一升级内部推导顺序变了回填出来的半选跟库里对不上排查了半天。第二是禁用节点的联动语义。业务里有些菜单是系统内置的不允许取消勾选。现成组件的做法通常是禁用节点自己不可点但父节点勾选时仍然会被带上。这在权限场景下是灾难用户勾了整棵子树内置菜单被顺带取消保存时报错。自研时把「禁用节点不参与任何传播」写死在状态机里反而干净。第三是深层级下的渲染体积。菜单数据在一千到三千节点这个量级全展开时 DOM 节点数直接上万滚动明显掉帧。组件库的虚拟滚动往往和它自己的数据结构绑死想接上得改数据形状。自己做的话展开态拍平成数组再交给一个定高虚拟列表改动面小得多。提示判断要不要自研看的不是「现成组件功能够不够」而是「它的默认语义和你的业务语义差几个补丁」。差一个能用差三个就该自己写了。技术栈上选的是 Vue 3 TypeScript组合式 API 写状态机比选项式清爽类型也能把TreeKey这种联合类型约束住。构建用 Vite样式用原生 CSS 变量不引 UI 框架方便直接嵌进任何后台项目。2.2 目录结构与数据模型整个组件拆成三层容器层管状态和事件递归节点层只管渲染composable 层放算法。这么拆的好处是算法可以单测不用挂载组件。路径职责是否可独立复用src/components/TreeMenu.vue根容器接收 props持有展开态与选中态对外抛事件否src/components/TreeNode.vue递归节点渲染单行 递归子节点否src/composables/useCheckState.ts多选状态机向下传播、向上回溯、半选计算是src/composables/useTreeData.ts扁平转树、索引构建、搜索过滤是src/utils/tree.ts纯函数遍历、拍平、找叶子是src/types.ts类型定义是数据结构这块我刻意只规定了三个必填字段其余全部放开。原因是不同业务方给的数据形状差太多有人用id有人用code有人把业务字段塞在data里。统一成key label children之后业务侧写个map就能接上。// src/types.ts export type TreeKey string | number export interface TreeNode { key: TreeKey // 唯一标识整棵树内必须唯一重复会导致勾选串行 label: string // 展示文案 children?: TreeNode[] // 子节点不传或空数组都视为叶子 disabled?: boolean // 禁用节点自身不可勾选也不参与父子联动 isLeaf?: boolean // 懒加载场景下显式声明叶子避免显示展开箭头 raw?: unknown // 业务原始数据事件里原样回传 } export type CheckState checked | indeterminate | unchecked export interface CheckPayload { checkedKeys: TreeKey[] // 全选中的节点含被联动选中的父节点 halfCheckedKeys: TreeKey[] // 半选中的父节点 checkedLeaves: TreeKey[] // 只含叶子后端权限接口通常只认这个 }raw这个字段值得单独说一句。早期版本没留它结果业务方拿到事件后还要拿 key 回自己数组里find一遍才能取到菜单名称几千个节点每次点击都扫一遍数组白浪费。加上raw之后checkedLeaves交给后端raw交给前端做展示两边都不用二次查找。isLeaf是为懒加载准备的接口返回的父节点此时children还是空的如果不显式声明组件会把它当叶子渲染用户永远点不出下一级。2.3 状态放在哪一层受控与非受控的取舍组件对外的 props 设计成「受控优先、非受控兜底」。传了modelValue就完全听父组件的不传就自己维护一份内部状态两种模式共用同一套状态机。参数类型默认值说明dataTreeNode[][]树数据支持嵌套或扁平配parentFieldmodelValueTreeKey[]undefined受控选中集合不传则内部维护checkStrictlybooleanfalse为true时父子不联动各自独立defaultExpandAllbooleanfalse初始是否展开全部filterTextstring搜索关键词命中节点保留父链lazyLoad(node) PromiseTreeNode[]undefined懒加载子节点的取数函数受控模式下最容易翻车的是事件回环父组件监听update:modelValue拿到新值后 setState触发子组件重渲染子组件又在 watch 里 emit 一次无限循环。规避办法是子组件内部只在用户交互时 emitwatch 只做「外部值变化 → 重算内部状态」的单向同步不反向触发。这套逻辑放在useCheckState里用一个syncing标志位挡住重入。非受控模式看着简单但有个坑data变化时内部状态不会自动清空。比如切换了角色菜单树整个换了一棵旧的checkedKeys还留着就会出现「勾选了一个不存在的 key」。所以data的 watch 里要做一次 key 的有效性校验把不在新树里的 key 剔掉。3. 核心实现拆解一次勾选从节点到根节点的完整链路3.1 递归组件的骨架递归组件的关键只有两件事组件要能引用自己props 要能一路透传下去。Vue 3 的script setup里用文件名做组件名可以自引用但为了在别处引用时不产生歧义我习惯显式写defineOptions。!-- src/components/TreeNode.vue -- script setup langts import { computed } from vue import type { TreeNode, TreeKey, CheckState } from ../types defineOptions({ name: TreeNode }) // 递归自引用必须否则模板里解析不到自己 const props defineProps{ node: TreeNode depth: number // 层级只用来算缩进 expandedKeys: SetTreeKey stateOf: (key: TreeKey) CheckState }() const emit defineEmits{ (e: toggle-expand, key: TreeKey): void (e: toggle-check, node: TreeNode): void }() // 有 children 且非空才显示展开箭头isLeaf 优先处理懒加载未取数的场景 const hasChildren computed(() { if (props.node.isLeaf) return false return Array.isArray(props.node.children) props.node.children.length 0 }) const expanded computed(() props.expandedKeys.has(props.node.key)) const checkState computed(() props.stateOf(props.node.key)) /script template li classtree-node div classtree-node__row :style{ paddingLeft: ${depth * 18}px } span classtree-node__arrow :class{ is-expanded: expanded, is-hidden: !hasChildren } clickemit(toggle-expand, node.key) / input typecheckbox :checkedcheckState checked :indeterminate.propcheckState indeterminate :disablednode.disabled changeemit(toggle-check, node) / span classtree-node__label{{ node.label }}/span /div ul v-ifhasChildren expanded classtree-node__children TreeNode v-forchild in node.children :keychild.key :nodechild :depthdepth 1 :expanded-keysexpandedKeys :state-ofstateOf toggle-expandemit(toggle-expand, $event) toggle-checkemit(toggle-check, $event) / /ul /li /template这段代码有三个地方值得注意。stateOf是函数而不是 map因为如果传一个MapTreeKey, CheckState下去每次勾选都要重建整个 map几千个节点就是几千次对象分配传函数则只在渲染时按需求值Vue 的响应式会正确追踪函数内部读到的Set。indeterminate在原生 input 上是个 property 不是 attribute模板里必须写.prop修饰符写成普通 attribute 在部分浏览器上不生效。缩进用paddingLeft而不是嵌套margin是因为后者在深层级下会累积出奇怪的布局且虚拟滚动时行高不好算。递归的终止条件是hasChildren为 false 时不渲染ul。这里如果把空数组[]当成有子节点会渲染出一个空列表占掉一行高度视觉上就是一个莫名多出来的空白属于典型的血泪经验。3.2 扁平数组转树与索引构建业务侧给的原始数据十有八九是带parentId的扁平数组直接从数据库查出来的。转树本身不难难的是转完之后要能快速回答两个问题某个 key 的父节点是谁某个节点的所有后代替换掉要花多久。// src/utils/tree.ts export interface FlatNode extends OmitTreeNode, children { parentId?: TreeKey | null } export function buildTree(list: FlatNode[], rootId: TreeKey | null null): TreeNode[] { const nodeMap new MapTreeKey, TreeNode() const roots: TreeNode[] [] // 第一遍建索引并复制节点顺带清掉 children避免复用同一份数据时串树 for (const item of list) { const { parentId, ...rest } item nodeMap.set(item.key, { ...rest }) } // 第二遍挂父节点。parentId 为空或在索引里找不到的都当根节点处理 for (const item of list) { const node nodeMap.get(item.key)! const parent item.parentId ! null ? nodeMap.get(item.parentId) : undefined if (parent) { ;(parent.children ?? []).push(node) } else { roots.push(node) } } return roots } // 建立 key - parentKey 的反向索引向上回溯时用 export function buildParentMap(nodes: TreeNode[], parentKey?: TreeKey, map new MapTreeKey, TreeKey()) { for (const node of nodes) { if (parentKey ! undefined) map.set(node.key, parentKey) if (node.children?.length) buildParentMap(node.children, node.key, map) } return map }buildTree用两遍扫描而不是递归插入复杂度是 O(n)。递归插入的写法更直观但每次插一个节点都要从根开始找父节点一千个节点就是 O(n²)实测在三千节点时能明显感觉到卡顿。第二遍扫描里parentId指向不存在的 key 时兜底成根节点这一条是防止脏数据导致整棵子树消失——接口返回的菜单被删了父级但子级还在这种情况在权限表里很常见。buildParentMap返回的反向索引是向上回溯的基础。每次树结构变化懒加载出一批子节点都要增量更新它而不是重建重建的代价是 O(n)在懒加载频繁触发的场景下会拖慢展开速度。3.3 多选状态机向下传播与向上回溯这是整个组件最核心的部分。一次勾选要同时完成两件事把选中态推给所有后代再把结果沿父链一路修正上去。先明确选中态的判定规则。一个节点是checked当且仅当它自己 key 在选中集合里是indeterminate当且仅当它不在选中集合里但它的直接子节点中存在checked或indeterminate否则是unchecked。禁用节点不参与任何传播既不会被父节点带选中也不影响父节点的状态判定。// src/composables/useCheckState.ts import { ref, computed } from vue import type { TreeNode, TreeKey, CheckState, CheckPayload } from ../types export function useCheckState(getTree: () TreeNode[]) { const checked ref(new SetTreeKey()) const half ref(new SetTreeKey()) const nodeMap new MapTreeKey, TreeNode() const parentMap new MapTreeKey, TreeKey() function rebuildIndex(nodes: TreeNode[], parentKey?: TreeKey) { for (const node of nodes) { nodeMap.set(node.key, node) if (parentKey ! undefined) parentMap.set(node.key, parentKey) if (node.children?.length) rebuildIndex(node.children, node.key) } } function stateOf(key: TreeKey): CheckState { if (checked.value.has(key)) return checked if (half.value.has(key)) return indeterminate return unchecked } // 向下把 node 及其全部后代置为同一状态跳过禁用节点 function applySubtree(node: TreeNode, next: boolean) { if (node.disabled) return if (next) checked.value.add(node.key) else checked.value.delete(node.key) half.value.delete(node.key) // 自身状态确定后半选标记必须清掉 for (const child of node.children ?? []) { applySubtree(child, next) } } // 向上从直接父节点开始逐级重算全选 / 半选 / 未选 function applyAncestors(key: TreeKey) { let parentKey parentMap.get(key) while (parentKey ! undefined) { const parent nodeMap.get(parentKey) if (!parent) break const siblings (parent.children ?? []).filter(c !c.disabled) let allChecked siblings.length 0 let anyChecked false for (const c of siblings) { const s stateOf(c.key) if (s ! checked) allChecked false if (s ! unchecked) anyChecked true } if (allChecked) { checked.value.add(parentKey) half.value.delete(parentKey) } else if (anyChecked) { checked.value.delete(parentKey) half.value.add(parentKey) } else { checked.value.delete(parentKey) half.value.delete(parentKey) } parentKey parentMap.get(parentKey) } } function toggleCheck(node: TreeNode, strictly false) { if (node.disabled) return // 半选状态点击视为「全选」这是绝大多数用户的心理预期 const next stateOf(node.key) ! checked if (strictly) { next ? checked.value.add(node.key) : checked.value.delete(node.key) half.value.delete(node.key) } else { applySubtree(node, next) } applyAncestors(node.key) } const payload computedCheckPayload(() { const checkedKeys [...checked.value] return { checkedKeys, halfCheckedKeys: [...half.value], // 只回传叶子后端权限表一般只维护菜单与角色的直接关系 checkedLeaves: checkedKeys.filter(k !nodeMap.get(k)?.children?.length), } }) return { checked, half, stateOf, toggleCheck, rebuildIndex, payload } }applySubtree里那句half.value.delete(node.key)是整个状态机最容易漏的一行。假设某个父节点原本是半选用户直接点它一下变成全选如果不清half这个 key 会同时存在于checked和half两个集合里stateOf先查checked返回checked看起来没问题但对外抛出的halfCheckedKeys里就多了一个本不该出现的 key后端拿去做校验时会对不上。我自己就踩过这个查了半天才发现是集合没清干净。applyAncestors里遍历的是「直接子节点」而不是「所有叶子后代」这样每次回溯的代价是节点的平均分叉数不是子树大小整体复杂度接近 O(深度 × 平均分叉)。另一种常见写法是维护每个节点的叶子集合用叶子选中比例来判断父节点状态那种做法在懒加载下会失效——子节点还没加载出来叶子集合是错的。还有一处细节siblings.length 0才会进入allChecked分支。如果父节点的所有子节点都被禁用filter之后是空数组此时allChecked初始为true就会把父节点错误地标记成全选。这种情况在「整棵子树都是系统内置菜单」时会出现最后的表现是用户什么都没点保存时却提交了一堆内置菜单的 key。3.4 搜索过滤保留父链不破坏选中态搜索功能看着简单做错的方式却有几种。最常见的错误是过滤时直接浅拷贝节点把children也一起裁掉结果用户搜索完再清空关键词展开态和选中态全乱了。正确做法是深拷贝路径上的节点命中节点保留完整子树仅子级命中的节点只保留命中的分支。// src/utils/tree.ts export function filterTree(nodes: TreeNode[], keyword: string): TreeNode[] { const kw keyword.trim().toLowerCase() if (!kw) return nodes const walk (list: TreeNode[]): TreeNode[] { const result: TreeNode[] [] for (const node of list) { const selfHit node.label.toLowerCase().includes(kw) const hitChildren node.children?.length ? walk(node.children) : [] // 自身命中保留全部子树方便用户在结果里继续往下找 // 仅子级命中只保留命中分支路径上的父节点用于展示层级 if (selfHit) { result.push({ ...node }) } else if (hitChildren.length) { result.push({ ...node, children: hitChildren }) } } return result } return walk(nodes) }这个函数返回的是新对象key保持不变。渲染时状态机仍然按 key 查stateOf所以过滤之后勾选状态不会丢。但如果直接把过滤结果写回datanodeMap里的引用就指向旧对象了懒加载时往旧对象上挂children界面不更新。所以过滤结果只能用于渲染索引始终基于原始树维护。4. 性能与懒加载三千个节点怎么不卡4.1 展开态拍平与定高虚拟列表递归渲染在节点少的时候没问题全展开三千个节点就会同时挂载上万个 DOM滚动直接掉到个位数帧率。解决办法是把可见节点拍成一维数组交给虚拟列表只渲染视口内的那几十行。// src/utils/tree.ts export interface FlatRow { node: TreeNode depth: number expandable: boolean } export function flattenVisible( nodes: TreeNode[], expanded: SetTreeKey, depth 0, out: FlatRow[] [] ): FlatRow[] { for (const node of nodes) { const expandable !node.isLeaf !!node.children?.length out.push({ node, depth, expandable }) if (expandable expanded.has(node.key)) { flattenVisible(node.children!, expanded, depth 1, out) } } return out }flattenVisible只遍历展开路径上的节点收起的大分支直接跳过所以每次展开/收起的代价是 O(可见节点数)不是 O(全树)。配合一个定高虚拟列表行高固定 32pxscrollTop / 32算出起始索引再取视口高度除以 32 加个缓冲区作为渲染条数三千节点的滚动和三十节点没区别。这里有个取舍虚拟列表要求行高固定意味着节点文案不能换行。菜单名称一般不会太长超长时用text-overflow: ellipsis截断鼠标悬停给 title 提示就够了。如果业务非要换行就得换成动态高度的虚拟列表复杂度和维护成本会上一个台阶我不建议为一个树组件付这个代价。还有一点拍平后的数组每次展开都重建如果树特别大且展开操作很频繁可以按expanded的版本号做缓存。实测三千节点下重建一次耗时在 1ms 以内没必要提前优化。4.2 懒加载子树的选中回填懒加载的坑集中在回填上。父节点被勾选时子节点还没加载此时选中集合里只有父节点的 key。等用户展开、子节点从接口取回来这些子节点默认是未选中的界面就会出现父节点全选、子节点全空的诡异状态。// src/composables/useLazyTree.ts import type { TreeNode, TreeKey } from ../types export async function loadChildren( node: TreeNode, fetcher: (node: TreeNode) PromiseTreeNode[], ctx: { checked: SetTreeKey half: SetTreeKey preset: SetTreeKey // 后端历史选中记录用于半选回填 applySubtree: (n: TreeNode, v: boolean) void rebuildIndex: () void } ) { if (node.children?.length || node.isLeaf) return const children await fetcher(node) node.children children ctx.rebuildIndex() // 新节点要进索引否则向上回溯找不到父链 if (ctx.checked.has(node.key)) { // 父节点全选新加载的子节点全部继承选中 children.forEach(c ctx.applySubtree(c, true)) } else if (ctx.half.has(node.key)) { // 父节点半选按历史记录逐个子节点回填命中一个就整棵子树补上 children.forEach(c { if (ctx.preset.has(c.key)) ctx.applySubtree(c, true) }) } }rebuildIndex这一步不能省。索引是在组件初始化时建的一次性快照新节点挂上去之后如果不更新parentMap用户勾选这个新节点时applyAncestors查不到它的父节点状态就只往上修正一层祖父节点永远停在半选。这个 bug 表现为「勾了三级菜单一级菜单没变成半选」极难从表象反推原因。半选回填依赖preset这份历史记录。业务侧通常在打开配置页时一次性拉取某个角色已授权的菜单 key 列表把它作为preset传进来。父节点半选时只有出现在preset里的子节点才被选中其余保持未选这样重新展开后状态和库里存的一致。如果preset没传退化行为就是半选父节点下的子节点全部未选用户可以接受但要在文档里写清楚。4.3 选中集合的序列化与去重对外抛事件时Set要转成数组。这里有两个细节值得单独拎出来。一是去重和顺序。Set本身保证不重复转数组用展开运算符就行但顺序是插入顺序不是树的层级顺序。后端如果对顺序敏感虽然少见需要按树的中序遍历重新排一遍。二是序列化体积。三千节点的树全选时checkedKeys有两千多个如果还包含父节点实际有用的叶子 key 可能只有一千出头。checkedLeaves就是为这个场景准备的能砍掉近一半的传输量。字段全选时的数量用途checkedKeys全部节点数前端做展开态还原、回填halfCheckedKeys半选父节点数前端做回填后端用于展示checkedLeaves叶子节点数提交给权限接口体积最小提交后端时用checkedLeaves前端内部状态还原用checkedKeys halfCheckedKeys两者的转换在payload这个 computed 里一次性算完避免在多处重复整理。5. 避坑与排查树组件最容易翻车的几个地方5.1 数据层的坑现象勾选一个子菜单结果好几个不相干的节点一起被选中。原因key重复。扁平数据转树时如果有两条记录的id相同nodeMap里后者覆盖前者两个位置的节点指向同一个对象引用Set里存的是同一个 key勾一个等于勾两个。这种情况在关联表里很常见比如一个菜单被配到两个父级下。 解决buildTree里加一次重复检测开发环境直接抛错生产环境打日志并在 key 后面拼#index兜底。// 开发期断言生产期降级处理 const seen new SetTreeKey() for (const item of list) { if (seen.has(item.key)) { if (import.meta.env.DEV) throw new Error([tree] duplicate key: ${item.key}) item.key ${item.key}#${seen.size} } seen.add(item.key) }现象叶子节点也显示展开箭头点开是空的。原因数据里的children是空数组[]而不是undefined。hasChildren判空时用了Array.isArray而没有检查length。 解决统一判length 0或者在数据入口处把空数组归一化成undefined。我倾向于后者在buildTree里做完下游代码不用每处都判。现象父组件传进来的树数据被改坏了切换角色后菜单错乱。原因递归组件或懒加载直接往props.node.children上挂数据改的是父组件引用的对象。Vue 的 props 是浅只读的改嵌套属性不会报错就 silently 生效了。 解决buildTree第一遍扫描时做一次深拷贝{ ...rest }只拷一层children是后面重新挂的够用懒加载挂children时也确保操作的是拷贝后的对象。或者在组件入口处对整个data做structuredClone代价是大的时候有点慢三千节点大约几毫秒能接受。5.2 交互层的坑现象勾选父节点后收起再展开子节点的勾选丢了。原因渲染用的是filterTree返回的副本而状态机索引nodeMap里存的是原始树的对象。搜索过滤之后某些节点的children换了引用懒加载又往原始对象上挂数据两边对不上。 解决索引只在原始树上维护渲染树和状态树严格分离。过滤函数返回的对象里key必须保持不变所有状态查询走 key 而不是对象引用。现象受控模式下页面直接卡死控制台报「Maximum recursive updates exceeded」。原因子组件在watch(modelValue)里调用了 emit 更新父组件父组件 setState 后又把新值传下来形成闭环。 解决watch 里加一个syncing标志位同步过程中产生的 emit 直接丢弃或者更彻底一点受控模式下内部完全不 emit只把用户操作当成事件抛出去让父组件决定要不要更新modelValue。let syncing false watch(() props.modelValue, (val) { if (syncing) return syncing true syncFromProps(val ?? []) nextTick(() { syncing false }) // 等一轮渲染结束再放开防止同步触发的 emit 回流 }, { immediate: true })现象禁用节点在父节点全选后仍然显示未勾选保存时后端报「职责冲突」。原因applySubtree里对禁用节点直接 return这是对的但父节点的状态判定把禁用子节点也算进了「全选」的比较里导致父节点显示全选而实际上有一个子节点没选。 解决父节点状态判定时先filter(c !c.disabled)只拿可选子节点做比较。同时对外抛 payload 时把「父节点全选但存在禁用子节点」的情况单独标记出来交给业务侧决定是提示还是拦住。6. 二次开发把这棵树改造进自己的业务表单拿到组件之后最常见的改造是把「菜单权限」换成别的层级数据比如部门-岗位-人员或者商品类目-品牌-规格。改造成本主要看两点数据映射和交互扩展。数据映射用map一行就能接上注意key一定要全局唯一跨类型的 key 建议加前缀比如dept:12、user:88避免不同表的主键撞车。// 把业务数据映射成组件认识的形状 import type { TreeNode } from /components/tree/types export function toTreeData(depts: DeptDto[]): TreeNode[] { return depts.map(d ({ key: dept:${d.id}, label: d.name, disabled: d.locked, // 锁定部门不可取消授权 isLeaf: d.type user, // 人员节点标记为叶子不显示展开箭头 raw: d, // 原始数据带下去事件里直接取 children: d.children?.length ? toTreeData(d.children) : undefined, })) }交互扩展主要靠插槽。组件里预留了#node-extra和#empty两个具名插槽前者在每行末尾渲染用来放「查看详情」「批量设置」这类操作按钮后者在过滤无结果时展示。插槽的作用域参数是{ node, depth, checkState }够用了。要验证改造有没有改坏最省事的办法是给状态机写单测不挂载组件直接喂数据结构断言结果。下面这几条是我每次改动后都会跑的用例用例输入期望勾选叶子后父链半选勾选深度 3 的一个叶子一级、二级均为indeterminate勾满同层全部子节点勾选某父节点下所有可选子节点父节点变为checked禁用节点不参与联动父节点下有一个禁用子节点勾选其余父节点checked禁用节点unchecked半选节点再次点击对半选父节点执行 toggle父节点变checked子树全选我一般还会额外跑一遍「连续勾选再取消」的循环一百次随机操作后断言checked和half两个集合没有交集。这个断言帮我抓出过两次状态清理的遗漏都是那种偶发、难复现、用户点几次才出现的 bug。从那以后我每次给树组件加新功能都强制先把这几条单测跑一遍再去看界面。树这种带联动的组件肉眼验证太不可靠了界面上看着对集合里可能已经脏了。希望帮到你。本文还有配套的精品资源点击获取