手把手教你寫一個 AST 抽象語法樹

AST 解析器工做中常常用到,Vue.js 中的 VNode 就是如此!
其實若是有須要將 非結構化數據轉 換成 結構化對象用 來分析、處理、渲染的場景,咱們均可以用此思想作轉換。html

圖片

logovue

如何解析成 AST ?

咱們知道 HTML 源碼只是一個文本數據,儘管它裏面包含複雜的含義和嵌套節點邏輯,可是對於瀏覽器,Babel 或者 Vue 來講,輸入的就是一個長字符串,顯然,純粹的一個字符串是表示不出來啥含義,那麼就須要轉換成結構化的數據,可以清晰的表達每一節點是幹嗎的。字符串的處理,天然而然就是強大的正則表達式了。node

本文闡述 AST 解析器的實現方法和主要細節,簡單易懂~~~~,總共解析器代碼不過百行!git

目標

本次目標,一步一步將以下 HTML 結構文檔轉換成 AST 抽象語法樹github

<div class="classAttr" data-type="dataType" data-id="dataId" style="color:red">我是外層div
    <span>我是內層span</span>
</div>

結構比較簡單,外層一個 div,內層嵌套一個 span,外層有 class,data,stye 等屬性。
麻雀雖小,五臟俱全,基本包含咱們常常用到的了。其中轉換後的 AST 結構 有哪些屬性,須要怎樣的形式顯示,均可以根據須要本身定義便可。
本次轉換後的結構:正則表達式

{
    "node": "root",
    "child": [{
        "node": "element",
        "tag": "div",
        "class": "classAttr",
        "dataset": {
            "type": "dataType",
            "id": "dataId"
        },
        "attrs": [{
            "name": "style",
            "value": "color:red"
        }],
        "child": [{
            "node": "text",
            "text": "我是外層div"
        }, {
            "node": "element",
            "tag": "span",
            "dataset": {},
            "attrs": [],
            "child": [{
                "node": "text",
                "text": "我是內層span"
            }]
        }]
    }]
}

不難發現,外層是根節點,而後內層用 child 一層一層標記子節點,有 attr 標記節點的屬性,classStr 來標記 class 屬性,data 來標記 data- 屬性,type 來標記節點類型,好比自定義的 data-type="title" 等。數組

回顧正則表達式

先來看幾組簡單的正則表達式:瀏覽器

  • ^ 匹配一個輸入或一行的開頭,/^a/匹配"ab",而不匹配"ba"
  • 匹配一個輸入或一行的結尾,/匹配"ba",而不匹配"ab"
  • 匹配前面元字符 0 次或屢次,/ab*/將匹配 a,ab,abb,abbb
  • 匹配前面元字符 1 次或屢次,/ab+/將匹配 ab,abb,可是不匹配 a
  • [ab] 字符集匹配,匹配這個集合中的任一一個字符(或元字符),/[ab]/將匹配 a,b,ab
  • \w 組成單詞匹配,匹配字母,數字,下劃線,等於[a-zA-Z0-9]

匹配標籤元素

首先咱們將以下的 HTML 字符串用正則表達式表示出來:測試

<div>我是一個div</div>

這個字符串用正則描述大體以下:spa

以 < 開頭 跟着 div 字符,而後接着 > ,而後是中文 「我是一個 div」,再跟着 </ ,而後繼續是元素 div 最後已 > 結尾。

div 是 HTML 的標籤,咱們知道 HTML 標籤是已字母和下劃線開頭,包含字母、數字、下滑線、中劃線、點號組成的,對應正則以下:

const ncname = '[a-zA-Z_][\w-.]*'

因而組合的正則表達式以下:

`<${ncname}>`
  1. 根據上面分析,很容易得出正則表達式爲下:
`<${ncname}></${ncname}>`
  1. 我是一個div

標籤內能夠是任意字符,那麼任意字符如何描述呢?
\s 匹配一個空白字符 \S 匹配一個非空白字符 \w 是字母數字數字下劃線
\W 是非\w 的
同理還有\d 和\D 等。
咱們一般採用\s 和\S 來描述任何字符(一、通用,二、規則簡單,利於正則匹配):

`<${ncname}>[\s\S]*</${ncname}>`

匹配標籤屬性

HTML 標籤上的屬性名稱有哪些呢,常見的有 class,id,style,data-屬性,固然也能夠用戶隨便定義。可是屬性名稱咱們也須要遵循原則,一般是用字母、下劃線、冒號開頭(Vue 的綁定屬性用:開頭,一般咱們不會這麼定義)的,而後包含字母數字下劃線中劃線冒號和點的。正則描述以下:

const attrKey = /[a-zA-Z_:][-a-zA-Z0-9_:.]*/

HTML 的屬性的寫法目前有如下幾種:

  1. class="title"
  2. class='title'
  3. class=title
const attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)=("([^"]*)"|'([^']*)'|([^\s"'=<>`]+)/

attrKey 跟着 = ,而後跟着三種狀況:

  1. 」 開頭 跟着多個不是 " 的字符,而後跟着 」 結尾
  2. ' 開頭 跟着多個不是 ‘ 的字符,而後跟着 ' 結尾
  3. 不是(空格,」,’,=,<,>)的多個字符

咱們測試一下 attr 的正則

"class=abc".match(attr);
// output
(6) ["class=abc", "class", "abc", undefined, undefined, "abc", index: 0, input: "class=abc", groups: undefined]

"class='abc'".match(attr);
// output
(6) ["class='abc'", "class", "'abc'", undefined, "abc", undefined, index: 0, input: "class='abc'", groups: undefined]

咱們發現,第二個帶單引號的,匹配的結果是"‘abc’",多了一個單引號‘,所以咱們須要用到正則裏面的非匹配獲取(?:)了。
例子:

"abcde".match(/a(?:b)c(.*)/);   輸出 ["abcde", "de", index: 0, input: "abcde"]

這裏匹配到了 b,可是在 output 的結果裏面並無 b 字符。
場景:正則須要匹配到存在 b,可是輸出結果中不須要有該匹配的字符。
因而我麼增長空格和非匹配獲取的屬性匹配表達式以下:

const attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/

\= 兩邊能夠增長零或多個空格,= 號右邊的匹配括號使用非匹配獲取,那麼相似 = 號右側的最外層大括號的獲取匹配失效,而內層的括號獲取匹配的是在雙引號和單引號裏面。效果以下:

圖片

從圖中咱們清晰看到,匹配的結果的數組的第二位是屬性名稱,第三位若是有值就是雙引號的,第四位若是有值就是單引號的,第五位若是有值就是沒有引號的。

匹配節點

有了上面的標籤匹配和屬性匹配以後,那麼將二者合起來就是以下:

/<[a-zA-Z_][\w\-\.]*(?:\s+([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))*>[\s\S]*<\/[a-zA-Z_][\w\-\.]*>/

上述正則完整描述了一個節點,理解了簽名的描述,如今看起來是否是很簡答啦~

AST 解析實戰

有了前面的 HTML 節點的正則表達式的基礎,咱們如今開始解析上面的節點元素。
顯然,HTML 節點擁有複雜的多層次的嵌套,咱們沒法用一個正則表達式就把 HTML 的結構都一次性的表述出來,所以咱們須要一段一段處理。
咱們將字符串分段處理,總共分紅三段:

  1. 標籤的起始
  2. 標籤內的內容
  3. 標籤的結束

因而將上述正則拆分:

const DOM = /<[a-zA-Z_][\w\-\.]*(?:\s+([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))*>[\s\S]*<\/[a-zA-Z_][\w\-\.]*>/;
// 增長()分組輸出
const startTag = /<([a-zA-Z_][\w\-\.]*)((?:\s+([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))*)\s*(\/?)>/;

const endTag = /<\/([a-zA-Z_][\w\-\.]*)>/;

const attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g

// 其餘的就是標籤裏面的內容了

不難發現,標籤已 < 開頭,爲標籤起始標識位置,已 </ 開頭的爲標籤結束標識位置。
咱們將 HTML 拼接成字符串形式,就是以下了。

let html = '<div class="classAttr" data-type="dataType" data-id="dataId" style="color:red">我是外層div<span>我是內層span</span></div>';

咱們開始一段一段處理上面的 html 字符串吧~

const bufArray = [];
const results = {
    node: 'root',
    child: [],
};
let chars;
let match;
while (html&&last!=html){
    last = html;
    chars = true;// 是否是文本內容
    // do something parse html
}

bufArray: 用了存儲未匹配完成的起始標籤
results: 定義一個開始的 AST 的節點。
咱們再循環處理 HTML 的時候,若是已經處理的字符,則將其刪除,這裏判斷 last!=html 若是處理一輪以後,html 仍是等於 last,說明沒有須要處理的了,結束循環。

首先判斷是不是 </ 開頭,若是是則說明是標籤結尾標識

if(html.indexOf("</")==0){
    match = html.match(endTag);
    if(match){
        chars = false;
        html = html.substring(match[0].length);
        match[0].replace(endTag, parseEndTag);
    }
}

已 </ 開頭,且能匹配上實時截止標籤的正則,則該 html 字符串內容要向後移動匹配到的長度,繼續匹配剩下的。
這裏使用了 replace 方法,parseEndTag 的參數就是"()"匹配的輸出結果了,已經匹配到的字符再 parseEndTag 處理標籤。

若是不是已 </ 開頭的,則判斷是不是 < 開頭的,若是是說明是標籤起始標識,同理,須要 substring 來剔除已經處理過的字符。

else if(html.indexOf("<")==0){
    match = html.match(startTag);
    if(match){
        chars = false;
        html = html.substring(match[0].length);
        match[0].replace(startTag, parseStartTag);
    }
}

若是既不是起始標籤,也不是截止標籤,或者是不符合起始和截止標籤的正則,咱們統一當文本內容處理。

if(chars){
    let index = html.indexOf('<');
    let text;
    if(index < 0){
        text = html;
        html = '';
    }else{
        text = html.substring(0,index);
        html = html.substring(index);;
    }
    const node = {
        node: 'text',
        text,
    };
    pushChild(node);
}

若是是文本節點,咱們則加入文本節點到目標 AST 上,咱們着手 pushChild 方法,bufArray 是匹配起始和截止標籤的臨時數組,存放尚未找到截止標籤的起始標籤內容。

function pushChild (node) {
    if (bufArray.length === 0) {
        results.child.push(node);
    } else {
        const parent = bufArray[bufArray.length - 1];
        if (typeof parent.child == 'undefined') {
            parent.child = [];
        }
        parent.child.push(node);
    }
}

若是沒有 bufArray ,說明當前 Node 是一個新 Node,不是上一個節點的嵌套子節點,則新 push 一個節點;不然 取最後一個 bufArray 的值,也就是最近的一個未匹配標籤起始節點,將當前節點當作爲最近節點的子節點。

<div><div></div></div>

顯然,第一個 </div> 截止節點,匹配這裏的第二個起始節點

,即最後一個未匹配的節點。

在每一輪循環中,若是是符合預期,HTML 字符串會愈來愈少,直到被處理完成。

接下來咱們來處理 parseStartTag 方法,也是稍微複雜一點的方法。

function parseStartTag (tag, tagName, rest) {
    tagName = tagName.toLowerCase();

    const ds = {};
    const attrs = [];
    let unary = !!arguments[7];

    const node = {
        node: 'element',
        tag:tagName
    };
    rest.replace(attr, function (match, name) {
        const value = arguments[2] ? arguments[2] :
            arguments[3] ? arguments[3] :
                arguments[4] ? arguments[4] :'';
        if(name&&name.indexOf('data-')==0){
            ds[name.replace('data-',"")] = value;
        }else{
            if(name=='class'){
                node.class = value;
            }else{
                attrs.push({
                    name,
                    value
                });
            }
        }
    });
    node.dataset = ds;
    node.attrs = attrs;
    if (!unary){
         bufArray.push(node);
    }else{
        pushChild(node);
    }
}

遇到起始標籤,若是該起始標籤不是一個結束標籤(unary 爲 true,如:,若是自己是截止標籤,那麼直接處理完便可),則將起始標籤入棧,等待找到下一個匹配的截止標籤。
起始標籤除了標籤名稱外的屬性內容,咱們將 dataset 內容放在 dataset 字段,其餘屬性放在 attrs

咱們接下來看下處理截止標籤

function parseEndTag (tag, tagName) {
    let pos = 0;
    for (pos = bufArray.length - 1; pos >= 0; pos--){
        if (bufArray[pos].tag == tagName){
            break;
        }
    }
    if (pos >= 0) {
        pushChild(bufArray.pop());
    }
}

記錄還未匹配到的起始標籤的 bufArray 數組,從最後的數組位置開始查找,找到最近匹配的標籤。
好比:

<div class="One"><div class="Two"></div></div>

class One 的標籤先入棧,class Two 的再入棧,而後遇到第一個</div>,匹配的則是 class Two 的起始標籤,而後再匹配的是 class One 的起始標籤。

到此,一個簡單的 AST 解析器已經完成了。

固然,本文是實現一個簡單的 AST 解析器,基本主邏輯已經包含,完整版參考以下:

完整解析參考:vue-html-parse[1]

本文的 AST 解析器的完整代碼以下:

easy-ast[2]

參考資料

[1]

完整解析參考:vue-html-parse: https://github.com/vuejs/vue/...

[2]

easy-ast: https://github.com/antiter/bl...