ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

多级下拉菜单选型与实战:el-select联动、el-cascader与树形下拉对比解析

多级下拉菜单选型与实战:el-select联动、el-cascader与树形下拉对比解析 做后台管理系统这些年我几乎每个项目都会遇到“多级筛选框”这种需求商品分类要选到第三级、组织架构要选到某个部门、地区要选到区县、学科要选到某个知识点节点。大多数朋友拿到这种需求的第一反应都是“这不就是几个下拉框联动嘛”然后立刻拖出三四个 el-select一个 change 事件接一个 change 事件地写。等代码量上来或者第二个页面要复用或者后端把字段名从 id 改成 code 的时候才意识到这套东西远没有想象中那么简单。多级下拉菜单在 element-ui 生态里其实有三个完全不同的解法一是多个 el-select 串联动适合选项之间是“父查子、子查孙”的接口关系二是 el-cascader 级联选择器适合数据本身是一棵树、又希望一个控件搞定选择与回显的场景三是树形下拉选择适合层级深、还要支持搜索和部分选中的筛选框。三个方案各有边界不存在“一个方案走天下”的说法。这篇我来把三种方案的适配场景、核心代码和我在落地过程中踩过的坑完整过一遍给后面做同类需求的朋友一个可以直接参考的路线。1. 先搞清楚需求多级筛选的三种形态和选型依据说实话大部分实现翻车都不是因为代码难而是因为第一版就把形态选错了。多级筛选看起来都是“带层级的筛选”但产品要的交互细节不同技术方案完全不一样。我习惯接到需求后先问三个问题数据是一次性拿全还是按层级异步加载用户是“必须选到最末级”还是“停在任意层级都可以”编辑页回显时后端返回的是完整路径还是只有一个叶子 id问题答案不一样方案选型就跟着变。1.1 多级下拉菜单的三种典型形态第一种是多 select 联动。比如省、市、区三个下拉框并排选了省再加载市选了市再加载区。这种形态的优势是交互直观、每一级都能看到明确的字段名称适合层级固定、最多三四级的表单缺点是一旦层级变深、变化规则复杂代码里要维护的“重置逻辑”会明显变多。第二种是el-cascader 级联选择器。整个树在一个控件里展开和选择视觉上更紧凑选中后输入框里展示完整层级路径。它天然适合“数据是一棵树”的场景比如商品分类、知识图谱、组织架构。el-cascader 在 element-ui 和 element-plus 里都是官方组件稳定性最好。第三种是树形下拉。一个下拉面板里展示一棵树支持点击节点选择、勾选多选、关键字过滤。这种方案最接近“筛选框”的直觉我从树上选一个节点选了之后回填。适合树比较深、节点多、还需要搜索定位的场景缺点是 element-ui 没有官方组件需要自己封装或者在 element-plus 下直接用 el-tree-select。1.2 选型前先回答的三个问题第一个问题数据量级。几百条全量数据三种方案随便选上万条节点多 select 联动和树形下拉必须做懒加载级联选择器也建议用 lazy 模式。第二个问题选择约束。如果业务上“选到省级也能提交只有需要精确统计时才强制到区县”那 el-cascader 要开 checkStrictly树形下拉也要允许任意节点选中多 select 联动则每级都要允许为空。第三个问题回显方式。编辑页打开时后端最常见的做法是给你一个最末级的 id。多 select 联动需要逐个接口反查父级el-cascader 需要拿到完整路径数组树形下拉只需要叶子节点 id 就能高亮。这里往往能直接决定方案选型。方案数据加载方式搜索支持编辑回显适合场景多 select 联动按级异步加载弱只能单级搜索需要多级回填后逐级反查省市区、简单的三级分类表单el-cascader全量或懒加载内置 filterable需完整路径数组树形数据的单选控件树形下拉全量或懒加载强支持关键字过滤叶子 id 基本够用组织架构、目录、复杂树筛选树形下拉多选全量或懒加载强数组 id 即可标签、权限等多选树这张表每次我评审代码都会带着看一遍。多数项目其实是“表单 筛选区”并存所以我下面会把三种方案分别展开重点讲落地细节。2. 级联选择器 el-cascader数据结构、懒加载与回显el-cascader 是 element-ui 里最省心的级联组件但省心不等于无脑。我在项目里见过最多的问题集中在三个地方数据结构不匹配、懒加载叶子节点判断错误、编辑回显时路径对不上。2.1 数据结构和字段映射el-cascader 默认的数据结构是{ value, label, children }。后端接口返回的字段很少恰好叫这三个名字所以绝大多数情况下第一步是用 props 映射而不是强行改后端。比如后端字段叫id、name、childList写成这样cascaderProps: { value: id, label: name, children: childList, checkStrictly: false, emitPath: true }这里有两个容易踩的点。第一children 字段如果没有子节点后端经常给的是空数组[]。空数组本身没问题但如果后端给的是null或者干脆不返回el-cascader 在展开时就会报Cannot read properties of null。我的做法是在接口返回后统一清洗一层function normalizeTree(list) { return (list || []).map(item ({ ...item, childList: item.childList item.childList.length ? normalizeTree(item.childList) : undefined })) }把空 children 统一清掉让组件认定它是叶子节点。第二emitPath默认是 true意思是 v-model 绑定的是“从根到叶的完整路径数组”如果业务只需要叶子 id把它设成 false 会让回显简单很多但代价是提交到后端后没法直接看出完整链路。这里我倾向于根据接口设计来定不要盲目关掉。2.2 懒加载模式省流量但别把叶子节点判断写错树特别大时全量把 data 塞给 el-cascader 会让首屏渲染卡顿应该用 lazy 模式按需加载。核心逻辑是配置 lazyLoad组件在展开节点时自动调用cascaderProps: { lazy: true, lazyLoad(node, resolve) { const parentId node.level 0 ? 0 : node.value getCategoryList({ parentId }).then(({ data }) { resolve(data.map(item ({ ...item, leaf: !item.hasChildren }))) }) } }这段代码的坑在leaf字段。如果接口返回的hasChildren是给前端判断用的那你需要显式映射成leaf: !item.hasChildren如果没有这个字段默认所有节点都会被认为还有子节点结果就是“永远展开不到叶子点了也选不中”。我见过有人排查了一下午最后发现是不带 leaf 导致的问题。另外lazyLoad里node.level 0是第一层第一层的父节点 id 按后端约定传 0 还是空字符串要和接口文档对齐。懒加载模式下还有一个小优化把已经加载过的节点结果缓存起来。否则用户展开某个节点、收起、再展开时会重新请求一次接口在高频操作场景下既费流量又显得页面卡。缓存方案也不复杂用一个 Map 存下来data() { return { childrenCache: new Map() } }, methods: { async handleLazyLoad(node, resolve) { const parentId node.level 0 ? 0 : node.value if (this.childrenCache.has(parentId)) { resolve(this.childrenCache.get(parentId)) return } const { data } await getCategoryList({ parentId }) const normalized data.map(item ({ ...item, leaf: !item.hasChildren })) this.childrenCache.set(parentId, normalized) resolve(normalized) } }这样同一层级只在第一次展开时请求。2.3 编辑页回显最麻烦的场景el-cascader 的 v-model 在 emitPath 为 true 时绑定的是完整路径数组比如[1001, 100101, 10010101]。但后端详情接口通常只返回最末级10010101编辑页打开时组件根本不知道前面的路径自然无法回显。全量加载的数据可以把这棵树的父级路径找出来getPathById(tree, targetId, path []) { for (const node of tree) { const nextPath [...path, node.id] if (node.id targetId) return nextPath if (node.childList) { const result this.getPathById(node.childList, targetId, nextPath) if (result) return result } } return null }在拿到详情数据后调用一次赋值给 cascader 的 v-model 即可。懒加载场景下这个函数没法直接工作因为父级 children 还没加载。我的经验是两个方向一是让后端在详情接口里多返回一个parentIds或pathIds字段直接用它赋值二是在组件 mounted 后逐级调用查询接口从根开始逐层找到目标 id 所在分支再把这条链路收集起来。后者需要串行请求用户体验和代码复杂度都差一些所以我更推荐第一种让后端把路径一起返回来。3. 轻量表单场景el-select 三级联动的实现思路不是所有“多级筛选”都要用一棵树来表现。很多表单页就是省、市、区三个下拉框产品希望字段能对应到数据库的独立列比如province、city、district三个字段分开提交。这时候再用 el-cascader 反而要额外从路径数组里拆分多 select 联动是更自然的方案。3.1 模板与状态设计模板层面其实就是三个 el-select后一级的启用与否取决于前一级是否已经选值el-form-item label地区 el-select v-modelform.province placeholder请选择省份 clearable changehandleProvinceChange el-option v-foritem in provinceList :keyitem.value :labelitem.label :valueitem.value / /el-select el-select v-modelform.city placeholder请选择城市 clearable :disabled!form.province changehandleCityChange el-option v-foritem in cityList :keyitem.value :labelitem.label :valueitem.value / /el-select el-select v-modelform.district placeholder请选择区县 clearable :disabled!form.city el-option v-foritem in districtList :keyitem.value :labelitem.label :valueitem.value / /el-select /el-form-item这里要注意第一级和第二级都绑定了 change 事件但第三级没有。为什么要单独处理每一级因为选区的联动规则是“上游变化要把下游的选项和值全部重置”。如果我单独把第三级也交给 change 事件去重置第二级就会出现一连串的多余赋值和重复请求。3.2 为什么我建议用 computed 而不是 watch 来驱动选项很多人写联动喜欢用 watch 监听form.province然后手动给cityList赋值。代码看起来也能跑但存在两个问题一是 watch 回调里要同时处理“清空 form.city、更新 cityList、清空 form.district、更新 districtList”状态更新散落在多个地方逻辑一多就开始乱二是万一赋值顺序不对界面会出现一瞬间的旧数据。更好的方式是让“下一级可选项”完全成为“上一级值”的派生状态用 computed 搞定computed: { cityList() { const province this.provinceList.find(item item.value this.form.province) return province ? province.children || [] : [] }, districtList() { const province this.provinceList.find(item item.value this.form.province) const city province?.children?.find(item item.value this.form.city) return city ? city.children || [] : [] } }这样 option 列表是自动跟随上游值变化的不需要手动维护任何“在 change 里塞数据”的逻辑。模板里仍然保留 handleProvinceChange 和 handleCityChange这两个方法只做一件事清空下游的已选值。handleProvinceChange() { this.form.city this.form.district }, handleCityChange() { this.form.district }可能有人会问为什么不用 watch因为 watch 是“值变了以后额外做事情”而 computed 是“值直接由其他状态推导出来”。能推导的状态就不要用命令式赋值去维护这是 Vue 里一条很重要的原则。computed 让数据流变成单向的上游值 → 下游选项 → 下游值思路清晰得多。3.3 边界情况清空、切分支和字段残留清了第一级第二、第三级必须跟着清这个逻辑容易漏在“编辑回显”和“切分支”上。编辑时后端一次性返回 province、city、district 三个字段v-model 直接赋值没问题但如果是“先填了三级又回去把省改了”不处理的话 form.city 和 form.district 还是旧值提交时就会出现“省是广东市却是杭州”这种脏数据。所以只要上游变化下游值必须重置这个没得商量。还有一个更容易忽略的点接口返回的 value 类型。如果省的值是 number 1而后端回显时返回字符串 1v-model 匹配不上会导致下拉框显示为空但表单提交的值又是正确的这种“看似没毛病、实际没回显”的问题比报错还难发现。排查思路很简单在编辑页打开时先把 form 的值打印出来和 provinceList 里的 value 逐个对比类型。如果发现类型不一致在赋值时就地转换不要等提交再做处理。4. 树形下拉选择兼容方案与搜索取舍如果说 el-cascader 适合“选择器”那树形下拉就更适合“筛选框”。筛选框的特点是可以不选、可以精确到任意层级、要支持搜索定位、甚至要多选。这时候 el-cascader 的体验反而一般树形下拉才是正解。4.1 版本差异先说清楚vue2 element-ui 官方没有 tree-select 组件这是很多人一开始没想到的。element-plusvue3里才有官方 el-tree-select。所以如果你的项目还是 element-ui只有两条路自己封装或者引入社区封装好的树形下拉组件。自己封装不复杂核心思路是用 el-popover 包一个 el-tree再加一个输入框或展示框做选中回显。element-plus 则可以直接用el-tree-select v-modeldeptId :datadeptTree check-strictly filterable clearable node-keyid :props{ label: name, children: children } :render-after-expandfalse placeholder请选择部门 /这里check-strictly控制的是“父子节点是否联动选中”。筛选场景下我建议开启 check-strictly也就是选父节点不会自动把子节点全选上。原因是筛选区的语义通常是“我要按这个节点过滤数据”选中父节点时把它下面的子节点也带上是没意义的反而会让查询条件不好理解。4.2 自己封装一个树形下拉的思路element-ui 下要封装最小可用版本大概是这个结构外层 el-popover内部一个 el-input 用于展示选中值弹出层放一个 el-tree。el-tree 负责渲染层级和保持搜索过滤后的结构el-input 只负责展示。节点选中时把当前节点数据回填给父组件同时关闭 popovertemplate el-popover v-modelvisible triggerclick placementbottom-start width280 el-input v-modelfilterText placeholder搜索节点 clearable stylemargin-bottom: 8px / el-tree reftreeRef :datatreeData :props{ label: name, children: children } node-keyid highlight-current default-expand-all :filter-node-methodfilterNodeMethod node-clickhandleNodeClick / template #reference el-input v-modeldisplayText readonly placeholder请选择部门 suffix-iconel-icon-arrow-down / /template /el-popover /template搜索过滤是树形下拉的关键能力el-tree 提供了filter-node-method配合filter方法使用但要注意默认过滤只会让命中的节点保留如果命中节点的父级不命中父级也会被过滤掉树就断了。所以要自己写一个“保留命中节点及祖先节点”的过滤方法。每次输入变化时对树的每一层做一次过滤并把命中的父级重新拼接回来这一步是树形搜索里最容易出问题的地方。4.3 大节点树的性能优化树节点一多el-tree 全量渲染会很慢。有两个非常实用的优化点。一是render-after-expand设为 false让树在展开时才渲染该子节点避免首屏把所有节点全部渲染出来二是配合之前说的懒加载接口让树按需拉取子节点。如果再配合搜索我习惯在 filter 方法里加 300ms 防抖避免用户每敲一个字符就全树遍历一次watch: { filterText(val) { this.debouncedFilter(val) } }, created() { this.debouncedFilter debounce((val) { this.$refs.treeRef.filter(val) }, 300) }防抖函数可以自己写也可以用 lodash 的 debounce。核心是为了防止树在万级节点下高频遍历导致的卡顿。另外如果树数据里还包含“是否可选”这种权限控制建议在渲染前把不可选节点的状态统一处理掉不要在 filter 方法里再做二次判断逻辑会复杂很多。5. 我在实际项目里踩过的坑与完整排查过程前面讲的是方案下面这几个坑是我在真实项目里花过时间排查的每个我都尽量还原排查链路而不是直接给结论。遇到同样问题的人可以按这个思路一步步走。5.1 编辑回显空白的完整排查链路现象是编辑页打开cascader 输入框空白但 v-model 里确实有值。我的排查步骤控制台打印 v-model 当前值确认它是路径数组还是叶子 id打开 Vue devtools看 options 里的条目的 id 类型和 v-model 里的是否一致检查 props 配置里的 value/label/children 字段名是否和后端字段完全一致确认 emitPath 和 checkStrictly 是否和数据结构匹配。最后发现原因通常是“后端返回的叶子 id 是字符串树的 id 是数字”双等号匹配不上。el-cascader 内部用的是严格比较类型不一致直接导致回显失败。解决方案是接口层统一转成数字或者在赋值时Number(id)不要期待组件自己兼容。5.2 懒加载节点无法选中的坑另一个常见现象是树懒加载一切正常展开也正常但点叶子节点时选不中也没有任何报错。排查链路先在 Network 面板确认 lazyLoad 返回的数据结构重点看每一项有没有 leaf 字段。如果接口没有返回 hasChildren也没有 leaf组件会默认该节点仍有子节点于是点击操作被当成“展开”而不是“选中”。我之前实际排查时发现后端返回的内容里 leaf 字段拼错了导致 leaf 判断恒为 true。修复方式是在 normalize 时用hasChildren重新计算leaf: item.hasChildren false顺便说一句如果你同时传了 options 和 lazy: true级联组件的行为会变得很怪。我建议二选一要么全量传 options要么只用 lazy 模式千万别混用。5.3 下拉面板错位和卡顿的周边问题最后一个比较常见的坑和性能有关。el-select 和 el-cascader 的下拉面板默认是绝对定位在 body 上的如果容器设置了 overflow 或 transform面板会出现错位、被裁剪。排查确认后可以给组件加popper-append-to-bodyelement-ui 默认 true并确认容器没有 overflow-y: auto 的嵌套限制如果是 element-plus对应属性是 teleported。这个问题的特征是下拉面板在其他地方正常唯独在某个弹窗或抽屉里位置不对基本就是容器 overflow 加上定位父级导致的。性能方面大节点树除了懒加载之外我强烈建议把“搜索过滤”和“渲染”解耦。不要在 el-tree 的 data 上做原地 splice而是用 computed 生成一份过滤后的数据再传给 el-tree。这样树组件只负责渲染过滤逻辑完全可控出问题也好定位。多级下拉菜单本身不是一个需要炫技的功能但它牵扯到数据结构、组件选型、接口约定、回显方案这么多环节在生产环境里一旦出问题排查成本往往比写代码高得多。我自己后来养成的习惯是接到这种需求先花十分钟把“数据从哪来、选中后存什么、回显拿什么”这条链路画清楚选型阶段省下的时间后面一定能加倍还回来。如果你正在做类似的中后台页面从这三种形态里先选一个最贴近业务模型的再去看官方文档对应的组件会比硬套别人的代码省心很多。
返回列表