最稳写法是原生select+AJAX,避免重复请求、缓存失效和DOM冲突;后端用三张表分层存储并Redis缓存,前端用事件委托绑定change并加防抖,严格对齐pid/level/_token参数。

ThinkPHP 中用 select + ajax 实现三级联动最稳的写法
直接用原生 select 标签配合 AJAX 加载,不依赖任何插件或第三方组件,兼容 ThinkPHP 6 和 ThinkPHP 8(含多应用模式)。关键不是“怎么渲染”,而是“怎么避免重复请求、缓存失效和 DOM 冲突”。
常见错误现象:change 事件反复绑定导致多次触发;省市区数据每次请求都全量返回,前端手动遍历过滤;province_id 传错字段名后端收不到,返回空数组但前端没报错。
- 后端统一用
json返回扁平数组,结构固定为[{'id':1,'name':'北京','pid':0},...],pid为上级 ID(0 表示省级) - 前端用
data-pid存储当前级联关系,避免用val()猜层级 - 每次切换前先清空下级
select的disabled状态和option,再发请求,防止残留旧选项 - 对市级/区级请求加防抖(
setTimeout+clearTimeout),避免快速切换省份时发一堆无效请求
省市区数据怎么存、怎么查才不卡
三张表(region_province、region_city、region_district)比单表自关联更易维护,尤其当你要支持港澳台、海外城市等特殊行政区划时。单表虽然看着“简洁”,但 where pid = ? 在百万级数据下会明显变慢,且无法对每级建独立索引。
实际使用中,建议把省级数据全量缓存进 Redis(key 为 region:province),城市/区级按父 ID 缓存(如 region:city:11),过期时间设为 1 天 —— 行政区划极少变更,没必要实时查库。
立即学习“PHP免费学习笔记(深入)”;
- ThinkPHP 6 查询示例:
Db::name('region_province')->field('id,name')->select()->toArray() - 不要用
with关联查三级,一次最多查两级(省→市 或 市→区),否则 N+1 问题立刻出现 - 如果用模型,给
RegionModel加个静态方法getByPid($pid),内部自动判断缓存是否存在,不存在再查库并写入
$.post 请求后端接口时必须带的三个参数
很多联动失败,不是逻辑错,是参数没对齐。ThinkPHP 默认开启 CSRF 验证(尤其在后台模块),而前端 AJAX 默认不带 token,结果接口直接返回 403 或空数据。
后端接口路由必须显式定义为 POST,不能靠 Route::rule 模糊匹配,否则 GET 请求也能访问,但 POST 数据拿不到。
- 前端必须传
_token:从模板里取{:csrf_token()}或从 meta 标签读取,拼进 data 对象 - 必须传
pid:注意不是parent_id或province_id,统一用pid,后端直接用$this->request->post('pid') - 必须传
level:值为1(省)、2(市)、3(区),避免后端靠if-else猜意图,也方便后续扩展街道四级
容易被忽略的 DOM 绑定时机问题
很多人把 $('#province').on('change', ...) 写在 $(document).ready 里,但若页面用了 Layui、AdminLTE 等框架,select 是 JS 动态生成的(比如 tab 切换后才渲染表单),这时事件根本绑不上。
正确做法是用事件委托:$(document).on('change', '#province', function(){...}),或者在动态插入 HTML 后立即绑定(推荐后者,性能更好)。
- 如果用了 ThinkPHP 的
fetch()渲染局部模板,确保 JS 初始化代码跟在 HTML 后面,或包装成函数由父页面调用 - 禁用浏览器自动填充(
autocomplete="off")—— 某些 Chrome 版本会把“北京市”填进所有select,导致 change 事件误触发 - 移动端 Safari 对
select的change支持不稳定,可加一层blur作为兜底,但仅限 iOS 设备检测后启用
真正难的不是“怎么让三级动起来”,而是“怎么让它们在不同网络、不同设备、不同缓存策略下都稳定响应”。数据结构、请求粒度、事件绑定方式,这三个点卡住任何一个,都会让联动变成玄学。



















