自我介绍:大家好,我是吉帅振的网络日志;微信公众号:吉帅振的网络日志;前端开发工程师,工作4年,去过上海、北京,经历创业公司,进过大厂,现在郑州敲代码。
Vue.js 3.0 的编译场景分服务端 SSR 编译和 web 编译,本文我们只分析 web 的编译。我们先来看 web 编译的入口 compile 函数,分析它的实现原理:
function compile(template, options = {}) {
return baseCompile(template, extend({}, parserOptions, options, {
nodeTransforms: [...DOMNodeTransforms, ...(options.nodeTransforms || [])],
directiveTransforms: extend({}, DOMDirectiveTransforms, options.directiveTransforms || {}),
transformHoist: null
}))
}
compile 函数支持两个参数,第一个参数 template 是待编译的模板字符串,第二个参数 options 是编译的一些配置信息。compile 内部通过执行 baseCompile 方法完成编译工作,可以看到 baseCompile 在参数 options 的基础上又扩展了一些配置。
function baseCompile(template, options = {}) {
const prefixIdentifiers = false
// 解析 template 生成 AST
const ast = isString(template) ? baseParse(template, options) : template
const [nodeTransforms, directiveTransforms] = getBaseTransformPreset()
// AST 转换
transform(ast, extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || [])
],
directiveTransforms: extend({}, directiveTransforms, options.directiveTransforms || {}
)
}))
// 生成代码
return generate(ast, extend({}, options, {
prefixIdentifiers
}))
}
{{ msg }}
This is an app
//解析后,生成相应的 AST 对象:
{
"type": 0,
"children": [
{
"type": 1,
"ns": 0,
"tag": "div",
"tagType": 0,
"props": [
{
"type": 6,
"name": "class",
"value": {
"type": 2,
"content": "app",
"loc": {
"start": {
"column": 12,
"line": 1,
"offset": 11
},
"end": {
"column": 17,
"line": 1,
"offset": 16
},
"source": "\"app\""
}
},
"loc": {
"start": {
"column": 6,
"line": 1,
"offset": 5
},
"end": {
"column": 17,
"line": 1,
"offset": 16
},
"source": "class=\"app\""
}
}
],
}
可以看到,AST 是树状结构,对于树中的每个节点,会有 type 字段描述节点的类型,tag 字段描述节点的标签,props 描述节点的属性,loc 描述节点对应代码相关信息,children 指向它的子节点对象数组。当然 AST 中的节点还包含其他的一些属性,我在这里就不一一介绍了,你现在要理解的是 AST 中的节点是可以完整地描述它在模板中映射的节点信息。注意,AST 对象根节点其实是一个虚拟节点,它并不会映射到一个具体节点,另外它还包含了其他的一些属性,这些属性在后续的 AST 转换的过程中会赋值,并在生成代码阶段用到。
那么,为什么要设计一个虚拟节点呢?因为 Vue.js 3.0 和 Vue.js 2.x 有一个很大的不同——Vue.js 3.0 支持了 Fragment 的语法,即组件可以有多个根节点,比如:
//这种写法在 Vue.js 2.x 中会报错,提示模板只能有一个根节点,
//而 Vue.js 3.0 允许了这种写法。但是对于一棵树而言,必须有一个根节点,
//所以虚拟节点在这种场景下就非常有用了,它可以作为 AST 的根节点,
//然后其 children 包含了 img 和 hello 的节点。 baseParse 的实现:
function baseParse(content, options = {}) {
// 创建解析上下文
const context = createPa rserContext(content, options)
const start = getCursor(context)
// 解析子节点,并创建 AST
return createRoot(parseChildren(context, 0 /* DATA */, []), getSelection(context, start))
}
1.创建解析上下文
// 默认解析配置
const defaultParserOptions = {
delimiters: [`{{`, `}}`],
getNamespace: () => 0 /* HTML */,
getTextMode: () => 0 /* DATA */,
isVoidTag: NO,
isPreTag: NO,
isCustomElement: NO,
decodeEntities: (rawText) => rawText.replace(decodeRE, (_, p1) => decodeMap[p1]),
onError: defaultOnError
}
function createParserContext(content, options) {
return {
options: extend({}, defaultParserOptions, options),
column: 1,
line: 1,
offset: 0,
originalSource: content,
source: content,
inPre: false,
inVPre: false
}
}
解析上下文实际上就是一个 JavaScript 对象,它维护着解析过程中的上下文,其中 options 表示解析相关配置 ,column 表示当前代码的列号,line 表示当前代码的行号,originalSource 表示最初的原始代码,source 表示当前代码,offset 表示当前代码相对于原始代码的偏移量,inPre 表示当前代码是否在 pre 标签内,inVPre 表示当前代码是否在 v-pre 指令的环境下。在后续解析的过程中,会始终维护和更新这个解析上下文,它能够表示当前解析的状态。创建完解析上下文,接下来就开始解析子节点了。
2.解析子节点
function parseChildren(context, mode, ancestors) {
const parent = last(ancestors)
const ns = parent ? parent.ns : 0 /* HTML */
const nodes = []
// 自顶向下分析代码,生成 nodes
let removedWhitespace = false
// 空白字符管理
return removedWhitespace ? nodes.filter(Boolean) : nodes
}
parseChildren 的目的就是解析并创建 AST 节点数组。它有两个主要流程,第一个是自顶向下分析代码,生成 AST 节点数组 nodes;第二个是空白字符管理,用于提高编译的效率。
function parseChildren(context, mode, ancestors) {
// 父节点
const parent = last(ancestors)
const ns = parent ? parent.ns : 0 /* HTML */
const nodes = []
// 判断是否遍历结束
while (!isEnd(context, mode, ancestors)) {
const s = context.source
let node = undefined
if (mode === 0 /* DATA */ || mode === 1 /* RCDATA */) {
if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
// 处理 {{ 插值代码
node = parseInterpolation(context, mode)
}
else if (mode === 0 /* DATA */ && s[0] === '<') {
// 处理 < 开头的代码
if (s.length === 1) {
// s 长度为 1,说明代码结尾是 <,报错
emitError(context, 5 /* EOF_BEFORE_TAG_NAME */, 1)
}
else if (s[1] === '!') {
// 处理 ') {
// > 缺少结束标签,报错
emitError(context, 14 /* MISSING_END_TAG_NAME */, 2)
advanceBy(context, 3)
continue
}
else if (/[a-z]/i.test(s[2])) {
// 多余的结束标签
emitError(context, 23 /* X_INVALID_END_TAG */)
parseTag(context, 1 /* End */, parent)
continue
}
else {
emitError(context, 12 /* INVALID_FIRST_CHARACTER_OF_TAG_NAME */, 2)
node = parseBogusComment(context)
}
}
else if (/[a-z]/i.test(s[1])) {
// 解析标签元素节点
node = parseElement(context, ancestors)
}
else if (s[1] === '?') {
emitError(context, 21 /* UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME */, 1)
node = parseBogusComment(context)
}
else {
emitError(context, 12 /* INVALID_FIRST_CHARACTER_OF_TAG_NAME */, 1)
}
}
}
if (!node) {
// 解析普通文本节点
node = parseText(context, mode)
}
if (isArray(node)) {
// 如果 node 是数组,则遍历添加
for (let i = 0; i < node.length; i++) {
pushNode(nodes, node[i])
}
}
else {
// 添加单个 node
pushNode(nodes, node)
}
}
}
这些代码看起来很复杂,但它的思路就是自顶向下地去遍历代码,然后根据不同的情况尝试去解析代码,然后把生成的 node 添加到 AST nodes 数组中。在解析的过程中,解析上下文 context 的状态也是在不断发生变化的,我们可以通过 context.source 拿到当前解析剩余的代码 s,然后根据 s 不同的情况走不同的分支处理逻辑。在解析的过程中,可能会遇到各种错误,都会通过 emitError 方法报错。
function createRoot(children, loc = locStub) {
return {
type: 0 /* ROOT */,
children,
helpers: [],
components: [],
directives: [],
hoists: [],
imports: [],
cached: 0,
temps: 0,
codegenNode: undefined,
loc
}
}
createRoot 的实现非常简单,它就是返回一个 JavaScript 对象,作为 AST 根节点。其中 type 表示它是一个根节点类型,children 是我们前面解析的子节点数组。
掌握 Vue.js 编译过程的第一步,即把 template 解析生成 AST 对象。整个解析过程是一个自顶向下的分析过程,也就是从代码开始,通过语法分析,找到对应的解析处理逻辑,创建 AST 节点,处理的过程中也在不断前进代码,更新解析上下文,最终根据生成的 AST 节点数组创建 AST 根节点。