小程序组件化框架 WePY 在性能调优上做出的探究
in 小程序 with 0 comment

小程序组件化框架 WePY 在性能调优上做出的探究

in 小程序 with 0 comment

预先加载

原理

传统H5中也可以通过预加载来提升用户体验,但在小程序中做到这一点实际上是可以更简单方便却又更容易被忽视的。

传统H5在启动时,page1.html 只会加载 page1.html 的页面与逻辑代码,当page1.html 跳转至 page2.html 时,page1 所有的 Javascript 数据将会从内存中消失。page1 与 page2 之间的数据通信只能通过 URL 参数传递或者浏览器的 cookie,localStorge 存储处理。

小程序在启动时,会直接加载所有页面逻辑代码进内存,即便 page2 可能都不会被使用。在 page1 跳转至 page2 时,page1 的逻辑代码 Javascript 数据也不会从内存中消失。page2 甚至可以直接访问 page1 中的数据。

最简单的验证方式就是在 page1 中加入一个 setInterval(function () {console.log('exist')}, 1000)。传统H5中跳转后定时器会自动消失,小程序中跳转后定时器仍然工作。

小程序的这种机制差异正好可以更好的实现预加载。通常情况下,我们习惯将数据拉取写在 onLoad 事件中。但是小程序的 page1 跳转到 page2,到 page2 的 onLoad 是存在一个 300ms ~ 400ms 的延时的。如下图:

1.png

因为小程序的特性,完全可以在 page1 中预先拿取数据,然后在 page2 中直接使用数据,这样就可以避开 redirecting 的 300ms ~ 400ms了。如下图:

2.png

试验

在官方demo中加入两个页面:page1,page2

 // page1.js 点击事件中记录开始时间
 bindTap: function () {
   wx.startTime = +new Date();
   wx.navigateTo({
     url: '../page2/page2'
   });
 }


 // page2.js 中假设从服务器拉取数据需要500ms
 fetchData: function (cb) {
   setTimeout(function () {
     cb({a:1});
   }, 500);
 },
 onLoad: function () {
   wx.endTime = +new Date();
   this.fetchData(function () {
     wx.endFetch = +new Date();
     console.log('page1 redirect start -> page2 onload invoke -> fetch data complete: ' + (wx.endTime - wx.startTime) + 'ms - ' + (wx.endFetch - wx.endTime) + 'ms');
   });
 }

重试10次,得到的结果如下:

3.png

优化

对于上述问题,WePY 中封装了两种概念去解决:

扩展了生命周期,添加了onPrefetch事件,会在 redirect 之时被主动调用。同时给onLoad事件添加了一个参数,用于接收预加载或者是预查询的数据:

 // params
 // data.from: 来源页面,page1
 // data.prefetch: 预查询数据
 // data.preload: 预加载数据
 onLoad (params, data) {}

预加载数据示例:

// page1.wpy 预先加载 page2 需要的数据。

 methods: {
   tap () {
     this.$redirect('./page2');
   }
 },
 onLoad () {
   setTimeout(() => {
     this.$preload('list', api.getBigList())
   }, 3000)
 }

 // page2.wpy 直接从参数中拿到 page1 中预先加载的数据
 onLoad (params, data) {
   data.preload.list.then((list) => render(list));
 }

预查询数据示例:

// page1.wpy 使用封装的 redirect 方法跳转时,会调用 page2 的 onPrefetch 方法
 methods: {
   tap () {
     this.$redirect('./page2');
   }
 }

 // page2.wpy 直接从参数中拿到 onPrefetch 中返回的数据
 onPrefetch () {
   return api.getBigList();
 }
 onLoad (params, data) {
   data.prefetch.then((list) => render(list));
 }

数据绑定

原理

在针对数据绑定做优化时,需要先了解小程序的运行机制。因为视图层与逻辑层的完全分离,所以二者之间的通信全都依赖于 WeixinJSBridge 实现。如:

因此数据绑定方法this.setData也如此,频繁的数据绑定就增加了通信的成本。再来看看this.setData究竟做了哪些事情。基于开发者工具的代码,单步调试大致还原出完整的流程,以下是还原后的代码:

 /*
 setData 主流程精简还原,并非完整主流程,内有注释
 */
 function setData (obj) {
     if (typeof(obj) !== 'Object') {
         console.log('类型错误'); // 并没有预期中的return;
     }
     let type = 'appDataChange';

     // u.default.emit(e, this.__wxWebviewId__) 代码还原
     let e = [type, {
                 data: {data: list}, 
                 options: {timestamp: +new Date()}
             },
             [0] // this.__wxWebviewId__
     }];

     // WeixinJSBridge.publish.apply(WeixinJSBridge, e); 代码还原
     var datalength = JSON.stringify(e.data).length;  // 第一次 JSON.stringify
     if (datalength > AppserviceMaxDataSize) { // AppserviceMaxDataSize === 1048576
         console.error('已经超过最大长度');
         return;
     }

     if (type === 'appDataChange' || type === 'pageInitData' || type === '__updateAppData') {

         // sendMsgToNW({appData: __wxAppData, sdkName: "send_app_data"}) 代码还原
         __wxAppData = {
             'pages/page1/page1': alldata
         }
         e = { appData: __wxAppData, sdkName: "send_app_data" }

         var postdata = JSON.parse(JSON.stringify(e)); // 第二次 JSON.stringify 第一次 JSON.parse
         window.postMessage({
             postdata
         }, "*");
     }


     // sendMsgToNW({appData: __wxAppData, sdkName: "send_app_data"}) 代码还原
     e = {
         eventName: type,
         data: e[1],
         webviewIds: [0],
         sdkName: 'publish'
     };

     var postdata = JSON.parse(JSON.stringify(e));  // 第三次 JSON.stringify 第二次 JSON.parse
     window.postMessage({
         postdata
     }, "*");
 }

setData 运行的流程如下:
4.png

从上面代码以及流程图中可以看出,在一次setData({a: 1})作时,会进行三次 JSON.stringify,二次JSON.parse以及两次window.postMessage操作。并且在第一次window.postMessage时,并不是单单只处理传递的{a:1},而是处理当前页面的所有 data 数据。因此可想而知每次setData操作的开销是非常大的,只能通过减少数据量,以及减少setData操作来规避。

与 setData 相近的是 React 的 setState 方法,同样是使用 setState 去更新视图的,可以通过源码 React:L199 看到 setState 的关键代码如下:

 function enqueueUpdate(component) {
   ensureInjected();
   if (!batchingStrategy.isBatchingUpdates) {
     batchingStrategy.batchedUpdates(enqueueUpdate, component);
     return;
   }
   dirtyComponents.push(component);
 }

setState的工作流程如下:
5.png
可以看出,setState 加入了一个缓冲列队,在同一执行流程中进行多次 setState 之后也不会重复渲染视图,这就是一种很好的优化方式。

实验

为了证实setData的性能问题,可以写简单的测试例子去测试:
动态绑定1000条数据的列表进行性能测试,这里测试了三种情况:

 // page1.wxml
 <view bindtap="worse">
   <text class="user-motto">worse数据绑定测试</text>
 </view>
 <view bindtap="best">
   <text class="user-motto">best数据绑定测试</text>
 </view>
 <view bindtap="digest">
   <text class="user-motto">digest数据绑定测试</text>
 </view>

 <view class="list">
   <view wx:for="{{list}}" wx:for-index="index"wx:for-item="item">
       <text>{{item.id}}</text>---<text>{{item.name}}</text>
   </view>
 </view>


 // page1.js
 worse: function () {
    var start = +new Date();
    for (var i = 0; i < 1000; i++) {
      this.data.list.push({id: i, name: Math.random()});
      this.setData({list: this.data.list});
    }
    var end = +new Date();
    console.log(end - start);
 },
 best: function () {
   var start = +new Date();
   for (var i = 0; i < 1000; i++) {
     this.data.list.push({id: i, name: Math.random()});
   }
   this.setData({list: this.data.list});
   var end = +new Date();
   console.log(end - start);
 },
 digest: function () {
   var start = +new Date();
   for (var i = 0; i < 1000; i++) {
     this.data.list.push({id: i, name: Math.random()});
   }
   var data = this.data;
   var $data = this.$data;
   var readyToSet = {};
   for (k in data)  {
     if (!util.$isEqual(data[k], $data[k])) {
       readyToSet[k] = data[k];
       $data[k] = util.$copy(data[k], true);
     }
   }
   if (Object.keys(readyToSet).length) {
     this.setData(readyToSet);
   }
   var end = +new Date();
   console.log(end - start);
 },
 onLoad: function () {
   this.$data = util.$copy(this.data, true);
 }

在经过十次刷新运行测试后得出以下结果:

6.png

实现同样的逻辑,性能数据却相差40倍左右。由此可以看出,在开发过程中,一定要避免同一流程内多次 setData 操作。

优化

在开发时,避免在同一流程内多次使用setData当然是最佳实践。采取人工维护肯定是能够实现的,就好比能用原生 js 能写出比众多框架更高效的性能一样。但当页面逻辑负责起来之后,花很大的精力去维护都不一定能保证每个流程只存在一次setData,而且可维护性也不高。因此,WePY选择使用脏检查去做数据绑定优化。用户不用再担心在我的流程里,数据被修改了多少次,只会在流程最后做一次脏检查,并且按需执行setData。

脏检测机制借鉴自AngularJS,多数人一听到脏检查都会觉得是低效率的一种作法,认为使用 Vue.js 中的 getter,setter更高效。其实不然,两种机制都是对同一件事的不同实现方式。各有优劣,取决于使用的人在使用过程中是否正好放大了机制中的劣势面。

WePY 中的 setData 就好比是一个 setter,在每次调用时都会去渲染视图。因此如果再封装一层 getter、setter 就完全没有意义,没有任何优化可言。这也就是为什么一个类 Vue.js 的小程序框架却选择了与之相反的另外一种数据绑定方式。

再回来看脏检查的问题在哪里,从上面实验的代码可以看出,脏检查的性能问题在于每次进行脏检查时,需要遍历所以数据并且作值的深比较,性能取决于遍历以及比较数据的大小。WePY 中深比较是使用的 underscore 的 isEqual 方法。为了验证效率问题,使用不同的比较方法对一个 16.7 KB 的复杂 JSON 数据进行深比较,测试用例请看这里:deep-compare-test-case (Deep Object Compare)

得到的结果如下:
7.png
从结果来看,对于一个 16.7 KB 的数据深比较是完全不足以产生性能问题的。那 AngularJS 1.x 脏检查的性能问题是怎么出现的呢?

AngularJS 1.x 中没有组件的概念,页面数据就位于 controller 的 $scope 当中。每一次脏检查都是从 $rootScope 开始,随后遍历至所有子 $scope。参考这里 angular.js:L1081。对于一个大型的单页应用来说,所有 $scope 中的数据可能达到了上百甚至上千个都有可能。那时,脏检查的每次遍历就可能真的会成为了性能的瓶颈了。

反观 WePY,使用类似于 Vue.js 的组件化开发,在抛开父子组件双向绑定通信的情况下,组件的脏检查仅针对组件本身的数据进行,一个组件的数据通常不会太多,数据太多时可以细化组件划分的粒度。因此在这种情况下,脏检查并不会导致性能问题。

其实,在很多情况下,框架封装的解决方案都不是性能优化的最优解决方案,使用原生肯定能优化出更快的代码。但它们之所以存在并且有价值,那都是因为它们是在性能、开发效率、可维护性上寻找到一个平衡点,这也是为什么 WePY 选择使用脏检查作为数据绑定的优化。

其它优化

组件化开发

支持组件循环、嵌套,支持组件 Props 传值,组件事件通信等等。

 parent.wpy
 <child :item.sync="myitem" />

 <repeat for="{{list}}" item="item" index="index">
    <item :item="item" />
 </repeat>

支持丰富的编译器

js 可以选择用 Babel 或者 TypeScript 编译。
wxml 可以选择使用 Pug(原Jade)。
wxss 可以选择使用 Less、Sass、Styus。

生命周期优化

添加了 onRoute 的生命周期。用于页面跳转后触发。
因为并不存在一个页面跳转事件(onShow 事件可以用作页面跳转事件,但同时也存在负作用,比如按 HOME 键后切回来,或者拉起支付后取消,拉起分享后取消都会触发 onShow 事件)。

转自 https://zhuanlan.zhihu.com/p/27283965

Responses