Vue源码剖析(一):抽离AST抽象语法树

Vue源码剖析,针对Vue2.x的Vue框架源码,描述相应的核心实现并简单实现具体的流程

今天说的是如何将编写的模板语法,抽离成框架处理的AST语法树
  • Vue是一个面向视图层的渐进式前端框架,它推荐用户使用不同的状态控制页面元素,而减少用户对DOM元素的直接操作。在Vue中,比较核心的两块内容:虚拟DOM, DIFF算法。
  • 关于虚拟DOM:虚拟DOM其实直白一点的说法就是使用js对象来描述我们的真实DOM元素及元素间的关系。而关于AST抽象语法树则是使用JS对象来描述原生的语法,二者稍有区别:
// 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源码中的简单,但大致的思路是这样的)

  • 上面都是开胃菜,现在我们开始说如何将一个模板节点转化为AST抽象语法树:
    先提一下Vue中转化的思路: 使用一个parseHtml()的函数,将传递进来的html字符串不停的解析,通过正则表达式提取html字符串中响应的内容,提取后删除提取的字符串,直到匹配结束。在匹配到响应的字符串后进型相应的转化。
// 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抽象语法树_第1张图片

关于Vue中抽离AST语法树就说到这里了,大家 加油!!!

你可能感兴趣的:(Vue源码剖析(一):抽离AST抽象语法树)