Github: https://github.com/didi/mpx
本文做者: 肖磊( https://github.com/CommanderXL)
与目前业内的几个小程序框架相比较而言,mpx 开发设计的出发点就是基于原生的小程序去作功能加强。因此从开发框架的角度来讲,是没有任何“包袱”,围绕着原生小程序这个 core 去作不一样功能的 patch 工做,使得开发小程序的体验更好。javascript
因而我挑了一些我很是感兴趣的点去学习了下 mpx 在相关功能上的设计与实现。html
不一样于 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
这里会遇到这样一个问题,就是在 vue 当中,若是你要引入一个页面/组件的话,直接经过import
语法去引入对应的 vue 文件便可。可是在小程序的标准规范里面,它有本身一套组件系统,即若是你在某个页面/组件里面想要使用另一个组件,那么须要在你的 json 配置文件当中去声明usingComponents
这个字段,对应的值为这个组件的路径。java
在 vue 里面 import 一个 vue 文件,那么这个文件会被当作一个 dependency 去加入到 webpack 的编译流程当中。可是 mpx 是保持小程序原有的功能,去进行功能的加强。所以一个 mpx 文件当中若是须要引入其余页面/组件,那么就是遵守小程序的组件规范须要在usingComponents
定义好组件名:路径
便可,mpx 提供的 webpack 插件来完成肯定依赖关系,同时将被引入的页面/组件加入到编译构建的环节当中。node
接下来就来看下具体的实现,mpx webpack-plugin 暴露出来的插件上也提供了静态方法去使用 loader。这个 loader 的做用和 vue-loader 的做用相似,首先就是拿到 mpx 原始的文件后转化一个 js 文本的文件。例如一个 list.mpx 文件里面有关 json 的配置会被编译为:react
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 配置文件)android
其中动态添加入口的处理流程是在 json-compiler 当中去完成的。例如在你的 page/home.mpx
文件当中的 json 配置中使用了 局部组件 components/list.mpx
:webpack
<script type="application/json"> { "usingComponents": { "list": "../components/list" } } </script>
在 json-compiler 当中:ios
... 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.tapAsync
hook,当这个 hook 触发后会调用这个插件上的 SingleEntryPlugin.createDependency 静态方法去建立一个入口依赖,而后调用compilation.addEntry
将这个依赖加入到编译的流程当中,这个是单入口文件的编译流程的最开始的一个步骤(具体能够参见 Webpack SingleEntryPlugin 源码)。git
Mpx 正是利用了 webpack 提供的这样一种能力,在遵守小程序的自定义组件的规范的前提下,解析 mpx json 配置文件的过程当中,手动的调用 SingleEntryPlugin 相关的方法去完成动态入口的添加工做。这样也就串联起了全部的 mpx 文件的编译工做。
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 代码的转化规则就是:
这里的 this 为 mpx 构造的一个代理对象,在你业务代码当中调用 createComponent/createPage 方法传入的配置项,例如 data,都会经过这个代理对象转化为响应式的数据。
须要注意的是无论哪一种数据形式的改造,最终须要达到的效果就是确保在 Render Function 执行的过程中,这些被模板使用到的数据能被正常的访问到,在访问的阶段中,这些被访问到的数据即被加入到 mpx 构建的整个响应式的系统当中。
只要在 template 当中使用到的 data 数据(包括衍生的 computed 数据),最终都会被 renderData 所记录,而记录的数据形式是例如:
renderData['xxx'] = [this.xxx, 'xxx'] // 数组的形式,第一项为这个数据实际的值,第二项为这个数据的 firstKey(主要用以数据 diff 的工做)
以上就是 mpx 生成 Render Function 的整个过程。总结下 Render Function 所作的工做:
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 src="../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) } ) } }
不一样于 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() } }) }
稍微总结下上面的处理流程:
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 方法内部主要是完成了如下的几个工做:
$watch
,$forceUpdate
,$updated
,$nextTick
等方法,这样在你的业务代码当中便可直接访问实例上部署好的这些方法;这里咱们具体的来看下 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 观察者核心实现的工做流程就是:
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 做为逻辑层和视图层之间通信的核心接口,那么对于这个接口的使用遵守一些准则将有助于性能方面的提高。
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 接受到的第一个数据的深拷贝值。
这里大体的描述下相关流程:
相关参阅文档:
每次调用 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/