AST 解析器工做中常常用到,Vue.js 中的 VNode 就是如此!
其實若是有須要將 非結構化數據轉 換成 結構化對象用 來分析、處理、渲染的場景,咱們均可以用此思想作轉換。html
logovue
咱們知道 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" 等。數組
先來看幾組簡單的正則表達式:瀏覽器
首先咱們將以下的 HTML 字符串用正則表達式表示出來:測試
<div>我是一個div</div>
這個字符串用正則描述大體以下:spa
以 < 開頭 跟着 div 字符,而後接着 > ,而後是中文 「我是一個 div」,再跟着 </ ,而後繼續是元素 div 最後已 > 結尾。
div 是 HTML 的標籤,咱們知道 HTML 標籤是已字母和下劃線開頭,包含字母、數字、下滑線、中劃線、點號組成的,對應正則以下:
const ncname = '[a-zA-Z_][\w-.]*'
因而組合的正則表達式以下:
`<${ncname}>`
`<${ncname}></${ncname}>`
標籤內能夠是任意字符,那麼任意字符如何描述呢?
\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 的屬性的寫法目前有如下幾種:
const attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)=("([^"]*)"|'([^']*)'|([^\s"'=<>`]+)/
attrKey 跟着 = ,而後跟着三種狀況:
咱們測試一下 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\-\.]*>/
上述正則完整描述了一個節點,理解了簽名的描述,如今看起來是否是很簡答啦~
有了前面的 HTML 節點的正則表達式的基礎,咱們如今開始解析上面的節點元素。
顯然,HTML 節點擁有複雜的多層次的嵌套,咱們沒法用一個正則表達式就把 HTML 的結構都一次性的表述出來,所以咱們須要一段一段處理。
咱們將字符串分段處理,總共分紅三段:
因而將上述正則拆分:
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...