【JS 实战案例】原生实现企业级城市选择组件(省市区联动+搜索+拼音检索+记忆功能)

张开发
2026/4/10 7:05:50 15 分钟阅读

分享文章

【JS 实战案例】原生实现企业级城市选择组件(省市区联动+搜索+拼音检索+记忆功能)
前言城市选择组件是电商、外卖、物流、注册表单等场景的高频刚需组件市面上的组件要么依赖框架要么功能简陋无拼音检索、无记忆功能。本文基于原生 JavaScript 实现一套企业级城市选择组件包含省市区三级联动、关键词搜索、拼音检索、最近选择记忆、热门城市快捷选择核心功能无任何第三方库依赖轻量可集成、样式可定制适配PC/移动端直接满足生产环境需求。实现功能三级联动省份→城市→区县选择省份自动加载对应城市选择城市自动加载对应区县无数据时显示“无数据”提示双重检索支持中文关键词搜索匹配省/市/区名称、拼音首字母检索如“bj”匹配北京实时过滤结果记忆功能本地存储最近选择的3个城市下次打开自动显示提升用户操作效率热门城市默认展示10个热门城市可自定义支持快捷选择无需逐级筛选交互优化点击空白关闭弹窗、选中城市自动回填、检索无结果提示、hover状态反馈响应式适配PC端弹窗居中显示移动端全屏弹窗适配不同屏幕尺寸可定制化支持自定义热门城市、弹窗大小、颜色主题适配不同项目风格完整代码可直接复制运行企业级质量!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0/ title原生JS 企业级城市选择组件 | 省市区联动拼音检索/title style * { margin: 0; padding: 0; box-sizing: border-box; font-family: Microsoft YaHei, sans-serif; } body { background: #f5f7fa; padding: 50px 20px; } .container { max-width: 600px; margin: 0 auto; } .title { text-align: center; font-size: 24px; color: #2c3e50; margin-bottom: 30px; } /* 触发按钮 */ .city-trigger { width: 100%; padding: 12px 16px; border: 1px solid #e5e6eb; border-radius: 8px; background: #fff; font-size: 16px; color: #333; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } .city-trigger:hover { border-color: #409eff; } .trigger-icon { color: #999; transition: transform 0.2s; } .trigger-icon.active { transform: rotate(180deg); } /* 弹窗遮罩 */ .city-mask { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 999; display: none; } /* 弹窗容器 */ .city-modal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 800px; background: #fff; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 1000; display: none; } /* 移动端适配 */ media (max-width: 768px) { .city-modal { width: 100%; height: 80vh; top: auto; bottom: 0; left: 0; transform: none; border-radius: 12px 12px 0 0; } } /* 弹窗头部 */ .modal-header { padding: 16px; border-bottom: 1px solid #e5e6eb; display: flex; justify-content: space-between; align-items: center; } .modal-title { font-size: 18px; font-weight: 600; color: #2c3e50; } .modal-close { width: 32px; height: 32px; border: none; background: transparent; font-size: 20px; color: #999; cursor: pointer; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .modal-close:hover { background: #f5f7fa; color: #f56c6c; } /* 搜索框 */ .search-container { padding: 16px; border-bottom: 1px solid #e5e6eb; } .search-input { width: 100%; padding: 12px 16px; border: 1px solid #e5e6eb; border-radius: 8px; font-size: 14px; outline: none; } .search-input:focus { border-color: #409eff; box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2); } /* 内容区域 */ .modal-content { display: flex; height: 300px; overflow: hidden; } media (max-width: 768px) { .modal-content { height: calc(80vh - 120px); } } /* 选项列表 */ .city-list { flex: 1; overflow-y: auto; border-right: 1px solid #e5e6eb; } .city-list:last-child { border-right: none; } .list-title { padding: 12px 16px; font-size: 14px; color: #999; background: #f5f7fa; } .list-item { padding: 12px 16px; font-size: 14px; color: #333; cursor: pointer; transition: background 0.2s; } .list-item:hover, .list-item.active { background: #e8f4ff; color: #409eff; } .list-empty { padding: 20px; text-align: center; font-size: 14px; color: #999; } /* 热门城市 */ .hot-city { padding: 16px; border-bottom: 1px solid #e5e6eb; } .hot-title { font-size: 14px; color: #999; margin-bottom: 12px; } .hot-list { display: flex; flex-wrap: wrap; gap: 8px; } .hot-item { padding: 6px 12px; background: #f5f7fa; border-radius: 4px; font-size: 14px; color: #333; cursor: pointer; transition: all 0.2s; } .hot-item:hover { background: #e8f4ff; color: #409eff; } /* 最近选择 */ .recent-city { padding: 16px; } .recent-title { font-size: 14px; color: #999; margin-bottom: 12px; } .recent-list { display: flex; flex-wrap: wrap; gap: 8px; } .recent-item { padding: 6px 12px; background: #f5f7fa; border-radius: 4px; font-size: 14px; color: #333; cursor: pointer; transition: all 0.2s; } .recent-item:hover { background: #e8f4ff; color: #409eff; } /* 无结果提示 */ .no-result { padding: 40px; text-align: center; color: #999; font-size: 14px; } .no-result i { font-size: 32px; margin-bottom: 8px; display: block; } /style /head body div classcontainer h2 classtitle原生JS 企业级城市选择组件/h2 !-- 触发按钮 -- div classcity-trigger idcityTrigger span idselectedCity请选择城市/span span classtrigger-icon idtriggerIcon▼/span /div !-- 遮罩层 -- div classcity-mask idcityMask/div !-- 弹窗容器 -- div classcity-modal idcityModal div classmodal-header div classmodal-title选择城市/div button classmodal-close idmodalClose×/button /div !-- 搜索框 -- div classsearch-container input typetext classsearch-input idsearchInput placeholder输入城市名称或拼音首字母搜索 / /div !-- 最近选择 -- div classrecent-city idrecentCity div classrecent-title最近选择/div div classrecent-list idrecentList/div /div !-- 热门城市 -- div classhot-city div classhot-title热门城市/div div classhot-list idhotList/div /div !-- 省市区选择区域 -- div classmodal-content idmodalContent div classcity-list idprovinceList div classlist-title省份/div /div div classcity-list idcityList div classlist-title城市/div div classlist-empty请先选择省份/div /div div classcity-list iddistrictList div classlist-title区县/div div classlist-empty请先选择城市/div /div /div /div /div script // 省市区数据简化版实际项目可对接接口获取完整数据 const cityData [ { name: 北京市, pinyin: beijing, shortPinyin: bj, cities: [ { name: 北京市, pinyin: beijing, shortPinyin: bj, districts: [ { name: 东城区, pinyin: dongcheng, shortPinyin: dc }, { name: 西城区, pinyin: xicheng, shortPinyin: xc }, { name: 朝阳区, pinyin: chaoyang, shortPinyin: cy }, { name: 海淀区, pinyin: haidian, shortPinyin: hd } ] } ] }, { name: 上海市, pinyin: shanghai, shortPinyin: sh, cities: [ { name: 上海市, pinyin: shanghai, shortPinyin: sh, districts: [ { name: 黄浦区, pinyin: huangpu, shortPinyin: hp }, { name: 静安区, pinyin: jingan, shortPinyin: ja }, { name: 徐汇区, pinyin: xuhui, shortPinyin: xh }, { name: 长宁区, pinyin: changning, shortPinyin: cn } ] } ] }, { name: 广东省, pinyin: guangdong, shortPinyin: gd, cities: [ { name: 广州市, pinyin: guangzhou, shortPinyin: gz, districts: [ { name: 天河区, pinyin: tianhe, shortPinyin: th }, { name: 越秀区, pinyin: yuexiu, shortPinyin: yx }, { name: 海珠区, pinyin: haizhu, shortPinyin: hz } ] }, { name: 深圳市, pinyin: shenzhen, shortPinyin: sz, districts: [ { name: 南山区, pinyin: nanshan, shortPinyin: ns }, { name: 福田区, pinyin: futian, shortPinyin: ft }, { name: 宝安区, pinyin: baoan, shortPinyin: ba } ] }, { name: 佛山市, pinyin: foshan, shortPinyin: fs, districts: [ { name: 禅城区, pinyin: chancheng, shortPinyin: cc }, { name: 南海区, pinyin: nanhai, shortPinyin: nh } ] } ] }, { name: 江苏省, pinyin: jiangsu, shortPinyin: js, cities: [ { name: 南京市, pinyin: nanjing, shortPinyin: nj, districts: [ { name: 玄武区, pinyin: xuanwu, shortPinyin: xw }, { name: 秦淮区, pinyin: qinhuai, shortPinyin: qh } ] }, { name: 苏州市, pinyin: suzhou, shortPinyin: sz, districts: [ { name: 姑苏区, pinyin: gusu, shortPinyin: gs }, { name: 工业园区, pinyin: gongyeyuanqu, shortPinyin: gyyq } ] } ] }, { name: 浙江省, pinyin: zhejiang, shortPinyin: zj, cities: [ { name: 杭州市, pinyin: hangzhou, shortPinyin: hz, districts: [ { name: 西湖区, pinyin: xihu, shortPinyin: xh }, { name: 滨江区, pinyin: binjiang, shortPinyin: bj } ] }, { name: 宁波市, pinyin: ningbo, shortPinyin: nb, districts: [ { name: 鄞州区, pinyin: yinzhou, shortPinyin: yz }, { name: 海曙区, pinyin: haishu, shortPinyin: hs } ] } ] } ]; // 热门城市可自定义 const hotCities [ { name: 北京市, pinyin: beijing, shortPinyin: bj }, { name: 上海市, pinyin: shanghai, shortPinyin: sh }, { name: 广州市, pinyin: guangzhou, shortPinyin: gz }, { name: 深圳市, pinyin: shenzhen, shortPinyin: sz }, { name: 杭州市, pinyin: hangzhou, shortPinyin: hz }, { name: 南京市, pinyin: nanjing, shortPinyin: nj }, { name: 成都市, pinyin: chengdu, shortPinyin: cd }, { name: 重庆市, pinyin: chongqing, shortPinyin: cq }, { name: 武汉市, pinyin: wuhan, shortPinyin: wh }, { name: 西安市, pinyin: xian, shortPinyin: xa } ]; class CitySelector { constructor() { // 核心元素 this.elements { trigger: document.getElementById(cityTrigger), selectedCity: document.getElementById(selectedCity), triggerIcon: document.getElementById(triggerIcon), mask: document.getElementById(cityMask), modal: document.getElementById(cityModal), modalClose: document.getElementById(modalClose), searchInput: document.getElementById(searchInput), provinceList: document.getElementById(provinceList), cityList: document.getElementById(cityList), districtList: document.getElementById(districtList), hotList: document.getElementById(hotList), recentList: document.getElementById(recentList) }; // 组件状态 this.state { selectedProvince: null, // 选中的省份 selectedCity: null, // 选中的城市 selectedDistrict: null, // 选中的区县 recentCities: [], // 最近选择的城市 filteredData: [...cityData], // 过滤后的数据 isSearching: false // 是否处于搜索状态 }; // 初始化 this.init(); } // 初始化 init() { // 加载最近选择的城市 this.loadRecentCities(); // 渲染热门城市 this.renderHotCities(); // 渲染省份列表 this.renderProvinceList(); // 绑定事件 this.bindEvents(); } // 加载最近选择的城市本地存储 loadRecentCities() { const savedRecent localStorage.getItem(city_selector_recent); if (savedRecent) { this.state.recentCities JSON.parse(savedRecent); this.renderRecentCities(); } } // 保存最近选择的城市最多3个 saveRecentCity(cityInfo) { // 去重如果已存在移到最前面 this.state.recentCities this.state.recentCities.filter( item item.name ! cityInfo.name ); // 添加到最前面 this.state.recentCities.unshift(cityInfo); // 保留最多3个 if (this.state.recentCities.length 3) { this.state.recentCities.pop(); } // 保存到本地存储 localStorage.setItem(city_selector_recent, JSON.stringify(this.state.recentCities)); // 重新渲染最近选择 this.renderRecentCities(); } // 渲染最近选择的城市 renderRecentCities() { if (this.state.recentCities.length 0) { this.elements.recentList.innerHTML div classlist-empty stylepadding: 6px 12px;暂无最近选择/div; return; } let html ; this.state.recentCities.forEach(city { html div classrecent-item data-name${city.name} data-pinyin${city.pinyin} data-short-pinyin${city.shortPinyin}${city.name}/div; }); this.elements.recentList.innerHTML html; } // 渲染热门城市 renderHotCities() { let html ; hotCities.forEach(city { html div classhot-item data-name${city.name} data-pinyin${city.pinyin} data-short-pinyin${city.shortPinyin}${city.name}/div; }); this.elements.hotList.innerHTML html; } // 渲染省份列表 renderProvinceList() { let html div classlist-title省份/div; if (this.state.filteredData.length 0) { html div classlist-empty无匹配省份/div; this.elements.provinceList.innerHTML html; return; } this.state.filteredData.forEach(province { html div classlist-item ${this.state.selectedProvince?.name province.name ? active : } data-name${province.name} data-pinyin${province.pinyin} data-short-pinyin${province.shortPinyin}${province.name}/div; }); this.elements.provinceList.innerHTML html; } // 渲染城市列表 renderCityList(cities []) { let html div classlist-title城市/div; if (cities.length 0) { html div classlist-empty无匹配城市/div; this.elements.cityList.innerHTML html; // 清空区县列表 this.renderDistrictList([]); return; } cities.forEach(city { html div classlist-item ${this.state.selectedCity?.name city.name ? active : } data-name${city.name} data-pinyin${city.pinyin} data-short-pinyin${city.shortPinyin}${city.name}/div; }); this.elements.cityList.innerHTML html; } // 渲染区县列表 renderDistrictList(districts []) { let html div classlist-title区县/div; if (districts.length 0) { html div classlist-empty无匹配区县/div; this.elements.districtList.innerHTML html; return; } districts.forEach(district { html div classlist-item ${this.state.selectedDistrict?.name district.name ? active : } data-name${district.name} data-pinyin${district.pinyin} data-short-pinyin${district.shortPinyin}${district.name}/div; }); this.elements.districtList.innerHTML html; } // 搜索过滤中文拼音首字母 searchCity(keyword) { keyword keyword.trim().toLowerCase(); if (!keyword) { // 无关键词恢复原始数据 this.state.filteredData [...cityData]; this.state.isSearching false; this.renderProvinceList(); // 重置选中状态 this.state.selectedProvince null; this.state.selectedCity null; this.state.selectedDistrict null; this.renderCityList([]); return; } this.state.isSearching true; // 过滤省份匹配名称、全拼、首字母 const filteredProvinces cityData.filter(province { return ( province.name.toLowerCase().includes(keyword) || province.pinyin.includes(keyword) || province.shortPinyin.includes(keyword) ); }); // 进一步过滤城市和区县确保至少有一个匹配项 const finalFiltered filteredProvinces.map(province { const filteredCities province.cities.filter(city { const cityMatch ( city.name.toLowerCase().includes(keyword) || city.pinyin.includes(keyword) || city.shortPinyin.includes(keyword) ); if (cityMatch) return true; // 检查区县是否匹配 const districtMatch city.districts.some(district { return ( district.name.toLowerCase().includes(keyword) || district.pinyin.includes(keyword) || district.shortPinyin.includes(keyword) ); }); return districtMatch; }); // 过滤区县仅保留匹配的区县 const citiesWithFilteredDistricts filteredCities.map(city { const filteredDistricts city.districts.filter(district { return ( district.name.toLowerCase().includes(keyword) || district.pinyin.includes(keyword) || district.shortPinyin.includes(keyword) ); }); return { ...city, districts: filteredDistricts }; }); return { ...province, cities: citiesWithFilteredDistricts }; }).filter(province province.cities.length 0); this.state.filteredData finalFiltered; this.renderProvinceList(); // 如果只有一个省份自动选中并加载城市 if (finalFiltered.length 1) { this.state.selectedProvince finalFiltered[0]; this.renderProvinceList(); // 重新渲染选中状态 this.renderCityList(finalFiltered[0].cities); // 如果只有一个城市自动选中并加载区县 if (finalFiltered[0].cities.length 1) { this.state.selectedCity finalFiltered[0].cities[0]; this.renderCityList(finalFiltered[0].cities); // 重新渲染选中状态 this.renderDistrictList(finalFiltered[0].cities[0].districts); } } else { // 多个省份清空城市和区县 this.state.selectedCity null; this.state.selectedDistrict null; this.renderCityList([]); } } // 选中城市确认选择 selectCity(cityInfo) { // 回填选中城市 this.elements.selectedCity.textContent cityInfo.name; // 保存到最近选择 this.saveRecentCity(cityInfo); // 关闭弹窗 this.closeModal(); // 重置搜索状态 this.elements.searchInput.value ; this.state.isSearching false; this.state.filteredData [...cityData]; // 重置选中状态 this.state.selectedProvince null; this.state.selectedCity null; this.state.selectedDistrict null; this.renderProvinceList(); this.renderCityList([]); } // 打开弹窗 openModal() { this.elements.mask.style.display block; this.elements.modal.style.display block; this.elements.triggerIcon.classList.add(active); // 聚焦搜索框 this.elements.searchInput.focus(); } // 关闭弹窗 closeModal() { this.elements.mask.style.display none; this.elements.modal.style.display none; this.elements.triggerIcon.classList.remove(active); } // 绑定事件 bindEvents() { // 打开弹窗 this.elements.trigger.addEventListener(click, () { this.openModal(); }); // 关闭弹窗 this.elements.modalClose.addEventListener(click, () { this.closeModal(); }); this.elements.mask.addEventListener(click, () { this.closeModal(); }); // 搜索框输入事件 this.elements.searchInput.addEventListener(input, (e) { const keyword e.target.value; this.searchCity(keyword); }); // 省份选择事件事件委托 this.elements.provinceList.addEventListener(click, (e) { const item e.target.closest(.list-item); if (!item) return; const provinceName item.dataset.name; // 找到选中的省份 const selectedProvince this.state.filteredData.find( province province.name provinceName ); if (!selectedProvince) return; // 更新选中状态 this.state.selectedProvince selectedProvince; this.state.selectedCity null; this.state.selectedDistrict null; // 重新渲染省份、城市列表 this.renderProvinceList(); this.renderCityList(selectedProvince.cities); }); // 城市选择事件事件委托 this.elements.cityList.addEventListener(click, (e) { const item e.target.closest(.list-item); if (!item) return; const cityName item.dataset.name; // 找到选中的城市 const selectedCity this.state.selectedProvince.cities.find( city city.name cityName ); if (!selectedCity) return; // 更新选中状态 this.state.selectedCity selectedCity; this.state.selectedDistrict null; // 重新渲染城市、区县列表 this.renderCityList(this.state.selectedProvince.cities); this.renderDistrictList(selectedCity.districts); }); // 区县选择事件事件委托 this.elements.districtList.addEventListener(click, (e) { const item e.target.closest(.list-item); if (!item) return; // 区县选择后确认选择区县为最终选择项 const cityInfo { name: item.dataset.name, pinyin: item.dataset.pinyin, shortPinyin: item.dataset.shortPinyin }; this.selectCity(cityInfo); }); // 热门城市选择事件事件委托 this.elements.hotList.addEventListener(click, (e) { const item e.target.closest(.hot-item); if (!item) return; const cityInfo { name: item.dataset.name, pinyin: item.dataset.pinyin, shortPinyin: item.dataset.shortPinyin }; this.selectCity(cityInfo); }); // 最近选择城市事件事件委托 this.elements.recentList.addEventListener(click, (e) { const item e.target.closest(.recent-item); if (!item) return; const cityInfo { name: item.dataset.name, pinyin: item.dataset.pinyin, shortPinyin: item.dataset.shortPinyin }; this.selectCity(cityInfo); }); } } // 初始化城市选择组件 window.addEventListener(DOMContentLoaded, () { new CitySelector(); }); /script /body /html核心实现思路1. 基础架构设计采用「触发按钮弹窗遮罩弹窗主体」的三层结构弹窗内部拆分「搜索区最近选择区热门城市区省市区联动区」分层清晰、低耦合便于后期扩展和维护。核心设计亮点将省市区数据与渲染逻辑解耦、选中状态集中管理支持本地存储记忆兼顾用户体验和工程化规范。2. 核心功能实现企业级重点1省市区三级联动基于提前定义的省市区数据实际项目可对接后端接口实现「省份→城市→区县」的逐级联动选择上级自动加载下级数据。边界处理无匹配数据时显示“无数据”提示避免用户无感知选中上级后自动重置下级选中状态保证数据一致性。事件委托通过事件委托绑定省/市/区选择事件减少事件绑定数量提升性能尤其数据量大时。2双重检索功能核心亮点中文检索匹配省/市/区名称的关键词实时过滤结果如输入“北京”过滤出北京市及下属区县。拼音检索支持全拼和首字母检索如输入“bj”或“beijing”均能匹配北京适配用户快速输入习惯。检索优化无关键词时恢复原始数据检索结果自动联动如检索“深圳”自动选中广东省、深圳市加载深圳下属区县。3最近选择记忆基于localStorage实现最近选择城市的持久化存储最多保留3个最近选择避免用户重复筛选。去重逻辑如果重复选择同一城市自动将其移到最近选择列表的最前面保证列表的实用性。空状态处理无最近选择时显示友好提示提升用户体验。4交互体验优化弹窗控制点击触发按钮打开弹窗点击关闭按钮/空白遮罩关闭弹窗配合图标旋转动效视觉反馈清晰。选中反馈选中的省/市/区添加高亮样式hover状态有背景色变化用户可清晰感知当前选中项。响应式适配PC端弹窗居中移动端全屏弹窗适配触摸操作保证多端体验一致。企业级扩展方向可直接加到文章中提升深度数据优化对接后端接口获取完整省市区数据含港澳台支持数据缓存减少接口请求添加数据加载中状态避免白屏。功能增强支持“只选省份”“只选城市”模式适配不同业务场景如物流只需要省份外卖需要区县。添加城市拼音全量检索如输入“guangzhou”匹配广州市支持模糊匹配。集成城市定位功能基于IP或GPS自动推荐当前城市。样式定制支持自定义主题色、弹窗大小、字体大小提供配置项适配不同项目的UI风格。性能优化省市区数据分片加载避免大数据量一次性渲染导致的卡顿。搜索防抖处理避免高频输入导致的频繁过滤提升性能。集成扩展支持模块化引入适配Vue/React项目添加回调函数选中城市后可触发自定义逻辑如表单提交、地址渲染。适用场景贴合企业实际需求电商平台收货地址选择、配送范围筛选。外卖/生鲜APP定位城市、选择配送区域。注册/登录表单用户所在地选择用于身份验证或地域统计。物流系统出发地/目的地选择适配物流配送范围。本地生活平台城市切换展示对应城市的服务内容。核心知识点总结适配CSDN读者学习需求原生JS事件委托减少事件绑定数量提升组件性能尤其适用于列表类组件。localStorage本地存储实现用户偏好记忆提升用户体验掌握持久化存储的核心用法。数据过滤与检索正则匹配、字符串处理实现中文拼音双重检索掌握检索算法的基础逻辑。三级联动逻辑数据关联与状态管理掌握组件化开发中“数据-视图”联动的核心思路。响应式布局媒体查询适配不同屏幕尺寸掌握移动端与PC端交互差异的适配方法。组件化思维将功能拆分为独立方法渲染、事件绑定、数据处理提升代码可维护性和可扩展性。

更多文章