简介:面向需要实现多层数据结构界面的 Android 与前端开发者,这套无限级树形菜单示例围绕递归建树、节点展开折叠、遍历渲染与多选状态管理展开,可用于文件目录、组织架构、导航菜单等层级较深的交互场景,也适合用来理解全选、反选及父子节点联动选择的处理方式。压缩包共 1296 个文件,约 8.12MB,以 xml 布局与资源、png 界面素材、json 配置、class 与 jar 依赖、java 源码为主,并附带 apk、aidl 及 Gradle 构建文件,基本覆盖一个可运行工程的完整结构。已有 1022 人学习下载,说明其在树形菜单与多选交互主题上具有一定参考价值。读者可从中梳理递归组件的组织方式、节点状态的维护思路、事件监听与渲染性能优化策略,并借助工程内的资源与依赖对照调试,形成可复用的层级菜单实现方案。
1. 后台权限树点一下全亮:无限级菜单到底难在哪
上周帮人看一个后台配置页,左侧挂着一棵权限树,勾选部门父节点,底下几十个子项同时亮起;取消其中一个子项,父节点立刻变成那个灰色横杠的半选态。产品经理觉得这是基本操作,写的人却在这上面耗了两天。无限级菜单这个词听起来像“选个组件”的事,实际是数据结构、状态机、递归渲染三件事绑在一起:层级一深,父子联动要向上回算,节点一多,渲染就开始卡,再叠上搜索过滤和展开状态保持,几处稍有不慎就会出问题。
这份源码包做的就是这件事:无限级树形菜单,支持多选、父子联动、半选态、全选反选、关键字过滤,节点层级不设上限。它把“扁平数据转树”“递归渲染”“勾选状态回溯”拆成三个互不干扰的模块,数据层和视图层是分开的,接进现有后台时不需要改后端接口格式。适合两类人:被权限树、部门树、分类树反复折磨的前端,以及想弄明白多选联动那套状态到底怎么推的开发者。下面按“它怎么设计的、核心链路长什么样、怎么跑、怎么改”一路拆下去。
2. 技术选型与工程骨架:递归组件加扁平数据双轨
2.1 递归渲染和循环拼接,为什么最终选了组件递归
树形结构在前端有两种典型写法。一种是把整棵树拼成 HTML 字符串再塞 innerHTML,另一种是组件自己调用自己。前者在只读展示场景够用,但只要节点上要挂 checkbox、展开箭头、加载态、右键菜单,字符串拼接就会变成一堆字符串模板套 if-else,事件还得靠事件委托按>// src/utils/tree.js /** * 扁平列表转树,同时挂载 parent 回指指针 * @param {Array} list 扁平节点数组 * @param {Object} options 可配置字段名 * @returns {Array} 根节点数组 */ export function listToTree(list, options = {}) { const { idKey = 'id', parentKey = 'parentId', labelKey = 'label' } = options const nodeMap = new Map() const roots = [] // 第一遍:建索引,深拷贝一层,避免污染原始数据 for (const item of list) { nodeMap.set(item[idKey], { ...item, label: item[labelKey], children: [] }) } // 第二遍:挂载父子关系 for (const item of list) { const node = nodeMap.get(item[idKey]) const parent = nodeMap.get(item[parentKey]) if (parent) { node.parent = parent // 回指指针,向上回溯用 parent.children.push(node) } else { node.parent = null roots.push(node) // 找不到父节点一律当根,防止孤儿节点丢失 } } return roots }
这段代码有三处理性上的考量。第一,拆成两遍遍历而不是一遍递归查找父节点,是因为单遍写法要在数组里不断find,复杂度是 O(n²),菜单数据上千条时页面首次渲染会有明显卡顿。用 Map 建索引后两次 O(n) 遍历,一千条数据转完不到 2 毫秒。第二,node.parent = parent这个回指是给勾选联动准备的,向上回溯时不用每次都从根节点重新找爹。第三,找不到父节点的孤儿节点当根处理,这点很重要——后端数据被删过、parentId指向了不存在的记录是常事,很多实现直接跳过这种节点,结果就是某个菜单在页面上凭空消失,排查半天以为是接口问题。
注意:挂了
parent回指之后,整棵树就有循环引用了。往 localStorage 缓存或者走JSON.stringify传给别的模块之前,必须先剥掉parent字段,否则直接抛Converting circular structure to JSON。这是这套设计唯一需要记住的副作用。
节点对象的完整字段如下,接口返回的不需要的字段会原样保留在对象上,方便二次开发时取业务属性:
| 字段 | 类型 | 说明 |
|---|---|---|
id | string / number | 节点唯一标识 |
label | string | 展示文本,来源字段可通过labelKey配置 |
parentId | string / number | 父节点 id,根节点为 null |
parent | object / null | 转树时生成的回指指针,不参与序列化 |
children | array | 子节点数组,叶子为空数组 |
disabled | boolean | 可选,禁用后不可勾选 |
3. 核心模块拆解:从扁平数组到半选状态的四步链路
3.1 递归渲染:TreeNode 怎么把自己再调一次
递归组件是整个树的骨架。它要做的事很朴素:渲染自己这一行,如果有子节点并且处于展开态,就再渲染一批自己。关键在于状态从哪来。
<!-- src/components/TreeMenu/TreeNode.vue --> <template> <li class="tree-node"> <div class="tree-node__row" :style="{ paddingLeft: depth * 18 + 'px' }"> <!-- 展开箭头:无子节点时占位不显示 --> <span v-if="hasChildren" class="tree-node__arrow" :class="{ 'is-open': isOpen }" @click="$emit('toggle', node.id)" ></span> <span v-else class="tree-node__arrow is-leaf"></span> <input type="checkbox" :checked="isChecked" :indeterminate.prop="isHalf" :disabled="node.disabled" @change="onChange($event.target.checked)" /> <span class="tree-node__label" @click="onChange(!isChecked)">{{ node.label }}</span> </div> <!-- 只在展开时递归,收起状态不生成任何 DOM --> <ul v-if="hasChildren && isOpen" class="tree-node__children"> <TreeNode v-for="child in node.children" :key="child.id" :node="child" :depth="depth + 1" :checked-ids="checkedIds" :half-ids="halfIds" :expanded-ids="expandedIds" @check="(id, val) => $emit('check', id, val)" @toggle="(id) => $emit('toggle', id)" /> </ul> </li> </template>脚本部分只做三件读取和一次上报:
<script setup> import { computed } from 'vue' const props = defineProps({ node: { type: Object, required: true }, depth: { type: Number, default: 0 }, checkedIds: { type: Set, required: true }, // 已选集合,只读 halfIds: { type: Set, required: true }, // 半选集合,只读 expandedIds: { type: Set, required: true }, // 展开集合,只读 }) const emit = defineEmits(['check', 'toggle']) const hasChildren = computed(() => props.node.children?.length > 0) const isOpen = computed(() => props.expandedIds.has(props.node.id)) const isChecked = computed(() => props.checkedIds.has(props.node.id)) const isHalf = computed(() => props.halfIds.has(props.node.id)) // 本节点只上报"谁被点了、点成什么",不自己改状态 function onChange(val) { if (props.node.disabled) return emit('check', props.node.id, val) } </script>这里的设计要点是受控:TreeNode 自己不持有任何勾选状态,checkedIds、halfIds、expandedIds三个 Set 全部由顶层组件传下来,节点只负责比对has(id)得到布尔值,点击时把 id 和意图上报。这么做有两个好处。其一,全选、反选、清空这类批量操作只需要顶层一次性生成新 Set,不用逐个通知子节点,避免了“层层 $emit 上去再层层传下来”的重复渲染。其二,状态只有一个来源,调试时在顶层打印三个 Set 就能看清整棵树的真实状态,不用挨个节点翻组件树——这比排查散落在每个节点里的data.checked要舒服太多。
性能上有个细节:<ul v-if="hasChildren && isOpen">用的是v-if而不是v-show。收起状态下的子树根本不会创建组件实例,两千个节点默认只展开两级时,实际渲染的 DOM 可能只有一两百个。如果换成v-show,整棵树全量渲染再加display: none,首屏会明显发涩。这也是这套组件敢说“无限级”的底气之一——层级深不可怕,可怕的是全量渲染。
3.2 勾选联动:向下全选与向上回溯的两段式算法
这是整个组件最核心的一段,也是最容易写错的一段。需求听起来简单:勾一个父节点,所有子孙跟着选中;取消一个子节点,父节点从全选变半选,再往上爷爷节点也得跟着重算。难点在于向上回溯——子节点变化后,父节点的状态必须重新推导,而父节点一变,它自己的父节点又要重算,一路到根。
// src/components/TreeMenu/useTreeCheck.js /** * 应用一次勾选,返回新的选中集合与半选集合 * 不修改入参,纯函数式,方便撤销和历史记录 */ export function applyCheck(node, shouldCheck, checkedIds, halfIds) { const nextChecked = new Set(checkedIds) const nextHalf = new Set(halfIds) // 第一段:向下,整棵子树跟随 const walkDown = (n) => { if (shouldCheck) { nextChecked.add(n.id) } else { nextChecked.delete(n.id) } nextHalf.delete(n.id) // 整棵子树要么全选要么全不选,不留半选 n.children?.forEach(walkDown) } walkDown(node) // 第二段:向上,逐级重算每一代父节点 let parent = node.parent while (parent) { const kids = parent.children || [] const checkedCount = kids.filter((c) => nextChecked.has(c.id)).length const halfCount = kids.filter((c) => nextHalf.has(c.id)).length if (checkedCount === kids.length && halfCount === 0) { // 子节点全选且无半选 → 父节点全选 nextChecked.add(parent.id) nextHalf.delete(parent.id) } else if (checkedCount === 0 && halfCount === 0) { // 子节点一个没选且无半选 → 父节点取消 nextChecked.delete(parent.id) nextHalf.delete(parent.id) } else { // 其余情况 → 父节点半选,且不能出现在选中集合里 nextChecked.delete(parent.id) nextHalf.add(parent.id) } parent = parent.parent } return { checkedIds: nextChecked, halfIds: nextHalf } }参数和逻辑拆开说。入参node是被点击的那个节点对象,shouldCheck是目标状态(true 表示要选中,false 表示要取消),后两个是当前的选中集合和半选集合。返回值是两个新集合,而不是就地修改入参。
为什么坚持返回新集合?因为要留后悔药。顶层拿到返回值后,可以顺手 push 进一个历史栈,做撤销重做,也可以做“对比上次提交的权限差异”,改了什么一目了然。如果就地add/delete,这一切都无从谈起。代价是每次勾选要复制两个 Set,几千个节点时复制成本大概在毫秒级,对这个交互频率来说完全可以接受。
第二段的判断条件值得再抠一下。父节点有三种状态:全选、半选、未选。判定不能只看选中数,还要看半选数——如果两个子节点一个全选、一个半选,checkedCount是 1,kids.length是 2,进不了第一个分支,也进不了第二个分支,落到 else 变成半选,正确。但如果只看checkedCount不看halfCount,当一个父节点下所有子节点都是半选态、且它们自己都不在checkedIds里时,checkedCount === 0会让父节点被误判成“未选”,整条链就崩了。这就是为什么 else 分支里必须同时清理nextChecked和设置nextHalf——一个节点不可能同时全选和半选。
顶层组件的接法很薄:
// index.vue 中 const checkedIds = ref(new Set()) const halfIds = ref(new Set()) const expandedIds = ref(new Set()) function onCheck(id, val) { const node = findNodeById(treeData.value, id) // 一次查找换引用 if (!node) return const result = applyCheck(node, val, checkedIds.value, halfIds.value) checkedIds.value = result.checkedIds halfIds.value = result.halfIds emit('change', [...result.checkedIds]) // 对外只暴露纯 id 数组 }对外只吐出 id 数组,不吐整棵树,这样父组件拿去做提交时不会把parent回指指针带进请求体,省一堆序列化报错。
3.3 搜索过滤:命中节点保留,祖先链不能断
过滤是另一个高频需求。用户输入关键字,只保留命中的节点——但如果只留命中的那个节点,它和根之间的路径就断了,树渲染出来是一堆散落的孤立行,用户看不出层级。所以正确做法是:命中节点保留,它的所有祖先作为骨架也保留,不命中的兄弟节点剪掉。
// src/utils/tree.js /** * 关键字过滤,返回剪枝后的新树 * @param {Array} nodes 树形节点数组 * @param {string} keyword 关键字,空串返回原树 * @param {string} field 参与匹配的字段 */ export function filterTree(nodes, keyword, field = 'label') { const kw = keyword.trim().toLowerCase() if (!kw) return nodes const walk = (list) => { const result = [] for (const node of list) { const children = walk(node.children || []) const hit = String(node[field] || '').toLowerCase().includes(kw) // 自己命中,或者子孙里有命中,都保留;两者都没中才剪掉 if (hit || children.length) { result.push({ ...node, children, matched: hit }) } } return result } return walk(nodes) }matched字段是给视图层做高亮准备的,命中节点可以加个<mark>或者背景色,祖先节点则不加,用户一眼能分清“这是匹配的”和“这是为了显示层级保留的”。
过滤完还有一步容易被漏掉:展开。过滤前用户可能收起了好几层,过滤后命中的节点藏在收起的父节点里,用户看到的结果是“什么都没变”。所以过滤完成时要顺手把所有“有子节点且子树里含命中项”的节点 id 塞进expandedIds,让结果直接可见。清空关键字时再把展开集合恢复成过滤前的快照,这个快照用一个临时变量存着就行,别直接把展开集合清空——用户手工展开的层级也是状态,一并清掉体验很别扭。
3.4 全选、反选与提交前的半选清理
批量操作按钮摆三个:全选、反选、清空。这三个都建立在同一个前提上——遍历整棵树。全选最简单,把所有未禁用节点的 id 扫进checkedIds,halfIds清空。清空反过来。反选麻烦一点:对每个节点,如果当前在checkedIds里就删掉,不在就加上,然后重新走一遍自底向上的父节点推导。
更值得说的是提交。半选状态的节点绝不能被当成“已选”交给后端。用户勾了一个父节点下的两个子项,父节点显示半选,如果提交时把父节点 id 也带上去,后端就会认为这个用户拥有整个父节点的权限,页面能进去但里面一堆按钮是灰的,用户会来投诉。所以提交前必须做一次清理:halfIds里的 id 一律从checkedIds中剔除。这个清理逻辑放在顶层组件的getCheckedIds()方法里,对外只暴露这个方法,任何要走提交的地方都调它,不要各写各的。
// 提交前过滤:半选节点不算选中,禁用节点也不交 function getCheckedIds() { const result = [] for (const id of checkedIds.value) { if (halfIds.value.has(id)) continue // 半选剔除 const node = findNodeById(treeData.value, id) if (node?.disabled) continue // 禁用项剔除 result.push(id) } return result }4. 跑通与验收:本地启动、Props 契约和一份勾选用例清单
4.1 环境与启动步骤
工程基于 Vite 加 Vue 3,Node 版本建议 16 以上。拉下来之后按常规流程走:
# 1. 安装依赖 npm install # 2. 启动开发服务,默认 5173 端口 npm run dev # 3. 生产构建,产物在 dist 下 npm run build启动后打开首页,左侧就是那棵树,默认展开前两层,右侧面板实时显示当前选中和半选的 id 列表。演示数据是十几条顶层菜单,每条下面挂三到五级不等的子菜单,最深的一条埋到了第七层,专门用来看无限级渲染有没有问题。
如果npm install卡住或者某个依赖版本对不上,先看package.json里的vue版本是不是 3.x,再核一下vite的主版本,这两个对不上会出现启动即报错的情况。构建产物丢到自己项目里时,只需要拷贝src/components/TreeMenu和src/utils/tree.js两块,样式用的是 scoped,不会污染全局。
4.2 Props 与事件契约
接进别人项目前,先把对外接口看清楚。顶层组件的 props 如下:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
data | Array | [] | 扁平节点数组,组件内部自行转树 |
idKey | String | 'id' | 节点唯一标识字段名 |
parentKey | String | 'parentId' | 父节点关联字段名 |
labelKey | String | 'label' | 展示文本字段名 |
defaultChecked | Array | [] | 初始选中的 id 数组 |
defaultExpanded | Array | [] | 初始展开的 id 数组,留空则展开前两层 |
checkStrictly | Boolean | false | 开启后父子不再联动,各选各的 |
disabledIds | Array | [] | 禁止勾选的节点 id |
事件有三个:
| 事件名 | 回调参数 | 触发时机 |
|---|---|---|
change | (checkedIds: Array, halfIds: Array) | 任何一次勾选变化后 |
node-click | (node, event) | 点击节点文本 |
expand | (expandedIds: Array) | 展开或收起节点后 |
checkStrictly这个开关是给“半联动”场景留的:有些系统要求父节点勾选不自动带上子节点,用户得一个个点。开启后applyCheck跳过向下遍历和向上回溯,只做单节点 toggle。这个参数建议在接入早期就确认清楚,因为两种模式下用户的操作习惯完全不同,等页面写完了再改,测试用例得重写一遍。
defaultChecked里只传叶子节点 id 是安全的,但如果传了父节点 id,组件初始化时不会自动补全子孙——这是一个有意的设计取舍:初始化时按“用户明确勾了什么就是什么”处理,避免了“后端数据里有一堆历史脏 id 导致一进页面全亮”的尴尬。如果业务上确实需要“勾父带子”的初始化语义,在App.vue里调一次applyCheck即可,一行的事。
4.3 一份能照着点的验收清单
界面能不能用,不靠感觉,靠一组合法输入的结果比对。下面这组用例每次改完联动逻辑都过一遍,大概三分钟:
- 点击顶层节点的复选框,该节点所有子孙全部变为选中,
change事件返回的checkedIds数量等于该子树节点总数(减去禁用项)。 - 取消某一个叶子节点,它的父节点应立即变为半选,祖父节点同样是半选,
halfIds里能同时查到这两个 id。 - 把某个父节点下所有子节点逐个勾满,父节点自动从半选变成全选,
halfIds里父节点 id 消失。 - 勾选一个节点后收起它的父节点再展开,勾选状态保持不变。
- 输入关键字过滤,命中节点及其祖先链可见,其余节点不可见,且祖先链自动展开。
- 清空关键字,树恢复成过滤前的样子,之前手工收起的层级仍然保持收起。
- 打开
checkStrictly,重复第 1 步,子节点不应被联动,父节点也不应出现半选。
第 3 条和第 6 条是最容易漏测的。前者测的是向上回溯的收敛条件,后者测的是过滤前后状态快照有没有存对。这两条挂了,用户未必立刻反馈,但一旦在真实数据上触发,前端就会出现“看起来是对的,提交上去全是错的”这种难查的问题。
5. 排查与避坑:多选树最常翻车的五个地方
5.1 状态与联动类
现象一:半选的那个横杠怎么都不出现,复选框只有勾和不勾两种。原因通常是indeterminate通过普通 attribute 方式绑定,写成了:indeterminate="isHalf"。它不是一个 HTML 属性,而是一个 DOM property,赋值方式不对时浏览器会静默忽略,控制台也不报错。解决方式是用.prop修饰符显式绑定::indeterminate.prop="isHalf"。这个坑几乎每个人第一次写多选树都会踩,改完以后记得在验收清单第 2 条里确认一下。
现象二:勾了一个子节点,父节点变半选,再勾第二个子节点,父节点却直接变成了未选。看代码大概率是向上回溯时只判断了checkedCount === kids.length和checkedCount === 0两个分支,没有第三个 else 兜底。当子节点处于半选态时它不在checkedIds里,checkedCount少算一个,两个分支都不进,父节点状态就丢了。解决办法是把判定拆成“全选、全不选、其余”三分支,else 里显式执行nextChecked.delete(parent.id)加nextHalf.add(parent.id),别指望默认状态能自动对。
现象三:拿到提交结果发现半选节点的 id 也在里面。这是语义理解偏差:半选节点在视觉上是“部分选中”,但它本身不应该被算作一个被授权的对象。解决方式是在导出方法getCheckedIds()里做一次剔除,遍历checkedIds时凡是在halfIds里出现的一律跳过。别指望调用方自己记得清,把清理收在组件内部,对外只暴露清洗后的结果。
5.2 渲染与数据接入类
现象四:菜单数据一千多条,页面首次渲染白屏两秒,滚动明显掉帧。两个可能的原因。第一个是转树用了单遍遍历加find找父节点,复杂度 O(n²),一千条时循环体执行上百万次。改用 Map 建索引的两遍遍历,耗时能压到个位数毫秒。第二个是v-show控制子节点显隐,导致整棵树全量渲染。换v-if之后,收起状态的子树根本不创建实例,实际 DOM 数量下降一个数量级。两个都改完还有卡顿,再考虑虚拟滚动,但一般后台菜单到不了那个量级。
现象五:接口返回的数据里,某个二级菜单在页面上凭空消失。去查这条记录的parentId是不是指向了一个已被删除或者被禁用的节点。单遍递归或者按父节点分组再拼装的实现,遇到找不到爹的节点通常直接丢弃,而且不报错。解决办法是在listToTree的第二遍循环里加兜底:parent为undefined时把节点挂到根数组,同时在开发环境打一条 warn,把这条脏数据的 id 打出来。有了这条 warn,下次后端数据出问题能直接定位到是哪条记录,不用再靠肉眼比对两边的数据量。
提示:这五个坑里有三个跟“状态放在哪”有关。改联动逻辑之前,先在顶层把三个 Set 打印出来,确认状态本身是对的,再去怀疑渲染层,能省掉一半排查时间。
6. 二次开发:异步加载与只选叶子怎么接进去
实际项目里这套组件最先被改的两个地方,一个是节点太多不能一次拉全,一个是业务只允许选叶子节点。
异步加载的接入点不在转树函数,而在 TreeNode 的展开回调。顶层收到toggle事件时判断:如果这个节点还没有children且没加载过,就去请求它的直接子节点,拿到扁平数组后用listToTree转一层,把parent回指指向当前节点,再塞进node.children。这里有个细节——异步返回的子节点,它们的parentId指向当前节点,但当前节点对象可能是响应式代理,直接赋值时要保证拿到的是同一个引用,否则回指指针会指向一个副本,向上回溯时找不到真正的父节点,半选就传不上去。
// 异步加载子节点,注意 parent 指向响应式对象本身 async function loadChildren(node) { if (node.children?.length || node.loading || node.loaded) return node.loading = true try { const { data } = await fetchMenuChildren(node.id) const children = listToTree(data) // 先转成树 children.forEach((child) => { child.parent = node }) // 再挂回指 node.children = children node.loaded = true expandedIds.value.add(node.id) } finally { node.loading = false } }只选叶子的改法更彻底一点:勾选联动里的“向下全选”整段去掉,只保留单节点 toggle,然后父节点的状态完全由子节点推导出来,而不是在点击时回写。推导函数每次渲染时算一遍,节点数是叶子数量的线性级别,几千个叶子也就是几毫秒,完全够用。
// 只选叶子模式下:父节点状态全部由后代叶子推导 function deriveState(node, checkedIds) { const leaves = collectLeaves(node) // 收集该子树下所有叶子 if (!leaves.length) return checkedIds.has(node.id) ? 'all' : 'none' const hit = leaves.filter((leaf) => checkedIds.has(leaf.id)).length if (hit === 0) return 'none' if (hit === leaves.length) return 'all' return 'half' }这种“状态推导”写法的好处是不会有中间态残留:用户怎么点都行,父节点的显示永远由叶子集合算出来,不存在“父节点被手动设成半选但子节点其实全选”这种自相矛盾的情况。代价是每次勾选要重算受影响路径上的所有祖先,好在路径深度有限,一般不超过十层。
最后提一个改造时验证的小习惯。我一般会在改完联动逻辑后,不急着点页面,先写一个 Node 脚本,手工构造一棵三层的小树,把各种勾选顺序跑一遍,把结果console.log出来跟预期对一遍。纯函数的好处就在这儿——脱离浏览器也能验,比在页面上狂点快得多。等逻辑确认无误了再回浏览器看视觉,两件事分开做,效率高不少。这套流程是从一次线上事故之后固定下来的:当时改了一行向上回溯的判断,页面上看起来都对,提交后发现半选节点的 id 混进了权限列表,用户多了一堆本不该有的菜单入口。从那以后我每次动这类联动代码,都强制先把纯函数用脚本跑一遍断言,再进浏览器点。希望帮到你。
本文还有配套的精品资源,点击获取