Vue源码解析:模版字符串转AST语法树

shushushu

通过对 Vue2.0 源码阅读,想写一写自己的理解,能力有限故从尤大佬2016.4.11第一次提交开始读,准备陆续写:

其中包含自己的理解和源码的分析,尽量通俗易懂!由于是2.0的最早提交,所以和最新版本有很多差异、bug,后续将陆续补充,敬请谅解!包含中文注释的Vue源码已上传...

问题

  1. 什么是AST?
    AST(abstract syntax tree)意为抽象语法树,其实就是树形数据结构的表现形式,有父节点、子节点、兄弟节点等概念...
  2. 本身就是树形结构的HTML为什么还要转化?
    因为真实DOM含不需要的属性太多了,如果筛选出我们需要的属性,再对其进行操作,将大大优化性能!
  3. AST和虚拟节点vnode有什么关系?
    它们结构很相似,AST其实算得上是vnode的前身,AST经过一系列的指令解析、数据渲染就会变成vnode!这边的AST其实只是简单的html解析。

开始

举个🌰,我们先看看输入和输出,

<div class="container">
    <span :class="{active: isActive}">{{msg}}</span>
    <ul>
        <li v-for="item in list">{{item + $index}}</li>
    </ul>
    <button @click="handle">change msg</button>
</div>

clipboard.png

很明显的看到,输出的AST语法树是个对象,只拿了我们需要的节点标签(tag)和属性(attribute),当然还有树形结构依赖关系(parent&children)。

难点就在于,字符串的解析以及父子节点关系的构建。通过阅读源码,html字符串的解析主要用到了HTMLParser函数,该函数通过循环:

  • 第一步 正则表达式的匹配。模版字符串依次找注释、IE判断标签、doctype标签、结束标签、开始标签、文本等等;
  • 第二步 处理第一步找到的结果。将结果从模版字符串中截取掉,然后进一步处理结束标签或开始标签或文本取到的字符串;
  • 第三步 截取过的模版字符串再次循环第1、2步,直到为空时跳出循环。

拿上面的例子来说,第一次循环拿到开始标签<div class="container">,第二次拿到文本节点\n,第三次拿到开始标签<span :class="{active: isActive}">,第四次拿到文本{{msg}}...当然每次取到之后会对字符串进行处理,后续会详说。

另外关于父子节点关系的建立,主要用到了栈的后进先出的原理:每次匹配到开始标签会入栈,同时将其设为当前父节点;匹配到结束标签会出栈,并将栈末元素设为当前父节点。

源码解析

Vue2.0 有关模版字符串转AST语法树的代码全在html-parser.js中,因为里面夹杂很多兼容的处理(浏览器兼容,XHTML兼容等等),所以拿个简化版的parser.js来解析一下,你可以把代码复制下来丢控制台回车一下看看效果。

parse() 函数

先看一下 parse() 函数,参数html为模版字符串,返回值为AST语法树:

function parse (html) {
    let root // AST根节点
    let currentParent // 当前父节点
    let stack = [] // 节点栈
    
    HTMLParser(html, {
    // 处理开始标签
    start (tag, attrs, unary) {
      let element = {
        tag,
        attrs,
        // [{name: 'class', value: 'xx'}, ...] => [{class: 'xx'}, ...]
        attrsMap: attrs.reduce((cumulated, { name, value }) => { 
                    cumulated[name] = value || true;
                    return cumulated;
                  }, {}),
        parent: currentParent,
        children: []
      }
      // 初始化根节点
      if (!root) {
        root = element
      }
      // 有父节点,就把当前节点推入children数组
      if (currentParent) {
        currentParent.children.push(element)
      }
      // 不是自闭合标签
      // 进入当前节点内部遍历,故currentParent设为自身
      if (!unary) {
        currentParent = element
        stack.push(element)
      }
    },
    // 处理结束标签
    end () {
      // 出栈,重新赋值父节点
      stack.length -= 1
      currentParent = stack[stack.length - 1]
    },
    // 处理文本节点
    chars (text) {
      text = currentParent.tag === 'pre'
        ? text
        : text.trim() ? text : ' '
      currentParent.children.push(text)
    }
  })
  return root
}

该方法内部创建变量后,主要调用了HTMLParser()函数,参数为模版字符串和一个对象(包含处理开始标签、结束标签、文本的回调)。先看一下每次匹配开始标签会怎么处理,start()函数:

  1. 接收三个参数,tag(标签名),attrs(标签属性,形如[{name: 'class', value: 'container'}, ...]),unary(是否是自闭合标签);
  2. 创建树节点对象,含属性tagattrsattrsMap(就是将attrs转成[{class: 'container'}, ...]),parent(根节点该属性为undefined),children
  3. 初始化根节点root(只有第一次会走这);
  4. 有父节点,就把当前节点推入children数组(只有第一次不走这);
  5. 不是自闭合标签,把刚刚创建的树节点对象作为父节点,并入栈。

匹配到结束标签的处理就比较简单了,出栈并将栈末元素设为父节点;匹配到文本节点时,简单处理一下就推入children数组。

到这大概了解到,HTMLParser这个函数要做的事情就是:遇到开始标签,把标签名、标签属性和是否是自闭合标签拿到,然后调用一下start();遇到结束标签了,就调用end(),都不用你传参;遇到文本节点了,就把文本节点作为参数,调用一下chars()

HTMLParser()函数

那接下来看一下HTMLParser()函数具体是怎么实现的,先看一下正则,已经被我改简单很多了...

// 开始标签头
const startTagOpen = /^<([\w\-]+)/,
// 开始标签尾
startTagClose = /^\s*(\/?)>/, 
// 标签属性
attribute = /^\s*([^\s"'<>\/=]+)(?:\s*((?:=))\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/,
// 结束标签
endTag = /^<\/([\w\-]+)>/; 

还有前面一直提到的自闭合标签,就没结束标签的那类:

var empty = makeMap('area,base,basefont,br,col,embed,frame,hr,img,' + 
                    'input,isindex,keygen,link,meta,param,source,track,wbr');

function makeMap (values) {
    values = values.split(/,/);
    var map = {};
    values.forEach(function (value) {
        map[value] = 1;
    });
    return function (value) {
        return map[value.toLowerCase()] === 1;
    };
}

// empty('input');    => true

以及经常会用到的截取html字符串的函数 advance(),参数为需要截取的长度:

function advance (n) {
    index += n;    // index用于记录剩余字符串在原字符串中的位置
    html = html.substring(n);
}

前面定义的种种,都将HTMLParser函数中用到,看一下该函数的结构:

function HTMLParser (html, handler) {
    var tagStack = [];    // 标签栈
    var index = 0;
    while (html) {
        // html 是通过 getOuterHTML 并删除了前后空格,所以第一次textEnd肯定为0
        var textEnd = html.indexOf('<');
        if (textEnd === 0) {
            // 匹配开始标签
            var startTagMatch = parseStartTag();
            if (startTagMatch) {
                handleStartTag(startTagMatch);
                continue;
            }
            // 匹配结束标签
            var endTagMatch = html.match(endTag);
            if (endTagMatch) {
                var curIndex = index;
                advance(endTagMatch[0].length);
                parseEndTag(endTagMatch[1], curIndex, index);
                continue;
            }
        }
        // 处理文本节点
        ...
    }
    // 这边还有一些函数的定义...
}

我们来详细说一下这个方法,拿到html之后,就进入while循环,跳出循环的条件是把html榨干。循环开始时,找到 < 在html中的位置,为0表示是匹配到了开始标签或者结束标签(这边暂时不考虑注释、Doctype标签等等),不为0则表示有文本节点。先看看 < 下标为0时,parseStartTag()函数怎么解析开始标签的:

function parseStartTag () {
    var start = html.match(startTagOpen);
    if (start) {
        var match = {
            tagName: start[1],
            attrs: [],
            start: index
        };
        advance(start[0].length);
        var end, attr;
        // 未结束且匹配到标签属性
        while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
            advance(attr[0].length);
            match.attrs.push(attr);    // 添加属性
        }
        if (end) {
            advance(end[0].length);
            match.end = index;
            return match;
        }
    }
}

看到parseStartTag()函数刚开始,拿开始标签头的正则表达式startTagOpen去匹配,若匹配成功则截取html。举个🌰,<div class="container"></div>,匹配成功并截取后,余下class="container"></div>。随后拿匹配标签属性的正则表达式attribute,依次取出并将匹配结果放入attrs数组,直到匹配到开始标签尾startTagClose)。继续拿上面的🌰,这步走完只剩下</div>,最终也将返回match对象。让我们看看它什么样:

clipboard.png

到这开始标签的匹配工作完成大半了,标签名和标签属性都拿到了,但标签属性还是正则匹配结果,需要进一步处理,以及判断一下是不是自闭合标签。马上看一下handleStartTag()函数是怎么处理的:

function handleStartTag (match) {
    var tagName = match.tagName;
    var unary = empty(tagName);
    var attrs = match.attrs.map(attr => {
        return {
            name: attr[1],
            value: attr[3] || attr[4] || attr[5] || ''
        };
    });
    // 不是自闭标签
    if (!unary) {
        tagStack.push({ tag: tagName, attrs: attrs});
    }
    if (handler.start) {
        handler.start(tagName, attrs, unary, match.start, match.end);
    }
}

handleStartTag()函数就是将上面返回的match结果,拿到标签名,判断是否是自闭合标签,再将属性结果处理成{name: 'class', value: 'container'}形式,然后不是自闭合标签就把标签信息推入标签栈中(这一步是用于后续匹配结束标签做铺垫),最后调用传入的start回调,至此开始标签匹配结束。

随后进入结束标签的匹配环节,这边比较简单。首先是用正则去匹配形如</xxx>的结束标签(匹配完后截取原html),然后拿到标签名去标签栈末开始找位置(下标),找到后把该位置到栈末全部出栈,再调用传入的end回调。之前一直没懂为什么要做这个操作?除了自闭合标签,一个元素节点的开始结束标签是成对存在的啊。这边举个例子:拿一段有问题的html字符串 <ul><li>1</ul>,故意少写了 li 的闭合标签,那栈刚开始推入ul,再推入li,匹配到ul的结束标签后把栈中的liul都出栈,这就是没问题的!看一下函数内具体啥样:

function parseEndTag (tagName, start, end) {
    var pos;
    if (start == null) start = index;
    if (end == null) end = index;
    if (tagName) {
        var needle = tagName.toLowerCase();
        // 找到结束标签在标签栈的位置
        for (pos = tagStack.length - 1; pos >= 0; pos--) {
            if (tagStack[pos].tag.toLowerCase() === needle) {
                break;
            }
        }
    }
    if (pos >= 0) {
        for (var i = tagStack.length - 1; i >= pos; i--) {
            if (handler.end) {
                handler.end(tagStack[i].tag, start, end);
            }
        }
        tagStack.length = pos;    // 标签栈出栈
    }
  }
}

最后看一下文本节点的处理方法啦~

var text, rest, next;
if (textEnd >= 0) {
    rest = html.slice(textEnd);
    while (
        !endTag.test(rest) &&
        !startTagOpen.test(rest)
    ) {
        // 处理小于号等其他文本
        next = rest.indexOf('<', 1);
        if (next < 0) break;
        textEnd += next;
        rest = html.slice(textEnd);
    }
    text = html.substring(0, textEnd);
    advance(textEnd);
}
if (textEnd < 0) {
    text = html;
    html = '';
}
if (handler.chars) {
    handler.chars(text);
}

先看看三个变量都是干啥的,text(String)用于存储文本节点信息,rest(String)是去除文本节点后剩余html,next(Number)是rest中第二个 < 的位置。可能会有点疑问,这边其实是为了防止我们找到的<不是标签的开始标志,也有可能是小于号等等!也举个例子,<div>{{age<18?'adult':'nonage'}}</div>,匹配完开始标签后剩余{{age<18?'adult':'nonage'}}</div>,这时候找到<下标不为0,拿到{{age屁颠屁颠就进入下次循环!所以为了防止这种情况发生,我们需要看看剩余部分<18?'adult':'nonage'}}</div>是否满足开始标签,不满足就找下一个<,最终找到{{age<18?'adult':'nonage'}}才是我们要的文本节点!完事~

结语

写了贼久终于写完了...这是我对Vue中AST语法树建立的看法,一定存在很多问题,希望各位及时指出 (┬_┬),后续会抓紧时间把其他几篇也写出来祸害各位的!看到这就点个赞呗~
嘿嘿嘿

阅读 5.5k
534 声望
24 粉丝
0 条评论
534 声望
24 粉丝
文章目录
宣传栏