Pure-JavaScript-HTML5-Parser源碼解讀

有個需求要用到html標籤解析,又碰巧之前有人寫過,就直接用了之前用的東西https://github.com/blowsie/Pure-JavaScript-HTML5-Parser,git上星不多,不過感覺思路比較特別,和我最開始想的不太一樣,稍微看了看原理,總結一下。因爲沒有release版本,只能寫一個commit版本號,3e8b2b1153a40495f9a16506c778d00150c6b7a3,2015年9月21日,真是久遠,所以有些標籤的定義也和現在不太一樣了,大概。

PJHP(簡寫)提供了三個函數,HTMLParser,HTMLtoXML,HTMLtoDOM,其中後面兩個是基於第一個的,也就是HTMLParser的用法之一,那麼我們來看HTMLParser

先看官方例子

var results = "";

HTMLParser("<p id=test>hello <i>world", {
  start: function( tag, attrs, unary ) {
    results += "<" + tag;
 
    for ( var i = 0; i < attrs.length; i++ )
      results += " " + attrs[i].name + '="' + attrs[i].escaped + '"';
 
    results += ">";
  },
  end: function( tag ) {
    results += "</" + tag + ">";
  },
  chars: function( text ) {
    results += text;
  },
  comment: function( text ) {
    results += "<!--" + text + "-->";
  }
});

執行完後results爲<p>hello <i>world</i></p>,去掉第二個參數裏的一些屬性也是可以照樣工作的,當然全部去掉就沒有了。代碼一共不到兩百行。

代碼的大體結構:先定義了三個正則表達式,startTag、endTag、attr,還有一堆的標籤和屬性作爲全局使用。
在HTMLParser函數體裏,有一個大的while循環、兩重的if判斷和一個parseStartTag函數和一個parseEndTag函數,沒了,比較簡潔。

整個過程中我們要克服的困難主要有:

  1. 起始標籤和結束標籤不一致,或者只有起始標籤沒有結束標籤
  2. 標籤的嵌套,並且標籤中還可能有文本,塊級標籤和行級標籤的嵌套順序
  3. 其他問題

首先來看if (html.indexOf("<!--") == 0)這一級(內層)的幾個判斷,先通過<!--</<分別來判斷註釋、結束標籤和起始標籤,如果都沒有匹配到,進入if (chars)這個判斷裏去,if (chars)的大括號裏有兩種實現,有點意思,來看看

一種是這樣的

index = html.indexOf("<");

var text = index < 0 ? html : html.substring(0, index);
html = index < 0 ? "" : html.substring(index);

if (handler.chars) handler.chars(text);

另一種是這樣的

index = html.indexOf("<");
let text = "";
while (index === 0) {
    text += "<";
    html = html.substring(1);
    index = html.indexOf("<");
}

text += index < 0 ? html : html.substring(0, index);
html = index < 0 ? "" : html.substring(index);

if (handler.chars) handler.chars(text);

這兩種的區別在於第二種多了個while循環,就這麼看這個代碼可能看不出具體是幹嘛用的,但是如果用<p><<asd</p>作爲輸入參數就會發現,第一種會解析出錯,因爲解析到<<asd這裏的時候,變量html沒有被裁剪,導致下面有一句if (html == last)生效了,拋出瞭解析錯誤(這裏可以看到只有當一次循環下來,html沒有變化時,纔會報解析出錯)。第二種的while循環就是爲了應對這種情況。

至此註釋和文本的兩個判斷裏的內容已經沒啥可看的了,來看看起始標籤和結束標籤的兩個判斷。

起始標籤

match = html.match(startTag);

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

先正則匹配startTag起始標籤,如果匹配到了,通過startTag裏的捕獲組把捕獲到的標籤找出來,從html變量裏去掉,然後通過replace函數來執行parseStartTag,這裏用replace是爲了組裝parseStartTag的輸入參數,不起到替換作用。

parseStartTag函數的四個參數分別是tag:標籤, tagName:標籤名,rest:屬性,unary:是否單個標籤,對於輸入<p>hello <i>world</i></p>來說,第一次執行parseStartTag時四個參數是

tag: <p>
tagName: p
rest: ''
unary: ''

參數的形式還是和startTag的寫法有關,如果輸入是<img src='123' />hello <i>world</i>,第一次執行parseStartTag時四個參數是

tag: <img src='123' />
tagName: img
rest: ' src="123"'
unary: '/'

現在我們來看下parseStartTag幹了什麼

HTMLParser函數裏唯一的一句stack.push就在parseStartTag函數裏,當標籤是成對出現的標籤(比如div,p這種)時,會執行stack.push,自閉合標籤(代碼裏寫的是empty,直譯是空標籤,代碼裏定義的closeSelf,自閉合標籤是colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr這些,並且代碼裏inline和block也有些和我所知的不一樣),比如img,或者<div />這種形式,不會執行。

如果我們用<img src='123' alt="picture" />作爲輸入參數,會執行到rest.replace(attr, function (match, name)這句裏去,裏面是處理標籤的屬性的,按照當前的輸入,function第一次執行的參數如下

0: "src="123""
1: "src"
2: "123"
3: undefined
4: undefined
5: 1
6:  src="123" alt="picture"

然後所有屬性會保存到attrs這個變量中去,可惜這一句目前我還沒看懂value.replace(/(^|[^\\])"/g, '$1\\\"'),parseStartTag裏還有些代碼要和parseEndTag一起看,所以我們來看下parseEndTag。

parseEndTag出現在這麼幾個地方,1.結束標籤判斷裏,2.外層的else裏,3.大while循環結束的地方,4.parseStartTag函數裏的兩個if裏。出現次數很多。

我們用<div><img src="123" alt="picture" /></div>作爲輸入來看一看,首先會進入結束標籤的判斷else if (html.indexOf("</") == 0)裏調用的那個parseEndTag,參數是標籤和標籤名,此時div在起始標籤裏push過一次stack,所以會執行else和第二個if,但是看不出什麼端倪。

我們把輸入改成<div><p><img src="123" alt="picture" /></p></div><div></a>再來看看,這時先進入parseEndTag的是</p>,而stack裏已有的是一個div和一個p,在else中找到了和結束標籤</p>一致的起始標籤<p>,並標記了位置,在第二個if中將已經匹配的起始標籤通過stack.length = pos;給去除。後面</div>進入parseEndTag,情況相同,但是</a>進入parseEndTag時,在else中匹配不到對應的標籤,並且沒有進入第二個if,直到while結束位置的parseEndTag,此時第二個if通過stack把先前沒有匹配到的<div>還原出來,所以我們最開始輸入的<p id=test>hello <i>world會輸出<p>hello <i>world</i></p>

接下來我們把輸入改成<span><div>lalala</div></span>來看看。當執行到<div>時,在parseStartTag中會進入第一個if,看下這個if的代碼可以看出其實是要處理行級標籤包裹了塊級標籤的情形,處理方式是通過parseEndTag閉合行級標籤,這裏看似stack.last()取的不對,因爲可能不是最靠近當前的塊級標籤的那個標籤是行級,但是再往前推一定會有一個行級標籤和塊級標籤相鄰。

if (block[tagName]) {
    while (stack.last() && inline[stack.last()]) {
        parseEndTag("", stack.last());
    }
}

第二個if的邏輯有點奇怪,並且關於瀏覽器會自動關閉的單個標籤已經不止代碼裏定義的那幾種了,所以第二個if就不講了。

接下來我們把輸入改成<head><style>.main{color:red;}</style></head>來看看,可以看到代碼運行到了外層的else中去,然後通過html.replace(new RegExp("([\\s\\S]*?)<\/" + stack.last() + "[^>]*>")來匹配對應的</style>標籤,然後通過parseEndTag來將style閉合。這裏代碼處理的不好的地方是如果只出現了起始的style,而沒有出現閉合標籤,因爲匹配不到結束標籤,解析會出錯,我目前能想到的也就只有去匹配一個<作爲結束,但是這樣也不能完美解決問題。

這樣parseEndTag出現的四種情形我們就都說完了,HTMLParser的代碼也全部說完了。

總結下代碼的主要邏輯:

  1. 傳入的參數作爲字符串,存在html這個變量裏,從頭到尾順序往下匹配,匹配到一種類型(起始標籤,結束標籤,註釋,文本)就把html截斷到不屬於這個類型的範圍,一直截到html爲空結束

  2. 起始的標籤會push到一個stack變量中去,作爲後面需要查詢前面標籤的保存

  3. 結束標籤如果和起始標籤不一致是會被丟棄的,起始標籤作爲基準,並且在parseEndTag中通過用戶提供的處理函數處理結束標籤

  4. 循環結束後還有一波收尾工作,閉合沒有閉合的標籤

  5. 遇到style標籤和script標籤有單獨的處理邏輯,看着像是另一個作者寫的

總結一下PJHP的優缺點,優點:短小精悍,缺點:可讀性較差(其實還好),正則匹配較複雜,可能性能不好(下次再測)

還有一些算不上缺陷也不能算bug的東西:

  1. 對於單獨的結束標籤會直接丟棄,單獨的起始標籤會按先進後出的方式還原,但是如果是類似head和body一起出現的情況就坑了
  2. 只能處理成對出現的<style><script>,單個出現會報錯
  3. 對於doctype什麼的完全沒處理,不過doctype不算html標籤就是了

要注意的是,如果想要把script、style等標籤裏的內容給去掉,很麻煩,只能在解析開始前把不需要的標籤都去掉。如果要解析成dom樹的樣子,可以結合這個https://github.com/Jxck/html2json。

下次有空的時候,會和其他的工具庫做個比較,比如htmlparser2

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章