省市区三级联动几乎是所有涉及收货地址、公司注册、门店选址的后台系统都绕不开的功能。直接把全国三千多个区县一次性下发到前端,数据量往往超过一百KB,首屏加载白白浪费带宽;而完全依赖后端拼接,又会把交互拆得很碎。比较合理的做法是Cascader组件配合懒加载:初始只拉省级列表,用户点开某个省时再去请求对应的城市,区县同理。本文会先拆解级联选择器的数据结构,再动手实现一个支持懒加载的React Cascader组件,覆盖数据缓存、异步竞态、回显默认值这些容易踩坑的地方。

一、级联数据结构:Cascader的根基
Cascader组件的本质是把一棵树形数据逐层展开给用户看。标准的级联数据通常长这样:每个节点包含value(唯一标识)、label(显示文本)、children(子节点数组)。顶级数组是省份列表,children里放城市,城市的children里再放区县。渲染时,第一列展示顶级数组,用户点击某个节点后,组件根据children渲染第二列,依次类推。
懒加载场景下,children有两个变体:一是压根不存在这个字段,等用户展开时再动态挂上去;二是用一个isLeaf标记声明节点是否为叶子节点,比如省份节点isLeaf为false,区县节点为true。组件在展开非叶子节点时触发数据加载回调,拿到结果后再写入对应节点的children并更新视图。理解这个约定,后面无论是用antd还是自己写,思路都是一致的。
还需要注意value的唯一性范围。省市区编码天然全局唯一(比如110000是北京市),可以直接用编码做value。但如果你的数据源用的是名称拼接,就要小心重名问题,例如多个城市下属都有同名区县,此时用名称做value会导致选中结果歧义,正确做法是value存编码,label存名称,提交表单时再转换。
二、手写一个支持懒加载的React Cascader
先搭一个最小可用版本:组件接收options和onLoadChildren两个props,前者是初始省级数据,后者是异步加载函数。内部维护一个展开状态数组activeValues,记录当前各列选中的value,用点击事件驱动列的显示与子节点加载。核心代码如下:
import { useState, useCallback } from 'react';
function LazyCascader({ options, onLoadChildren }) {
const [panes, setPanes] = useState([options]); // 每一列的数据源
const [activeValues, setActiveValues] = useState([]);
const [loading, setLoading] = useState(false);
const handleExpand = useCallback(async (node, level) => {
// 更新激活值并裁剪残留的后续列
const next = activeValues.slice(0, level);
next[level] = node.value;
setActiveValues(next);
setPanes(panes.slice(0, level + 1));
if (!node.isLeaf && !node.children) {
setLoading(true);
const children = await onLoadChildren(node);
setLoading(false);
// 把children挂回原始节点,形成天然缓存
node.children = children;
setPanes(prev => [...prev.slice(0, level + 1), children]);
}
}, [activeValues, panes, onLoadChildren]);
return (
<div className="cascader">
{panes.map((pane, i) => (
<div className="cascader-panel" key={i}>
{pane.map(node => (
<div
key={node.value}
className={activeValues[i] === node.value ? 'active' : ''}
onClick={() => handleExpand(node, i)}
>
{node.label}
</div>
))}
</div>
))}
{loading && <div className="loading">加载中...</div>}
</div>
);
}
这段代码里有几个细节值得展开。第一,children直接挂在node对象上,相当于利用引用共享做缓存,用户第二次展开同一个省时,发现children已存在就不再发请求,省掉了额外的缓存层。第二,panes的裁剪逻辑很关键:用户先展开浙江省再回头点江苏省时,必须把残留的城市列、区县列砍掉,否则界面会出现新旧数据混排的错乱。
第三点是异步竞态。如果用户快速连续点击两个省份,两个Promise都在飞行中,后返回的旧数据可能覆盖新数据。解决办法是给每次请求记一个序号或使用AbortController,返回时比对当前激活值是否仍然匹配,不匹配就直接丢弃这次结果。antd内部也是靠类似机制保证一致性的,自己实现时千万别省这一步。
三、懒加载的数据接口设计与缓存策略
后端接口通常有两种形态:一种是按parent编码查询,比如请求携带parent=330000返回浙江省下属城市列表;另一种是全量树接口配合前端过滤。懒加载显然适合前者。请求函数可以这样封装:
const cache = new Map(); // 模块级缓存,跨组件实例复用
async function fetchChildren(parentCode) {
if (cache.has(parentCode)) {
return cache.get(parentCode);
}
const res = await fetch(`/api/region?parent=${parentCode}`);
const list = await res.json();
const mapped = list.map(item => ({
value: item.code,
label: item.name,
isLeaf: item.level === 3 // 区县为叶子节点
}));
cache.set(parentCode, mapped);
return mapped;
}
用Map做全局缓存的好处是组件卸载重建后数据仍然在,用户填表填一半切换页面也不用重新请求。代价是内存占用,不过全国行政区划的数据总量也就几百KB,完全可以接受。如果你的应用对数据新鲜度敏感(比如行政区划调整),可以给缓存加过期时间,或者提供手动清空缓存的入口。
另一个常被忽略的点是错误处理。网络抖动导致城市列表加载失败时,组件不能卡在loading态,应当捕获异常、给出重试入口,同时把loading复位。建议在onLoadChildren外层统一包一层try-catch,避免未处理的Promise rejection影响整个组件树。
四、默认值回显:懒加载最麻烦的场景
编辑收货地址时,需要把已保存的编码路径回显到Cascader里。麻烦在于懒加载模式下,城市的children一开始并不存在,组件没法凭空渲染出第二列和第三列。解决思路是沿着value路径逐级补数据:拿到默认值数组后,先确认省级列表包含第一个编码,再依次调用fetchChildren加载下一级数据,同时把每一级的激活值和面板数据设置好,直到路径走完。
async function restoreDefaultValue(defaultValue) {
const newPanes = [options];
const newActive = [];
let currentLevel = options;
for (const code of defaultValue) {
const node = currentLevel.find(n => n.value === code);
if (!node) break; // 数据不匹配,终止回显
newActive.push(code);
if (!node.children) {
node.children = await fetchChildren(code);
}
newPanes.push(node.children);
currentLevel = node.children;
}
setPanes(newPanes);
setActiveValues(newActive);
}
回显过程中同样要防竞态:用户在回显还没完成时就自己点了别的省份,两条路径的异步写入会互相打架。简单的处理是回显期间禁用组件交互,或者在每个await之后校验组件是否已经卸载、用户是否已手动操作过,满足条件就中断回显流程。
五、对比antd Cascader与自研方案
antd的Cascader组件通过loadData属性支持懒加载,配合fieldNames字段名映射可以直接对接后端数据,还自带搜索、多选、面板自定义等能力,绝大多数业务直接用它就够了。示例代码如下:
import { Cascader } from 'antd';
function Demo() {
const loadData = async selectedOptions => {
const target = selectedOptions[selectedOptions.length - 1];
target.loading = true;
const children = await fetchChildren(target.value);
target.children = children;
target.loading = false;
// 触发视图更新(antd 5.x 会自动处理)
setOptions([...options]);
};
return (
<Cascader
options={options}
loadData={loadData}
changeOnSelect={false}
placeholder="请选择省市区"
/>
);
}
那什么时候需要自研?主要是三类情况:一是UI定制要求高,需要把级联面板嵌进自己的弹层或特殊表单布局里;二是交互形态特殊,比如移动端常见的三列滚轮式选择;三是有特殊性能诉求,比如要在虚拟滚动里渲染超长列表。自研方案的自由度换来的维护成本,包括键盘可访问性、边界交互、无障碍支持这些antd已经打磨过的细节,做技术选型时要把这笔账算进去。
最后补充搜索过滤的实现思路:懒加载模式下前端只持有部分数据,本地搜索天然不完整。常见解法是提供远程搜索接口,用户输入关键词后后端返回匹配的完整路径,前端把路径转换为可直接选中的候选项,点击候选项时再触发一次路径回显加载。这样既保留了懒加载的按需加载优势,又补齐了搜索体验。
React Cascader省市区三级联动懒加载修改时间:2026-09-12 18:06:58