该组件是一个功能完整、性能优化的省市区级联选择器,适用于需要用户选择地址(并获取详细编码和名称)的场景(如表单提交、地址编辑等)
1. 懒加载地区数据
通过级联选择器的lazy模式,实现地区数据的按需加载:
- 初始不加载全量数据,仅在用户展开某一级节点时(如点击省份展开城市),才通过接口
getCityData请求对应父级的子地区数据(父级编码初始为00,对应省份数据)。 - 已加载的地区数据会被缓存(
loadedNodes),避免重复请求,提升性能。
2. 双向数据绑定与值同步
- 支持通过
v-model(即modelValueprop)与外部组件同步选中的地址编码(如[省份编码, 城市编码, 区县编码])。 - 当内部选中值变化时,通过
update:modelValue事件同步给外部;同时通过change事件返回更详细的信息(包含省、市、区的编码和名称)。
3. 地址名称与编码映射管理
- 维护了
codeToNameMap(编码 - 名称映射)和fullNodeMap(完整节点信息),用于快速获取选中地址的名称(无需重复请求接口)。 - 若名称获取失败,会尝试重新加载对应父级数据,确保名称显示准确性。
4. 初始值与外部值更新处理
- 组件挂载时,若外部传入初始
modelValue(如编辑场景的默认地址),会预加载对应地区数据并同步到级联选择器,确保初始显示正确。 - 监听外部传入的
modelValue变化,当值更新时,自动预加载数据并更新选择器状态,保证内外值一致。
5. 其他特性
- 支持禁用状态(
disabledprop)。 - 可自定义接口地址(
apiUrlprop)。 - 支持清空选择(
clearable)。 - 组件卸载时自动清理缓存数据,避免内存泄漏。
<template>
<el-cascader
ref="cascaderRef"
v-model="selectedOptions"
:options="[]"
:props="cascaderProps"
:disabled="disabled"
placeholder="请选择地址"
style="width: 300px"
clearable
@change="handleChange"
@expand-change="handleExpandChange"
/>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, defineProps, defineEmits, nextTick, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import { getCityData } from '@/api/system/tenant';
interface RegionItem {
code: string;
name: string;
regionLevel: number;
childLs?: RegionItem[];
value?: string;
label?: string;
children?: RegionItem[];
leaf?: boolean;
}
const props = defineProps({
modelValue: {
type: Array as () => string[],
default: () => []
},
disabled: {
type: Boolean,
default: false
},
apiUrl: {
type: String,
default: '/api/regions'
}
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void;
(
e: 'change',
value: {
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
districtCode: string;
districtName: string;
}
): void;
}>();
const cascaderRef = ref<any>(null);
const selectedOptions = ref<string[]>([...props.modelValue]);
const loadedNodes = ref<Map<string, RegionItem[]>>(new Map());
const codeToNameMap = ref<Map<string, string>>(new Map());
const fullNodeMap = ref<Map<string, RegionItem>>(new Map());
// 新增:标记组件是否已挂载
const isMounted = ref(false);
const arraysEqual = (a: string[], b: string[]) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
};
const cascaderProps = {
value: 'code',
label: 'name',
children: 'children',
leaf: 'leaf',
checkStrictly: false,
lazy: true,
lazyLoad: async (node: any, resolve: any) => {
const { level, data } = node;
const parentCode = level > 0 ? data.code : '00';
if (loadedNodes.value.has(parentCode)) {
return resolve(loadedNodes.value.get(parentCode));
}
try {
const childNodes = await fetchRegions(parentCode);
const formattedNodes = childNodes.map((item) => {
codeToNameMap.value.set(item.code, item.name);
fullNodeMap.value.set(item.code, item);
return {
...item,
value: item.code,
label: item.name,
leaf: item.regionLevel === 3,
children: item.regionLevel < 3 ? [] : undefined
};
});
loadedNodes.value.set(parentCode, formattedNodes);
resolve(formattedNodes);
} catch (error) {
console.error('加载地区数据失败:', error);
ElMessage.error('加载地区数据失败,请稍后重试');
resolve([]);
}
}
};
const fetchRegions = async (parentCode: string = '00'): Promise<RegionItem[]> => {
try {
const response = await getCityData(parentCode);
return Array.isArray(response.data.childLs) ? response.data.childLs : [];
} catch (error) {
console.error('获取地区数据失败:', error);
ElMessage.error('获取地区数据失败,请稍后重试');
return [];
}
};
const getRegionName = (code: string, level: number): string => {
if (!code) return '';
if (cascaderRef.value) {
const checkedNodes = cascaderRef.value.getCheckedNodes() || [];
const node = checkedNodes.find((n: any) => n.value === code);
if (node?.label) {
return node.label;
}
}
const fullNode = fullNodeMap.value.get(code);
if (fullNode?.name) {
return fullNode.name;
}
const nameFromMap = codeToNameMap.value.get(code);
if (nameFromMap) {
return nameFromMap;
}
console.warn(`名称获取失败,尝试重新加载 - 编码: ${code}`);
const parentCode = level === 1 ? '00' : level === 2 ? selectedOptions.value[0] : selectedOptions.value[1];
if (parentCode && isMounted.value) {
fetchRegions(parentCode).then(() => {
console.log(`重新加载后获取到的名称: ${codeToNameMap.value.get(code)}`);
});
}
return '';
};
const handleChange = (value: string[]) => {
emit('update:modelValue', value);
const result = {
provinceCode: value[0] || '',
provinceName: value[0] ? getRegionName(value[0], 1) : '',
cityCode: value[1] || '',
cityName: value[1] ? getRegionName(value[1], 2) : '',
districtCode: value[2] || '',
districtName: value[2] ? getRegionName(value[2], 3) : ''
};
emit('change', result);
};
const handleExpandChange = async (value: string[]) => {
if (value.length > 0 && isMounted.value) {
await preloadNodesForDisplay(value);
}
};
const preloadNodesForDisplay = async (codes: string[]) => {
if (!codes || codes.length === 0 || !isMounted.value) return;
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
if (!code) continue;
const parentCode = i === 0 ? '00' : codes[i - 1];
if (!loadedNodes.value.has(parentCode)) {
const nodes = await fetchRegions(parentCode);
nodes.forEach((node) => {
codeToNameMap.value.set(node.code, node.name);
fullNodeMap.value.set(node.code, node);
});
}
}
};
// 关键修复:访问内部实例前增加多层有效性检查
const safeUpdateCascader = async (values: string[]) => {
// 确保组件已挂载且ref有效
if (!isMounted.value || !cascaderRef.value) return;
await nextTick();
// 检查panel实例是否存在
const panel = cascaderRef.value.$refs.panel;
if (panel && typeof panel.clearCheckedPaths === 'function') {
panel.clearCheckedPaths();
}
// 安全设置选中值
if (typeof cascaderRef.value.setCheckedValue === 'function') {
cascaderRef.value.setCheckedValue([...values]);
}
};
watch(
() => props.modelValue,
async (newVal) => {
// 仅在组件挂载后执行
if (!isMounted.value) return;
if (!arraysEqual(newVal, selectedOptions.value) && newVal.length > 0) {
await preloadNodesForDisplay(newVal);
selectedOptions.value = [...newVal];
// 使用安全更新方法
await safeUpdateCascader(newVal);
}
},
{ deep: true, immediate: true }
);
onMounted(async () => {
// 标记组件已挂载
isMounted.value = true;
if (props.modelValue && props.modelValue.length) {
await preloadNodesForDisplay(props.modelValue);
await nextTick();
await safeUpdateCascader(props.modelValue);
}
});
// 组件卸载时清理状态
onUnmounted(() => {
isMounted.value = false;
loadedNodes.value.clear();
codeToNameMap.value.clear();
fullNodeMap.value.clear();
});
</script>
<style scoped>
:deep(.el-cascader) {
width: 100%;
max-width: 500px;
}
</style>
父组件引用
<AreaCascader v-if="dialog.visible" v-model="form.regAddress" @change="handleAreaChange" />
// 处理选择变化
const handleAreaChange = (value: AreaInfo) => {
console.log('选中的编码:', value);
const name = value.provinceName + value.cityName + value.districtName;
form.value.regAddressName = name;
};