Mpx 小程序框架手艺揭秘

Github: https://github.com/didi/mpx 本文作者: 肖磊(https://github.com/CommanderXL) 与现在业内的几个小程序框架相比较而言,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:></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 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)
      }
    )
  }
}

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 阶段

相关参阅文档:

  • Page)

尽可能的削减 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/

思源资源网:分类流动

1.阿里云: 本站现在使用的是阿里云主机,平安/可靠/稳固。点击领取2000米代金券、领会最新阿里云产物的种种优惠流动点击进入

2.腾讯云: 提供云服务器、云数据库、云存储、视频与CDN、域名等服务。腾讯云各种产物的最新流动,优惠券领取点击进入

3.广告同盟: 整理了现在主流的广告同盟平台,若是你有流量,可以作为参考选择适合你的平台点击进入

链接: http://www.fly63.com/article/detial/7039

  • 发表于 2021-04-20 10:04
  • 阅读 ( 232 )
  • 分类:互联网

0 条评论

请先 登录 后评论
诸葛神侯
诸葛神侯

682 篇文章

你可能感兴趣的文章

相关问题