基本概念介绍:
在vue框架中,我们写的html模板会被编译成渲染函数,渲染函数会生成vnode,最终以vnode渲染视图。
渲染流程如下:
本章内容讲模板编译的过程,vue是如何讲html模板转化成render函数的呢?
模板编译可分为三个步骤:
1、将html模板转换化成AST(AST即抽象语法树,是一个用来表示html的js对象)
2、将AST中的静态节点打上标签
3、用AST生成代码字符串
这三个步骤分别对应三个模块:解析器、优化器、代码生成器
解析器:
解析器的作用就是将html模板转化为AST。
例如:
<div>
<p>{{name}}</p>
</div>
转换为AST后:
{
tag: "div", // 标签
type: 1, // 1 元素节点,2 带变量的文本,3 静态文本
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: "text",
static: false,
expression: "_s(name)"
}]
}]
}
解析器源码:
const stack = [] // 存放节点的栈
let currentParent // 当前正在处理的节点的父节点
parseHTML(template, {
start(tag, attrs, unary) {
// tag 标签,attrs 属性,unary 是否为自闭合标签
// 解析到开始标签时调用
element = {
type: 1,
tag,
attrs,
parent: currentParent,
children: []
}
if (!unary) {
// 不是自闭合标签,当前节点压入栈内,接下来处理的其他节点都是该节点的子节点
currentParent = element;
stack.push(element);
} else {
// 自闭合标签
closeElement(element);
}
}
end() {
// 解析到结束标签时调用
// 当前父节点标签解析完成,弹出栈最顶层节点,当前父节点设为弹出后的最顶层节点。
element = stack[stack.length - 1];
stack.length -= 1;
currentParent = stack[stack.length - 1];
closeElement(element);
},
chars(text) {
// 解析到文本时调用
// parseText用于处理文本,若文本中有变量则有返回值
if (res = parseText(text, delimiters)) {
element = {
type: 2,
expression: res.expression,
tokens: res.tokens,
text
}
} else {
element = {type: 3, text};
}
// 设置为当前父节点的子节点
currentParent.children.push(element);
},
comment(text) {
// 解析到注释时调用
element = {type: 3, text, isComment: true};
// 设置为当前父节点的子节点
currentParent.children.push(element);
}
})
function closeElement (element) {
// 绑定当前父节点与当前正在处理节点的父子关系
...
currentParent.children.push(element);
element.parent = currentParent;
...
}
解析器内部原理是一小段一小段地截取模板字符串,每截取一小段字符串就会根据截取的出的字符串,利用正则匹配判断出它的类型触发相应的钩子函数生成相应的AST节点,直到模板字符串截空为止。
伪代码如下:
function parseHTML(html, options) {
while(html) {
// 截取html并触发钩子函数
}
}
另外,解析器用栈来维护节点间的层级关系,所有被处理的节点依次压入栈中,处理完的节点弹出栈。按照这样的逻辑,栈的最顶层节点就是当前正在处理的节点的父节点。
例如,现在有一个模板字符串:
<div>
<p>{{name}}</p>
</div>
解析步骤如下:
一、截取<div>,识别为开始标签,触发start方法,将div标签压入栈内,currentParent 设为div。
二、截取<p>,识别为开始标签,触发start方法,将div标签压入栈内,currentParent 设为p。
三、截取{{name}},识别为文本,触发chars方法,此时currentParent为p,生成的文本节点设为p标签的子节点。
四、截取</p>,识别为结束标签,将p从栈中弹出,currentParent设为弹出后最顶层的节点,即div,调用closeElement 绑定p与div的父子关系。
五、截取</div>,识别为结束标签, 将div从栈中弹出。
六、模板为空,解析完毕。
优化器:
优化器的作用是在AST中找出静态根节点并打上标记,即staticRoot属性设为true,这样做有两个好处:
1、每次重新渲染时,不需要渲染静态节点树,直接复用原来的静态节点树。
2、在虚拟DOM进行diff的过程可以直接跳过。(对于diff算法不了解的同学可以看一下 解析vue2.x源码之diff算法)
静态节点概念:
// 静态节点
<p>my name is gavin</p>
// 动态节点
<p>my name is {{name}}</p>
静态节点不依赖于变量,不管变量如何变化,都不会影响它的渲染。
优化器源码:
优化器的内部实现主要分两个步骤:
一、找出AST所有静态节点并打上标记,static属性设为true
function markStatic (node: ASTNode) {
// 调用isStatic判断当前节点是否为静态节点
node.static = isStatic(node)
if (node.type === 1) { // 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
}
}
}
}
function isStatic (node: ASTNode): boolean {
if (node.type === 2) { // type为2是动态文本,返回false
return false
}
if (node.type === 3) { // type为3是静态文本,返回true
return true
}
return !!(node.pre || (
!node.hasBindings && // 没有动态绑定
!node.if && !node.for && // 没有v-if、v-else、v-for
!isBuiltInTag(node.tag) && // 不是内置标签,slot或component
isPlatformReservedTag(node.tag) && // 不是组件
!isDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isStaticKey) // 所有的key都在静态节点所有属性范围内
))
}
二、找出AST中所有静态根节点,staticRoot属性设为true
静态根节点就是一个静态子树的根节点,所以我们需要从根节点一层层往下找,如果一个节点被判定为静态根节点,就不需要向他的子级继续寻找了,因为静态树的最顶层就是静态根节点。
但有两种特殊情况,即使节点被判定为静态根节点,也不会将他标记为静态根节点
1、静态根节点只有一个文本节点
2、静态根节点没有子节点
因为这两种情况优化的成本大于收益。
function markStaticRoots (node: ASTNode) {
// 元素节点才有可能是静态根节点,所以只处理type为1的情况
if (node.type === 1) {
// 当前节点必须是静态节点而且有子节点且唯一的子节点不是静态文本节点
if (node.static && node.children.length && !(
node.children.length === 1 &&
node.children[0].type === 3
)) {
// 当前节点判定为静态根节点,直接return,不需再往下找
node.staticRoot = true
return
} else {
node.staticRoot = false
}
// 当前节点不是静态根节点,继续遍历子节点,寻找静态根节点
if (node.children) {
for (let i = 0, l = node.children.length; i < l; i++) {
markStaticRoots(node.children[i])
}
}
}
}
总的来说就是通过两次循环,第一次找出所有静态节点,第二次根据静态节点标记找出所有静态根节点,有了静态根节点就可以避免重复渲染,优化渲染性能。
代码生成器:
代码生成器的作用是递归AST生成可执行的代码字符串,当代码字符串拼接好后,会放在with中返回给调用者。
例如:
// 模板
<div id="el">Hello {{name}}</div>
// 转为AST
{
type: 1,
div: 'div',
attrsList: [{
name: 'id',
value: ''el
}],
attrs: [{
name: 'id',
value: ''el
}],
attrsMap: {
id: 'el'
},
plain: false,
static: false,
staticRoot: false,
children: [
type: 2,
expression: '"hello "+ _s(name)',
text: 'Hello {{name}}',
static: false
]
}
// 生成代码字符串
// _c对应元素节点、_v对应文本节点、_s对应动态文本
with (this) {
return _c(
"div",
{
attrs: {id: 'el'}
},
[
_v("Hello "+_s(name))
]
)
}
代码字符串中_c其实就是render函数,三个参数分别为:标签名、属性对象、子节点数组。
代码字符串根据AST节点的类型调用对应的方法生成vnode。
类型 | 创建方法 | 别名 |
元素节点 | createElement | _c |
文本节点 | createTextVNode | _v |
注释节点 | createEmptyVNode | _e |
代码生成器源码:
function generate (
ASTElement
){
// 有AST则调用genElement生成代码字符串
const code = ast ? genElement(ast, state) : '_c("div")'
return {
// 代码字符串包在with关键字中返回
render: `with(this){return ${code}}`
}
}
function genElement(el) {
// 如果plain为true,说明节点没有属性
const data = el.plain ? undefined : genData(el)
const children = genChildren(el)
code = `_c(
'${el.tag}'
${data? `,${data}`: ''}
${children? `,${children}`: ''
})`
return code
}
function genData(el) {
let data = '{'
if(el.key) {
data += 'key:${el.key}'
}
if(el.ref) {
data += 'ref:${el.ref}'
}
if(el.pre) {
data += 'pre:${el.pre}'
}
// 还有很多种属性的处理
...
data = data.replace(/,$/, '') + '}'
return data
}
function genChildren (el) {
const children = el.children
// 返回子节点的代码字符串数组
if(children.length) {
return `[${children.map(c => genNode(c)),join(',')}]`
}
}
function genNode(node) {
if(node.type === 1) { // 元素节点
return genElement(node)
}
if(node.type === 3 && node.isComment) { // 注释节点
return `_e(JSON.stringify(node.text))`
} else { // 文本节点
return `_v(${node.type === 2}? node.expression: JSON.stringify(node.text))`
}
}