ARTICLE DETAIL

资讯详情

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

el-cascader懒加载动态加载:触发机制、踩坑排查与封装实践

el-cascader懒加载动态加载:触发机制、踩坑排查与封装实践 先交代一下背景最近在做组织架构选择器时后端只给两层部门数据选到第二层以后必须再调接口才能拿到下级用 el-cascader 的懒加载做并不复杂但我在动态加载这条路上踩了回显、校验、边界数据一堆坑前后折腾了两天。这篇文章就把 el-cascader 动态加载的触发机制、报错排查链路和最终落地的封装方案完整记录下来给同样被它折磨过的同学一个能直接套用的参考。1. 从一次部门选择器需求说起为什么要用懒加载1.1 用户需求与静态数据的冲突当时的业务是一个人员转岗弹窗里面要选“目标部门”。部门树一共六层最深的节点有几千个后端明确说“一次性返回全部树接口要 3 秒以上绝对不能这么干”只提供一个按父级 id 查询子级部门的接口GET /dept/children?parentId0 // 返回顶级部门 GET /dept/children?parentId1001 // 返回 1001 的子级拿到这种接口第一反应是“那我自己写一个点击加载子级的组件”。但仔细一想级联选择器要处理的东西不少面板展开、父子联动、选中路径、单选多选、清空重置、表单校验这些 el-cascader 全都内置了。手动实现一套代码量和维护成本都下不来。所以最终判断是使用 el-cascader 的lazylazyLoad懒加载模式让它自己在节点展开时调用接口而不是把整棵树一次性塞进options。1.2 选 lazy 而不是“动态改 options”的原因有人会问我不开 lazy先给顶级节点等用户点开某级时再手动往options里 push 子级不是一样吗理论上可以但实际会遇到两个麻烦级联面板的展开状态是组件内部维护的你手动改了options组件不一定能正确感知到当前展开节点下新增了子级经常出现“数据变了但面板没刷新”的情况。使用options动态追加时叶子节点的判断、选中路径的回显都需要自己处理等于重复造轮子。而lazy模式是官方设计的按需加载方案组件知道该在什么时候调你的函数也管理好加载状态和面板刷新。只要把接口请求逻辑写进lazyLoad剩下的交给组件就行。2. lazyLoad 的核心机制触发时机与节点状态2.1 最简实现官方示例为什么只能当 Demo 看先看一个最基础的实现这段代码是能跑通的但只适合最简单的场景template el-cascader v-modelselected :optionsoptions :propscascaderProps 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 是父级 idvalue 是子节点数组。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里返回的数据带上明确的leafresolve( 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: 前端组 } ]前端把这个数组逐层嵌套手动塞进optionsconst 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 :keycascaderKey v-modelselected :optionsoptions :propscascaderProps / /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 后端接口字段约定与两种兼容写法动态加载能不能稳定工作很大程度取决于后端接口字段是否规范。我建议在项目里和后端明确约定这一套字段类型说明idnumber/string节点 value必须有namestring节点 label必须有leafboolean是否叶子节点建议必须有disabledboolean是否禁用该节点可选如果后端不能返回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看每个层级的加载耗时。如果某个节点展开后加载超过一秒就考虑是不是接口一次返回的数据量太大或者需要后端加缓存。这个数据对优化体验非常有帮助。
返回列表