手把手教你寫一個AST

  • 2019 年 12 月 16 日
  • 筆記

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

logo

如何解析成 AST ?

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

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

目標

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

<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>

這個字元串用正則描述大致如下:

以 < 開頭 跟著 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 來描述任何字元(1、通用,2、規則簡單,利於正則匹配):

`<${ncname}>[sS]*</${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"'=<>`]+)))*>[sS]*</[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"'=<>`]+)))*>[sS]*</[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/blob/dev/src/compiler/parser/html-parser.js

[2]

easy-ast: https://github.com/antiter/blogs/tree/master/code-mark/easy-ast.js