Vue2源码笔记(1)编译时-模板代码如何生效之生成AST树

前言

虽然现在已经是Vue3的版本,也已经用了相当一段时间的Vue3。但想起来Vue2的源码之前断断续续也看了蛮长的时间,就再回头整理一遍。有始有终。

我这里把接下来要写的内容分为编译时运行时

首先登场的是编译时。

在平常的开发中,我们经常写.vue文件:

xml 复制代码
<template>
    <div class="container" style="color:red; font-size:14px">
        <div></div>
        hello {{ name }}
    </div>
</template>

这样的代码看起来和HTML很像,但浏览器是绝对不会认识它的,它们这么近那么远,隔了一座山。而用来打通这座山的就是Vue2的编译时代码:

我把这个过程简化为:parse() -> generate()->生成渲染函数代码,这些生成的js代码,便在运行时将我们在IDE中写在.vue文件中的代码画在了页面上。

注:以下代码与Vue2源码并非完全一致,但力求能在实现主要功能的基础上更便于理解。

parse()与AST抽象语法树

在编译时,模板代码被parse()解析后会生成抽象语法树ast(如果想抢先知道模板被处理成什么样的话,拖到最后有一个以上对应的模拟AST树)。

php 复制代码
/**
 * 模板代码解析函数
 * @param {String} html 
 * @returns ast抽象语法树
 */
function parse(html) {
    
}
​
// 一个ast node,其实就是一个带有特定属性的js对象
astEg = {
    tag: tagName,
    type: ELEMENT_TYPE,
    children: [],
    attrs,
    parent: null,
}

我们把模板文件<template>中书写的内容看作普通的文本内容,放到js中进行处理。HTML堪称是一门非常友好的"语言",特有的尖括号、标签、属性等很容易被人理解,那么同样的,这些特征,也使得我们能很快想到如何建立一个识别它的模型,分门别类地捕获它们,把它们填入一个js对象中,即得到了ast-node

内容识别

Vue2的parse()函数使用了相当多的正则来识别这些内容:

javascript 复制代码
function parse(html) {
    const ncname = `[a-zA-Z_][\-\.0-9_a-zA-Z]*`; //匹配标签名 形如 abc-123
    const qnameCapture = `((?:${ncname}\:)?${ncname})`; //匹配特殊标签 形如 abc:234 前面的abc:可有可无
    const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开始 形如 <abc-123 捕获里面的标签名
    const startTagClose = /^\s*(/?)>/; // 匹配标签结束  >
    const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`); // 匹配标签结尾 如 </abc-123> 捕获里面的标签名
​
    const attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性
}

而对于ast node也进行了约定:

javascript 复制代码
function parse(html) {
    // ...
    const ELEMENT_TYPE = 1; // 元素节点
    const TEXT_TYPE = 3; // 文本节点
    function createASTElement(tagName, attrs) {
        return {
            tag: tagName,
            type: ELEMENT_TYPE,
            children: [],
            attrs,
            parent: null
        }
    }
    function createASTText(text) {
        return {
            type: TEXT_TYPE,
            text,
        }
    }
}

代码逻辑

而对于文本的解析则通过循环逐步读取、解析。

开始标签

对于开始标签的解析:

ini 复制代码
function parse(html) {
    // ...
    while (html) {
        let textEnd = html.indexOf("<"); // 找到最近的 < 字符位置
        if (textEnd === 0) {
            const startTagMatch = parseStartTag(); // 捕获开始标签及attr,返回值形如{tagName: 'xxx', attr: [{name: '', value: ''}] }, 没结果的话返回undefined
        }
    }
    
    function parseStartTag() {
        const start = html.match(startTagOpen);
        if (start) {
            const match = {
                tagName: start[1], // 使用正则捕获到最近的标签的内容,即<tagName中的tagName
                attrs: [],
            }
        }
        // advance是一个用来将解析html内容的位置往前推的函数,类似于将内容标记为已读。我们稍后实现它
        advance(start[0].length); // start[0]则是<tagName
        
        let end, attr;
        while (
            !(end = html.match(startTagClose)) // 这里再确定当前标签未闭合,>
            &&
            (attr = html.match(attribute)) // 在标签未闭合的情况下,捕获最近的属性值
        ) {
            advance(attr[0].length); // 标记当前内容已读
            attr = {
                name: attr[1], // 属性名
                value: attr[3] || attr[4] || attr[5] // 这里是因为正则捕获支持双引号、单引号、无引号 标记的属性值
            }
            match.attrs.push(attr);
        }
        // 上述过程就持续至到达>时, 并赋值给end, end = html.match(startTagClose)
        if (end) {
            advance(1);
            return match;
        }
    }
}

对于开始标签的处理:

ini 复制代码
function parse(html) {
    let root, currentParent; // 存储root节点、当前父节点
    let stack = []; // 解析过程中内容存放于栈中,当匹配到结束标签时出栈,以完成对嵌套节点的处理,保证结构正确
    // ...
    while (html) {
        let textEnd = html.indexOf('<');
        if (textEnd === 0) {
            const startTagMatch = parseStartTag();
            if (startTagMatch) {
                handleStartTag(startTagMatch);
                continue; // 此时对于开始标签的处理已经完成,重回循环,因为处理的情况可能是<tag1>content</tag1>, 也可能是<tag1><tag2></tag2></tag1>
            }
        }
    }
    
    function handleStartTag({tagName, attrs}) {
        let element = craeteASTElement(tagName, attrs); // 创建元素节点
        if (!root) {
            root = element;
        }
        currentParent = element;
        stack.push(element); // 将节点先入栈,等到结束标签在做处理
    }
}

结束标签

对于结束标签的解析和处理(因为结束标签上并没有需要解析的内容,所以这部分会比开始标签要简略):

scss 复制代码
function parse(html) {
    while (html) {
        let textEnd = html.indexOf('<');
        if (textEnd === 0) {
            const startTagMatch = parseStartTag();
            if (startTagMatch) {
                handleStartTag(startTagMatch);
                continue;
            }
            
            // 结束标签
            const endTagMatch = html.match(endTag); // 结束标签</tag>上并没有需要解析的内容,所以只需要确认是否是结束标签即可
            if (endTagMatch) {
                advance(endTagMatch[0].length);
                handleEndTag(endTagMatch[1]);
                continue;
            }
        }
    }
    
    function handleEndTag(tagName) {
        let element = stack.pop() // 匹配到结束标签,自然认为与栈顶为一对
        currentParent = stack[stack.length - 1]; // 重新设置栈顶节点为当前父节点
        if (currentParent) {
            // 从这里可以看出在慢慢形成树一样的结构,所以称为AST树
            element.parent = currentParent; // 此时栈顶元素必然是其父节点, 兄弟节点和子节点早就出栈了
            currentParent.children.push(element); // 存入父节点的children属性中
        }
    }
}

文本

除了标签相关的内容外,我们的template里还可能存在的就是文本,继续来处理它们:

scss 复制代码
function parse(html) {
    while (html) {
        let textEnd = html.indexOf('<');
        if (textEnd === 0) {
            const startTagMatch = parseStartTag();
            if (startTagMatch) {
                handleStartTag(startTagMatch);
                continue;
            }
            
            const endTagMatch = html.match(endTag);
            if (endTagMatch) {
                advance(endTagMatch[0].length);
                handleEndTag(endTagMatch[1]);
                continue;
            }
        }
        
        // 文本
        let text;
        if (textEnd > 0) { // 最近的 < 不在当前读到的位置,而无论是开始标签还是结束标签都已经被以上代码分支处理,这里必是文本
            text = html.substring(0, textEnd)
        }
        if (text) {
            advance(text.length);
            handleChars(text);
        }
    }
    
    function handleChars(text) {
        text = text.replace(/\s/g, ""); // 处理文本中的 所有 空格
        if (text) {
            let textNode = createASTText(text);
            currentParent.children.push(textNode);
        }
    }
    
    return root; // 当这个循环完成后,从root节点已经长成一棵AST树
}

到这里parse()的主要流程就结束了,我们最后揭晓一下advance()函数:

scss 复制代码
function parse(html) {
    // ...
    function advance(n) {
        html = html.substring(n)
    }
}

结语

最后我们看一个实例,由我们一开始的那个模板代码经过parse()处理的产物的模拟:

yaml 复制代码
{
    tag: 'div',
    type: 1,
    attrs: [{name: 'class', value: 'container'}, {name: 'style', value: 'color:red; font-size:14px'}],
    parent: null,
    children: [
        {
            tag: 'div',
            type: 1,
            attrs: [],
            parent,
            children: []
        },
        {
            type: 3,
            text: 'hello{{name}}'
        }
    ]
}

这会跟在Vue2版本中断点查看的有些许出入,但不影响我们理解这一过程。

至此我们已经完成了第一篇,在这里我们主要整理了Vue2编译时,从模板生成ast树的过程,来做一个总结:

  1. 模板内容被视作文本,交由js代码处理
  2. 使用正则进行匹配捕获:开始标签、标签中的属性、结束标签、文本
  3. 使用栈结构,开始标签->元素节点,入栈;结束标签作为出栈的标志,出栈;文本->文本节点,添加到当前currentParent.children中
  4. 循环遍历模板内容逐步由栈结构得到AST树

下一篇,我们将继续整理generate()函数,记录和分析从AST树 到 code文本的过程。

【参考】:

Vue2.0源码(二)模板编译原理

相关推荐
追梦人物14 分钟前
Uniswap 手续费和协议费机制剖析
前端·后端·区块链
拾光拾趣录1 小时前
基础 | 🔥6种声明方式全解⚠️
前端·面试
朱程3 小时前
AI 编程时代手工匠人代码打造 React 项目实战(四):使用路由参数 & mock 接口数据
前端
PineappleCoder3 小时前
深入浅出React状态提升:告别组件间的"鸡同鸭讲"!
前端·react.js
程序员嘉逸3 小时前
LESS 预处理器
前端
橡皮擦1993 小时前
PanJiaChen /vue-element-admin 多标签页TagsView方案总结
前端
碎花里3 小时前
Vue3+Uniapp 环境多语言实现方案
vue.js
程序员嘉逸3 小时前
SASS/SCSS 预处理器
前端
咕噜分发企业签名APP加固彭于晏3 小时前
腾讯云eo激活码领取
前端·面试