滴滴出行前端团队

滴滴出行前端团队 查看完整档案

北京编辑  |  填写毕业院校滴滴出行  |  前端 编辑 github.com/didi/mpx 编辑
编辑

滴滴出行网约车前端团队

个人动态

滴滴出行前端团队 发布了文章 · 9月7日

滴滴出行小程序I18n最佳实践

作者:sky-admin猫儿不熊

背景

I18n = Internationalization,国际化,因为单词由首末字符i/n和中间18个字母组成,简称i18n。对程序来说,就是要在不修改内部代码的情况下,能根据不同语言及地区显示相应的界面,以支持不同语言的人顺利使用程序。

业务背景

互联网行业进入下半场,精细化运营是关键。多语言支持能让产品更好地服务境内的其他语言用户,也为产品出海打下基础,随着 WeChat/Alipay 的全球化,你的小程序是否做好准备了呢?

4月初,滴滴出行小程序团队接到支持英文版的需求,预计上线时间为6月上旬。当前滴滴出行小程序集成的众多业务线和各种公共库,展示给用户的有前端硬编码的静态文本和服务端下发的文案,都要同步接入多语言。考虑到小程序当前的体量,光文本收集、语料翻译、npm package 支持,联调,测试,沟通成本等等,并且前端开发只投入1.5人力的情况下,时间是蛮紧迫的,但是我们抗住了压力,最终英文版滴滴出行小程序如期上线,截止目前运行稳定,用户反馈良好,得到了超出预期的收益。

当然这一切得益于各团队同学的高效工作,和各团队的通力配合,更得益于部门技术团队 Mpx框架优雅的多语言能力支持。划重点来咯,所谓工欲善其事必先利其器,如果你的公司业务需要开发小程序,也需要接入多语言,那么请搬好小板凳,我们来看一下小程序框架 Mpx 是如何优雅支持多语言能力。相信看完这篇,可以帮助你认识 Mpx(https://github.com/didi/mpx) ,加深对框架的理解,最终利用 Mpx 框架高效迭代小程序,年终奖多出那部分可以打赏一下作者,买杯咖啡哈(偷笑.jpg)

以下是滴滴出行小程序的中英文版本对比:

滴滴出行微信小程序i18n

也欢迎大家在微信/支付宝里搜索滴滴出行小程序,实际使用感受下。PS:切换语言的方法是,打开小程序,点击左上角用户头像,进入侧边栏设置页面,点击切换中英文即可体验。

技术背景

在上述业务背景下,Mpx 框架——滴滴自研的专注提升小程序开发体验的增强型小程序框架,内建 i18n 能力便提上日程。

与 WEB 不同,小程序(本文以微信小程序为例)运行环境采用双线程架构设计,渲染层的界面使用 WebView 进行渲染,逻辑层采用 JSCore 线程运行 JS脚本。逻辑层数据改变,通过 setData 将数据转发到 Native(微信客户端),Native 再将数据转发到渲染层,以此更新页面。由于线程间通信成本较高,实际项目开发时需要控制频次和数量。另外小程序的渲染层不支持运行 JS ,一些如事件处理等操作无法在渲染层实现,因此微信官方提供了一套脚本语言 WXS ,结合 WXML ,可以构建出页面的结构(不了解 WXS ?戳这里)。

基于小程序的双线程架构设计,实现 i18n 存在一些技术上的难点与挑战,由于 Mpx 框架早期构建起来的强大基础,最终得以优雅支持多语言能力,实现了和vue-i18n 基本一致的使用体验。

使用

在使用上,Mpx 支持 i18n 能力提供的 API 与 vue-i18n 大体对齐,用法上也基本一致。

模板中使用 i18n

编译阶段通过用户配置的 i18n 字典,结合框架内建的翻译函数通过 wxs-i18n-loader 合成为可执行的 WXS 翻译函数,并自动注入到有翻译函数调用的模板中,具体调用方式如下图。

// mpx文件
<template>
  <view>
    <view>{{ $t('message.hello', { msg: 'hello' })}}</view>
    <!-- formattedDatetime计算属性,可基于locale变更响应刷新 -->
    <view>{{formattedDatetime}}</view>
  </view>
</template>

JS 中使用 i18n

通过框架提供的 wxs2js 能力,将 WXS 翻译函数转换为 JS 模块注入到 JS 运行时,使运行时环境中也能够调用翻译函数。

// mpx文件
<script>
  import mpx, { createComponent } from '@mpxjs/core'
  createComponent({
    ready () {
      // js中使用
      console.log(this.$t('message.hello', { msg: 'hello' }))
      // 局部locale变更,生效范围为当前组件内
      this.$i18n.locale = 'en-US'
      setTimeout(() => {
        // 全局locale变更,生效范围为项目全局
        mpx.i18n.locale = 'zh-CN'
      }, 10000)
    },
    computed: {
      formattedDatetime () {
        return this.$d(new Date(), 'long')
      }
    }
  })
</script>

定义 i18n 字典

项目构建时传入 i18n 配置对象,主要包括语言字典和默认语言类型。

new MpxWebpackPlugin({
  i18n: {
    locale: 'en-US',
    // messages既可以通过对象字面量传入,也可以通过messagesPath指定一个js模块路径,在该模块中定义配置并导出,dateTimeFormats/dateTimeFormatsPath和numberFormats/numberFormatsPath同理
    messages: {
      'en-US': {
        message: {
          hello: '{msg} world'
        }
      },
      'zh-CN': {
        message: {
          hello: '{msg} 世界'
        }
      }
    },
    // messagesPath: path.resolve(__dirname, '../src/i18n.js')
  }
})

如果是通过 Mpx 提供的 cli 工具生成的项目,这部分配置会在 mpx.conf.js 文件中,不光可以直接内联写在该文件中,也可以指定语言包的路径。

以上,Mpx 的 i18n 方案接入成本低,使用优雅,体验优秀。直观感受可参考下面 mpx i18n demo :https://github.com/didi/mpx/t...

方案

Mpx框架的 i18n 支持几乎完全实现了 vue-i18n 的全部能力,下面我们来详细说明 Mpx 框架 i18n 能力的具体实现。

方案探索

基于小程序运行环境的双线程架构,我们尝试了不同方案,具体探索过程如下:

方案一:基于 Mpx 框架已提供的数据增强能力 computed 计算属性,来支持 i18n 。该方案与 uniapp 的实现思路相似(后文会进行对比分析),存在一定不足,包括线程通信带来的性能开销和for循环场景下的处理较复杂等,最终放弃。
方案二:基于 WXS + JS 支持 i18n 适配。通过视图层注入 WXS,将 WXS 语法转换为 JS 后注入到逻辑层,这样视图层和逻辑层均可实现 i18n 适配,并且在一定程度上有效减少两个线程间的通信耗时,提高性能。

从性能和合理性上考虑,我们最终采用了方案二进行 Mpx 的 i18n 方案实现。

mpx-i18n内部流程示意图

Mpx i18n 架构设计图

由于各大小程序平台上,WXS 语法和使用均存在较大差异,因此该方案实现过程中也存在一些技术上的难点,这些难点基于 Mpx 框架的早期构建起来的跨平台能力也一一得以攻克,具体如下。

实现难点

WXS 在模板中运行的跨平台处理

WXS 是运行在视图层中的 JS,可以减少与逻辑层通信耗时,提高性能。因此 Mpx 框架在迭代初期便已支持在模板和 JS 运行环境使用 WXS 语言,并且针对小程序跨平台 WXS 语法进行抹平。
在模板中,Mpx 自定义一个 webpack chunk template,以微信 WXS 作为 DSL,利用 babylon 将注入的 WXS 转化成 ast,然后遍历 ast 节点,抹平各大平台对 WXS 语法的处理差异,输出各平台可以识别的类 WXS 文件。目前主要支持微信(WXS)、支付宝(sjs)、百度(filter)、QQ(qs)、头条(sjs)等小程序平台。

WXS 在逻辑层运行的跨平台处理

WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致。并且 WXS 的运行环境和其他 JavaScript 代码是隔离的,WXS 中不能调用其他 JavaScript 文件中定义的函数,也不能调用小程序提供的API。
因此在逻辑层,Mpx 将注入的 WXS 语法转化为 JS,通过 webpack 注入到当前模块。例如 WXS 全局方法 getRegExp/getDate 在 JS 中是无法调用的,Mpx将它们分别转化成 JS 模块,再通过 webpack addVariable 将模块注入到 bundle.js 中。
同理,Mpx 会将编译时注入的 i18n wxs 翻译函数和 i18n 配置对象挂载到全局 global 对象上,利用 mixin 混入到页面组件,并监听 i18n 配置对象,这样JS和模板中即可直接调用 i18n 翻译函数,实现数据响应。

以上便是 Mpx 框架在小程序中支持 i18n 能力的技术细节,由于 WXS 是可以在视图层执行的类 JS 语法的一门语言,这样就减少了小程序逻辑层和视图层的通信耗时,提升性能。但是由于实现依赖类 WXS 能力,以及 WXS 执行环境的限制,目前模板上可直接使用的翻译函数包括 $t/$tc/$te ,如果需要格式化数字或日期可以使用对应的翻译函数在 JS 中 Mpx 提供的计算属性中实现。

输出 web 时使用 i18n

Mpx同时还支持转换产出H5,而 Mpx 提供的 i18n 能力在使用上与 vue-i18n 基本一致,输出 web 时框架会自动引入 vue-i18n,并使用当前的 Mpx i18n 配置信息对其进行初始化,用户无需进行任何更改,即可输出和小程序表现完全一致的 i18n web 项目。

对比

上面分析了 Mpx 框架的 i18n 方案的技术细节,我们来看下和其他方案的对比,主要是和 uniapp - 基于 Vue 编写小程序的方案,和微信官方的方案,两者提供的 i18n 支持与Mpx的对比有何优劣。

uniapp的方案

uniapp 提供了对 i18n 能力的支持,是直接引入vue-i18n。但小程序中无法在模板上调用 JS 方法,本质上是利用计算属性 Computed 转换好语言,然后利用模板插值在小程序模板中使用。

模板中:
<view>{{ message.hello }}</view>

JS里需要写:

  computed: {  
    message () {  
      return { hello: this.$t('message.hello') }
    }
  }

因此该方案存在一个性能问题,最终的渲染层所看到的文本还是通过 setData 跨线程通信完成,这样就会导致线程间通信增多,性能开销较大。

并且,早期这种形式使用成本较高,后来 uniapp 也针对其做过优化,实现了可以在模板上写 $t() 的能力,使用上方便了不少。

这个 $t() 的实现是在编译时候识别到 $t 就自动替换,帮你替换成一个 uniapp 的 computed 数据,因此数据部分还是和之前一样要维护两份。尤其是模板上的for循环,即使 for 里只有一个数据要被转换,整个列表都要被替换成一个计算属性,在线程间通信时进一步加大了性能开销。

微信官方的方案

微信小程序本身也提供了一个 i18n 的方案,仓库地址是:wechat-miniprogram/miniprogram-i18n 。

这个方案从 i18n 本身的实现来讲和Mpx框架的设计是类似的,也是基于 WXS 实现(英雄所见略同啊)。但因为周边配套上没有完整的体系,整体使用体验上就也略逊于基于Mpx框架来开发支持 i18n 的国际化小程序了。

主要的点就是,官方提供的方案,要基于 gulp 工具进行一次额外构建,同时在JS中使用时候还要额外引入一个 behavior 去让JS中也可以使用翻译能力。

而Mpx框架通过一次统一的Webpack构建产出完整的内容,用户无需担心语言包更新后忘记重新构建,在JS中使用的时候不光更方便,而且语言信息还是个响应式的,任何组件都可以很方便地监听语言值的变化去做一些其他的事情。

最后,Mpx的 i18n 方案对比微信官方的方案还有个巨大的优点,结合Mpx的跨平台能力,能实现均以这个方案,一套代码产出支持微信/支付宝/百度/QQ/头条多个平台的支持 i18n 的小程序。

总结

Mpx 框架专注小程序开发,期望为开发者提供最舒适的开发体验,有众多优秀的功能特性,帮助开发者提效。本文介绍的是其内置的 i18n 能力,通过对比分析得出相比其他框架方案在使用成本和性能等方面有明显的优势,欢迎各位有相关需求的同学进行体验尝试。

未来 Mpx 还会持续迭代优化,提供更多更好的能力帮助小程序开发者提效。在使用过程中遇到任何问题,欢迎大家在 Git 上提 issue,团队成员会及时响应。同时也鼓励大家一起为开源社区做贡献,参与到 Mpx 共建中来,为小程序技术发展添砖加瓦。

Git地址 [https://github.com/didi/mpx]
Mpx文档 [https://mpxjs.cn/]

欢迎技术交流与反馈,顺便star一下鼓励开源项目贡献者,我们将持续发力贡献社区。

附:以往Mpx文章链接
滴滴开源小程序框架Mpx - https://mpxjs.cn/articles/1.0.html
滴滴小程序框架Mpx发布2.0,支持小程序跨平台开发,可直接转换已有微信小程序 - https://mpxjs.cn/articles/2.0.html
小程序开发者,为什么你应该尝试下MPX - https://mpxjs.cn/articles/mpx1.html
Mpx 小程序框架技术揭秘 - https://mpxjs.cn/articles/mpx2.html
滴滴出行小程序体积优化实践 - https://mpxjs.cn/articles/size-control.html

查看原文

赞 4 收藏 1 评论 2

滴滴出行前端团队 赞了回答 · 8月24日

解决在微信小程序里,用Component构造器构造Page页面,有什么弊端吗?

可以用,没什么弊端,多注意一下组件的生命周期。事实上再 在组件的生命周期做一些事情比 页面的 onLoad 好一些
组件本身还可以添加 如下功能
computed 类似 vue 计算属性
observer 类似 vue watch
可以看一下我的 博客 从 VantComponent 谈 小程序维护,可以参考后实现一下 vue 风格的 Page。
如果对你有帮助可以在博客点一下赞以及采纳

--
2019-7-10 更新
刷到了 mpx框架滴滴开源小程序框架Mpx
实锤Component 比 Page 性能好,同时作为 mpx框架的基石。
clipboard.png
图片来源网络 如有侵权请联系删除

关注 2 回答 1

滴滴出行前端团队 发布了文章 · 6月15日

滴滴出行小程序体积优化实践

概述

2019年下半年,为了将微信钱包/支付宝九宫格入口的滴滴出行迁移为小程序,团队对小程序进行了大量的功能升级与补全。在整个过程中也遇到并克服了一系列问题和挑战,其中包体积问题尤为突出。接下来全面介绍一下滴滴出行小程序在体积控制方面做的努力与沉淀。

背景

微信对小程序包体积的要求是总体积不得超过12M,主包及单个分包体积不得超过2M。支付宝对于小程序包体积的计算方式虽和微信略有区别,不过整体也大同小异。

18年至19年初时,滴滴出行小程序里承载的业务只有网约车,且业务需求较少,在主包内都能够搞定。而在下半年时,为了将微信钱包/支付宝九宫格入口迁移至小程序,小程序开始新增诸如公交/代驾/车服/单车/顺风车等众多业务线,同时网约车的业务需求也要做全面的补齐,业务量和代码量一起爆炸式增长。

滴滴出行包含了丰富多样的出行业务,包含了快车/专车/出租车/豪华车/拼车/单车/代驾/顺风车/公交/车生活等众多业务线。整个滴滴出行小程序的最重要,使用最高频的页面是首页与订单详情页,首页中承载了各个业务线的需求表达,各个业务线的订单详情页则承载了具体的出行订单展示逻辑。此外还有各种功能页面比如个人中心,营销页面,设置,历史行程。

按照滴滴出行的产品逻辑,所有业务线的需求表达逻辑都在首页承载,为了良好的切换体验,在首页采用了单页顶导的方案进行业务线展示。即每个业务线在首页中提供一个需求表达组件,当用户切换顶导业务线后,切换出对应的业务线组件。

在这种设计下,所有的业务线的需求表达逻辑都集中在首页这个单一页面中,导致在业务迭代过程中,承载首页的主包体积迅速增长,很快触碰了小程序平台的单包2M上限,对后续的业务迭代与发展带来巨大阻碍。因此,对于包体积的控制是我们在小程序开发过程中面临的一大难题。

体积控制

下面我们将介绍滴滴出行小程序开发迭代过程中,我们对于小程序包体积进行的一系列优化控制实践。

基础优化手段

对于小程序来说,基础的包体积优化手段包括:资源压缩/去除代码冗余/资源CDN化/异步加载

在web开发中,webpack提供了大量的代码优化能力,包括依赖分析、模块去重、代码压缩、tree shaking、side effects等,这些能力可以方便地完成资源压缩和去除代码冗余的工作。滴滴出行小程序基于滴滴开源的小程序框架Mpx( https://github.com/didi/mpx )进行开发,Mpx框架的编译构建完全基于webpack,兼容webpack内部生态,天然可以使用上述能力对包体积进行优化。

小程序中支持部分静态资源(如图像视频等)使用CDN地址加载,我们会尽可能将相关的资源压缩后放到CDN上,避免这部分资源对包体积的占用。

小程序场景下无法像web当中通过script标签便捷地进行异步加载,但是小程序平台后期纷纷支持了分包加载的方案来实现该能力,由于分包加载是小程序特有的技术规范,webpack无法直接支持,因此Mpx框架专门针对该技术规范进行了良好的适配支持,关于该能力的应用我们会在后文详细阐述。

除此之外,Mpx框架还针对小程序场景进行了许多包体积优化的适配工作,如尽可能减少框架运行时包体积占用(压缩后占用56Kb),对引用到的页面/组件按需进行打包构建,声明公共样式进行样式复用,分包内公共模块抽取等。

在Mpx框架的这些能力的支持下,基本不需要额外配置就能构建出一个经过初步优化的小程序包。

微信开发者工具选项里也有类似的"上传代码时自动压缩混淆"可勾选,但在开发者工具中上传代码时计算体积是直接计算的当前项目代码的体积,并不会依据压缩后的体积。因此,如果你使用原生小程序进行开发,你的source代码极有可能进行进一步的压缩以节省空间。

分析体积

虽然框架已经提供了很多在体积控制方面的优化,但是随着业务迭代我们发现主包体积依然偏大。

在遇到主包体积偏大后,我们需要弄明白,主包里有哪些东西?它们为什么这么大?

使用原生小程序或者其他非基于webpack的框架进行开发的同学遇到这个问题后,可能只能去看硬盘上的文件大小。这样一来,各个模块的大小占比可能并不直观。而我们则可以借助 webpack-bundle-analyzer 这样一个webpack插件去做辅助分析。

比如这是一个使用Mpx框架编写的demo,通过 npm run build --report 就可以看到这样一个界面:

体积分析图

可以看到这个demo工程由 moment / lodash / socket-weapp / core-js 等第三方库组成。各个库的大小,相互依赖关系也能清晰地看出。

对于滴滴出行小程序也是能看到类似的图,能看到整个项目到底是由哪些代码组成。

另外,滴滴出行前端开发一直是采用“源码编译”的,可以让整个项目里公共的依赖可以实现仅有一份,一起共用。简而言之,也有助于减少项目代码体积。相关资料:https://github.com/DDFE/DDFE-blog/issues/23

要完美发挥源码编译的效果,需要上下游一起建立整套源码编译生态,比如主项目的依赖方在声明公用依赖时,就应该用peerDep或者devDep来声明一些公有依赖,这些共有依赖应该在主项目中统一声明,避免因版本不同装出两份公共依赖,那样反而会增大体积。由于滴滴出行小程序涉及业务线及团队众多,部分团队可能并不知道这个事情,因此代码里实际是可能出现上述劣化场景。而依照分析图,可以容易地发现这种问题,并推动相关团队清除这些重复依赖。

同时,我们依照体积分析图,对其中体积较大的文件重点分析,进行了一轮业务代码梳理和精简,删除了一些无用代码,精简了websocket的消息体描述文件等。

配置分包

分包是小程序给出的类似web异步引入的一个方案,把一些初始进入时不需要的页面可以放进分包里,跳转到对应页面时才去下载分包,将这些页面及其附属资源放到分包里可以有效减少主包体积。

Mpx框架早期对分包规范进行了初步支持,资源访问规则保持和微信一致,主要根据资源存放的目录判断应该输出到主包还是分包。有这个能力后,我们把行程页抽到了分包,大概抽出了200多K左右的空间。

有了行程页的成功拆分后,我们开始对所有的非首页代码进行分包操作,比如起终点选择和个人中心。以及部分业务线的接入是通过npm的方式接入,我们也尽可能将这些业务线的所有非首页的代码放到了分包。

这里还有个题外话,得益于mpx早期设计了packages形式的业务组合方案,可以很方便地让业务独立开发,又能及其方便地整合。而后发现微信的分包的json配置设计和packages很像,就在这个基础上支持了微信的分包,用户侧仅需在原来的packages基础上加一个query标记这个分包的名字即可。

拆除各个分包后,整个项目结构大概如图:

分包一期结构图

初阶的分包工作进行完毕后,总计从主包里拆了差不多400K的空间到分包里。

分包资源精细化管理

上面提到,Mpx框架初期的分包处理规则是完全按照微信的方式,把在分包路径下的资源收集到分包里。而npm管理的资源因为都在node_modules目录下,不属于任何分包路径,则会被全部收集进主包。

比如之前我们有行程页分包,行程页自有的状态管理store整个都在行程页分包的路径下,就会被收集到行程页分包中。而行程页还用到了封装好的didi-socket库,这个库是公共的npm包,即使它只在行程页分包里被使用,但由于它本身路径是在node_modules下的,那么就会将其收集进主包里。

因为早期的一些设计,行程页的资源和首页是分割开的,都比较独立地存在于各自的路径下,一期的分包处理的大头也主要是行程页,它刚好契合了Mpx初期对分包处理上的特点,因此能较好地收集进行程页分包里。

随着业务迭代,后续大量业务线的接入都是通过npm进行的,就会有大量npm包资源,他们都在node_modules目录下,因此全部会被收集进主包。

所以Mpx框架进行了一系列改造:

  1. 在构建的依赖收集过程中,我们会对收集到的依赖打上标记,记录它是被哪些分包引入的。一旦它只有一个分包引入,它就会被输出到这个分包中。
  2. 我们会根据用户定义的分包配置,自动在 SplitChunksPlugin 中生成各个分包的 cacheGroups ,把分包中的复用模块抽取到分包下的bundle中。
  3. 对于组件和静态资源,如果他们被多个分包所引用且未在主包中引用,为了确保主包体积最优,这些资源将产生多份副本分别输出到对应分包中,而不会占用主包体积。

这样一来,不管分包中引用的资源原本在什么位置,最终输出时都会尽可能将其输出到dist的分包目录下,避免占用主包空间

这个改动完成后项目结构看似和之前一样,但得益于Mpx处理分包资源能力的升级,我们得以将业务线分包中引用的npm资源成功输出到其所在的分包目录下。

封面方案

再后来滴滴出行小程序需要替换微信/支付宝里原有的WebApp入口,小程序接入的业务线迅速增加,包体积迅速增长。

这个部分体积增长的主要原因前面提到过,所有的业务线都要接入到主页来展示。这也是由于业务特点决定的,滴滴出行提供了丰富的出行产品线,包括快车/专车/出租车/豪华车/拼车/单车/代驾/顺风行车等产品,用户是可能需要反复切换挑选的。这个过程还要保留起终点车型之类的信息,必须是一个页面内切换组件加一整套非常复杂的大型状态管理才能比较流畅顺滑地实现。而不能像一些电商/信息平台,将不同的功能拆分到不同页面,让用户通过首页的菜单进入子页面再进行操作,首页只承载入口,只有较少的业务逻辑,分包处理起来就会容易很多。

因此各个业务线都要提供首页组件进行接入。这个组件会在首页被用到,所以无论如何也拆不到分包里。最终,整个首页主包部分的体积可以分成两个部分:基础库和业务代码。两者的体积占比大概是公共依赖基础库占1M左右,业务代码占1M左右。

这么庞大的基础库体积主要是由于滴滴出行的业务线及业务团队众多,各方均有一些自己的基础依赖。比如网约车依赖的长链接通信pb数据描述文件,地图会依的大数计算库,顺风车依赖的CML框架运行时、代驾依赖的通信网关库,以及公用的组件库和polyfill等。

所以滴滴出行小程序面对的问题在当时已经无法用纯技术方案在短期内快速解决问题了,于是我们做了一个工程架构调整,可以叫封面页方案,解决了主包问题。

封面方案简单讲,就是做一个带滴滴出行Logo的封面作为启动页面,而页面一旦加载,立刻跳转另一个页面,这个页面真正承载业务,且它被放在分包里。

这个操作的意义在于,主包里就只剩下了所有方都要依赖的基础框架/库等,而业务全被抽离到了分包里。

封面方案结构图

这是封面方案完成后项目的结构图,之前很大块的首页业务逻辑被抽出到首页分包中了。

这样一个挪移的操作的结果是我们可以有2M的主包空间来乘放基础的公共的库,有一个2M左右的分包来乘放前面提到的滴滴出行的集成了各种业务的“大主页”。而当时拆下来差不多有1.2M的主包,800K+的业务主分包。

这个改造最优秀的一点在于,后续的业务迭代产生的体积增长几乎全是在业务主分包里,剩下的1.1M+空间留给业务迭代还是比较充裕的。而主包的体积在理想条件下是可以长久保持不变的,就不会因为业务需求的不断开发反复导致主包体积临近超标,不再需要为主包体积感到焦虑。

当然,可以看到,这个方案本身是没有消减任何体积的,只是把位置变换了一下。除此之外,这个封面页方案其实也存在一些缺陷,比如首屏业务的展示会变慢,因为要加载的内容会变多,不过小程序本身有较好的缓存资源的能力,因此还算可以接受。

相比于因体积问题卡住需求迭代以及产品线的接入,目前这个方案至少能解决有无问题。我们开发团队后续也会持续跟进关注体积问题,看是否会有产品方案变更或者小程序本身给出一些解决方案来进一步优化这个部分。

总结

Mpx框架在包体积控制上做了大量工作,对于npm场景下的小程序分包进行了非常完善的支持。

滴滴出行小程序团队在框架支持的基础上,通过梳理业务依赖,充分利用分包,调整交互方案等一系列手段,在不阻碍业务发展的前提下,将庞大复杂的滴滴出行小程序包体积控制在平台限制范围内。

希望本文能给在包体积上遇到问题的小程序开发者们带来一些启发,欢迎留言交流。

查看原文

赞 13 收藏 5 评论 2

滴滴出行前端团队 发布了文章 · 4月3日

小程序框架运行时性能大测评

作者:董宏平(hiyuki),滴滴出行小程序负责人,mpx框架负责人及核心作者

随着小程序在商业上的巨大成功,小程序开发在国内前端领域越来越受到重视,为了方便广大开发者更好地进行小程序开发,各类小程序框架也层出不穷,呈现出百花齐放的态势。但是到目前为止,业内一直没有出现一份全面、详细、客观、公正的小程序框架测评报告,为小程序开发者在技术选型时提供参考。于是我便筹划推出一系列文章,对业内流行的小程序框架进行一次全方位的、客观公正的测评,本文是系列文章的第一篇——运行时性能篇。

在本文中,我们会对下列框架进行运行时性能测试(排名不分先后):

其中对于kbone和taro next均以vue作为业务框架进行测试。

运行时性能的测试内容包括以下几个维度:

  • 框架运行时体积
  • 页面渲染耗时
  • 页面更新耗时
  • 局部更新耗时
  • setData调用次数
  • setData发送数据大小

框架性能测试demo全部存放于https://github.com/hiyuki/mp-... 中,欢迎广大开发者进行验证纠错及补全;

测试方案

为了使测试结果真实有效,我基于常见的业务场景构建了两种测试场景,分别是动态测试场景和静态测试场景。

动态测试场景

动态测试中,视图基于数据动态渲染,静态节点较少,视图更新耗时和setData调用情况是该测试场景中的主要测试点。

动态测试demo模拟了实际业务中常见的长列表+多tab场景,该demo中存在两份优惠券列表数据,一份为可用券数据,另一份为不可用券数据,其中同一时刻视图中只会渲染展示其中一份数据,可以在上方的操作区模拟对列表数据的各种操作及视图展示切换(切tab)。

动态测试demo

动态测试demo

在动态测试中,我在外部通过函数代理的方式在初始化之前将App、Page和Component构造器进行代理,通过mixin的方式在Page的onLoad和Component的created钩子中注入setData拦截逻辑,对所有页面和组件的setData调用进行监听,并统计小程序的视图更新耗时及setData调用情况。该测试方式能够做到对框架代码的零侵入,能够跟踪到小程序全量的setData行为并进行独立的耗时计算,具有很强的普适性,代码具体实现可以查看https://github.com/hiyuki/mp-...

静态测试场景

静态测试模拟业务中静态页面的场景,如运营活动和文章等页面,页面内具备大量的静态节点,而没有数据动态渲染,初始ready耗时是该场景下测试的重心。

静态测试demo使用了我去年发表的一篇技术文章的html代码进行小程序适配构建,其中包含大量静态节点及文本内容。

静态测试demo

静态测试demo

测试流程及数据

以下所有耗时类的测试数据均为微信小程序中真机进行5次测试计算平均值得出,单位均为ms。Ios测试环境为手机型号iPhone 11,系统版本13.3.1,微信版本7.0.12,安卓测试环境为手机型号小米9,系统版本Android10,微信版本7.0.12。
为了使数据展示不过于混乱复杂,文章中所列的数据以Ios的测试结果为主,安卓测试结论与Ios相符,整体耗时比Ios高3~4倍左右,所有的原始测试数据存放在https://github.com/hiyuki/mp-...
由于transform-runtime引入的core-js会对框架的运行时体积和运行耗时带来一定影响,且不是所有的框架都会在编译时开启transform-runtime,为了对齐测试环境,下述测试均在transform-runtime关闭时进行。

框架运行时体积

由于不是所有框架都能够使用webpack-bundle-analyzer得到精确的包体积占用,这里我通过将各框架生成的demo项目体积减去native编写的demo项目体积作为框架的运行时体积。

demo总体积(KB)框架运行时体积(KB)
native270
wepy26639
uniapp11487
mpx7851
chameleon136109
mpvue10376
kbone395368
taro next183156

该项测试的结论为:
native > wepy2 > mpx > mpvue > uniapp > chameleon > taro next > kbone

结论分析:

  • wepy2和mpx在框架运行时体积上控制得最好;
  • taro next和kbone由于动态渲染的特性,在dist中会生成递归渲染模板/组件,所以占用体积较大。

页面渲染耗时(动态测试)

我们使用刷新页面操作触发页面重新加载,对于大部分框架来说,页面渲染耗时是从触发刷新操作到页面执行onReady的耗时,但是对于像kbone和taro next这样的动态渲染框架,页面执行onReady并不代表视图真正渲染完成,为此,我们设定了一个特殊规则,在页面onReady触发的1000ms内,在没有任何操作的情况下出现setData回调时,以最后触发的setData回调作为页面渲染完成时机来计算真实的页面渲染耗时,测试结果如下:

页面渲染耗时
native60.8
wepy264
uniapp56.4
mpx52.6
chameleon56.4
mpvue117.8
kbone98.6
taro next89.6
该项测试的耗时并不等同于真实的渲染耗时,由于小程序自身没有提供performance api,真实渲染耗时无法通过js准确测试得出,不过从得出的数据来看该项数据依然具备一定的参考意义。

该项测试的结论为:
mpx ≈ chameleon ≈ uniapp ≈ native ≈ wepy2 > taro next ≈ kbone ≈ mpvue

结论分析:

  • 由于mpvue全量在页面进行渲染,kbone和taro next采用了动态渲染技术,页面渲染耗时较长,其余框架并无太大区别。

页面更新耗时(无后台数据)

这里后台数据的定义为data中存在但当前页面渲染中未使用到的数据,在这个demo场景下即为不可用券的数据,当前会在不可用券为0的情况下,对可用券列表进行各种操作,并统计更新耗时。

更新耗时的计算方式是从数据操作事件触发开始到对应的setData回调完成的耗时

mpvue中使用了当前时间戳(new Date)作为超时依据对setData进行了超时时间为50ms的节流操作,该方式存在严重问题,当vue内单次渲染同步流程执行耗时超过50ms时,后续组件patch触发的setData会突破这个节流限制,以50ms每次的频率对setData进行高频无效调用。在该性能测试demo中,当优惠券数量超过500时,界面就会完全卡死。为了顺利跑完整个测试流程,我对该问题进行了简单修复,使用setTimeout重写了节流部分,确保在vue单次渲染流程同步执行完毕后才会调用setData发送合并数据,之后mpvue的所有性能测试都是基于这个patch版本来进行的,该patch版本存放在https://github.com/hiyuki/mp-...
理论上来讲native的性能在进行优化的前提下一定是所有框架的天花板,但是在日常业务开发中我们可能无法对每一次setData都进行优化,以下性能测试中所有的native数据均采用修改数据后全量发送的形式来实现。

第一项测试我们使用新增可用券(100)操作将可用券数量由0逐级递增到1000:

1002003004005006007008009001000
native84.669.871.67577.278.882.893.293.4105.4
wepy2118.4168.6204.6246.4288.6347.8389.2434.2496539
uniapp121.21009698.297.899.6104102.4109.4107.6
mpx110.487.282.28380.679.686.690.689.296.4
chameleon116.8115.4117119.6122125.2133.8133.2144.8145.6
mpvue112.8121.2140169198.8234.2278.8318.4361.4408.2
kbone556.4762.4991.61220.61468.81689.61933.22150.423892620.6
taro next470604.6759.6902.41056.212281393.41536.21707.81867.2

然后我们按顺序逐项点击删除可用券(all) > 新增可用券(1000) > 更新可用券(1) > 更新可用券(all) > 删除可用券(1)

delete(all)add(1000)update(1)update(all)delete(1)
native32.8295.692.292.283
wepy256.8726.449.2535530.8
uniapp43.6584.454.8144.8131.2
mpx41.8489.652.6169.4165.6
chameleon39765.695.6237.8144.8
mpvue103.6669.4404.4414.8433.6
kbone120.249782356.42419.42357
taro next126.63930.61607.81788.62318.2
该项测试中初期我update(all)的逻辑是循环对每个列表项进行更新,形如listData.forEach((item)=>{item.count++}),发现在chameleon框架中执行界面会完全卡死,追踪发现chameleon框架中没有对setData进行异步合并处理,而是在数据变动时直接同步发送,这样在数据量为1000的场景下用该方式进行更新会高频触发1000次setData,导致界面卡死;对此,我在chameleon框架的测试demo中,将update(all)的逻辑调整为深clone产生一份更新后的listData,再将其整体赋值到this.listData当中,以确保该项测试能够正常进行。

该项测试的结论为:
native > mpx ≈ uniapp > chameleon > mpvue > wepy2 > taro next > kbone

结论分析:

  • mpx和uniapp在框架内部进行了完善的diff优化,随着数据量的增加,两个框架的新增耗时没有显著上升;
  • wepy2会在数据变更时对props数据也进行setData,在该场景下造成了大量的无效性能损耗,导致性能表现不佳;
  • kbone和taro next采用了动态渲染方案,每次新增更新时会发送大量描述dom结构的数据,与此同时动态递归渲染的耗时也远大于常规的静态模板渲染,使得这两个框架在所有的更新场景下耗时都远大于其他框架。

页面更新耗时(有后台数据)

刷新页面后我们使用新增不可用券(1000)创建后台数据,观察该操作是否会触发setData并统计耗时

back add(1000)
native45.2
wepy2174.6
uniapp89.4
mpx0
chameleon142.6
mpvue134
kbone0
taro next0
mpx进行setData优化时inspired by vue,使用了编译时生成的渲染函数跟踪模板数据依赖,在后台数据变更时不会进行setData调用,而kbone和taro next采用了动态渲染技术模拟了web底层环境,在上层完整地运行了vue框架,也达到了同样的效果。

然后我们执行和上面无后台数据时相同的操作进行耗时统计,首先是递增100:

1002003004005006007008009001000
native8869.871.280.879.484.489.893.299.6108
wepy2121173.4213.6250298345.6383434.8476.8535.6
uniapp135.4112.4110.6106.4109.6107.2114.4116118.8117.4
mpx112.686.284.686.89087.291.288.892.493.4
chameleon178.4178.2186.4184.6192.6203.8210217.6232.6236.8
mpvue139151173.4194231.4258.8303.4340.4384.6429.4
kbone559.8746.6980.61226.81450.61705.41927.22154.82367.82617
taro next482.6626.2755909.610851233.213841568.61740.61883.8

然后按下表操作顺序逐项点击统计

delete(all)add(1000)update(1)update(all)delete(1)
native43.4299.889.28987.2
wepy243.2762.450533522.4
uniapp57.8589.862.6160.6154.4
mpx45.8490.852.8167166
chameleon93.8837184.6318220.8
mpvue124.8696.2423.4419430.6
kbone121.44978.22331.22448.42348
taro next129.83947.21610.41813.82290.2

该项测试的结论为:
native > mpx > uniapp > chameleon > mpvue > wepy2 > taro next > kbone

结论分析:

  • 具备模板数据跟踪能力的三个框架mpx,kbone和taro next在有后台数据场景下耗时并没有显著增加;
  • wepy2当中的diff精度不足,耗时也没有产生明显变化;
  • 其余框架由于每次更新都会对后台数据进行deep diff,耗时都产生了一定提升。

页面更新耗时(大数据量场景)

由于mpvue和taro next的渲染全部在页面中进行,而kbone的渲染方案会额外新增大量的自定义组件,这三个框架都会在优惠券数量达到2000时崩溃白屏,我们排除了这三个框架对其余框架进行大数据量场景下的页面更新耗时测试

首先还是在无后台数据场景下使用新增可用券(1000)将可用券数量递增至5000:

10002000300040005000
native332.6350412.6498.2569.4
wepy2970.21531.42015.22890.63364.2
uniapp655.2593.4655675.6718.8
mpx532.2496548.6564601.8
chameleon805.4839.6952.81086.61291.8

然后点击新增不可用券(5000)将后台数据量增加至5000,再测试可用券数量递增至5000的耗时:

back add(5000)
native117.4
wepy2511.6
uniapp285
mpx0
chameleon824
10002000300040005000
native349.8348.4430.4497594.8
wepy2112818722470.43263.44075.8
uniapp715666.8709.2755.6810.2
mpx538.8501.8562.6573.6595.2
chameleon1509.21672.41951.82232.42586.2

该项测试的结论为:
native > mpx > uniapp > chameleon > wepy2

结论分析:

  • 在大数据量场景下,框架之间基础性能的差异会变得更加明显,mpx和uniapp依然保持了接近原生的良好性能表现,而chameleon和wepy2则产生了比较显著的性能劣化。

局部更新耗时

我们在可用券数量为1000的情况下,点击任意一张可用券触发选中状态,以测试局部更新性能

toggleSelect(ms)
native2
wepy22.6
uniapp2.8
mpx2.2
chameleon2
mpvue289.6
kbone2440.8
taro next1975

该项测试的结论为:
native ≈ chameleon ≈ mpx ≈ wepy2 ≈ uniapp > mpvue > taro next > kbone

结论分析:

  • 可以看出所有使用了原生自定义组件进行组件化实现的框架局部更新耗时都极低,这足以证明小程序原生自定义组件的优秀性和重要性;
  • mpvue由于使用了页面更新,局部更新耗时显著增加;
  • kbone和taro next由于递归动态渲染的性能开销巨大,导致局部更新耗时同样巨大。

setData调用

我们将proxySetData的count和size选项设置为true,开启setData的次数和体积统计,重新构建后按照以下流程执行系列操作,并统计setData的调用次数和发送数据的体积。

操作流程如下:

  1. 100逐级递增可用券(0->500)
  2. 切换至不可用券
  3. 新增不可用券(1000)
  4. 100逐级递增可用券(500->1000)
  5. 更新可用券(all)
  6. 切换至可用券

操作完成后我们使用getCountgetSize方法获取累积的setData调用次数和数据体积,其中数据体积计算方式为JSON.stringify后按照utf-8编码方式进行体积计算,统计结果为:

countsize(KB)
native14803
wepy235141124
mpvue162127
uniapp14274
mpx8261
chameleon2515319
kbone2210572
taro next92321

该项测试的结论为:
mpx > uniapp > native > chameleon > wepy2 > taro next > mpvue > kbone

结论分析:

  • mpx框架成功实现了理论上setData的最优;
  • uniapp由于缺失模板追踪能力紧随其后;
  • chameleon由于组件每次创建时都会进行一次不必要的setData,产生了大量无效setData调用,但是数据的发送本身经过diff,在数据发送量上表现不错;
  • wepy2的组件会在数据更新时调用setData发送已经更新过的props数据,因此也产生了大量无效调用,且diff精度不足,发送的数据量也较大;
  • taro next由于上层完全基于vue,在数据发送次数上控制到了9次,但由于需要发送大量的dom描述信息,数据发送量较大;
  • mpvue由于使用较长的数据路径描述数据对应的组件,也产生了较大的数据发送量;
  • kbone对于setData的调用控制得不是很好,在上层运行vue的情况依然进行了22次数据发送,且发送的数据量巨大,在此流程中达到了惊人的10MB。

页面渲染耗时(静态测试)

此处的页面渲染耗时与前面描述的动态测试场景中相同,测试结果如下:

页面渲染耗时
native70.4
wepy286.6
mpvue115.2
uniapp69.6
mpx66.6
chameleon65
kbone144.2
taro next119.8

该项测试的结论为:
chameleon ≈ mpx ≈ uniapp ≈ native > wepy2 > mpvue ≈ taro next > kbone

结论分析:

  • 除了kbone和taro next采用动态渲染耗时增加,mpvue使用页面模板渲染性能稍差,其余框架的静态页面渲染表现都和原生差不多。

结论

综合上述测试数据,我们得到最终的小程序框架运行时性能排名为:
mpx > uniapp > chameleon > wepy2 > mpvue > taro next > kbone

一点私货

虽然kbone和taro next采用了动态渲染技术在性能表现上并不尽如人意,但是我依然认为这是很棒的技术方案。虽然本文从头到位都在进行性能测试和对比,但性能并不是框架的全部,开发效率和高可用性仍然是框架的重心,开发效率相信是所有框架设计的初衷,但是高可用性却在很大程度被忽视。从这个角度来说,kbone和taro next是非常成功的,不同于过去的转译思路,这种从抹平底层渲染环境的做法能够使上层web框架完整运行,在框架可用性上带来非常大的提升,非常适合于运营类简单小程序的迁移和开发。

我主导开发的mpx框架 https://github.com/didi/mpx 选择了另一条道路解决可用性问题,那就是基于小程序原生语法能力进行增强,这样既能避免转译web框架时带来的不确定性和不稳定性,同时也能带来非常接近于原生的性能表现,对于复杂业务小程序的开发者来说,非常推荐使用。在跨端输出方面,mpx目前能够完善支持业内全部小程序平台和web平台的同构输出,滴滴内部最重要最复杂的小程序——滴滴出行小程序完全基于mpx进行开发,并利用框架提供的跨端能力对微信和支付宝入口进行同步业务迭代,大大提升了业务开发效率。

查看原文

赞 55 收藏 32 评论 19

滴滴出行前端团队 发布了文章 · 2019-12-25

Mpx 小程序框架技术揭秘

Github: https://github.com/didi/mpx
本文作者: 肖磊(https://github.com/CommanderXL

与目前业内的几个小程序框架相比较而言,mpx 开发设计的出发点就是基于原生的小程序去做功能增强。所以从开发框架的角度来说,是没有任何“包袱”,围绕着原生小程序这个 core 去做不同功能的 patch 工作,使得开发小程序的体验更好。

于是我挑了一些我非常感兴趣的点去学习了下 mpx 在相关功能上的设计与实现。

编译环节

动态入口编译

不同于 web 规范,我们都知道小程序每个 page/component 需要被最终在 webview 上渲染出来的内容是需要包含这几个独立的文件的:js/json/wxml/wxss。为了提升小程序的开发体验,mpx 参考 vue 的 SFC(single file component)的设计思路,采用单文件的代码组织方式进行开发。既然采用这种方式去组织代码的话,那么模板、逻辑代码、json配置文件、style样式等都放到了同一个文件当中。那么 mpx 需要做的一个工作就是如何将 SFC 在代码编译后拆分为 js/json/wxml/wxss 以满足小程序技术规范。熟悉 vue 生态的同学都知道,vue-loader 里面就做了这样一个编译转化工作。具体有关 vue-loader 的工作流程可以参见我写的文章

这里会遇到这样一个问题,就是在 vue 当中,如果你要引入一个页面/组件的话,直接通过import语法去引入对应的 vue 文件即可。但是在小程序的标准规范里面,它有自己一套组件系统,即如果你在某个页面/组件里面想要使用另外一个组件,那么需要在你的 json 配置文件当中去声明usingComponents这个字段,对应的值为这个组件的路径。

在 vue 里面 import 一个 vue 文件,那么这个文件会被当做一个 dependency 去加入到 webpack 的编译流程当中。但是 mpx 是保持小程序原有的功能,去进行功能的增强。因此一个 mpx 文件当中如果需要引入其他页面/组件,那么就是遵照小程序的组件规范需要在usingComponents定义好组件名:路径即可,mpx 提供的 webpack 插件来完成确定依赖关系,同时将被引入的页面/组件加入到编译构建的环节当中

接下来就来看下具体的实现,mpx webpack-plugin 暴露出来的插件上也提供了静态方法去使用 loader。这个 loader 的作用和 vue-loader 的作用类似,首先就是拿到 mpx 原始的文件后转化一个 js 文本的文件。例如一个 list.mpx 文件里面有关 json 的配置会被编译为:

require("!!../../node_modules/@mpxjs/webpack-plugin/lib/extractor?type=json&index=0!../../node_modules/@mpxjs/webpack-plugin/lib/json-compiler/index?root=!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=json&index=0!./list.mpx")

这样可以清楚的看到 list.mpx 这个文件首先 selector(抽离list.mpx当中有关 json 的配置,并传入到 json-compiler 当中) --->>> json-compiler(对 json 配置进行处理,添加动态入口等) --->>> extractor(利用 child compiler 单独生成 json 配置文件)

其中动态添加入口的处理流程是在 json-compiler 当中去完成的。例如在你的 page/home.mpx 文件当中的 json 配置中使用了 局部组件 components/list.mpx:

<script type="application/json">
  {
    "usingComponents": {
      "list": "../components/list"
    }
  }
</script>

在 json-compiler 当中:

...

const addEntrySafely = (resource, name, callback) => {
  // 如果loader已经回调,就不再添加entry
  if (callbacked) return callback()
  // 使用 webpack 提供的 SingleEntryPlugin 插件创建一个单文件的入口依赖(即这个 component)
  const dep = SingleEntryPlugin.createDependency(resource, name)
  entryDeps.add(dep)
  // compilation.addEntry 方法开始将这个需要被编译的 component 作为依赖添加到 webpack 的构建流程当中
  // 这里可以看到的是整个动态添加入口文件的过程是深度优先的
  this._compilation.addEntry(this._compiler.context, dep, name, (err, module) => {
    entryDeps.delete(dep)
    checkEntryDeps()
    callback(err, module)
  })
}

const processComponent = (component, context, rewritePath, componentPath, callback) => {
  ...
  // 调用 loaderContext 上提供的 resolve 方法去解析这个 component path 完整的路径,以及这个 component 所属的 package 相关的信息(例如 package.json 等)
  this.resolve(context, component, (err, rawResult, info) => {
    ...
    componentPath = componentPath || path.join(subPackageRoot, 'components', componentName + hash(result), componentName)
    ...
    // component path 解析完之后,调用 addEntrySafely 开始在 webpack 构建流程中动态添加入口
    addEntrySafely(rawResult, componentPath, callback)
  })
}

if (isApp) {
  ...
} else {
  if (json.usingComponents) {
    // async.forEachOf 流程控制依次调用 processComponent 方法
    async.forEachOf(json.usingComponents, (component, name, callback) => {
      processComponent(component, this.context, (path) => {
        json.usingComponents[name] = path
      }, undefined, callback)
    }, callback)
  }
  ...
}
...

这里需要解释说明下有关 webpack 提供的 SingleEntryPlugin 插件。这个插件是 webpack 提供的一个内置插件,当这个插件被挂载到 webpack 的编译流程的过程中是,会绑定compiler.hooks.make.tapAsynchook,当这个 hook 触发后会调用这个插件上的 SingleEntryPlugin.createDependency 静态方法去创建一个入口依赖,然后调用compilation.addEntry将这个依赖加入到编译的流程当中,这个是单入口文件的编译流程的最开始的一个步骤(具体可以参见 Webpack SingleEntryPlugin 源码)。

Mpx 正是利用了 webpack 提供的这样一种能力,在遵照小程序的自定义组件的规范的前提下,解析 mpx json 配置文件的过程中,手动的调用 SingleEntryPlugin 相关的方法去完成动态入口的添加工作。这样也就串联起了所有的 mpx 文件的编译工作。

Render Function

Render Function 这块的内容我觉得是 Mpx 设计上的一大亮点内容。Mpx 引入 Render Function 主要解决的问题是性能优化方向相关的,因为小程序的架构设计,逻辑层和渲染层是2个独立的。

这里直接引用 Mpx 有关 Render Function 对于性能优化相关开发工作的描述:

作为一个接管了小程序setData的数据响应开发框架,我们高度重视Mpx的渲染性能,通过小程序官方文档中提到的性能优化建议可以得知,setData对于小程序性能来说是重中之重,setData优化的方向主要有两个:

  • 尽可能减少setData调用的频次
  • 尽可能减少单次setData传输的数据

为了实现以上两个优化方向,我们做了以下几项工作:

将组件的静态模板编译为可执行的render函数,通过render函数收集模板数据依赖,只有当render函数中的依赖数据发生变化时才会触发小程序组件的setData,同时通过一个异步队列确保一个tick中最多只会进行一次setData,这个机制和Vue中的render机制非常类似,大大降低了setData的调用频次;

将模板编译render函数的过程中,我们还记录输出了模板中使用的数据路径,在每次需要setData时会根据这些数据路径与上一次的数据进行diff,仅将发生变化的数据通过数据路径的方式进行setData,这样确保了每次setData传输的数据量最低,同时避免了不必要的setData操作,进一步降低了setData的频次。

接下来我们看下 Mpx 是如何实现 Render Function 的。这里我们从一个简单的 demo 来说起:

<template>
  <text>Computed reversed message: "{{ reversedMessage }}"</text>
  <view>the c string {{ demoObj.a.b.c }}</view>
  <view wx:class="{{ { active: isActive } }}"></view>
</template>

<script>
import { createComponent } from "@mpxjs/core";

createComponent({
  data: {
    isActive: true,
    message: 'messages',
    demoObj: {
      a: {
        b: {
          c: 'c'
        }
      }
    }
  },
  computed() {
    reversedMessage() {
      return this.message.split('').reverse().join('')
    }
  }
})

</script>

.mpx 文件经过 loader 编译转换的过程中。对于 template 模块的处理和 vue 类似,首先将 template 转化为 AST,然后再将 AST 转化为 code 的过程中做相关转化的工作,最终得到我们需要的 template 模板代码。

packages/webpack-plugin/lib/template-compiler.js模板处理 loader 当中:

let renderResult = bindThis(`global.currentInject = {
    moduleId: ${JSON.stringify(options.moduleId)},
    render: function () {
      var __seen = [];
      var renderData = {};
      ${compiler.genNode(ast)}return renderData;
    }
};\n`, {
    needCollect: true,
    ignoreMap: meta.wxsModuleMap
  })

在 render 方法内部,创建 renderData 局部变量,调用compiler.genNode(ast)方法完成 Render Function 核心代码的生成工作,最终将这个 renderData 返回。例如在上面给出来的 demo 实例当中,通过compiler.genNode(ast)方法最终生成的代码为:

((mpxShow)||(mpxShow)===undefined?'':'display:none;');
if(( isActive )){
}
"Computed reversed message: \""+( reversedMessage )+"\"";
"the c string "+( demoObj.a.b.c );
(__injectHelper.transformClass("list", ( {active: isActive} )));

mpx 文件当中的 template 模块被初步处理成上面的代码后,可以看到这是一段可执行的 js 代码。那么这段 js 代码到底是用作何处呢?可以看到compiler.genNode方法是被包裹至bindThis方法当中的。即这段 js 代码还会被bindThis方法做进一步的处理。打开 bind-this.js 文件可以看到内部的实现其实就是一个 babel 的 transform plugin。在处理上面这段 js 代码的 AST 的过程中,通过这个插件对 js 代码做进一步的处理。最终这段 js 代码处理后的结果是:

/* mpx inject */ global.currentInject = {
  moduleId: "2271575d",
  render: function () {
    var __seen = [];
    var renderData = {};
    (renderData["mpxShow"] = [this.mpxShow, "mpxShow"], this.mpxShow) || (renderData["mpxShow"] = [this.mpxShow, "mpxShow"], this.mpxShow) === undefined ? '' : 'display:none;';
    "Computed reversed message: \"" + (renderData["reversedMessage"] = [this.reversedMessage, "reversedMessage"], this.reversedMessage) + "\"";
    "the c string " + (renderData["demoObj.a.b.c"] = [this.demoObj.a.b.c, "demoObj"], this.__get(this.__get(this.__get(this.demoObj, "a"), "b"), "c"));
    this.__get(__injectHelper, "transformClass")("list", { active: (renderData["isActive"] = [this.isActive, "isActive"], this.isActive) });
    return renderData;
  }
};

bindThis 方法对于 js 代码的转化规则就是:

  1. 一个变量的访问形式,改造成 this.xxx 的形式;
  2. 对象属性的访问形式,改造成 this.__get(object, property) 的形式(this.__get方法为运行时 mpx runtime 提供的方法)

这里的 this 为 mpx 构造的一个代理对象,在你业务代码当中调用 createComponent/createPage 方法传入的配置项,例如 data,都会通过这个代理对象转化为响应式的数据。

需要注意的是不管哪种数据形式的改造,最终需要达到的效果就是确保在 Render Function 执行的过程当中,这些被模板使用到的数据能被正常的访问到,在访问的阶段中,这些被访问到的数据即被加入到 mpx 构建的整个响应式的系统当中。

只要在 template 当中使用到的 data 数据(包括衍生的 computed 数据),最终都会被 renderData 所记录,而记录的数据形式是例如:

renderData['xxx'] = [this.xxx, 'xxx'] // 数组的形式,第一项为这个数据实际的值,第二项为这个数据的 firstKey(主要用以数据 diff 的工作)

以上就是 mpx 生成 Render Function 的整个过程。总结下 Render Function 所做的工作:

  1. 执行 render 函数,将渲染模板使用到的数据加入到响应式的系统当中;
  2. 返回 renderData 用以接下来的数据 diff 以及调用小程序的 setData 方法来完成视图的更新

Wxs Module

Wxs 是小程序自己推出的一套脚本语言。官方文档给出的示例,wxs 模块必须要声明式的被 wxml 引用。和 js 在 jsCore 当中去运行不同的是 wxs 是在渲染线程当中去运行的。因此 wxs 的执行便少了一次从 jsCore 执行的线程和渲染线程的通讯,从这个角度来说是对代码执行效率和性能上的比较大的一个优化手段。

有关官方提到的有关 wxs 的运行效率的问题还有待论证:

“在 android 设备中,小程序里的 wxs 与 js 运行效率无差异,而在 ios 设备中,小程序里的 wxs 会比 js 快 2~20倍。”

因为 mpx 是对小程序做渐进增强,因此 wxs 的使用方式和原生的小程序保持一致。在你的.mpx文件当中的 template block 内通过路径直接去引入 wxs 模块即可使用:

<template>
  <wxs data-original="../wxs/components/list.wxs" module="list">
  <view>{{ list.FOO }}</view>
</template>


// wxs/components/list.wxs

const Foo = 'This is from list wxs module'
module.exports = {
  Foo
}

在 template 模块经过 template-compiler 处理的过程中。模板编译器 compiler 在解析模板的 AST 过程中会针对 wxs 标签缓存一份 wxs 模块的映射表:

{
  meta: {
    wxsModuleMap: {
      list: '../wxs/components/list.wxs'
    }
  }
}

当 compiler 对 template 模板解析完后,template-compiler 接下来就开始处理 wxs 模块相关的内容:

// template-compiler/index.js

module.exports = function (raw) {
  ...

  const addDependency = dep => {
    const resourceIdent = dep.getResourceIdentifier()
    if (resourceIdent) {
      const factory = compilation.dependencyFactories.get(dep.constructor)
      if (factory === undefined) {
        throw new Error(`No module factory available for dependency type: ${dep.constructor.name}`)
      }
      let innerMap = dependencies.get(factory)
      if (innerMap === undefined) {
        dependencies.set(factory, (innerMap = new Map()))
      }
      let list = innerMap.get(resourceIdent)
      if (list === undefined) innerMap.set(resourceIdent, (list = []))
      list.push(dep)
    }
  }

  // 如果有 wxsModuleMap 即为 wxs module 依赖的话,那么下面会调用 compilation.addModuleDependencies 方法
  // 将 wxsModule 作为 issuer 的依赖再次进行编译,最终也会被打包进输出的模块代码当中
  // 需要注意的就是 wxs module 不仅要被注入到 bundle 里的 render 函数当中,同时也会通过 wxs-loader 处理,单独输出一份可运行的 wxs js 文件供 wxml 引入使用
  for (let module in meta.wxsModuleMap) {
    isSync = false
    let src = meta.wxsModuleMap[module]
    const expression = `require(${JSON.stringify(src)})`
    const deps = []
    // parser 为 js 的编译器
    parser.parse(expression, {
      current: { // 需要注意的是这里需要部署 addDependency 接口,因为通过 parse.parse 对代码进行编译的时候,会调用这个接口来获取 require(${JSON.stringify(src)}) 编译产生的依赖模块
        addDependency: dep => {
          dep.userRequest = module
          deps.push(dep)
        }
      },
      module: issuer
    })
    issuer.addVariable(module, expression, deps) // 给 issuer module 添加 variable 依赖
    iterationOfArrayCallback(deps, addDependency)
  }

  // 如果没有 wxs module 的处理,那么 template-compiler 即为同步任务,否则为异步任务
  if (isSync) {
    return result
  } else {
    const callback = this.async()

    const sortedDependencies = []
    for (const pair1 of dependencies) {
      for (const pair2 of pair1[1]) {
        sortedDependencies.push({
          factory: pair1[0],
          dependencies: pair2[1]
        })
      }
    }

    // 调用 compilation.addModuleDependencies 方法,将 wxs module 作为 issuer module 的依赖加入到编译流程中
    compilation.addModuleDependencies(
      issuer,
      sortedDependencies,
      compilation.bail,
      null,
      true,
      () => {
        callback(null, result)
      }
    )
  }
}

template/script/style/json 模块单文件的生成

不同于 Vue 借助 webpack 是将 Vue 单文件最终打包成单独的 js chunk 文件。而小程序的规范是每个页面/组件需要对应的 wxml/js/wxss/json 4个文件。因为 mpx 使用单文件的方式去组织代码,所以在编译环节所需要做的工作之一就是将 mpx 单文件当中不同 block 的内容拆解到对应文件类型当中。在动态入口编译的小节里面我们了解到 mpx 会分析每个 mpx 文件的引用依赖,从而去给这个文件创建一个 entry 依赖(SingleEntryPlugin)并加入到 webpack 的编译流程当中。我们还是继续看下 mpx loader 对于 mpx 单文件初步编译转化后的内容:

/* script */
export * from "!!babel-loader!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=script&index=0!./list.mpx"

/* styles */
require("!!../../node_modules/@mpxjs/webpack-plugin/lib/extractor?type=styles&index=0!../../node_modules/@mpxjs/webpack-plugin/lib/wxss/loader?root=&importLoaders=1&extract=true!../../node_modules/@mpxjs/webpack-plugin/lib/style-compiler/index?{\"id\":\"2271575d\",\"scoped\":false,\"sourceMap\":false,\"transRpx\":{\"mode\":\"only\",\"comment\":\"use rpx\",\"include\":\"/Users/XRene/demo/mpx-demo-source/src\"}}!stylus-loader!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=styles&index=0!./list.mpx")

/* json */
require("!!../../node_modules/@mpxjs/webpack-plugin/lib/extractor?type=json&index=0!../../node_modules/@mpxjs/webpack-plugin/lib/json-compiler/index?root=!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=json&index=0!./list.mpx")

/* template */
require("!!../../node_modules/@mpxjs/webpack-plugin/lib/extractor?type=template&index=0!../../node_modules/@mpxjs/webpack-plugin/lib/wxml/wxml-loader?root=!../../node_modules/@mpxjs/webpack-plugin/lib/template-compiler/index?{\"usingComponents\":[],\"hasScoped\":false,\"isNative\":false,\"moduleId\":\"2271575d\"}!../../node_modules/@mpxjs/webpack-plugin/lib/selector?type=template&index=0!./list.mpx")

接下来可以看下 styles/json/template 这3个 block 的处理流程是什么样。

首先来看下 json block 的处理流程:list.mpx -> json-compiler -> extractor。第一个阶段 list.mpx 文件经由 json-compiler 的处理流程在前面的章节已经讲过,主要就是分析依赖增加动态入口的编译过程。当所有的依赖分析完后,调用 json-compiler loader 的异步回调函数:

// lib/json-compiler/index.js

module.exports = function (content) {

  ...
  const nativeCallback = this.async()
  ...

  let callbacked = false
  const callback = (err, processOutput) => {
    checkEntryDeps(() => {
      callbacked = true
      if (err) return nativeCallback(err)
      let output = `var json = ${JSON.stringify(json, null, 2)};\n`
      if (processOutput) output = processOutput(output)
      output += `module.exports = JSON.stringify(json, null, 2);\n`
      nativeCallback(null, output)
    })
  }
}

这里我们可以看到经由 json-compiler 处理后,通过nativeCallback方法传入下一个 loader 的文本内容形如:

var json = {
  "usingComponents": {
    "list": "/components/list397512ea/list"
  }
}

module.exports = JSON.stringify(json, null, 2)

即这段文本内容会传递到下一个 loader 内部进行处理,即 extractor。接下来我们来看下 extractor 里面主要是实现了哪些功能:

// lib/extractor.js

module.exports = function (content) {
  ...
  const contentLoader = normalize.lib('content-loader')
  let request = `!!${contentLoader}?${JSON.stringify(options)}!${this.resource}` // 构建一个新的 resource,且这个 resource 只需要经过 content-loader
  let resultSource = defaultResultSource
  const childFilename = 'extractor-filename'
  const outputOptions = {
    filename: childFilename
  }
  // 创建一个 child compiler
  const childCompiler = mainCompilation.createChildCompiler(request, outputOptions, [
    new NodeTemplatePlugin(outputOptions),
    new LibraryTemplatePlugin(null, 'commonjs2'), // 最终输出的 chunk 内容遵循 commonjs 规范的可执行的模块代码 module.exports = (function(modules) {})([modules])
    new NodeTargetPlugin(),
    new SingleEntryPlugin(this.context, request, resourcePath),
    new LimitChunkCountPlugin({ maxChunks: 1 })
  ])

  ...
  childCompiler.hooks.thisCompilation.tap('MpxWebpackPlugin ', (compilation) => {
    // 创建 loaderContext 时触发的 hook,在这个 hook 触发的时候,将原本从 json-compiler 传递过来的 content 内容挂载至 loaderContext.__mpx__ 属性上面以供接下来的 content -loader 来进行使用
    compilation.hooks.normalModuleLoader.tap('MpxWebpackPlugin', (loaderContext, module) => {
      // 传递编译结果,子编译器进入content-loader后直接输出
      loaderContext.__mpx__ = {
        content,
        fileDependencies: this.getDependencies(),
        contextDependencies: this.getContextDependencies()
      }
    })
  })

  let source

  childCompiler.hooks.afterCompile.tapAsync('MpxWebpackPlugin', (compilation, callback) => {
    // 这里 afterCompile 产出的 assets 的代码当中是包含 webpack runtime bootstrap 的代码,不过需要注意的是这个 source 模块的产出形式
    // 因为使用了 new LibraryTemplatePlugin(null, 'commonjs2') 等插件。所以产出的 source 是可以在 node 环境下执行的 module
    // 因为在 loaderContext 上部署了 exec 方法,即可以直接执行 commonjs 规范的 module 代码,这样就最终完成了 mpx 单文件当中不同模块的抽离工作
    source = compilation.assets[childFilename] && compilation.assets[childFilename].source()

    // Remove all chunk assets
    compilation.chunks.forEach((chunk) => {
      chunk.files.forEach((file) => {
        delete compilation.assets[file]
      })
    })

    callback()
  })

  childCompiler.runAsChild((err, entries, compilation) => {
    ...
    try {
      // exec 是 loaderContext 上提供的一个方法,在其内部会构建原生的 node.js module,并执行这个 module 的代码
      // 执行这个 module 代码后获取的内容就是通过 module.exports 导出的内容
      let text = this.exec(source, request)
      if (Array.isArray(text)) {
        text = text.map((item) => {
          return item[1]
        }).join('\n')
      }

      let extracted = extract(text, options.type, resourcePath, +options.index, selfResourcePath)
      if (extracted) {
        resultSource = `module.exports = __webpack_public_path__ + ${JSON.stringify(extracted)};`
      }
    } catch (err) {
      return nativeCallback(err)
    }
    if (resultSource) {
      nativeCallback(null, resultSource)
    } else {
      nativeCallback()
    }
  })
}

稍微总结下上面的处理流程:

  1. 构建一个以当前模块路径及 content-loader 的 resource 路径
  2. 以这个 resource 路径作为入口模块,创建一个 childCompiler
  3. childCompiler 启动后,创建 loaderContext 的过程中,将 content 文本内容挂载至 loaderContext.__mpx__ 上,这样在 content-loader 在处理入口模块的时候仅仅就是取出这个 content 文本内容并返回。实际上这个入口模块经过 loader 的过程不会做任何的处理工作,仅仅是将父 compilation 传入的 content 返回出去。
  4. loader 处理模块的环节结束后,进入到 module.build 阶段,这个阶段对 content 内容没有太多的处理
  5. createAssets 阶段,输出 chunk。
  6. 将输出的 chunk 构建为一个原生的 node.js 模块并执行,获取从这个 chunk 导出的内容。也就是模块通过module.exports导出的内容。

所以上面的示例 demo 最终会输出一个 json 文件,里面包含的内容即为:

{
  "usingComponents": {
    "list": "/components/list397512ea/list"
  }
}

运行时环节

以上几个章节主要是分析了几个 Mpx 在编译构建环节所做的工作。接下来我们来看下 Mpx 在运行时环节做了哪些工作。

响应式系统

小程序也是通过数据去驱动视图的渲染,需要手动的调用setData去完成这样一个动作。同时小程序的视图层也提供了用户交互的响应事件系统,在 js 代码中可以去注册相关的事件回调并在回调中去更改相关数据的值。Mpx 使用 Mobx 作为响应式数据工具并引入到小程序当中,使得小程序也有一套完成的响应式的系统,让小程序的开发有了更好的体验。

还是从组件的角度开始分析 mpx 的整个响应式的系统。每次通过createComponent方法去创建一个新的组件,这个方法将原生的小程序创造组件的方法Component做了一层代理,例如在 attched 的生命周期钩子函数内部会注入一个 mixin:

// attached 生命周期钩子 mixin

attached() {
  // 提供代理对象需要的api
  transformApiForProxy(this, currentInject)
  // 缓存options
  this.$rawOptions = rawOptions // 原始的,没有剔除 customKeys 的 options 配置
  // 创建proxy对象
  const mpxProxy = new MPXProxy(rawOptions, this) // 将当前实例代理到 MPXProxy 这个代理对象上面去
  this.$mpxProxy = mpxProxy // 在小程序实例上绑定 $mpxProxy 的实例
  // 组件监听视图数据更新, attached之后才能拿到properties
  this.$mpxProxy.created()
}

在这个方法内部首先调用transformApiForProxy方法对组件实例上下文this做一层代理工作,在 context 上下文上去重置小程序的 setData 方法,同时拓展 context 相关的属性内容:

function transformApiForProxy (context, currentInject) {
  const rawSetData = context.setData.bind(context) // setData 绑定对应的 context 上下文
  Object.defineProperties(context, {
    setData: { // 重置 context 的 setData 方法
      get () {
        return this.$mpxProxy.setData.bind(this.$mpxProxy)
      },
      configurable: true
    },
    __getInitialData: {
      get () {
        return () => context.data
      },
      configurable: false
    },
    __render: { // 小程序原生的 setData 方法
      get () {
        return rawSetData
      },
      configurable: false
    }
  })
  // context 绑定注入的render函数
  if (currentInject) {
    if (currentInject.render) { // 编译过程中生成的 render 函数
      Object.defineProperties(context, {
        __injectedRender: {
          get () {
            return currentInject.render.bind(context)
          },
          configurable: false
        }
      })
    }
    if (currentInject.getRefsData) {
      Object.defineProperties(context, {
        __getRefsData: {
          get () {
            return currentInject.getRefsData
          },
          configurable: false
        }
      })
    }
  }
}

接下来实例化一个 mpxProxy 实例并挂载至 context 上下文的 $mpxProxy 属性上,并调用 mpxProxy 的 created 方法完成这个代理对象的初始化的工作。在 created 方法内部主要是完成了以下的几个工作:

  1. initApi,在组件实例 this 上挂载$watch,$forceUpdate,$updated,$nextTick等方法,这样在你的业务代码当中即可直接访问实例上部署好的这些方法;
  2. initData
  3. initComputed,将 computed 计算属性字段全部代理至组件实例 this 上;
  4. 通过 Mobx observable 方法将 data 数据转化为响应式的数据;
  5. initWatch,初始化所有的 watcher 实例;
  6. initRender,初始化一个 renderWatcher 实例;

这里我们具体的来看下 initRender 方法内部是如何进行工作的:

export default class MPXProxy {
  ...
  initRender() {
    let renderWatcher
    let renderExcutedFailed = false
    if (this.target.__injectedRender) { // webpack 注入的有关这个 page/component 的 renderFunction
      renderWatcher = watch(this.target, () => {
        if (renderExcutedFailed) {
          this.render()
        } else {
          try {
            return this.target.__injectedRender() // 执行 renderFunction,获取渲染所需的响应式数据
          } catch(e) {
            ...
          }
        }
      }, {
        handler: (ret) => {
          if (!renderExcutedFailed) {
            this.renderWithData(ret) // 渲染页面
          }
        },
        immediate: true,
        forceCallback: true
      })
    }
  }
  ...
}

在 initRender 方法内部非常清楚的看到,首先判断这个 page/component 是否具有 renderFunction,如果有的话那么就直接实例化一个 renderWatcher:

export default class Watcher {
  constructor (context, expr, callback, options) {
    this.destroyed = false
    this.get = () => {
      return type(expr) === 'String' ? getByPath(context, expr) : expr()
    }
    const callbackType = type(callback)
    if (callbackType === 'Object') {
      options = callback
      callback = null
    } else if (callbackType === 'String') {
      callback = context[callback]
    }
    this.callback = typeof callback === 'function' ? action(callback.bind(context)) : null
    this.options = options || {}
    this.id = ++uid
    // 创建一个新的 reaction
    this.reaction = new Reaction(`mpx-watcher-${this.id}`, () => {
      this.update()
    })
    // 在调用 getValue 函数的时候,实际上是调用 reaction.track 方法,这个方法内部会自动执行 effect 函数,即执行 this.update() 方法,这样便会出发一次模板当中的 render 函数来完成依赖的收集
    const value = this.getValue()
    if (this.options.immediateAsync) { // 放置到一个队列里面去执行
      queueWatcher(this)
    } else { // 立即执行 callback
      this.value = value
      if (this.options.immediate) {
        this.callback && this.callback(this.value)
      }
    }
  }

  getValue () {
    let value
    this.reaction.track(() => {
      value = this.get() // 获取注入的 render 函数执行后返回的 renderData 的值,在执行 render 函数的过程中,就会访问响应式数据的值
      if (this.options.deep) {
        const valueType = type(value)
        // 某些情况下,最外层是非isObservable 对象,比如同时观察多个属性时
        if (!isObservable(value) && (valueType === 'Array' || valueType === 'Object')) {
          if (valueType === 'Array') {
            value = value.map(item => toJS(item, false))
          } else {
            const newValue = {}
            Object.keys(value).forEach(key => {
              newValue[key] = toJS(value[key], false)
            })
            value = newValue
          }
        } else {
          value = toJS(value, false)
        }
      } else if (isObservableArray(value)) {
        value.peek()
      } else if (isObservableObject(value)) {
        keys(value)
      }
    })
    return value
  }

  update () {
    if (this.options.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }

  run () {
    const immediateAsync = !this.hasOwnProperty('value')
    const oldValue = this.value
    this.value = this.getValue() // 重新获取新的 renderData 的值
    if (immediateAsync || this.value !== oldValue || isObject(this.value) || this.options.forceCallback) {
      if (this.callback) {
        immediateAsync ? this.callback(this.value) : this.callback(this.value, oldValue)
      }
    }
  }

  destroy () {
    this.destroyed = true
    this.reaction.getDisposer()()
  }
}

Watcher 观察者核心实现的工作流程就是:

  1. 构建一个 Reaction 实例;
  2. 调用 getValue 方法,即 reaction.track,在这个方法内部执行过程中会调用 renderFunction,这样在 renderFunction 方法的执行过程中便会访问到渲染所需要的响应式的数据并完成依赖收集;
  3. 根据 immediateAsync 配置来决定回调是放到下一帧还是立即执行;
  4. 当响应式数据发生变化的时候,执行 reaction 实例当中的回调函数,即this.update()方法来完成页面的重新渲染。

mpx 在构建这个响应式的系统当中,主要有2个大的环节,其一为在构建编译的过程中,将 template 模块转化为 renderFunction,提供了渲染模板时所需响应式数据的访问机制,并将 renderFunction 注入到运行时代码当中,其二就是在运行环节,mpx 通过构建一个小程序实例的代理对象,将小程序实例上的数据访问全部代理至 MPXProxy 实例上,而 MPXProxy 实例即 mpx 基于 Mobx 去构建的一套响应式数据对象,首先将 data 数据转化为响应式数据,其次提供了 computed 计算属性,watch 方法等一系列增强的拓展属性/方法,虽然在你的业务代码当中 page/component 实例 this 都是小程序提供的,但是最终经过代理机制,实际上访问的是 MPXProxy 所提供的增强功能,所以 mpx 也是通过这样一个代理对象去接管了小程序的实例。需要特别指出的是,mpx 将小程序官方提供的 setData 方法同样收敛至内部,这也是响应式系统提供的基础能力,即开发者只需要关注业务开发,而有关小程序渲染运行在 mpx 内部去帮你完成。

性能优化

由于小程序的双线程的架构设计,逻辑层和视图层之间需要桥接 native bridge。如果要完成视图层的更新,那么逻辑层需要调用 setData 方法,数据经由 native bridge,再到渲染层,这个工程流程为:

小程序逻辑层调用宿主环境的 setData 方法;

逻辑层执行 JSON.stringify 将待传输数据转换成字符串并拼接到特定的JS脚本,并通过evaluateJavascript 执行脚本将数据传输到渲染层;

渲染层接收到后, WebView JS 线程会对脚本进行编译,得到待更新数据后进入渲染队列等待 WebView 线程空闲时进行页面渲染;

WebView 线程开始执行渲染时,待更新数据会合并到视图层保留的原始 data 数据,并将新数据套用在WXML片段中得到新的虚拟节点树。经过新虚拟节点树与当前节点树的 diff 对比,将差异部分更新到UI视图。同时,将新的节点树替换旧节点树,用于下一次重渲染。

文章来源

而 setData 作为逻辑层和视图层之间通讯的核心接口,那么对于这个接口的使用遵照一些准则将有助于性能方面的提升。

尽可能的减少 setData 传输的数据

Mpx 在这个方面所做的工作之一就是基于数据路径的 diff。这也是官方所推荐的 setData 的方式。每次响应式数据发生了变化,调用 setData 方法的时候确保传递的数据都为 diff 过后的最小数据集,这样来减少 setData 传输的数据。

接下来我们就来看下这个优化手段的具体实现思路,首先还是从一个简单的 demo 来看:

<script>
import { createComponent } from '@mpxjs/core'

createComponent({
  data: {
    obj: {
      a: {
        c: 1,
        d: 2
      }
    }
  }
  onShow() {
    setTimeout(() => {
      this.obj.a = {
        c: 1,
        d: 'd'
      }
    }, 200)
  }
})
</script>

在示例 demo 当中,声明了一个 obj 对象(这个对象里面的内容在模块当中被使用到了)。然后经过 200ms 后,手动修改 obj.a 的值,因为对于 c 字段来说它的值没有发生改变,而 d 字段发生了改变。因此在 setData 方法当中也应该只更新 obj.a.d 的值,即:

this.setData('obj.a.d', 'd')

因为 mpx 是整体接管了小程序当中有关调用 setData 方法并驱动视图更新的机制。所以当你在改变某些数据的时候,mpx 会帮你完成数据的 diff 工作,以保证每次调用 setData 方法时,传入的是最小的更新数据集。

这里也简单的分析下 mpx 是如何去实现这样的功能的。在上文的编译构建阶段有分析到 mpx 生成的 Render Function,这个 Render Function 每次执行的时候会返回一个 renderData,而这个 renderData 即用以接下来进行 setData 驱动视图渲染的原始数据。renderData 的数据组织形式是模板当中使用到的数据路径作为 key 键值,对应的值使用一个数组组织,数组第一项为数据的访问路径(可获取到对应渲染数据),第二项为数据路径的第一个键值,例如在 demo 示例当中的 renderData 数据如下:

renderData['obj.a.c'] = [this.obj.a.c, 'obj']
renderData['obj.a.d'] = [this.obj.a.d, 'obj']

当页面第一次渲染,或者是响应式输出发生变化的时候,Render Function 都会被执行一次用以获取最新的 renderData 来进行接下来的页面渲染过程。

// src/core/proxy.js

class MPXProxy {
  ...
  renderWithData(rawRenderData) { // rawRenderData 即为 Render Function 执行后获取的初始化 renderData
    const renderData = preprocessRenderData(rawRenderData) // renderData 数据的预处理
    if (!this.miniRenderData) { // 最小数据渲染集,页面/组件初次渲染的时候使用 miniRenderData 进行渲染,初次渲染的时候是没有数据需要进行 diff 的
      this.miniRenderData = {}
      for (let key in renderData) { // 遍历数据访问路径
        if (renderData.hasOwnProperty(key)) {
          let item = renderData[key] 
          let data = item[0]
          let firstKey = item[1] // 某个字段 path 的第一个 key 值
          if (this.localKeys.indexOf(firstKey) > -1) {
            this.miniRenderData[key] = diffAndCloneA(data).clone
          }
        }
      }
      this.doRender(this.miniRenderData)
    } else { // 非初次渲染使用 processRenderData 进行数据的处理,主要是需要进行数据的 diff 取值工作,并更新 miniRenderData 的值
      this.doRender(this.processRenderData(renderData))
    }
  }

  processRenderData(renderData) {
    let result = {}
    for (let key in renderData) {
      if (renderData.hasOwnProperty(key)) {
        let item = renderData[key]
        let data = item[0]
        let firstKey = item[1]
        let { clone, diff } = diffAndCloneA(data, this.miniRenderData[key]) // 开始数据 diff
        // firstKey 必须是为响应式数据的 key,且这个发生变化的 key 为 forceUpdateKey 或者是在 diff 阶段发现确实出现了 diff 的情况
        if (this.localKeys.indexOf(firstKey) > -1 && (this.checkInForceUpdateKeys(key) || diff)) {
          this.miniRenderData[key] = result[key] = clone
        }
      }
    }
    return result
  }
  ...
}

// src/helper/utils.js

// 如果 renderData 里面即包含对某个 key 的访问,同时还有对这个 key 的子节点访问的话,那么需要剔除这个子节点
/**
 * process renderData, remove sub node if visit parent node already
 * @param {Object} renderData
 * @return {Object} processedRenderData
 */
export function preprocessRenderData (renderData) { 
  // method for get key path array
  const processKeyPathMap = (keyPathMap) => {
    let keyPath = Object.keys(keyPathMap)
    return keyPath.filter((keyA) => {
      return keyPath.every((keyB) => {
        if (keyA.startsWith(keyB) && keyA !== keyB) {
          let nextChar = keyA[keyB.length]
          if (nextChar === '.' || nextChar === '[') {
            return false
          }
        }
        return true
      })
    })
  }

  const processedRenderData = {}
  const renderDataFinalKey = processKeyPathMap(renderData) // 获取最终需要被渲染的数据的 key
  Object.keys(renderData).forEach(item => {
    if (renderDataFinalKey.indexOf(item) > -1) {
      processedRenderData[item] = renderData[item]
    }
  })
  return processedRenderData
}

其中在 processRenderData 方法内部调用了 diffAndCloneA 方法去完成数据的 diff 工作。在这个方法内部判断新、旧值是否发生变化,返回的 diff 字段即表示是否发生了变化,clone 为 diffAndCloneA 接受到的第一个数据的深拷贝值。

这里大致的描述下相关流程:

  1. 响应式的数据发生了变化,触发 Render Function 重新执行,获取最新的 renderData;
  2. renderData 的预处理,主要是用以剔除通过路径访问时同时有父、子路径情况下的子路径的 key;
  3. 判断是否存在 miniRenderData 最小数据渲染集,如果没有那么 Mpx 完成 miniRenderData 最小渲染数据集的收集,如果有那么使用处理后的 renderData 和 miniRenderData 进行数据的 diff 工作(diffAndCloneA),并更新最新的 miniRenderData 的值;
  4. 调用 doRender 方法,进入到 setData 阶段

相关参阅文档:

尽可能的减少 setData 的调用频次

每次调用 setData 方法都会完成一次从逻辑层 -> native bridge -> 视图层的通讯,并完成页面的更新。因此频繁的调用 setData 方法势必也会造成视图的多次渲染,用户的交互受阻。所以对于 setData 方法另外一个优化角度就是尽可能的减少 setData 的调用频次,将多个同步的 setData 操作合并到一次调用当中。接下来就来看下 mpx 在这方面是如何做优化的。

还是先来看一个简单的 demo:

<script>
import { createComponent } from '@mpxjs/core'

createComponent({
  data: {
    msg: 'hello',
    obj: {
      a: {
        c: 1,
        d: 2
      }
    }
  }
  watch: {
    obj: {
      handler() {
        this.msg = 'world'
      },
      deep: true
    }
  },
  onShow() {
    setTimeout(() => {
      this.obj.a = {
        c: 1,
        d: 'd'
      }
    }, 200)
  }
})
</script>

在示例 demo 当中,msg 和 obj 都作为模板依赖的数据,这个组件开始展示后的 200ms,更新 obj.a 的值,同时 obj 被 watch,当 obj 发生改变后,更新 msg 的值。这里的逻辑处理顺序是:

obj.a 变化 -> 将 renderWatch 加入到执行队列 -> 触发 obj watch -> 将 obj watch 加入到执行队列 -> 将执行队列放到下一帧执行 -> 按照 watch id 从小到大依次执行 watch.run -> setData 方法调用一次(即 renderWatch 回调),统一更新 obj.a 及 msg -> 视图重新渲染

接下来就来具体看下这个流程:由于 obj 作为模板渲染的依赖数据,自然会被这个组件的 renderWatch 作为依赖而被收集。当 obj 的值发生变化后,首先触发 reaction 的回调,即 this.update() 方法,如果是个同步的 watch,那么立即调用 this.run() 方法,即 watcher 监听的回调方法,否则就通过 queueWatcher(this) 方法将这个 watcher 加入到执行队列:

// src/core/watcher.js
export default Watcher {
  constructor (context, expr, callback, options) {
    ...
    this.id = ++uid
    this.reaction = new Reaction(`mpx-watcher-${this.id}`, () => {
      this.update()
    })
    ...
  }

  update () {
    if (this.options.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

而在 queueWatcher 方法中,lockTask 维护了一个异步锁,即将 flushQueue 当成微任务统一放到下一帧去执行。所以在 flushQueue 开始执行之前,还会有同步的代码将 watcher 加入到执行队列当中,当 flushQueue 开始执行的时候,依照 watcher.id 升序依次执行,这样去确保 renderWatcher 在执行前,其他所有的 watcher 回调都执行完了,即执行 renderWatcher 的回调的时候获取到的 renderData 都是最新的,然后再去进行 setData 的操作,完成页面的更新。

// src/core/queueWatcher.js
import { asyncLock } from '../helper/utils'
const queue = []
const idsMap = {}
let flushing = false
let curIndex = 0
const lockTask = asyncLock()
export default function queueWatcher (watcher) {
  if (!watcher.id && typeof watcher === 'function') {
    watcher = {
      id: Infinity,
      run: watcher
    }
  }
  if (!idsMap[watcher.id] || watcher.id === Infinity) {
    idsMap[watcher.id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      let i = queue.length - 1
      while (i > curIndex && watcher.id < queue[i].id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    lockTask(flushQueue, resetQueue)
  }
}

function flushQueue () {
  flushing = true
  queue.sort((a, b) => a.id - b.id)
  for (curIndex = 0; curIndex < queue.length; curIndex++) {
    const watcher = queue[curIndex]
    idsMap[watcher.id] = null
    watcher.destroyed || watcher.run()
  }
  resetQueue()
}

function resetQueue () {
  flushing = false
  curIndex = queue.length = 0
}

Mpx github: https://github.com/didi/mpx
使用文档: https://didi.github.io/mpx/

查看原文

赞 27 收藏 13 评论 0

滴滴出行前端团队 关注了用户 · 2019-12-25

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2122

滴滴出行前端团队 关注了专栏 · 2019-12-25

cxuan的技术园地

Java后端开发,欢迎关注个人微信公众号 Java建设者 及时关注最新技术文章。

关注 8392

滴滴出行前端团队 关注了专栏 · 2019-12-25

LeanCloud 官方专栏

LeanCloud(原 AVOS Cloud) 是针对移动应用的一站式云端服务,专注于为应用开发者提供工具和平台。提供包括LeanStorage 数据存储、LeanMessage 通信服务、LeanAnalytics 统计分析、LeanModules 拓展模块等四大类型的后端云服务,加速应用开发。

关注 5005

滴滴出行前端团队 关注了专栏 · 2019-12-25

前端巅峰

注重前端性能优化和前沿技术,重型跨平台开发,即时通讯技术等。 欢迎关注微信公众号:前端巅峰

关注 16739

滴滴出行前端团队 关注了专栏 · 2019-12-25

网易云音乐大前端团队

网易云音乐大前端技术团队专栏

关注 1622

认证与成就

  • 获得 100 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2019-12-25
个人主页被 2.3k 人浏览