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ù)- 檢查 stack && 關(guān)閉那些特殊閉合方式的 tagName , 例如<input> 和 <th> <tr>
- 若 tagName 不是自閉和節(jié)點(diǎn)則入棧
- 把 attrs 即
id="app"
的字符串replace 為{ name:'id', value:'app', escaped }
的數(shù)據(jù) - 調(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 鉤子
- 若當(dāng)前字符串以
- 收尾工作再次調(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}}
這類模板解析字符串的處理
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 的@
符號的, 簡單修改attr
和starTag
的正則表達(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>
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;
}
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 的parseStartTag
和parseEndTag
函數(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;
};
其中
settAttribute('@click',"clickItem(index)")
會報(bào)錯(cuò), 所以把@click
臨時(shí)改成了onclick