跳到主要内容

Vue2 模板编译

· 阅读需 16 分钟

Vue 的版本

  • vue.js:完整版本,包含了模板编译的能力
  • vue.runtime.js:运行时版本,不提供模板编译能力,需要通过 vue-loader 进行提前编译

简单来说,就是如果你用了 vue-loader ,就可以使用 vue.runtime.min.js,将模板编译的过程交过 vue-loader,如果你是在浏览器中直接通过 script 标签引入 Vue,需要使用 vue.min.js,运行的时候编译模板。

介绍开始之前,先看下模板编译的整体的流程

编译入口

入口文件位于 src/platforms/web/entry-runtime-with-compiler.js

// 省略了部分代码,只保留了关键部分
import { compileToFunctions } from './compiler/index'

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el) {
const options = this.$options

// 如果没有 render 方法,则进行 template 编译
if (!options.render) {
let template = options.template
if (template) {
// 调用 compileToFunctions,编译 template,得到 render 方法
const { render, staticRenderFns } = compileToFunctions(
template,
{
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
},
this
)
// 这里的 render 方法就是生成生成虚拟 DOM 的方法
options.render = render
}
}
return mount.call(this, el, hydrating)
}

compileToFunctions来自于src/compiler/index,当调用createCompilerCreator会返回一个createCompiler函数, 该函数返回了compileToFunctions,具体可查看代码 src/coppiler/create-compiler.js

下面继续分析主流程的执行过程, 查看src/compiler/index

export const createCompiler = createCompilerCreator(function baseCompile(
template: string,
options: CompilerOptions
): CompiledResult {
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
optimize(ast, options)
}
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
})

可以看到主流程主要是执行了三步:

  • 模板编译,将模板代码转化为 AST
  • 优化 AST,方便后续虚拟 DOM 更新;
  • 生成代码,将 AST 转化为可执行的代码;

parse

调用 parse方法,传入模板字符串,解析 HTML 并会返回一个ASTElement对象

解析 HTML

parse 的整体流程比较复杂

import { parseHTML } from './html-parser'

export function parse(template, options) {
let root
parseHTML(template, {
// some options...
start() {}, // 解析到标签位置开始的回调
end() {}, // 解析到标签位置结束的回调
chars() {}, // 解析到文本时的回调
comment() {} // 解析到注释时的回调
})
return root
}

可以看到 parse 主要通过 parseHTML 进行工作,这个 parseHTML 本身来自于开源库:simplehtmlparser

下面继续分析 parseHTML 的源码

export function parseHTML(html, options) {
let index = 0
let last,lastTag
const stack = []
while(html) {
last = html
let textEnd = html.indexOf('<')

// "<" 字符在当前 html 字符串开始位置
if (textEnd === 0) {
// 1、匹配到注释: <!-- -->
if (/^<!\\--/.test(html)) {
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
// 调用 options.comment 回调,传入注释内容
options.comment(html.substring(4, commentEnd))
// 裁切掉注释部分
advance(commentEnd + 3)
continue
}
}

// 2、匹配到条件注释: <![if !IE]> <![endif]>
if (/^<!\\[/.test(html)) {
// ... 逻辑与匹配到注释类似
}

// 3、匹配到 Doctype: <!DOCTYPE html>
const doctypeMatch = html.match(/^<!DOCTYPE [^>]+>/i)
if (doctypeMatch) {
// ... 逻辑与匹配到注释类似
}

// 4、匹配到结束标签: </div>
const endTagMatch = html.match(endTag)
if (endTagMatch) {}

// 5、匹配到开始标签: <div>
const startTagMatch = parseStartTag()
if (startTagMatch) {}
}
// "<" 字符在当前 html 字符串中间位置
let text, rest, next
if (textEnd > 0) {
// 提取中间字符
rest = html.slice(textEnd)
// 这一部分当成文本处理
text = html.substring(0, textEnd)
advance(textEnd)
}
// "<" 字符在当前 html 字符串中不存在
if (textEnd < 0) {
text = html
html = ''
}

// 如果存在 text 文本
// 调用 options.chars 回调,传入 text 文本
if (options.chars && text) {
// 字符相关回调
options.chars(text)
}
}
// 向前推进,裁切 html
function advance(n) {
index += n
html = html.substring(n)
}
}

上述代码为简化后的 parseHTMLwhile 循环中每次截取一段 html 文本,然后通过正则判断文本的类型进行处理,这就类似于编译原理中常用的有限状态机。每次拿到 "<" 字符前后的文本,"<" 字符前的就当做文本处理,"<" 字符后的通过正则判断,可推算出有限的几种状态

其他的逻辑处理都不复杂,主要是开始标签与结束标签,具体请查看 Vue编译器 token解析

值得注意的是为了 HTML 结构的完整性,代码中使用了栈结构。 当在解析开始标签的时候,如果该标签不是单标签,会将该标签放入到一个堆栈当中,每次遇到闭合标签的时候,会从栈顶找同名的开始标签并出栈。算法类似于leetcode 20

回到调用 parseHTML 的位置,调用该方法的时候,一共会传入四个回调,分别对应标签的开始和结束、文本、注释

parseHTML(template, {
// some options...

// 解析到标签位置开始的回调
start(tag, attrs, unary) {},
// 解析到标签位置结束的回调
end(tag) {},
// 解析到文本时的回调
chars(text: string) {},
// 解析到注释时的回调
comment(text: string) {}
})

处理开始标签

首先看解析到开始标签时,会生成一个 AST 节点,然后处理标签上的属性,最后将 AST 节点放入树形结构中。

function makeAttrsMap(attrs) {
const map = {}
for (let i = 0, l = attrs.length; i < l; i++) {
const { name, value } = attrs[i]
map[name] = value
}
return map
}
function createASTElement(tag, attrs, parent) {
const attrsList = attrs
const attrsMap = makeAttrsMap(attrsList)
return {
type: 1, // 节点类型
tag, // 节点名称
attrsMap, // 节点属性映射
attrsList, // 节点属性数组
parent, // 父节点
children: [] // 子节点
}
}

const stack = []
let root // 根节点
let currentParent // 暂存当前的父节点
parseHTML(template, {
// some options...

// 解析到标签位置开始的回调
start(tag, attrs, unary) {
// 创建 AST 节点
let element = createASTElement(tag, attrs, currentParent)

// 处理指令: v-for v-if v-once
processFor(element)
processIf(element)
processOnce(element)
processElement(element, options)

// 处理 AST 树
// 根节点不存在,则设置该元素为根节点
if (!root) {
root = element
checkRootConstraints(root)
}
// 存在父节点
if (currentParent) {
// 将该元素推入父节点的子节点中
currentParent.children.push(element)
element.parent = currentParent
}
if (!unary) {
// 非单标签需要入栈,且切换当前父元素的位置
currentParent = element
stack.push(element)
}
}
})

处理结束标签

标签结束只需要去除栈内最后一个未闭合标签,进行闭合即可。

parseHTML(template, {
// 解析到标签位置结束的回调
end() {
const element = stack[stack.length - 1]
const lastNode = element.children[element.children.length - 1]
// 处理尾部空格的情况
if (lastNode && lastNode.type === 3 && lastNode.text === ' ') {
element.children.pop()
}
// 出栈,重置当前的父节点
stack.length -= 1
currentParent = stack[stack.length - 1]
}
})

处理文本

处理完标签后,还需要对标签内的文本进行处理。文本的处理分两种情况,一种是带表达式的文本,还一种就是纯静态的文本。

parseHTML(template, {
// some options...

// 解析到文本时的回调
chars(text) {
if (!currentParent) {
// 文本节点外如果没有父节点则不处理
return
}

const children = currentParent.children
text = text.trim()
if (text) {
// parseText 用来解析表达式
// delimiters 表示表达式标识符,默认为 ['{{', '}}']
const res = parseText(text, delimiters))
if (res) {
// 表达式
children.push({
type: 2,
expression: res.expression,
tokens: res.tokens,
text
})
} else {
// 静态文本
children.push({
type: 3,
text
})
}
}
}
})

parseText 解析表达式

// 构造匹配表达式的正则
const buildRegex = (delimiters) => {
const open = delimiters[0]
const close = delimiters[1]
return new RegExp(open + '((?:.|\\\\n)+?)' + close, 'g')
}

function parseText(text, delimiters) {
// delimiters 默认为 {{ }}
const tagRE = buildRegex(delimiters || ['{{', '}}'])
// 未匹配到表达式,直接返回
if (!tagRE.test(text)) {
return
}
const tokens = []
const rawTokens = []
let lastIndex = (tagRE.lastIndex = 0)
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
// 表达式开始的位置
index = match.index
// 提取表达式开始位置前面的静态字符,放入 token 中
if (index > lastIndex) {
rawTokens.push((tokenValue = text.slice(lastIndex, index)))
tokens.push(JSON.stringify(tokenValue))
}
// 提取表达式内部的内容,使用 _s() 方法包裹
const exp = match[1].trim()
tokens.push(`_s(${exp})`)
rawTokens.push({ '@binding': exp })
lastIndex = index + match[0].length
}
// 表达式后面还有其他静态字符,放入 token 中
if (lastIndex < text.length) {
rawTokens.push((tokenValue = text.slice(lastIndex)))
tokens.push(JSON.stringify(tokenValue))
}
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}

可以看到遇到表达式将表达式的内容存储在对象的@binding 属性上。

optimize

经过对 HTML 字符串的解析得到一颗 AST,需要对AST进行优化,确保静态的数据不会进入虚拟 DOM 的更新阶段。优化详细内容位于 src/compiler/optimizer.js

export function optimize(root: ?ASTElement, options: CompilerOptions) {
if (!root) return
isStaticKey = genStaticKeysCached(options.staticKeys || '')
isPlatformReservedTag = options.isReservedTag || no
// first pass: mark all non-static nodes.
markStatic(root)
// second pass: mark static roots.
markStaticRoots(root, false)
}

markStatic 核心就是将静态节点的 static 属性设置为 true,那么什么是静态节点呢

function isStatic(node: ASTNode): boolean {
if (node.type === 2) {
// expression
return false
}
if (node.type === 3) {
// text
return true
}
return !!(
node.pre ||
(!node.hasBindings && // 没有动态绑定
!node.if &&
!node.for && // 没有 v-if/v-for
!isBuiltInTag(node.tag) && // 不是内置组件 slot/component
isPlatformReservedTag(node.tag) && // not a component
!isDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isStaticKey))
)
}

ASTNode 的 type 来自三种数据结构

  • type=1, ASTElement
  • type=2, ASTExpression
  • type=3, ASTText

static 属性标记为 true还需要针对 ASTElement的子节点进行递归设置,如果子节点都是静态的,则在父节点标记 node.staticRoot = true, 在后续的 generate流程中会将生成的渲染函数放在组件的staticRenderFns属性上,并在组件的 render函数中引用。具体请看下面的流程

generate

优化完 ast 就需要生成 render方法了,详细代码位于 src/compiler/codegen/index.js

export function generate(
ast: ASTElement | void,
options: CompilerOptions
): CodegenResult {
const state = new CodegenState(options)
const code = ast ? genElement(ast, state) : '_c("div")'
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns
}
}

可以看到 render方法中的内容主要使用 getElement(ast, state) 生成。

export function genElement(el: ASTElement, state: CodegenState): string {
if (el.parent) {
el.pre = el.pre || el.parent.pre
}
if (el.staticRoot && !el.staticProcessed) {
return genStatic(el, state)
} else if (el.once && !el.onceProcessed) {
return genOnce(el, state)
} else if (el.for && !el.forProcessed) {
return genFor(el, state)
} else if (el.if && !el.ifProcessed) {
return genIf(el, state)
} else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
return genChildren(el, state) || 'void 0'
} else if (el.tag === 'slot') {
return genSlot(el, state)
} else {
// component or element
let code
if (el.component) {
code = genComponent(el.component, el, state)
} else {
let data
if (!el.plain || (el.pre && state.maybeComponent(el))) {
data = genData(el, state)
}

const children = el.inlineTemplate ? null : genChildren(el, state, true)
code = `_c('${el.tag}'${
data ? `,${data}` : '' // data
}${
children ? `,${children}` : '' // children
})`
}
// module transforms
for (let i = 0; i < state.transforms.length; i++) {
code = state.transforms[i](el, code)
}
return code
}
}

生成函数的过程中

  • 会先判断当前的节点的staticRoot是不是true, 为true则说明其子节点都是静态,直接调用genStatic
  • 使用v-once的元素,调用genOnce。 其只会渲染一次,随后即使改变了其中响应式字段的值,也不进行更新。genOnce会将元素的staticProcessed设置为true, 表示是处理过程中的静态节点
  • 使用v-for的元素,调用 genFor。其中会检查是否给元素添加了 key,没有则提示警告, 之后只用 _l进行渲染

后续针对不同节点的处理请查看源码,这里不再赘述。

上述创建 dom 的核心代码是

code = `_c('${el.tag}'${
data ? `,${data}` : '' // data
}${
children ? `,${children}` : '' // children
})`

HTML 标签会经过 _c的包裹, _c 表示

// a: tag, b: property, c: children, d: normalizationType
vm._c = function (a, b, c, d) {
return createElement(vm, a, b, c, d, false)
}

createElement 函数位于 src/core/vdom/create-element.js, 其会返回一个 vnode 结构虚拟 dom 节点。createElement结构如下

export function createElement (
context: Component,
tag: any,
data: any,
children: any,
normalizationType: any,
alwaysNormalize: boolean
): VNode | Array<VNode>

而对于其他类似于_l, _s则定义在src/core/instance/render-helpers/index.js

export function installRenderHelpers(target: any) {
target._o = markOnce
target._n = toNumber
target._s = toString
target._l = renderList
target._t = renderSlot
target._q = looseEqual
target._i = looseIndexOf
target._m = renderStatic
target._f = resolveFilter
target._k = checkKeyCodes
target._b = bindObjectProps
target._v = createTextVNode
target._e = createEmptyVNode
target._u = resolveScopedSlots
target._g = bindObjectListeners
}

假设现有一段模板如下所示

<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
<a href="<https://cli.vuejs.org>" target="_blank" rel="noopener"
>vue-cli documentation</a
>.
</p>
<div v-for="(num, i) in names" :key="i">{{ num }}</div>
<h3>asdasd</h3>
</div>
</template>

则其编译后的结果如下

var render = function () {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
'div',
{ staticClass: 'hello' },
[
_c('h1', [_vm._v(_vm._s(_vm.msg))]),
_vm._m(0),
_vm._l(_vm.names, function (num, i) {
return _c('div', { key: i }, [_vm._v(_vm._s(num))])
}),
_c('h3', [_vm._v('asdasd')])
],
2
)
}
var staticRenderFns = [
function () {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c('p', [
_c(
'a',
{
attrs: {
href: '<https://cli.vuejs.org>',
target: '_blank',
rel: 'noopener'
}
},
[_vm._v('vue-cli documentation')]
),
_vm._v('. ')
])
}
]

模板编译完成后,后续需要进行 dom 的渲染和更新