
写这篇文章的起因是我自己在做后台管理系统时被“多级筛选”这个需求反复折腾过。Vue 生态里 element-ui 用得很顺手但真要做到“省市区联动、多级分类穿梭、树形结构筛选”这类效果组件库自带的 Select 并不够用直接上 Cascader 又会遇到数据格式、回显、懒加载一堆细节。这篇文章就把我踩过的坑和沉淀下来的方案一次性整理清楚帮你把多级下拉菜单做明白。适合正在用 Vue 2 element-ui 做中后台项目、被筛选器搞到头秃的同学参考对 Vue 3 element-plus 的读者也有迁移价值因为核心的递归思路与数据处理逻辑换一套组件名后同样成立。1. 需求场景与方案选型先想清楚你要做哪种多级下拉拿到“多级下拉菜单筛选框”这个需求不要急着写代码。我见过太多人一上来就抄一个 el-select 嵌套 el-select 的模板结果数据一变就崩。先搞清楚你的场景才能选对方案。1.1 多级选择的三种典型诉求第一种是“纯层级选择”用户要从多级分类里选出一个完整路径。典型场景是商品类目选择先选一级分类“数码产品”再选二级“手机通讯”最后选三级“智能手机”。这种需求的本质是级联层级之间是父子依赖关系选中子级时必须带上所有父级信息。第二种是“树形过滤单选”比如在一个机构树里选某个部门树有三四层但用户可能在任意一层做选择。这种场景比第一种更自由因为不需要强制选到叶子节点。系统后台设置“数据权限”时最常见不同角色可以挂在任意层级节点上。第三种是“多选级联筛选”比如报表系统里要同时筛多个地区的多个月度落地到界面上就是 checkbox 和级联逻辑的组合复杂度一下子高了很多。不要试图用一个组件满足所有需求。把诉求拆分清楚再决定是用 el-cascader 还是 el-select 加 el-tree这是所有后续工作的基石。我在实际项目里吃过亏最开始图省事统一用了 el-cascader结果遇到树形过滤场景不仅要允许选中父节点还要定制展示内容的样式改起来比自研一个还费劲。1.2 四种实现方案的优劣对比方案不能靠感觉选我把常见可行的路径整理成了对照表方便你按项目情况直接对号入座。实现方案优点缺点适用场景el-cascader 级联选择器官方支持、交互成熟、有搜索能力数据格式需符合特定结构回显和懒加载配置有学习成本省市县、级联分类等“必选到叶子”的场景el-select el-tree 自定义下拉交互自由、支持任意层级选中写起来繁琐需自己控制弹层、搜索、回显任意层级可作为结果的树形筛选el-select 嵌套 el-select实现简单、逻辑直观层级固定、无法动态匹配深度多选时几乎不可用层级少且固定如只两级的临时需求自研递归下拉组件灵活性最高、可控性最强开发成本大需考虑键盘导航、无障碍等细节数据交互复杂、需深度定制的场景我个人的经验是80% 的“多级下拉菜单”需求用 el-cascader 就够。它内部封装了下拉面板、路径拆分、搜索高亮比自己造的轮子稳得多。但如果你的树形结构里允许用户在非叶子节点结束选择并且展示文案需要拼接路径那 el-select el-tree 的方案会让你少掉很多头发。至于嵌套 Select层级超过两层就非常难维护建议只在原型验证期用一用生产代码里别这么干。1.3 为什么我最终选择了组件组合而不是纯自研纯自研的方案看上去最“自由”但你在获得自由的同时要承担所有细节成本。最典型的坑是弹层随页面滚动导致定位错乱你得监听滚动态动手动调整浮层位置再比如键盘上下选择时的焦点管理原生 Select 都帮你处理好了自研时全得自己实现。我最终选择的组合方式是业务场景符合级联语义的直接用 el-cascader通过配置项解决 90% 的问题树形语义更重的筛选框用 el-popover 包一层 el-tree配合一个输入框做关键字过滤。这两个方案都站在 element-ui 的肩膀上既保证了稳定性又不至于把组件源码翻个底朝天。2. 核心实现一el-cascader 筛选框从配置到进阶下面进入正题。先说最通用的 el-cascader 方案我会把配置项的核心逻辑、懒加载细节、数据格式要求一次讲透。2.1 基础配置与数据格式el-cascader 绑定的数据结构非常明确就是一个树形数组每个节点必须有value和label字段。子节点挂在children字段下面。这里有一个很多新手踩过的坑后台接口经常返回id和name而不是value和label。要么让后端改字段名要么前端做一次 map 转换。我建议前端做转换因为接口返回的原始结构可能在别的地方还有用别为了一个下拉框污染整个数据流。template el-cascader v-modelselectedPath :optionscategoryOptions :propscascaderProps clearable filterable placeholder请选择商品分类 changehandleCategoryChange / /template script export default { data() { return { selectedPath: [], categoryOptions: [], cascaderProps: { value: id, label: name, children: children, emitPath: true, checkStrictly: true } }; }, created() { // 模拟从接口拿数据 this.categoryOptions [ { id: 1, name: 数码产品, children: [ { id: 11, name: 手机通讯, children: [ { id: 111, name: 智能手机 }, { id: 112, name: 功能机 } ] } ] } ]; }, methods: { handleCategoryChange(value) { // value 是数组如 [1, 11, 111] console.log(value); } } }; /scriptcascaderProps里的emitPath很关键。默认是true意味着v-model里存的值是完整路径数组[1, 11, 111]这个数据送到后端时后端就能明确知道用户选了哪个叶子节点的完整分类链。如果你只关心最后选中的那一级可以设置emitPath: false这样 v-model 里就只剩111。但我不建议随意关闭因为失去路径信息后回显时还要额外的查询接口去还原父级关系得不偿失。checkStrictly这个配置项决定能否选中任意一级节点。默认是false也就是只能选叶子节点如果你允许用户在二级就结束筛选就需要设置成true。在筛选场景中我几乎总会把这个开起来因为用户很多时候并不关心是不是叶子他只想快点找到要筛的类目。2.2 懒加载大数据量场景的必经之路商品类目可能有几千条机构树可能有上万条一次性全部拉下来接口压力大前端渲染也会卡顿。el-cascader 支持懒加载当你展开某一级时才去请求它的子级这是后端接口允许分步查询时的最优解。template el-cascader v-modelselectedPath :propslazyProps clearable changehandleChange / /template script export default { data() { return { selectedPath: [], lazyProps: { lazy: true, lazyLoad(node, resolve) { const { level } node; // 模拟异步请求 setTimeout(() { if (level 0) { resolve([ { id: 1, name: 数码产品, leaf: false }, { id: 2, name: 家用电器, leaf: false } ]); } else { const nodes [ { id: node.data.id * 10 1, name: 子级${node.data.id * 10 1}, leaf: level 1 } ]; resolve(nodes); } }, 300); } } }; } }; /script懒加载有几个细节必须注意。第一是leaf字段它告诉组件当前节点到底还有没有子级不设置这个字段组件会一直显示展开箭头拿去 Request 下一级接口造成无效请求。第二是lazyLoad方法的参数node对象里有level、data等信息resolve是回调函数必须调用它并把子级数组传进去否则下拉面板会一直转圈。还有一个小技巧懒加载模式下没法用filterable去做前端全量搜索因为组件拿不到未加载的数据。要搜索要么在接口层做关键字搜索要么退回去用一次性加载全部数据。我在项目里对几千条数据一般就直接全量加载了配合虚拟滚动插件渲染不成问题比做搜索接口省事。2.3 回显问题编辑场景的难点cascader 的回显相对简单因为v-model存的是完整路径。拿到后端返回的[1, 11, 111]直接赋给 v-model组件会自动展开并高亮对应节点。真正麻烦的是后面讲的 el-select 加 el-tree 的组合方案那种非级联的树形结构回显时需要手动遍历树找节点来拼label文字。回显还有一个容易忽略的点空数据判定。后端返回的筛选条件是 null 或者空数组要给组件一个明确的初始化值不然 v-model 上残留上一次的数据用户会看到搜索条件被错误地带到新页面。我习惯在进入编辑页时统一做一次 reset把所有筛选字段初始化成空数组或undefined再回填实际数据。另外组件的change事件触发时机也有讲究。el-cascader 在 v-model 变化时如果是由外部赋值触发的也可能触发 change 事件导致在编辑页初始化时误触发一次搜索请求。这个需要在业务逻辑里做防抖或者加一个isInit标志位来忽略首次 change。3. 核心实现二el-select 加 el-tree 打造自由树形筛选框有些场景el-cascader 真的撑不起来。比如用户需要在一棵组织架构树里任意节点做单选并且选择框里要展示完整路径“总公司 / 华东分公司 / 杭州分部”但 el-cascader 默认的展示形式会把路径放在多级联动区域改起来极其别扭。这个时候我推荐用 el-select 的外壳套 el-tree 的内容自己控一个筛选框。3.1 组件结构拆解这个组合方案的核心思路很简单用 el-select 的输入框和下拉机制承载外观用 el-tree 替换掉 el-select 原本的下拉列表内容。因为 el-select 只认自己的 option 结构所以不能直接在 el-select 里塞 el-tree我采用的是 el-popover 加一个长得像下框的容器。如果你用的是 element-ui 的 el-select有个取巧的办法是让 el-tree 显示在下拉 popper 里但更干净的方案是自己封装一个组件内部用 el-popover el-input el-tree。template el-popover v-modelvisible triggerclick placementbottom-start width300 popper-classtree-select-popper el-input v-modelfilterText sizesmall placeholder输入关键字过滤 clearable stylemargin-bottom: 8px / el-tree reftreeRef :datatreeData :propsdefaultProps node-keyid highlight-current filter-node-methodfilterNode :current-node-keycurrentKey node-clickhandleNodeClick / template #reference el-input v-modeldisplayText readonly placeholder请选择部门 suffix-iconel-icon-arrow-down / /template /el-popover /template script export default { data() { return { visible: false, filterText: , currentKey: null, displayText: , treeData: [], defaultProps: { children: children, label: name } }; }, watch: { filterText(val) { this.$refs.treeRef.filter(val); } }, methods: { filterNode(value, data) { if (!value) return true; return data.name.includes(value); }, handleNodeClick(data) { // 拼路径显示 this.displayText this.getNodePath(data.id); this.currentKey data.id; this.visible false; this.$emit(change, data.id); }, getNodePath(id) { // 通过 ref 获取当前节点的路径 const node this.$refs.treeRef.getNode(id); if (!node) return ; const path [node.label]; let parent node.parent; while (parent parent.level 0) { path.unshift(parent.label); parent parent.parent; } return path.join( / ); } } }; /script这里有个核心方法getNodePath它利用了 el-tree 内部节点的level和parent属性从当前节点一路往上找父节点再拼接成“总公司 / 华东分公司 / 杭州分部”这样的展示文本。el-tree 的getNode方法返回的 Node 对象带有完整的父子关系这是拼接路径的利器。3.2 搜索过滤与数据定位的联动树形筛选框的搜索逻辑和普通下拉不一样。el-select 的 filterable 只过滤一级列表但 el-tree 的filter-node-method支持自定义过滤方法可以只保留命中的节点并让它的父节点自动展开。需要注意el-tree 的过滤逻辑默认是“命中当前节点时保留该节点以及它的所有祖先节点”这恰好满足我们对分类筛选的需求搜索“手机”展示出“数码产品 手机通讯 智能手机”这条路径。但在大数据量场景过滤后树的展示仍然可能很长。我在实际项目里加了一个交互优化过滤时只展示匹配节点的高亮结果不展示无关的兄弟节点。这个需要自己实现一个过滤后的临时树结构或者在 filterNode 里把判断条件写得足够精确让不匹配的节点直接返回 false。el-tree 对返回 false 的节点会连同其子孙一起隐藏利用这个特性就能实现精准过滤。3.3 多选与回显的扩展思路树形多选筛选框是另一个高频需求。el-tree 自带show-checkbox但必须配合node-key使用否则勾选状态无法正确记录。选中的值默认是所有勾选节点的 key 数组通过getCheckedKeys获取。回显时如果后端返回的是一个扁平数组[1, 11, 111]直接赋给setCheckedKeys即可el-tree 会自动勾选对应节点并展开相关父级。但如果这些 key 并不是同一棵树的叶子节点比如既选了父节点又选了子节点勾选状态会造成视觉上的歧义。我建议在回显前把子节点包含在父节点路径里的数据合并掉保证 setCheckedKeys 传入的节点之间没有父子重叠。// 去掉被父节点覆盖的子节点 function normalizeCheckedKeys(keys, treeData) { const set new Set(keys); const walk (nodes) { nodes.forEach((node) { if (set.has(node.id) node.children node.children.length) { node.children.forEach((child) set.delete(child.id)); } if (node.children) walk(node.children); }); }; walk(treeData); return Array.from(set); }这玩意儿看起来简单但真实项目中后端返回的权限树和前端展示树经常不是同一套结构节点 id 对不上setCheckedKeys 就会静默失败。遇到这种情况只能在前端做一次 id 映射或者用node-key指定的字段来统一关联。我的建议是前后端一定要约定一个通用的树节点标识字段不要各搞一套否则这种联调问题会反复出现。4. 进阶细节数据转换、样式覆盖与交互体验这一章聊聊那些配置文档里不细讲但实战中躲不过去的细节问题。每一项都是我亲手处理过的直接拿出来分享。4.1 数据转换的通用工具函数接口返回的数据五花八门有的是扁平列表有 parentId有的是嵌套树。开发多级下拉之前你最需要的是一套稳定的树数据转换工具。扁平列表转树是最常见的逻辑不复杂但必须注意根节点的判定。export function buildTree(list, rootId) { const map {}; const roots []; list.forEach((item) { map[item.id] { ...item, children: [] }; }); list.forEach((item) { const node map[item.id]; if (item.parentId rootId) { roots.push(node); } else { const parent map[item.parentId]; if (parent) { parent.children.push(node); } else { // 父节点不存在的孤立节点按根处理 roots.push(node); } } }); return roots; }这个函数有个容错逻辑如果parentId对应的父节点不存在就把该节点当作根节点处理。因为在真实的脏数据里多少会有些记录指向已经被逻辑删除的父级直接忽略会造成分类丢失。加这个容错后至少界面上不会白屏。4.2 下拉面板高度与样式覆盖的硬核技巧很多知乎和 CSDN 上都有人问“element-ui select 选择器高度怎么设置”这个问题在普通单级下拉里很容易改 CSS 变量或者覆盖.el-select__wrapper就行。但多级下拉场景真正棘手的是下拉面板popper里树节点的高度、展开箭头的对齐、搜索框和树之间的间距。popper 内容默认渲染在 body 下所以你的 scoped 样式无法直接命中必须用popper-class加上一个自定义类名然后在全局样式里覆盖。这一点是 element-ui 定制面板样式的核心忘掉它你会在样式上白耗一小时。.tree-select-popper .el-tree-node__content { height: 34px; } .tree-select-popper .el-select-dropdown__item { height: auto; line-height: 1.6; padding: 8px 12px; white-space: normal; word-break: break-all; }高度设置的几个值我也说说默认行高是 34px如果你觉得太挤可以调到 38px 或 40px如果树节点文案超长一定要在el-select-dropdown__item上设置white-space: normal否则超长文本会撑破面板。面板最大高度可以设置.el-tree-select-panel__wrapper或直接用max-height: 50vh防止页面过长时下拉超出视口。4.3 搜索体验优化防抖与按下即搜多级下拉里的搜索框我强烈建议加防抖。用户连续输入“周黑鸭”的时候如果每敲一个字就去过滤一次大量树节点主线程会被阻塞页面明显掉帧。一个 200ms 的防抖足矣。watch: { filterText(val) { if (this.timer) clearTimeout(this.timer); this.timer setTimeout(() { this.$refs.treeRef.filter(val); }, 200); } }还有一个很多人忽略的点树形下拉框默认不展开所有节点用户打开面板时只看到第一层。如果某次用户明确知道目标在深层他还要逐级展开体验很割裂。我的解决办法是在过滤条件为空时默认展开前两层节点让用户先看到主干结构有过滤词时则依靠 el-tree 的filter方法自动展开路径。这样折中后大多数用户的操作路径最短。5. 常见问题与排查技巧实录开发多级下拉时的问题来来回回就那几个。我直接整理成速查表再把每个问题背后的原因讲透你排查时可以少走弯路。问题现象根本原因解决方案el-cascader 点击无反应options 数据未更新或 props 字段名不对检查 options 是否为响应式数组props 的 value/label/children 是否匹配下拉面板样式错乱popper 渲染在 bodyscoped 样式不生效使用 popper-class 和全局样式覆盖树形下拉回显空白current-key 与 node-key 的字段不一致确认 node-key 指定的字段与传入的 key 值类型一致el-tree 无法过滤未在 watch 中调用 filterNode 方法加上 filterText 的 watch并调用 this.$refs.treeRef.filter(val)懒加载不触发请求leaf 字段始终为 false 或没有该字段叶子节点设置 leaf: true并确保子节点为空的节点标记为叶子级联数据无限循环children 字段指向自身或数据结构成环在数据转换时做层级深度限制或使用 Set 防止循环引用5.1 el-cascader 选中后下拉面板不关闭el-cascader 默认在选中叶子节点后自动关闭但当你开了checkStrictly: true允许选择任意级后选中非叶子节点时面板不会自动关闭。这其实是设计使然因为组件认为你可能还要继续选下级。但筛选场景里用户选完就想关闭。解决办法是监听 change 事件如果在非懒加载模式下选中后手动把下拉面板收起。但这个隐藏很 tricky因为组件没有直接的关闭方法。我有两个思路一是用ref拿到级联面板实例但组件没有暴露 public 方法二是用一个透明遮罩盖住页面点击时把 v-model 控制的外层 popover 关掉。更优雅的是一个取巧方案在 handleChange 里判断如果选中的节点没有 children再让面板关闭但 element-ui 默认没暴露此类接口所以只能用遮罩这种间接方式。如果你能接受自定义我建议这种“允许选任意级”的场景直接采用前面说的 el-popover el-tree 组合关闭逻辑完全由自己控制反而更顺手。5.2 处理级联数据中的同名节点与值冲突真实数据里两个不同分支下的节点可能有相同的 label比如“市场部”在一级和二级都出现了。如果只用 name 做筛选展示用户很容易看混。el-cascader 的搜索框是根据 options 里的 label 来判断的所以同名节点会同时出现在搜索结果里但它们的路径展示能区分。这还好。麻烦的是 id 冲突。如果两个后台系统各自生成 id拼接到一棵组合树里时可能出现重复 id导致 v-model 里存的路径无法唯一指向某个节点。遇到这种情况我通常在前端对 id 做一层包装比如category_123、dept_456再传入组件。虽然会被后端嫌弃但能保证前端展示和回显的唯一性值得做。5.3 大数据量下的性能优化方案我处理过的数据量最大的一棵分类树节点数接近 1.5 万。加搜索过滤其实还好但首次全量渲染时el-tree 的递归渲染明显卡顿下拉面板打开要等 1 秒左右。第一个优化是延迟渲染。面板初次打开时不渲染树只显示 loading等数据从接口返回后再一次性赋值渲染。第二是给 el-tree 设置默认展开层级为 0不要全部展开这样初始渲染的 DOM 数量会大幅减少。第三是配合懒加载使用把数据切成按级请求的粒度。如果你的数据量比 1.5 万还大比如几十万节点那就要换方案了老老实实用虚拟滚动或者切片渲染。element-ui 官方不直接提供树形虚拟滚动只能结合第三方库或手动实现成本和收益要自己权衡。我的经验是绝大多数中后台项目的数据量还到不了那个级别不用为极端情况过度设计。写在最后的一点建议多级下拉菜单做下来最大的感悟是组件库“能用”和“好用”之间隔着一大堆数据处理和边界情况。el-cascader 适合级联语义el-select 加 el-tree 适合树形自由筛选选型对了后面所有工作都顺选型错了后面补丁打到怀疑人生。还有一个小技巧想分享在开发这类组合组件时一定要先确定数据结构再写界面。树组件的 value 和 label 字段、node-key 字段、回显格式这些在前端和后端之间要一次性对齐否则联调里反复返工的时间足够你把整个组件重写一遍了。好的数据结构能让组件代码简洁得不像话。我自己后期做的项目所有多级筛选数据统一走一个 normalize 函数转换成标准树格式所有组件只管消费同一结构代码复用率高了很多。这个思路希望你在下个项目里也能用上。