周末在家里闲来无事,就想看一看 uni-app 是如何编译 vue 文件的,翻开 uni-app 源码,这个代码仓库内容非常庞大,但是今天我只对小程序编译感兴趣。翻开目录结构,我完全找不到东南西北,因为内容实在太多了,不知道哪一个文件夹才是我要找的。
每次看到这些目录结构就不想看下去了,闲着干点其他的事情不好吗?刷刷抖音,逛逛 b 站,但是这些都会让我无比的空虚,看完之后会觉得自己啥也没干,不是吗?于是我又犯贱地翻开了源码目录,哪怕每一次看一个文件夹,多来几次也能搜索到核心代码。将我的搜索结果列出来如下:
css
├── global.d.ts 全局类型文件
├── playground 示例代码
├── shims-node.d.ts .d.ts类型文件
├── shims-uni-app.d.ts
├── shims-vue-runtime.d.ts
├── shims-vue.d.ts
├── size-check 用来检测运行时源码体积是否超标
├── uni-api 导出所有原生方法的定义
├── uni-app uni-app 开头的是 原生 app 源代码
├── uni-app-plus
├── uni-app-uts
├── uni-app-vite
├── uni-app-vue
├── uni-automator
├── uni-cli-shared uni-cli 开头的是 cli 工具的源代码
├── uni-cli-utils
├── uni-cloud uni 云服务
├── uni-components 公共组件
├── uni-core
├── uni-h5 uni-h5 开头的是 web 端的源代码
├── uni-h5-vite
├── uni-h5-vue
├── uni-i18n
├── uni-mp-alipay uni-app 开头的是小程序端源代码
├── uni-mp-baidu
├── uni-mp-compiler 小程序编译时源码
├── uni-mp-core 小程序运行时源码
├── uni-mp-jd
├── uni-mp-kuaishou
├── uni-mp-lark
├── uni-mp-qq
├── uni-mp-toutiao
├── uni-mp-vite
├── uni-mp-vue
├── uni-mp-weixin
├── uni-mp-xhs
├── uni-nvue-styler
├── uni-push
├── uni-quickapp-webview
├── uni-shared
├── uni-stacktracey
├── uni-stat
├── uni-uts-v1
├── uni-vue
├── uni-vue-devtools
├── uts
├── uts-darwin-arm64
├── uts-darwin-x64
├── uts-linux-x64-gnu
├── uts-linux-x64-musl
├── uts-win32-ia32-msvc
├── uts-win32-x64-msvc
└── vite-plugin-uni
显而易见,本文要讨论的就是 uni-mp-compiler
编译器一般运行流程
众所周知,编译器一般的运行流程都是这样的:
Parse阶段:先对源代码进行词法分析,分解为 token,然后对 token 进行语法分析,得到 ast,也就是抽象语法树
Transform阶段:把所有需要转换的情况穷举出来,然后遍历 ast 每一个节点进行对应的转换
Generate阶段:对转换后的 ast 节点进行遍历,转换回代码
显然 uni-app 的编译过程也遵循这三个步骤,这一点从目录结构中就能够看出来:
css
├── ast.ts
├── codegen.ts
├── compile.ts Parse 阶段
├── decodeHtml.ts
├── errors.ts
├── identifier.ts
├── index.ts
├── namedChars.json
├── options.ts
├── parserOptions.ts
├── runtimeHelpers.ts
├── template
│ └── codegen.ts Generate阶段
├── transform.ts
└── transforms Transform阶段
Parse阶段
Parse 阶段直接引用的@vue/compiler-core这个包来进行解析,跟着我翻开这个包的源码,第一个映入眼帘的就是Tokenizer,它就是用来做词法分析的,利用到了状态机,状态机的三大要素就是状态、事件、响应
这里相当于穷举了所有的状态,并且写出了所有的状态转换逻辑,具体的业务逻辑就不再深入,感兴趣的可以自行阅读;
类比一下 babel,babel 的 Parser 也是使用状态机来做词法分析的:github1s.com/babel/babel...
语法分析阶段 babel 用的是 estree,这些了解一下即可;
总结一下:如果要编译 vue 文件直接使用@vue/compiler-core即可,如果只是编译 js 那么就可以用@babel/core
Transform阶段
上一个阶段已经拿到了抽象语法树,这一阶段的主要任务就是写业务逻辑,那就是把 vue 中的语法转化为小程序的语法,首先要转换 vue 的内置指令:
vText: 删除 vText 这个 props,然后追加到 children 中去
js
const transformText = (node, _) => {
if (!(0, uni_cli_shared_1.isElementNode)(node)) {
return;
}
const dir = (0, compiler_core_1.findDir)(node, 'text');
if (!dir) {
return;
}
// remove v-text
node.props.splice(node.props.indexOf(dir), 1);
if (node.tagType !== 0 /* ElementTypes.ELEMENT */) {
return;
}
node.isSelfClosing = false;
node.children = [
{
type: 5 /* NodeTypes.INTERPOLATION */,
loc: dir.exp.loc,
content: dir.exp,
},
];
};
vHtml:将 vHtml 这个 props 转换为 rich-text 这个子元素
js
{
tag: 'rich-text',
type: 1 /* NodeTypes.ELEMENT */,
tagType: 0 /* ElementTypes.ELEMENT */,
props: [(0, uni_cli_shared_1.createBindDirectiveNode)('nodes', dir.exp || '')],
isSelfClosing: true,
children: [],
codegenNode: undefined,
ns: node.ns,
loc: node.loc,
};
// 转换前的 props
{
"type": 7,
"name": "html",
"exp": {
"type": 4,
"content": "html.value",
"isStatic": false,
"constType": 0,
"loc": {
"start": {
"column": 19,
"line": 7,
"offset": 164
},
"end": {
"column": 23,
"line": 7,
"offset": 168
},
"source": "html"
}
},
"modifiers": [],
"loc": {
"start": {
"column": 11,
"line": 7,
"offset": 156
},
"end": {
"column": 24,
"line": 7,
"offset": 169
},
"source": "v-html=\"html\""
}
}
// 转换后
{
"tag": "rich-text",
"type": 1,
"tagType": 0,
"props": [
{
"type": 7,
"name": "bind",
"modifiers": [],
"loc": {
"source": "",
"start": {
"line": 1,
"column": 1,
"offset": 0
},
"end": {
"line": 1,
"column": 1,
"offset": 0
}
},
"arg": {
"type": 4,
"loc": {
"source": "",
"start": {
"line": 1,
"column": 1,
"offset": 0
},
"end": {
"line": 1,
"column": 1,
"offset": 0
}
},
"content": "nodes",
"isStatic": true,
"constType": 3
},
"exp": {
"type": 4,
"content": "html.value",
"isStatic": false,
"constType": 0,
"loc": {
"start": {
"column": 19,
"line": 7,
"offset": 164
},
"end": {
"column": 23,
"line": 7,
"offset": 168
},
"source": "html"
}
}
}
],
"isSelfClosing": true,
"children": [],
"ns": 0,
"loc": {
"start": {
"column": 5,
"line": 7,
"offset": 150
},
"end": {
"column": 32,
"line": 7,
"offset": 177
},
"source": "<view v-html=\"html\"></view>"
}
}
可能直接看这个 ast 有点疑惑,那么我来转换成代码:源码为<view v-html=\"html\"></view>
,转换后<view><rich-text v-bind.nodes=\"html\"/></view>
,是不是一目了然?
vSlot:插槽,又分为具名插槽和作用域插槽,具名插槽是这样转换的:
html
// 转换前
<custom>
<template v-slot:header/>
<template v-slot:default/>
<template v-slot:footer/>
</custom>
// 转换后
<custom u-s="{{['header','d','footer']}}" u-i="2a9ec0b0-0">
<view slot="header"/>
<view/>
<view slot="footer"/>
</custom>
可以看到转换之后把 template 改成了 view 组件,然后标注了三个插槽的名称,后面就交给运行时处理了
作用域插槽就没有上面那么简单了,它不仅编译出来了 wxml 还编译出了 js,它使用 v-for 来实现作用域插槽:
html
// 源代码
<custom>
<template v-slot:default="slotProps">
<view>{{ slotProps.item }}</view>
</template>
</custom>
// 编译出来的 wxml
<custom u-s="{{['d']}}" u-i="2a9ec0b0-0">
<view wx:for="{{a}}" wx:for-item="slotProps" wx:key="b" slot="{{slotProps.c}}">
<view>{{slotProps.a}}</view>
</view>
</custom>
// 编译出来的 js
(_ctx,_cache)=>{
return {
a: _w((slotProps,s0,i0)=>{
return {
a: _t(slotProps.item),
b: i0,
c: s0
};
}
, {
name: 'd',
path: 'a',
vueId: '2a9ec0b0-0'
})
}
}
vOn:和上面一样也会编译为两个文件,<view v-on:click="onClick"/>
会被编译为<view bindtap="{{a}}"/>
以及(_ctx, _cache) => { return { a: _o(_ctx.onClick) } }
vIf和 vFor都比较简单直接替换为 wx:if和wx:for就行了
Generate阶段
上面的示例除了第一个,我用的都是 Generate 之后的代码了,那么再来看看怎么从 ast 转化为代码呢。答案依然是穷举,把所有的情况都要列举出来然后一一转换。
可以看到是不断地循环然后执行了 genElement 最终把完整的 wxml 拼接出来了;