Github:
https://github.com/didi/mpx


Xiao Lei (
https://github.com/CommanderXL)

Compared with several small program frameworks in the industry at present, the starting point of MPX development and design is based on native small program to do function enhancement. Therefore, from the perspective of the development framework, there is no “burden”, and patch work of different functions is done around the core of native applet, so as to make the development experience of applet better.

So I picked some points that I was very interested in to learn about the design and implementation of MPX in related functions.

Compile the link

Dynamic entry compilation

Unlike the Web specification, we all know that each page/component of a small program that needs to be rendered in the WebView needs to contain several separate files: js/json/ WXML/WXSS. In order to improve the development experience of small programs, MPX refers to the design idea of VUE’s SFC(Single File Component), and adopts the code organization of single file for development. Since the code is organized this way, the templates, logical code, JSON configuration files, style styles, and so on are all put into the same file. Then one of the tasks MPX needs to do is how to split the SFC into JS/JSON/WXML/WXSS after the code is compiled to meet the small program technical specification. If you are familiar with VUE ecology, you all know that VUE-loader does such a compilation and conversion work. The detailed workflow of Vue-loader can be seen in the article I wrote.

One problem is that in Vue, if you want to import a page/component, you can import the corresponding Vue file using the import syntax. However, in the standard specification of applets, it has its own component system, which means that if you want to use another component in a page/component, you need to declare the usingComponents field in your JSON configuration file, and the corresponding value is the path of the component.

Import a Vue file from Vue, and this file will be added as a dependency to the Webpack build process. But MPX is to keep the original function of small program, to enhance the function. Therefore, if additional pages/components need to be introduced into an MPX file, then the component specification of the applet needs to define the component name: path in UsingComponents. The Webpack plugin provided by MPX can determine the dependencies. Include the introduced pages/components in the build process.

The implementation of the MPX webpack-plugin also provides a static method to use the loader. The function of this loader is similar to that of Vue-loader. The first thing is to take the original MPX file and convert it into a JS text file. For example, a list.mpx file with JSON configuration is compiled as:

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")

MPX is the first selector(remove the json configuration from list. MPX and pass it to the JSIN-compiler) –>>> json-compiler(process the json configuration) Add dynamic entry, etc.) –>>> extractor(use Child Compiler to generate a separate JSON configuration file)

The process of dynamically adding entry is completed in JSON-Compiler. For example, in the json configuration of your page/home. MPX file, you use the local component components/list. MPX:

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

In the JSON-compiler:

. Const addentryPassive = (resource, name, callback) = bb0 {// If the loader has called back, Entry if (callbacked) return callback() // Use the Webpack-provided SingleEntryPlugin to create a single file entry dependency (this component) const dep  = SingleEntryPlugin.createDependency(resource, Name) entryDeps.add(dep) // Compilation.AddEntry starts adding the component that needs to be compiled as a dependency to the Webpack build process // What you can see here is that the whole process of dynamically adding the entry file is depth-first this._compilation.addentry (this._compiler.context, dep, name, (err, module) => { entryDeps.delete(dep) checkEntryDeps() callback(err, module) }) } const processComponent = (component, context, rewritePath, componentPath, callback) => { ... // Call the resolve method provided on LoaderContext to resolve the full path of the component path. This.resolve (context, component, (err, rawResult, info) => {... componentPath = componentPath || path.join(subPackageRoot, 'components', componentName + hash(result), componentName) ... // When the component path is parsed, Call AddentryPassive Start dynamically adding entry AddentryPassive (RawResult, ComponentPath, Callback)})} if (isApp) {... } else {if (JSSON. UsingComponents) {// Async.foreachof process control calls the ProcessComponent method in turn async.forEachOf(json.usingComponents, (component, name, callback) => { processComponent(component, this.context, (path) => { json.usingComponents[name] = path }, undefined, callback) }, callback) } ... }...

A little explanation is needed here about the SingleEntryPlugin provided by Webpack. This plugin is to provide a built-in plugin webpack, when the plugin is mounted to the webpack is in the process of the compilation process, will be binding compiler. The hooks. Make. TapAsynchook, When the hook when triggered calls on the plug-in SingleEntryPlugin. CreateDependency static methods to create an entrance to rely on, and then call compilation. The addEntry will join this dependency to compile process, This is the first step in the single-entry file compilation process (see the Webpack SingleEntryPlugin source code for details).

MPX takes advantage of Webpack’s ability to add dynamic entries by manually calling SingleEntryPlugin related methods during the process of parsing MPX JSON configuration files following the specification of custom components of applets. This will also string up all the compilation of MPX files.

Render Function

I think the content of the Render Function is one of the highlights of the MPX design. The main problem solved by the introduction of the Render Function in MPX is related to the direction of performance optimization, because the architecture design of the small program, the logic layer and the rendering layer are two independent.

Here is a direct reference to MPX’s description of the Render Function for performance optimization related development work:

As a data response development framework that takes over small program setData, we attach great importance to the rendering performance of MPX. Through the performance optimization suggestions mentioned in the official document of small program, we can know that setData is the top priority for small program performance. There are two main directions for setData optimization:

  • Minimize the frequency of setData calls
  • Minimize the number of data transmitted in a single setData transfer

In order to realize the above two optimization directions, we have done the following work:

Compile the component’s static template into the executable Render function. Collect the template’s data dependencies through the Render function. SetData of the applet component will be triggered only when the dependent data in the Render function changes. This mechanism is very similar to the render mechanism in Vue, and greatly reduces the frequency of setData calls;

In the process of compiling the render function of the template, we also recorded and output the data paths used in the template. When setData is needed every time, diff will be conducted between these data paths and the last data. Only the changed data will be setData by the way of data path. In this way, the amount of data transmitted by each setData is ensured to be the lowest, and unnecessary setData operations are avoided at the same time to further reduce the frequency of setData.

Let’s take a look at how MPX implements the Render Function. Let’s start with a simple 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 file is compiled and converted by the loader. The Template module is handled similarly to Vue. We first convert the Template to the AST, and then we convert the AST to code by doing the related transformation work. Finally, we get the template code we need.

Packages /webpack-plugin/lib/template-compiler.js template handling loader Packages /webpack-plugin/lib/template-compiler.js template handling 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
  })

Inside the render method, create the renderData local variable, call Compiler.genNode (ast) method to complete the generation of the render Function core code, and finally return the renderData. For example, in the demo given above, the final code generated through Compiler.genNode (ast) method is:

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

After the template module in the MPX file is preprocessed into the above code, you can see that it is a piece of executable JS code. So what is this JS code for? You can see that the compiler.genNode method is wrapped around the bindThis method. That is, the JS code is further processed by the bindThis method. Open the bind-this.js file and you can see that the internal implementation is actually a Babel Transform Plugin. In the process of processing the AST of the JS code above, further processing of the JS code is done through the plugin. The final result of this JavaScript code processing is:

/* 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;
  }
};

The BindThis method converts JS code as follows:

  1. A variable’s access form, modified to this. XXX;
  2. __get(object, property) (this.__get is the method provided by MPX Runtime)

This here for MPX construct a proxy object, in your business code in the incoming call createComponent/createPage method configuration items, such as data, by the proxy objects into the response data.

It should be noted that no matter what kind of data form transformation, the final effect needs to be achieved is to ensure that in the process of the implementation of the Render Function, the data used by the template can be normally accessed, in the stage of access, the data accessed is added to the whole responsive system built by MPX.

Any data used in the template (including computed data derived) is eventually recorded by renderData in the form of, for example:

RenderData [' XXX '] = [this.xxx, 'XXX ']; renderData[' XXX '] = [this.xxx,' XXX ']; renderData[' XXX '] = [this.xxx, 'XXX '];

This is the whole process of generating the Render Function for MPX. Summarize the work of Render Function:

  1. Execute the render function to add the data used by the render template to the responsive system;
  2. Return RenderData for the following data diff and call the setData method of the applet to complete the update of the view

Wxs Module

WXS is a scripting language launched by small programs themselves. As an example given in the official documentation, the WXS module must be referenced declaratively by WXML. Unlike JS, which runs in JScore, WXS runs in the render thread. Therefore, the execution of WXS reduces the communication between the executing thread from JScore and the rendering thread. From this point of view, it is a large means of optimizing the execution efficiency and performance of code.

The official mention of the efficiency of WXS remains to be argued:

“On Android devices, the efficiency of WXS in small programs is no different from that of JS, while on iOS devices, WXS in small programs will be 2-20 times faster than JS.”

Because MPX is a progressive enhancement of applets, WXS is used in the same way as native applets. To use the template block in your.mpx file, use the path directly to import the WXS module:

<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 }

During the Template module being processed by the Template-Compiler. When parsing the AST of the template, the template compiler caches a map of the WXS module for the WXS tag:

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

After the compiler has parsed the template template, the template-compiler then proceeds to process the WXS module related content:

// 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)}} The following will be called compilation. AddModuleDependencies method / / wxsModule dependence as the issuer to compile again, // Note that the WXS module is not only injected into the bundle's render function, but is also processed by the WXS-Loader. For (Let Module in Meta. WxsModuleMap) {isSync = false let SRC = Meta. WxsModuleMap [module] const expression = 'require(${JSON.stringify(SRC)})' const deps = [] // Parser is a compiler for js parser.parse(expression, { current: {// Note that the AddDependency interface needs to be deployed here, because when code is compiled with parse. Parse, This interface is called to retrieve the dependency module generated by require(${json.stringify (SRC)}) : dep => { dep.userRequest = module deps.push(dep) } }, module: Issuer}) issuer. AddVariable (module, expression, deps) // Add a variable dependent iterationofarrayCallback (deps,) to issuer module. AddDependency)} // If there is no WXS module processing, then template-compiler is a synchronized task. 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]})}} / / call compilation. AddModuleDependencies method, Join the WXS module as the issuer of the module dependency to compilation. The compilation process addModuleDependencies (issuer, sortedDependencies, compilation.bail, null, true, () => { callback(null, result) } ) } }

The template/script/style/json module of the single file generated

Unlike Vue, a single Vue file is eventually packaged into a separate JS chunk file with Webpack. The specification of small programs is that each page/component needs the corresponding WXML/JS/WXSS/JSON 4 files. Because MPX uses a single file to organize code, one of the things you need to do during compilation is to split the contents of the different blocks in an MPX single file into their respective file types. In the Dynamic Entry Compilation section we learned that MPX analyzes the reference dependencies of each MPX file to create an Entry Dependency (SingleEntryPlugin) for that file and add it to the Webpack compilation process. Let’s continue with the MPX Loader’s initial compilation and conversion of an MPX single file:

/* 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")

Let’s see how the styles/json/template blocks work.

List.mpx-> JSSON -compiler-> extractor is an example of the json block process. The first stage, in which the list.mpx file is processed through the JSon-compiler process, is described in the previous section, which analyzes the compilation process that relies on adding dynamic entries. When all dependencies are analyzed, call the JSON-Compiler loader’s asynchronous callback function:

// 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) }) } }

Here we can see the text content passed to the next loader via the nativeCallback method after being processed in the JSon-compiler as follows:

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

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

That is, the text content will be passed inside the next loader for processing, that is, extractor. Next let’s take a look at extractor which is mainly to achieve what functions:

// lib/extractor.js module.exports = function (content) { ... const contentLoader = normalize.lib('content-loader') let request = `!! ${contentLoader}? ${JSON.stringify(options)}! ${this.resource} '// Build a new resource, ResultSource = defaultResultSource const childFileName = 'extractor-fileName' const outputOptions = { filename: Child compiler childFilename} / / create a const childCompiler = mainCompilation. CreateChildCompiler (request, outputOptions, [ new NodeTemplatePlugin(outputOptions), new LibraryTemplatePlugin(null, 'commonjs2'), // exports = (Function (modules) {})([modules]) new NoDetArgetPlugin (), new SingleEntryPlugin(this.context, request, resourcePath), new LimitChunkCountPlugin({ maxChunks: 1 }) ]) ... ChildCompiler. Hooks. ThisCompilation. Tap (' MpxWebpackPlugin '(compilation) = > {/ / create loaderContext triggered when the hook, When this hook fires, Load the content that was passed from the JSON-compiler onto the loaderContext.__mpx__ property for use by the subsequent Content-Loader Compilation. Hooks. NormalModuleLoader. Tap (' MpxWebpackPlugin '(loaderContext, module) = > {/ / compile results, LoaderContext. __mpx__ = {content, fileDependencies: this.getDependencies(), contextDependencies: this.getContextDependencies() } }) }) let source childCompiler.hooks.afterCompile.tapAsync('MpxWebpackPlugin', (Compile operation, callback) => {// After Compile is produced, assets include Webpack Runtime bootstrap. The new LibraryTemplatePlugin(NULL, 'commonjs2') is used in the source module. // Because you deploy exec methods on LoaderContext, you can execute CommonJS module code directly. 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 is a method provided on LoaderContext that builds the native Node.js module inside. Let text = this.exec(source, export.exec, export.exec, export.exec, export.exec, export.exec, export.exec, export.exec (source, export.exec, export.exec) 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() } }) }

To summarize the above process:

  1. Construct a resource path with the current module path and Content-Loader
  2. Create a ChildCompiler with the Resource path as the entry module
  3. After childCompiler is started, during the creation of LoaderContext, the content text is mounted on LoaderContext. __mpx__, the loaderContext.__mpx__. This way, when the Content-Loader processes the entry module, it will simply fetch the content text content and return it. In fact, this entry module will not do any processing work after the process of loader, but just return the content passed in by the parent Compilation.
  4. After the loader finishes processing modules, it goes into the module.build phase, which does not do much processing for content content
  5. The createSeats stage prints Chunk.
  6. Build the output chunk into a native Node.js module and execute it, fetching the contents exported from this chunk. So the module passesmodule.exportsExported content.

So the sample demo above will eventually output a JSON file containing the following contents:

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

Runtime link

The above chapters mainly analyze the work done by several MPX in the process of compilation and construction. Let’s take a look at what MPX does at runtime.

Responsive system

Small programs also use data to drive view rendering, need to manually call setData to complete such an action. At the same time, the view layer of the applet also provides the response event system for user interaction. In the JS code, the relevant event callbacks can be registered and the value of the relevant data can be changed in the callback. MPX uses Mobx as a responsive data tool and is introduced into the small program, making the small program also has a set of completed responsive system, so that the development of small program has a better experience.

From the perspective of components, we will start to analyze the entire responsive system of MPX. Each time a new Component is created through the createComponent method, this method proxies the Component of the native applet method that creates the Component. For example, a mixin is injected inside the life cycle hook function of Attched:

// Attached life cycle hook mixin Attached () {// Provides the API TransformApiForProxy required by the proxy (this, CurrentObject) // cache options this.$rawOptions = rawOptions // // Create a Proxy object const mpxProxy = new mpxProxy (rawOptions, $this.$mpxProxy = mpxProxy // This.$mpxProxy = mpxProxy // This. Properties this.$mpxproxy.created ()}

Inside this method, we first call the TransformApiForProxy method to do a level of proxy work on the component instance context this, resets the setData method of the applet on the context, and extends the contextualproperties:

function transformApiForProxy (context, CurrentObject) {const RawSetData = Context.setData.bind (context) // SetData binding corresponding to the context context Object.defineProperties(context, { setData: }, desc, desc, desc, desc, desc, desc, desc, desc, desc, desc, desc, desc, desc, desc, desc, desc; true }, __getInitialData: { get () { return () => context.data }, configurable: false }, __render: GetData () {return rawSetData}, configurable: False}}) // Context binding injects the render function if (currentObject.render) {if (currentObject.render) {// The render function generated during the compile process 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 } }) } } }

We then instantiate an instance of MPXProxy and mount it on the $MPXProxy property of the context, and call the created method of MPXProxy to initialize the proxy object. Inside the created method, the following works are mainly completed:

  1. InitAPI, mounted on the component instance this$watch.$forceUpdate.$updated.$nextTickEtc., so that the methods deployed on the instance can be accessed directly from your business code.
  2. initData
  3. InitComputed, all computed computed property fields are proxied to the component instance this;
  4. Mobx Observable method is used to transform data data into responsive data.
  5. InitWatch, initializes all Watcher instances;
  6. InitRender, initializes an instance of RenderWatcher;

Here’s a concrete look at how the initRender method works inside:

export default class MPXProxy { ... InitRender () {let renderWatcher let renderExcutedFailed = false if (this.target.__InjectedRender) {// Webpack injected about this Page/Component renderFunction renderWatcher = watch(this.target, this.target, this.target, this.target, this.target, this.target, this.target, this.target, this.target () => {if (RenderExcutedFailed) {this.render()} else {try {return this.target.__InjectedRender () // Execute } catch(e) {return return (e) {return return (e); } } }, { handler: (ret) => { if (! RenderWithData (ret)}, immediate: true, forceCallback: true})}... }

In the initRender method, it is clear that the page/component first has a renderFunction and, if so, instantiates a renderWatcher directly:

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 enclosing the options = options | | {} this. Id = + + uid / / create a new reaction. This reaction = new Reaction(' mpx-watcher-${this.id} ', () => {this.update()}) // When we call getValue, we are actually calling Reaction.track. This method automatically executes the effect function internally, executing the this.update() method, This will render function of starting a template to complete reliance on the collection of const value = this. GetValue () if (this. Options. ImmediateAsync) {/ / is placed into a queue QueueWatcher (this)} else {// execute callback this.value = value if (this.options.immediate) {this.callback && This.callback (this.value)}} getValue () {let value this.reaction. Track (() => {value = this.get()) // Get the injected render If (this.options.deep) {const valueType = type(value) // In some cases, if (this.options.deep) {const valueType = type(value); The outermost layer is a non-IsObservable object, such as 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() // Returns the new RenderData value 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()() } }

The workflow of the core implementation of Watcher Observer is as follows:

  1. Construct a Reaction instance;
  2. Call getValue method, namely Reaction. track, and call RenderFunction during the internal execution of this method. In this way, RenderFunction will access the responsive data needed for rendering and complete dependency collection during the execution of RenderFunction method.
  3. The immediacy of call-back is determined based on the immediacy Ateasync configuration for each callback.
  4. When the reactive data changes, the callback function in the Reaction instance is executed, i.ethis.update()Method to complete the page renderings.

In the process of building this responsive system, MPX mainly has two major steps. One is to convert the template module into RenderFunction in the process of building and compiling, which provides the access mechanism for responsive data required in rendering the template. And inject renderFunction into the runtime code. Second, in the run link, MPX proxies all the data access on the applet instance to the MPXProxy instance by building a proxy object of the applet instance. The MPXProxy example is a set of responsive data objects built by MPX based on Mobx. It first transforms data data into responsive data, and then provides a series of enhanced extended properties/methods such as computed properties and watch methods. Although the page/component instance this is provided by the applet in your business code, the proxy mechanism actually accesses the enhancements provided by the MPXProxy, so MPX takes over the applet instance through such a proxy object. In particular, MPX also converges the setData method provided by the applet authorities internally. This is the basic capability provided by the responsive system, i.e. developers only need to focus on business development, while the applet rendering runs internally for you.

Performance optimization

Due to the dual-threaded architecture design of small programs, a native bridge is needed between the logic layer and the view layer. If you want to complete the update of view layer, then the logical layer needs to call setData method, and the data passes through the Native Bridge and then to the rendering layer. The engineering process is as follows:

The small program logic layer calls the setData method of the host environment;

The logical layer executes JSSON. Stringify to convert the data to a string and concatenate it into a specific JS script, and transfers the data to the render layer through the EvaluateJavascript execution script;

When the rendering layer receives the script, the WebView JS thread will compile the script and get the data to be updated. After that, the script will enter the rendering queue and wait for the page rendering when the WebView thread is idle.

When the WebView thread starts rendering, the data to be updated is merged into the original data retained by the view layer, and the new data is applied to the WXML fragment to get a new virtual tree of nodes. After comparing the diff of the new virtual node tree with the current node tree, the differences are updated to the UI view. At the same time, replace the old node tree with the new node tree for the next rerender.

The article source

Since SetData is the core interface for communication between the logical layer and the view layer, following some guidelines for using this interface will help in terms of performance.

Minimize the data transmitted by setData

One of the things MPX does in this area is data path-based diff. This is the official recommended way to setData. Each time the response data changes, the setData method is called to ensure that the data passed is the smallest data set after diff, so as to reduce the data transmitted by setData.

Next, let’s take a look at the specific implementation ideas of this optimization method. First of all, let’s see from a simple 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>

In the sample demo, an obj object is declared (the contents of this object are used in the module). Then, after 200ms, manually modify the value of obj.a, because it has not changed for the c field, while the d field has changed. Therefore, in the setData method, only the value of obj.a.d should be updated, i.e. :

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

Because MPX takes over the whole mechanism of calling setData and driving view updates in the applet. So when you change some data, MPX will do the diff work for you to ensure that each call to setData is passed in a minimum set of updated data.

Here is also a simple analysis of how MPX to achieve such a function. In the compilation and construction phase above, I analyzed the Render Function generated by MPX, which will return a RenderData every time it is executed. This renderData is the raw data that will be used for the subsequent rendering of the setData-driven view. The data organization form of RenderData is that the data path used in the template is used as the key value, and the corresponding value is organized by an array. The first item of the array is the access path of the data (to obtain the corresponding rendering data), and the second item is the first key value of the data path. For example, in the demo example, renderData has the following data:

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

When the page is first rendered, or when the responsive output changes, the Render Function is executed once to get the latest renderData for the rest of the page Render process.

// src/core/proxy.js class MPXProxy { ... RenderWithData (RawRenderData) {// RawRenderData (RawRenderData) {// RawRenderData (RawRenderData) PreprocessRenderData (rawRenderData) // PreprocessRenderData (rawRenderData) This. miniRenderData) {// Minimal data rendering set, page/component rendering with miniRenderData for the first time, This. miniRenderData = {} for (let key in RenderData) {// iterate the data access path if there is no data to diff (renderData hasOwnProperty (key)) {let item = renderData [key] let data = item [0] let firstKey = item [1] / / a field path If (this.localKeys. Indexof (firstKey) >-1) {this.miniRenderData[key] = diffandClonea (data). Clone}}} This.doRender (this.miniRenderData)} else {// processRenderData is used to process the data for the first render. 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]) // start data diff // firstKey must be the key of the responsive data, If (this.localKeys. IndexOf (firstKey) >-1 &&) if (this.localKeys. IndexOf (firstKey) >-1 &&) if (this.localKeys. IndexOf (firstKey) >-1 &&) if (this.localKeys (this.checkInForceUpdateKeys(key) || diff)) { this.miniRenderData[key] = result[key] = clone } } } return result } ... } // SRC /helper/utils.js // If renderData contains both access to a key and access to the key's children, then renderData contains access to the key's children. /** * 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) // Get the key of the final data that needs to be rendered Object.keys(renderData).forEach(item => { if (renderDataFinalKey.indexOf(item) > -1) { processedRenderData[item] = renderData[item] } }) return processedRenderData }

The DiffAndClonea method is called inside the processRenderData method to do the diff work. Clone is the first deep copy value that diffandClonea receives. The diff field is the first deep copy value that diffandClonea receives. The diff field is the first deep copy value that diffandClonea receives.

Here is a general description of the relevant process:

  1. The responsive data has changed, triggering the Render Function to re-execute, get the latest RenderData;
  2. The pre-processing of RenderData is mainly used to eliminate the key of child path when there are both parent path and child path when accessing through the path.
  3. Determine whether the miniRenderData minimum data rendering set exists. If not, MPX completes the collection of the miniRenderData minimum rendering data set. If so, use the processed RenderData and miniRenderData to diff the data (DiffandClonea), and update the latest miniRenderData value;
  4. Call the doRender method and enter the setData phase

Refer to relevant documents:

  • Page)

Minimize the frequency of setData calls

Each call to setData method will complete a communication from the logical layer -> Native Bridge -> view layer, and complete the update of the page. Therefore, frequent calls to the setData method are bound to cause multiple rendering of the view and hinder user interaction. Therefore, another optimization Angle for setData method is to reduce the frequency of setData invocation as much as possible, and combine multiple synchronous setData operations into one call. Let’s take a look at how MPX optimizes in this regard.

Let’s start with a simple 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>

In the sample demo, MSG and obj are used as template-dependent data. 200ms after this component starts to display, the value of obj is updated. At the same time, obj is watched and the value of MSG is updated when obj changes. The logical order of processing here is:

Obj. a change -> adds renderWatch to the execution queue -> triggers obj watch -> adds obj watch to the execution queue -> puts the execution queue to the next frame -> executes from small to large according to the watch ID Watch. Run-> setData method is called once (i.e., RenderWatch callback), and the Obj. A and Msg-> view are updated

Here’s how it works: Since obj renders the dependency data as a template, it is naturally collected by the component’s RenderWatch as a dependency. When the value of obj changes, it triggers the reaction (this.update() method). If it’s a synchronized watch, it immediately calls this.run(), which is the callback that Watcher is listening for. Otherwise, the watcher is added to the execution queue via the queueWatcher(this) method:

// 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)
    }
  }
}

In the QueueWatcher method, LockTask maintains an asynchronous lock that puts FlushQueue to the next frame as a microtask to execute. So before FlushQueue starts executing, there will be synchronized code that will add watchers to the execution queue. When FlushQueue starts executing, it will execute in ascending order of watcher.id to ensure that before RenderWatcher executes, All other Watcher callbacks have been executed, that is, when executing RenderWatcher callback to get the latest RenderData, and then go to the setData operation, complete the page update.

// 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 lot: https://github.com/didi/mpx using document: https://didi.github.io/mpx/