简介:面向需要在 Android 或前端项目中实现层级菜单的开发者,这份“无限级树形菜单(支持多选)”实例围绕递归建树、节点展开折叠、遍历渲染与多选状态管理等核心问题展开,适合文件管理、组织架构、导航菜单等复杂层级场景,也可作为中初级开发者理解树形交互与组件封装的参考。压缩包共 1296 个文件,约 8.12MB,以 xml 布局与资源、png 图片素材、json 配置为主,并包含 java 源码、class 与 jar 依赖、aidl 接口、gradle 构建脚本及 apk 产物,覆盖从界面资源到可运行工程的完整构成。已有 1022 人学习下载,读者可借此梳理无限级菜单的数据结构设计、展开与渲染流程、多选及批量选择逻辑、事件响应和性能优化思路,并对照递归函数逐层处理子节点、父子选中状态联动以及全选反选等交互细节,结合项目目录理解 Android 工程的组织方式与调试要点。
1. 部门选择器引发的重构:无限级树形菜单为什么值得单独做一套
后台权限配置页要选部门,层级最深六层,还要支持多选——这是无限级树形菜单最典型的出场场景。第一版直接用了现成组件库的树,结果父节点一勾、子节点没跟上,半选状态和真实数据对不上;换个筛选条件再展开,之前勾的全没了。问题不在组件本身,而在于树的数据、勾选状态和渲染三者被绑在了一起。
这套方案要解决的就是这三件事的解耦:数据统一成扁平结构加索引表,勾选状态收敛成一个可推导的状态机,渲染层再按节点规模决定用递归还是虚拟列表。适合三类人拿去改:正在做管理系统权限模块的从业者、要交毕设或课设的学生、手里有一份半成品树组件想补齐多选和性能的开发者。
2. 数据归一化:扁平存储、索引表与三种数据源的统一入口
树组件翻车的根源,十次有八次在数据结构。业务后端给的树千奇百怪:有的返回嵌套children,有的返回带parentId的平表,有的从数据库sys_dept表直接查出id / pid三列。组件如果只认一种格式,调用方就得在每次请求后写一遍转换,转换代码散落在五六个页面里,改一次字段名要全项目搜。
我一般会在组件入口做一次归一化,把三种输入统一成内部节点结构,之后的建树、搜索、勾选、懒加载全部只认这一个结构。这样组件对外是宽容的,对内是严格的。
2.1 内部节点结构:为什么 children 只存 id
先看内部结构。扁平存储的意思是不在节点里放子节点对象,只放parentId,父子关系靠一张索引表表达。
// 内部统一节点结构:扁平存储,父子关系靠 parentId + index 表达 export interface FlatNode<T = unknown> { id: string | number; parentId: string | number | null; // null 表示根节点 label: string; // 显示文本,来源可能是 name / label / title level: number; // 层级,根为 0,用于缩进和判断是否还能展开 isLeaf: boolean; // 是否叶子,懒加载时用来决定要不要画展开箭头 disabled: boolean; // 禁用节点不参与勾选传播 raw: T; // 业务原始数据透传,避免组件吞字段 }为什么不把children塞进节点里?三个原因。第一,嵌套结构做深拷贝的代价随层级指数上升,六层树的深拷贝在低端机上能卡出明显掉帧。第二,勾选联动要从任意节点往上找祖先,嵌套结构只能靠递归搜索,复杂度 O(n),扁平结构配一张Map就是 O(1)。第三,懒加载场景下子节点是异步补进来的,往嵌套对象里插数据要一路记住路径,往扁平数组里 push 加更新parentId就完事。
raw字段是个小设计但很关键。业务方经常要在节点上挂code、type、sort这些字段,组件不能预知,原样透传出去,回调用的时候直接拿,省掉一次二次查询。
2.2 三种输入的归一化实现
归一化函数要处理的输入形态:嵌套children、扁平parentId、数据库风格的pid。核心是一个深度优先的遍历,边遍历边建索引。
export interface NormalizeResult { nodes: FlatNode[]; index: Map<string | number, FlatNode>; // id -> 节点,O(1) 定位 childrenMap: Map<string | number | null, Array<string | number>>; // 父 id -> 子 id 列表 } // 兼容嵌套 children / 扁平 parentId / 数据库 pid 三种输入 export function normalize<T extends Record<string, any>>( source: T[], options: { idKey?: string; labelKey?: string; parentKey?: string } = {} ): NormalizeResult { const { idKey = 'id', labelKey = 'label', parentKey = 'parentId' } = options; const nodes: FlatNode<T>[] = []; const index = new Map<string | number, FlatNode<T>>(); const childrenMap = new Map<string | number | null, Array<string | number>>(); // 第一类:嵌套结构,递归展开;第二类:扁平结构,parentId 已在数据里 const walk = ( list: T[], parentId: string | number | null, level: number ): void => { for (const item of list) { const id = item[idKey]; // 脏数据兜底:没有 id 的节点直接丢弃,否则索引会错位 if (id === undefined || id === null) continue; // 重复 id 只保留首次出现,避免 children 被挂两次 if (index.has(id)) continue; const node: FlatNode<T> = { id, parentId, label: String(item[labelKey] ?? item.name ?? id), level, isLeaf: item.isLeaf ?? !(Array.isArray(item.children) && item.children.length > 0), disabled: Boolean(item.disabled), raw: item, }; nodes.push(node); index.set(id, node); if (!childrenMap.has(parentId)) childrenMap.set(parentId, []); childrenMap.get(parentId)!.push(id); if (Array.isArray(item.children) && item.children.length) { walk(item.children, id, level + 1); } } }; walk(source, null, 0); // 扁表输入:没有 children 字段,靠 parentId / pid 二次挂载 const flatOnly = nodes.filter((n) => n.parentId === null); if (source.length && !source.some((i) => Array.isArray(i.children))) { for (const node of nodes) { const pid = node.raw[parentKey] ?? node.raw.pid ?? null; if (pid !== null && index.has(pid)) { node.parentId = pid; node.level = (index.get(pid)!.level ?? 0) + 1; const siblings = childrenMap.get(pid) ?? []; if (!siblings.includes(node.id)) siblings.push(node.id); childrenMap.set(pid, siblings); // 挂上父节点后,节点不再是根 if (flatOnly.includes(node)) flatOnly.splice(flatOnly.indexOf(node), 1); } else { node.parentId = null; } } // 根集合重建 childrenMap.set(null, flatOnly.map((n) => n.id)); } return { nodes, index, childrenMap }; }这段代码有三个地方值得说明。idKey / labelKey / parentKey做成可配参数,是因为不同后端字段命名差异很大,硬编码会让调用方又回到写转换层的老路。重复 id 只保留第一次,看起来是个小细节,但在多表联查拼出来的数据里经常出现,放任不管会导致某个子节点在界面上出现两次、勾选时状态互相覆盖。第三段扁表挂载用了flatOnly.includes做判断,节点数量上千时这是 O(n²),实际项目里我会改成用Set记录根 id,性能差距在万级数据上非常明显。
2.3 索引表的价值:从 O(n) 到 O(1)
childrenMap和index这两张表是后面所有逻辑的地基。有了它们,getChildren(id)是常数时间,getAncestors(id)顺着parentId往上走,复杂度等于层数而不是节点数。树的层级通常不超过十层,所以这条链路的成本可以忽略。
对比一下没有索引表的做法:判断某个节点是否应该显示为半选,需要递归扫一遍它的子树看有没有全选、有没有全不选;在大列表里每个节点都这么扫一次,就是 O(n²)。我见过一个六层、每层几十个节点的部门树,滚动时肉眼可见地卡。加了索引表之后同样的操作是每帧常数级的。
提示:
childrenMap的 key 用null表示根集合,这样根节点的遍历和普通节点的遍历能共用一套代码,不用写 if 分支。
如果输入数据量特别大,比如一次返回两万个节点,归一化本身也会花时间。这时的做法是拆分:首屏只归一化第一层和当前展开路径,其余等展开时再请求、再归一化并增量合并进index和childrenMap。合并逻辑要判断 id 是否已存在,已存在就更新raw和label,不存在才追加,避免用户反复展开折叠时节点被重复插入。
3. 勾选状态机:父子联动、半选推导与三种勾选策略的取舍
数据理顺之后,真正难的是多选。很多人写的树勾选会出这样的问题:勾了父节点、再取消一个子节点,父节点还是勾着的;或者父节点显示半选,但子节点看起来全是勾的。这类 bug 的本质是把checked当成唯一真相,然后到处手动改它。正确的做法是把状态拆成两层:一层是用户显式操作留下的记录,一层是由记录推导出来的展示状态。
3.1 只存显式勾选:一个 Set 撑起整个状态
我的做法是只维护一个Set,装的是用户明确勾选或取消的节点 id,但这里的语义要更精确一点:存的是「叶子节点的勾选结果」加上「用户对整棵子树的批量意愿」。为了简化,我通常采用更直接的一种:checkedSet存所有被勾选的节点(含父节点),同时维护indeterminateSet由推导得出,不做持久化。
export type Id = string | number; export class CheckModel { /** 显式勾选的节点 id,父子联动时会被批量写入 */ private checked = new Set<Id>(); /** 半选集合,纯派生状态,任何写操作后重算 */ private indeterminate = new Set<Id>(); constructor( private index: Map<Id, FlatNode>, private childrenMap: Map<Id | null, Id[]>, private strict = false // true 时父子互不影响 ) {} isChecked(id: Id): boolean { return this.checked.has(id); } isIndeterminate(id: Id): boolean { return this.indeterminate.has(id); } toggle(id: Id, value: boolean): void { if (this.strict) { // 严格模式:只改自己,不动父也不动子 value ? this.checked.add(id) : this.checked.delete(id); return; } this.setSubtree(id, value); // 向下传播 this.refreshAncestors(id); // 向上重算半选 } /** 向下:整棵子树同步,跳过 disabled 节点 */ private setSubtree(id: Id, value: boolean): void { const stack: Id[] = [id]; while (stack.length) { const cur = stack.pop()!; const node = this.index.get(cur); if (!node || node.disabled) continue; value ? this.checked.add(cur) : this.checked.delete(cur); this.indeterminate.delete(cur); const children = this.childrenMap.get(cur) ?? []; for (const c of children) stack.push(c); } } /** 向上:逐层判断全选 / 半选 / 全不选 */ private refreshAncestors(id: Id): void { let cur = this.index.get(id)?.parentId ?? null; while (cur !== null) { const children = this.childrenMap.get(cur) ?? []; let allChecked = children.length > 0; let anyChecked = false; for (const c of children) { if (this.checked.has(c)) anyChecked = true; else allChecked = false; if (this.indeterminate.has(c)) anyChecked = true; // 有一个子节点既没勾也没半选,父节点就不可能全选 if (!this.checked.has(c) && !this.indeterminate.has(c)) allChecked = false; } if (allChecked) { this.checked.add(cur); this.indeterminate.delete(cur); } else if (anyChecked) { this.checked.delete(cur); this.indeterminate.add(cur); } else { this.checked.delete(cur); this.indeterminate.delete(cur); } cur = this.index.get(cur)?.parentId ?? null; } } getCheckedIds(): Id[] { return [...this.checked]; } }setSubtree用的是显式栈而不是递归,原因是六层以上、节点上万时递归有爆栈风险,而且显式栈可以随时中断、分批执行。refreshAncestors里那段判断逻辑是半选的核心:父节点只有在所有子节点都处于「已勾选」状态时才是全选;只要有一个子节点是勾选或半选,父节点就是半选。注意这里把半选也算进anyChecked,因为孙辈的勾选状态必须能一路冒泡到根。
参数strict用来切换策略,下一节展开。
3.2 三种勾选策略:联动、独立、只选叶子
业务上对「勾选父节点」的理解并不统一,所以组件必须能切。
| 策略 | 勾父节点的效果 | 典型场景 | 提交时的值 |
|---|---|---|---|
| 父子联动(默认) | 整棵子树全选 | 权限分配、部门筛选 | 只提交叶子,或全量由后端判断 |
| 父子独立(strict) | 只勾父节点本身 | 组织架构打标签 | 提交所有勾选 id |
| 只选叶子(leafOnly) | 父节点不可勾,仅作展开 | 商品类目、地区选择 | 只提交叶子 id |
联动模式最常用,也最容易出问题。它有一个隐含约定:提交给后端的数据到底是只给叶子还是给全路径。我在项目里一般默认只提交叶子节点,因为后端做权限校验时通常是按最末级资源判断的;如果业务需要全路径,就在getCheckedIds外面再套一层getCheckedWithAncestors,顺着parentId往上补齐。这件事一定要和调用方在文档里写清楚,不然联调时双方各说各话,来回改接口。
只选叶子模式在实现上要改两处:toggle里遇到非叶子节点直接 return,refreshAncestors里父节点的checked永远不置 true,只维护indeterminate。这样做出来的交互是父节点只能展开、不能勾,视觉上一般给个浅色背景表示「下面有选中的」。
独立模式实现最简单,strict = true时toggle只改自己。但它有个体验问题:用户勾了一堆父节点,子节点还全是空的,提交上去后端可能识别不了。我的做法是勾父节点时给个即时提示,或者在提交前做一次校验,把「只选了父节点没选子节点」的组合列出来让用户确认。
3.3 全选与反选:作用域必须是可见集
工具栏上的「全选」是最容易埋雷的地方。用户先搜索「研发部」,列表只剩三个节点,点全选,结果整个树几百个节点全被勾上,提交时权限给多了——这种事故的修复成本很高。
正确的语义是:全选、反选、清空都只作用于当前可见(过滤后)的节点集合。实现上不新增状态,只把setSubtree换成对可见 id 列表的批量toggle,批量操作走完再统一刷新祖先,避免每个节点都触发一次向上遍历。
// 批量勾选:先按可见集筛一遍,再统一刷新祖先,避免逐节点向上遍历 checkVisible(visibleIds: Id[], value: boolean): void { const touched = new Set<Id>(); for (const id of visibleIds) { if (this.index.get(id)?.disabled) continue; value ? this.checked.add(id) : this.checked.delete(id); this.indeterminate.delete(id); touched.add(id); } // 只对本批次的节点刷祖先,父级会各自去重 for (const id of touched) this.refreshAncestors(id); }这段的性能关键在于最后那次统一刷新。如果每个节点都调一次toggle,同一条祖先链会被反复计算几十次。实际项目里三千个节点的全选,逐节点刷要几十毫秒,批量刷在十毫秒以内,肉眼差别明显。
还有一个边界:全选之后用户又展开了一个没加载过的分支,新加载的节点默认是未勾选的,父节点的半选状态要跟着变。这个联动在懒加载场景下必须显式处理,否则会出现「父节点显示全选、但展开后里面有没勾的节点」这种自相矛盾的画面。做法是把新加载节点的parentId拿到,对它调一次refreshAncestors。
3.4 状态提升:为什么勾选不能放在组件内部
组件内部data里放一个checkedKeys数组,是最常见的写法,也是最常见的事故源。用户勾完一堆节点,收起某个父节点再展开,或者切了个 tab 回来,勾选全没了。原因很直接:节点用v-if控制显隐,销毁时组件实例上的状态一起没了。
我的做法是把CheckModel实例提升到页面层或状态管理层,组件只通过 props 拿状态、通过事件往外抛操作。这样折叠、展开、切换 tab 都不会影响勾选记录。同时它带来一个额外好处:可以随时JSON.stringify(model.getCheckedIds())打到控制台,调试时不用点界面就能看到真实状态,省掉很多猜测。
提示:
indeterminate永远不要从外部传入,只由CheckModel内部推导。一旦允许外部写,离「父节点显示全选但子节点没勾」这类 bug 就不远了。
4. 渲染与性能:递归组件、虚拟滚动与万级节点的懒加载方案
状态和数据结构都稳了,剩下的是怎么画出来。树的渲染有两种路线:递归组件和扁平列表。前者写起来直观,后者性能好,边界很清晰——节点数在一千以内,递归完全够用;超过三千,或者节点高度不固定,就该考虑扁平加虚拟滚动了。
4.1 递归组件的写法与它的边界
递归组件的核心是把「节点」和「子树」拆成两个角色:一个渲染单行,一个负责遍历子节点。Vue 3 里用name选项或<script setup>里显式声明组件名就能自引用。
<!-- TreeItem.vue:递归渲染单个节点及其子树 --> <script setup lang="ts"> import { computed } from 'vue'; import type { FlatNode } from './types'; const props = defineProps<{ node: FlatNode; expanded: Set<string | number>; checkModel: any; // 实际项目里用 provide/inject 传,避免逐层 props 透传 loadState: Map<string | number, 'idle' | 'loading' | 'done'>; }>(); const emit = defineEmits<{ (e: 'toggle-expand', id: string | number): void; (e: 'toggle-check', id: string | number, value: boolean): void; }>(); const checked = computed(() => props.checkModel.isChecked(props.node.id)); const indeterminate = computed(() => props.checkModel.isIndeterminate(props.node.id)); const isOpen = computed(() => props.expanded.has(props.node.id)); </script> <template> <div class="tree-row" :style="{ paddingLeft: node.level * 18 + 'px' }"> <span class="arrow" @click="emit('toggle-expand', node.id)"> {{ node.isLeaf ? '' : isOpen ? '▾' : '▸' }} </span> <input type="checkbox" :checked="checked" :indeterminate.prop="indeterminate" :disabled="node.disabled" @change="emit('toggle-check', node.id, ($event.target as HTMLInputElement).checked)" /> <span class="label">{{ node.label }}</span> </div> <!-- 关键:只有展开且非叶子时才继续递归 --> <template v-if="isOpen && !node.isLeaf"> <TreeItem v-for="childId in checkModel.childrenOf(node.id)" :key="childId" :node="checkModel.nodeOf(childId)" :expanded="expanded" :check-model="checkModel" :load-state="loadState" @toggle-expand="emit('toggle-expand', $event)" @toggle-check="(id, v) => emit('toggle-check', id, v)" /> </template> </template>几个细节。indeterminate是 DOM 属性不是 HTML attribute,必须用.prop修饰符,直接写:indeterminate在部分版本里不生效,这是踩过的坑。v-for的 key 必须用节点 id,用 index 会导致折叠后节点复用错乱,勾选状态串到别的行上。递归的终止条件放在v-if上而不是组件内部return,这样未展开的子树根本不会创建组件实例,层级再深也不影响首屏。
递归组件的天花板在于「展开全部」。六层树全展开可能是几千个 DOM 节点,配合每行一个 checkbox 和 label,浏览器布局一次要几十毫秒,滚动就掉帧了。所以它适合默认只展开第一层、用户按需展开的场景。
4.2 扁平化加虚拟滚动:把 DOM 数量压到视口内
节点规模上到几千、又要求「一键展开全部」时,就得换成扁平渲染。思路是:根据展开状态算出一个可见行的一维数组,只渲染视口内的那一段。
// 把树拍平成可见行数组,虚拟滚动只认这个数组 export function flattenVisible( rootIds: Array<string | number>, childrenMap: Map<string | number | null, Array<string | number>>, index: Map<string | number, FlatNode>, expanded: Set<string | number> ): FlatNode[] { const rows: FlatNode[] = []; const stack = [...rootIds].reverse(); // 栈倒序,保证输出顺序与视觉顺序一致 while (stack.length) { const id = stack.pop()!; const node = index.get(id); if (!node) continue; rows.push(node); if (expanded.has(id)) { const children = childrenMap.get(id) ?? []; for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]); } } return rows; }这个函数是虚拟滚动的数据源。行高固定时,直接按scrollTop / rowHeight算出起止索引,只渲染那一段,上下用两个撑高的空 div 占位。行高不固定(比如节点上带标签、描述)就麻烦一些,需要用预估高度加动态测量,一般项目里我会强制固定行高 32 或 36 像素,把复杂度降下来。
节点数超过一万时还有一层优化:flattenVisible本身是 O(可见节点数),每次展开都全量重算。如果用户连续快速展开十几层,这个重算会被触发十几次。做法是加个缓存,key 用expanded的版本号加根节点版本号,版本没变就复用上次的结果。
4.3 异步懒加载:占位、去重与失败重试
部门树动辄几万节点,一次性拉全量不现实,懒加载是标配。接口约定一般是loadChildren(parentId)返回子节点数组,组件在展开时调用。
实现上有三个点要注意。第一,节点展开前必须先把isLeaf标成 false 并插入一个占位子节点,否则用户看不到展开箭头,也就没法触发加载。第二,同一个节点在加载中被重复点击时要去重,用loadState记一个 loading 状态,加载中的直接忽略。第三,加载失败要把占位节点删掉、把expanded里的 id 移除,让用户能重新点。
async function loadChildren(id: string | number): Promise<void> { const state = loadState.get(id); if (state === 'loading' || state === 'done') return; // 去重,避免重复请求 loadState.set(id, 'loading'); try { const list = await props.fetchChildren(id); // 增量合并进索引和 childrenMap,不重建整棵树 const { nodes, index: subIndex, childrenMap: subMap } = normalize(list); for (const n of nodes) { if (props.index.has(n.id)) continue; // 后端重复返回时跳过 n.parentId = id; n.level = (props.index.get(id)?.level ?? 0) + 1; props.index.set(n.id, n); } // 用新数据替换占位子节点 props.childrenMap.set(id, nodes.map((n) => n.id)); loadState.set(id, 'done'); // 新节点默认未勾选,需要重算一次父链的半选状态 props.checkModel.refreshAncestors(id); } catch (err) { loadState.delete(id); props.expanded.delete(id); // 这里给用户一个可重试的提示,而不是静默失败 props.onError?.(id, err); } }normalize在这里被复用了,说明归一化放在入口是对的,懒加载拿到的子节点列表可能是任意格式,走同一套转换就不用在两个地方维护。最后那行refreshAncestors是懒加载和勾选联动的接缝,漏了就会出现「父节点全选但展开后有未勾选子节点」的矛盾状态。
4.4 搜索过滤:保留命中路径而不是只留命中节点
搜索是树组件的高频需求,也是体验分水岭。差的实现是只把命中的节点留下来,用户看到的是一堆没有上下文的孤零零节点,不知道它在哪一层。好的实现是保留命中节点到根的完整路径,同时把路径上的节点自动展开。
实现分两步:先对label做匹配拿到命中 id 集合,再对每个命中 id 沿parentId往上走,把经过的所有 id 加进「可见集」。这个可见集不改变原始数据,只在flattenVisible之前做一次过滤。
// 搜索:返回命中节点及其全部祖先,保证树形上下文不丢 export function filterWithAncestors( keyword: string, nodes: FlatNode[], index: Map<string | number, FlatNode> ): Set<string | number> { const visible = new Set<string | number>(); const kw = keyword.trim().toLowerCase(); if (!kw) return visible; for (const node of nodes) { if (!node.label.toLowerCase().includes(kw)) continue; visible.add(node.id); // 命中节点的祖先链全部保留,否则树上只剩一个孤立节点 let cur = node.parentId; while (cur !== null && !visible.has(cur)) { visible.add(cur); cur = index.get(cur)?.parentId ?? null; } } return visible; }这个函数返回的是可见集,UI 上还要把它转成「自动展开」的集合,否则路径保留了但用户还是得手动一层层点开。我一般会把可见集里所有非叶子的 id 直接塞进expanded,搜索结束时再恢复原来的展开状态。
这里有个语义问题必须提前定:搜索状态下点「全选」,是选中搜索结果还是选中全部数据?我的默认是只选搜索结果,因为这符合用户当下看到的范围;如果业务方要的是全量,就单独提供一个「选中全部匹配项」的勾选项,把两种语义显式分开,别让用户猜。
5. 常见问题排查:半选错乱、勾选丢失与递归爆栈的四类故障
树组件的 bug 有个特点:现象看起来都差不多,都是「勾选不对」,但根因分散在数据、状态、渲染三层。下面这四类是我在不同项目里反复遇到的,按「现象、原因、解决」拆开写,遇到问题可以当排查清单顺着对。
5.1 半选状态错乱:父节点显示全选但子节点没勾满
现象:点父节点展开后,里面有几个子节点是未勾选状态,但父节点仍然显示为勾选而不是半选。
原因基本是两个。一是父节点的checked被直接赋了值,没有经过子节点推导,常见于「回显」场景——接口返回了一个父节点 id,代码直接checked.add(parentId),但没把它的子节点一起加上。二是refreshAncestors只在toggle里调,回显、懒加载、数据更新这些入口都漏了。
解决:把「写 checked」收敛成唯一入口,任何外部来源的勾选(回显、接口返回、懒加载新节点)都必须走一次「先写进去、再对受影响的 id 刷祖先」。回显时尤其要注意,接口给的可能是父节点 id,也可能是叶子 id,两种都要能正确展开成完整状态;判断不出来的话,就按「给定 id 全部当作显式勾选,然后自底向上重算一遍」处理,宁可多算一次也不要留矛盾状态。
另外检查indeterminate是不是被外部写过。它必须是纯派生状态,一旦有外部写入,就再也无法保证和子节点一致。
5.2 勾选丢失:折叠再展开后选择被清空
现象:勾选若干节点,折叠父节点再展开,或者切走 tab 再切回来,勾选状态回到初始值。
原因:勾选状态存在了递归组件的局部data里,节点被v-if销毁时状态一起销毁。这在递归组件写法里特别容易发生,因为每层子组件都有一份自己的状态副本,父组件根本不知道下面存了什么。
解决:状态提升。把CheckModel放在页面层或状态管理里,组件只读不算;节点销毁重建只是重新渲染,数据本身还在。提升之后还会顺带解决另一个问题:提交表单时不用去遍历组件实例收集状态,直接从 model 上取getCheckedIds()就行,这在弹窗嵌套的场景里差别很大。
一个检查技巧:在toggle里打一行日志,观察折叠展开后 toggle 有没有被意外触发。如果触发了,说明某处的 watch 或 computed 在重建时反向写了状态,这种自反馈循环会表现为「勾了又自动取消」,比单纯丢失更难查。
5.3 递归爆栈与首屏白屏:层级深或数据量大时直接卡死
现象:数据里存在环(A 的父是 B,B 的父是 A),或者层级深到几十层,页面直接白屏,控制台报Maximum call stack size exceeded。
原因:递归建树或递归渲染时没有做访问标记,遇到环就一直往下走。数据里出现环的原因通常是脏数据或者后端某个parentId填错了自己,这种事在生产环境的树表里并不罕见。
解决:在normalize的walk里用一个visited集合记录已处理的 id,进入前判断一次;扁表挂载那一步也要判断「父节点不能是自己」,pid === id的直接当根处理。渲染层同样要防,递归组件的终止条件除了「是否展开」还要判断层级上限,超过比如 20 层就停止递归并给出提示,把问题暴露出来而不是让页面崩掉。
首屏白屏还有另一个常见原因:默认把整棵树全展开了。几千个节点同时渲染,DOM 数量爆炸。默认只展开第一层或前两层,剩下的交给用户点,这一条能解决大部分「打开就卡」的反馈。
5.4 全选选中了看不见的节点
现象:搜索过滤后点全选,清空搜索发现大量没见过的节点也被勾上了,提交后权限给多了。
原因:全选直接作用在checked全集上,没有考虑当前的过滤条件和展开状态。这是语义层面的问题,不是代码 bug,但后果最严重。
解决:全选、反选、清空三个操作都基于「当前可见集」计算,可见集由filterWithAncestors的结果和展开状态共同决定。同时在交互上把语义显式化,工具栏显示「已选 N 项 / 当前筛选 M 项」,两个数字不一样时用户自己能意识到范围差异。如果业务确实需要跨筛选保留勾选(常见于批量操作),就在提交前弹一个确认,列出「有 X 个已选项不在当前筛选范围内」,让人工兜底一次。
还有一个隐蔽的变体:虚拟滚动下 DOM 只渲染了视口内的几十行,如果全选的实现依赖「遍历 DOM 上的 checkbox」,就会只选中可见的那部分。这类实现一定要改成基于数据模型计算,别碰 DOM。
6. 验收与答辩:一份能当场演示的检查清单与三分钟讲解脚本
方案做完,最怕的是演示时被问一句「你这个和组件库的有什么区别」然后答不上来。验收要准备两样东西:一份覆盖边界情况的检查清单,和一段三分钟内讲清设计取舍的脚本。
先看检查清单。这张表我一般会提前打印出来,逐条点一遍,过了才敢交。
| 检查项 | 操作 | 期望结果 |
|---|---|---|
| 深层联动 | 勾选第三层节点,看根节点 | 根节点显示半选,不是全选 |
| 取消联动 | 勾满某父节点全部子节点,再取消一个 | 父节点从全选变半选 |
| 折叠保持 | 勾选后折叠父节点再展开 | 勾选状态完整保留 |
| 搜索范围 | 搜索关键词后点全选 | 只有命中路径上的节点被选中 |
| 懒加载联动 | 展开未加载分支,勾选其中节点 | 祖先半选状态实时更新 |
| 禁用节点 | 勾选含禁用子节点的父节点 | 禁用节点及其子树不被改变 |
| 大数据量 | 导入两万节点,一键展开 | 滚动流畅,无白屏 |
| 环数据 | 构造 parentId 指向自身的脏数据 | 页面正常渲染,不爆栈 |
| 重复 id | 数据里出现两个相同 id | 只渲染一次,勾选不错乱 |
| 提交格式 | 点击提交,打印结果 | id 列表与文档约定一致 |
演示脚本按这个顺序走。第一分钟讲问题:打开组件库的树,勾父节点、取消一个子节点,展示半选是对的;然后折叠再展开,指出状态丢失的问题(可以故意用一版没修的实现对比)。第二分钟讲结构和实现:画一下扁平存储加索引表,说明为什么getAncestors是常数级;再切到状态机那段代码,讲「只存显式勾选、半选靠推导」这一句,这是整套方案最核心的一句话。第三分钟讲性能:现场导入两万节点,一键展开,滚动给人看,再点开搜索框输入关键词,展示路径保留和范围全选。
被问到「为什么不用现成组件库」时,别急着否定它。稳妥的回答是:组件库覆盖了八成通用场景,这套方案补的是剩下两成——跨筛选保留勾选、懒加载下的半选联动、万级节点的虚拟滚动、以及提交格式的可配置。把差异点说成补充而不是替代,既真实也不容易被打回。
被问到「你这个复杂度多少」时,直接答:单次勾选向下传播是 O(子树节点数),向上重算是 O(层数 × 平均分支数),检索是 O(1),全量拍平是 O(可见节点数)。数字摆出来比说「性能很好」有说服力得多。
血泪经验是:答辩前一晚一定要用真实量级的数据跑一遍,别拿十几个节点的 demo 数据演示。我第一次交这个东西的时候,演示数据只有二十个节点,一切正常,评审随手导了一份真实部门表,两万节点,页面直接卡住不动,那几分钟非常难熬。从那以后我每次交付树组件,都强制走一遍「两万节点 + 一键展开 + 搜索 + 全选」这条链路,跑通了才敢说完成。
这套东西从数据归一化、状态机、渲染性能到验收清单是完整的一条线,代码和检查表都可以直接拿去改。希望帮到你。
本文还有配套的精品资源,点击获取