从静态到动态:Leaflet 行政区划组件的工程化改进之路
一、前言
1.1 工程化改造的必要性
在做地图相关的项目时,行政区划选择是一个绕不开的需求。最开始我直接用了一个简单粗暴的方案:把所有省市区数据硬编码在 JS 文件里,前端一次性加载。功能倒是能跑,但随着需求迭代,问题一个接一个地冒了出来。在之前的博客中,我们曾经简单的介绍了一下基本的支持行政区划筛选的功能,原文地址:Axure 原型落地 HTML:基于 Trae Solo 实现 GIS 多级行政区划切换全流程实践。感兴趣的朋友可以点开链接参考。虽然基本的功能实现了,但代码耦合严重是最直观的感受------HTML、CSS、JS 全挤在一个文件里,后期维护很头疼,新人接手时找一个样式要翻几百行代码。更深层的矛盾在于数据的静态化:更新行政区划等于重新发版,不同客户需要不同的数据范围却做不到按需裁剪,首屏加载的 JS 文件越来越重,里面大部分数据用户可能根本用不到。这些问题让我意识到,这个组件需要从"脚本小子"式的写法,转向真正的工程化结构。拆分代码、分离数据、引入后台服务,每一步都是为了把"能跑的 demo"变成"可维护的系统"。

1.2 支持全球切换的需求
工程化改造的直接驱动力来自一个具体的功能需求:支持全球国家切换。原来的组件只覆盖中国,层级从 0 到 3(国/省/市/区县)。现在产品要求用户能在地图上浏览全球,从大洲到国家,再到省市区县逐级下钻。这意味着数据量从几十条省份暴增到几百个国家,层级体系也需要重新设计------在"国"之上还要加"全球"和"大洲"两层。同时,国家列表多了之后,翻找变得困难,搜索功能也必须跟上。这些需求叠加在一起,让原来的单文件方案彻底撑不住了。

二、原始方案介绍
2.1 最初方案介绍
最早的功能很简单:基于 Leaflet 地图,在右上角放一个可展开的悬浮面板,展示中国各省市区。点击面板里的行政区划标签,地图自动飞行定位到对应坐标。数据直接写在前端 JS 的 regionData 对象里。每个区划包含四个字段:
level:层级编号,1=省,2=市,3=区县code:行政区划代码,如'110000'代表北京latlng:中心坐标数组[纬度, 经度]children:下级行政区划名称数组
面板的交互逻辑也很直接:顶部面包屑显示当前路径,下方列表展示当前层级的所有下级区划。点击某个区划,把它加到路径里,列表刷新为它的下级。点击面包屑中的某个节点,路径回退到该层级,列表同步更新。

整个组件用一个 HTML 文件搞定,内嵌 <style> 写样式,内嵌 <script> 写逻辑,地图底图用的 高德在线影像的在线瓦片。
2.2 存在的痛点
这种方案在 demo 阶段完全没问题,但放到真实项目里很快就暴露了短板。
首屏加载越来越重。中国的省市区数据已经不小,后来产品要求支持全球国家列表,数据量直接翻了几倍。用户打开页面就要下载一个几百 KB 的 JS 文件,里面大部分数据可能根本用不到。
更新数据等于重新发版。行政区划调整是常态------新设街道、合并区县、国家更名。每次变动都要改代码、打包、上线,运维成本很高。
无法做多场景适配。不同客户可能需要不同的数据范围,比如有的只看国内,有的需要亚太区。静态数据做不到按需裁剪。
代码耦合严重。HTML、CSS、JS 全挤在一个文件里,后期维护很头疼。新人接手时,找一个样式要翻几百行代码。
三、工程化改造实践
3.1 第一步:代码拆分,各司其职
最基础的改进是把代码拆成三个独立文件。这个改动看似简单,但对后续的可维护性影响很大。
文件结构
├── global.html # 纯 HTML 结构,无样式无逻辑
├── css/global.css # 全部视觉样式
└── js/global.js # 全部业务逻辑
HTML 只负责骨架
global.html 里只剩 DOM 结构和外部资源引用。所有 style 标签和 script 标签里的业务代码全部移走。这样改的好处是:设计师改样式不用碰 JS,后端改接口不用碰 HTML,各层之间不会互相踩脚。

CSS 按功能模块组织
global.css 里用注释把样式分成几个清晰的区块:CSS 变量定义、全局重置、地图容器、悬浮按钮、行政区划面板、面包屑导航、搜索框、区划列表、加载动画、空状态。每个区块内部保持高内聚。比如面包屑相关的 .breadcrumb-bar、.breadcrumb-item、.breadcrumb-separator 放在一起,改导航样式时一目了然。global.css文件的代码大概有379行。
JS 添加详细注解
global.js 是改动最大的部分。我把代码按职责拆成十大模块,每个函数都加上 JSDoc 注解:
- 数据层 :
regionData的结构说明、扩展方式 - 状态管理 :
currentPath、isPanelOpen、currentSearch的用途 - 地图初始化:Leaflet 配置、高德瓦片参数
- 数据访问函数 :
getRegionData、getChildren、getLevel、getLatLng - UI 渲染函数 :
renderBreadcrumb、renderRegionList、updateMap - 面板交互 :
togglePanel、openPanel、closePanel - 事件绑定:搜索输入、按钮点击、外部点击关闭
- 动态扩展 API :
extendRegionData、addRegionChild
注解里明确写了参数类型、返回值类型、缓存机制、错误处理逻辑。后来的人看代码,不用从头读到尾就能知道一个函数是干什么的。

javascript
/**
* 渲染层级展示条(面包屑导航)
*
* 功能说明:
* 根据 currentPath 数组动态生成面包屑导航,每个节点可点击,
* 点击后回退到对应层级,同时清空搜索关键词。
*
* 视觉效果:
* - 最后一个节点为激活状态(蓝色背景)
* - 节点之间用 "/" 分隔
* - 悬停时显示层级提示
*/
function renderBreadcrumb() {
const $bar = $('#breadcrumbBar').empty();
currentPath.forEach((name, index) => {
const level = getLevel(name);
const levelName = levelNames[level] || '';
const isLast = index === currentPath.length - 1;
// 创建面包屑节点
const $item = $('<span>')
.addClass('breadcrumb-item')
.toggleClass('active', isLast)
.text(name)
.attr('data-index', index)
.attr('title', `层级: ${levelName}`);
// 绑定点击事件:回退到该层级
$item.on('click', function() {
const clickedIndex = parseInt($(this).attr('data-index'));
// 截断路径到点击的索引位置
currentPath = currentPath.slice(0, clickedIndex + 1);
// 清空搜索状态
currentSearch = '';
$('#searchInput').val('');
// 重新渲染所有组件
renderBreadcrumb();
renderRegionList();
updateMap();
});
$bar.append($item);
// 添加分隔符(最后一个节点后不加)
if (!isLast) {
$bar.append($('<span>').addClass('breadcrumb-separator').text('/'));
}
});
}
3.2 从国内扩展到全球
代码结构理顺之后,开始处理数据范围的扩展。产品要求支持全球国家列表,同时保留中国省市区县的下钻能力。
层级重新设计
原来的层级只有 0~3(国/省/市/区县),不够用。我把它扩展为 -1~4:
| 层级编号 | 名称 | 示例 |
|---|---|---|
| -1 | 全球 | 全球 |
| 0 | 大洲 | 亚洲、欧洲 |
| 1 | 国家 | 中国、美国 |
| 2 | 省/州 | 广东省、加利福尼亚州 |
| 3 | 市 | 广州市、洛杉矶市 |
| 4 | 区/县 | 荔湾区、橙县 |
levelNames 映射表同步更新,面包屑和地图弹窗里的层级名称能正确显示。
数据填充策略
全球 200 多个国家和地区,不可能每个都配齐到区县级别。我的做法是:
- 全球 + 六大洲:完整数据,作为一级导航
- 主要国家:中、美、英、法、德、俄、日、韩、印、澳、加、巴西、埃及、南非等,配到省/州一级
- 中国:完整 34 个省级行政区,重点城市配到区县一级
- 其他国家:只列国家名,下级留空或配几个主要城市作为示例
这样既满足了"能看到全球"的需求,又不会让数据文件失控。后续可以通过后台接口逐步补充其他国家的详细数据。

搜索功能
国家多了之后,翻找变得困难。我在面板里加了一个搜索框,监听 input 事件,实时对当前层级的 children 数组做过滤。

搜索逻辑很简单:把输入关键词转小写,遍历 children 看 name.toLowerCase().includes(keyword) 是否成立。匹配结果显示在列表里,不匹配的空状态提示"未找到匹配的行政区划"。
一个细节:点击某个区划进入下级后,搜索框会自动清空。这样用户不会带着上一级的搜索词去过滤下一级。
3.3 地图联动优化
全球视角下,地图的初始中心和缩放级别需要调整。原来中国版的初始中心是 [35.86, 104.19],缩放 4 级。全球版改成 [20, 0],缩放 2 级,这样打开页面能看到整个世界地图。
每个层级对应的缩放级别也重新校准:
javascript
const zoomLevels = { '-1': 2, 0: 3, 1: 4, 2: 7, 3: 10, 4: 12 };
用户从"全球"点进"亚洲",地图从 2 级缩到 3 级;再点进"中国",缩到 4 级;点进"广东省",飞到 7 级。每次切换都有平滑的飞行动画(map.flyTo,持续 1.2 秒),体验比较自然。
标记点(Marker)的处理也做了优化。每次切换区划时,先移除旧标记,再在新位置添加标记并打开弹窗,弹窗里显示区划名称和层级。
四、后台动态升级
4.1 后台动态加载方案
代码结构和数据范围都理顺之后,最后一个硬骨头是:如何把静态数据改成从后台动态获取。
静态数据方案有几个绕不开的硬伤:数据量继续增长的话 JS 文件会越来越大;产品要求支持多语言、多数据源切换;需要对接民政部的标准区划代码定期同步更新;不同客户的数据权限不同,有的只能看某些国家。这些需求靠前端静态数据都解决不了,必须上后台服务。

接口设计思路
我设计的原则是"按需分层拉取,绝不一次性返回全量树"。
如果接口返回完整的全球区划树,数据量可能达到数 MB,前端解析也要时间。正确的做法是:用户点开面板时,只加载第一级(全球 → 六大洲);点击"亚洲"时,再发请求加载亚洲的国家列表;点击"中国"时,再加载中国的省份。
接口清单:
| 接口 | 用途 |
|---|---|
GET /api/regions/{name}/children |
获取某区划的下级列表 |
GET /api/regions/{name} |
获取某区划的详情(含坐标) |
GET /api/regions/search?q= |
搜索区划 |
响应数据结构:
json
{
"name": "中国",
"level": 1,
"code": "CN",
"latlng": [35.8617, 104.1954],
"children": [
{ "name": "北京市", "level": 2, "code": "110000" },
{ "name": "上海市", "level": 2, "code": "310000" }
]
}
注意 children 里只返回元数据(名称、层级、代码),不递归返回每个子级的完整子树。这是控制响应体积的关键。
前端改造要点
前端需要把同步的数据访问改成异步。核心改动有三处:
1. 数据缓存池
用 regionCache 对象替代原来的 regionData。已经加载过的区划数据存在内存里,避免重复请求。
javascript
const regionCache = {};
async function fetchChildren(name) {
if (regionCache[name]?.children) {
return Promise.resolve(regionCache[name].children);
}
const res = await fetch(`/api/regions/${encodeURIComponent(name)}/children`);
const data = await res.json();
regionCache[name] = data;
return data.children;
}
2. 渲染函数异步化
renderRegionList 改成 async,先显示加载动画,等数据返回再渲染 Chip 标签。
javascript
async function renderRegionList() {
showLoading();
try {
const children = await fetchChildren(currentPath.at(-1));
// 渲染逻辑...
} catch (e) {
$list.html('<div class="empty-state">数据加载失败</div>');
} finally {
hideLoading();
}
}
3. 坐标获取异步化
updateMap 里调用 fetchLatLng,优先读缓存,未命中时请求详情接口。
后台实现(Spring Boot)
我们团队的技术栈是 Java,所以后台用 Spring Boot + Spring Data JPA + MySQL 实现。
实体类:
java
@Entity
@Table(name = "regions", indexes = {
@Index(name = "idx_parent", columnList = "parentName"),
@Index(name = "idx_level", columnList = "level"),
@Index(name = "idx_name", columnList = "name")
})
public class Region {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Integer level;
private String code;
private String parentName;
private BigDecimal lat;
private BigDecimal lng;
}
Repository 接口:
java
public interface RegionRepository extends JpaRepository<Region, Long> {
Optional<Region> findByName(String name);
List<Region> findByParentName(String parentName);
List<Region> findByNameContaining(String keyword);
}
Spring Data JPA 的派生查询方法很省心,不用手写 SQL 就能实现按名称查、按父级查、模糊搜索。
Service 层:
java
@Service
@RequiredArgsConstructor
public class RegionService {
private final RegionRepository regionRepository;
@Transactional(readOnly = true)
public RegionResponse getRegionWithChildren(String name) {
Region region = regionRepository.findByName(name)
.orElseThrow(() -> new RegionNotFoundException("区划不存在: " + name));
List<Region> children = regionRepository.findByParentName(name);
// 组装 DTO...
return response;
}
}
Controller 层:
java
@RestController
@RequestMapping("/api/regions")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class RegionController {
private final RegionService regionService;
@GetMapping("/{name}/children")
public ResponseEntity<RegionResponse> getChildren(@PathVariable String name) {
return ResponseEntity.ok(regionService.getRegionWithChildren(name));
}
@GetMapping("/search")
public ResponseEntity<List<Region>> search(@RequestParam String q) {
return ResponseEntity.ok(regionService.searchRegions(q));
}
}
全局异常处理用 @RestControllerAdvice 统一封装,404 返回 {error: "区划不存在"},500 返回 {error: "服务器内部错误"},避免堆栈信息泄露到前端。
4.2 性能优化策略
动态加载引入了网络请求,必须做缓存来保体验。
前端内存缓存 :regionCache 对象存已经加载过的区划,回退面包屑时直接从缓存读,不重复请求。
HTTP 缓存 :接口响应头加 Cache-Control: max-age=3600,浏览器层缓存一小时。区划数据变动不频繁,这个时间是安全的。
CDN 加速:如果数据量进一步增大,可以把区划数据部署到 CDN,就近访问降低延迟。
预加载:预测用户行为,鼠标悬停在某个区划 Chip 上时,提前发起请求加载其下级数据。用户真正点击时,数据已经准备好了。
降级方案 :如果后台服务不可用,前端自动降级为本地静态数据,保证基础功能不挂。这个可以用 fetch 的 catch 分支实现,请求失败时读一个内置的精简版 regionData。
五、总结
这个组件的改进过程,本质上是从"写一段能跑的代码"到"设计一个可维护的系统"的转变。代码拆分 解决了可读性和协作问题,CSS、JS、HTML 各管一摊。全球扩展 验证了层级设计的可扩展性,-1~4 的编号方案能覆盖从全球到区县的所有场景。后台动态加载把数据从代码里解放出来,为后续的多数据源、多语言、权限控制打下了基础。
几个值得记住的经验:
- 区划数据一定要分层拉取,全量树是性能杀手
- 前端缓存不是可选项,是必选项
- 接口设计时控制返回粒度,只给前端需要的数据
- 降级方案要在设计阶段就考虑,不要等到线上挂了再补
行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激。