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/