背景:需求需要把 html 字元串轉成 DOM 對象樹或者 js 對象樹,然後進行一些處理/操作。 htmlparser 這個庫還行,但是對 attribute 上一些特殊屬性值轉換不行,同時看了看 開标簽文法 html-attribute 的支援規則
( syntax-start-tag:whatwg )、
attributes:whatwg ) 和一些其他庫的實作,在一些邊界場景(特殊屬性值和 web component
)處理還是缺少,算了... 自己撸了個 html parser 的函數麼好了。
本文主要是記錄下實作過程,做個技術沉澱,有相關需求的可以做個參考。
前期處理
首先,定義一些正規表達式,用以比對希望找到的内容
const ltReg = /\</g
const gtReg = /\>/g
const sqReg = /'/g
const qReg = /"/g
const sqAttrReg = /(?<=\=')[^']*?(?=')/g
const qAttrReg = /(?<=\=")[^"]*?(?=")/g
const qRegBk = /"/g
const sqRegBk = /'/g
const ltRegBk = /</g
const gtRegBk = />/g
const attrReplaceReg = /[\:\w\d_-]*?=(["].*?["]|['].*?['])/g
const attrReg = /(?<=\s)([\:\w\d\-]+\=(["'].*?["']|[\w\d]+)|\w+)/g
const numReg = /^\d+$/
const clReg = /\n/g
const sReg = /\s/g
const spReg = /\s+/g
const tagReg = /\<[^\<\>]*?\>/
const startReg = /\<[^\/\!].*?\>/
const endReg = /\<\/.*?\>/
const commentReg = /(?<=\<\!\-\-).*?(?=\-\-\>)/
const tagCheckReg = /(?<=\<)[\w\-]+/
開始處理邏輯,拿個簡單的 html 字元串做例子。
const str = `
<div id="container">
<div class="test" data-html="<p>hello 1</p>">
<p>hello 2</p>
<input type="text" value="hello 3" >
</div>
</div>
`
屬性值轉義
拿到字元串 str,取各個開标簽,并将标簽内的 attribute 裡的特殊字元做轉義字元替換,傳回字元串 str1
const replaceAttribute = (html: string): string => {
return html.replace(attrReplaceReg, v => {
return v
.replace(ltReg, '<')
.replace(gtReg, '>')
.replace(sqAttrReg, v => {
return v.replace(qReg, '"')
})
.replace(qAttrReg, v => {
return v.replace(sqReg, ''')
})
})
}
結果如下:
;`<div id="container">
<div class="test" data-html="<p>hello 1</p>">
<p>hello 2</p>
<input type="text" value="hello 3" >
</div>
</div>`
形成内容數組
從上一步的字元串 str1 中截取出元素(元素是: 開标簽、内容、閉合标簽),放入新數組 arr。
const convertStringToArray = (html: string) => {
let privateHtml = html
let temporaryHtml = html
const arr = []
while (privateHtml.match(tagReg)) {
privateHtml = temporaryHtml.replace(tagReg, (v, i) => {
if (i > 0) {
const value = temporaryHtml.slice(0, i)
if (value.replace(sReg, '').length > 0) {
arr.push(value)
}
}
temporaryHtml = temporaryHtml.slice(i + v.length)
arr.push(v)
return ''
})
}
return arr
}
["<div id="container">", "<div class="test" data-html="<p>hello 1</p>">", "<p>", "hello 2", "</p>", "<input type="text" value="hello 3" >", "</div>", "</div>"]
生成對象樹
循環上一步形成的 arr,處理成對象樹
// 單标簽集合
var singleTags = [
'img',
'input',
'br',
'hr',
'meta',
'link',
'param',
'base',
'basefont',
'area',
'source',
'track',
'embed'
]
// 其中 DomUtil 是根據 nodejs 還是 browser 環境生成 js 對象/ dom 對象的函數
var makeUpTree = function(arr) {
var root = DomUtil('container')
var deep = 0
var parentElements = [root]
arr.forEach(function(i) {
var parentElement = parentElements[parentElements.length - 1]
if (parentElement) {
var inlineI = toOneLine(i)
// 開标簽處理,新增個開标簽标記
if (startReg.test(inlineI)) {
deep++
var tagName = i.match(tagCheckReg)
if (!tagName) {
throw Error('标簽規範錯誤')
}
var element_1 = DomUtil(tagName[0])
var attrs = matchAttr(i)
attrs.forEach(function(attr) {
if (element_1) {
element_1.setAttribute(attr[0], attr[1])
}
})
parentElement.appendChild(element_1)
// 單标簽處理,deep--,完成一次閉合标記
if (
singleTags.indexOf(tagName[0]) > -1 ||
i.charAt(i.length - 2) === '/'
) {
deep--
} else {
parentElements.push(element_1)
}
}
// 閉合标簽處理
else if (endReg.test(inlineI)) {
deep--
parentElements.pop()
} else if (commentReg.test(inlineI)) {
var matchValue = i.match(commentReg)
var comment = matchValue ? matchValue[0] : ''
deep++
var element = DomUtil('comment', comment)
parentElement.appendChild(element)
deep--
} else {
deep++
var textElement = DomUtil('text', i)
parentElement.appendChild(textElement)
deep--
}
}
})
if (deep < 0) {
throw Error('存在多餘閉合标簽')
} else if (deep > 0) {
throw Error('存在多餘開标簽')
}
return root.children
}
;[
{
attrs: {
id: 'container'
},
parentElement: [DomElement],
children: [
{
attrs: {
class: 'test',
'data-html': '<p>hello 1</p>'
},
parentElement: [DomElement],
children: [
{
attrs: {},
parentElement: [DomElement],
children: [
{
attrs: {},
parentElement: [DomElement],
children: [],
tagName: 'text',
data: 'hello 2'
}
],
tagName: 'p'
},
{
attrs: {
type: 'text',
value: 'hello 3'
},
parentElement: [DomElement],
children: [],
tagName: 'input'
}
],
tagName: 'div'
}
],
tagName: 'div'
}
]
組合
組合以上的 3 個步驟
const Parser = (html: string) => {
const htmlAfterAttrsReplace = replaceAttribute(html)
const stringArray = convertStringToArray(htmlAfterAttrsReplace)
const domTree = makeUpTree(stringArray)
return domTree
}
測試
最後肯定的要測試一波。
把
tuya / taobao / baidu / jd / tx
的首頁或者新聞頁都拷貝了 html 試了一波,基本在
100ms
内執行完,并且 dom 數量大概在幾千的樣子,對比了一番, html 字元串上的标簽屬性和對象的 attrs 對象,都還對應的上。
emm... 還算行,先用着。
最後
寫代碼麼...開心就好
如果您對我們團隊感興趣,歡迎加入,期待您的加入,可以投遞我的郵箱 [email protected] !
更多崗位可以檢視
Tuya 招聘