javascript技巧

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript技巧 > uni-app构建小程序

一文带你详细理解uni-app如何构建小程序

作者:luocheng

uni-app是近年来一种新兴的多端混合开发框架,适合开发跨平台应用,方便多端运行,下面这篇文章主要给大家介绍了关于uni-app如何构建小程序的相关资料,需要的朋友可以参考下

前言

uni-app是一个基于Vue.js语法开发小程序的前端框架,开发者通过编写一套代码,可发布到iOS、Android、Web以及各种小程序平台。今天,我们通过相关案例分析uni-app是怎样把Vue.js构建成原生小程序的。

Vue是template、script、style三段式的SFC,uni-app是怎么把SFC拆分成小程序的ttml、ttss、js、json四段式?带着问题,本文将从webpack、编译器、运行时三方面带你了解uni-app是如何构建小程序的。

一.用法

uni-app是基于vue-cli脚手架开发,集成一个远程的Vue Preset

npm install -g @vue/cli
vue create -p dcloudio/uni-preset-vue my-project

uni-app目前集成了很多不同的项目模版,可以根据不同的需要,选择不同的模版

运行、发布uni-app,以字节小程序为例

npm run dev:mp-toutiao
npm run build:mp-toutiao

二.原理

uni-app是一个比较传统的小程序框架,包括编译器+运行时。 小程序是视图和逻辑层分开的双线程架构,视图和逻辑的加载和运行互不阻塞,同时,逻辑层数据更新会驱动视图层的更新,视图的事件响应,会触发逻辑层的交互。 uni-app的源码主要包括三方面:

三.webpack

1. package.json

先看package.json scripts命令:

"dev:mp-toutiao": "cross-env NODE_ENV=development UNI_PLATFORM=mp-toutiao vue-cli-service uni-build --watch",

2. 入口

当我们在项目内部运行 vue-cli-service 命令时,它会自动解析并加载 package.json 中列出的所有 CLI 插件,Vue CLI 插件的命名遵循 vue-cli-plugin- 或者 @scope/vue-cli-plugin-的规范,这里主要的插件是@dcloudio/vue-cli-plugin-uni,相关源码:

module.exports = (api, options) => {
  api.registerCommand('uni-build', {
    description: 'build for production',
    usage: 'vue-cli-service uni-build [options]',
    options: {
      '--watch': 'watch for changes',
      '--minimize': 'Tell webpack to minimize the bundle using the TerserPlugin.',
      '--auto-host': 'specify automator host',
      '--auto-port': 'specify automator port'
    }
  }, async (args) => {
    for (const key in defaults) {
      if (args[key] == null) {
        args[key] = defaults[key]
      }
    }

    require('./util').initAutomator(args)

    args.entry = args.entry || args._[0]

    process.env.VUE_CLI_BUILD_TARGET = args.target

    // build函数会去获取webpack配置并执行
    await build(args, api, options)

    delete process.env.VUE_CLI_BUILD_TARGET
  })
}

当我们执行UNI_PLATFORM=mp-toutiao vue-cli-service uni-build时,@dcloudio/vue-cli-plugin-uni无非做了两件事:

3. Entry

uni-app会调用parseEntry去解析pages.json,然后放在process.UNI_ENTRY

webpackConfig () {
    parseEntry();
    return {
        entry () {
            return process.UNI_ENTRY
        }
    }
}

我们看下parseEntry主要代码:

function parseEntry (pagesJson) {
  // 默认有一个入口
  process.UNI_ENTRY = {
    'common/main': path.resolve(process.env.UNI_INPUT_DIR, getMainEntry())
  }

  if (!pagesJson) {
    pagesJson = getPagesJson()
  }

  // 添加pages入口
  pagesJson.pages.forEach(page => {
    process.UNI_ENTRY[page.path] = getMainJsPath(page.path)
  })
}

function getPagesJson () {
  // 获取pages.json进行解析
  return processPagesJson(getJson('pages.json', true))
}

const pagesJsonJsFileName = 'pages.js'
function processPagesJson (pagesJson) {
  const pagesJsonJsPath = path.resolve(process.env.UNI_INPUT_DIR, pagesJsonJsFileName)
  if (fs.existsSync(pagesJsonJsPath)) {
    const pagesJsonJsFn = require(pagesJsonJsPath)
    if (typeof pagesJsonJsFn === 'function') {
      pagesJson = pagesJsonJsFn(pagesJson, loader)
      if (!pagesJson) {
        console.error(`${pagesJsonJsFileName}  必须返回一个 json 对象`)
      }
    } else {
      console.error(`${pagesJsonJsFileName} 必须导出 function`)
    }
  }
  // 检查配置是否合法
  filterPages(pagesJson.pages)
  return pagesJson
}

function getMainJsPath (page) {
  // 将main.js和page参数组合成出新的入口
  return path.resolve(process.env.UNI_INPUT_DIR, getMainEntry() + '?' + JSON.stringify({
    page: encodeURIComponent(page)
  }))
}

parseEntry的主要工作:

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "uni-app"
      }
    }
  ],
  "globalStyle": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "uni-app",
    "navigationBarBackgroundColor": "#F8F8F8",
    "backgroundColor": "#F8F8F8"
  }
}

然后我们看下输出的enrty,可以发现其实就是通过在main.js带上响应参数来区分page的,这跟vue-loader区分template、script、style其实很像,后面可以通过判断参数,调用不同loader进行处理。

{
  'common/main': '/Users/src/main.js',
  'pages/index/index': '/Users/src/main.js?{"page":"pages%2Findex%2Findex"}'
}

4. Output

对于输出比较简单,devbuild分别打包到dist/dev/mp-toutiaodist/build/mp-toutiao

Object.assign(options, {
outputDir: process.env.UNI_OUTPUT_TMP_DIR || process.env.UNI_OUTPUT_DIR,
assetsDir
}, vueConfig)
  
webpackConfig () {
    return {
        output: {
        filename: '[name].js',
        chunkFilename: '[id].js',
    }
}

5. Alias

uni-app有两个主要的alias配置

resolve: {
    alias: {
      vue$: getPlatformVue(vueOptions), 
      'uni-pages': path.resolve(process.env.UNI_INPUT_DIR, 'pages.json'),
    },
    modules: [
      process.env.UNI_INPUT_DIR,
      path.resolve(process.env.UNI_INPUT_DIR, 'node_modules')
    ]
},
getPlatformVue (vueOptions) {
    if (uniPluginOptions.vue) {
      return uniPluginOptions.vue
    }
    if (process.env.UNI_USING_VUE3) {
      return '@dcloudio/uni-mp-vue'
    }
    return '@dcloudio/vue-cli-plugin-uni/packages/mp-vue'
},

6. Loader

从上面我们看出entry都是main.js,只不过会带上page的参数,我们从入口开始,看下uni-app是怎么一步步处理文件的,先看下处理main.js的两个loader:lib/mainwrap-loader

module: {
    rules: [{
      test: path.resolve(process.env.UNI_INPUT_DIR, getMainEntry()),
      use: [{
        loader: path.resolve(__dirname, '../../packages/wrap-loader'),
        options: {
          before: [
            'import \'uni-pages\';'
          ]
        }
      }, {
        loader: '@dcloudio/webpack-uni-mp-loader/lib/main'
      }]
    }]
}

a. lib/main:

我们看下核心代码,根据resourceQuery参数进行划分,我们主要看下有query的情况,会在这里引入Vue和pages/index/index.vue,同时调用createPage进行初始化,createPage是运行时,后面会讲到。由于引入了.vue,所以之后的解析就交给了vue-loader

module.exports = function (source, map) {
this.cacheable && this.cacheable()

  if (this.resourceQuery) {
    const params = loaderUtils.parseQuery(this.resourceQuery)
    if (params && params.page) {
      params.page = decodeURIComponent(params.page)
      // import Vue from 'vue'是为了触发 vendor 合并
      let ext = '.vue'
      return this.callback(null,
        `
import Vue from 'vue'
import Page from './${normalizePath(params.page)}${ext}'
createPage(Page)
`, map)
    }
  }    else    {......}
}

b. wrap-loader:

引入了uni-pages,从alias可知道就是import pages.json,对于pages.json,uni-app也有专门的webpack-uni-pages-loader进行处理。

module.exports = function (source, map) {
  this.cacheable()

  const opts = utils.getOptions(this) || {}
  this.callback(null, [].concat(opts.before, source, opts.after).join('').trim(), map)
}

c. webpack-uni-pages-loader:

代码比较多,我们贴下大体的核心代码,看看主要完成的事项

module.exports = function (content, map) {
  // 获取mainfest.json文件
  const manifestJsonPath = path.resolve(process.env.UNI_INPUT_DIR, 'manifest.json')
  const manifestJson = parseManifestJson(fs.readFileSync(manifestJsonPath, 'utf8'))

  // 解析pages.json
  let pagesJson = parsePagesJson(content, {
    addDependency: (file) => {
      (process.UNI_PAGES_DEPS || (process.UNI_PAGES_DEPS = new Set())).add(normalizePath(file))
      this.addDependency(file)
    }
  })
  
  const jsonFiles = require('./platforms/' + process.env.UNI_PLATFORM)(pagesJson, manifestJson, isAppView)

  if (jsonFiles && jsonFiles.length) {
    jsonFiles.forEach(jsonFile => {
      if (jsonFile) {
        // 对解析到的app.json和project.config.json进行缓存
        if (jsonFile.name === 'app') {
          // updateAppJson和updateProjectJson其实就是调用updateComponentJson
          updateAppJson(jsonFile.name, renameUsingComponents(jsonFile.content))
        } else {
          updateProjectJson(jsonFile.name, jsonFile.content)
        }
      }
    })
  }

  this.callback(null, '', map)
}

function updateAppJson (name, jsonObj) {
  updateComponentJson(name, jsonObj, true, 'App')
}

function updateProjectJson (name, jsonObj) {
  updateComponentJson(name, jsonObj, false, 'Project')
}

// 更新json文件
function updateComponentJson (name, jsonObj, usingComponents = true, type = 'Component') {
  if (type === 'Component') {
    jsonObj.component = true
  }
  if (type === 'Page') {
    if (process.env.UNI_PLATFORM === 'mp-baidu') {
      jsonObj.component = true
    }
  }

  const oldJsonStr = getJsonFile(name)
  if (oldJsonStr) { // update
    if (usingComponents) { // merge usingComponents
      // 其实直接拿新的 merge 到旧的应该就行
      const oldJsonObj = JSON.parse(oldJsonStr)
      jsonObj.usingComponents = oldJsonObj.usingComponents || {}
      jsonObj.usingAutoImportComponents = oldJsonObj.usingAutoImportComponents || {}
      if (oldJsonObj.usingGlobalComponents) { // 复制 global components(针对不支持全局 usingComponents 的平台)
        jsonObj.usingGlobalComponents = oldJsonObj.usingGlobalComponents
      }
    }
    const newJsonStr = JSON.stringify(jsonObj, null, 2)
    if (newJsonStr !== oldJsonStr) {
      updateJsonFile(name, newJsonStr)
    }
  } else { // add
    updateJsonFile(name, jsonObj)
  }
}

let jsonFileMap = new Map()
function updateJsonFile (name, jsonStr) {
  if (typeof jsonStr !== 'string') {
    jsonStr = JSON.stringify(jsonStr, null, 2)
  }
  jsonFileMap.set(name, jsonStr)
}

我们通过分步来了解webpack-uni-pages-loader的作用:

// 转换前的page.json
{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "uni-app"
      }
    }
  ],
  "globalStyle": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "uni-app",
    "navigationBarBackgroundColor": "#F8F8F8",
    "backgroundColor": "#F8F8F8"
  }
}
// 转换后得到的app.json
{
  "pages": [
    "pages/index/index"
  ],
  "subPackages": [],
  "window": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "uni-app",
    "navigationBarBackgroundColor": "#F8F8F8",
    "backgroundColor": "#F8F8F8"
  },
  "usingComponents": {}
}

// 转换前的mainfest.json
{
  "name": "",
  "appid": "",
  "description": "",
  "versionName": "1.0.0",
  "versionCode": "100",
  "transformPx": true
}

// 转换后得到的project.config.json
{
  "setting": {
    "urlCheck": true,
    "es6": false,
    "postcss": false,
    "minified": false,
    "newFeature": true
  },
  "appid": "体验appId",
  "projectname": "uniapp-analysis"
}

d. vue-loader:

处理完js和json文件,接下来就到了vue文件的处理,vue-loader会把vue拆分成template、style、script。 对于style,其实就是css,会经过less-loadersass-loaderpostcss-loadercss-loader的处理,最后由mini-css-extract-plugin生成对应的.ttss文件。 对于script,uni-app主要配置了script loader进行处理,该过程主要是将index.vue中引入的组件抽离成index.json,然后也是跟app.json一样,缓存在jsonFileMap数组中。

{
  resourceQuery: /vue&type=script/,
  use: [{
    loader: '@dcloudio/webpack-uni-mp-loader/lib/script'
  }]
}

对于template,这是比较核心的模块,uni-app更改了vue-loader的compiler,将vue-template-compiler替换成了uni-template-compiler,uni-template-compiler是用来把vue语法转换为小程序语法的,这里我们可以先记着,后面会讲到是如何编译的。这里我们关注的处理template的loader lib/template 。

{
  resourceQuery: /vue&type=template/,
  use: [{
    loader: '@dcloudio/webpack-uni-mp-loader/lib/template'
  }, {
    loader: '@dcloudio/vue-cli-plugin-uni/packages/webpack-uni-app-loader/page-meta'
  }]
}

loader lib/template首先会去获取vueLoaderOptions,然后添加新的options,小程序这里有一个关键是emitFile,因为vue-loader本身是没有往compiler注入emitFile的,所以compiler编译出来的语法要生成ttml需要有emitFile。

module.exports = function (content, map) {
  this.cacheable && this.cacheable()
  const vueLoaderOptions = this.loaders.find(loader => loader.ident === 'vue-loader-options')
  Object.assign(vueLoaderOptions.options.compilerOptions, {
      mp: {
        platform: process.env.UNI_PLATFORM
      },
      filterModules,
      filterTagName,
      resourcePath,
      emitFile: this.emitFile,
      wxComponents,
      getJsonFile,
      getShadowTemplate,
      updateSpecialMethods,
      globalUsingComponents,
      updateGenericComponents,
      updateComponentGenerics,
      updateUsingGlobalComponents
  })
}

7. plugin

uni-app主要的plugin是createUniMPPlugin,该过程对应了我们loader处理json时生成的jsonFileMap对象,本质就是把jsonFileMap里的json生成真实的文件。

class WebpackUniMPPlugin {
  apply (compiler) {
    if (!process.env.UNI_USING_NATIVE && !process.env.UNI_USING_V3_NATIVE) {
      compiler.hooks.emit.tapPromise('webpack-uni-mp-emit', compilation => {
        return new Promise((resolve, reject) => {
          // 生成.json
          generateJson(compilation)
          // 生成app.json、project.config.json
          generateApp(compilation)
            .forEach(({
              file,
              source
            }) => emitFile(file, source, compilation))

          resolve()
        })
      })
    }

相关的全局配置变量

plugins: [
    new webpack.ProvidePlugin({
        uni: [
            '/Users/luojincheng/source code/uniapp-analysis/node_modules/@dcloudio/uni-mp-toutiao/dist/index.js',
            'default'
          ],
        createPage: [
            '/Users/luojincheng/source code/uniapp-analysis/node_modules/@dcloudio/uni-mp-toutiao/dist/index.js',
            'createPage'
          ]
    })
]

四. 编译器知一二

编译器的原理其实就是通过ast的语法分析,把vue的template语法转换为小程序的ttml语法。但这样说其实很抽象,具体是怎么通过ast语法来转换的?接下来,我们通过构建简单版的template=>ttml的编译器,实现div=>view的标签转换,来了解uni-app的编译流程。

<div style="height: 100px;"><text>hello world!</text></div>

上面这个template经过uni-app编译后会变成下面的代码,看这里只是div => view的替换,但其实中间是走了很多流程的。

<view style="height: 100px;"><text>hello world!</text></view>

1. vue-template-compiler

首先,template会经过vue的编译器,得到渲染函数render

const {compile} = require('vue-template-compiler');
const {render} = compile(state.vueTemplate);
// 生成的render:
// with(this){return _c('div',{staticStyle:{"height":"100px"}},[_c('text',[_v("hello world!")])])}

2. @babel/parser

这一步是利用parser将render函数转化为ast。ast是Abstract syntax tree的缩写,即抽象语法树。

const parser = require('@babel/parser');
const ast = parser.parse(render);

这里我们过滤掉了一些start、end、loc、errors等会影响我们阅读的字段(完整ast可以通过 astexplorer.net网站查看),看看转译后的ast对象,该json对象我们重点关注program.body[0].expression。 1.type的类型在这里有四种:

{
  "type": "File",
  "program": {
    "type": "Program",
    },
    "sourceType": "module",
    "interpreter": null,
    "body": [
      {
        "type": "ExpressionStatement",
        "expression": {
          "callee": {
            "type": "Identifier",
            "name": "_c"
          },
          "arguments": [
            {
              "type": "StringLiteral",
              "value": "div"
            },
            {
              "type": "ObjectExpression",
              "properties": [
                {
                  "type": "ObjectProperty",
                  "method": false,
                  "key": {
                    "type": "Identifier",
                    "name": "staticStyle"
                  },
                  "computed": false,
                  "shorthand": false,
                  "value": {
                    "type": "ObjectExpression",
                    "properties": [
                      {
                        "type": "ObjectProperty",
                        "method": false,
                        "key": {
                          "type": "StringLiteral",
                          "value": "height"
                        },
                        "computed": false,
                        "shorthand": false,
                        "value": {
                          "type": "StringLiteral",
                          "value": "100px"
                        }
                      }
                    ]
                  }
                }
              ]
            },
            {
              "type": "ArrayExpression",
              "elements": [
                {
                  "type": "CallExpression",
                  "callee": {
                    "name": "_c"
                  },
                  "arguments": [
                    {
                      "type": "StringLiteral",
                      "value": "text"
                    },
                    {
                      "type": "ArrayExpression",
                      "elements": [
                        {
                          "type": "CallExpression",
                          "callee": {
                            "type": "Identifier",
                            "name": "_v"
                          },
                          "arguments": [
                            {
                              "type": "CallExpression",
                              "callee": {
                                "type": "Identifier",
                                "name": "_s"
                              },
                              "arguments": [
                                {
                                  "type": "Identifier",
                                  "name": "hello"
                                }
                              ]
                            }
                          ]
                        }
                      ]
                    }
                  ]
                }
              ]
            }
          ]
        }
      }
    ],
    "directives": []
  },
  "comments": []
}

3. @babel/traverse和@babel/types

这一步主要是利用traverse对生成的ast对象进行遍历,然后利用types判断和修改ast的语法。 traverse(ast, visitor)主要有两个参数:

const t = require('@babel/types')
const babelTraverse = require('@babel/traverse').default

const tagMap = {
  'div': 'view',
  'img': 'image',
  'p': 'text'
};

const visitor = {
  CallExpression (path) {
    const callee = path.node.callee;
    const methodName = callee.name
    switch (methodName) {
      case '_c': {
        const tagNode = path.node.arguments[0];
        if (t.isStringLiteral(tagNode)) {
          const tagName = tagMap[tagNode.value];
          tagNode.value = tagName;
        }
      }
    }
  }
};

traverse(ast, visitor);

4. Generate vnode

uni-app生成小程序的ttml需要先把修改后的ast生成类似vNode的对象,然后再遍历vNode生成ttml。

const traverse = require('@babel/traverse').default;

traverse(ast, {
  WithStatement(path) {
    state.vNode = traverseExpr(path.node.body.body[0].argument);
  },
});

// 不同的element走不同的创建函数
function traverseExpr(exprNode) {
  if (t.isCallExpression(exprNode)) {
    const traverses = {
      _c: traverseCreateElement,
      _v: traverseCreateTextVNode,
    };
    return traverses[exprNode.callee.name](exprNode);
  } else if (t.isArrayExpression(exprNode)) {
    return exprNode.elements.reduce((nodes, exprNodeItem) => {
      return nodes.concat(traverseExpr(exprNodeItem, state));
    }, []);
  }
}

// 转换style属性
function traverseDataNode(dataNode) {
  const ret = {};
  dataNode.properties.forEach((property) => {
    switch (property.key.name) {
      case 'staticStyle':
        ret.style = property.value.properties.reduce((pre, {key, value}) => {
          return (pre += `${key.value}: ${value.value};`);
        }, '');
        break;
    }
  });
  return ret;
}

// 创建Text文本节点
function traverseCreateTextVNode(callExprNode) {
  const arg = callExprNode.arguments[0];
  if (t.isStringLiteral(arg)) {
    return arg.value;
  }
}

// 创建element节点
function traverseCreateElement(callExprNode) {
  const args = callExprNode.arguments;
  const tagNode = args[0];

  const node = {
    type: tagNode.value,
    attr: {},
    children: [],
  };

  if (args.length < 2) {
    return node;
  }

  const dataNodeOrChildNodes = args[1];
  if (t.isObjectExpression(dataNodeOrChildNodes)) {
    Object.assign(node.attr, traverseDataNode(dataNodeOrChildNodes));
  } else {
    node.children = traverseExpr(dataNodeOrChildNodes);
  }

  if (args.length < 3) {
    return node;
  }
  const childNodes = args[2];
  if (node.children && node.children.length) {
    node.children = node.children.concat(traverseExpr(childNodes));
  } else {
    node.children = traverseExpr(childNodes, state);
  }

  return node;
}

这里之所以没有使用@babel/generator,是因为使用generator生成的还是render函数,虽然语法已经修改了,但要根据render是没办法直接生成小程序的ttml,还是得转换成vNode。 最好,我们看下生成的VNode对象。

{
    "type": "view",
    "attr": {
        "style": "height: 100px;"
    },
    "children": [{
        "type": "text",
        "attr": {},
        "children": ["hello world!"]
    }]
}

5. Generate code

遍历VNode,递归生成小程序代码

function generate(vNode) {
  if (!vNode) {
    return '';
  }

  if (typeof vNode === 'string') {
    return vNode;
  }

  const names = Object.keys(vNode.attr);
  const props = names.length
    ? ' ' +
      names
        .map((name) => {
          const value = vNode.attr[name];
          return `${name}="${value}"`;
        })
        .join(' ')
    : '';
  const children = vNode.children
    .map((child) => {
      return generate(child);
    })
    .join('');

  return `<${vNode.type}${props}>${children}</${vNode.type}>`;
}

6. 总体流程:

这里列出了uni-template-compiler大致转换的流程和关键代码,uni-template-compiler的ast语法转换工作都是在traverse这个过程完成的。

const {compile} = require('vue-template-compiler');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');

const state = {
  vueTemplate: '<div style="height: 100px;"><text>hello world!</text></div>',
  mpTemplate: '',
  vNode: '',
};
const tagMap = {
  div: 'view',
};

// 1.vue template => vue render
const {render} = compile(state.vueTemplate);
// 2.vue render => code ast
const ast = parser.parse(`function render(){${render}}`);
// 3.map code ast, modify syntax
traverse(ast, getVisitor());
// 4.code ast => mp vNode
traverse(ast, {
  WithStatement(path) {
    state.vNode = traverseExpr(path.node.body.body[0].argument);
  },
});
// 5.mp vNode => ttml
state.mpTemplate = generate(state.vNode);

console.log('vue template:', state.vueTemplate);
console.log('mp  template:', state.mpTemplate);

五.运行时的原理

uni-app提供了一个运行时uni-app runtime,打包到最终运行的小程序发行代码中,该运行时实现了Vue.js 和小程序两系统之间的数据、事件同步。

1.事件代理

我们以一个数字增加为例,看看uni-app是怎样把小程序的数据、事件跟vue整合起来的。

<template>
  <div @click="add(); subtract(2)" @touchstart="mixin($event)">{{ num }}</div>
</template>

<script>
export default {
  data() {
    return {
      num1: 0,
      num2: 0,
    }
  },
  methods: {
    add () {
      this.num1++;
    },
    subtract (num) {
      console.log(num)
    },
    mixin (e) {
      console.log(e)
    }
  }
}
</script>

a. 编译后的ttml,这里编译出来data-event-opts、bindtap跟前面的编译器div => view的原理是差不多,也是在traverse做的ast转换,我们直接看编译后生成的ttml:

<view 
    data-event-opts="{{
        [
            ['tap',[['add'],['subtract',[2]]]],
            ['touchstart',[['mixin',['$event']]]]
        ]
    }}"
    bindtap="__e" bindtouchstart="__e"
    class="_div">
    {{num}}
</view>

这里我们先解析一下data-event-opts数组: data-event-opts是一个二维数组,每个子数组代表一个事件类型。子数组有两个值,第一个表示事件类型名称,第二个表示触发事件函数的个数。事件函数又是一个数组,第一个值表述事件函数名称,第二个是参数个数。 ['tap',[['add'],['subtract',[2]]]]表示事件类型为tap,触发函数有两个,一个为add函数且无参数,一个为subtract且参数为2。 ['touchstart',[['mixin',['$event']]]]表示事件类型为touchstart,触发函数有一个为mixin,参数为$event对象。

b. 编译后的js的代码:

import Vue from 'vue'
import Page from './index/index.vue'
createPage(Page)

这里其实就是后调用uni-mp-toutiao里的createPage对vue的script部分进行了初始化。 createPage返回小程序的Component构造器,之后是一层层的调用parsePageparseBasePageparseComponentparseBaseComponentparseBaseComponent最后返回一个Component构造器

function createPage (vuePageOptions) {
  {
    return Component(parsePage(vuePageOptions))
  }
}

function parsePage (vuePageOptions) {
  const pageOptions = parseBasePage(vuePageOptions, {
    isPage,
    initRelation
  });
  return pageOptions
}

function parseBasePage (vuePageOptions, {
  isPage,
  initRelation
}) {
  const pageOptions = parseComponent(vuePageOptions);

  return pageOptions
}

function parseComponent (vueOptions) {
  const [componentOptions, VueComponent] = parseBaseComponent(vueOptions);

  return componentOptions
}

我们直接对比转换前后的vue和mp参数差异,本身vue的语法和mp Component的语法就很像。这里,uni-app会把vue的data属性和methods方法copy到mp的data,而且mp的methods主要有__e方法。

回到编译器生成ttml代码,发现所有的事件都会调用__e事件,而__e对应的就是handleEvent事件,我们看下handleEvent

function handleEvent (event) {
  event = wrapper$1(event);

  // [['tap',[['handle',[1,2,a]],['handle1',[1,2,a]]]]]
  const dataset = (event.currentTarget || event.target).dataset;
  const eventOpts = dataset.eventOpts || dataset['event-opts']; // 支付宝 web-view 组件 dataset 非驼峰

  // [['handle',[1,2,a]],['handle1',[1,2,a]]]
  const eventType = event.type;

  const ret = [];

  eventOpts.forEach(eventOpt => {
    let type = eventOpt[0];
    const eventsArray = eventOpt[1];

    if (eventsArray && isMatchEventType(eventType, type)) {
      eventsArray.forEach(eventArray => {
        const methodName = eventArray[0];
        if (methodName) {
          let handlerCtx = this.$vm;
          if (handlerCtx.$options.generic) { // mp-weixin,mp-toutiao 抽象节点模拟 scoped slots
            handlerCtx = getContextVm(handlerCtx) || handlerCtx;
          }
          if (methodName === '$emit') {
            handlerCtx.$emit.apply(handlerCtx,
              processEventArgs(
                this.$vm,
                event,
                eventArray[1],
                eventArray[2],
                isCustom,
                methodName
              ));
            return
          }
          const handler = handlerCtx[methodName];
          const params = processEventArgs(
            this.$vm,
            event,
            eventArray[1],
            eventArray[2],
            isCustom,
            methodName
          );
          ret.push(handler.apply(handlerCtx, (Array.isArray(params) ? params : []).concat([, , , , , , , , , , event])));
        }
      });
    }
  });
}

2. 数据同步机制

小程序视图层事件响应,会触发小程序逻辑事件,逻辑层会调用vue相应的事件,触发数据更新。那Vue数据更新之后,又是怎样触发小程序视图层更新的呢?

小程序数据更新必须要调用小程序的setData函数,而Vue数据更新的时候会触发Vue.prototype._update方法,所以,只要在_update里调用setData函数就可以了。 uni-app在Vue里新增了patch函数,该函数会在_update时被调用。

// install platform patch function
Vue.prototype.__patch__ = patch;

var patch = function(oldVnode, vnode) {
  var this$1 = this;

  if (vnode === null) { //destroy
    return
  }
  if (this.mpType === 'page' || this.mpType === 'component') {
    var mpInstance = this.$scope;
    var data = Object.create(null);
    try {
      data = cloneWithData(this);
    } catch (err) {
      console.error(err);
    }
    data.__webviewId__ = mpInstance.data.__webviewId__;
    var mpData = Object.create(null);
    Object.keys(data).forEach(function (key) { //仅同步 data 中有的数据
      mpData[key] = mpInstance.data[key];
    });
    var diffData = this.$shouldDiffData === false ? data : diff(data, mpData);
    if (Object.keys(diffData).length) {
      if (process.env.VUE_APP_DEBUG) {
        console.log('[' + (+new Date) + '][' + (mpInstance.is || mpInstance.route) + '][' + this._uid +
          ']差量更新',
          JSON.stringify(diffData));
      }
      this.__next_tick_pending = true
      mpInstance.setData(diffData, function () {
        this$1.__next_tick_pending = false;
        flushCallbacks$1(this$1);
      });
    } else {
      flushCallbacks$1(this);
    }
  }
};

源代码比较简单,就是比对更新前后的数据,然后获得diffData,最后批量调用setData更新数据。

3. diff算法

小程序数据更新有三种情况

page({
    data:{
        list:['item1','item2','item3','item4']
    },
    change(){
        // 1.类型改变
        this.setData({
            list: 'list'
        })
    },
    cut(){
        // 2.减量更新
        let newData = ['item5', 'item6'];
        this.setData({
            list: newData
        })
    },
    add(){
        // 3.增量更新
        let newData = ['item5','item6','item7','item8'];
        this.data.list.push(...newData); //列表项新增记录
        this.setData({
            list:this.data.list
        })
    }
})

对于类型替换或者减量更新,我们只要直接替换数据即可,但对于增量更新,如果进行直接数据替换,会有一定的性能问题,比如上面的例子,将item1~item4更新为了item1~item8,这个过程我们需要8个数据全部传递过去,但是实践上只更新了item5~item8。在这种情况下,为了优化性能,我们必须要采用如下写法,手动进行增量更新:

this.setData({
    list[4]: 'item5',
    list[5]: 'item6',
    list[6]: 'item7',
    list[7]: 'item8',
})

这种写法的开发体验极差,而且不便于维护,所以uni-app借鉴了westore JSON Diff的原理,在setData时进行了差量更新,下面,让我们通过源码,来了解diff的原理吧。

function setResult(result, k, v) {
    result[k] = v;
}

function _diff(current, pre, path, result) {
    if (current === pre) {
       // 更新前后无改变
      return;
    }
    var rootCurrentType = type(current);
    var rootPreType = type(pre);
    if (rootCurrentType == OBJECTTYPE) {
      // 1.对象类型
      if (rootPreType != OBJECTTYPE || Object.keys(current).length < Object.keys(pre).length) {
        // 1.1数据类型不一致或者减量更新,直接替换
        setResult(result, path, current);
      } else {
        var loop = function (key) {
          var currentValue = current[key];
          var preValue = pre[key];
          var currentType = type(currentValue);
          var preType = type(preValue);
          if (currentType != ARRAYTYPE && currentType != OBJECTTYPE) {
            // 1.2.1 处理基础类型
            if (currentValue != pre[key]) {
              setResult(result, (path == '' ? '' : path + '.') + key, currentValue);
            }
          } else if (currentType == ARRAYTYPE) {
            // 1.2.2 处理数组类型
            if (preType != ARRAYTYPE) {
              // 类型不一致
              setResult(result, (path == '' ? '' : path + '.') + key, currentValue);
            } else {
              if (currentValue.length < preValue.length) {
                // 减量更新
                setResult(result, (path == '' ? '' : path + '.') + key, currentValue);
              } else {
                // 增量更新则递归
                currentValue.forEach(function (item, index) {
                  _diff(item, preValue[index], (path == '' ? '' : path + '.') + key + '[' + index + ']', result);
                });
              }
            }
          } else if (currentType == OBJECTTYPE) {
            // 1.2.3 处理对象类型
            if (preType != OBJECTTYPE || Object.keys(currentValue).length < Object.keys(preValue).length) {
              // 类型不一致/减量更新
              setResult(result, (path == '' ? '' : path + '.') + key, currentValue);
            } else {
              // 增量更新则递归
              for (var subKey in currentValue) {
                _diff(
                  currentValue[subKey],
                  preValue[subKey],
                  (path == '' ? '' : path + '.') + key + '.' + subKey,
                  result
                );
              }
            }
          }
        };
        // 1.2遍历对象/数据类型
        for (var key in current) loop(key);
      }
    } else if (rootCurrentType == ARRAYTYPE) {
      // 2.数组类型
      if (rootPreType != ARRAYTYPE) {
        // 类型不一致
        setResult(result, path, current);
      } else {
        if (current.length < pre.length) {
          // 减量更新
          setResult(result, path, current);
        } else {
          // 增量更新则递归
          current.forEach(function (item, index) {
            _diff(item, pre[index], path + '[' + index + ']', result);
          });
        }
      }
    } else {
      // 3.基本类型
      setResult(result, path, current);
    }
  },

六.对比

uni-app是编译型的框架,虽然目前市面上运行型的框架层出不穷,比如Rax 运行时/Remax/Taro Next。对比这些,uni-app这类编译型的框架的劣势在于语法支持,运行型的框架几乎没有语法限制,而uni-app因为ast的复杂度和可转换性,导致部分语法无法支持。但是运行时也有缺点,运行型用的是小程序的模版语法template,而uni-app采用Component构造器,使用Component的好处就是原生框架可以知道页面的大体结构,而template模版渲染无法做到,同时,运行型框架数据传输量大,需要将数据转换成VNode传递个视图层,这也是运行型性能损耗的原因。

七.总结

七.参考资料

uni-app官网

前端搞跨端跨栈|保哥-如何打磨 uni-app 跨端框架的高性能和易用性 · 语雀

前端搞跨端跨栈|JJ-如何借助 Taro Next 横穿跨端业务线 · 语雀

在 2020 年,谈小程序框架该如何选择

总结

到此这篇关于uni-app如何构建小程序的文章就介绍到这了,更多相关uni-app构建小程序内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文