与目前业内的几个小程序框架相比较而言,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:
在 json-compiler 当中:
这里需要解释说明下有关 webpack 提供的 SingleEntryPlugin 插件。这个插件是 webpack 提供的一个内置插件,当这个插件被挂载到 webpack 的编译流程的过程中是,会绑定compiler.hooks.make.tapAsynchook,当这个 hook 触发后会调用这个插件上的 SingleEntryPlugin.createDependency 静态方法去创建一个入口依赖,然后调用compilation.addEntry将这个依赖加入到编译的流程当中,这个是单入口文件的编译流程的最开始的一个步骤。
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 来说起:
.mpx 文件经过 loader 编译转换的过程中。对于 template 模块的处理和 vue 类似,首先将 template 转化为 AST,然后再将 AST 转化为 code 的过程中做相关转化的工作,最终得到我们需要的 template 模板代码。
packages/webpack-plugin/lib/template-compiler.js模板处理 loader 当中:
在render方法内部,创建renderData局部变量,调用compiler.genNode(ast)方法完成 Render Function 核心代码的生成工作,最终将这个 renderData 返回。例如在上面给出来的 demo 实例当中,通过compiler.genNode(ast)方法最终生成的代码为:
mpx 文件当中的 template 模块被初步处理成上面的代码后,可以看到这是一段可执行的js代码。那么这段 js 代码到底是用作何处呢?可以看到compiler.genNode方法是被包裹至bindThis方法当中的。即这段 js 代码还会被bindThis方法做进一步的处理。打开 bind-this.js 文件可以看到内部的实现其实就是一个 babel 的 transform plugin。在处理上面这段 js 代码的 AST 的过程中,通过这个插件对 js 代码做进一步的处理。
bindThis 方法对于 js 代码的转化规则就是:
- 一个变量的访问形式,改造成 this.xxx 的形式;
- 对象属性的访问形式,改造成 this.__get(object, property) 的形式(this.__get方法为运行时 mpx runtime 提供的方法)
这里的 this 为 mpx 构造的一个代理对象,在你业务代码当中调用 createComponent/createPage 方法传入的配置项,例如 data,都会通过这个代理对象转化为响应式的数据。
需要注意的是不管哪种数据形式的改造,最终需要达到的效果就是确保在 Render Function 执行的过程当中,这些被模板使用到的数据能被正常的访问到,在访问的阶段中,这些被访问到的数据即被加入到 mpx 构建的整个响应式的系统当中。
只要在 template 当中使用到的 data 数据(包括衍生的 computed 数据),最终都会被 renderData 所记录,而记录的数据形式是例如:
以上就是 mpx 生成 Render Function 的整个过程。总结下 Render Function 所做的工作:
- 执行 render 函数,将渲染模板使用到的数据加入到响应式的系统当中;
- 返回 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模块经过template-compiler 处理的过程中。模板编译器 compiler 在解析模板的 AST 过程中会针对 wxs 标签缓存一份 wxs 模块的映射表:
当 compiler 对 template 模板解析完后,template-compiler 接下来就开始处理 wxs 模块相关的内容:
template/script/style/json 模块单文件的生成:
不同于 Vue 借助 webpack 是将 Vue 单文件最终打包成单独的 js chunk 文件。而小程序的规范是每个页面/组件需要对应的 wxml/js/wxss/json 4个文件。因为 mpx 使用单文件的方式去组织代码,所以在编译环节所需要做的工作之一就是将 mpx 单文件当中不同 block 的内容拆解到对应文件类型当中。在动态入口编译的小节里面我们了解到 mpx 会分析每个 mpx 文件的引用依赖,从而去给这个文件创建一个 entry 依赖(SingleEntryPlugin)并加入到 webpack 的编译流程当中。
接下来可以看下 styles/json/template 这3个 block 的处理流程是什么样。
首先来看下 json block 的处理流程:list.mpx -> json-compiler -> extractor。第一个阶段 list.mpx 文件经由 json-compiler 的处理流程在前面的章节已经讲过,主要就是分析依赖增加动态入口的编译过程。当所有的依赖分析完后,调用 json-compiler loader 的异步回调函数:
这里我们可以看到经由 json-compiler 处理后,通过nativeCallback方法传入下一个 loader 的文本内容形如:
即这段文本内容会传递到下一个 loader 内部进行处理,即 extractor。接下来我们来看下 extractor 里面主要是实现了哪些功能:
稍微总结下上面的处理流程:
- 构建一个以当前模块路径及 content-loader 的 resource 路径
- 以这个 resource 路径作为入口模块,创建一个 childCompiler
- childCompiler 启动后,创建 loaderContext 的过程中,将 content 文本内容挂载至 loaderContext.mpx 上,这样在 content-loader 在处理入口模块的时候仅仅就是取出这个 content 文本内容并返回。实际上这个入口模块经过 loader 的过程不会做任何的处理工作,仅仅是将父 compilation 传入的 content 返回出去。
- loader 处理模块的环节结束后,进入到 module.build 阶段,这个阶段对 content 内容没有太多的处理
- createAssets 阶段,输出 chunk。
- 将输出的 chunk 构建为一个原生的 node.js 模块并执行,获取从这个 chunk 导出的内容。也就是模块通过module.exports导出的内容。
所以上面的示例 demo 最终会输出一个 json 文件,里面包含的内容即为:
以上几个章节主要是分析了几个 Mpx 在编译构建环节所做的工作。接下来我们来看下 Mpx 在运行时环节做了哪些工作。
响应式系统
小程序也是通过数据去驱动视图的渲染,需要手动的调用setData去完成这样一个动作。同时小程序的视图层也提供了用户交互的响应事件系统,在 js 代码中可以去注册相关的事件回调并在回调中去更改相关数据的值。Mpx 使用 Mobx 作为响应式数据工具并引入到小程序当中,使得小程序也有一套完成的响应式的系统,让小程序的开发有了更好的体验。
还是从组件的角度开始分析 mpx 的整个响应式的系统。每次通过createComponent方法去创建一个新的组件,这个方法将原生的小程序创造组件的方法Component做了一层代理,例如在 attched 的生命周期钩子函数内部会注入一个 mixin:
在这个方法内部首先调用transformApiForProxy方法对组件实例上下文this做一层代理工作,在 context 上下文上去重置小程序的 setData 方法,同时拓展 context 相关的属性内容:
接下来实例化一个 mpxProxy 实例并挂载至 context 上下文的 $mpxProxy 属性上,并调用 mpxProxy 的 created 方法完成这个代理对象的初始化的工作。在 created 方法内部主要是完成了以下的几个工作:
- initApi,在组件实例this上挂载$watch,$forceUpdate,$updated,$nextTick等方法,这样在你的业务代码当中即可直接访问实例上部署好的这些方法;
- initData
- initComputed,将 computed 计算属性字段全部代理至组件实例 this 上;
- 通过 Mobx observable 方法将 data 数据转化为响应式的数据;
- initWatch,初始化所有的 watcher 实例;
- initRender,初始化一个 renderWatcher 实例;
这里我们具体的来看下 initRender 方法内部是如何进行工作的:
在 initRender 方法内部非常清楚的看到,首先判断这个 page/component 是否具有 renderFunction,如果有的话那么就直接实例化一个 renderWatcher:
Watcher 观察者核心实现的工作流程就是:
- 构建一个 Reaction 实例;
- 调用 getValue 方法,即 reaction.track,在这个方法内部执行过程中会调用 renderFunction,这样在 renderFunction 方法的执行过程中便会访问到渲染所需要的响应式的数据并完成依赖收集;
- 根据 immediateAsync 配置来决定回调是放到下一帧还是立即执行;
- 当响应式数据发生变化的时候,执行 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 来看:
在示例 demo 当中,声明了一个 obj 对象(这个对象里面的内容在模块当中被使用到了)。然后经过 200ms 后,手动修改 obj.a 的值,因为对于 c 字段来说它的值没有发生改变,而 d 字段发生了改变。因此在 setData 方法当中也应该只更新 obj.a.d 的值,即:
因为 mpx 是整体接管了小程序当中有关调用 setData 方法并驱动视图更新的机制。所以当你在改变某些数据的时候,mpx 会帮你完成数据的 diff 工作,以保证每次调用 setData 方法时,传入的是最小的更新数据集。
这里也简单的分析下 mpx 是如何去实现这样的功能的。在上文的编译构建阶段有分析到 mpx 生成的 Render Function,这个 Render Function 每次执行的时候会返回一个 renderData,而这个 renderData 即用以接下来进行 setData 驱动视图渲染的原始数据。renderData 的数据组织形式是模板当中使用到的数据路径作为 key 键值,对应的值使用一个数组组织,数组第一项为数据的访问路径(可获取到对应渲染数据),第二项为数据路径的第一个键值,例如在 demo 示例当中的 renderData 数据如下:
当页面第一次渲染,或者是响应式输出发生变化的时候,Render Function 都会被执行一次用以获取最新的 renderData 来进行接下来的页面渲染过程。
其中在 processRenderData 方法内部调用了 diffAndCloneA 方法去完成数据的 diff 工作。在这个方法内部判断新、旧值是否发生变化,返回的 diff 字段即表示是否发生了变化,clone 为 diffAndCloneA 接受到的第一个数据的深拷贝值。
这里大致的描述下相关流程:
- 响应式的数据发生了变化,触发 Render Function 重新执行,获取最新的 renderData;
- renderData 的预处理,主要是用以剔除通过路径访问时同时有父、子路径情况下的子路径的 key;
- 判断是否存在 miniRenderData 最小数据渲染集,如果没有那么 Mpx 完成 miniRenderData 最小渲染数据集的收集,如果有那么使用处理后的 renderData 和 miniRenderData 进行数据的 diff 工作(diffAndCloneA),并更新最新的 miniRenderData 的值;
- 调用 doRender 方法,进入到 setData 阶段
尽可能的减少 setData 的调用频次
每次调用 setData 方法都会完成一次从逻辑层 -> native bridge -> 视图层的通讯,并完成页面的更新。因此频繁的调用 setData 方法势必也会造成视图的多次渲染,用户的交互受阻。
所以对于 setData 方法另外一个优化角度就是尽可能的减少 setData 的调用频次,将多个同步的 setData 操作合并到一次调用当中。接下来就来看下 mpx 在这方面是如何做优化的。
还是先来看一个简单的 demo:
在示例 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 加入到执行队列:
而在 queueWatcher 方法中,lockTask 维护了一个异步锁,即将 flushQueue 当成微任务统一放到下一帧去执行。所以在 flushQueue 开始执行之前,还会有同步的代码将 watcher 加入到执行队列当中,当 flushQueue 开始执行的时候,依照 watcher.id 升序依次执行,这样去确保 renderWatcher 在执行前,其他所有的 watcher 回调都执行完了,即执行 renderWatcher 的回调的时候获取到的 renderData 都是最新的,然后再去进行 setData 的操作,完成页面的更新。