vue3+ts+element plus实现省市县懒加载组件

47 阅读4分钟

该组件是一个功能完整、性能优化的省市区级联选择器,适用于需要用户选择地址(并获取详细编码和名称)的场景(如表单提交、地址编辑等)

1. 懒加载地区数据

通过级联选择器的lazy模式,实现地区数据的按需加载:

  • 初始不加载全量数据,仅在用户展开某一级节点时(如点击省份展开城市),才通过接口getCityData请求对应父级的子地区数据(父级编码初始为00,对应省份数据)。
  • 已加载的地区数据会被缓存(loadedNodes),避免重复请求,提升性能。

2. 双向数据绑定与值同步

  • 支持通过v-model(即modelValue prop)与外部组件同步选中的地址编码(如[省份编码, 城市编码, 区县编码])。
  • 当内部选中值变化时,通过update:modelValue事件同步给外部;同时通过change事件返回更详细的信息(包含省、市、区的编码和名称)。

3. 地址名称与编码映射管理

  • 维护了codeToNameMap(编码 - 名称映射)和fullNodeMap(完整节点信息),用于快速获取选中地址的名称(无需重复请求接口)。
  • 若名称获取失败,会尝试重新加载对应父级数据,确保名称显示准确性。

4. 初始值与外部值更新处理

  • 组件挂载时,若外部传入初始modelValue(如编辑场景的默认地址),会预加载对应地区数据并同步到级联选择器,确保初始显示正确。
  • 监听外部传入的modelValue变化,当值更新时,自动预加载数据并更新选择器状态,保证内外值一致。

5. 其他特性

  • 支持禁用状态(disabled prop)。
  • 可自定义接口地址(apiUrl prop)。
  • 支持清空选择(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;
};