
做后台管理系统的人大概率都撞过同一种需求选择部门、选择产品分类、选择区域节点……数据天然是一棵无限的树交互却只想给一个下拉框。Element UI 的 el-select 很好用但它只认识扁平的 option 列表el-tree 展示层级和勾选都挺顺手却又给不了输入框和标签。于是“el-select el-tree 实现下拉树形多选”就成了前端社区里隔三差五被捞起来问一遍的题目B端岗位面试也常拿它当手写题。这篇文章不聊玄学把我项目里稳定跑了多个版本的下拉树组件拆开讲清楚骨架怎么搭、数据怎么同步、搜索怎么联动、踩过哪些坑。方案基于 Vue 2 Element UI 2.15.x如果你用的是 Element Plus思路一致但插槽行为和弹层细节会有差异需要对照调整。1. 先拆需求下拉树形多选不是把两个组件拼起来那么简单1.1 Element UI 原生组件为什么覆盖不了这类场景先厘清一个容易被忽视的事实el-select 的核心数据模型是“一维数组”它的选项是el-option平铺出来的没有父子层级概念。你可以用“中国 / 广东省 / 深圳市”这样的字符串拼接标签来模拟层级但那只是“看起来像树”搜索、回显、选中父级子级一起联动这些需求一上来就会穿帮。el-tree 倒是天然支持层级、勾选、半选、懒加载但它不是一个“输入型组件”它没有输入框也没有 tag 展示区。用户不可能在表格筛选栏里直接放一棵树交互太重了。于是很多团队会去找现成的 tree-select 第三方库但引入外部依赖意味着要统一视觉风格、处理版本冲突、跟着上游升级对一个已经深度绑定 Element UI 的中后台项目来说并不划算。自己用 el-select el-tree 封装一个反而可控性最高。还有一个选项是 el-cascader它确实能展示层级但在多选、搜索、自定义 tag、只提交叶子节点这些组合需求下配置会变得很别扭。Cascader 的搜索是对路径文本的匹配用户想按节点名直接搜出深层某个部门时体验不如自己写的过滤逻辑灵活。所以“el-select 做外壳、el-tree 做面板”这条路看起来是绕路实际上是最贴合业务定制需求的。1.2 我落地的目标交互清单动手写代码之前必须先列清楚交互预期否则写出来大概率是个“能看不能用”的半成品。我最终确定的需求清单是这样的点击输入框弹出树形面板面板内节点带 checkbox支持多选。已选节点以 el-select 的 tag 形式展示每个 tag 可以单独删除。输入关键词可以过滤树节点过滤时保留匹配节点的祖先链路并自动展开。每次重新打开面板树的勾选状态和 tag 状态必须一致。父节点和子节点的关系要能配置有些业务只允许选叶子节点有些业务任意层级都能选。清空按钮、删除 tag 后树的勾选状态也要同步变化。这些点看起来是常识但网上的 demo 十有八九只覆盖了前两条。真正拿去生产第五、第六点才是翻车重灾区。2. 组件骨架el-select 和 el-tree 怎么在互不干扰的前提下协作2.1 不推荐的“硬塞”写法直接把 el-tree 塞进 select 默认插槽很多教程会给你这样一种写法el-select v-modelselectedIds multiple el-option v-foritem in flatOptions :keyitem.id :valueitem.id :labelitem.label / el-tree :datatreeData show-checkbox node-keyid checkhandleCheck / /el-select先说结论在 Element UI 2.15.x 上这段代码确实能跑但它是“能用”和“健壮”之间的灰色地带。el-select渲染默认插槽时会把它内部不认识的非el-option组件原样渲染进下拉面板容器所以树会出现在下拉列表里。问题是Element UI 官方从未承诺这种用法版本升级后插槽结构一旦调整组件可能直接渲染异常。下拉面板里的滚动条、内边距、hover 高亮这些样式都是为 option 设计的塞进一棵树以后需要额外写不少 CSS 去覆盖。如果flatOptions很长隐藏 option 的方式没做好用户会看到树底下漏出一截“幽灵选项”。所以我在项目里的做法是仍然借助 select 的默认插槽但里面只放隐藏的el-option用来支撑 tag 的 label 映射el-tree放在 option 列表之后。代码形态和上面差不多但通过popper-class精准控制样式不污染全局。2.2 我的可靠结构隐藏 option 提供 tag 映射el-tree 接管面板内容组件的模板结构如下template div classtree-select el-select refselectRef v-modelselectedIds multiple filterable clearable :placeholderplaceholder :filter-methodhandleFilterMethod popper-classtree-select-popper visible-changehandleVisibleChange remove-taghandleRemoveTag clearhandleClear el-option v-foritem in flatOptions :keyitem.id :valueitem.id :labelitem.label classtree-select-option / el-tree reftreeRef classtree-select__tree :datatreeData :propstreeProps node-keyid show-checkbox :default-expanded-keysexpandedKeys :filter-node-methodfilterNode :check-strictlycheckStrictly :expand-on-click-nodefalse checkhandleCheck / /el-select /div /template对应的关键样式.tree-select-popper .el-select-dropdown__item.tree-select-option { display: none; } .tree-select-popper .el-scrollbar__view { padding: 8px; } .tree-select-popper .el-select-dropdown__wrap { max-height: 280px; } .tree-select__tree { min-height: 60px; max-height: 260px; overflow-y: auto; }这里要解释两个关键点。第一为什么不能把所有.el-select-dropdown__item都隐藏 因为如果全隐藏Element UI 在下拉面板里计算空状态、定位滚动条时会出现视觉异常而且万一以后想在树下面加“全选/清空”这种自定义 footer会被一刀切掉。我只给树里面的隐藏 option 加上tree-select-option这个类精准隐藏。第二为什么flatOptions必须有 因为 el-select 的 multiple 模式渲染 tag 时需要根据 value 找到对应的 option 的 label。它不像 el-tree 能直接从节点数据里拿名字。如果只往 selectedIds 里塞 id不给 select 提供任何 optiontag 区域会显示成裸 id。所以flatOptions承担的是“id 到 label 的映射表”它的数据来自树节点扁平化。2.3 为什么这样协作不会出现空白选项遮挡树你可能担心option 隐藏了但它还占着 DOM会不会在面板里留下一排空白实测下来不会。.el-select-dropdown__item设置了display: none之后不会占用布局空间。面板的可视区域就是 padding 8px 加上树的高度。需要注意的是滚动容器Element UI 的下拉面板默认是.el-select-dropdown__wrap滚动的树自己又有一层滚动区两层滚动条叠加会很丑。所以我给树的容器限了max-height和overflow-y: auto同时把 select 面板的 wrap 也限高这样交互更接近原生下拉。3. 数据流同步勾选、tag、回显三者的核心逻辑3.1 树勾选状态如何变成 select 的选中值先看树节点扁平化的方法computed: { flatOptions() { const result []; const walk (nodes, parents []) { nodes.forEach((node) { const label [...parents, node.label].join( / ); result.push({ id: node.id, label, }); if (node.children node.children.length) { walk(node.children, [...parents, node.label]); } }); }; walk(this.treeData); return result; }, }这里的 label 我用“父级路径 / 当前名称”拼接目的是让 tag 悬浮时能看出节点的完整路径。如果你觉得太长也可以只用node.label。树的勾选事件处理是核心handleCheck(data, checkInfo) { let checkedIds checkInfo.checkedNodes.map((node) node.id); // 如果业务上只允许提交叶子节点把父节点从结果里过滤掉 if (this.onlyLeaf) { checkedIds checkedIds.filter((id) { const node this.findNode(id); return node !node.children; }); } this.selectedIds checkedIds; this.syncTreeChecked(checkedIds); this.$emit(check, data, checkInfo); }findNode就是从 treeData 里递归查找节点的辅助方法findNode(id, nodes this.treeData) { for (const node of nodes) { if (node.id id) return node; if (node.children node.children.length) { const found this.findNode(id, node.children); if (found) return found; } } return null; }选完之后el-select 的 tag 会自动根据selectedIds和flatOptions的映射渲染出来。这一步很顺但因为 el-select 的v-model是数组引用我们在组件里用 computed 的 getter/setter 做一层中转方便外部用v-model绑定selectedIds: { get() { return this.value; }, set(val) { this.$emit(input, val); }, }3.2 删除 tag 和清空如何反向同步树的勾选状态el-select 在 multiple 模式下删除 tag 时会自己把对应 id 从数组中移除但它不知道树的存在。如果你不处理就会出现这种情况界面上 tag 删掉了再次打开下拉面板树的 checkbox 还勾着看起来非常弱智。所以必须监听remove-tag和clearhandleRemoveTag() { this.syncTreeChecked(this.selectedIds); }, handleClear() { this.syncTreeChecked([]); }, syncTreeChecked(checkedIds) { const sorted [...checkedIds].sort().join(,); const last [...this.lastSyncKeys].sort().join(,); if (sorted last) return; this.lastSyncKeys [...checkedIds]; this.$nextTick(() { const tree this.$refs.treeRef; if (tree) { tree.setCheckedKeys(checkedIds); } }); }lastSyncKeys是一个我在 data 里维护的上次同步快照用来防止重复调用setCheckedKeys。为什么要防因为父子联动的树在勾选父节点时Element UI 内部会一次性改变很多节点的 checked 状态check 事件可能连续触发如果每次触发都无条件setCheckedKeys极端情况下会进入死循环或产生性能毛刺。3.3 编辑回显的陷阱setCheckedKeys 必须在树渲染完成之后调用回显是下拉树组件最容易翻车的地方。很多人直接把外部传入的 value 塞给default-checked-keys然后发现第一次打开面板树是空的。原因很简单el-tree的default-checked-keys只在组件初始化时生效一次。如果数据是异步加载的或者树在面板隐藏时还没有渲染这个默认值就错过了。我的处理方式是在visible-change时主动同步handleVisibleChange(visible) { if (visible) { // 每次打开前把上次输入的过滤词清掉 if (this.$refs.selectRef) { this.$refs.selectRef.query ; } this.$nextTick(() { const tree this.$refs.treeRef; if (!tree) return; tree.filter(); tree.setCheckedKeys([...this.selectedIds]); }); } }这里有两个细节this.$refs.selectRef.query 是直接改 el-select 的内部 query 字段实测在 2.15.x 可用。如果你不想依赖内部属性可以在外层维护一个真实的 input 做搜索输入但那样会改变整个交互结构不推荐。tree.setCheckedKeys([...this.selectedIds])前先tree.filter()是为了清掉上一次搜索可能残留的过滤状态否则回显的时候部分节点处于隐藏状态勾选视觉效果不完整。3.4 父子联动策略回显时为什么不能直接传父节点 id当checkStrictly为 false 时el-tree 是父子联动勾选的勾选父节点会自动勾选全部子节点。此时如果你在回显时把父节点 id 和子节点 id 一起传给setCheckedKeys会导致子节点被重复联动面板上出现半选状态混乱。所以我的建议是如果业务只需要叶子节点就在回显前把父节点 id 过滤掉只传叶子 id。树会自动把父节点变成半选状态展示上完全正确。如果业务要求任意层级都能被选中并参与提交那就把checkStrictly设为 true让每个节点独立勾选此时 selectedIds 是什么就保存什么回显也原样传回去不会有多余联动。这块没有统一答案但必须在一开始就定下来。我见过太多项目上线以后才发现权限节点存了父级 id导致下级部门全部继承权限排查半天结果是回显逻辑的问题。4. 搜索过滤与祖先节点展开的实现细节4.1 重写 filter-method把过滤目标从 option 换成树el-select 的filterable默认会根据 option 的 label 过滤下拉项。但我们的下拉项都被隐藏了真正要过滤的是 el-tree。所以需要给 el-select 绑定自定义:filter-methodhandleFilterMethod(query) { this.keyword query; if (this.$refs.treeRef) { this.$refs.treeRef.filter(query); if (query) { this.expandMatchedParents(query); } else { this.expandedKeys []; } } }filter-method的作用是接管 el-select 原生的过滤行为。在这里我只调用树的filter不修改任何 option所以 tag 区域不会因为搜索而丢失已选状态。4.2 让匹配节点的祖先节点保留并展开el-tree 的filter-node-method官方逻辑非常简单返回 true 的节点保留返回 false 的节点隐藏。如果只判断“当前节点名是否包含关键词”那深层节点命中后它的父节点因为不命中会被隐藏用户什么都看不见。所以filter-node-method必须递归判断“当前节点自己的名称是否匹配或者任意后代节点是否匹配”filterNode(query, data) { if (!query) return true; const lower String(query).toLowerCase(); if (String(data.label).toLowerCase().includes(lower)) return true; const children data.children || []; return children.some((child) this.filterNode(query, child)); }这样匹配节点的祖先会继续显示在面板里。但“显示”不等于“展开”元素还在折叠状态用户还是看不到深层命中的节点。所以要单独实现expandMatchedParentsexpandMatchedParents(query) { const tree this.$refs.treeRef; if (!tree || !tree.store) return; const nodesMap tree.store.nodesMap || {}; const expanded []; Object.keys(nodesMap).forEach((key) { const node nodesMap[key]; if (!node || !node.data) return; if (this.nodeMatchesQuery(node.data, query)) { let parent node.parent; while (parent parent.data parent.data.id ! undefined) { expanded.push(parent.data.id); parent parent.parent; } } }); this.expandedKeys Array.from(new Set(expanded)); }nodeMatchesQuery和filterNode的判断逻辑保持一致nodeMatchesQuery(data, query) { const lower String(query).toLowerCase(); if (String(data.label).toLowerCase().includes(lower)) return true; const children data.children || []; return children.some((child) this.nodeMatchesQuery(child, query)); }这里是通过 el-tree 内部 store 的nodesMap拿到所有节点实例再逐个判断。需要注意nodesMap是 Element UI 内部结构不是公开 API升级版本时建议加个空判断并回归测试。4.3 搜索状态下勾选和删除 tag 不能互相打断一个容易被忽略的交互用户输入关键词过滤树然后勾选了一个匹配节点此时 tag 新增了这时如果再点 tag 上的 × 删除remove-tag触发el-tree 的setCheckedKeys会把树恢复成 selectedIds 状态但搜索关键词还在过滤状态还在视觉上没问题。真正的隐患是如果remove-tag里调用setCheckedKeys时某些节点因为过滤暂时不可见Element UI 仍然会修改其勾选状态等用户清空搜索词后节点状态其实是对的。这一点实测没问题不用担心。但如果你的树用了懒加载搜索时还没加载出来的节点不在 store 里setCheckedKeys就可能漏掉。懒加载场景下建议对load回调里做一次补设或者干脆在懒加载模式下关闭搜索功能两害相权取其轻。5. 实测踩坑最容易翻车的五个现场5.1 点击树节点时下拉面板直接收起这是我最早踩到的坑。现象是el-select 弹出下拉面板点击树里的 checkbox 时面板闪一下就关了根本来不及勾选。原因在于 el-select 的下拉层默认挂载到 body 上它通过监听全局 click 判断点击是否落在 select 组件内部。树面板虽然视觉上是“下拉的一部分”但在 DOM 层级上不属于 select 根节点所以 clickoutside 判定点击了外部直接关闭面板。解决方式很简单在 el-tree 的外层包一层 div加上mousedown阻止冒泡div mousedown.native.stop el-tree ... / /div注意是mousedown不是click。click 发生时 Element UI 的全局监听已经跑完了阻止无效。5.2 回显时选中值莫名其妙多出父节点如果你用的是父子联动并且setCheckedKeys传入的数组里既包含父节点 id又包含子节点 id那么 el-tree 内部会先勾上父节点自动勾选全部子节点再按数组中已有的子节点 id 去匹配结果是父节点和子节点全部变成 checked而不是理想中的“父半选、子全选”。我的规避方式是回显前统一过滤。selectedIds里如果存的是叶子节点回显时就只传叶子节点让树自己算出父节点的半选状态。如果业务上允许父节点参与提交那就开启checkStrictly彻底关掉联动两个模式不要混用。5.3 清空按钮删了 tag树的勾选状态还在el-select 的clearable清空时只会把自身的 value 清成空数组不会触发check事件。所以必须监听clear手动把树的勾选清掉handleClear() { this.$nextTick(() { if (this.$refs.treeRef) { this.$refs.treeRef.setCheckedKeys([]); } }); }remove-tag同理。这块逻辑虽然简单但漏写的概率非常高因为本地小 demo 往往不会反复开关面板验证。5.4 搜索过滤后再次打开面板输入框残留旧关键词Element UI 的 el-select 在 multiple filterable 模式下关闭面板后通常会自己清空 query但当你自定义了filter-method以后这个行为在不同版本里表现不稳定。我在 2.15.7 上遇到过残留2.15.12 上又正常。稳妥的做法是在visible-change变成 true 时主动清一下前面已经写过了if (this.$refs.selectRef) { this.$refs.selectRef.query ; }这段代码虽然碰了内部属性但副作用极小。如果你实在不想依赖内部属性可以考虑不给 el-select 绑定 filterable而是自己在外部包一层搜索框不过这会让组件形态更接近 Popover和标题里“el-select 实现”的诉求就偏离了。5.5 树节点太高把下拉面板撑出屏幕树默认没有高度限制如果数据层级深、兄弟节点多面板高度可能超过视口。Element UI 的 popper 会根据placement自动翻转但翻转后树顶部被截断体验很糟。我给树容器设置了max-height: 260px; overflow-y: auto同时把.el-select-dropdown__wrap也限高 280px。两个滚动区配合视觉上更接近普通下拉不会出现整棵大树怼到底部的情况。6. 二次封装从“能用的 demo”升级为“可维护的公共组件”6.1 props 与事件设计写完一个能跑的版本后我建议直接抽成公共组件。参考配置如下参数类型默认值说明valueArray[]v-model 绑定的选中节点 id 数组treeDataArray[]树数据源placeholderString请选择输入框占位文案checkStrictlyBooleanfalse是否父子不关联onlyLeafBooleanfalse只允许叶子节点作为选中值nodeKeyStringid节点唯一标识字段labelKeyStringlabel节点显示字段childrenKeyStringchildren子节点字段事件方面对外暴露check、change、remove-tag这三个最关键的事件。组件内部方法建议通过ref暴露两个能力// 外部主动设置勾选状态 setChecked(keys) { this.syncTreeChecked(keys); }, // 清空所有选择 clearAll() { this.selectedIds []; this.syncTreeChecked([]); }6.2 半选状态的语义必须和产品对齐有一类特殊需求不仅保存选中的节点还要保存半选节点。比如配置菜单权限时用户勾了两个子节点产品希望把父节点也存成半选方便下次回显时保持同样的视觉状态。这种情况不要把半选节点塞进selectedIds否则 tag 区域会出现父节点和子节点同时有 tag 的情况。我通常的做法是selectedIds只存业务提交值半选节点单独通过half-check事件抛给父组件由父组件决定持久化策略。回显时父组件拿业务值setCheckedKeys树会根据父子联动自动还原半选状态不需要额外处理。6.3 大数据量下的性能建议el-tree 默认不是虚拟滚动几千个节点全部展开时会有肉眼可见的卡顿。如果数据量超过 1000建议优先使用懒加载模式。懒加载和搜索一起用会比较麻烦因为未加载的节点根本参与不了过滤。我的取舍是大数据量下关闭搜索或者搜索时服务端过滤前端只展示过滤后的顶层数据都不适合的时候再考虑引入虚拟滚动树。另外flatOptions这个 computed 在每次 selectedIds 变化时都会被重新计算。如果树节点很多建议用一个带缓存的辅助函数或者 watchtreeData变化时单独维护一份扁平映射避免无意义的遍历。最后再分享一个细节这套组件在项目里稳定跑了很长时间真正让我觉得“当初这个设计是对的”的时刻是产品后来提了一个新需求把下拉树从“多选任意节点”改成“只能选最末级子节点但父节点要能半选展示”。我只改了一行onlyLeaf的传参就实现了树的联动逻辑完全不用动。这就是把数据流拆清楚的回报。如果你也在封装类似组件建议不要只抄模板代码把你业务里的父子联动语义先确定下来再动手写——这比任何技巧都重要。