AST 解析器工做中常常用到,vue中的VNode就是如此!
其實若是有須要將 非結構化數據轉 換成 結構化對象用 來分析、處理、渲染的場景,咱們均可以用此思想作轉換。 html
咱們知道 html 源碼只是一個文本數據,儘管它裏面包含複雜的含義和嵌套節點邏輯,可是對於瀏覽器,babel 或者 vue 來講,輸入的就是一個長字符串,顯然,純粹的一個字符串是表示不出來啥含義,那麼就須要轉換成結構化的數據,可以清晰的表達每一節點是幹嗎的。字符串的處理,天然而然就是強大的正則表達式了。vue
本文闡述 AST 解析器的實現方法和主要細節,簡單易懂~~~~~~~~,總共解析器代碼不過百行!node
本次目標,一步一步將以下 html 結構文檔轉換成 AST 抽象語法樹git
<div class="classAttr" data-type="dataType" data-id="dataId" style="color:red">我是外層div
<span>我是內層span</span>
</div>
複製代碼
結構比較簡單,外層一個div,內層嵌套一個span,外層有class,data,stye等屬性。
麻雀雖小,五臟俱全,基本包含咱們常常用到的了。其中轉換後的 AST 結構 有哪些屬性,須要怎樣的形式顯示,均可以根據須要本身定義便可。
本次轉換後的結構:github
{
"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>
複製代碼
這個字符串用正則描述大體以下:微信
以 < 開頭 跟着 div 字符,而後接着 > ,而後是中文 「我是一個 div」,再跟着 </ ,而後繼續是元素 div 最後已 > 結尾。babel
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解析器,基本主邏輯已經包含,完整版參考以下:
本文的 AST解析器的完整代碼以下:
若是你以爲這篇內容對你有價值,請點贊,並關注咱們的官網和咱們的微信公衆號(WecTeam),每週都有優質文章推送: