# Vue 2 中如何解析模版

# 1、Vue 2 的三种选项

在使用 Vue 时,我们一般都是采用模版的方式开发的,但其实 Vue 可以有三种书写形式去创建我们的 DOM。本文源码非官方源码,为本人手写。源码链接:

# 1.1 el + 模版

这是我们最常用的一种方式,在 template 标签中书写模版,然后传入 el 来指定我们要将 Vue 中的数据挂载到哪个 DOM 元素上,或者说我们需要解析哪部分的模版。

  <body>
    <div id="app">
      <div style="color:red">{{name}} <span></span> 你好 </div>
      <span>{{age}}</span>
      <br/>
    </div>
    <script src="vue.js"></script>
    <script>
      const vm = new Vue({
        data(){
          return {
           name:'zs',
           age:34,
           obj:{
            name:'ls',
            age:122
           },
           hobby:['eat','drink']
          }
        },
        el:'#app',
      })
    </script>
  </body>

# 1.2 template 选项

通过传入 template 配置项来直接传入需要解析的模版

     const vm = new Vue({
        data(){
          return {
           name:'zs',
           age:34,
           obj:{
            name:'ls',
            age:122
           },
           hobby:['eat','drink']
          }
        },
        template:'<div>你好</div>'
      })

# 1.3 render 选项

直接传入 render 选项

     const vm = new Vue({
        data(){
          return {
           name:'zs',
           age:34,
           obj:{
            name:'ls',
            age:122
           },
           hobby:['eat','drink']
          }
        },
        render(){
          return createElement('div',{})
        }
      })

# 2、Vue 2 内部对这三种方式的处理

无论是直接传入 render 函数,又或是通过另外两种方式传人的模版,最终经过 Vue 的处理之后,都会被编译为 render 函数(直接传入 render 函数不用编译)

Vue 2 对三种方式的处理:

    Vue.prototype.$mount = function (el) {
        const vm = this
        // 获取 el 对应的真实 dom
        el = document.querySelector(el)
        const opts = vm.$options
        if (!opts.render) {
            // 没有传 render 选项
            let template
            if (!opts.template && el) {
                // 没有传入模版,但传入了el,那么就去找 el 指定的模版
                // 获取到 el 对应的 HTML 结构,也就是模版
                template = el.outerHTML
            } else {
                if (el) {
                    // 传入了模版和 el
                    template = opts.template
                }
            }
            if (template) {
                // 编译模版,获取 render
                const render = compileToFunction(template)
                opts.render = render
            }
        }

        // 如果有模版,则编译成render;反之如果有render,则不必编译。模版和render函数最终都会被统一成render函数
    }

可以看到,无论是 el + templae 模版的方式,还是直接传人 template 选项的方式,最终都会被 compileToFunction 函数进行编译,生成 render 函数。compileToFunction 主要做了两步,一是将 template 模版转化成 AST 语法树;二是通过 AST 语法树生成 render 方法。本文主要探讨将 template 模版转化成 AST 语法树的过程,将 template 模版转化成 AST 语法树的过程主要分为:解析模版 和 生成 AST 语法树两部分。

# 3、正文:如何解析模版

# 3.1 综述

解析模版 和 生成 AST 语法树的过程,都是通过 parseHTML 函数来实现的,其接受一个模版参数,返回一个 AST 语法树。

// 编译模版,返回render方法
export function compileToFunction(template) {
    // 1. 将 template 模版转化成 AST 语法树
    let ast = parseHTML(template)

    // 2. 生成 render 方法
    ......
}

Vue 模版中的标签有很多,每个标签还用各种属性,另外还存在文本标签。那么,Vue 是如何区分这些标签的呢,其实是通过正则表达式来进行匹配的。无论是开始标签、结束标签,还是文本节点,都会有他相应的一些语法特征。Vue 通过正则去匹配这些特征,就可以了解到文本所对应的节点或者是标签的类型。

// 解析部分使用到的正则表达式,非本人手写
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`

// 匹配 <xxx 开始的标签名,也就是开始标签的前半部分,最终匹配到的分组是开始标签的名字
const startTagOpen = new RegExp(`^<${qnameCapture}`)

// 匹配结束标签 </xxx> 最终匹配到的分组是结束标签的名字
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)

// 匹配标签上的属性,属性的第一个分组是属性的名称(key),第3 | 4 | 5 分组中有一个是他的值
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

// 匹配开始标签的结束部分的 比如 <span> 的>  和 <br/> 的 />
const startTagClose = /^\s*(\/?)>/

// 匹配双花括号 {{value}}
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

解析部分核心源码:

// 将 template 模版转化成 AST 语法树
function parseHTML(html) {

	...... //非解析部分的核心代码,有部分省略

    // 删除html的前 n 位字符
    function advance(n) {
        html = html.substring(n)
    }
    // 解析开始标签
    function parseStarTag() {
        const start = html.match(startTagOpen) // 得到一个数组,以div为例:['<div','div'],没匹配到,start 为 null
        if (start) {
            // 匹配到了,返回开始标签的对象
            const match = {
                tagName: start[1],
                attrs: []
            }
            // 把匹配过的部分删掉,便于继续向后匹配
            advance(start[0].length)

            // 继续向后匹配,获得开始标签的属性,一直匹配到开始标签的结束位置
            let attr, end
            while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
                // 把匹配到的属性删掉
                if (attr) 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
        } else {
            return false
        }
    }
    // html 一般是以 < 开头的,如果不是以 < 开头的,说明开头是一个文本节点
    while (html) {
        // 如果 textEnd 为 0,说明模版开头是一个标签(开始或结束标签未知)
        // 如果 textEnd 不是 0,说明模版开头是一段文本,textEnd 表示文本节点结束的位置
        let textEnd = html.indexOf('<')

        if (textEnd === 0) {
            //开头是标签,尝试匹配是否为开始标签
            const startTagMatch = parseStarTag()

            // 匹配到了开始标签,也把开始标签从html中删除掉了,需要进行下一轮的匹配了
            if (startTagMatch) {
                // 把开始标签交给生成语法树的函数处理
                start(startTagMatch.tagName, startTagMatch.attrs)
                continue
            }
            else {
                // 不是开始标签,那么匹配到的一定是结束标签
                const endTagMatch = html.match(endTag)
                if (endTagMatch) {
                    // 把结束标签交给生成语法树的函数处理
                    end(endTagMatch[1])
                    // 把匹配过的部分删除
                    advance(endTagMatch[0].length)
                    continue
                }
            }
        }

        // 处理文本节点
        if (textEnd > 0) {
            let text = html.substring(0, textEnd) // 截取文本节点的内容

            if (text) {
                // 把文本节点的内容交给生成语法树的函数处理
                chars(text)
                advance(text.length) // 把文本节点从 html 中删除
            }
        }
    }
}

# 3.2 解析开始标签

拿到模版之后,parseHTML 会进行正则匹配。一般来说,模版的开头都是一个标签,无论是开始标签还是结束标签,其开头必定是 < 符号,如果不是以 < 开头,那么可以肯定,开头的这个节点必然是一个文本节点。所以我们通过 indexof 来判断 < 在模版中的位置,从而确定开头的第一个节点是否为文本节点。

  let textEnd = html.indexOf('<')

        if (textEnd === 0) {
            //开头是标签,尝试匹配是否为开始标签
            const startTagMatch = parseStarTag()

            // 匹配到了开始标签,也把开始标签从html中删除掉了,需要进行下一轮的匹配了
            if (startTagMatch) {
                // 把开始标签交给生成语法树的函数处理
                start(startTagMatch.tagName, startTagMatch.attrs)
                continue
            }
            ......

如果发现模版确实是以尖括号开头,那么先交给 parseStarTag 来判断是否为开始标签,parseStarTag 函数流程如下:

function parseStarTag() {
        const start = html.match(startTagOpen) // 得到一个数组,以div为例:['<div','div'],没匹配到,start 为 null
        if (start) {
            // 匹配到了,返回开始标签的对象
            const match = {
                tagName: start[1],
                attrs: []
            }
            // 把匹配过的部分删掉,便于继续向后匹配
            advance(start[0].length)

            // 继续向后匹配,获得开始标签的属性,一直匹配到开始标签的结束位置
            let attr, end
            while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
                // 把匹配到的属性删掉
                if (attr) 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
        } else {
            return false
        }
    }
<div id="text" style="color:red">

parseStarTag 会对当前模版的开头做正则匹配,如果发现不是开始标签的话,就返回 false;如果是开始标签的话,就提取当前开始标签的名称,接着调用 advance 函数将标签名称从模版中删去,然后再继续对剩余的模版做标签属性的匹配,提取到所有的属性之后,最后匹配开始标签的结束部分(> 或者 />)。以<div id="text" style="color:red">为例:

image-20240420210744102

# 3.3 解析结束标签

在解析过程中的结束标签对我们没有什么价值,直接使用 advance 删掉就行,结束标签在生成 AST 语法树时才有意义。

# 3.4 解析文本节点

文本节点的内容多种多样,很难找出其特征进行正则匹配。所以,Vue 采用的方式的寻找 < 在模版中的位置来寻找文本节点的,如果 < 的位置不在模版的开头第一个字符的位置,那么就说明这个模版的开头不是一个标签,而是文本节点,文本的长度就等于模版开头到模版中第一个 < 的位置。我们截取这部分模版,就得到了文本节点的内容。

// textEnd > 0 ,说明开头不是标签
if (textEnd > 0) {
            let text = html.substring(0, textEnd) // 截取文本节点的内容

            if (text) {
                // 把文本节点的内容交给生成语法树的函数处理
                chars(text)
                advance(text.length) // 把文本节点从 html 中删除
            }
        }

# 4、正文:如何生成 AST 语法树

虽然本文将解析模版和生成 AST 语法树作为两个部分来讲解,但其实这两个部分并没有先后顺序,而是同时执行的。在解析模版的时候,Vue 会同步的构建 AST 语法树。首先来看 AST 语法树的节点结构:

    //元素节点结构
 {
     tag: 'div', // 标签名
     type: 1, // 节点类型:1 元素节点 3 文本节点
     attrs: [{ name: 'id', value: 'app' }], // 属性数组
     parent: null, // 父节点,根节点的父节点wei null
     children: [  // 节点下的子节点
         {}, {}
     ]
 }

    //文本节结构
 {
    type: 3,
    text,
    parent: currentParent
 }

构建 AST 语法树的关键点就在于:如何确定节点的父子关系。对此,Vue 采用了栈的方式来解决。具体来说,当我们在解析模版的时候,如果遇到了一个开始标签,就将这个标签入栈,一直这样操作,知道遇到结束标签时,将栈中的最后一个节点弹出。这样维护一个栈结构,就可以保证,当我们新创建一个 AST 节点的时候,这个节点就是当前栈中最后一个节点的子节点。以 <div><span><p>你好Vue</p></span></div> 为例,过程如下:

image-20240420224121957

生成 AST 抽象语法树的核心代码如下:


    const ELEMENT_TYPE = 1 // 元素节点类型
    const TXET_TYPE = 3 // 文本节点类型

    let stack = [] // 栈,用于创建语法树时判断节点的父节点
    let currentParent = null; // 栈顶指针,指向栈中的最后一个节点 
    let root = null // 指向 AST 语法树的根节点

    // 创建语法树节点函数
    function createASTElement(tag, attrs) {
        return {
            tag,
            type: ELEMENT_TYPE,
            attrs,
            children: [],
            parent: null
        }
    }
    
	// 下面的三个方法都是用于生成抽象语法树的
    // 处理开始标签
    function start(tag, attrs) {
        // 遇到开始标签,入栈
        let node = createASTElement(tag, attrs)

        if (!root) root = node //还没有根AST节点,那么这个节点就是根节点

        // 栈中最后一个节点就是新节点的父节点
        if (currentParent) {
            node.parent = currentParent
            currentParent.children.push(node)
        }


        // 节点入栈
        stack.push(node)
        // 移动栈顶指针
        currentParent = node
    }

    // 处理文本标签
    function chars(text) {
        // 把空文本删掉,实际上源码中是会保存两个空格的,多于两个空格的就删掉了
        text = text.replace(/\s/g, '')
        // 文本标签不用入栈,他就是当前栈顶指针指向的那个开始节点的子节点
        text && currentParent.children.push({
            type: TXET_TYPE,
            text,
            parent: currentParent
        })
    }

    // 处理结束标签
    function end(tag) {
        // 遇到结束标签,弹出栈里的最后一个开始节点,并且更新 currentParent
        stack.pop()
        currentParent = stack[stack.length - 1]
    }

当我们解析模版的时候,匹配到了开始标签,就将标签名和标签上的属性传递给 start 函数,start 函数会根据我们传递进来的信息,生成一个 AST 的节点,将这个节点与当前栈中的最后一个节点建立父子关系,并将这个节点入栈。当我们匹配到结束标签时,就调用 end 方法,将栈中的元素弹出。当匹配到文本节点的时候,就调用 charts 方法,将文本内容传递给 chars 方法,并生成一个文本节点,同样这个节点与当前栈中的最后一个节点建立父子关系,但文本节点不可能有子节点,也没有结束标签,所以不用入栈。这样,当我们解析完所有的模版之后,就可以得到一颗 AST 抽象语法树。