大白話Vue源碼系列(03):生成AST


本篇探討 Vue 根據 html 模板片段構建出 AST 的具體過程。這對 Vue 的使用通常沒什么幫助,但熟悉這個過程會對 Vue 的內部工作原理有更清晰的認識。

主代碼位置:Vue 項目的 src/compiler/parser/html-parser.js 文件。

AST 節點定義

AST 是由一個個節點組成的,正如 DOM 樹是由 DOM 節點組成的一樣。

Vue 使用正則表達式匹配 html 標簽,並將標簽解析成 AST 節點,所以繼續下面的內容之前最好對正則表達式有一定了解。

Vue 的 AST 節點數據結構定義如下:

// 節點包含 3 種類型:標簽元素、普通文本、插值表達式
declare type ASTNode = ASTElement | ASTText | ASTExpression;

declare type ASTElement = {
  type: 1;
  tag: string;
  attrsList: [];
  parent: ASTElement | void;    
  children: [];
}

declare type ASTExpression = {
  type: 2;
  expression: string;
  text: string;
}

declare type ASTText = {
  type: 3;
  text: string;
  isComment: boolean;
}
 

declare type 是 flow.js 的語法,用於靜態類型檢查。請留意 ASTElement 定義中的 parentchildren 字段,它們將是用於建立父子關系從而構成一顆樹的依據。

接下來開始剖析代碼細節。

標簽的正則匹配

下面是比較枯燥的正則式環節。

1、匹配標簽名

const tagName = '([a-zA-Z_][\\w\\-\\.]*)'
 

需要注意的是,不同於[a-zA-Z_],正則式 \w 用於匹配包括下划線的任何單詞字符,包括中文字符。因此上面一行正則式的意思是匹配以英文字母或下划線開頭([a-zA-Z_])接若干個單詞字符或下划線([\w\-\.]*)的字符串。

該正則式可匹配到 <div id="index">div 名稱部分。

2、匹配標簽屬性

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
 

這行正則式用於匹配 key = value 這種屬性鍵值對,雖然看起來挺復雜,但其實是挺簡單的匹配,主要是兼容處理屬性值的雙引號,單引號和數字等寫法。

該正則式可匹配到 <div id="index">id="index" 屬性部分。

3、匹配開始標簽

const startTagOpen = new RegExp(`^<${tagName}`)
const startTagClose = /^\s*(\/?)>/
 

startTagOpen 用於匹配開始標簽的左邊開頭部分,即 <div id="index">{{msg}}</div><div 部分。
startTagClose 用於匹配開始標簽的右邊閉合部分,即 >{{msg}}</div> 左邊開頭的 > 部分,請注意這一點,因為 Vue 是用步步蠶食(也就是解析一點,剪掉一點)的方法一點一點進行解析的。

開始標簽?結束標簽?
在這里把 <div></div><div> 叫做開始標簽(startTag),把 </div> 叫做結束標簽(endTag)。

4、匹配結束標簽

const endTag = new RegExp(`^<\\/${tagName}[^>]*>`)
 

注意正則式中 ^ 放在首位表示匹配行首。因此該正則式可匹配到 </div><h1></h1></div>

解析 html 模板主要就用到這 4 個關鍵的正則式,接下來開始正式解析。

解析用到的工具方法

1、advance 方法

該方法用於步步蠶食,也就是每解析一部分,就從待解析的模板片段中去掉一部分,直到解析完畢,html.length0

let index = 0;

function advance (n) {
    index += n
    html = html.substring(n)
}
 

比如 <div id="index"> 經過 advance(4) 就變成 id="index">index 變量也從 0 變成了 4,表示已經解析了 4 個字符。

2、createASTElement 方法

這個方法用於構造一個 AST 元素節點(對應上面的 AST 節點類型定義),每解析一個標簽就要生成一個這樣的 AST 元素節點。注意傳入的 parent 參數,除了根元素,其它節點一般都有一個 parent 元素,還是那句話,多類比 DOM 樹。

function createASTElement (tag, attrs, parent){
    return {
        type: 1,
        tag,
        lowerCasedTag: tag.toLowerCase(),
        attrsList: attrs,
        parent,
        children: []
    }
}
 

解析開始標簽

接下來的內容就比較消耗腦細胞了,建議先仔細了解一下字符串的 match 方法,因為之后的解析里會多處用到。

老規矩,先看方法定義:

let root
let currentParent
let stack = []  // 標簽元素棧

function parseStartTag () {
    //-- 第一步 首先匹配開始標簽的左邊開頭部分 --
    const start = html.match(startTagOpen)
    if (start) {
        const match = {
            tagName: start[1],
            attrs: [],
            start: index
        }
        advance(start[0].length)

        //-- 第二步 循環解析開始標簽上的每一個屬性鍵值對 --
        let end, attr
        while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
            advance(attr[0].length)
            match.attrs.push({
                name: attr[1],
                value: attr[3]
            })
        }

        //-- 第三步 匹配到開始標簽的閉合部分,至此開始標簽解析結束 --
        if (end) {
            match.unarySlash = end[1]
            advance(end[0].length)
        }

        // 解析完標簽創建一個 AST 節點
        let element = createASTElement(match.tagName, match.attrs, currentParent)

        if(!root){
            root = element
        }

        if(currentParent){
            currentParent.children.push(element);
        }

        // 自閉合就不用壓入棧中了
        if (!match.unarySlash) {
            stack.push(element)
            currentParent = element
        }

    }
}
 

為了在解析到結束標簽時找到與之對應的開始標簽,Vue 通過維護一個標簽棧 stack 來匹配對應的標簽。currentParent 用於指向棧頂的 AST 節點。

以解析 <div id="index" class="content"> 為例,

經過第一步解析標簽名,解析的結果如下:

match = {
    tagName: "div",
    attrs: [],
    start: 0
}
 

此時 html 也經 advance 成了 id="index" class="content">

接着經過第二步解析屬性鍵值對,解析的結果變成:

match = {
    tagName: "div",
    attrs: [
        {
            "name": "id",
            "value": "index"
        },
        {
            "name": "class",
            "value": "content"
        }
    ],
    start: 0
}
 

此時 html 經過多次 advance 成了 >

然后經過第三步解析開始標簽閉合部分,並且生成了一個 AST 節點,最終的變量狀態如下:

match = {
    tagName: "div",
    attrs: [
        {
            "name": "id",
            "value": "index"
        },
        {
            "name": "class",
            "value": "content"
        }
    ],
    start: 0,
    end: 32,
    unarySlash: "",
}

root = element
stack = [element]
currentParent = element
 

此時 html 經過 advance 已經變成了空字符串,解析完畢。

什么是棧?
類似於數組,是一種常用的線性表數據結構,可以使用數組輕松地實現。后進先出的操作方式特別適合 html 標簽的這種嵌套語法結構。

解析結束標簽

解析結束標簽的關鍵點是找到與之對應的開始標簽。

先看方法定義:

function parseEndTag () {
    const end = html.match(endTag);
    if (end) {
        advance(end[0].length)

        let tagName = end[1], lowerCasedTagName = tagName.toLowerCase()
        let pos

        // 從棧頂往棧底找,直到找到棧中離的最近的同類型標簽
        for (pos = stack.length - 1; pos >= 0; pos--) {
            if (stack[pos].lowerCasedTag === lowerCasedTagName) {
                break
            }
        }

        // 如果找到了就取出對應的開始標簽
        if (pos >= 0) {
            stack.length = pos
            currentParent = stack[stack.length - 1]
        }
    }
}
 

可以看到,在解析結束標簽時,會去找棧中離的最近的同類型標簽。在找到后會取出找到的節點並更新 currentParent 指向,也就是說假設 stack 現在為 ['div', 'p', 'a'],經過 parseEndTag 之后可能就會變成 ['div', 'p']currentParent 也從指向 a 變成了指向棧頂的 p

解析文本

文本為什么需要解析?別忘了,Vue 是支持在文本中插值的,即 <div>hello, {{msg}}</div>{{msg}}。文本解析就是解析這些混在文本中的表達式。

建議先了解一下正則式的 exec 方法,本段代碼在遍歷時使用了它,注意它與字符串的 match 方法不同。

先看方法定義:

const defaultTagRE = /\{\{((?:.|\n)+?)\}\}/g

function parseText(text){
    if (defaultTagRE.test(text)) {
        // tokens 用於分割普通文本和插值文本
        const tokens = []
        let lastIndex = defaultTagRE.lastIndex = 0
        let match, index
        while ((match = defaultTagRE.exec(text))) {
            index = match.index

            // push 普通文本
            if (index > lastIndex) {
                tokens.push(JSON.stringify(text.slice(lastIndex, index)))
            }
            // push 插值表達式
            tokens.push(`_s(${match[1].trim()})`)

            // 游標前移
            lastIndex = index + match[0].length
        }

        // 將剩余的普通文本壓入 tokens 中
        if (lastIndex < text.length) {
            tokens.push(JSON.stringify(text.slice(lastIndex)))
        }

        // 生成 ASTExpression 節點
        currentParent.children.push({
            type: 2,
            expression: tokens.join('+'),
            text
        })
    }else{
        // 生成 ASTText 節點
        currentParent.children.push({
            type: 3,
            text
        });
    }
}
 

可以看到,並沒有什么特別的地方,只是遍歷傳入的字符串並將所有插值摘出來。例如 hello, {{msg}} 會被分割成 ['"hello"', '_s(msg)'],注意普通文本是被 JSON.stringify 了的,這樣在后面 tokens.join('+') 時才會變成 "hello"+_s(msg) 這種所期望的格式,也就是最簡單的字符串和變量拼接。

文本通常就是葉子節點了,因此文本和表達式的節點定義(ASTText和ASTExpression)中並沒有 parentchildren 字段。

解析整塊 HTML 模板

終於到最后了,這是咱這幾年寫過的最長文章了o(╥﹏╥)o

html 文檔的結構基本上就是 <tag>text</tag> 這類標簽的各種嵌套,套來套去套出一個頁面。上面解析各部分(開始標簽、結束標簽、文本)的方法都已經有了,接下來就是使用上面的方法將整塊 html 模板一層一層剝開,從而構建出整棵 AST。

先看方法定義:

let html

function parseHTML(_html){
    html = _html

    while (html) {
        let textEnd = html.indexOf('<')
        if (textEnd === 0) {

            //-- 匹配開始標簽 --
            const startTagMatch = html.match(startTagOpen)
            if (startTagMatch) {
                parseStartTag()
                continue
            }

            //-- 匹配結束標簽 --
            const endTagMatch = html.match(endTag)
            if (endTagMatch) {
                parseEndTag()
                continue
            }
        }

        //-- 匹配文本 --
        let text, rest
        if (textEnd >= 0) {
            rest = html.slice(textEnd)
            text = html.substring(0, textEnd)
            advance(textEnd)
        }
        if (textEnd < 0) {
            text = html
            html = ''
        }
        text && parseText(text)
    }

    return root
}
 

可以看到,parseHTML 是循環一截一截把整塊 html 蠶食掉的。返回值 root 就是對生成的 AST 的引用,其實就是一個被精心組織的 JSON 對象,上篇已經提到,使用 JSON 描述樹形結構具有天然優勢。

現在看看忙活了半天的成果:

let tpl = `<div id="index"><p>hello, {{msg}}</p> by DOM哥</div>`
console.info(parseHTML(tpl))
 

控制台輸出截圖如下:

parseHTML 執行結果

Vue 解析 HTML 的主流程基本上就是這樣,由於是基於 HTML,還是比較簡單的。

戳這兒查看本文的完整代碼

未提及的細節

Vue 的實際實現做了大量的兼容性處理,有針對某些瀏覽器(IE:看我干什么)的,也有針對 HTML 標簽的,比如 <p> 標簽既可以有結束標簽,也可以沒有結束標簽,因此需要特殊處理。另外還要考慮注釋的解析,特殊 html 標簽如 Doctype 的處理。總之需要考慮的地方很多,因此實際實現比上面要復雜的多,但處理的思路基本上是一樣的。

Vue 代碼分割的很嚴重,因此上面的實現代碼不可能全部集成在一個文件里,而是分成了好幾個小模塊,比如生成 AST 節點的模塊是抽出來的,處理文本的模塊也是單獨抽出來的。

如果想要錙銖必較地咀嚼每一行代碼,這是非常困難的,而且寸步難行,甚至最后會半途而廢。研究源碼最主要的是去學習其中的思路,而不要糾結在一字一句。

還記得 Vue 編譯器編譯成 render 函數的 3 個步驟嗎,生成 AST,優化 AST,生成 render 函數。本篇暫告一段落,將在下篇繼續研究 Vue 是如何優化 AST 的以及如何根據 AST 生成 render 函數。

大白話 Vue 源碼系列目錄

本系列會以每周一篇的速度持續更新,喜歡的小伙伴記得點關注哦。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM