当前位置:网站首页>uni-app是如何构建小程序的?
uni-app是如何构建小程序的?
2022-06-09 22:12:00 【夜跑者】
推荐看原文。
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._[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。
所以,入口文件其实就是执行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": "#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
对于输出比较简单,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-vueuni-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/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, '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的作用:
- 获取
mainfest.json和pages.json的内容 - 分别调用
updateAppJson和updateProjectJson处理mainfest.json和page.json updateAppJson和updateProjectJson本质都是调用了updateComponentJson,updateComponentJson会更新json文件,最终调用updateJsonFileupdateJsonFile是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": "#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-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_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的类型在这里有四种:
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解析出来的astvisitor: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[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构造器,之后是一层层的调用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$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会将新旧数据进行比对,然后获得差量更新的数据,调用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 年,谈小程序框架该如何选择
作者:luocheng
链接:https://juejin.cn/post/6968438754180595742
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
边栏推荐
- The survey shows that MacOS application developers generally say that their biggest challenge is how the product is discovered by users
- Who is the slowest child in C language test 169
- 定义requestAnimationFrame一秒执行一次
- sparksql源码系列 | 最全的logical plan优化规则整理(spark2.3)
- centos+mysql报:Can‘t connect to local MySQL server through socket ‘/var/lib/mysql/mysql.socket
- M-Arch(雅特力M4)【AT-START-F425测评】No.04 CRC
- 经典面试题:如何快速求解根号2?
- 【滤波器】基于时变维纳滤波器实现语音去噪含Matlab源码
- cadence SPB17.4 - allegro - Move With Dynamic Alignment ON/OFF
- Laravel upload file information acquisition
猜你喜欢

Lua learning notes (4) -- building mobdebug remote development environment

【图像去噪】基于高斯、均值、中值、双边滤波实现图像去噪含Matlab源码

【蓝桥杯集训100题】scratch眩晕苹果 蓝桥杯scratch比赛专项预测编程题 集训模拟练习题第12题

还在怀疑数字藏品么?国家队都开始入局了

关于mongodb的那些安装、配置、报错处理、CRUD操作等再总结

Find My产品|新款TWS耳机支持Find My功能,苹果Find My在第三方厂家应用更广泛

【图像加密解密】基于混沌序列结合DWT+SVD实现图像加密解密(含相关性检验)含Matlab源码

Still doubting the digital collection? The national team is starting to get in

Find my technology | in the era of Internet of things, apple find my realizes real intelligent loss prevention

Do your filial duty to make an old people's fall prevention alarm system for your family
随机推荐
be all eagerness to see it! Data list of Benji Banas' first quarter reward activities!
On a machine with 4GB of physical memory, how about applying for 8g of memory?
Find my technology | in the era of Internet of things, apple find my realizes real intelligent loss prevention
Erc20 protocol API interface specification
时间序列预测中使用类EMD方法时的信息泄露和计算量问题
常见SQL语句
Technology sharing behind NFT and opensea transactions
Maximum value and subscript of acquisition matrix of C language test question 168
Implementing Lmax disruptor queue from scratch (II) analysis of consumption dependency principle among multiple consumers and consumer groups
常见的嵌入式端流媒体服务器开源项目!
【报表工具的第二次革命】基于SPL语言优化报表结构、提升报表运算性能
基于JSP实现网上招聘系统
AI helps release the potential energy of legal affairs -- the itarms contract intelligent review system of Fada was officially released
Expérience de l'optimisation des bases de données
目前28岁,从20年开始北漂闯荡到入职软件测试,我成功捧起15K薪资
【Jmeter】Jmeter从入门到精通
如何实现高效的IM即时通讯长连接自适应心跳保活机制
[filter] speech denoising based on time-varying Wiener filter, including Matlab source code
【图像重建】基于正则化的图像超分辨重建附matlab代码
Leetcode(力扣)超高頻題講解(一)