做省市区选择、商品类目选择这类功能时,很多项目会直接用picker的mode属性配region或者多列模式,但一旦需求变成三列以上、或者数据源来自后端接口且层级不固定,就得自己实现联动picker了。自己实现的过程中,最容易踩坑的不是界面,而是数据格式化:接口给一份扁平数组,picker要的是按层级嵌套的二维或多维结构,中间的转换逻辑写不好,轻则切换列时响应慢半拍,重则整页卡死。这篇文章就围绕这个转换环节,把常见的写法和优化思路完整梳理一遍。

一、先理清数据格式化的目标:从扁平数组到嵌套结构
假设后端返回的是这样一份扁平数据,每条记录带上自己的父级id:
// 接口返回的扁平结构
const rawList = [
{ id: 1, parentId: 0, name: '浙江省' },
{ id: 2, parentId: 1, name: '杭州市' },
{ id: 3, parentId: 1, name: '宁波市' },
{ id: 4, parentId: 2, name: '西湖区' },
{ id: 5, parentId: 2, name: '滨江区' },
{ id: 6, parentId: 0, name: '江苏省' },
{ id: 7, parentId: 6, name: '南京市' }
];而自定义picker通常需要的是每列独立的数组,并且切换前面列时后面列的数据要跟着换。多列picker-view的核心数据结构是columns数组,每个元素对应一列的选项列表。联动时要根据前面几列的选中值,逐层从原始数据里找到对应的子集。
这里有个很容易被忽略的细节:很多实现会在每次列切换的回调里现场遍历全量数据去找子节点,数据量小的时候感觉不到,一旦全国省市区这种三千多条的数据进来,每次滑动都要全量遍历,体验立刻变差。所以格式化的核心目标是:一次性预处理,把查找过程变成近似O(1)的索引访问。
二、常见的低效写法及其问题
先看一段典型的问题代码,很多人初版都是这么写的:
// 每次列变化时递归全量查找,性能差
onColumnChange(e) {
const { column, value } = e.detail;
const selected = this.data.selectedIds.slice(0, column);
selected[column] = this.data.columns[column][value].id;
// 每次都从rawList里filter出下一列数据
const nextCol = this.data.rawList.filter(
item => item.parentId === selected[column]
);
const columns = this.data.columns.slice();
columns[column + 1] = nextCol;
this.setData({ columns, selectedIds: selected });
}这段代码功能上是对的,但每次filter都要扫过整个数组。假设数据有N条、共M列,极端情况下用户从第一列滑到最后一列,会触发M次全量扫描,复杂度是M乘以N。省市区这个量级大概是3000条数据,在低端安卓机上一次filter加上setData的渲染,肉眼可见地掉帧。
另一个问题是setData的滥用。每次列变化都把整个columns对象塞进setData,哪怕只有一列变了。微信小程序的setData是有序列化开销的,数据越大、层级越深,传输和渲染成本越高。这一点在官方性能文档里也明确提到过:应该用路径写法只更新变化的部分。
三、优化方案:一次建索引,切换时直接查表
优化的思路其实很简单:在数据加载完成后做一次预处理,把扁平数组转成一张以parentId为键的哈希表,之后任何一列的数据获取都只是查一次Map,代价几乎为零。
// 初始化时一次性建索引
buildIndex(rawList) {
const index = new Map();
for (const item of rawList) {
if (!index.has(item.parentId)) {
index.set(item.parentId, []);
}
index.get(item.parentId).push(item);
}
this._index = index;
// 第一列就是parentId为0的节点
this.setData({
columns: [index.get(0) || [], [], []],
selectedIds: [0, 0, 0]
});
}建好索引后,列切换的回调就变得非常轻量:
onColumnChange(e) {
const { column, value } = this.data;
const col = e.detail.column;
const val = e.detail.value;
const columns = this.data.columns;
const current = columns[col][val];
if (!current) return;
// 直接查表拿下一列数据
if (col + 1 < columns.length) {
const nextData = this._index.get(current.id) || [];
this.setData({
[`columns[${col + 1}]`]: nextData,
[`selectedIds[${col}]`]: current.id
});
}
}注意这里用了columns[${col + 1}]这种路径写法,只更新变化的那一列,setData的数据量从整个嵌套结构降到了单个子数组,渲染开销大幅下降。同时因为索引是在页面加载时建好的,切换过程没有任何遍历计算。
如果层级不固定,比如有的分支到第二级就到叶子了,还可以在建索引时顺手标记每个节点是否有子节点,picker里据此动态决定列数,或者对叶子节点填充一个占位项,避免出现空列导致的选择异常。
四、大列表的进一步优化:懒加载格式化与缓存
当数据规模再上一个台阶,比如全国省市区的四级地址(省市区加乡镇),总量可能超过四万条,一次性建索引也会有几百毫秒的耗时,而且全量数据放进内存对小程序也不友好。这时可以考虑两种手段结合。
第一种是分层数据拆分加载:第一级(省份)全量加载,用户选中某个省之后再按需请求或格式化该省下面的市,依次类推。索引结构不变,只是填充索引的时机从一次性变成按需。配合loading提示,用户几乎感知不到加载过程。
// 懒加载下一级数据
async loadChildren(parentId) {
if (this._index.has(parentId)) {
return this._index.get(parentId);
}
const res = await wx.request({
url: 'https://ipipp.com/api/region/children',
data: { parentId }
});
this._index.set(parentId, res.data.list);
return res.data.list;
}第二种是本地缓存。地址这类数据变化频率极低,可以把建好的索引序列化后存进wx.setStorage,下次进入页面直接读缓存反序列化,省掉整个格式化过程。实测四万条数据建索引大约两百毫秒,而从缓存读取结构化数据只要二十毫秒左右,差距接近十倍。
另外还有一个细节值得注意:如果用的是picker-view而不是原生picker,列数据只渲染当前可见区域,超长列表本身没有渲染压力,压力主要在数据传输上。所以优化重点应放在setData的数据量控制上,只传变化的列、只传必要的字段(把原始记录里的冗余字段在格式化阶段就剔除,只保留id和name),这些小改动叠加起来效果很明显。
总结一下,多级联动picker的性能优化核心就三点:预处理建索引避免重复遍历、setData用路径写法做最小更新、大数据量场景下懒加载加缓存。按这套方案改造之后,即使四级地址联动,在低端机上的切换响应也能稳定在几十毫秒以内,基本等于即点即响应。