写在前面
由于虚拟VNode节点部分某些关键地方涉及到了JavaScript正则表达式,在此再度简单记录正则表达式的JavaScript版本。
JavaScript的正则表达式
元字符
与Python中的元字符类似,JavaScript也有元字符,它们是正则表达式中最基础的部分,理解、掌握了它们,就相当于掌握了正则表达式的一半。
重要匹配方法
exec()
在设置了 global 或 sticky 标志位的情况下(如 /foo/g or /foo/y),JavaScript RegExp 对象是有状态的。他们会将上次成功匹配后的位置记录在 lastIndex 属性中。使用此特性,exec() 可用来对单个字符串中的多次匹配结果进行逐条的遍历(包括捕获到的匹配)。
正则表达式的lastIndex属性:记录了匹配成功的第一个元素下标。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="../vue/vue.js"></script>
</head>
<body>
<div id="main">
<p>
{{ title }}
</p>
<span>{{ author }}</span>
</div>
</body>
<script>
let vm = new Vue({
el: '#main',
data() {
return {
title: '三国演义',
author: '罗贯中'
}
},
})
const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g
let text = `<div id="main"><p>{{ title }}</p><span>{{ author }}</span></div>`
while (match = defaultTagRE.exec(text)) {
console.log(defaultTagRE.lastIndex);
console.log(match); // 匹配结果,是一个特殊的数组
console.log(match[0]);
}
</script>
</html>
- 使用while循环语句以及exec(),可以匹配出全部满足条件的字符串;
- defaultTagRE是正则表达式,它被设置为全局,具有状态,可以利用它的lastIndex属性,获取上一次匹配成功时的起始字符下标,比如这里第一个匹配成功的是“Vue源码阅读之虚拟VNode节点”,从原字符串text的第19个字符开始,故第一轮循环的打印结果就是19。
上述代码的控制台输出结果:
29
(2) ['{{ title }}', ' title ', index: 18, input: '<div id="main"><p>{{ title }}</p><span>{{ author }}</span></div>', groups: undefined]
{{ title }}
51
(2) ['{{ author }}', ' author ', index: 39, input: '<div id="main"><p>{{ title }}</p><span>{{ author }}</span></div>', groups: undefined]
{{ author }}
从HTML到真实DOM的大体流程
AST语法树
AST树,即abstract syntax tree树,意为抽象语法树。经过generage过程,得到render函数,将返回Vue的虚拟DOM节点(包含标签名、子节点、文本信息等)。
比如下面这一句html语言,定义了一个input标签,是一个html表单元素:<input class="warn" value="default text" :style="innerStyle">
解析后的AST节点:
inputAstElm = {
type: 1,
tag: 'input',
attrs: [
{ name: "class", value: "\"warn\"" },
{ name: "style", value: "innerStyle" }
],
props: [ { name: "value", value: "\"default text\"" } ],
}
从手写函数开始建立节点
我们希望通过调用这样的函数就可以创建一棵虚拟节点树(VNode)。
var ul =
c('ul', [
c('li', [ t("Item 1") ]),
c('li', [ t("Item 2") ]),
c('li', [ t("Item 3") ])
])
这其中需要用到JavaScript的正则表达式提取相关内容。细节参见源码注释。
在建立虚拟节点VNode之后,我们就可以通过VNode来间接管理DOM了——要做到这一点,我们还需要在两者之间建立关联:
将当前建立的VNode渲染到DOM树上;
当VNode有更新时,DOM树也要相应地作出变化。
这其中涉及到了Vue中的diff算法。源码中由patch()函数总体控制对新旧VNode的更改:
patch(oldVnode, newVnode)
。而要实现新旧VNode节点的更新,首先要做的是判断两者标签是否一致:如果两者连标签都不一样了,那么就需要相对较为复杂的操作——
- 找到oldVnode对应的DOM节点以及父节点;
- 将当前newVnode渲染到原来的父节点下;
- 将旧的DOM节点移除。
如果两者标签一致,那么就需要比较它们的子节点(children)了。为此,我们设置四个指针oldStartVnode、oldEndVnode、newStartVnode、oldEndVnode,用于指向oldVnode和newVnode的子节点开始、结束的位置。
然后就是四种情况了。
Compile编译字符串
从字符串到HTMLElement Token流
但是相对于上面的手动调用函数,我们更希望一个函数能够解析html字符串,就比如这里的compile:
var html =
`<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>`
var ul = compile(html)
我们以Token流的形式记录每一个html标签转换后的内容,它是一个列表:
interface Attr {
name: string,
value: any,
}
interface StartToken {
tagName: string,
attrs: Attr[],
}
// Token流形状
interface Tokens {
startToken: StartToken,
charsToken: string,
endToken: any,
}
// 也即:
tokens: = [
StartToken: {
tagName: '',
attrs: [
{
name: '',
value: '',
},
...
]
},
StartToken: {},
...,
CharsToken: '',
...,
endToken,
...
]
StartToken表示开始标签
EndToken表示结束标签
CharsToken表示文本节点
其中的解析(parse)过程就是一个类似括号匹配问题的栈的实际应用:
设置一个栈,用于管理字符串,当标签匹配的时候就把栈顶元素弹出,否则入栈;同时将解析出来的标签转化为Token计入Tokens流。
https://github.com/raphealguo/how-to-learn-vue2-blob/raw/master/figure/1.2/str2tokens.png
从HTMLElement Token流到AST树
拿到tokens序列后,我们可以构建AST树节点。AST树节点形状大致如下:
tagAstElement = {
type: number,
tag: string,
attrsList,
attrsMap,
children,
parent,
}
我们构建一棵AST树,也需要一个栈stack,记录所有的父节点,同时定义一个指向栈顶的指针currentParent:
let stack = []
let currentParent
在源码中,处理Tokens流,得到AST树的函数parse()中,在其中的parseHTML()参数中定义了三个函数:
- start,处理开始标签流
- end,处理结束标签流
- chars,处理文本标签流
在处理开始标签流时,需要将上面的Tokens中的数据项往AST树的数据项转化。另外,由于AST树包含了节点之间的父子关系,因此在处理开始标签流时,需要判断它是否是单标签,如果不是单标签,就表示该标签之间还可以嵌套其他的标签,该标签也可以作为父节点;因此,该标签需要入栈stack,并且currentParent指针指向栈顶元素——该标签:
if (!unary) {
// 如果不是单标签,则当前节点记为父节点,并压入堆栈
currentParent = element
stack.push(element)
}
在处理结束标签流时,同样的道理,需要弹栈:
// 标签闭合,栈顶元素弹出,长度减一,当前父节点指针currentParent指向最新的栈顶元素
stack.length -= 1
currentParent = stack[stack.length - 1]
从AST树到VNode树
将已经生成的AST节点转化为VNode的方法如下:
_c (tag, data, children) 创建一个非文本 VNode 节点
_v (text) 创建一个文本VNode节点
_s (exp) 把 exp 输出成字符串
_e() 创建一个空的 VNode
_l (list, render) 渲染一个 VNode 列表
_k(eventKeyCode, key, builtInAlias) 判定当前事件的按键是否为预期按键值,builtInAlias == eventKeyCode
VNode节点形状如下:
VNode = {
tag: 'xxx', // 标签名
data: {
attrs: {...}, // 标签的属性
domProps: { "value": "default text" }
}
}
在实际的源码中,实现了逐步封装(见/compiler/codegen/index.js文件)
generate: (ast: any) => obj
,这个函数是最外层的函数,接收传入的ast树,并将其挂载到当前的vm实例对象,用于生成一棵VNode树。export function generate(ast) { const code = ast ? genElement(ast) : '_c("div")' return { render: ("with(this){return " + code + "}") } }
genElement: (el: any) => string
,接下来把目光转移到第二层函数,它用于生成具体执行的代码语句(字符串格式)。function genElement(el) { if (el.for && !el.forProcessed) { // 为了v-for和v-if的优先级: <ul v-for="(item, index) in list" v-if="index==0">,需要先处理for语句 return genFor(el) } if (el.if && !el.ifProcessed) { return genIf(el) } else { let code const children = genChildren(el) || '[]' const data = genData(el) code = `_c('${el.tag}'${ children ? `,${children}` : '' // children })` return code } }
不难发现,这一层函数其实也没做多少事,关键还得看genFor、genIf、genChildren、genData这几个打工人(它们生成了模板字符串中的模板变量,也就是el上缺省的属性值el.tag, el.children, el.data)。
genData: (el: any) => string
, 我们先来看看分量最重的data子选项。genData用于得到data,而data包含了el的很多属性,如el.key, el.attrs, el.props, el.event等,其中,el.attrs、el.props、el.events相对较复杂,因此又套了一层函数。function genData(el) { let data = '{' // key if (el.key) { data += `key:${el.key},` } if (el.attrs) { data += `attrs:{${genProps(el.attrs)}},` } // DOM props if (el.props) { data += `domProps:{${genProps(el.props)}},` } // event handlers if (el.events) { data += `${genHandlers(el.events)},` } // class if (el.staticClass) { data += `staticClass:${el.staticClass},` } if (el.classBinding) { data += `class:${el.classBinding},` } data = data.replace(/,$/, '') + '}' return data }
genProps: (props: []) => string
, 这个函数用于将props配置项数组的每一项转为字符串键值对,然后全部拼接起来,并返回。function genProps(props) { let res = '' for (let i = 0; i < props.length; i++) { const prop = props[i] res += `"${prop.name}":${prop.value},` } return res.slice(0, -1) // 去掉尾巴的逗号 }
_c()方法可以生成一个VNode,从而实现AST到VNode的过程。
从VNode树到DOM树
经历了patch(vnode)函数,此时在真实的DOM上处理UI渲染、事件监听等。
用一个栗子做个总结
原始字符串如下:
<input class="warn" value="default text" :style="innerStyle">
对原始字符串进行解析,得到Token流
将Tokens流转为AST:
inputAstElm = { type: 1, // 表示类型 tag: 'input', // 表示html元素标签 attrs: [ // 表示属性 {name: 'class', value: "'warn'"}, {name: 'style', value: "innerStyle"} ], props: [{ name: "value", value: "'default text'" }], // 表示属性 }
经过上面提过的render函数得到VNode:
_c("input", { attrs: { "class": "warn", "style": innerStyle }, domProps: { "value": "default text" } }, [])
VNode = { tag: 'input', data: { attrs: { "class": "warn", "style": "vm.innerStyle运行后的值" }, domProps: { "value": "default text" }, } }
最后一步,将虚拟VNode渲染到真实DOM
inputDom.setAttribute('class', 'warn') inputDom.setAttribute('style', 'vm.innerStyle运行后的值')