二級聯動下拉選單是 Web 表單常見需求:選擇「省份」後動態載入對應的「城市」。本文提供基於 Java Web (JSP + Servlet + AJAX) 的完整實作,包含前端邏輯、後端介面、資料結構設計,並說明現代化改造方向。
┌─────────────┐ HTTP GET ┌─────────────┐
│ 瀏覽器 │ ───────────────▶ │ Tomcat │
│ (JSP/JS) │ ?provinceId=1 │ (Servlet) │
│ │ ◀─────────────── │ │
└─────────────┘ XML/JSON └─────────────┘
▲ │
│ 城市列表 │
└──────────────────────────────────┘
public class Region {
private String id;
private String name;
private String parentId;
// 建構子、Getter/Setter、toString()
}
@Component
public class RegionService {
private final Map<String, List<Region>> childrenCache = new ConcurrentHashMap<>();
private final Map<String, Region> allRegions = new ConcurrentHashMap<>();
public RegionService() {
initMockData();
}
private void initMockData() {
// 省份
addRegion("1", "北京市", "0");
addRegion("2", "天津市", "0");
addRegion("3", "河北省", "0");
addRegion("4", "山西省", "0");
// 城市
addRegion("101", "北京市", "1");
addRegion("102", "天津市", "2");
addRegion("103", "石家莊市", "3");
addRegion("104", "唐山市", "3");
addRegion("105", "秦皇島市", "3");
addRegion("106", "太原市", "4");
addRegion("107", "大同市", "4");
}
private void addRegion(String id, String name, String parentId) {
Region r = new Region(id, name, parentId);
allRegions.put(id, r);
childrenCache.computeIfAbsent(parentId, k -> new ArrayList<>()).add(r);
}
public List<Region> getProvinces() {
return childrenCache.getOrDefault("0", Collections.emptyList());
}
public List<Region> getCitiesByProvince(String provinceId) {
return childrenCache.getOrDefault(provinceId, Collections.emptyList());
}
}
@WebServlet("/api/regions")
public class RegionServlet extends HttpServlet {
private final RegionService regionService = new RegionService();
private final ObjectMapper mapper = new ObjectMapper();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String parentId = req.getParameter("parentId");
if (parentId == null || parentId.isBlank()) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "缺少 parentId 參數");
return;
}
List<Region> regions;
if ("0".equals(parentId)) {
regions = regionService.getProvinces();
} else {
regions = regionService.getCitiesByProvince(parentId);
}
resp.setContentType("application/json;charset=UTF-8");
resp.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
mapper.writeValue(resp.getWriter(), regions);
}
}
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>二級聯動下拉選單示例</title>
<style>
.form-group { margin-bottom: 1rem; }
label { display: inline-block; width: 80px; font-weight: bold; }
select { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; min-width: 200px; }
.loading { color: #666; font-style: italic; }
.error { color: red; }
</style>
</head>
<body>
<div class="container" style="max-width: 600px; margin: 2rem auto; padding: 1rem;">
<h2>地址選擇 - 二級聯動示例</h2>
<div class="form-group">
<label>省份:</label>
<select id="provinceSelect">
<option value="">-- 請選擇省份 --</option>
</select>
</div>
<div class="form-group">
<label>城市:</label>
<select id="citySelect" disabled>
<option value="">-- 請先選擇省份 --</option>
</select>
<span id="cityStatus" class="loading"></span>
</div>
<div class="form-group">
<label>結果:</label>
<div id="result">尚未選擇</div>
</div>
</div>
<script>
// 配置
const API_BASE = '/api/regions';
// DOM 元素
const provinceSelect = document.getElementById('provinceSelect');
const citySelect = document.getElementById('citySelect');
const cityStatus = document.getElementById('cityStatus');
const resultDiv = document.getElementById('result');
// 頁面載入:獲取省份列表
document.addEventListener('DOMContentLoaded', loadProvinces);
async function loadProvinces() {
try {
const response = await fetch(`${API_BASE}?parentId=0`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const provinces = await response.json();
renderOptions(provinceSelect, provinces, '請選擇省份');
} catch (e) {
showError('載入省份失敗: ' + e.message);
}
}
// 省份變更事件
provinceSelect.addEventListener('change', async function() {
const provinceId = this.value;
citySelect.disabled = true;
citySelect.innerHTML = '<option value="">-- 請選擇城市 --</option>';
cityStatus.textContent = '';
resultDiv.textContent = '尚未選擇';
if (!provinceId) return;
cityStatus.textContent = '載入中...';
cityStatus.className = 'loading';
try {
const response = await fetch(`${API_BASE}?parentId=${encodeURIComponent(provinceId)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const cities = await response.json();
if (cities.length === 0) {
cityStatus.textContent = '該省份暫無城市資料';
cityStatus.className = 'error';
} else {
renderOptions(citySelect, cities, '請選擇城市');
citySelect.disabled = false;
cityStatus.textContent = '';
}
} catch (e) {
cityStatus.textContent = '載入失敗: ' + e.message;
cityStatus.className = 'error';
}
});
// 城市變更事件
citySelect.addEventListener('change', function() {
const provinceText = provinceSelect.options[provinceSelect.selectedIndex].text;
const cityText = this.options[this.selectedIndex].text;
if (this.value) {
resultDiv.textContent = `已選擇: ${provinceText} - ${cityText} (ID: ${this.value})`;
} else {
resultDiv.textContent = '尚未選擇城市';
}
});
// 工具函數
function renderOptions(selectEl, data, placeholder) {
selectEl.innerHTML = ``;
data.forEach(item => {
const opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.name;
selectEl.appendChild(opt);
});
}
function showError(msg) {
console.error(msg);
alert(msg);
}
</script>
</body>
</html>
// Controller
@RestController
@RequestMapping("/api/regions")
public class RegionController {
@GetMapping
public List<Region> getRegions(@RequestParam String parentId) {
return regionService.getChildren(parentId);
}
}
// Vue 3 組件
<script setup>
import { ref, onMounted } from 'vue'
const provinces = ref([])
const cities = ref([])
const selectedProvince = ref('')
const selectedCity = ref('')
async function loadProvinces() {
const res = await fetch('/api/regions?parentId=0')
provinces.value = await res.json()
}
async function loadCities() {
if (!selectedProvince.value) {
cities.value = []
return
}
const res = await fetch(`/api/regions?parentId=${selectedProvince.value}`)
cities.value = await res.json()
}
onMounted(loadProvinces)
</script>
<template>
<select v-model="selectedProvince" @change="loadCities">
<option value="">請選擇省份</option>
<option v-for="p in provinces" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<select v-model="selectedCity" :disabled="!cities.length">
<option value="">請選擇城市</option>
<option v-for="c in cities" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</template>
// regions.json - 建構時打包
{
"provinces": [
{"id": "1", "name": "北京市", "cities": [
{"id": "101", "name": "北京市"}
]},
{"id": "3", "name": "河北省", "cities": [
{"id": "103", "name": "石家莊市"},
{"id": "104", "name": "唐山市"}
]}
]
}
// 純前端邏輯,零後端請求
const data = await import('./regions.json')
const provinces = data.provinces
function onProvinceChange(provinceId) {
const province = provinces.find(p => p.id === provinceId)
cities.value = province?.cities || []
}
CREATE TABLE sys_region (
id VARCHAR(32) PRIMARY KEY COMMENT '區劃代碼',
name VARCHAR(100) NOT NULL COMMENT '名稱',
parent_id VARCHAR(32) NOT NULL COMMENT '父級ID,頂級為 0',
level TINYINT NOT NULL COMMENT '層級:1省 2市 3區/縣',
sort_order INT DEFAULT 0 COMMENT '排序',
status TINYINT DEFAULT 1 COMMENT '狀態:1啟用 0停用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_parent (parent_id),
INDEX idx_level (level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='行政區劃表';
| 功能 | 實作要點 |
|---|---|
| 三級/多級聯動 | 遞歸獲取子級,前端動態生成 Select |
| 搜尋/過濾 | Select2、Choices.js 或自帶 filter 屬性 |
| 懶加載 | 展開時才請求下一級,減少首屏請求 |
| 關鍵字聯想 | 輸入時模糊匹配,下拉顯示建議 |
| 多選支援 | select multiple + 標籤顯示 |
| 資料快取 | Redis 快取熱門省份,本地 localStorage 快取 |
| 國際化 | i18n 資源檔,支援中英多語言 |