模板编译的主要目的就是生成渲染函数.这个过程包含三个部分:
- 将模板字符串解析为AST(Abstract Syntax Tree 抽象语法树)
- 遍历AST标记所有的静态节点(不需要重新渲染的节点)
- 使用AST生成渲染函数
以上三步分别对应模板编译中的三个模块:1.解析器(Html解析器 文本解析器 过滤器解析器) 2.优化器 3.代码生成器.
让我们从vue.$mount函数开始,分别介绍以上三个模块的具体实现原理:
// 省略了部分代码,只保留了关键部分
const { compile, compileToFunctions } = createCompiler(baseOptions)
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)
}
可以看到 vue在挂载实例时会调用compileToFunction方法,并将模板字符串编译成渲染函数Render.而compileToFunction的源码如下:
export function createCompiler(baseOptions) {
const baseCompile = (template, options) => {
// 解析 html,转化为 ast
const ast = parse(template.trim(), options)
// 优化 ast,标记静态节点
optimize(ast, options)
// 将 ast 转化为可执行代码
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
const compile = (template, options) => {
const tips = []
const errors = []
// 收集编译过程中的错误信息
options.warn = (msg, tip) => {
(tip ? tips : errors).push(msg)
}
// 编译
const compiled = baseCompile(template, options)
compiled.errors = errors
compiled.tips = tips
return compiled
}
const createCompileToFunctionFn = () => {
// 编译缓存
const cache = Object.create(null)
return (template, options, vm) => {
// 已编译模板直接走缓存
if (cache[template]) {
return cache[template]
}
const compiled = compile(template, options)
return (cache[key] = compiled)
}
}
return {
compile,
compileToFunctions: createCompileToFunctionFn(compile)
}
}
其中主要逻辑写在baseCompile中 ,而其中所调用的parse函数对应将模板字符串解析为AST的解析过程,optimize对应标记静态节点的优化过程,generate对应生成渲染函数过程.即解析器,优化器,代码生成器三部分.接下来分别探讨这三个部分.
1.解析器
解析器的主要作用是将模板字符串构建成AST,如:
<div>
<p>{{name}}</p>
</div>
{
tag: "div"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: undefined,
attrsList: [],
attrsMap: {},
children: [
{
tag: "p"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: {tag: "div", ...},
attrsList: [],
attrsMap: {},
children: [{
type: 2,
text: "{{name}}",
static: false,
expression: "_s(name)"
}]
}
]
}
事实上,解析器又分为好多子解析器,如HTML解析器,文本解析器,过滤器解析器等,其中最主要的就是HTML解析器.顾名思义,其作用就是解析HTML,在其过程中会触发不同的钩子函数(标签开始钩子 标签结束钩子 文本钩子 注释钩子).
import { parseHTML } from './html-parser'
export function parse(template, options) {
let root
parseHTML(template, {
// some options...
start(tag,attrs,unary) {}, // 解析到标签位置开始的回调
end() {}, // 解析到标签位置结束的回调
chars(text) {}, // 解析到文本时的回调
comment(text) {} // 解析到注释时的回调
})
return root
}
可以看到,start钩子有三个属性,分别对应标签名,标签属性,以及是否是自闭和标签.而文本钩子和注释钩子都只传入文本即可 .
如何构建AST中节点的层级关系呢?其实非常简单,我们只需要维护一个栈即可.每当触发start函数时,就把当前节点推入栈中,每当触发end函数时,就从栈中弹出一个节点.如此,栈中的最后一个节点就是当前正在构建的节点的父节点.
而parse函数中调用的parseHtml函数是一个循环的过程,每轮循环都解析模板字符串中一小段字符串并调用相应的钩子函数,然后从模板字符串中删除这个串,直到模板串为空.
因为每次都是从字符串的开头截取字符串,那么可以获得 '<' 符号的位置,并根据该符号位置进行分类处理.
export function parseHTML(html, options) {
let index = 0
let last,lastTag
const stack = []//用于管理层级关系
while(html) {
last = html
let textEnd = html.indexOf('<')
/* "<" 字符在当前 html 字符串开始位置,说明是一个标签 分以下几种情况
* 注释<!-- -->
* 条件注释
* DOCTYPE
* 开始标签
* 结束标签
*/
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) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// 5、匹配到开始标签: <div>
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
continue
}
}
// "<" 字符在当前 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)
}
}
这里着重分析一下处理标签开始和标签结束时候的逻辑(parseStartTag handleStartTag parseEndTag):
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const startTagOpen = new RegExp(`^<${ncname}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${ncname}[^>]*>`)
// 判断是否标签开始位置,如果是,则提取标签名以及相关属性
function parseStartTag () {
// 提取 <xxx
const start = html.match(startTagOpen)
if (start) {
const [fullStr, tag] = start
const match = {
attrs: [],
start: index,
tagName: tag,
}
advance(fullStr.length)
let end, attr
// 递归提取属性,直到出现 ">" 或 "/>" 字符
while (
!(end = html.match(startTagClose)) &&
(attr = html.match(attribute))
) {
advance(attr[0].length)
match.attrs.push(attr)
}
if (end) {
// 如果是 "/>" 表示单标签
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
}
}
// 处理开始标签
function handleStartTag (match) {
const tagName = match.tagName
const unary = match.unarySlash
const len = match.attrs.length
const attrs = new Array(len)
for (let i = 0; i < l; i++) {
const args = match.attrs[i]
// 这里的 3、4、5 分别对应三种不同复制属性的方式
// 3: attr="xxx" 双引号
// 4: attr='xxx' 单引号
// 5: attr=xxx 省略引号
const value = args[3] || args[4] || args[5] || ''
attrs[i] = {
name: args[1],
value
}
}
if (!unary) {
// 非单标签,入栈
stack.push({
tag: tagName,
lowerCasedTag:
tagName.toLowerCase(),
attrs: attrs
})
lastTag = tagName
}
if (options.start) {
// 开始标签的回调
options.start(tagName, attrs, unary, match.start, match.end)
}
}
// 处理闭合标签
function parseEndTag (tagName, start, end) {
let pos, lowerCasedTagName
if (start == null) start = index
if (end == null) end = index
if (tagName) {
lowerCasedTagName = tagName.toLowerCase()
}
// 在栈内查找相同类型的未闭合标签
if (tagName) {
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
pos = 0
}
if (pos >= 0) {
// 关闭该标签内的未闭合标签,更新堆栈
for (let i = stack.length - 1; i >= pos; i--) {
if (options.end) {
// end 回调
options.end(stack[i].tag, start, end)
}
}
// 堆栈中删除已关闭标签
stack.length = pos
lastTag = pos && stack[pos - 1].tag
}
}
这样我们就理清了调用解析器解析模板字符串时候的过程,每次解析后让不同类型的内容调用不同类型的钩子函数.接下来这些钩子函数又要进行怎样的操作呢?
start(tag,attrs,unary):创建一个新的元素入栈,并建立当前节点和其父节点的父子关系
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)
}
}
})
function createASTElement(tag, attrs, parent) {
const attrsList = attrs
const attrsMap = makeAttrsMap(attrsList)
return {
type: 1, // 节点类型
tag, // 节点名称
attrsMap, // 节点属性映射
attrsList, // 节点属性数组
parent, // 父节点
children: [], // 子节点
}
}
end() :将弹出栈顶标签
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]
}
chars(text):对带表达式的文本以及静态文本与其父节点关系构建,文本节点不入栈.
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
})
}
}
}
2.优化器
通过解析器的处理,我们就得到了 Vue 模板的 AST。由于 Vue 是响应式设计,所以拿到AST 之后还需要进行一系列优化,确保静态的数据不会进入虚拟 DOM 的更新阶段,以此来优化性能.
简单来讲,就是把静态节点的static属性设置为true,然后找到再遍历一次找到静态根节点.
export function optimize (root, options) {
if (!root) return
// 标记静态节点
markStatic(root)
}
function isStatic (node) {
if (node.type === 2) { // 表达式,返回 false
return false
}
if (node.type === 3) { // 静态文本,返回 true
return true
}
// 此处省略了部分条件
return !!(
!node.hasBindings && // 没有动态绑定
!node.if && !node.for && // 没有 v-if/v-for
!isBuiltInTag(node.tag) && // 不是内置组件 slot/component
!isDirectChildOfTemplateFor(node) && // 不在 template for 循环内
Object.keys(node).every(isStaticKey) // 非静态节点
)
}
function markStatic (node) {
node.static = isStatic(node)
if (node.type === 1) {
// 如果是元素节点,需要遍历所有子节点
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i]
markStatic(child)
if (!child.static) {
// 如果有一个子节点不是静态节点,则该节点也必须是动态的
node.static = false
}
}
}
}
!注意: 静态节点的所有子节点都是静态节点,动态节点的父节点是动态节点.这个特性保证,我们找到的第一个静态节点会被标记为静态根节点,此时不用再遍历其子节点,因为他的子节点必然是静态节点.
3.代码生成器
至此,我们已经得到了优化后的AST,我们只需要做一些简单的字符串拼接就能生成Render函数
export function generate(ast, options) {
const state = new CodegenState(options)
const code = ast ? genElement(ast, state) : '_c("div")'
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns
}
}
export function genElement (el, state) {
let code
const data = genData(el, state)
const children = genChildren(el, state, true)
code = `_c('${el.tag}'${
data ? `,${data}` : '' // data
}${
children ? `,${children}` : '' // children
})`
return code
}
生成的渲染函数效果如图:
<div>
<h2 v-if="message">{{message}}</h2>
<button @click="showName">showName</button>
</div>
with (this) {
return _c(
'div',
[
(message) ? _c('h2', [_v(_s(message))]) : _e(),
_v(' '),
_c('button', { on: { click: showName } }, [_v('showName')])
])
;
}
注意:
vm._c 是创建DOM标签的
vm._v 是创建文本节点的
vm_s 就是 toString
总结:
1.解析器的主要功能就是将模板字符串转换为AST.解析器parse调用parseHtml函数用于循环解析模板字符串,并把不同类型的字符串调用相应的钩子函数(作用是维护层次栈以及建立AST),最终返回AST根节点.
2.优化器用于标记所有静态节点以及静态根节点
3.代码生成器将优化获得AST转换为渲染函数.
本文章仅为本人学习总结,如果有不足还请各位指出!