HTMLParser 的實(shí)現(xiàn)和使用

1. 概覽

HTMLParser 在很多地方都有它施展拳腳的地方, 例如在 Vue 中, Vue 把 template 模板字符串編譯成 render 函數(shù)的過程就用到了 HTMLParser.
要注意的一點(diǎn)是, HTMLParser 做的工作是解析字符串并把參數(shù)傳遞給回調(diào)函數(shù), 后續(xù)要執(zhí)行什么操作全部依賴于傳入的options中的start end comment chars等鉤子函數(shù)

1.1 一般的簡單 parser 邏輯

  • 初始時(shí)若發(fā)現(xiàn)第一個(gè) tagName 是 script 或 style, 則先做特殊處理. 原則上這種情況不應(yīng)該出現(xiàn)
  • while(html) 循環(huán)判斷當(dāng)前處理的字符串是否為空
    • 若當(dāng)前字符串以< !--開頭, 則進(jìn)入注釋處理邏輯, 調(diào)用 comment 鉤子
    • 若當(dāng)前字符以< /開頭, 則進(jìn)入endTag處理邏輯, 進(jìn)入內(nèi)部的 parseEndTag 函數(shù)
      1. 檢查 stack && 關(guān)閉那些特殊閉合方式的 tagName , 例如<input> 和 <th> <tr>
      2. 若 tagName 不是自閉和節(jié)點(diǎn)則入棧
      3. 把 attrs 即id="app"的字符串replace 為{ name:'id', value:'app', escaped }的數(shù)據(jù)
      4. 調(diào)用 start(tagName, attrs, unary) 鉤子
    • 若當(dāng)前字符以<開頭, 則進(jìn)入startTag處理邏輯, 進(jìn)入內(nèi)部的 parseStartTag 函數(shù)
      parseStartTag 實(shí)際上做的工作就是: 找到合適的 tagName 出棧, 調(diào)用 end 鉤子
    • 其它情況則調(diào)用 chars 鉤子
  • 收尾工作再次調(diào)用 parseEndTag() 來檢查標(biāo)簽是否閉合

1.2 Vue 額外增加的內(nèi)容

  • 大量的正確性檢查&&驗(yàn)證
    • 根節(jié)點(diǎn)只能有一個(gè)
    • 根節(jié)點(diǎn)不能是<template>等
    • tagName 要閉合(通過 stack 來判斷)
  • v-for v-if v-pre v-once等特殊屬性的處理(增加到構(gòu)建的 AST 的屬性中)
  • {{text}}這類模板解析字符串的處理

參考 "Vue.js 技術(shù)揭秘 | parse"

2. 一個(gè)160行的 HTMLParser

該項(xiàng)目地址在Github blowsie, 約200個(gè)Star , 代碼中正則+HTMLParser 函數(shù)體約160行, 只看函數(shù)體的話約130行

2.1 所用到的正則表達(dá)式

//匹配 tag 的開頭(包含 attrs), 例如 '<div>' 或 '<span class="red">'
var startTag = /^<([-A-Za-z0-9_]+)((?:\s+[a-zA-Z_:@][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)>/
//匹配 tag 的結(jié)尾, 例如'</div>'
var endTag = /^<\/([-A-Za-z0-9_]+)[^>]*>/
//匹配 attr 內(nèi)容, 可以有等號, 等號后面可以是雙引號或單引號, 例如 :class="c" :msg='msg'
//ps:該簡單 parser 不支持es6的``字符串, 而 vue 的正則是支持的
var attr = /([a-zA-Z_:@][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:(?:"((?:\\.|[^"])*)")|(?:'((?:\\.|[^'])*)')|([^>\s]+)))?/g;
//HTML5中的可以不寫 endTag 的那些元素的集合, 例如 <input /> <link />
var empty = makeMap("area,base,basefont,br,col,frame,hr,img,input,link,meta,param,embed,command,keygen,source,track,wbr");
//普通塊狀 HTML5標(biāo)簽 tagName 的集合, 例如<div>
var block = makeMap("a,address,article,applet,aside,audio,blockquote,button,canvas,center,dd,del,dir,div,dl,dt,fieldset,figcaption,figure,footer,form,frameset,h1,h2,h3,h4,h5,h6,header,hgroup,hr,iframe,ins,isindex,li,map,menu,noframes,noscript,object,ol,output,p,pre,section,script,table,tbody,td,tfoot,th,thead,tr,ul,video");
    // Inline Elements - HTML 5
    var inline = makeMap("abbr,acronym,applet,b,basefont,bdo,big,br,button,cite,code,del,dfn,em,font,i,iframe,img,input,ins,kbd,label,map,object,q,s,samp,script,select,small,span,strike,strong,sub,sup,textarea,tt,u,var");
    // Elements that you can, intentionally, leave open
    // (and which close themselves)
    var closeSelf = makeMap("colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr");
    // Attributes that have their values filled in disabled="disabled"
    var fillAttrs = makeMap("checked,compact,declare,defer,disabled,ismap,multiple,nohref,noresize,noshade,nowrap,readonly,selected");
    // Special Elements (can contain anything)
    var special = makeMap("script,style");

//makeMap是為了方便取 key-value 對. 實(shí)際上數(shù)組的 indexOf 也不慢
function makeMap(str) {
        var obj = {}, items = str.split(",");
        for (var i = 0; i < items.length; i++)
            obj[items[i]] = true;
        return obj;
}

2.2 函數(shù)體

var HTMLParser = this.HTMLParser = function (html, handler) {
    var index, chars, match, stack = [], last = html;
    stack.last = function () {
        return this[this.length - 1];
    };

    while (html) {
        chars = true;

        // Make sure we're not in a script or style element
        if (!stack.last() || !special[stack.last()]) {

            // Comment
            if (html.indexOf("<!--") == 0) {
                index = html.indexOf("-->");

                if (index >= 0) {
                    if (handler.comment)
                        handler.comment(html.substring(4, index));
                    html = html.substring(index + 3);
                    chars = false;
                }

                // end tag
            } else if (html.indexOf("</") == 0) {
                match = html.match(endTag);

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

                // start tag
            } else if (html.indexOf("<") == 0) {
                match = html.match(startTag);

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

            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);
            }

        } else {
            html = html.replace(new RegExp("([\\s\\S]*?)<\/" + stack.last() + "[^>]*>"), function (all, text) {
                text = text.replace(/<!--([\s\S]*?)-->|<!\[CDATA\[([\s\S]*?)]]>/g, "$1$2");
                if (handler.chars)
                    handler.chars(text);

                return "";
            });

            parseEndTag("", stack.last());
        }

        if (html == last)
            throw "Parse Error: " + html;
        last = html;
    }

    // Clean up any remaining tags
    parseEndTag();

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

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

        if (closeSelf[tagName] && stack.last() == tagName) {
            parseEndTag("", tagName);
        }

        unary = empty[tagName] || !!unary;

        if (!unary)
            stack.push(tagName);

        if (handler.start) {
            var attrs = [];

            rest.replace(attr, function (match, name) {
                var value = arguments[2] ? arguments[2] :
                    arguments[3] ? arguments[3] :
                        arguments[4] ? arguments[4] :
                            fillAttrs[name] ? name : "";

                attrs.push({
                    name: name,
                    value: value,
                    escaped: value.replace(/(^|[^\\])"/g, '$1\\\"') //"
                });
            });

            if (handler.start)
                handler.start(tagName, attrs, unary);
        }
    }

    function parseEndTag(tag, tagName) {
        // If no tag name is provided, clean shop
        if (!tagName)
            var pos = 0;

        // Find the closest opened tag of the same type
        else
            for (var pos = stack.length - 1; pos >= 0; pos--)
                if (stack[pos] == tagName)
                    break;

        if (pos >= 0) {
            // Close all the open elements, up the stack
            for (var i = stack.length - 1; i >= pos; i--)
                if (handler.end)
                    handler.end(stack[i]);

            // Remove the open elements from the stack
            stack.length = pos;
        }
    }
};

2.3 查看函數(shù)執(zhí)行結(jié)果

可用如下代碼進(jìn)行測試鉤子函數(shù)的返回值. ps, 原生的 HTMLParser 是不能識別 vue 的@符號的, 簡單修改attrstarTag的正則表達(dá)式即可

<body>
    <div id="app">
        <ul :class="bindCls" class="list" v-if="isShow">
            <li v-for="(item,index) in data" @click="clickItem(index)">{{item}}:{{index}}</li>
        </ul>
    </div>
</body>
<script src="./html-parser.js"></script>
<script>
    window.HTMLParser(document.querySelector('#app').outerHTML,(function(){
        var obj = {};
        ['start','end','comment','chars'].forEach(x=>{
            obj[x] = function (...args){
                console.log(x,args)
            }
        })
        return obj
    })())
</script>
瀏覽器輸出結(jié)果

3. 使用 HTMLParser 來構(gòu)建目標(biāo)數(shù)據(jù)結(jié)構(gòu)

3.1 以構(gòu)建 XML 為例來展示 options 配置

function HTMLtoXML(html) {
    var results = "";
    HTMLParser(html, {
        start: function (tag, attrs, unary) {
            results += "<" + tag;
            for (var i = 0; i < attrs.length; i++)
                results += " " + attrs[i].name + '="' + attrs[i].escaped + '"';
            results += ">";
        },
        end: tag => results += "</" + tag + ">",
        chars:  text => results += text, 
        comment: text => results += "<!--" + text + "-->"
    });
    return results;
}
HTMLtoXML測試結(jié)果

3.2 使用 HTMLParser 構(gòu)建 AST

最簡單的版本, 不考慮標(biāo)簽不閉合帶來的錯(cuò)誤, 同時(shí)保留空白節(jié)點(diǎn)

function HTMLtoAST(html){
    var root = null
    var stack = []
    HTMLParser(html,{
        start(tag,attrs,unary){
            var element = { tag, attrs, children:[], type:1 }
            if(!root){
                root = element
            }
            if(stack.length){
                var currentParent = stack[stack.length-1]
                currentParent.children.push(element)
                element.parent = currentParent
            }
            if(!unary){
                stack.push(element)
            }
        },
        end(){
            stack.pop()
        },
        chars(text){
              var currentParent = stack[stack.length - 1]
              currentParent.children.push({ type: 2, text })
        }
    })
    return root
}

如果要考慮不閉合標(biāo)簽的錯(cuò)誤恢復(fù), 例如

  • <p><p></p> => <p></p><p></p>
  • <p><div></div> => <p></p><div></div>
  • <li><li> => <li></li><li></li>

實(shí)際上, 這件事已經(jīng)在前面的160行的 HTMLParser 的parseStartTag函數(shù)中處理過了.

也可以參考 vue 的parseStartTagparseEndTag函數(shù)

function handleStartTag (match) {
  const tagName = match.tagName
  const unarySlash = match.unarySlash
  
  if (expectHTML) {
    if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
      parseEndTag(lastTag)
    }
    if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
      parseEndTag(tagName)
    }
  }
  //...
}

關(guān)于什么是 isNonPhrasingTag , 即不允許藏在<p></p>內(nèi)部的標(biāo)簽, 例如<div>, 可以參考

W3C: #phrasing-content
W3C HTML5標(biāo)準(zhǔn)閱讀筆記 – 元素分類與內(nèi)容模型(Content Model)

3.3 構(gòu)建瀏覽器 DOM

    function HTMLtoDOM(html) {
        var doc = window.document
        var stack = []
        var curParentNode,root

        HTMLParser(html, {
            start: function (tagName, attrs, unary) {
                var elem = doc.createElement(tagName);

                for (var attr in attrs)
                    elem.setAttribute(attrs[attr].name, attrs[attr].value);

                if (curParentNode && curParentNode.appendChild)
                    curParentNode.appendChild(elem);

                if (!root) root = elem
                if (!unary) {
                    stack.push(elem);
                    curParentNode = elem;
                }
            },
            end: function (tag) {
                curParentNode = stack.pop();
            },
            chars: function (text) {
                curParentNode.appendChild(doc.createTextNode(text));
            },
            comment: function (text) {
                // create comment node
            }
        });

        return root;
    };

HTMLtoDOM 測試結(jié)果,

其中settAttribute('@click',"clickItem(index)")會報(bào)錯(cuò), 所以把@click臨時(shí)改成了onclick

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末溯职,一起剝皮案震驚了整個(gè)濱河市囤捻,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌坟漱,老刑警劉巖伯复,帶你破解...
    沈念sama閱讀 206,723評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件慨代,死亡現(xiàn)場離奇詭異,居然都是意外死亡啸如,警方通過查閱死者的電腦和手機(jī)侍匙,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,485評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人想暗,你說我怎么就攤上這事妇汗。” “怎么了说莫?”我有些...
    開封第一講書人閱讀 152,998評論 0 344
  • 文/不壞的土叔 我叫張陵杨箭,是天一觀的道長。 經(jīng)常有香客問我储狭,道長互婿,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,323評論 1 279
  • 正文 為了忘掉前任辽狈,我火速辦了婚禮慈参,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘刮萌。我一直安慰自己驮配,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,355評論 5 374
  • 文/花漫 我一把揭開白布着茸。 她就那樣靜靜地躺著壮锻,像睡著了一般。 火紅的嫁衣襯著肌膚如雪涮阔。 梳的紋絲不亂的頭發(fā)上躯保,一...
    開封第一講書人閱讀 49,079評論 1 285
  • 那天,我揣著相機(jī)與錄音澎语,去河邊找鬼途事。 笑死,一個(gè)胖子當(dāng)著我的面吹牛擅羞,可吹牛的內(nèi)容都是我干的尸变。 我是一名探鬼主播,決...
    沈念sama閱讀 38,389評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼减俏,長吁一口氣:“原來是場噩夢啊……” “哼召烂!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起娃承,我...
    開封第一講書人閱讀 37,019評論 0 259
  • 序言:老撾萬榮一對情侶失蹤奏夫,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后历筝,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體酗昼,經(jīng)...
    沈念sama閱讀 43,519評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,971評論 2 325
  • 正文 我和宋清朗相戀三年梳猪,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了麻削。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,100評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖呛哟,靈堂內(nèi)的尸體忽然破棺而出叠荠,到底是詐尸還是另有隱情,我是刑警寧澤扫责,帶...
    沈念sama閱讀 33,738評論 4 324
  • 正文 年R本政府宣布榛鼎,位于F島的核電站,受9級特大地震影響鳖孤,放射性物質(zhì)發(fā)生泄漏者娱。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,293評論 3 307
  • 文/蒙蒙 一淌铐、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧蔫缸,春花似錦腿准、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,289評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至校翔,卻和暖如春弟跑,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背防症。 一陣腳步聲響...
    開封第一講書人閱讀 31,517評論 1 262
  • 我被黑心中介騙來泰國打工孟辑, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人蔫敲。 一個(gè)月前我還...
    沈念sama閱讀 45,547評論 2 354
  • 正文 我出身青樓饲嗽,卻偏偏與公主長得像,于是被迫代替她去往敵國和親奈嘿。 傳聞我的和親對象是個(gè)殘疾皇子貌虾,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,834評論 2 345

推薦閱讀更多精彩內(nèi)容