文章转自: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的源码主要包括三方面:

  1. webpack。webpack是前端常用的一个模块打包器,uni-app构建过程中,会将Vue SFC的template、script、style三段式的结构,编译成小程序四段式结构,以字节小程序为例,会得到ttml、ttss、js、json四种文件。
  2. 编译器。uni-app的编译器本质是把Vue 的视图编译成小程序的视图,即把template语法编译成小程序的ttml语法,之后,uni-app不会维护视图层,视图层的更新完全交给小程序自身维护。但是uni-app是使用Vue进行开发的,那Vue跟小程序是怎么交互的呢?这就依赖于uni-app的运行时。
  3. 运行时。运行时相当于一个桥梁,打通了Vue和小程序。小程序视图层的更新,比如事件点击、触摸等操作,会经过运行时的事件代理机制,然后到达Vue的事件函数。而Vue的事件函数触发了数据更新,又会重新经过运行时,触发setData,进一步更新小程序的视图层。

备注:本文章阅读的源码是uni-app ^2.0.0-30720210122002版本。

三.webpack

1. package.json

先看package.json scripts命令:

  • 注入NODE_ENV和UNI_PLATFORM命令
  • 调用vue-cli-service命令,执行uni-build命令
"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无非做了两件事:

  • 获取小程序的webpack配置。
  • 执行uni-build命令时,然后执行webpack

所以,入口文件其实就是执行webpackuni-appwebpack配置主要位于@dcloudio/vue-cli-plugin-uni/lib/mp/index.js,接下来我们通过entry、output、loader、plugin来看看uni-app是怎么把Vue SFC转换成小程序的。

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的主要工作:

  • 配置默认入口main.js
  • 解析pages.json,将page作为参数,和main.js组成新的入口

比如,我们的pages.json内容如下:

{"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配置

  • vue$是把vue替换成来uni-app的mp-vue
  • uni-pages表示pages.json文件
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.jsonlet 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其实就是调用updateComponentJsonupdateAppJson(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) { // updateif (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 { // addupdateJsonFile(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的作用:

  1. 获取mainfest.jsonpages.json的内容
  2. 分别调用updateAppJsonupdateProjectJson处理mainfest.jsonpage.json
  3. updateAppJsonupdateProjectJson本质都是调用了updateComponentJsonupdateComponentJson会更新json文件,最终调用updateJsonFile
  4. updateJsonFilejson文件生成的关键点。首先会定义一个共享的jsonFileMap键值对象,然后这里并没有直接生成相应的json文件,而是把mainfest.jsonpage.json处理成project.configapp,然后缓存在jsonFileMap中。
  5. 这里为什么不直接生成?因为后续pages/index/index.vue里也会有json文件的生成,所以所有的json文件都是暂时缓存在jsonFileMap中,后续由plugin统一生成。

通俗的说,webpack-uni-pages-loader实现的功能就是json语法的转换,还有就是缓存,语法转换很简单,只是对象key value的更改,我们可以直观的对比下mainfest.jsonpage.json构建前后差异。

// 转换前的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) => {// 生成.jsongenerateJson(compilation)// 生成app.json、project.config.jsongenerateApp(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的类型在这里有四种:

  • CallExpression(调用表达式):_c()
  • StringLiteral(字符串字面量):'div'
  • ObjectExpression(对象表达式):'{}'
  • ArrayExpression(数组表达式):[_v("hello world!")]

2.callee.name是调用表达式的名称:这里有_c、_v两种 3.arguments.*.value是参数的值:这里有div、text、hello world! 我们把ast对象和render函数对比,不难发现这两个其实是一一对应可逆的关系。

{"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)主要有两个参数:

  • parser解析出来的ast
  • visitor:visitor是一个由各种type或者是enter和exit组成的对象。这里我们指定了CallExpression类型,遍历ast时遇到CallExpression类型会执行该函数,把对应的div、img转换为view、image。

其它类型可看文档:babeljs.io/docs/en/bab…

const t = require('@babel/types')
const babelTraverse = require('@babel/traverse').defaultconst tagMap = {'div': 'view','img': 'image','p': 'text'
};const visitor = {CallExpression (path) {const callee = path.node.callee;const methodName = callee.nameswitch (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

  1. 拿到点击元素上的data-event-opts属性:[['tap',[['add'],['subtract',[2]]]],['touchstart',[['mixin',['$event']]]]]
  2. 根据点击类型获取相应数组,比如bindTap就取['tap',[['add'],['subtract',[2]]]]bindtouchstart就取['touchstart',[['mixin',['$event']]]]
  3. 依次调用相应事件类型的函数,并传入参数,比如tap调用this.add();this.subtract(2)
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 slotshandlerCtx = 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) { //destroyreturn}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 = truempInstance.setData(diffData, function () {this$1.__next_tick_pending = false;flushCallbacks$1(this$1);});} else {flushCallbacks$1(this);}}
};
复制代码

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

3. diff算法

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

  1. 类型改变
  2. 减量更新
  3. 增量更新
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会将新旧数据进行比对,然后获得差量更新的数据,调用setData更新。
  • 通过cur === pre进行判断,相同则直接返回。
  • 通过type(cur) === OBJECTTYPE进行对象判断:
    • pre不是OBJECTTYPE或者cur长度少于pre,则是类型改变或者减量更新,调用setResult直接添加新数据。
    • 否则执行增量更新逻辑:
      • 遍历cur,对每个key批量调用loop函数进行处理。
      • currentType不是ARRAYTYPE或者OBJECTTYPE,则是类型改变,调用setResult直接添加新数据。
      • currentTypeARRAYTYPE
        • preType不是ARRAYTYPE,或者currentValue长度少于preValue,则是类型改变或者减量更新,调用setResult直接添加新数据。
        • 否则执行增量更新逻辑,遍历currentValue,执行_diff进行递归。
      • currentTypeOBJECTTYPE:
        • preType不是OBJECTTYPE或者currentValue长度少于preValue,则是类型改变或者减量更新,调用setResult直接添加新数据。
        • 否则执行增量更新逻辑,遍历currentValue,执行_diff进行递归。
  • 通过type(cur) === ARRAYTYPE进行数组判断:
    • preType不是OBJECTTYPE或者currentValue长度少于preValue,则是类型改变或者减量更新,调用setResult直接添加新数据。
    • 否则执行增量更新逻辑,遍历currentValue,执行_diff进行递归。
  • 若以上三个判断居不成立,则判断是基础类型,调用setResult添加新数据。

小结:_diff的过程,主要进行对象、数组和基础类型的判断。只有基本类型、类型改变、减量更新会进行setResult,否则进行遍历递归_diff。

六.对比

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 年,谈小程序框架该如何选择

作者:luocheng
链接:https://juejin.cn/post/6968438754180595742
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

uni-app是如何构建小程序的?相关推荐

  1. uniapp App跳转微信小程序并互相传递参数、接收微信小程序传递的参数

    本文是uniapp打包成安卓App. 一.注意事项 1.用到了分享功能,在打包App时,需要配置manifest.json:App 模块配置->Share.按照提示填写微信分享的信息,appid ...

  2. uni-app app 跳转 微信小程序(安卓/ios)

    uni-app app 跳转 微信小程序(安卓/ios) 近日接到需求,需要实现在 app 中直接跳转到微信小程序中,其实非常简单,uni-app 都已经集成好了. 安卓和ios 有点区别,这个需要注 ...

  3. 【微信小程序】使用uni-app——开发首页搜索框导航栏(可同时兼容APP、H5、小程序)

    目录 前言 App.H5效果 小程序效果 一.兼容APP.H5的方式 二.兼容小程序 三.实现同时兼容 前言 首页都会提供一个搜索框给到客户,让客户自己去搜索自己想要的内容,这里就需要导航栏,来实现搜 ...

  4. 微信小程序-构建小程序的一些步骤

    自用. 一些可能用到的网站 iconfont-图标素材库 十六进制颜色值与RGB值互转 二颜色混合器 日本传统颜色 Flex布局语法 学习html 学习css 学习javascript 学习React ...

  5. dakai微信小程序 ios_iOS APP拉起微信小程序

    背景:由于公司业务需求,我们公司自己开发了一个微信小程序,然后通过App拉起微信小程序进行交互,有一些功能也可以放在小程序中去实现! 准备:首先你在微信的开放平台创建应用,并通过审核,就是你的App具 ...

  6. 惊爆:各大 App 可直接打开小程序!微信收割移动互联网的时间开始了

    点击上方"CSDN",选择"置顶公众号" 关键时刻,第一时间送达! 作者 | loonggg 本文经授权转自「非著名程序员」 最近两天,一条新闻在各大科技媒体上 ...

  7. APP跳转微信小程序,跳转微信公众号

    一.判断手机是否安装了微信的工具类: //判断手机是否安装了某些程序的工具类 public class IsInstallUtils {//判断是否安装了微信public static boolean ...

  8. APP 跳转微信小程序和回调

    在同一开放平台账号下的移动应用及小程序无需关联即可完成跳转,非同一开放平台账号下的小程序需与移动应用(APP)成功关联后才支持跳转. 可在"管理中心-移动应用-应用详情-关联小程序信息&qu ...

  9. Android APP跳转微信小程序和APP跳转支付宝小程序传参

    Android APP跳转微信小程序和APP跳转支付宝小程序传参 微信开放文档链接 MINIPROGRAM_TYPE_PREVIEW:体验版. MINIPROGRAM_TYPE_TEST:开发版 MI ...

最新文章

  1. 万字长文带你了解推荐系统全貌!
  2. 零基础入门学习Python(10)-列表(2)
  3. 朱永官等综述土壤生态学研究前沿
  4. 数据、事实、实体、值对象、事务、不变性
  5. The difference between synchronous and asynchronous code in JavaScript
  6. mysql数据库使用命令导入sql文件
  7. kvm虚拟机端口映射(端口转发)到宿主机
  8. 我回答的一个粉丝关于模拟SAP事务的问题
  9. 搭建srs服务器(rtmp)
  10. 经验:Library Cache Lock之异常分析-云和恩墨技术通讯精选
  11. 韩顺平php视频笔记37 php数据类型(部分)
  12. UIKit框架-高级控件Swift版本: 6.UIAlertView方法/属性详解
  13. WebSphere的管理员界面
  14. 一分钟了解自动化测试
  15. 【图像去噪】基于matlab改进非局部均值红外图像混合噪声【含Matlab源码 1640期】
  16. 服务器linux攻击方法,Linux操作系统中实现DDOS攻击的方法
  17. 360、腾讯、迅雷Windows编程、网络编程面试题及答案
  18. 竖亥——京东以神为名出击,神一样的物流黑科技
  19. 【C语言】验证哥德巴赫猜想:任何一个大于2的偶数均可表示成为两个素数之和。
  20. R语言学习 - Rstudio

热门文章

  1. 联发科MT6639与Wi-Fi 7兼容吗?
  2. 网站域名被QQ拦截提示:当前网页非官方页面拦截的解决办法
  3. unity3d 牧师与恶魔
  4. yuneec 滑板遥控器改造记录(1)
  5. lulu-ui日期选择器-周选择封装
  6. Unity2D教程:UI随屏幕缩放相关问题
  7. IT男独身勇闯女性精品店
  8. 我的个人网站——“甜果网”上线了!
  9. Thinkphp 获取用户IP
  10. 杭州计算机工作站,杭州学子工作站成立