小程序开发必看:小程序极致性能优化之 setData,让你的 setData速度翻倍提升

2,741 阅读15分钟

对于setData优化相关,官方已做了详细的说明和优化建议,那为什么你还需要看这篇文章?

因为本篇可以解决你对setData的所有疑问:

  1. 彻底搞清楚为什么要优化 setData ?小程序为什么要设计 setData 来更新数据?
  2. setData 都做了哪些事?为什么 setData 那么消耗性能 ?优化完能带来多少性能提升?
  3. 有哪些优化方法和思路?
  4. 人工优化太麻烦?别怕,有问题就有对策!

为什么要重视优化setData?

1. 由于小程序双线程模型的运行机制,setData会产生额外的通信消耗

  • 网页开发中JS主线程(负责解析和执行js代码,例如V8引擎就在JS线程上运行)和渲染线程(负责DOM计算和绘制相关)是互斥的(因为渲染线程和JS线程都可以操作DOM,同时运行容易发生混乱),而小程序的逻辑层和渲染层是分开的。

  • 小程序采用 AppServiceWebView 的双线程模型(App 进程和 WebView 进程中的两个线程),视图渲染采用基于 WebView 和原生控件混合渲染的方式,这两个线程没有互斥的关系(因为处于不同的进程),可以在Native 客户端的协调下有条不紊的同时执行。官方文档中有这样一段话,解释了为什么采用双线程模型:

    当小程序基于 WebView 环境下时,WebView 的 JS 逻辑、DOM 树创建、CSS 解析、样式计算、Layout、Paint (Composite) 都发生在 同一线程,在 WebView 上执行过多的 JS 逻辑可能阻塞渲染,导致界面卡顿。以此为前提,小程序同时考虑了性能与安全,采用了目前称为「双线程模型」的架构。

    上面说的 “同一线程” 其实指的是webView的渲染进程(渲染进程中包含渲染线程和JS主线程等),网页开发中提到的渲染线程与JS主线程互斥和阻塞的问题在 WebView 环境下也不例外,所以小程序则将webView 中的 JS 逻辑部分拆到App级(App进程中不同于WebView环境,无法访问window document等对象)的JS线程中运行即所谓的 AppServiceWebViewApp 是独立的运行环境,虽然可以同时运行不会发生阻塞,但是也无法共享数据,所以要依靠 Native (微信客户端提供的WeixinJsBridge) 来通讯,那么必然会产生通信耗时。

2. setData整个流程执行任务较多,耗时较大

调用 setData 方法时,会产生如下操作:

  • 逻辑层虚拟 DOM 树的遍历和更新,触发组件生命周期和 observer(监听器) 等;

  • 将 data 从逻辑层传输到视图层;

    这个过程需要进行数据的序列化(JSON.stringfy())、跨线程/进程的数据传输(两个线程需要Native来中转,即上面说的通讯)、数据的反序列化(JSON.parse())。

    JSON.stringfy()内部会进行递归、遍历、多个判断、字符串拼接,键越多或嵌套越深耗时则越多。

    iOS/iPadOS/MacOS 上,数据传输是通过 evaluateJavascript 实现的,还会有额外 JS 脚本解析和执行的耗时。

  • 视图层虚拟 DOM 树的更新、真实 DOM 元素的更新并触发页面渲染更新,完成后触发setData回调。

双线程模型的优缺点

优点:

  • 将逻辑层和渲染层隔离开,用户无法直接操作DOM,提供了相对封闭和安全的运行环境。

  • JS执行不会阻塞或干扰webView渲染,但是大部分情况下视觉都要依赖JS中处理的数据,JS如果被阻塞(阻塞原因有逻辑重或请求慢等)了就不会通知视图去更新(即执行setData),所以这条优点其实意义不是很大!

    所以小程序官方搞出了 初始渲染缓存,会缓存初始data(不包含setData)的渲染结果,详情请看官方文档。

  • 所有的页面和组件的逻辑(js)都在一个线程(AppService)里,使用同一个上下文环境,比较好做状态共享或跨页面通讯。

    可以使用global对象试一下,如 global.a = 1; 另一个页面输出一下gobal可以看到a为1;

缺点:

  • 缺点在于每一次数据传递都要进行一次线程之间的通信,业务逻辑跟渲染层天然隔离,造成通信开销大、延迟高等问题,通信越频繁、数据量越大,则性能瓶颈越严重。

  • 每个页面都创建一个WebView线程处理,有更多的内存、时间开销。

  • 渲染层和逻辑层状态要维护两份,进一步加重内存、时间开销,并且没有办法完全保证两份数据状态实时保持一致,例如仅使用 this.data 更新数据而不是通过setData时,那么实际渲染的值与逻辑层的值就不一致,某些场景下会造成非预期的问题。

    尤其当 this.data.obj 这个obj指向一个引用地址的时候,带来的问题更加不可预期。

    this.viewModal = {obj: {a: 3}};
    this.setData({obj: this.viewModal.obj}); // 此时逻辑层的data.obj已经指向viewModal.obj对象的地址
    
    this.viewModal.obj.a = 666;
    this.data.obj.a === 666; // true
    
    // 之后无论你在任何地方不小心更改了this.viewModal.obj,那么this.data.obj也变了,而此时在渲染层的数据还是旧数据,你本意是想通过this.data获取当前被渲染的值,但是这时候可能值已经并不符合你的预期了。
    

总体上从开发者的角度来看,小程序的双线程模型架构并不是一个很好的架构,逻辑层与渲染层隔离,带来的问题远远比它解决的问题更多。

除了状态共享和跨页面通信外,几乎对开发者来说没啥吸引力,但是依旧很鸡肋:

  1. 一个应用里面的多个页面,你认为是共享的状态多,还是独立的状态多?
  2. 用户在操作页面时,更关心跨页面通信效率,还是更关心当前页面的渲染效率(页面里的内容是否顺滑)?

我们当然更关心性能,但是从微信的角度来说,他们想既能享受 web 生态的好处的同时也能限制 web 的开放性,增强自己对平台内容的管控程度,从禁用 evalnew Function() 上就能看出一二。

当然微信也知道架构所带来的性能问题,所以发明了 WXS ,让一部分 js 代码能在渲染层跑,部分解决通信消耗和延迟的问题,只能满足很小一部分场景,依旧很鸡肋。

理论上是可以在wxs中访问到window对象的,因为wxs代码运行在 webView 中,但是微信对wxs功能做了阉割限制,只提供很少一部分功能,尤其不能让用户操作DOM。

综上所述,优化setData是一个小程序应用无法忽略的关键一步,它占了小程序性能体验的大头,理应贯穿整个小程序开发周期。

优化建议

官方提供了5点优化建议,copy过来方便分析:

1. data 应只包括渲染相关的数据

setData 应只用来进行渲染相关的数据更新。用 setData 的方式更新渲染无关的字段,会触发额外的渲染流程,或者增加传输的数据量,影响渲染耗时。

  • ✅ 页面或组件的 data 字段,应用来存放和页面或组件渲染相关的数据(即直接在 wxml 中出现的字段);
  • ✅ 页面或组件渲染间接相关的数据可以设置为「纯数据字段」,可以使用 setData 设置并使用 observers 监听变化;
  • ✅ 页面或组件渲染无关的数据,应挂在非 data 的字段下,如 this.userData = {userId: 'xxx'}
  • ❌ 避免在 data 中包含渲染无关的业务数据;
  • ❌ 避免使用 data 在页面或组件方法间进行数据共享
  • ❌ 避免滥用 纯数据字段 来保存可以使用非 data 字段保存的数据。

官方为了优化减少双线程模型带来的性能问题可谓是操碎了心,为了不让开发者写太多与渲染层无关的数据给通信增加负担,又搞出了“纯数据字段”,用来在通信时过滤无用数据,虽然说是极其不优雅但也是无奈之举。

看一下这条:避免使用 data 在页面或组件方法间进行数据共享; 意思就是不要通过data.xxx的方式来共享数据,例如,组件获取页面实例然后通过PageInstance.data.xxx来获取页面的数据,而这个xxx与渲染层却无任何关系。

省流: 避免在data中添加与渲染层无关的数据,无论用来干什么

2. 控制 setData 的频率

每次 setData 都会触发逻辑层虚拟 DOM 树的遍历和更新,也可能会导致触发一次完整的页面渲染流程。过于频繁(毫秒级)的调用 setData ,会导致以下后果:

  • 逻辑层 JS 线程持续繁忙,无法正常响应用户操作的事件,也无法正常完成页面切换;
  • 视图层 JS 线程持续处于忙碌状态,逻辑层 -> 视图层通信耗时上升,视图层收到消息的延时较高,渲染出现明显延迟;
  • 视图层无法及时响应用户操作,用户滑动页面时感到明显卡顿,操作反馈延迟,用户操作事件无法及时传递到逻辑层,逻辑层亦无法及时将操作处理结果及时传递到视图层。

因此,开发者在调用 setData 时要注意:

  • ✅ 仅在需要进行页面内容更新时调用 setData;
  • ✅ 对连续的 setData 调用尽可能的进行合并
  • ❌ 避免不必要的 setData;
  • ❌ 避免以过高的频率持续调用 setData,例如毫秒级的倒计时;
  • ❌ 避免在 onPageScroll 回调中每次都调用 setData。

省流:尽可能地减少调用setData

3. 选择合适的 setData 范围

组件的 setData 只会引起当前组件和子组件的更新,可以降低虚拟 DOM 更新时的计算开销。

  • ✅ 对于需要频繁更新的页面元素(例如:秒杀倒计时),可以封装为独立的组件,在组件内进行 setData 操作。必要时可以使用 CSS contain 属性限制计算布局、样式和绘制等的范围。

组件的setData只会触发组件内的虚拟DOM的计算,相比于整个页面会少一些通信耗时,否则会频繁触发整个页面的虚拟DOM计算。 省流:将频繁更新的页面元素封装为组件

4. setData 应只传发生变化的数据

setData 的数据量会影响数据拷贝和数据通讯的耗时,增加页面更新的开销,造成页面更新延迟。

  • ✅ setData 应只传入发生变化的字段;
  • ✅ 建议以数据路径形式改变数组中的某一项或对象的某个属性,如 this.setData({'array[2].message': 'newVal', 'a.b.c.d': 'newVal'}) ,而不是每次都更新整个对象或数组;
  • ❌ 不要在 setData 中偷懒一次性传所有data: this.setData(this.data)

通信过程中会有遍历操作,数据越多耗时越多,已经被渲染过的数据重复渲染毫无意义,却额外增加了很大性能消耗,例如分页列表加载场景,可以使用二维数组优化,避免下面这种写法。

const list = [...]; // length: 10
const newList = [].concat(list); // newList 会越来越大,足够大时页面会有明显卡顿,甚至崩溃,其实每次仅需更新10条,其余合并的都是已经渲染过的
this.setData({list: newList})

省流:setData 应只传入数据发生变化的字段

5. 控制后台态页面的 setData

由于小程序逻辑层是单线程运行的,后台态页面去 setData 也会抢占前台页面的运行资源,且后台态页面的的渲染用户是无法感知的,会产生浪费。在某些平台上,小程序渲染层各 WebView 也是共享同一个线程,后台页面的渲染和逻辑执行也会导致前台页面的卡顿。

  • ✅ 页面切后台后的更新操作,应尽量避免,或延迟到页面 onShow 后延迟进行;
  • ❌ 避免在切后台后仍进行高频的 setData,例如倒计时更新。

逻辑层上面已经说过,运行在App进程的JS线程中,JS是单线程语言,遵循事件循环机制,任务在调用栈中后进先出依次执行,上一个任务没执行完,下一个就一直等待,所以这些后台执行的setData也会抢占前台资源。

可视窗口外的 setData 严格来说也算“后台”运行的 setData

省流:避免在用户无法感知的场景(后台)调用setData

最后再总结一下:

  • 避免在data中添加与渲染层无关的数据
  • 尽可能地减少调用setData
  • 将频繁更新的页面元素封装为组件,减小 setData 触发的计算的范围
  • setData 应只传入数据发生变化的字段
  • 避免在用户无法感知的场景(后台)调用setData

总之,这几点优化建议的根本要义就是调用少,数据精,即尽可能减少调用次数,尽可能让每次调用都只传输必要的数据。

优化实践

纸上得来终觉浅,理论还得去实践~

1. 删掉渲染层用不到的数据,全都改到 this 或下其他方式 --- 难度系数 ⭐️

**2. 将频繁更新的页面元素封装为组件,大多数场景我们都是在组件化开发,这点几乎没有难度,只不过需要额外留意“频繁更新”这个关键词,看有没有漏掉的 ** --- 难度系数 ⭐️

3. 检查后台运行的 setData,包括不在可视窗口内的,改成进入后台后暂停 setData,比如轮播,倒计时等场景可能为高发地段 --- 难度系数 ⭐️⭐️

鉴于人工检查、分析较为耗费精力,加一颗星

4. 减少调用 setData ,合并 setData --- 难度系数 ⭐️⭐️⭐️⭐️⭐️

看着不难做到,为啥五颗星?

由于函数式编程和函数单一职责原则,为了更好的可读性和可维护性,我们的代码往往要实现低耦合,这意味着某些场景我们不得不把 setData 分散到各个函数,而不能把它们糅杂到一起,造成的问题显而易见,每个 setData 都会产生通信消耗,那将浪费不少性能,能够完美的在性能和可维护性之间做好平衡是不容易的,大多数情况我们都是取可维护性而舍性能。

5. setData 只传入数据发生变化的字段,使用数据路径形式替换直接更新某个对象或数组 --- 难度系数 ⭐️⭐️⭐️⭐️⭐️

分页列表使用二维数组实现; 避免使用 this.setData(...obj)...本身就是遍历迭代器的操作,比 forEach 性能还要差一些,如果仅仅是 obj里的属性变化,使用数据路径形式替代,只更新必要字段,而且...也不直观;

除此之外,还有很多不易发现或者不易判断是否发生变化的属性,在开发过程中不可避免地会被遗漏掉,全都考虑的面面俱到的话整个开发过程会极为复杂,在setData一个属性时需要留意该属性目前可能是处于一个什么样的状态,做出判断,甚至得为了只更新变化的数据而多写很多逻辑,这样虽然可能性能有所提升,但是对于开发者来说极不友好,写个 setData 都得思前顾后。。。

还有些是我们为了更好的可读性、代码的简洁性主动忽略掉这点的。

因此,这样被重复渲染的数据在大多数项目中都绝不在少数,只是由于无法量化,且在当今的一些高性能手机上表现也过得去,所以大家也就没太在意,但是优化的空间肯定是存在的,且非常大,只不过优化成本较高,费心费力,所以此题也给 5 颗星。

至此,可优化空间这么吸引人,那绝不能带着遗憾戛然而止,有痛点就要解决掉!下一篇我们讲一下如何利用工具自动优化 setData ,让你放心地使用 setData ,开发中只需关注业务逻辑,提高开发效率和开发体验的同时也能大幅提高性能。

image.png

直达:juejin.cn/post/716047…

感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与大家分享更多干货