Vue3项目实战:用china-region封装一个高复用的省市区选择器Hook,支持Pinia状态管理
Vue3高级封装实践基于Composition API与Pinia的省市区选择器Hook设计在复杂的前端项目中地址选择功能往往需要跨多个模块复用。传统组件封装方式虽然能解决基础复用问题但在状态管理、类型安全和逻辑解耦方面存在明显局限。本文将带你从Composition API的设计哲学出发构建一个高度可复用的useChinaRegionHook并深度集成Pinia实现全局状态共享。1. 为什么需要Hook化封装省市区联动选择器看似简单实则隐藏着多个工程化挑战数据耦合三级数据联动导致组件内部状态复杂类型安全行政区划数据需要完善的TS类型支持状态共享多个表单需要同步地址选择状态UI差异不同页面可能需要不同的选择器样式传统组件封装方式通过props传递数据和事件虽然能实现基础复用但存在几个关键缺陷// 传统组件方式的问题示例 RegionSelector v-model:provinceform.province v-model:cityform.city v-model:districtform.district changehandleChange /这种模式的主要痛点在于状态管理完全依赖父组件无法在非组件环境中使用逻辑难以实现跨组件的状态同步而基于Composition API的Hook方案可以完美解决这些问题// Hook方案的优势 const { provinces, cities, districts, selectedRegion, loadCities, loadDistricts } useChinaRegion()2. 核心Hook设计与实现2.1 基础数据结构设计首先需要定义完整的类型系统这是TS项目的关键// types/region.ts interface Region { code: string name: string } export type Province Region { cities?: City[] } export type City Region { districts?: District[] } export type District Region export interface SelectedRegion { province?: Province city?: City district?: District }2.2 Hook主体结构useChinaRegion的核心在于响应式数据管理和联动逻辑// hooks/useChinaRegion.ts import { ref, computed } from vue import { getProvinces, getPrefectures, getCounties } from china-region export default function useChinaRegion(initialRegion?: SelectedRegion) { // 响应式数据 const provinces refProvince[]([]) const cities refCity[]([]) const districts refDistrict[]([]) // 选中状态 const selectedProvince refProvince() const selectedCity refCity() const selectedDistrict refDistrict() // 初始化加载省份数据 const loadProvinces () { provinces.value getProvinces().map(p ({ code: p.code, name: p.name })) } // 城市加载逻辑 const loadCities async (provinceCode: string) { cities.value getPrefectures(provinceCode).map(c ({ code: c.code, name: c.name })) } // 区县加载逻辑 const loadDistricts async (cityCode: string) { districts.value getCounties(cityCode).map(d ({ code: d.code, name: d.name })) } // 计算属性返回完整选择 const selectedRegion computedSelectedRegion(() ({ province: selectedProvince.value, city: selectedCity.value, district: selectedDistrict.value })) return { provinces, cities, districts, selectedProvince, selectedCity, selectedDistrict, loadProvinces, loadCities, loadDistricts, selectedRegion } }2.3 联动逻辑优化传统watch方案在复杂场景下可能引发循环更新我们采用更可控的命令式风格// 在useChinaRegion.ts中添加 const handleProvinceChange (province: Province) { selectedProvince.value province selectedCity.value undefined selectedDistrict.value undefined if (province?.code) { loadCities(province.code) } else { cities.value [] districts.value [] } } const handleCityChange (city: City) { selectedCity.value city selectedDistrict.value undefined if (city?.code) { loadDistricts(city.code) } else { districts.value [] } }3. Pinia状态管理集成对于需要跨组件共享地址数据的场景我们可以创建Pinia store// stores/region.ts import { defineStore } from pinia import { useChinaRegion } from /hooks/useChinaRegion export const useRegionStore defineStore(region, () { const { provinces, cities, districts, selectedRegion, loadProvinces, loadCities, loadDistricts, handleProvinceChange, handleCityChange } useChinaRegion() // 扩展store特有功能 const recentRegions refSelectedRegion[]([]) const addToRecent (region: SelectedRegion) { recentRegions.value.unshift(region) if (recentRegions.value.length 5) { recentRegions.value.pop() } } return { provinces, cities, districts, selectedRegion, recentRegions, loadProvinces, loadCities, loadDistricts, handleProvinceChange, handleCityChange, addToRecent } })4. 多UI框架适配策略Hook化的最大优势是可以适配不同UI组件库以下是几个典型示例4.1 Element Plus适配器// adapters/elementAdapter.ts import { useChinaRegion } from ../useChinaRegion export function useElementRegion() { const region useChinaRegion() const elementProps computed(() ({ provinceProps: { options: region.provinces, props: { label: name, value: code } }, cityProps: { options: region.cities, props: { label: name, value: code }, disabled: !region.selectedProvince.value }, districtProps: { options: region.districts, props: { label: name, value: code }, disabled: !region.selectedCity.value } })) return { ...region, elementProps } }4.2 Ant Design Vue适配示例// adapters/antdAdapter.ts import { useChinaRegion } from ../useChinaRegion export function useAntdRegion() { const region useChinaRegion() const antdOptions computed(() ({ provinceOptions: region.provinces.map(p ({ label: p.name, value: p.code })), cityOptions: region.cities.map(c ({ label: c.name, value: c.code })), districtOptions: region.districts.map(d ({ label: d.name, value: d.code })) })) return { ...region, antdOptions } }5. 性能优化与高级技巧5.1 数据缓存策略避免重复加载相同地区数据// 在useChinaRegion.ts中扩展 const cityCache new Mapstring, City[]() const districtCache new Mapstring, District[]() const loadCitiesWithCache async (provinceCode: string) { if (cityCache.has(provinceCode)) { cities.value cityCache.get(provinceCode)! return } const data getPrefectures(provinceCode).map(c ({ code: c.code, name: c.name })) cityCache.set(provinceCode, data) cities.value data }5.2 虚拟滚动优化处理特别多的地区数据时// hooks/useVirtualRegion.ts import { useChinaRegion } from ./useChinaRegion import { useVirtualList } from vueuse/core export function useVirtualRegion() { const region useChinaRegion() const { list: virtualProvinces } useVirtualList( region.provinces, { itemHeight: 36 } ) const { list: virtualCities } useVirtualList( region.cities, { itemHeight: 36 } ) const { list: virtualDistricts } useVirtualList( region.districts, { itemHeight: 36 } ) return { ...region, virtualProvinces, virtualCities, virtualDistricts } }5.3 单元测试策略确保核心逻辑的可靠性// tests/useChinaRegion.spec.ts import { useChinaRegion } from ../useChinaRegion import { flushPromises, mount } from vue/test-utils describe(useChinaRegion, () { it(should load provinces correctly, async () { const { provinces, loadProvinces } useChinaRegion() expect(provinces.value.length).toBe(0) loadProvinces() await flushPromises() expect(provinces.value.length).toBeGreaterThan(0) expect(provinces.value[0]).toHaveProperty(code) expect(provinces.value[0]).toHaveProperty(name) }) it(should clear cities when province changes, async () { const { provinces, cities, selectedProvince, handleProvinceChange } useChinaRegion() loadProvinces() await flushPromises() handleProvinceChange(provinces.value[0]) await flushPromises() expect(cities.value.length).toBeGreaterThan(0) handleProvinceChange(null) expect(cities.value.length).toBe(0) }) })6. 实际应用案例6.1 复杂表单集成script setup langts import { useRegionStore } from /stores/region import { storeToRefs } from pinia const store useRegionStore() const { provinces, cities, districts, selectedRegion } storeToRefs(store) const form ref({ name: , contact: , region: null as SelectedRegion | null }) watch(selectedRegion, (val) { form.value.region val }, { deep: true }) /script template form div classform-group label姓名/label input v-modelform.name / /div div classform-group label联系方式/label input v-modelform.contact / /div div classform-group label所在地区/label ProvinceSelector :provincesprovinces changestore.handleProvinceChange / CitySelector :citiescities :disabled!store.selectedProvince changestore.handleCityChange / DistrictSelector :districtsdistricts :disabled!store.selectedCity changestore.selectedDistrict $event / /div /form /template6.2 多步骤向导应用// composables/useWizardRegion.ts import { useChinaRegion } from ./useChinaRegion import { useWizardStore } from /stores/wizard export function useWizardRegion() { const region useChinaRegion() const wizard useWizardStore() watch(() region.selectedRegion.value, (val) { wizard.updateForm({ region: val }) }, { deep: true }) return { ...region, wizard } }这种架构设计使得我们的地址选择逻辑可以在组件树中的任何位置访问保持状态一致性轻松实现撤销/重做功能支持服务端状态同步