背景:需求需要把 html 字符串轉成 DOM 對象樹或者 js 對象樹,然后進行一些處理/操作。
htmlparser 這個庫還行,但是對 attribute 上一些特殊屬性值轉換不行,同時看了看`開標簽語法`(
syntax-start-tag:whatwg)、`html-attribute 的支持規則`(
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... 還算行,先用着。
最后
寫代碼么...開心就好
如果您對我們團隊感興趣,歡迎加入,期待您的加入,可以投遞我的郵箱 liaojc@tuya.com !
更多崗位可以查看
Tuya 招聘