Cascader级联选择器的深度优化与实战技巧

ლ馨翼 组件 阅读 2,251
赞 19 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这项目是个后台管理系统,要做一个地址选择功能,用户得从省、市、区三级里挑位置。本来想用三个下拉框联动,但产品给的原型是那种弹窗式级联菜单,点一下省,滑出市,再滑出区,体验更顺滑。没办法,只能上 Cascader。

Cascader级联选择器的深度优化与实战技巧

我们项目用的是 Vue 3 + Element Plus,Cascader 组件原生就支持懒加载、异步加载,看起来很理想。一开始我还挺轻松的,觉得这功能半小时就能搞完。结果……后来三天都陷在里面,主要是数据结构和性能问题。

后端给的数据是平铺的,不是树形结构。比如:

[
  { id: 1, name: "广东省", parentId: null },
  { id: 2, name: "深圳市", parentId: 1 },
  { id: 3, name: "南山区", parentId: 2 }
]

而 Cascader 要的是 children 嵌套结构,或者用 lazy + load 函数动态加载。我一开始图省事,想着一次性拉全量数据然后前端转成树,结果数据一多,页面卡得要死,尤其是第一次渲染的时候。

最大的坑:性能问题

开始没想到数据量会这么大。全国的行政区划数据拉下来有 3000 多条,我在本地转树结构用了递归嵌套遍历,代码写得还挺优雅:

function listToTree(list) {
  const map = {};
  const roots = [];

  list.forEach(item => {
    map[item.id] = { ...item, children: [] };
  });

  list.forEach(item => {
    if (item.parentId === null) {
      roots.push(map[item.id]);
    } else {
      const parent = map[item.parentId];
      if (parent) {
        parent.children.push(map[item.id]);
      }
    }
  });

  return roots;
}

这函数本身没问题,但执行起来在主线程上跑,数据一上来,页面直接卡两秒。用户点开弹窗,半天没反应,体验极差。

后来调整了方案,改用懒加载(lazy),让 Cascader 每次只请求当前层级的数据。这样每次打开只查一级,比如先查所有省,点广东省再查下属市,点南山区再查街道(如果有)。

最终的解决方案

核心思路是:不预加载,按需请求。后端配合加了个接口,根据 parentId 返回子节点列表。前端用 Element Plus 的 lazy-load 方案。

代码如下:

<template>
  <el-cascader
    v-model="selectedValue"
    :options="options"
    :props="cascaderProps"
    placeholder="请选择地区"
  />
</template>
<script setup>
import { ref } from 'vue';

const selectedValue = ref([]);
const options = ref([]);

const cascaderProps = {
  lazy: true,
  async lazyLoad(node, resolve) {
    const { level } = node;
    // 根节点:加载省级
    const parentId = level === 0 ? null : node.data.id;

    try {
      const res = await fetch(https://jztheme.com/api/regions?parentId=${parentId});
      const data = await res.json();

      const nodes = data.map(item => ({
        label: item.name,
        value: item.id,
        leaf: level >= 2 // 到区级就不往下展开了
      }));

      resolve(nodes);
    } catch (error) {
      console.error('加载失败:', error);
      resolve([]);
    }
  }
};
</script>

这里注意我踩过好几次坑:resolve 必须调用,否则节点一直转圈;leaf 字段控制是否还能展开,我一开始忘了设,导致最后一级还能点,请求空数据。

另外,后端那个 API 接口做了缓存,相同 parentId 的请求不会每次都打数据库,减轻压力。这个优化虽然不在前端,但对整体体验帮助很大。

还有一个小毛病没彻底解决

现在方案跑起来了,但有个小问题:用户如果频繁点击不同分支,比如快速切换“广东 → 深圳”和“浙江 → 杭州”,会发出多个异步请求,可能造成节点错乱——比如杭州的区出现在深圳下面。

原因是 Cascader 内部没有自动取消前一个请求,也没有根据请求顺序做校验。我试过加个请求锁,比如用一个 pending 变量防止并发,但会导致交互变卡,用户点第二下没反应。

最后妥协了:加了个简单的防抖,延迟 200ms 发请求,同时在 resolve 前判断当前 node 是否仍是活跃节点(比如通过比对 node.path 数组)。虽然不能 100% 避免,但实测中基本看不出来。

let lastRequestId = 0;

async lazyLoad(node, resolve) {
  const requestId = ++lastRequestId;
  const { level } = node;
  const parentId = level === 0 ? null : node.data.id;

  setTimeout(async () => {
    // 防止旧请求污染新节点
    if (requestId !== lastRequestId) return;

    try {
      const res = await fetch(https://jztheme.com/api/regions?parentId=${parentId});
      const data = await res.json();

      const nodes = data.map(item => ({
        label: item.name,
        value: item.id,
        leaf: level >= 2
      }));

      // 再次确认节点未被销毁或切换
      if (requestId === lastRequestId) {
        resolve(nodes);
      }
    } catch (error) {
      if (requestId === lastRequestId) {
        resolve([]);
      }
    }
  }, 200);
}

这个方案不是最优的,但最简单,也够用。毕竟真实使用中没人会疯狂点来点去。

回顾与反思

这次用 Cascader 最大的教训就是:别高估前端处理大数据的能力。哪怕只是格式转换,几千条数据在主线程跑递归,照样卡死。能懒加载就懒加载,让用户感觉不到加载过程才是王道。

另一个经验是,组件库的功能看着强大,但实际落地时往往需要定制。Element Plus 的 Cascader 文档写得挺全,但像防抖、请求竞态这种细节根本不会提,得自己补。

做得好的地方是跟后端协作设计了合理的 API 结构,让懒加载能顺利进行。要是后端不支持按 parentId 查询,这方案就得重来。

还能优化的点:

  • 前端加个简单缓存,比如把已加载过的市级节点存起来,避免重复请求
  • 用 Intersection Observer 预加载下一级热点区域(比如北上广深),提升响应速度
  • 移动端考虑改成 picker 滚动选择,Cascader 在小屏幕上体验一般

不过目前就这样了,上线一周没收到相关 bug 反馈,说明问题不大。

以上是我踩坑后的总结,希望对你有帮助

这个技巧的拓展用法还有很多,比如用于分类选择、组织架构、商品类目等场景,只要数据是树形结构,都能套这套懒加载逻辑。

以上是我个人对这个 Cascader 实战的完整讲解,有更优的实现方式欢迎评论区交流。毕竟谁还没几个没完全解决的小毛病呢。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论