ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

小程序 setData 数组追加避坑:路径更新、长列表性能与 wx:key

小程序 setData 数组追加避坑:路径更新、长列表性能与 wx:key

做小程序前端的人,几乎都栽在过同一件小事上:列表要追加一条数据,this.data.list.push(item)敲得行云流水,console.log打印出来数组确实变长了,可页面纹丝不动。然后你补一句this.setData({ list: this.data.list }),有时候好了,有时候又出别的问题——比如列表里某几项突然不对、滚动位置乱跳、数据量一大就卡成幻灯片。这篇就把“数组添加数据”这一件看似只有一行代码的事拆开讲透:setData到底在背后做了什么、追加元素的几种写法各自适合什么场景、为什么改一个字段不该把整个数组再传一遍、长列表不断往下追加时性能账怎么算。不管你是刚接触小程序组件化开发的新手,还是已经写过几个页面但总在列表上翻车的老手,下面这些内容都能直接抄去用。

1. 数据改了页面不动:先搞清楚 setData 到底在干什么

很多人对setData的理解停留在“通知页面刷新”,于是把它当成forceUpdate来用,一遇到不更新就到处补setData。这个理解偏差是所有数组操作问题的总根源。要把它理顺,得先接受一个事实:小程序的逻辑层和视图层是两个独立的运行环境,它们之间不共享内存。

1.1 直接 push 为什么注定无效

你在Page里写this.data.list.push(...),改的是逻辑层里那个 JS 对象。这个对象视图层根本看不见,视图层只认逻辑层“发过去”的那份数据副本。所以push之后再console.log看到长度变成 5,只是逻辑层自己热闹,视图层手里还是那份长度为 4 的旧快照。

Page({ data: { list: [] }, onLoad() { this.data.list.push({ id: 1, title: '第一条' }); console.log(this.data.list.length); // 1,逻辑层确实变了 // 视图层毫无反应 } });

有人会说“那我 push 完再this.setData({ list: this.data.list })不就行了”。这条路确实能跑通,因为setData会把传进去的数据序列化后送到视图层,即使传的是同一个引用对象也没关系。但它有个隐性问题:你在setData之前对this.data做的任何改动,都已经污染了源数据,一旦后续代码依赖this.data做 diff 或者做回滚,就会算错。所以我更推荐“先算出新数组,再一次性交出去”的写法,而不是“先改再通知”。

1.2 一次 setData 背后的跨线程通信

setData的本质是一次跨环境的数据传递:逻辑层把你给的这坨数据序列化,通过底层通道送到视图层,视图层再拿它去更新对应的节点。这个过程有几个绕不开的成本——序列化的成本、传输的体积、视图层重新计算绑定的成本。这就是为什么官方一直强调“只传变化的数据”,而不是“把整个data再传一遍”。

理解这一点,很多取舍就自然清楚了。追加一条数据,最省的做法不是把 200 条旧数据连同新的 1 条一起发过去,而是只发那 1 条。改列表里第 3 项的标题,最省的做法是只发list[2].title这一个字段。后面的章节会具体讲怎么写这种路径表达式。

1.3 判断“该不该 setData”的一个土办法

我在带新人的时候会给一个很笨但很好用的判断标准:问自己“视图层现在需要知道哪个具体位置变成了什么值”。如果你能精确回答出位置和值,就用路径式setData;如果你只能说“整个列表都变了”,那就老老实实传整个列表。这个标准的好处是把“性能优化”从玄学变成了一个可以当场回答的问题。

举个例子,下拉加载第二页,视图层需要知道的是“列表末尾多了 10 条”,但它没法只接收“末尾几条”,因为新数据是拼在数组后面的,位置取决于当前长度。这种场景传整个新数组是合理的,也是绝大多数分页列表的标准做法。但如果只是把第 3 项的状态从“未读”改成“已读”,那传整个列表就纯属浪费。

2. 给数组追加元素的四种写法与各自适用场景

同样是往数组尾部加东西,写法有好几种,它们不是“谁比谁高级”的关系,而是各自对应不同的使用场景。选错了,代码也能跑,但会在某些边界条件下咬你一口。

2.1 展开运算符 + 整体覆盖

这是目前最主流的写法,可读性最好:

const newItem = { id: Date.now(), title: '新条目' }; this.setData({ list: [...this.data.list, newItem] });

它创建了一个全新数组,旧数组原封不动。好处是数据流清晰,任何时刻this.data.list都是一份完整的、可以直接信任的快照,出了问题很好回溯。代价是每次都要新建一个数组,长度上万时内存开销会体现出来。不过在绝大多数业务列表(几十到几百条)里,这点开销完全可以忽略。我个人的默认选择就是它,除非有明确的性能理由才换别的写法。

2.2 concat 返回新数组

concat和展开运算符本质是一回事,都是返回新数组、不修改原数组。老项目里很常见:

this.setData({ list: this.data.list.concat([newItem]) });

注意concat的参数要传数组,传单个对象它也会当成一个元素拼进去,但语义上容易让人误解,还是包一层数组更清楚。有一个场景concat比展开运算符顺手:一次要追加一批数据,比如接口返回了 20 条:

this.setData({ list: this.data.list.concat(res.data.list) });

这种“合并两个数组”的语义,concat表达得比[...a, ...b]更贴近意图,尤其是b本身就是变量的时候。

2.3 push 之后补一次 setData

这是最容易踩坑的写法,因为它“有时候能用”:

this.data.list.push(newItem); this.setData({ list: this.data.list });

能用的前提是你在push之后没有再用旧引用做别的事。一旦出现下面这种情况就麻烦了:

const temp = this.data.list; temp.push(a); this.setData({ list: this.data.list }); // 后面某个回调里又用到 temp,发现它已经被改了

temp和this.data.list指向同一个数组,你以为拿到了快照,其实拿到了活引用。这种 bug 排查起来极其痛苦,因为出错的地方和改数据的地方隔了很远。所以除非是老代码里已经这么写了不想动,新代码我建议直接用不可变写法。

2.4 路径式 setData:最省流量的追加方式

如果你的需求就是“在末尾加一条”,而且不需要同时改别的数据,可以直接用下标路径:

const index = this.data.list.length; this.setData({ [`list[${index}]`]: newItem });

这里用到了 ES6 的计算属性名,方括号里是变量拼出来的字符串。这种写法只传一条数据过去,体积最小。但它有两条必须遵守的规则:

第一,下标必须恰好等于当前长度。如果你跳号,比如列表长度是 3,你去写list[5],中间会留下空洞,渲染出来很可能出现空白项或者undefined。第二,如果同时还要修改其他数据,路径式写法会让代码变得很难读,这时候不如老老实实传整个数组。

还有一种更隐蔽的路径式用法,用于批量追加。有人会写循环里多次setData,这是大忌,后文会专门讲。

3. 只改动一个元素时,别把整个数组再传一遍

列表里最常见的操作其实不是“追加”,而是“改某一项”。点赞按钮、勾选状态、数量加减,都是改数组中的某个元素。这类操作如果你每次都传整个数组,数据量一上来页面立刻就能感觉到卡。

3.1 下标路径的写法与坑

标准写法是这样:

// 把第 index 项标记为已选中 this.setData({ [`list[${index}].checked`]: true });

注意路径里既有下标又有字段名,中间用点连接。这条路能精确命中视图层对应的那个节点,其他地方完全不动,性能是最好的。

坑在于index必须是数字,且不能是字符串拼接出来的奇怪值。我见过有人写list[index].checked(漏了方括号),结果setData静默失败,页面上什么都不发生,控制台也不报错。小程序对数据路径的容错比较宽松,写错路径往往不会抛异常,只是不生效——这一点必须记住,它是后面排查章节的核心线索。

另外,如果list[index]本身是个对象,而你要改的字段还不存在,路径式赋值是可以给它新增字段的。但如果list[index]本身是undefined(比如下标越界),这次赋值大概率无效。所以路径式操作之前,先确认那一项确实存在。

3.2 对象数组里改某个字段

对象数组是最常见的结构,比如:

data: { goods: [ { id: 1, name: 'A', count: 1, price: 9.9 }, { id: 2, name: 'B', count: 2, price: 19.9 } ] }

要让第 0 项数量加一,正确做法是先算出新值再写路径:

const i = 0; const next = this.data.goods[i].count + 1; this.setData({ [`goods[${i}].count`]: next });

不要写成goods[${i}].count++这类表达式,setData的右侧必须是一个具体值,不能是“操作”。这个细节新手特别容易搞混,因为写this.data.goods[i].count++在纯 JS 里是合法的,但那个操作不会反映到视图层。

3.3 删除与插入的路径表达

删除和插入没法用路径表达,因为数组长度变了,后续所有元素的位置都跟着变。这两个操作必须传新数组:

// 删除第 index 项 const next = this.data.goods.filter((_, i) => i !== index); this.setData({ goods: next }); // 在第 index 位插入 const list = this.data.goods.slice(); list.splice(index, 0, newItem); this.setData({ goods: list });

这里用slice()先复制一份再splice,是为了避免直接改this.data。filter天然返回新数组,用起来更省心。这一对操作是“路径式不适用”的典型场景,别硬套。

3.4 动态 key 的拼接

有时候路径的层级是动态的,比如多级分类,你需要拼出categories[0].children[2].selected这样的路径。技巧是用模板字符串拼,但要注意只拼路径、不拼值:

const path = `categories[${i}].children[${j}].selected`; this.setData({ [path]: true });

如果层级很深、又要改多个字段,我更倾向于用一个小工具函数把旧数组复制出来改,再整体setData。因为深路径的可读性会随着层级增长迅速下降,半年后自己都看不懂。

4. 长列表不断往下追加,性能账怎么算

分页列表是数组追加的重灾区。用户一直往下滑,你的list越来越长,setData的数据量也越来越大,滑到后面开始卡顿、掉帧,甚至白屏。这不是玄学,是可以算出来的账。

4.1 数据量上限与体积估算

小程序对单次setData的数据量是有约束的,官方给的建议上限是单次不超过 1024KB。你可以粗略估一下自己的情况:一条商品数据如果包含 id、标题、图片地址、价格、标签数组、各种状态位,序列化后很容易到 300~500 字节。500 条就是 150~250KB,1000 条就可能逼近上限。

一旦接近这个量级,问题不只是“传得慢”,而是每次追加都要把历史数据重传一遍。用户滑到第 20 页,你为了加 10 条新数据,把前面 190 条又发了一次。这个浪费是可以算出来的:追加次数越多,单次成本越高,总成本是按平方级增长的。

所以长列表的正确姿势是:要么控制总量,要么避免整体重传。

4.2 分页追加的合并策略

控制总量最简单的办法是设一个上限,超过就丢弃最早的数据:

const MAX = 200; const merged = this.data.list.concat(res.list); const list = merged.length > MAX ? merged.slice(merged.length - MAX) : merged; this.setData({ list });

这个策略适合信息流、聊天记录这类“老数据不那么重要”的场景。代价是用户往回滚时看不到最早的内容,所以要么配合“顶部加载更多”,要么在 UI 上明确这是“只保留最近若干条”。

另一个思路是分批提交。如果一次接口返回 50 条,不要一次全塞进去,而是先渲染 10 条,剩下的用setTimeout分批追加。这个技巧在首屏数据特别大的场景里很有用,能让用户先看到内容,而不是对着白屏等。

4.3 滚动加载的节流与锁

onReachBottom或者scroll-view的bindscrolltolower在快速滑动时会连续触发。如果不加锁,你会同时发出好几个请求,回来之后连续setData好几次,页面直接卡住。

data: { loading: false, noMore: false }, async loadMore() { if (this.data.loading || this.data.noMore) return; this.setData({ loading: true }); try { const res = await requestPage(this.data.page); this.setData({ list: this.data.list.concat(res.list), page: this.data.page + 1, noMore: res.list.length === 0, loading: false }); } catch (e) { this.setData({ loading: false }); } }

注意这里的loading标志位要放在setData之前同步设置——不,准确说是要在函数入口处立刻判断并设置,不能等await之后。因为await会让出执行权,第二次触发时如果loading还是false,锁就白加了。这是异步加载里非常经典的一个时序坑。

4.4 虚列表与只渲染可视区

当列表真的长到几百上千条,即使数据量控制住了,视图层的节点数量也会拖慢渲染。这时候要考虑虚拟列表:只渲染可视区附近的那几十个节点,滚动时动态替换数据。

小程序的scroll-view本身不提供虚拟化,需要自己算。核心思路是用固定的行高(比如每条 100px),根据scrollTop算出当前应该渲染的起止下标,然后只把这部分数据setData进去,同时用一个占位容器撑起总高度。

onScroll(e) { const top = e.detail.scrollTop; const start = Math.floor(top / ITEM_HEIGHT); const end = start + VISIBLE_COUNT + BUFFER; if (start === this.data.start) return; this.setData({ start, visible: this.data.all.slice(start, end) }); }

这套东西实现起来不算复杂,但坑不少:行高必须固定、图片高度要提前占位、scrollTop事件触发频率高需要节流。我的建议是,列表长度稳定超过 300 条再考虑虚拟列表,否则投入产出比不划算,用前面的合并策略就够了。

5. 三个高频业务场景的数组操作套路

光讲语法没意思,数组操作的价值都在具体业务里。下面三个场景基本覆盖了日常开发中 80% 的数组追加与修改需求。

5.1 购物车:数量增减与本地同步

购物车最典型的操作是“某个商品数量 +1”。数据通常是对象数组,每一项带id、count、price。核心诉求是:改一项、算总价、不重传整个列表。

changeCount(e) { const index = e.currentTarget.dataset.index; const delta = Number(e.currentTarget.dataset.delta); const item = this.data.cart[index]; const nextCount = Math.max(1, item.count + delta); this.setData({ [`cart[${index}].count`]: nextCount, totalPrice: this.calcTotal(index, nextCount) }); }

这里有个取舍:总价要不要一起算。如果是本地算,就当场算好一起setData,一次通信解决。如果总价要服务端算,那就是异步的,中间会有一小段时间显示旧总价,得加个 loading 或者乐观更新的兜底。我一般选本地算,因为购物车这种场景对即时反馈要求高,等接口回来用户会以为没点中。

还有一个容易被忽略的点:dataset里的值在部分情况下会变成字符串,delta建议用Number()转一下,不然item.count + delta会变成字符串拼接,1 + "1"得到"11"。这个坑我见过不止一次。

5.2 动态表单:一行的增删与校验

动态表单允许用户自己加行,比如报销单、收货地址、问卷题目。数据结构就是一个数组,每行是一个对象。追加一行的写法很简单,但真正的难点在于每行的唯一标识和校验状态的维护。

addRow() { const row = { key: `row_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, name: '', amount: '' }; this.setData({ rows: this.data.rows.concat([row]) }); }

为什么要有key?因为删除中间某行时,如果你用数组下标当wx:key,视图层会复用错误的节点,导致输入框里的内容“串行”。用唯一key才能保证删除后每行的 DOM 和数据的对应关系是正确的。这个问题在小程序里非常典型,且症状诡异——你删了第 2 行,第 3 行变成了空白,或者第 1 行的输入跑到了第 2 行。

删除的实现要注意别用splice直接改原数组:

removeRow(e) { const index = e.currentTarget.dataset.index; const rows = this.data.rows.filter((_, i) => i !== index); this.setData({ rows }); }

如果还需要维护一个“已填写的行数”或者校验结果,记得在同一批setData里一起更新,别拆成两次。

5.3 消息流与评论:头部插入与去重

消息列表和数组追加的方向相反——新消息在最上面。这时候用的是unshift的不可变版本:

this.setData({ messages: [newMsg].concat(this.data.messages) });

注意unshift直接改原数组,所以要么用concat拼在前面,要么用[...arr]复制后unshift。前者的写法更干净。

消息流还有一个必须处理的问题:去重。推送、轮询、下拉刷新可能重复拿到同一条数据。如果只按内容判断会误杀,正确做法是按唯一 id 过滤:

const existIds = new Set(this.data.messages.map(m => m.id)); const incoming = res.list.filter(m => !existIds.has(m.id)); if (incoming.length === 0) return; this.setData({ messages: incoming.concat(this.data.messages) });

这里先判断incoming.length === 0再setData,是个很实用的小优化——没有新数据的时候不触发任何视图更新,能省掉很多无谓的渲染。评论列表、通知列表这类“随时可能推新内容”的页面,我基本都会加这个判断。

6. 排查链路:追加数据没生效时我一般怎么查

前面讲了各种正确写法,但现实是——你已经写了setData,页面还是不动。这时候别急着到处加代码,按下面的顺序走一遍,绝大多数问题都能定位到。

6.1 先确认赋值语句真的跑到了

最常见的原因是那句setData压根没执行。可能是事件没绑上、catchtap和bindtap用混了、方法名在wxml里拼错了一个字母、或者函数被早退的if拦住了。查法很简单,在setData前后各打一条日志:

console.log('before setData', this.data.list.length); this.setData({ list: newList }); console.log('after setData', newList.length);

如果两条日志都没打印,问题在事件绑定层,跟数组操作一点关系都没有。这一步能砍掉大约三成的“假 bug”。

6.2 检查路径拼写与数据类型

如果日志打了,但页面不动,第一个怀疑对象就是路径写错。小程序的setData对路径的校验很宽松,list[0].name写成list[0].Name、list.0.name、list[0]name,都不会报错,只会静默失效。

我的做法是把拼好的路径先console.log出来,肉眼核对一遍。同时检查下标是不是数字:从dataset里取出来的东西经常是字符串,list["0"]这种路径在部分基础库版本上表现不稳定。稳妥起见统一Number()转一下:

const index = Number(e.currentTarget.dataset.index);

另一个隐蔽的问题是数据类型不匹配导致绑定失败。比如wxml里写了wx:if="{{item.show}}",你setData的时候写了个字符串"false",在某些场景下字符串"false"是真值,wx:if依然成立。这类问题排查起来需要看wxml模板那一侧,不能只盯着 JS。

6.3 wx:key 与渲染层缓存

列表渲染如果wx:key给得不合适,表现就是“数据变了但某几项显示的还是旧内容”。这不是数据没传过去,是视图层复用了错误的节点。

<!-- 不推荐:下标当 key,删除或插入时会错位 --> <view wx:for="{{list}}" wx:key="index">{{item.title}}</view> <!-- 推荐:用业务唯一 id --> <view wx:for="{{list}}" wx:key="id">{{item.title}}</view>

判断方法很简单:在wxml里直接把长度打出来,比如<view>共 {{list.length}} 条</view>。如果长度变了但列表内容没变,基本可以确定是wx:key的问题。这个小技巧我常用来快速区分“数据层问题”和“渲染层问题”。

6.4 异步时序问题

还有一种情况:数据确实更新了,但马上被另一次setData覆盖了。典型场景是在onShow里刷新列表,同时在页面里做本地操作,两个流程打架,谁后执行谁赢。

排查方法是在每次setData前打一条带时间戳和调用来源的日志,看看到底是谁把数据覆盖回去了。定位到之后,解决办法通常是加一个版本号或者请求序号,只接受最新一次请求的结果:

const seq = this.seq = (this.seq || 0) + 1; fetchList().then(res => { if (seq !== this.seq) return; // 已经有更新的请求了,丢弃 this.setData({ list: res.list }); });

这个模式在搜索框联想、切页签刷新这类场景里几乎必备,不然用户输入快了就会看到结果跳来跳去。

7. 几个容易被忽略的细节

前面讲的是主干,这一节补充几个平时不大有人提、但确实会咬人的小地方。

7.1 基础库版本与语法兼容

路径式setData依赖计算属性名{ [key]: value },这是 ES6 语法。开发者工具默认会在“本地设置”里勾选ES6 转 ES5,勾上之后代码会被转译,老版本基础库也能跑。但如果这个选项被关掉了,或者用了某些更激进的语法(比如可选链?.、空值合并??),在低版本基础库上可能直接报语法错误。

我的习惯是:核心链路的数组操作只用最保险的写法,concat、展开运算符、字符串拼接 key,这三样在绝大多数基础库版本上都稳。花哨的新语法留给非关键路径,出问题也不影响主流程。

还有个小细节,模板字符串拼 key 的时候,变量一定要用Number()兜一下,避免出现list[1.5]这种奇怪的下标。虽然业务上基本不会出现,但如果你用scrollTop / itemHeight算下标又忘了取整,就很容易踩到。

7.2 setData 的回调与渲染时机

setData有第二个参数,是回调函数,在视图层更新完成之后才执行。这个特性在数组操作里很有用,尤其是需要在渲染后立即测量节点位置的场景:

this.setData({ list: newList }, () => { // 这里可以安全地拿列表的最新高度 this.createSelectorQuery() .select('.list') .boundingClientRect(rect => { // 处理滚动到底部等逻辑 }) .exec(); });

不过要注意,setData回调能保证“数据已经渲染”,但不保证“图片加载完成”。如果你的列表里有图片,节点高度会随着图片加载继续变化,这时候需要用图片的bindload去触发重算,不能只依赖回调。

7.3 深拷贝与引用共享的隐患

最后一个坑,也是最难查的一个:你从this.data里取出来的对象,和this.data里的是同一个引用。

const item = this.data.list[0]; item.title = '改了'; // 此时 this.data.list[0].title 已经被改了,虽然你没 setData

这会导致什么?会导致你在做“取消操作”或者“回滚”的时候,拿不到原始值。解决方法是取出后先浅拷贝一层:

const item = { ...this.data.list[0] };

如果对象里还有嵌套数组或对象,浅拷贝也不够,需要深拷贝。业务数据简单的话,JSON.parse(JSON.stringify(x))就够用;数据里有Date、undefined、函数的时候就不能用这招,得手写递归或者用结构化的克隆方式。

我个人的原则是:凡是打算改的数据,先从this.data复制出来。多写一行拷贝,能省掉以后半天的排查。

数组添加数据这件事,说到底就三段:算清楚新数据是什么、想清楚视图层需要知道哪部分、用最省的方式把那部分送过去。新人最容易犯的错是只做了第一段,忽略了第三段;而有了一定经验之后,反而容易在第三段上过度设计,把简单的追加写成一大坨 diff 逻辑。我的经验是,列表在两百条以内就别优化了,展开运算符 + 整体传,代码好读、出问题好查;真到了几百上千条,再去考虑路径式、分批、虚拟列表这些手段,而且一定要用真机测,模拟器上的流畅度参考价值有限。最后再提醒一句,wx:key从第一天就老老实实用业务唯一 id,别用下标图省事,等你要删除或者插入中间项的时候,会感谢当初的自己。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进