HTML-Parser
背景:需求需要把 html 字符串转成 DOM 对象树或者 js 对象树,然后进行一些处理/操作。htmlparser 这个库还行,但是对 attribute 上一些特殊属性值转换不行,同时看了看`开标签语法`(syntax-start-tag:whatwg)、`html-attribute 的支持规则`(attributes:whatwg) 和一些其他库的实现,在一些边界场景(特殊属性值和web component)处理还是缺少,算了... 自己撸了个 html parser 的函数么好了。
本文主要是记录下实现过程,做个技术沉淀,有相关需求的可以做个参考。
前期处理
首先,定义一些正则表达式,用以匹配希望找到的内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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 字符串做例子。
1 2 3 4 5 6 7 8 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 | 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, '' ') }) }) } |
结果如下:
1 2 3 4 5 6 | ;`<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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 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 } |
结果如下:
1 | [ "<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,处理成对象树
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | // 单标签集合 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 } |
结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | [ { 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 个步骤
1 2 3 4 5 6 | 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... 还算行,先用着。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· 展开说说关于C#中ORM框架的用法!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?