Vue源碼解析:模版字符串轉AST語法樹

經過對 Vue2.0 源碼閱讀,想寫一寫本身的理解,能力有限故從尤大佬2016.4.11第一次提交開始讀,準備陸續寫:javascript

其中包含本身的理解和源碼的分析,儘可能通俗易懂!因爲是2.0的最先提交,因此和最新版本有不少差別、bug,後續將陸續補充,敬請諒解!包含中文註釋的Vue源碼已上傳...html

問題

  1. 什麼是AST?
    AST(abstract syntax tree)意爲抽象語法樹,其實就是樹形數據結構的表現形式,有父節點、子節點、兄弟節點等概念...
  2. 自己就是樹形結構的HTML爲何還要轉化?
    由於真實DOM含不須要的屬性太多了,若是篩選出咱們須要的屬性,再對其進行操做,將大大優化性能!
  3. AST和虛擬節點vnode有什麼關係?
    它們結構很類似,AST其實算得上是vnode的前身,AST通過一系列的指令解析、數據渲染就會變成vnode!這邊的AST其實只是簡單的html解析。vue

開始

舉個🌰,咱們先看看輸入和輸出,java

<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)。node

難點就在於,字符串的解析以及父子節點關係的構建。經過閱讀源碼,html字符串的解析主要用到了HTMLParser函數,該函數經過循環:git

  • 第一步 正則表達式的匹配。模版字符串依次找註釋、IE判斷標籤、doctype標籤、結束標籤、開始標籤、文本等等;
  • 第二步 處理第一步找到的結果。將結果從模版字符串中截取掉,而後進一步處理結束標籤或開始標籤或文本取到的字符串;
  • 第三步 截取過的模版字符串再次循環第一、2步,直到爲空時跳出循環。

拿上面的例子來講,第一次循環拿到開始標籤<div class="container">,第二次拿到文本節點\n,第三次拿到開始標籤<span :class="{active: isActive}">,第四次拿到文本{{msg}}...固然每次取到以後會對字符串進行處理,後續會詳說。github

另外關於父子節點關係的創建,主要用到了棧的後進先出的原理:每次匹配到開始標籤會入棧,同時將其設爲當前父節點;匹配到結束標籤會出棧,並將棧末元素設爲當前父節點。正則表達式

源碼解析

Vue2.0 有關模版字符串轉AST語法樹的代碼全在html-parser.js中,由於裏面夾雜不少兼容的處理(瀏覽器兼容,XHTML兼容等等),因此拿個簡化版的parser.js來解析一下,你能夠把代碼複製下來丟控制檯回車一下看看效果。segmentfault

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語法樹創建的見解,必定存在不少問題,但願各位及時指出 (┬_┬),後續會抓緊時間把其餘幾篇也寫出來禍害各位的!看到這就點個讚唄~ 嘿嘿嘿

相關文章
相關標籤/搜索