目录
- 前言
- 一.用法
- 二.原理
- 三.webpack
- 1. package.json
- 2. 入口
- 3. Entry
- 4. Output
- 5. Alias
- 6. Loader
- a. lib/main:
- b. wrap-loader:
- c. webpack-uni-pages-loader:
- d. vue-loader:
- 7. plugin
- 四. 编译器知一二
- 1. vue-template-compiler
- 2. @babel/parser
- 3. @babel/traverse和@babel/types
- 4. Generate vnode
- 5. Generate code
- 6. 总体流程:
- 五.运行时的原理
- 1.事件代理
- a. 编译后的ttml,这里编译出来data-event-opts、bindtap跟前面的编译器div => view的原理是差不多,也是在traverse做的ast转换,我们直接看编译后生成的ttml:
- b. 编译后的js的代码:
- 2. 数据同步机制
- 3. diff算法
- 六.对比
- 七.总结
- 七.参考资料
- 总结
前言
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。webpack是前端常用的一个模块打包器,uni-app构建过程中,会将Vue SFC的template、script、style三段式的结构,编译成小程序四段式结构,以字节小程序为例,会得到ttml、ttss、js、json四种文件。
- 编译器。uni-app的编译器本质是把Vue 的视图编译成小程序的视图,即把template语法编译成小程序的ttml语法,之后,uni-app不会维护视图层,视图层的更新完全交给小程序自身维护。但是uni-app是使用Vue进行开发的,那Vue跟小程序是怎么交互的呢?这就依赖于uni-app的运行时。
- 运行时。运行时相当于一个桥梁,打通了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._[] | |
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。 所以,入口文件其实就是执行webpack,uni-app的webpack配置主要位于@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": "#FF8F8", | |
"backgroundColor": "#FF8F8" | |
} | |
} |
然后我们看下输出的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%Findex%2Findex"}' | |
} |
4. Output
对于输出比较简单,dev和build分别打包到dist/dev/mp-toutiao和dist/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_VUE) { | |
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/main和wrap-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, 'utf')) | |
// 解析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,) | |
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,) | |
} | |
jsonFileMap.set(name, jsonStr) | |
} |
我们通过分步来了解webpack-uni-pages-loader的作用:
- 获取mainfest.json和pages.json的内容
- 分别调用updateAppJson和updateProjectJson处理mainfest.json和page.json
- updateAppJson和updateProjectJson本质都是调用了updateComponentJson,updateComponentJson会更新json文件,最终调用updateJsonFile
- updateJsonFile是json文件生成的关键点。首先会定义一个共享的jsonFileMap键值对象,然后这里并没有直接生成相应的json文件,而是把mainfest.json和page.json处理成project.config和app,然后缓存在jsonFileMap中。
- 这里为什么不直接生成?因为后续pages/index/index.vue里也会有json文件的生成,所以所有的json文件都是暂时缓存在jsonFileMap中,后续由plugin统一生成。 通俗的说,webpack-uni-pages-loader实现的功能就是json语法的转换,还有就是缓存,语法转换很简单,只是对象key value的更改,我们可以直观的对比下mainfest.json和page.json构建前后差异。
// 转换前的page.json | |
{ | |
"pages": [ | |
{ | |
"path": "pages/index/index", | |
"style": { | |
"navigationBarTitleText": "uni-app" | |
} | |
} | |
], | |
"globalStyle": { | |
"navigationBarTextStyle": "black", | |
"navigationBarTitleText": "uni-app", | |
"navigationBarBackgroundColor": "#FF8F8", | |
"backgroundColor": "#FF8F8" | |
} | |
} | |
// 转换后得到的app.json | |
{ | |
"pages": [ | |
"pages/index/index" | |
], | |
"subPackages": [], | |
"window": { | |
"navigationBarTextStyle": "black", | |
"navigationBarTitleText": "uni-app", | |
"navigationBarBackgroundColor": "#FF8F8", | |
"backgroundColor": "#FF8F8" | |
}, | |
"usingComponents": {} | |
} | |
// 转换前的mainfest.json | |
{ | |
"name": "", | |
"appid": "", | |
"description": "", | |
"versionName": ".0.0", | |
"versionCode": "", | |
"transformPx": true | |
} | |
// 转换后得到的project.config.json | |
{ | |
"setting": { | |
"urlCheck": true, | |
"es": 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-loader、sass-loader、postcss-loader、css-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_V_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:px;"><text>hello world!</text></div>
上面这个template经过uni-app编译后会变成下面的代码,看这里只是div => view的替换,但其实中间是走了很多流程的。
<view style="height:px;"><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":"px"}},[_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": "px" | |
} | |
} | |
] | |
} | |
} | |
] | |
}, | |
{ | |
"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').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[]; | |
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[].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[]; | |
if (t.isStringLiteral(arg)) { | |
return arg.value; | |
} | |
} | |
// 创建element节点 | |
function traverseCreateElement(callExprNode) { | |
const args = callExprNode.arguments; | |
const tagNode = args[]; | |
const node = { | |
type: tagNode.value, | |
attr: {}, | |
children: [], | |
}; | |
if (args.length <) { | |
return node; | |
} | |
const dataNodeOrChildNodes = args[]; | |
if (t.isObjectExpression(dataNodeOrChildNodes)) { | |
Object.assign(node.attr, traverseDataNode(dataNodeOrChildNodes)); | |
} else { | |
node.children = traverseExpr(dataNodeOrChildNodes); | |
} | |
if (args.length <) { | |
return node; | |
} | |
const childNodes = args[]; | |
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:px;" | |
}, | |
"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:px;"><text>hello world!</text></div>', | |
mpTemplate: '', | |
vNode: '', | |
}; | |
const tagMap = { | |
div: 'view', | |
}; | |
//.vue template => vue render | |
const {render} = compile(state.vueTemplate); | |
//.vue render => code ast | |
const ast = parser.parse(`function render(){${render}}`); | |
//.map code ast, modify syntax | |
traverse(ast, getVisitor()); | |
//.code ast => mp vNode | |
traverse(ast, { | |
WithStatement(path) { | |
state.vNode = traverseExpr(path.node.body.body[].argument); | |
}, | |
}); | |
//.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()" @touchstart="mixin($event)">{{ num }}</div> | |
</template> | |
<script> | |
export default { | |
data() { | |
return { | |
num: 0, | |
num: 0, | |
} | |
}, | |
methods: { | |
add () { | |
this.num++; | |
}, | |
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',[]]]], | |
['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构造器,之后是一层层的调用parsePage、parseBasePage、parseComponent、parseBaseComponent,parseBaseComponent最后返回一个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:
- 拿到点击元素上的data-event-opts属性:[['tap',[['add'],['subtract',[2]]]],['touchstart',[['mixin',['$event']]]]]
- 根据点击类型获取相应数组,比如bindTap就取['tap',[['add'],['subtract',[2]]]],bindtouchstart就取['touchstart',[['mixin',['$event']]]]
- 依次调用相应事件类型的函数,并传入参数,比如tap调用this.add();this.subtract(2)
function handleEvent (event) { | |
event = wrapper$(event); | |
// [['tap',[['handle',[,2,a]],['handle1',[1,2,a]]]]] | |
const dataset = (event.currentTarget || event.target).dataset; | |
const eventOpts = dataset.eventOpts || dataset['event-opts']; // 支付宝 web-view 组件 dataset 非驼峰 | |
// [['handle',[,2,a]],['handle1',[1,2,a]]] | |
const eventType = event.type; | |
const ret = []; | |
eventOpts.forEach(eventOpt => { | |
let type = eventOpt[]; | |
const eventsArray = eventOpt[]; | |
if (eventsArray && isMatchEventType(eventType, type)) { | |
eventsArray.forEach(eventArray => { | |
const methodName = eventArray[]; | |
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[], | |
eventArray[], | |
isCustom, | |
methodName | |
)); | |
return | |
} | |
const handler = handlerCtx[methodName]; | |
const params = processEventArgs( | |
this.$vm, | |
event, | |
eventArray[], | |
eventArray[], | |
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$ = 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$.__next_tick_pending = false; | |
flushCallbacks$(this$1); | |
}); | |
} else { | |
flushCallbacks$(this); | |
} | |
} | |
}; |
源代码比较简单,就是比对更新前后的数据,然后获得diffData,最后批量调用setData更新数据。
3. diff算法
小程序数据更新有三种情况
- 类型改变
- 减量更新
- 增量更新
page({ | |
data:{ | |
list:['item','item2','item3','item4'] | |
}, | |
change(){ | |
//.类型改变 | |
this.setData({ | |
list: 'list' | |
}) | |
}, | |
cut(){ | |
//.减量更新 | |
let newData = ['item', 'item6']; | |
this.setData({ | |
list: newData | |
}) | |
}, | |
add(){ | |
//.增量更新 | |
let newData = ['item','item6','item7','item8']; | |
this.data.list.push(...newData); //列表项新增记录 | |
this.setData({ | |
list:this.data.list | |
}) | |
} | |
}) |
对于类型替换或者减量更新,我们只要直接替换数据即可,但对于增量更新,如果进行直接数据替换,会有一定的性能问题,比如上面的例子,将item1~item4更新为了item1~item8,这个过程我们需要8个数据全部传递过去,但是实践上只更新了item5~item8。在这种情况下,为了优化性能,我们必须要采用如下写法,手动进行增量更新:
this.setData({ | |
list[]: 'item5', | |
list[]: 'item6', | |
list[]: 'item7', | |
list[]: '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) { | |
//.对象类型 | |
if (rootPreType != OBJECTTYPE || Object.keys(current).length < Object.keys(pre).length) { | |
//.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) { | |
//.2.1 处理基础类型 | |
if (currentValue != pre[key]) { | |
setResult(result, (path == '' ? '' : path + '.') + key, currentValue); | |
} | |
} else if (currentType == ARRAYTYPE) { | |
//.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) { | |
//.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 | |
); | |
} | |
} | |
} | |
}; | |
//.2遍历对象/数据类型 | |
for (var key in current) loop(key); | |
} | |
} else if (rootCurrentType == ARRAYTYPE) { | |
//.数组类型 | |
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 { | |
//.基本类型 | |
setResult(result, path, current); | |
} | |
}, |
- 当数据发生改变时,uni-app会将新旧数据进行比对,然后获得差量更新的数据,调用setData更新。
- 通过cur === pre进行判断,相同则直接返回。
- 通过type(cur) === OBJECTTYPE进行对象判断:
- 若pre不是OBJECTTYPE或者cur长度少于pre,则是类型改变或者减量更新,调用setResult直接添加新数据。
- 否则执行增量更新逻辑:
- 遍历cur,对每个key批量调用loop函数进行处理。
- 若currentType不是ARRAYTYPE或者OBJECTTYPE,则是类型改变,调用setResult直接添加新数据。
- 若currentType是ARRAYTYPE:
- 若preType不是ARRAYTYPE,或者currentValue长度少于preValue,则是类型改变或者减量更新,调用setResult直接添加新数据。
- 否则执行增量更新逻辑,遍历currentValue,执行_diff进行递归。
- 若currentType是OBJECTTYPE:
- 若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 年,谈小程序框架该如何选择