“数组添加”这四个字,放在任何一个前端框架里都算不上知识点,毕竟 push、concat、展开运算符,随手就能写。可一旦落到小程序里,事情就变得微妙起来:你明明把数据塞进了数组,控制台打印出来的长度也是对的,页面却纹丝不动。我这两年经手的几个小程序项目,分页列表、动态表单、多选购物车、规格组合、评论盖楼,做的全是“往数组里加数据”这一件事,踩的坑也几乎全集中在这里。
这篇东西不讲框架原理,也不做 API 罗列,就是把我在实际项目里反复验证过的几种数组追加写法、它们各自适合什么场景、以及那些文档里不会写的坑,一条条摊开说清楚。刚入门、还在为“为什么我的列表不刷新”抓头发的同学,可以照着抄;做过一两个项目、想把这部分代码收拾干净的老手,也能从性能和数据结构的几节里拿到点东西。全文代码都是小程序原生写法,不依赖任何第三方状态管理,复制到你的页面 js 里就能跑。
1. 数组添加这件小事,为什么在小程序里格外容易翻车
1.1 setData 不是赋值,它是一次跨层的数据投递
很多同学从网页开发转过来,脑子里默认“改数据就等于改视图”,这个直觉在小程序里是失效的。小程序的逻辑层和视图层是分开跑的,this.data只是逻辑层手里的一份副本,你对它做push、splice、sort,改动只发生在逻辑层,视图层压根不知道。真正让页面动起来的动作只有一个:调用this.setData()。
我习惯用这样一个类比:this.data像是你桌上的一份草稿,setData才是把草稿拍照发给设计同事的那个动作。你埋头把草稿改得再漂亮,只要没按发送,对面看到的还是旧版本。更细一点说,setData这个动作有两副面孔:它对this.data的修改是同步的,函数调用返回时你再去读this.data已经是新值;但它对视图层的更新是异步的,页面什么时候渲染完,得靠第二个参数的回调来确认。这个“数据同步、视图异步”的特性,是后面所有坑的总根源。
1.2 我第一次把 push 写进项目里的后果
早些年做一个商品列表,上拉加载更多,我写的是this.data.list.push(...res.data),然后console.log(this.data.list.length)一看,数量确实涨了,心里还挺得意。结果真机上一滑,列表底下什么都没有。我第一反应是接口没返回数据,加了日志一看,数据好得很;又怀疑是 wxml 里wx:for写错了,翻来覆去检查了一遍也没问题。前后折腾了快四十分钟,最后才反应过来:我压根没调setData。
改成this.setData({ list: this.data.list.concat(res.data) })之后,一次就通了。这件事给我的教训不是“别忘了 setData”这么简单,而是:只要你的改动没有经过setData,它在视图层就等于没发生,控制台打印出来的正确数据反而会误导你,让你往错误的方向排查。所以后面我排查“列表不更新”这类问题,第一眼永远是看setData有没有被调用、传的 key 对不对,而不是去看数据本身。
1.3 动手写之前,先问自己三个问题
写追加逻辑之前,我现在会强制自己先想清楚三件事。第一,这次是整体替换还是局部追加?如果是追加一两条,路径式更新更省;如果是几十上百条一起进来,整体替换反而更稳。第二,数组元素是基本类型还是对象?基本类型随便折腾,对象就要考虑引用共享和 key 的唯一性。第三,数据量级有多大?几十条和几千条,写法完全是两码事。这三个问题想明白了,后面的代码基本不会返工;想不明白直接开写,十有八九要在真机上返工一遍。
2. 四种追加写法,什么时候用哪一个
2.1 concat:最不容易出错的老实写法
concat是我在项目里用得最多的一种,原因很简单:它返回一个新数组,不动原来的引用。
// 追加单条 const next = this.data.list.concat(newItem) this.setData({ list: next }) // 追加多条 const next = this.data.list.concat(res.data) this.setData({ list: next })这里有个细节很多人没注意:concat接受的不一定是数组,传单个对象进去它也会帮你包成一项,所以单条追加和多条追加可以共用一个写法,省掉一次判断。另一个好处是新旧数组是两个独立引用,你在setData之后再去改旧数组,不会影响已经渲染的数据,排查问题时不会出现“数据自己变了”的灵异现象。
它的代价也很明确:每次都造一个新数组,几千条的数据反复追加,内存里会短时间存在两份完整数据。不过在小程序这种页面级数据量下,这点开销基本可以忽略,除非你在做一个几千条的长列表还在不停地整体替换。
2.2 展开运算符:写法最短,但顺序要想清楚
// 追加到末尾 this.setData({ list: [...this.data.list, ...newItems] }) // 插入到开头(注意顺序) this.setData({ list: [...newItems, ...this.data.list] })展开运算符看起来最清爽,但它有两个必须记住的点。第一,顺序即语义,[...list, ...newItems]是往后面加,[...newItems, ...list]是往前面插,写反了页面不会报错,只是数据顺序不对,这种错误肉眼很难第一时间发现。第二,被展开的东西必须是可迭代的,如果this.data.list在某些分支里是undefined,展开就会直接抛错。我的习惯是在data里给它一个默认空数组list: [],需要防御的地方再补一句...(this.data.list || [])。
还有一种不太推荐的写法是this.data.list.push(...newItems)之后紧跟一次整体setData。它能跑,但语义上很别扭:你先污染了原数组,又把同一个数组传给setData,新旧引用是同一个,非常容易在后续的对比逻辑里出问题。能不用就不用。
2.3 路径式追加:只把“新的那几条”发过去
当列表已经很长、每次只追一两条的时候,把整个数组重新发一遍就有点浪费了。这时候可以用数据路径的方式,只把新增的项发给视图层:
const base = this.data.list.length const patch = {} newItems.forEach((item, i) => { patch[`list[${base + i}]`] = item }) this.setData(patch)这段代码的关键在base这一行:它必须在setData之前取。因为setData会同步更新this.data,如果你把取长度的动作放在循环里,或者连着调两次追加却没重新取基准,两次算出来的下标就会重叠,后一次直接把前一次的数据覆盖掉,页面看起来“少了几条”,非常难查。
路径式更新还有一个隐性好处:视图层收到的是增量,长列表下的渲染压力会小一些。缺点是代码可读性下降,拼接字符串也容易写错,所以我一般只在“每次追加 1 到 2 条、列表基数又大”的场景里用它,普通场景还是concat老实些。
2.4 往前插的时候,别硬算下标
unshift和splice(0, 0, item)都能往数组前面塞数据,用来做“新消息插到顶部”这类需求。但如果你用路径式更新,往前面插一条意味着后面所有元素的下标全部后移一位,你得把整段数据重新覆盖一遍,成本比整体替换还高。所以往前面插我一律走整体替换:
this.setData({ list: [newItem, ...this.data.list] })配合滚动位置的处理,体验会更好。很多人插完数据发现列表“跳”了一下,其实不是数据问题,是滚动条还停在原位置,视觉上像是内容被顶走了。我的做法是插完在setData的回调里把scroll-top归零,让用户看到最新的一条。
2.5 四种写法的选型对照
| 写法 | 适合场景 | 优势 | 需要小心的地方 |
|---|---|---|---|
| concat 整体替换 | 通用、默认选择 | 返回新引用,不污染旧数据 | 数据量大时有整份拷贝开销 |
| 展开运算符 | 追几条、插到头部 | 代码最短,顺序直观 | 空值展开报错,顺序写反难发现 |
| 路径式追加 | 长列表 + 少量追加 | 只传增量,通信体积小 | 基准下标必须提前取,字符串易写错 |
| unshift / splice | 顶部插入一条 | 语义直接 | 下标全变,不建议配路径式更新 |
3. 分页加载:把“追加”做成一个能复用的能力
3.1 状态设计:page、hasMore、loading 三件套
分页加载是最典型的“往数组里加数据”场景,写好一次能用到所有列表页。我的页面data里固定放三个字段:
data: { list: [], page: 1, pageSize: 20, hasMore: true, loading: false }page记页码,hasMore记还有没有下一页,loading是并发锁。这三个字段缺一个,都会在边界情况下出问题:没有loading,用户快速上拉会发出好几个重复请求,同一个第二页被追加两遍;没有hasMore,列表到底了还在不停地请求,白白浪费流量。至于pageSize,我一般设 20,接口允许的话设 10 也行,小程序单页数据量小一点,首屏出得快,用户感知更明显。
3.2 一次完整的上拉加载实现
async loadMore() { // 正在加载 或 没有更多,直接返回 if (this.data.loading || !this.data.hasMore) return this.setData({ loading: true }) try { const res = await request({ url: '/api/list', data: { page: this.data.page, size: this.data.pageSize } }) const incoming = res.list || [] // 去重:以 id 为唯一标识,防止接口重复返回 const existIds = new Set(this.data.list.map(item => item.id)) const fresh = incoming.filter(item => !existIds.has(item.id)) this.setData({ list: this.data.list.concat(fresh), page: this.data.page + 1, hasMore: incoming.length >= this.data.pageSize }) } catch (e) { // 失败不推进页码,下次上拉还能重试 wx.showToast({ title: '加载失败,请重试', icon: 'none' }) } finally { this.setData({ loading: false }) } }这段代码里有几个地方值得单独说。去重那几行不是可有可无的,接口在做分页时如果排序字段有重复值,相邻两页很容易返回同一条数据,用户就会看到一条内容出现两次,还以为是自己的 bug。用Set做一次过滤,成本很低。失败分支里不推进页码,这一点很重要,否则网络抖一下就会永久跳过一页数据。hasMore用返回条数判断,比让后端额外返回一个字段更通用,不足一页就认为到底了;如果后端有total,用它算也行,但要记得总数为 0 时别把hasMore算成true。
3.3 并发锁用在哪一层
loading这个锁我放在方法入口,而不是放在请求发出之后。差别在于:如果放在请求之后,两个几乎同时触发的事件(比如滚动到底和点击“加载更多”按钮同时发生)都能通过入口检查,还是会出现重复请求。放在第一行,第二次调用直接被拦下来,问题就没了。
另外还有一种更隐蔽的并发:用户在上拉的时候切了页面,请求回来后页面已经卸载,这时候setData会报“页面不存在”之类的警告。我的处理是在onUnload里把一个this.alive = false标记掉,请求回来先判断这个标记再setData。这个细节看着烦,但线上日志里这类警告确实不少,清干净了排查真问题时干扰更少。
3.4 空列表和首屏的体验补位
列表数据的追加逻辑写对了,还得照顾“没数据”的状态。我一般在wxml里做三层判断:loading且list为空时显示骨架屏,请求结束、list为空时显示空态图加一句人话提示,有数据时正常渲染列表。这个顺序不能反,否则会出现“先闪一下空态图,再出数据”的跳动感。骨架屏不需要多复杂,几行灰色占位块就够了,关键是让用户知道“在加载”,而不是“这页面坏了”。
4. 对象数组与嵌套结构:路径更新的正确姿势
4.1 对象数组追加,key 一定要给对
列表项是对象的时候,wx:for一定要配wx:key,而且我强烈建议用业务唯一标识(比如id),不要用index。
<view wx:for="{{list}}" wx:key="id"> <input value="{{item.name}}" bindinput="onNameInput">onToggle(e) { const index = e.currentTarget.dataset.index const list = this.data.list const nextChecked = !list[index].checked this.setData({ [`list[${index}].checked`]: nextChecked }) }拼接模板字符串时注意,整个 key 必须用方括号包起来,或者在对象字面量里用计算属性写法,写成'list[' + index + '].checked'也可以,但不能写成不加引号的list[index].checked,那样只是一个普通的字符串或者语法错误。另外,如果字段名本身带点、带中括号(比如后端返回的某些奇怪 key),路径解析会出错,这种情况就别用路径了,老老实实整体替换这一项:
const list = this.data.list.slice() list[index] = { ...list[index], checked: nextChecked } this.setData({ list })4.3 嵌套两层以上,先保证外层存在
做规格选择、多级分类这类需求,会碰到二维数组甚至多层对象。路径写法是matrix[0][1]这种形式:
this.setData({ [`matrix[${row}][${col}]`]: value })这里有个坑:路径上的中间节点必须已经存在。如果你要写matrix[3][0],但matrix[3]本身不存在,这次设置会失败,控制台可能只给一句含糊的报错。我的处理是写之前先确保外层结构存在:
const matrix = this.data.matrix.slice() if (!matrix[row]) matrix[row] = [] matrix[row][col] = value this.setData({ [`matrix[${row}]`]: matrix[row] })层级再深,我就放弃路径写法了,直接重建那一层数据整体替换。可读性比省那点传输量重要得多,毕竟后期维护的是人,不是机器。
4.4 追加时顺手把默认字段补齐
对象数组还有一个高频问题:后端返回的字段不全,或者不同条目字段不一致。渲染时item.status === 1拿到undefined,条件判断全走偏。我的习惯是在追加进数组的那一刻就把默认值补齐:
const normalize = item => ({ id: item.id, name: item.name || '', checked: Boolean(item.checked), count: Number(item.count) || 0, ...item }) this.setData({ list: this.data.list.concat(incoming.map(normalize)) })注意...item放在最后,让后端的真实字段覆盖默认值,而默认值只兜住缺失的那些。这一层处理写完,页面里所有取值就不用到处写||了,代码能干净一大截。
5. 性能:数组一大,setData 就是主要瓶颈
5.1 单次数据的体积和频率都要控制
文档里给出的上限是单次setData的数据不超过 1024KB,但那是硬红线,按那个量级去用,体验基本上是灾难。我的经验是单次尽量控制在几十 KB 以内,超过这个数就要考虑拆分或者只传增量。列表项如果带了长文本、图片地址、富文本内容,很容易几百条就超标。
还有个更容易被忽略的维度是频率。滚动、输入这类高频事件里如果每次都setData,逻辑层和视图层之间的通信会被打满,表现是页面卡顿、输入延迟。我的做法是在输入类场景里只更新必要的字段,滚动类场景里做节流,把一秒内十几次的更新压到两三次。
5.2 合并提交:把散装更新攒成一次
有时候一次交互要连改好几个地方,比如表单提交成功后既要往列表头插一条,又要清空表单,还要更新计数。分开写就是三四次setData,合并起来只算一次:
const nextList = [newRecord, ...this.data.list] this.setData({ list: nextList, total: nextList.length, form: { name: '', remark: '' }, submitting: false })原则是:在同一个事件回调里,能合就合。有人喜欢封装一个setData的包装函数,把短时间内的多次调用收集起来统一提交,这个思路对高频场景确实有效,但对普通业务页面属于过度设计,先把“同一个回调里合并”这件事做到,收益已经很明显了。
5.3 长列表只渲染看得见的部分
数据加到几百上千条之后,光靠优化setData是不够的,渲染节点太多,滚动照样会掉帧。这时候思路要从“少传点数据”转成“少渲染一些”。我的做法是把完整数据放在this.data.listAll或者页面实例的普通属性上(不参与渲染的字段不必进data),再按滚动位置算出一个可视区间的切片赋给list去渲染。
// 只把可视区附近的一段数据交给视图层 const start = Math.max(0, Math.floor(scrollTop / itemHeight) - 5) const end = start + 20 this.setData({ visibleList: this.listAll.slice(start, end) })配合一个占位容器把总高度撑起来,滚动条的长度才不会缩水。这套做法听起来麻烦,但它是长列表唯一稳妥的方向,靠“多渲染一点”硬扛,早晚会在低端机上翻车。
5.4 别把原始大对象整坨塞进 data
还有一个特别常见的浪费:接口返回的完整对象,包括各种用不上的描述、时间戳、嵌套配置,直接整套塞进data。真正参与渲染的可能只有三四个字段。我的习惯是入data之前做一次瘦身,只留渲染需要的,原始数据放到页面实例属性或者全局缓存里,按id去取。这样既省传输体积,也避免了“改了一个不相关字段导致整块重渲染”的连带问题。
6. 排查实录:数据明明加了,页面就是不动
6.1 一张排查顺序表
这类问题我遇到过太多次,已经形成固定的排查顺序,从上往下走,基本三分钟内能定位。
| 现象 | 最可能的原因 | 快速验证 | 处理方式 |
|---|---|---|---|
| 数据变了,页面没变 | 没调 setData,或直接改了数组 | 在改动后加一行 setData 日志 | 改为整体替换或路径更新 |
| setData 调了,还是没变 | key 路径写错、拼错字段名 | 打印传给 setData 的对象 | 核对路径字符串与 data 结构 |
| 数据翻倍、重复 | 并发请求,或去重缺失 | 看请求日志次数 | 加 loading 锁 + 按 id 去重 |
| 部分项不更新 | wx:key 用了 index,节点被复用 | 换成业务 id 再试 | wx:key 改唯一字段 |
| 报“页面不存在” | 请求回来时页面已卸载 | 看是否切页后出现 | 卸载标记 + 回调前判断 |
| 传了但渲染异常 | 数据里有函数、Date、undefined | 序列化打印一次 | 转成可序列化的普通值 |
6.2 几个格外隐蔽的坑
回调里的 this 丢了。在普通function回调里写this.setData会报错,因为this已经不是页面实例。我统一用箭头函数,或者在方法开头const that = this,两种都行,但一个项目里最好只用一种,混着写迟早会漏。
两个数组共用了一个引用。比如const a = res.list; this.setData({ listA: a, listB: a }),之后往listA里 push,listB也会跟着变,因为它们是同一个数组。解决办法是在赋值时slice()一份,成本很低。
setData 里塞了不可序列化的东西。函数、undefined、部分特殊对象在跨层传递时会被丢掉或者变形,页面拿到的和你以为的不一样。Date对象我一般直接转成时间戳或者格式化字符串再传,省得在渲染层做兼容。
往数组里 push 了同一个对象引用。循环里list.push(obj),如果obj是在循环外声明的同一个变量,最终数组里全是同一份数据的引用,改一个全变。要么在循环内新建对象,要么push({ ...obj })。
6.3 我常用的两个调试动作
第一个是打印快照:console.log(JSON.stringify(this.data.list))。直接打印对象在控制台里是引用,你后续再改,展开看的时候值可能已经变了,序列化之后拿到的是那一刻的真实快照。第二个是在setData的第二个参数回调里读一次this.data,确认“视图层已经渲染完”的时刻数据是什么样。这两个动作配合,绝大多数“数据与视图不一致”的问题都能定位到具体是哪一步出的偏差。
还有一个小技巧:开发工具的 wxml 面板可以直接看到当前渲染用的数据,跟逻辑层的this.data对比一下,差异立刻现形。比起在代码里到处加日志,这个方式快得多。
7. 我在项目里固化下来的几条习惯
7.1 封装一个 appendList 工具方法
同一个追加逻辑写三遍就该抽出来了。我在每个项目的utils里都放一个这样的方法:
// utils/list.js function appendList(page, key, incoming, idField = 'id') { const old = page.data[key] || [] const exist = new Set(old.map(i => i[idField])) const fresh = (incoming || []).filter(i => !exist.has(i[idField])) if (!fresh.length) return 0 page.setData({ [key]: old.concat(fresh) }) return fresh.length }它做的事情很朴素:取旧数组、按 id 过滤、concat后一次性setData,返回实际新增的条数。返回条数这个设计是有意为之的,调用方可以据此判断“这一页是不是全是重复数据”,从而决定要不要继续翻页。有了它,页面里的追加逻辑就只剩一行调用,重复代码和手写路径的错误一起消失了。
7.2 数据结构上做两个约定
第一个约定:每个列表项必须有唯一 id,哪怕是前端自己生成的临时 id,也要有。它同时承担wx:key、去重、定位三个职责,缺了它上面一半的技巧都用不了。第二个约定:状态字段和展示字段分开放,比如checked、expanded这类交互状态单独一组,接口返回的展示字段单独一组,更新时只碰状态那一层,避免把整条数据重写一遍。
7.3 几条用血换来的实操心得
- 页面
data里所有数组都给默认空数组,别留undefined,能省掉一大类展开报错和map报错。 - 任何“往数组里加数据”的代码,写完先在真机上滑一遍,模拟器上的表现和真机差得挺远,尤其是长列表。
- 删除数组元素后如果发现输入框内容错位,别怀疑数据,先去看
wx:key。 - 分页接口的排序字段一定要稳定,排序不稳定时相邻两页重复返回同一条,是最难查的一类“数据错乱”。
- 调试期间可以在
setData外面包一层临时日志,把每次传的 key 和体积打出来,性能问题往往一眼就能看见。
最后分享一个我最近才改掉的小毛病:以前我总喜欢在setData之后紧接着写一段依赖新数据的逻辑,觉得“数据都同步更新了,肯定没问题”。视图层那边其实还没渲染完,涉及节点尺寸、滚动高度的计算经常拿到旧值。后来统一改成把这类逻辑放进setData的回调里,那些“偶尔算错一次”的诡异问题就再没出现过。数组添加本身不难,难的是记住这条数据从逻辑层走到视图层需要一点时间,而所有的坑,基本都长在这段路上。