UniApp-X 实现省市区三级联动选择器(UVue + UTS)

在 UniApp-X 开发中,省市区三级联动选择器是表单场景中非常常见的组件需求。本文将详细讲解如何基于 UVue(UniApp-X 的 Vue 组件)和 UTS 语言,实现一个支持数据回显、联动切换、自定义样式的省市区三级联动选择器,适配 UniApp-X 的跨端特性。

一、组件核心功能

本次实现的省市区选择器具备以下核心特性:

  1. 基于 UniApp-X 的 picker 组件(multiSelector 模式)实现三级联动
  2. 支持对象数组数据源(非纯字符串数组),适配真实业务场景的地区数据格式
  3. 支持外部传入默认选中值,实现数据回显
  4. 选中后通过 v-model 语法糖向父组件传递完整的地区信息(ID + 名称)
  5. 自定义选择器展示样式,区分已选择 / 未选择状态
<template>
	<view class="uni-list">
		<view class="uni-list-cell">
			<view class="uni-list-cell-db">
				<!-- 三级联动 Picker 核心组件 -->
				<picker class="picker-multi--test" 
						mode="multiSelector" 
						@columnchange="bindMultiPickerColumnChange"
						@change="bindMultiPickerChange" 
						:value="data.multiIndex" 
						:range="data.multiArray" 
						range-key="name"
						header-text="选择区域">
					<!-- 已选择状态展示 -->
					<view class="uni-input picker-multi--value" v-if="displayText">
						<text>{{displayText}}</text>
						<text>》</text>
					</view>
					<!-- 未选择状态占位 -->
					<view v-else class="uni-placeholder">
						<text>请选择省市区</text>
						<text>》</text>
					</view>
				</picker>
			</view>
		</view>
	</view>
</template>
<script setup>
	import { reactive, watch, nextTick, computed } from 'vue';
	// 导入省市区数据源(需自行准备,格式为嵌套对象数组)
	import { areaData } from './area.uts'

	// 定义组件属性:接收外部传入的已选值
	const props = defineProps({
		selected: { type: Object, default: () => ({}) }
	})
	// 定义自定义事件:向父组件传递选中结果
	const emit = defineEmits(['update:selected'])

	// 类型定义:地区数据结构(适配真实业务的数据源格式)
	type AreaItem = {
		name : string,
		code : string,
		children : AreaItem[]
	}

	// 类型定义:Picker 展示的对象结构
	type PickerItem = {
		name : string,
		id : string
	}

	// 响应式数据:使用 reactive 保证自动化测试可访问
	const data = reactive({
		multiArray: [[], [], []] as PickerItem[][], // Picker 展示的三级数据
		multiIndex: [0, 0, 0], // Picker 选中索引
		sourceData: [] as AreaItem[], // 原始地区数据源(用于联动查找)
		hasSelected: false // 是否已选择标记
	})

	// 计算属性:格式化展示选中的省市区文本
	const displayText = computed(() => {
		if (!data.hasSelected) return '';
		const province = data.multiArray[0][data.multiIndex[0]]?.name || '';
		const city = data.multiArray[1][data.multiIndex[1]]?.name || '';
		const district = data.multiArray[2][data.multiIndex[2]]?.name || '';
		return `${province}/${city}/${district}`;
	})

	// 初始化加载地区数据
	async function loadData() {
		try {
			// 1. 保存原始数据源,用于后续联动查找
			data.sourceData = areaData as AreaItem[];

			// 2. 初始化省份列数据
			const provinces = areaData.map((item : AreaItem) => ({ name: item.name, code: item.code }));

			// 3. 初始化城市列数据(默认取第一个省份的城市)
			let cities : PickerItem[] = [];
			if (areaData.length > 0 && areaData[0].children) {
				cities = areaData[0].children.map((item : AreaItem) => ({ name: item.name, code: item.code }));
			}

			// 4. 初始化区县列数据(默认取第一个城市的区县)
			let districts : PickerItem[] = [];
			if (areaData.length > 0 && areaData[0].children && areaData[0].children.length > 0 && areaData[0].children[0].children) {
				districts = areaData[0].children[0].children.map((item : AreaItem) => ({ name: item.name, code: item.code }));
			}

			// 5. 更新 Picker 数据(使用 splice 触发响应式更新)
			data.multiArray.splice(0, 1, provinces);
			data.multiArray.splice(1, 1, cities);
			data.multiArray.splice(2, 1, districts);
			data.multiIndex = [0, 0, 0]; // 重置选中索引
		} finally {
			nextTick(() => {});
		}
	}

	// 从外部属性更新选中状态(用于回显)
	const updateFromProps = (selected : any) => {
		if (!selected || !selected.provinceId || !data.sourceData.length) return;
		
		// 1. 匹配省份索引
		const pIdx = data.sourceData.findIndex((p : any) => p.code === selected.provinceId);
		if (pIdx < 0) return;
		data.multiIndex[0] = pIdx;

		// 2. 更新城市列数据并匹配索引
		const cityList = data.sourceData[pIdx].children || [];
		data.multiArray.splice(1, 1, cityList.map((c : any) => ({ name: c.name, code: c.code })));
		let cIdx = 0;
		if (selected.cityId) {
			cIdx = cityList.findIndex((c : any) => c.code === selected.cityId) || 0;
		}
		data.multiIndex[1] = cIdx;

		// 3. 更新区县列数据并匹配索引
		const districtList = (cityList[cIdx] && cityList[cIdx].children) || [];
		data.multiArray.splice(2, 1, districtList.map((d : any) => ({ name: d.name, code: d.code })));
		let dIdx = 0;
		if (selected.districtId) {
			dIdx = districtList.findIndex((d : any) => d.code === selected.districtId) || 0;
		}
		data.multiIndex[2] = dIdx;

		// 标记为已选择
		data.hasSelected = true;
	}

	// 标记数据是否已加载
	let dataLoaded = false;

	// 监听外部选中值变化,实现数据回显
	watch(() => props.selected, (v : any) => {
		if (!dataLoaded) {
			// 首次加载:先初始化数据,再回显
			loadData().then(() => {
				dataLoaded = true;
				if (v && v.provinceId) updateFromProps(v);
			});
		} else {
			// 非首次:直接回显
			updateFromProps(v);
		}
	}, { deep: true, immediate: true })

	// Picker 列切换事件(核心联动逻辑)
	const bindMultiPickerColumnChange = (e : UniPickerColumnChangeEvent) => {
		data.multiIndex[e.detail.column] = e.detail.value;

		switch (e.detail.column) {
			case 0: // 切换省份
				const provinceIndex = data.multiIndex[0];
				if (data.sourceData[provinceIndex]) {
					const cityList = data.sourceData[provinceIndex].children || [];
					// 更新城市列
					data.multiArray.splice(1, 1, cityList.map((item : AreaItem) => ({ name: item.name, id: item.code })));
					// 更新区县列(默认第一个城市)
					const districtList = (cityList.length > 0 && cityList[0].children) || [];
					data.multiArray.splice(2, 1, districtList.map((item : AreaItem) => ({ name: item.name, id: item.code })));
					// 重置城市/区县索引
					data.multiIndex.splice(1, 1, 0);
					data.multiIndex.splice(2, 1, 0);
				}
				break;
			case 1: // 切换城市
				const pIndex = data.multiIndex[0];
				const cIndex = data.multiIndex[1];
				if (data.sourceData[pIndex] && data.sourceData[pIndex].children && data.sourceData[pIndex].children[cIndex]) {
					const districtList = data.sourceData[pIndex].children[cIndex].children || [];
					// 更新区县列
					data.multiArray.splice(2, 1, districtList.map((item : AreaItem) => ({ name: item.name, id: item.code })));
					// 重置区县索引
					data.multiIndex.splice(2, 1, 0);
				}
				break;
		}
	}

	// Picker 确认选择事件(向父组件传递结果)
	const bindMultiPickerChange = (e : UniPickerChangeEvent) => {
		const idxs = e.detail.value;
		data.multiIndex = idxs;
		data.hasSelected = true;

		// 从原始数据源获取完整的选中信息
		const p = data.sourceData[idxs[0]];
		const c = (p && p.children && p.children[idxs[1]]) || null;
		const d = (c && c.children && c.children[idxs[2]]) || null;

		// 构造返回结果
		const result = {
			provinceId: p?.code || '',
			provinceName: p?.name || '',
			cityId: c?.code || '',
			cityName: c?.name || '',
			districtId: d?.code || '',
			districtName: d?.name || '',
		};
		// 触发事件,传递结果给父组件
		emit('update:selected', result);
	}
</script>
<style>
	.uni-picker-tips {
		font-size: 14px;
		color: #333;
		margin-top: 5px;
		margin-bottom: 15px;
		padding: 0 15px;
	}

	.uni-input {
		font-size: 14px;
		color: #333;
		display: flex;
		flex-direction: row;
		align-items: center;
		padding: 0 40rpx;
		justify-content: space-between;
	}

	.uni-placeholder {
		font-size: 14px;
		color: gray;
		display: flex;
		flex-direction: row;
		align-items: center;
		padding: 0 40rpx;
		justify-content: space-between;
		
	}

</style>

二、关键知识点解析

1. 对象数组适配

UniApp-X 的 picker 组件默认支持字符串数组,但真实业务中地区数据多为对象数组(包含 name/code 等字段)。通过设置 range-key="name",可以指定 picker 展示对象中的 name 字段。

2. 联动逻辑实现

  • 省份切换:更新城市列和区县列数据,重置城市 / 区县索引为 0
  • 城市切换:仅更新区县列数据,重置区县索引为 0
  • 数据更新方式:使用 splice 而非直接赋值,确保 Vue 响应式系统能检测到数据变化,触发 picker 重新渲染

3. 数据回显实现

通过 watch 监听外部传入的 selected 属性,在数据加载完成后,根据 provinceId/cityId/districtId 匹配对应的索引,更新 picker 的选中状态。

4. 父组件使用方式

<template>
	<view>
		<AreaPicker :selected="form" @update:selected="handleAreaSelected" />
	</view>
</template>

<script setup>
	import { ref } from 'vue';
	import AreaPicker from '@/components/area-picker/area-picker.uvue';

	const form = ref();

	// 处理 area-picker 返回的选中结果
	function handleAreaSelected(selected) {
        if(selected){
		form.value.provinceId = selected.provinceId
		form.value.provinceName = selected.provinceName
		form.value.cityId = selected.cityId
		form.value.cityName = selected.cityName
		form.value.districtId = selected.districtId
		form.value.districtName = selected.districtName
        }
	}
</script>

<style>

</style>

5.area.uts 测试数据

const areaData = [
  {
    "name": "北京市",
    "code": "110000",
    "children": [
      {
        "name": "北京市",
        "code": "110000",
        "children": [
          { "name": "东城区", "code": "110101" },
          { "name": "西城区", "code": "110102" },
          { "name": "朝阳区", "code": "110105" },
          { "name": "海淀区", "code": "110108" }
        ]
      }
    ]
  },
  {
    "name": "上海市",
    "code": "310000",
    "children": [
      {
        "name": "上海市",
        "code": "310000",
        "children": [
          { "name": "黄浦区", "code": "310101" },
          { "name": "徐汇区", "code": "310104" },
          { "name": "长宁区", "code": "310105" },
          { "name": "静安区", "code": "310106" },
          { "name": "浦东新区", "code": "310115" }
        ]
      }
    ]
  },
  {
    "name": "广东省",
    "code": "440000",
    "children": [
      {
        "name": "广州市",
        "code": "440100",
        "children": [
          { "name": "荔湾区", "code": "440103" },
          { "name": "越秀区", "code": "440104" },
          { "name": "海珠区", "code": "440105" },
          { "name": "天河区", "code": "440106" },
          { "name": "白云区", "code": "440111" }
        ]
      },
      {
        "name": "深圳市",
        "code": "440300",
        "children": [
          { "name": "罗湖区", "code": "440303" },
          { "name": "福田区", "code": "440304" },
          { "name": "南山区", "code": "440305" },
          { "name": "宝安区", "code": "440306" },
          { "name": "龙岗区", "code": "440307" }
        ]
      }
    ]
  },
  {
    "name": "江苏省",
    "code": "320000",
    "children": [
      {
        "name": "南京市",
        "code": "320100",
        "children": [
          { "name": "玄武区", "code": "320102" },
          { "name": "秦淮区", "code": "320104" },
          { "name": "建邺区", "code": "320105" },
          { "name": "鼓楼区", "code": "320106" },
          { "name": "江宁区", "code": "320115" }
        ]
      },
      {
        "name": "苏州市",
        "code": "320500",
        "children": [
          { "name": "虎丘区", "code": "320505" },
          { "name": "吴中区", "code": "320506" },
          { "name": "相城区", "code": "320507" },
          { "name": "姑苏区", "code": "320508" },
          { "name": "吴江区", "code": "320509" }
        ]
      }
    ]
  },
  {
    "name": "浙江省",
    "code": "330000",
    "children": [
      {
        "name": "杭州市",
        "code": "330100",
        "children": [
          { "name": "上城区", "code": "330102" },
          { "name": "拱墅区", "code": "330105" },
          { "name": "西湖区", "code": "330106" },
          { "name": "滨江区", "code": "330108" },
          { "name": "萧山区", "code": "330109" }
        ]
      },
      {
        "name": "宁波市",
        "code": "330200",
        "children": [
          { "name": "海曙区", "code": "330203" },
          { "name": "江北区", "code": "330205" },
          { "name": "北仑区", "code": "330206" },
          { "name": "镇海区", "code": "330211" },
          { "name": "鄞州区", "code": "330212" }
        ]
      }
    ]
  }
];

export {
	areaData
}

四、注意事项

  1. 数据源准备:需自行准备 area.uts 文件,格式为嵌套的对象数组,包含 name/code/children 字段
  2. 响应式更新:UniApp-X 中 picker 的 range 数据更新需使用 splice,直接赋值可能导致视图不更新
  3. 跨端兼容:该组件基于 UniApp-X 原生 picker 组件实现,可兼容各端,但需注意不同平台的样式差异

总结

  1. 本次实现的省市区选择器基于 UniApp-X 的 picker 组件,通过 multiSelector 模式实现三级联动,适配对象数组数据源;
  2. 核心亮点是支持外部数据回显和 v-model 双向绑定,符合 Vue 组件设计规范;
  3. 关键技术点包括响应式数据更新(splice)、联动逻辑处理、计算属性格式化展示文本。

该组件可直接集成到 UniApp-X 项目中,适用于表单填写、地址选择等常见业务场景,只需替换 area.uts 中的地区数据源即可适配不同业务需求。
效果如下:
在这里插入图片描述
在这里插入图片描述

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐