先交代一下背景:最近在做组织架构选择器时,后端只给两层部门数据,选到第二层以后必须再调接口才能拿到下级,用 el-cascader 的懒加载做并不复杂,但我在动态加载这条路上踩了回显、校验、边界数据一堆坑,前后折腾了两天。这篇文章就把 el-cascader 动态加载的触发机制、报错排查链路和最终落地的封装方案完整记录下来,给同样被它折磨过的同学一个能直接套用的参考。
1. 从一次部门选择器需求说起:为什么要用懒加载
1.1 用户需求与静态数据的冲突
当时的业务是一个人员转岗弹窗,里面要选“目标部门”。部门树一共六层,最深的节点有几千个,后端明确说“一次性返回全部树接口要 3 秒以上,绝对不能这么干”,只提供一个按父级 id 查询子级部门的接口:
GET /dept/children?parentId=0 // 返回顶级部门 GET /dept/children?parentId=1001 // 返回 1001 的子级拿到这种接口,第一反应是“那我自己写一个点击加载子级的组件”。但仔细一想,级联选择器要处理的东西不少:面板展开、父子联动、选中路径、单选多选、清空重置、表单校验,这些 el-cascader 全都内置了。手动实现一套,代码量和维护成本都下不来。
所以最终判断是:使用 el-cascader 的lazy+lazyLoad懒加载模式,让它自己在节点展开时调用接口,而不是把整棵树一次性塞进options。
1.2 选 lazy 而不是“动态改 options”的原因
有人会问:我不开 lazy,先给顶级节点,等用户点开某级时再手动往options里 push 子级,不是一样吗?
理论上可以,但实际会遇到两个麻烦:
- 级联面板的展开状态是组件内部维护的,你手动改了
options,组件不一定能正确感知到当前展开节点下新增了子级,经常出现“数据变了但面板没刷新”的情况。 - 使用
options动态追加时,叶子节点的判断、选中路径的回显都需要自己处理,等于重复造轮子。
而lazy模式是官方设计的按需加载方案,组件知道该在什么时候调你的函数,也管理好加载状态和面板刷新。只要把接口请求逻辑写进lazyLoad,剩下的交给组件就行。
2. lazyLoad 的核心机制:触发时机与节点状态
2.1 最简实现:官方示例为什么只能当 Demo 看
先看一个最基础的实现,这段代码是能跑通的,但只适合最简单的场景:
<template> <el-cascader v-model="selected" :options="options" :props="cascaderProps" clearable /> </template> <script setup> import { ref, reactive } from 'vue' const selected = ref([]) const options = ref([]) const cascaderProps = { value: 'id', label: 'name', children: 'children', leaf: 'leaf', lazy: true, lazyLoad(node, resolve) { // 根节点的 level 是 0,没有 value const parentId = node.level === 0 ? 0 : node.value fetch(`/dept/children?parentId=${parentId}`) .then(res => res.json()) .then(data => { resolve( data.map(item => ({ id: item.id, name: item.name, leaf: item.leaf === undefined ? !item.hasChildren : item.leaf, disabled: !!item.disabled })) ) }) .catch(() => { // 这里千万不能少,否则节点会一直转圈 resolve([]) }) } } </script>这里有两个容易写错的地方,必须强调:
lazy和lazyLoad必须写在:props对象里,不是写在<el-cascader>的根属性上。很多人把lazy直接写到组件标签上,结果面板完全不触发加载,查半天查不到原因。- 给
resolve传入的数据,每一项要包含value和label对应的字段。如果props里配置了value: 'id',那返回的每一项就必须有id字段,否则组件拿不到节点值,选择时会出问题。
2.2 判断叶子节点:leaf 字段才是真正的钥匙
动态加载报错和无限请求,绝大多数都出在“叶子节点判断”上。官方文档其实没把这件事讲透。
Element 内部判断一个节点到底该不该继续加载,逻辑是这样的:
- 如果节点数据里有
leaf字段,并且值为true,直接当作叶子节点,不再触发lazyLoad。 - 如果节点数据里有
children字段,并且是一个非空数组,直接使用这些子节点,也不触发lazyLoad。 - 如果节点数据里有
children: [],组件会把它当成叶子节点,不继续加载。 - 如果节点数据里既没有
leaf字段,也没有children字段,组件会认为“这个节点还没加载过”,每次展开都会触发lazyLoad。
很多后端接口在没有子级时会返回children: [],这样其实没问题,组件不会再请求。但最怕的是后端返回结构里children这个字段名变了,比如返回childList,而你props里写的是children: 'children',组件读取不到children,就会当成“还没加载”,导致每次展开都发请求,接口被打爆。
所以我的建议是:和后端约定,接口一定返回leaf布尔字段。前端根据leaf判断是否继续加载,这个字段优先级最高,也最不容易出歧义。
2.3 官方文档没细说的“数据缓存”机制
Element 的lazyLoad不像普通函数那样每次必然重新执行。组件内部对每个节点有loaded状态,只要这个节点已经加载过,就不会再触发lazyLoad。
但有一个例外让人很头疼:如果leaf判断失败,节点每次展开都会重新调接口。这也是很多人反馈“明明加载过了,为什么又请求一遍”的原因。
解决方式有两种:
- 在后端把
leaf字段返回准确。 - 在前端自己维护一个 Map 缓存,key 是父级 id,value 是子节点数组。
lazyLoad里先查缓存,命中就直接resolve(cache[key]),不再请求接口。这个做法我在后面的封装里给了完整代码。
还有一点:连续快速点开多个节点时,接口是并发发出的,如果其中一个接口返回慢了,节点会一直转圈。组件内部只有在resolve被调用后才会关闭 loading 状态。所以不管请求成功还是失败,resolve一定要调用,这是后面所有报错处理的基石。
3. 动态加载真实开发里的报错排查链路
3.1 现象一:节点永远在转圈
这是最经典的问题,几乎每个用懒加载的人都遇到过。表现为:点击箭头,节点出现 loading,接口明明已经返回了,面板却一直转圈不消失。
排查思路:
先打开 Network 面板看接口状态。如果接口 200 且返回了正确的 JSON,那问题基本出在lazyLoad的异步回调没有被正确执行。
比如下面这段错误代码:
lazyLoad(node, resolve) { fetch(`/dept/children?parentId=${node.value}`) .then(res => res.json()) .then(data => { // 忘了调用 resolve(data) }) }接口返回了,但resolve没调用,组件永远等不到结果,loading 自然一直转。
还有一种情况更隐蔽:在then里做了数据转换,转换过程抛异常,导致resolve没执行:
.then(data => { const list = data.map(item => item.children.map(...)) // 这里报错,resolve 没走到 resolve(list) })所以我在项目里的做法是:所有数据处理都放进try/catch,成功和失败都调用resolve,失败时至少resolve([]),保证 UI 不会卡死:
async lazyLoad(node, resolve) { try { const data = await fetchChildren(node.level === 0 ? 0 : node.value) resolve(normalize(data)) } catch (e) { console.error('[cascader] load error:', e) resolve([]) } }3.2 现象二:Maximum call stack size exceeded
这个报错一出现,基本可以确定是“数据里存在循环引用”。
有一次我排查了很久,最后发现是后端在子节点对象里塞了一个parent字段,指向父节点对象。Element 内部在递归构建树时,顺着children往下一层又顺着parent往回走,最终栈溢出。
前端能做的处理:
把要传给resolve的数据做一次“字段白名单过滤”,只保留组件需要的字段,杜绝多余字段进入内部节点对象:
function normalize(nodes) { return nodes.map(item => ({ id: item.id, name: item.name, leaf: item.leaf, disabled: item.disabled, children: item.children ? normalize(item.children) : undefined })) }这里有个经验:不要直接JSON.parse(JSON.stringify(data))。如果数据里有循环引用,JSON.stringify自己就会抛Converting circular structure to JSON,起不到兜底作用。写一个递归白名单函数最稳。
另外,如果props里配置了children: 'children',同时某个节点又有一个字段叫children但不是数组,是对象或字符串,也会导致组件内部处理异常,甚至栈溢出。所以后端返回的节点里,children字段要么不要,要么必须是数组。
3.3 现象三:Cannot read properties of undefined (reading 'children')
这个报错看着像组件内部问题,但实际排查下来,绝大多数是自己的业务代码里访问了不存在的children。
比如你在change事件里写:
function onChange() { const node = cascaderRef.value.getCheckedNodes()[0] const children = node.children // 如果选中的是还没加载过的节点,children 是 undefined children.forEach(...) // 报错 }懒加载模式下,一个节点可能只是部分加载,node.children完全可能不存在。访问之前必须判空:
const children = node?.children || []还有一种情况是接口返回的数据层次不齐,根节点返回的数组里有数据,但某一项缺少children字段,同时该项又没有leaf字段。组件在把它当成“待加载节点”处理时,内部逻辑走到node.data.children相关的分支,也可能出现这个报错。
排查建议:在lazyLoad里把后端原始数据先打出来,肉眼扫一遍有没有节点既没有children也没有leaf。如果有,用leaf: true补上,这个报错基本就消失了。
3.4 现象四:展开节点后数据重复或请求频繁
展开同一个节点,每次都会发请求,这个问题的根因大概率是叶子判断不准确。
举个例子:后端返回的节点结构是:
{ "id": 1001, "name": "技术部", "hasChildren": true }props里没有配leaf字段,组件读不到leaf,也没有children字段,于是每次都要触发lazyLoad。
解决办法是让lazyLoad里返回的数据带上明确的leaf:
resolve( data.map(item => ({ id: item.id, name: item.name, leaf: !item.hasChildren })) )如果是父节点本身的问题,可以在前面提到过的 Map 缓存里做兜底。只要缓存命中了,就不发请求,这样即使某个节点 leaf 判断有问题,也不会重复打接口。
下面用一张表汇总这四类问题的排查方向:
| 现象 | 优先排查方向 | 处理方式 |
|---|---|---|
| 节点永远转圈 | resolve是否被调用 | 成功失败都要调用resolve |
| 栈溢出 | 数据循环引用或children类型错误 | 白名单递归过滤字段 |
| reading 'children' 报错 | 业务代码访问了不存在字段 | 访问前判空 |
| 展开重复请求 | leaf字段缺失或判断失败 | 后端返回leaf,前端加 Map 缓存 |
4. 动态加载场景下的回显、校验与重置
4.1 编辑页回显:值有但面板不显示
动态加载下回显是最烦人的。
静态数据时,options是齐的,你把v-model绑定的数组传进去,组件根据 value 找到 label 显示出来。但懒加载模式下,如果用户编辑时直接给v-model传[1001, 1002],而当前options里只有根节点,组件根本不知道1002是谁,面板就只显示一个空标签。
我最终用的方案是:编辑时让后端多给一个“路径接口”,返回从根到选中节点的完整路径节点数组:
[ { "id": 1, "name": "总公司" }, { "id": 1001, "name": "技术部" }, { "id": 1002, "name": "前端组" } ]前端把这个数组逐层嵌套,手动塞进options:
const path = await fetchPath(deptId) options.value = [ { id: path[0].id, name: path[0].name, children: [ { id: path[1].id, name: path[1].name, children: [...path.slice(2)] } ] } ]注意一个要点:如果路径末端节点下面还有子级,需要在节点上设置leaf: false,这样用户点击展开时还会继续走lazyLoad加载下一层。如果路径末端是叶子,就设置leaf: true,避免多余请求。
如果你不想在后端加路径接口,还有一个偏前端的方法:在lazyLoad里判断当前要加载的父节点 id 是否在回显路径上,如果命中,就resolve出路径上的下一个节点。但这个方法写起来啰嗦,而且容易漏边界,我建议优先走后端路径接口。
4.2 表单校验时机:提交时数据还没加载完
正常情况下,change事件会在用户选中一个节点后触发,此时v-model更新,表单校验也能通过。但懒加载模式下有个特例:用户点击一个父节点时,面板会先加载子节点,此时change不一定触发。
这会导致一个体验问题:用户选了某个父级,面板正在转圈,他以为已经选中了,直接点提交,结果校验提示“请选择部门”,因为v-model还没更新。
我当时的解决办法是在lazyLoad里维护一个全局加载计数:
let loadingCount = 0 async function lazyLoad(node, resolve) { loadingCount++ try { // ...请求逻辑 } finally { loadingCount-- } }提交时先判断:
if (loadingCount > 0) { ElMessage.warning('选项加载中,请稍候') return }这个方案成本极低,但体验提升非常明显,不会让用户误以为卡死或操作失败。
4.3 重置与刷新:为什么清空 value 后旧数据还在
还有一个很容易踩的坑:表单里点“重置”,v-model已经清空了,但再次打开级联面板,第一层数据还是上次加载过的缓存,选项没有刷新。
原因是组件实例没有销毁,它内部已经保存了节点的loaded状态和children数据。清空v-model不会清掉组件内部的树,只想清空选项是做不到的。
最简单的方案是给el-cascader加一个key,重置时让 key 变化,强制组件重新创建:
<template> <el-cascader :key="cascaderKey" v-model="selected" :options="options" :props="cascaderProps" /> </template> <script setup> const cascaderKey = ref(0) function handleReset() { selected.value = [] options.value = [] cascaderKey.value++ } </script>这种方式彻底销毁重建,所有缓存和节点状态都清空,是最稳妥的。
5. 我在项目里沉淀下来的封装方案
5.1 自定义 useCascaderLazy:缓存、失败兜底、加载计数
踩完这些坑以后,我把动态加载逻辑封装成了一个组合式函数,后续新项目直接复用:
import { ref, computed } from 'vue' export function useCascaderLazy(fetcher) { const cache = new Map() const loadingCount = ref(0) const loading = computed(() => loadingCount.value > 0) async function lazyLoad(node, resolve) { const parentKey = node.level === 0 ? '__ROOT__' : node.value if (cache.has(parentKey)) { resolve(cache.get(parentKey)) return } loadingCount.value++ try { const list = await fetcher(parentKey) const nodes = list.map(item => ({ id: item.id, name: item.name, leaf: item.leaf, disabled: item.disabled, children: undefined })) cache.set(parentKey, nodes) resolve(nodes) } catch (e) { console.error('[cascader] lazyLoad error:', e) resolve([]) } finally { loadingCount.value-- } } function reset() { cache.clear() loadingCount.value = 0 } return { lazyLoad, loading, reset } }使用方式:
const api = useCascaderLazy(async (parentId) => { const res = await fetch(`/dept/children?parentId=${parentId}`) return res.json() }) const cascaderProps = { value: 'id', label: 'name', children: 'children', leaf: 'leaf', lazy: true, lazyLoad: api.lazyLoad }这段封装解决了三件事:
- 缓存:同一父节点只请求一次。
- 失败兜底:接口报错也
resolve([]),节点不会卡死。 - 加载计数:提交前用
api.loading判断当前是否还有接口在加载。
5.2 后端接口字段约定与两种兼容写法
动态加载能不能稳定工作,很大程度取决于后端接口字段是否规范。我建议在项目里和后端明确约定这一套:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | number/string | 节点 value,必须有 |
| name | string | 节点 label,必须有 |
| leaf | boolean | 是否叶子节点,建议必须有 |
| disabled | boolean | 是否禁用该节点,可选 |
如果后端不能返回leaf,前端可以做一层兼容:根据hasChildren字段推断。
leaf: typeof item.leaf === 'boolean' ? item.leaf : !item.hasChildren如果hasChildren也没有,那就只能约定“没有 children 字段且没有 leaf 字段的节点视为叶子”,但这种情况我强烈建议推动后端补字段,否则前端永远在猜,早晚会出问题。
5.3 最后补充一个调试小工具
动态加载出了问题,不要急着改代码。先打开 Vue DevTools,找到 el-cascader 面板对应的组件,查看节点的内部属性:
node.loading:是否正在加载。node.loaded:该节点是否已经加载过。node.data.leaf:原始数据里到底怎么定义的。
这个信息几乎能定位所有动态加载问题。比如你想排查“为什么这个节点重复请求”,只要看到node.loaded是false,就说明组件压根没缓存它,问题一定出在leaf或children字段的判断上。这时直接看node.data里有没有leaf、有没有children字段,就知道该怎么改了。
我在实际开发中还有一个习惯:在lazyLoad的resolve之前做一个console.time和console.timeEnd,看每个层级的加载耗时。如果某个节点展开后加载超过一秒,就考虑是不是接口一次返回的数据量太大,或者需要后端加缓存。这个数据对优化体验非常有帮助。