Vue源码剖析,针对Vue2.x的Vue框架源码,描述相应的核心实现并简单实现具体的流程
// AST抽象语法树:
const dom = {
tag: "div",
attr: {
id: "app",
title: "根容器"
},
children: [
{
tag: "p",
attr: {},
children: "这是p标签中的内容"
}
//...
]
}
在Vue中,我们经常会有一个 h 函数,用于转化得到相应的虚拟DOM:
/**
* type: 节点标签名
* attr: 节点属性
* children: 节点的子元素
*/
function h(type, attr, ...children){
const props = {}
let key;
if (attr) {
if (attr.key) {
key = attr.key; // 单独抽离出key,用于后面的diff算法比较
}
}
for (let propName in attr) { // 迭代attr中的所有的属性
if (hasOwnProperty.call(attr, propName) && propName != 'key') { // 判断原型上是否有该属性
props[propName] = attr[propName]
}
}
return vnode(type, key, props, children.map((child, index) => {
return typeof child === "string" || typeof child === "number" ? vnode(
undefined, undefined, undefined, undefined, child
) : child
}))
}
const VNODE_TYPE = "VNODE_TYPE"
// type: 节点类型
// key: 节点的key值
// props: 节点的属性
// children: 节点的子元素
// domElement是此虚拟dom节点对应的真实DOM节点,用于后面diff更新dom
export function vnode(type, key, props = {}, children = [], text, domElement) {
return {
_type: VNODE_TYPE,
type, key, props, children, text, domElement
}
}
(上面的h函数是自己写的,所以比Vue源码中的简单,但大致的思路是这样的)
// Vue源码中的html匹配正则(这里只那会用到的,来自Vue源码)
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 标签开始的正则,匹配的内容是标签名
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; //匹配属性
const startTagClose = /^\s*(\/?)>/; //匹配标签的结束: >
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; // 匹配 {{}}中的内容
关于正则表达式这里就不多说了,后面做了一点简单的说明帮助大家体会。现在我们就开始实现一个大致的parseHtml()方法,在实现过程中附再附上相应的代码解析:
// 第一步: 这里先创建几个全局变量:
// root表示当前编译html的根节点
let root = null;
// 标识当前抽离的语法树节点的父级
let currentParent;
// 定义一个数组作为栈,用于将之后创建的每一个节点依次入栈
let stack = [];
// 标识当前元素节点的类型为标签节点
const ELEMENT_TYPE = 1;
// 标识当前的元素节点类型为文本节点
const TEXT_TYPE = 3;
function parseHTML(html){
while(html){ // 开始循环解析html字符串
let textEnd = html.indexof("<")
if(textEnd === 0){
// 如果匹配到 “<” 则表示是一个标签: 开始 | 结束
let startTagMatch = parseStartTag(); // 通过这个方法匹配到tagName, attrs
if(startTagMatch){
start(startTagMatch.tagName, startTagMatch.attrs) // 根据匹配的节点信息生成当前的ast元素
continue; // 如果开始标签匹配完毕后开始下一次的匹配
}
// 执行到了这里标识当前的是一个结束标签,所以直接匹配后删除相应字符
let endTagMatch = html.match(endTag);
if(endTagMatch){
advance(endTagMatch[0].length);
// end方法的作用: 如果一个标签结束,就需要从stack中将这个标签出栈(这个标签就是最后一个元素),并找到这个标签元素的父节点(就是栈中的倒数第二个元素),将这个节点挂在到其父元素的children上
end(endTagMatch[1]);
continue;
}
}
// 当走到这一步,则表明当前匹配的应该是标签中的文本内容
// 原因:当匹配到标签后,开始标签会在parseStartTag()中匹配结束并删除
// 当匹配到结束标签也会在上一个if中被删掉
let text;
if(textEnd >= 0){
// 从html中截取出文本信息
text = html.substring(0, textEnd)
}
if(text){
// 匹配到文本信息不问0,则将html中的文本信息进行删除
advance(text.length);
// chars()作用:将文本信息生成文本节点,同时将该文本节点挂载到其父节点上
chars(text);
}
}
// 当模板字符串匹配完成后就删除匹配的内容,方便下次匹配
// 参数n: 删除的字符个数
function advance(n){
html = html.substring(n)
}
function parseStartTag(){
let start = html.match(startTagOpen); // 匹配开始标签
if(start){
const match = {
tagName:start[1],
attrs: []
}
advance(start[0].length) // 将开始标签进行删除
let end, attr;
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))){ // 匹配节点的属性,直到标签闭合
// 前进属性字符的长度个字符(就是将html中此处匹配的属性的字符串长度个字符删除)
advance(attr[0].length);
// 将标签属性进行抽离
match.attrs.push({
name: attr[1], // 属性名
value:attr[3] || attr[4] || attr[5] // 属性值:取多个的原因是标签和属性之间可能存在空格
})
}
if(end){ //循环结束, 删除标签 >
advance(end[0].length)
return match;
}
}
}
}
// 这里开始定义上面使用到的辅助方法:
/**
* 创建一个AST元素节点:
* tagName: 元素名
* attrs: 元素属性列表
*/
function createASTElement(tagName, attrs){
return {
tag: tagName, // 标签名
type: ELEMENT_TYPE, // 节点类型
children: [], // 子节点
attrs, // 属性列表
parent:null // 一个指向当前父元素的指针
}
}
/**
* 匹配到开始标签及其属性后,就创建其对应的AST元素
* tagName: 标签名
* atters: 属性列表
*/
function start(tagName, attrs){
let element = createASTElement(tagName, attrs);
if(!root){ // 如果是第一个元素,则作为根元素放到root中
root = element;
}
currentParent = element; // 将当前元素标记成父元素
stack.push(element); // 将元素入栈用于后面处理
}
/**
* 生成一个文本节点:
* text: 文本节点的内容
*/
function chars(text){
// 去除掉文本中的苏哦有的空白字符
text = text.replace(/\s/g, '')
if(text){
// 将该文本节点挂在当前标记的父元素下
// 这里说明一下: 每次匹配到开始标签后会将这次匹配的标签作为当前的父元素
// 当匹配到结束时又立马将当前的节点出栈挂到其父节点上,同时会更新父元素为当前元素的父节点
// 到下一次匹配开始创建AST元素的时候,父节点又会跟着变更,所以currentParent始终指向的是当前元素的父节点
currentParent.children.push({
text,
type: TEXT_TYPE
})
}
}
/**
* 匹配到标签结束,将栈中最后一个元素弹出,放到其父节点上
* 说明: 每次标签匹配结束都会弹出这个标签挂到父元素上,所以无论当前有多少个兄弟元素,栈中倒数第二个元素都是其父元素
*/
function end(){
let element = stack.pop();
// 更新当前的父元素
currentParent = stack[stack.length - 1];
if(currentParent){
element.parent = currentParent; // 将当前元素的父元素指针指向其父元素
currentParent.children.push(element) // 往当前的父元素的children中push该元素
}
}
以上便是抽离AST语法树的全部逻辑了,当然这里是自己实现的,所以和Vue源码内容存在出入,但是基本逻辑思路相似,只是做了简化。
最后将解析后的效果附上(当然其余基本的语法这里就不说了,大家可以随便写一个模板字符串传入方法进行测试):
这里效果对应的html如下:
<div id="app" style="width: 100px">
<p>hahahah{{name}}</p>
<span>{{age}}</span>
</div>
关于Vue中抽离AST语法树就说到这里了,大家 加油!!!