省市区选择是微信小程序表单里非常常见的需求,从交互上看就是两个<picker>串联:省份变化后,城市选择器要立即换成对应城市。这个过程表面上是界面切换,实际要处理的是range数据数组的整体替换,以及range-key指定的显示字段是否仍然有效。

一、range与range-key的基础作用
<picker>默认mode为selector,展示单列选项。range接收一个数组,数组元素可以是字符串、数字,也可以是对象。当元素是对象时,组件并不知道应该显示对象的哪个字段,这时range-key就派上用场,它接收一个字符串,用来指定显示字段名。value属性表示当前选中的索引,从0开始计数。举个例子,如果range绑定的是城市对象数组,range-key设置为name,选择器里就会显示每个对象的name字段。
下面是一个最基础的对象数组选择器示例。WXML中通过range-key指定name,页面上直接使用cityList[cityIndex].name展示当前选中项。
<picker mode="selector" range="{{cityList}}" range-key="name" value="{{cityIndex}}" bindchange="onCityChange">
<view class="picker-value">{{cityList[cityIndex].name || '请选择城市'}}</view>
</picker>
对应的JS数据初始化如下:
Page({
data: {
cityList: [
{ id: 101, name: '广州市' },
{ id: 102, name: '深圳市' },
{ id: 103, name: '珠海市' }
],
cityIndex: 0
},
onCityChange(e) {
this.setData({
cityIndex: Number(e.detail.value)
});
}
});
需要特别注意的是,range-key只是一个字符串配置项,并不会参与索引计算。如果对象里缺少该字段,picker会显示空白,但不会报错,因此在动态切换字段时要保证目标字段确实存在。
二、动态修改range实现二级联动
二级联动最常见的场景是省份和城市。省份变化后,城市候选列表必须整体替换。这里的关键不是picker本身,而是setData的使用方式。this.data.cityList不能直接赋值,也不能通过push修改原数组后只setData一个标志位,否则视图层不会感知到range已经变化。
实现时,在省份picker的bindchange事件里读取e.detail.value,这个值在默认情况下可能是字符串,需要先转成数字索引。然后根据索引找到省份对象,再取出对应的城市数组,通过setData一次性更新provinceIndex、cityList和cityIndex。其中cityIndex必须重置为0,否则新的城市列表长度如果比旧索引小,就会出现索引越界,导致选择器显示异常。
<picker mode="selector" range="{{provinces}}" range-key="name" value="{{provinceIndex}}" bindchange="onProvinceChange">
<view>{{provinces[provinceIndex].name}}</view>
</picker>
<picker mode="selector" range="{{cityList}}" range-key="name" value="{{cityIndex}}" bindchange="onCityChange">
<view>{{cityList[cityIndex].name || '请选择城市'}}</view>
</picker>
JS侧数据结构可以使用cityMap按省份id缓存城市列表,不需要每次重新请求或遍历。onLoad时先根据第一个省份填充城市列表,避免页面首次渲染时cityList为空导致取值错误。
Page({
data: {
provinces: [
{ id: 1, name: '广东省' },
{ id: 2, name: '江苏省' }
],
cityMap: {
1: [
{ id: 101, name: '广州市' },
{ id: 102, name: '深圳市' }
],
2: [
{ id: 201, name: '南京市' },
{ id: 202, name: '苏州市' }
]
},
provinceIndex: 0,
cityList: [],
cityIndex: 0
},
onLoad() {
const firstProvince = this.data.provinces[0];
this.setData({
cityList: this.data.cityMap[firstProvince.id] || []
});
},
onProvinceChange(e) {
const index = Number(e.detail.value);
const province = this.data.provinces[index];
const cityList = this.data.cityMap[province.id] || [];
this.setData({
provinceIndex: index,
cityIndex: 0,
cityList: cityList
});
},
onCityChange(e) {
this.setData({
cityIndex: Number(e.detail.value)
});
}
});
这个模式可以继续扩展成三级联动,比如省、市、区。第三级的districtList在城市变化时动态更新,同样需要把districtIndex重置为0。如果使用后端接口获取子级数据,可以在onProvinceChange中调用请求,请求成功后setData城市列表;为了防止快速切换导致异步返回顺序错乱,可以记录一个请求序号,只有最新序号的结果才允许写入数据。
三、动态修改range-key的场景与注意事项
range-key本身也可以写成绑定表达式。比如同一份商品数据,有时需要显示中文名,有时需要显示英文编码。可以把range-key绑定到data中的一个字段,然后在事件中切换这个字段的值。组件会在下一次渲染时按照新的key重新读取对象字段。
<picker mode="selector" range="{{goodsList}}" range-key="{{displayKey}}" value="{{goodsIndex}}" bindchange="onGoodsChange">
<view>{{goodsList[goodsIndex][displayKey]}}</view>
</picker>
<button bindtap="switchKey">切换显示字段</button>
对应的JS如下:
Page({
data: {
goodsList: [
{ id: 1, cnName: '苹果', enName: 'Apple' },
{ id: 2, cnName: '香蕉', enName: 'Banana' }
],
goodsIndex: 0,
displayKey: 'cnName'
},
switchKey() {
const next = this.data.displayKey === 'cnName' ? 'enName' : 'cnName';
this.setData({
displayKey: next
});
},
onGoodsChange(e) {
this.setData({
goodsIndex: Number(e.detail.value)
});
}
});
动态修改range-key有一个容易忽略的点:显示字段虽然变了,但当前索引不会自动变化。比如原来选中的是第二项,切换字段之后仍然是第二项,只是显示内容不同。如果业务上需要在切换字段后回到第一项,需要在setData中同时重置goodsIndex。另一个问题是,如果range数组中部分对象缺少新字段,picker中对应项会显示空值,给用户造成困惑,所以切换前最好保证数据字段完整。
如果同一个选择器既要显示复杂格式,又不方便反复修改range-key,可以在数据源中预先拼好一个display字段。例如商品数据既有品牌又有型号,可以在后端或前端映射时生成display字段,然后让range-key固定为display。这样比频繁动态修改range-key更稳定,也更便于在页面上直接展示。
四、使用multiSelector实现多列联动
当业务需要在同一个弹出框里完成多列选择,而不是放置多个picker组件时,可以使用mode为multiSelector的<picker>。它的range不再是普通一维数组,而是二维数组,每个子数组对应一列。range-key仍然可以指定对象数组的显示字段,对所有列同时生效。多列联动的关键在于bindcolumnchange事件,当用户滑动某一列时,该事件会触发,并通过e.detail.column和e.detail.value告知当前列和当前索引。
下面以省市区三列为例。data中维护provinceIndex、cityIndex、districtIndex三个索引,以及provinces、cityList、districtList三个数组。初始时cityList显示第一个省份的城市,districtList显示第一个城市的区县。省份列变化时,更新cityList并重置城市、区县索引;城市列变化时,更新districtList并重置区县索引。
<picker mode="multiSelector" range="{{multiRange}}" range-key="name" value="{{multiIndex}}" bindchange="onMultiChange" bindcolumnchange="onColumnChange">
<view>
{{provinces[multiIndex[0]].name}} - {{cityList[multiIndex[1]].name}} - {{districtList[multiIndex[2]].name}}
</view>
</picker>
JS逻辑如下:
Page({
data: {
provinces: [
{ id: 1, name: '广东省' },
{ id: 2, name: '江苏省' }
],
cityMap: {
1: [
{ id: 101, name: '广州市' },
{ id: 102, name: '深圳市' }
],
2: [
{ id: 201, name: '南京市' },
{ id: 202, name: '苏州市' }
]
},
districtMap: {
101: [
{ id: 1001, name: '天河区' },
{ id: 1002, name: '越秀区' }
],
102: [
{ id: 1003, name: '南山区' },
{ id: 1004, name: '福田区' }
],
201: [
{ id: 1005, name: '玄武区' }
],
202: [
{ id: 1006, name: '姑苏区' }
]
},
cityList: [],
districtList: [],
multiIndex: [0, 0, 0]
},
onLoad() {
const firstProvince = this.data.provinces[0];
const cityList = this.data.cityMap[firstProvince.id] || [];
const districtList = this.data.districtMap[cityList[0].id] || [];
this.setData({
cityList: cityList,
districtList: districtList
});
},
onColumnChange(e) {
const column = Number(e.detail.column);
const value = Number(e.detail.value);
const multiIndex = this.data.multiIndex.slice();
multiIndex[column] = value;
if (column === 0) {
const province = this.data.provinces[value];
const cityList = this.data.cityMap[province.id] || [];
const districtList = this.data.districtMap[cityList[0].id] || [];
multiIndex[1] = 0;
multiIndex[2] = 0;
this.setData({
multiIndex: multiIndex,
cityList: cityList,
districtList: districtList
});
} else if (column === 1) {
const city = this.data.cityList[value];
const districtList = this.data.districtMap[city.id] || [];
multiIndex[2] = 0;
this.setData({
multiIndex: multiIndex,
districtList: districtList
});
} else {
this.setData({
multiIndex: multiIndex
});
}
},
onMultiChange(e) {
this.setData({
multiIndex: e.detail.value
});
}
});
multiSelector的value是一个索引数组,修改某一列索引时建议先复制原数组再修改,然后整体setData,避免直接修改this.data.multiIndex造成视图不更新。上面的onColumnChange通过slice()复制数组,再根据column决定是否更新后续列的数据。bindchange只在用户点击确定时触发,bindcolumnchange在滑动列时就会触发,因此联动逻辑要写在bindcolumnchange中,而不是等确定后再处理。
如果多列选择器的样式无法满足需求,可以改用picker-view组件。picker-view是嵌入页面的滚动选择器,可以完全自定义样式,但它不会自动处理range-key,需要自己基于索引和数组渲染文本。相比之下,<picker>更适合简单下钻和标准弹出选择场景,代码量更少;picker-view适合需要单选列、多列复杂样式或常驻展示的表单页面。
动态修改range和range-key说到底是在管理数据源和索引的同步关系。无论是普通selector的二级下钻,还是multiSelector的多列联动,核心步骤都是:在合适的bind事件中拿到当前索引,计算下一级数据,然后通过setData一次性更新range列表和对应索引。保证索引不越界、保证range数组整体替换、保证range-key与数据字段一致,就能避开大多数联动选择器的常见问题。