正则表达式的规则并不多,1小时足够看完并理解,难的是如何把那些规则串到一起运用起来,我的看法是没有捷径,唯手熟尔,一万小时定律大家应该都听过,但实际上如果只是想掌握一个很细分领域的能力并且只是想达到中上等的水平的话,100小时就足够了
本文不谈具体的正则规则,而是从实战角度,利用正则实现一个 HTML 的解析器,对于给定的 html 合法字符串片段,其能够解析出这个标签片段的 tag、nodeType、属性(attributes),并且会继续解析器子节点,最终形成一颗结构树,效果如下:
const str = `<div class="container" name="container-name"><input type="file"><button class="btn">选择文件</button></div>`
const ast = genHTML(str)
console.log(ast)
输出:
[
{
"nodeType": 1,
"tag": "div",
"attributes": { "class": "container", "name": "container-name" },
"children": [
{
"nodeType": 1,
"tag": "input",
"attributes": { "type": "file" },
"children": []
},
{
"nodeType": 1,
"tag": "button",
"attributes": { "class": "btn" },
"children": [{ "nodeType": 3, "content": "选择文件" }
]
}
]
}
]
简单标签数据提取
约定一下解析后的数据结构
const nodeTypes = {
// 标签节点
element: 1,
// 文本节点/空白节点
text: 3,
// 注释节点
comment: 8,
} as const
type TAstNode = {
nodeType: typeof nodeTypes[keyof typeof nodeTypes]
tag?: string
attributes?: { [key: string]: string }
content?: string
children?: TAstNode[]
}
先将问题简化,假定所提供的 HTML片段是标签片段没有子节点,那么其包含的数据就只有标签名和属性了,关于如何提取这种简单片段的详细方法,我在之前的一篇文章中 已经说过了,不再多赘述,这里只提一下不同点以及做简单的串联
对于 <div class="content" name="content-name"></div> 的片段,可以根据 /<(\w+)\s*([^>]*)\s*>/ 这个正则匹配出其对应的标签名和属性,但我们知道除了这种非自闭合的标签外,还有一种自闭合的标签,即 img、input、br、hr、meta、link 等
自闭合标签相对于非自闭合标签的区别在于,前者可以在标签的末尾加一个 /,下述写法都是合法的: <img/>、<img />、<img>、<img >,都是合法的,对于 /,其正则是 (\/)?,\是对 /进行转义,最后的 ? 表示加不加都行,再跟之前的正则拼接,得到:/^<(\w+)\s*([^>]*?)\s*(\/)?>/
const mt = s.match(/^<(\w+)\s*([^>]*?)\s*(\/)?>/)
// 标签名,例如 div
const tag = mt[1]
// 属性字符串,例如 class="content" name="content-name"
const attributes = mt[2]
这一步,标签名就拿到了,下一步对 attributes进行处理,得到属性的键值对,这个之前的文章也说过,不再多说,其正则为 /([^\s=]+)(=(["'])(.*?)\3)?/
那么我们就可以得到这个处理标签的方法了
function genSingleStr(s: string) {
const mt = s.match(/^<(\w+)\s*([^>]*?)\s*(\/)?>/)
const obj = {
nodeType: nodeTypes.element,
tag: mt[1],
attributes: {},
children: []
} as TAstNode
const attributes = mt[2]
if (attributes) {
const mt1 = attributes.match(/([^\s=]+)(=(["'])(.*?)\3)?/g)
if (mt1) {
mt1.forEach(p => {
const kv = p.trim().split('=')
obj.attributes[kv[0]] = kv[1].slice(1, -1)
})
}
}
return {
data: obj,
matchStr: mt[0]
}
}
对于 <div class="content" name="content-name"></div> 片段,运行方法后可得到结果
{
"nodeType": 1,
"tag": "div",
"attributes": {"class": "content", "name": "content-name"},
"children": []
}
对于 <img src="https://example.com/1.png"> 返回:
{
"nodeType": 1,
"tag": "img",
"attributes": {"src": "https://example.com/1.png"},
"children": []
}
子节点处理
非自闭合节点
如果节点没有子元素那很简单,只涉及到对标签的解析,但很显然大部分场景下节点都是有子节点的,那么就需要对子节点继续处理,同时还要保证数据结构上能够体现出父子节点的关系
对于一段 html片段中的某个节点来说,如何确定下一个节点是其子节点而不是兄弟节点呢?实际上也简单,只要这个节点还没遇到它的结束标签,那么在开始标签和结束标签之间所有的节点,都是其子节点,否则就是其兄弟节点
那么重点就变成了如何确定节点的开始标签和结束标签,也就是节点包括的片段范围
开始标签和结束标签肯定是同时存在的(否则就不是合法片段了),这就有点类似于括号配对问题,一个右括号肯定对应一个左括号
父节点只有一个开始标签和一个结束标签,但由于其可能存在子节点,其子节点也是有自己的开始标签和结束标签的,那么可以维护一个数组栈,遇到开始标签就入栈,遇到结束标签就出栈,这个开始标签和结束标签可能是这个父节点的也可能是这个父节点下子节点的,但不管这些,入栈出栈的过程中如果发现栈空了,那么说明已经找到这个父节点的结束标签了
这个栈的第一个元素就是父节点的开始标签,在栈被清空之前的最后一个标签就是父节点的结束标签
例如对于如下片段:
<div>
<p><span></span></p>
</div>
<div></div>
对其进行解析,首先遇到 <div>开始标签,那么入栈得 ['div'],继续往下遇到 <p>入栈得 ['div', 'p'],继续往下遇到 <span>入栈得 ['div', 'p', 'span'],再往下遇到 </span>,发现是结束标签,那么把栈最后一个元素出栈得 ['div', 'p'],然后又遇到 </p>发现是结束标签,那么把栈最后一个元素出栈得 ['div'],再往下遇到 </div>,发现是结束标签,那么把栈最后一个元素出栈得 [],同时发现栈空了,那么说明已经读取到一个完整的节点范围了,如果再往下,那么就是这个节点的兄弟节点了
只要是以 <开头的就是开始标签,以 </开头的就是结束标签,分别查找片段中 < 和 </ 的第一个位置,如果 </的位置比 < 靠前,那么说明首先匹配到的是结束标签,否则就是开始标签
function genHTML(s: string) {
const stack = []
const end = s.indexOf('</')
const start = s.indexOf('<')
if (end <= start) {
// 首先匹配到了结束标签
} else {
// 首先匹配到了开始标签
}
}
先看开始标签,如果匹配到了开始标签,那么应当将这个标签相关的数据入栈:
const beginRoot = genSingleStr(s)
stack.push(beginRoot.data)
如果在入栈的时候发现栈是空的,那么说明这个入栈的标签是顶级父节点,但如果不是那么说明是子节点,其父节点就是栈中的最后一个元素,那么在入栈的同时,也需要把这个标签赋值给其父节点的 children属性以维护父子关系
const beginRoot = genSingleStr(s)
if (stack.length !== 0) {
stack[stack.length - 1].children.push(beginRoot.data)
}
stack.push(beginRoot.data)
然后继续往下解析字符串片段,将已经解析过的字符串截掉只保留未解析的字符串,使用 while 循环来遍历未解析的片段
function genHTML(s: string) {
while (s) {
const stack = []
const end = s.indexOf('</')
const start = s.indexOf('<')
if (end <= start) {
// 首先匹配到了结束标签
} else {
// 首先匹配到了开始标签
const beginRoot = genSingleStr(s)
if (stack.length !== 0) {
stack[stack.length - 1].children.push(beginRoot.data)
}
stack.push(beginRoot.data)
s = s.slice(beginRoot.matchStr.length))
}
}
}
如果匹配到了结束标签,那么这个结束标签肯定跟 stack 中最后一个标签相同,否则就是不合法片段了,这里可以校验一下
对于 </div> 这种字符串,匹配出 div 这个字符串,正则为 /<\/(\w+)>/,考虑到 html标签具有很好的容错性,类似于 </div >、</div op>都是可以接受的,所以咱们也兼容下,正则改为: /<\/(\w+)[^>]*>/
if (end <= start) {
const mtEnd = s.match(/<\/(\w+)[^>]*>/)
if (!mtEnd) {
console.log('匹配结束标签失败:', s.slice(0, 20))
return null
}
const tag = mtEnd[1]
if (tag !== stack[stack.length - 1].tag) {
console.log(`标签无法匹配,${tag} => ${stack[stack.length - 1].tag}`)
return null
}
}
开始标签和结束标签匹配成功后,应该把 stack中最后一项也就是代表匹配成功的结束标签数据出栈,并且截取字符串片段汇总剩余未匹配以继续解析,并且在每次匹配到结束标签的时候都要检查下栈是否为空,如果栈空了说明已经匹配到一个完整的父节点范围了,循环应当退出
if (end <= start) {
// ...
stack.pop()
s = s.slice(mtEnd[0].length)
if (stack.length === 0) {
break
}
}
特殊节点
这里还有个问题,我们是假定字符串片段都是类似于 <div></div>这种正常的标签节点,但合法的节点还包括文本节点、空白符节点、注释节点以及自闭合标签节点,这些都要考虑
这些节点的特殊之处在于它们没有子节点也没有对应的结束标签,所以需要一一单独处理
对于文本节点,只要是以非 < 字符串开头的且不包含 < 的都是文本节点(实际上文本节点也可以包含<,但简单起见,这里暂且这么认为):
// 匹配标签之前的文字节点/空白符节点
function matchTextEmpty(s: string) {
return s.match(/^[^<]+(?=<)/)
}
这里用到了 零宽度正预测先行断言 ,(?=<)代表匹配 < 之前的位置
对于空白节点:
// 匹配标签之前的空白符节点
function matchEmpty(s: string) {
return s.match(/^\s+/)
}
对于注释节点,注释节点肯定是以 <!-- 开头,以 --> 结尾的,中间是什么无所谓,都属于注释的内容
// 匹配注释标签
function matchComment(s: string) {
return s.match(/^<!--[^>]*-->/)
}
对于自闭合标签节点,自闭合标签一共就那些,直接罗列出来就行:
// 匹配自闭合标签
function matchAutoCloseTag(s: string) {
return s.match(/^<(input|img|br|hr|meta|link)[^>]*>/)
}
把这些特殊节点的匹配逻辑封装一下
function manageSingleChild(s: string) {
// 文字节点/空白符节点
let mt = matchTextEmpty(s)
if (mt) {
return {
str: s.slice(mt[0].length),
node: { nodeType: nodeTypes.text, content: mt[0] }
}
}
// 空白符节点
mt = matchEmpty(s)
if (mt) {
return {
str: s.slice(mt[0].length),
node: { nodeType: nodeTypes.text, content: mt[0] }
}
}
// 自闭合标签
mt = matchAutoCloseTag(s)
if (mt) {
return {
str: s.slice(mt[0].length),
node: genSingleStr(s)
}
}
// 注释标签
mt = matchComment(s)
if (mt) {
return {
str: s.slice(mt[0].length),
node: { nodeType: nodeTypes.comment, content: mt[0] }
}
}
return null
}
在解析片段之前,先看下片段是不是以这些特殊节点开头的,如果是,那么就没必要走下面的子节点解析过程了:
while (s.length) {
const singleChildNode = manageSingleChild(s)
if (singleChildNode) {
stack[stack.length - 1].children.push(singleChildNode.node)
s = singleChildNode.str
continue
}
const end = s.indexOf('</')
const start = s.indexOf('<')
// ...
}
兄弟节点
接下来就是顶级节点存在兄弟节点的问题了,例如对于以下 html 片段,其存在两个顶级节点 .container 和 .footer
<div class="container"><p></p></div>
<div class="footer"></div>
那么我们可以再加一层循环,最外层的这层循环专门处理兄弟节点,内部的循环处理父子节点。同样的,兄弟节点可能是非自闭合标签、自闭合标签、文本节点、空白节点、注释节点,这些都要考虑,这个就跟 manageSingleChild 差不多了
function genHTML(s: string) {
const root = [] as TAstNode[]
while (s.length) {
const singleNode = manageSingleRoot(s)
if (singleNode) {
root.push(singleNode.node)
s = singleNode.str
continue
}
const stack = []
while (s.length) {
// ...
}
}
return root
}
至此,完成了整个 html 方法的逻辑
小结
本文实现的只是一个简易版的 html解析方法,并不完备,完备的方法肯定不是这点代码量就能解决的,但这并不是本文的重点,本文主要是想基于一个实战的场景,运用正则表达式解决实际问题,学会如何运用才是重点,毕竟授人以鱼不如授人以渔
完整代码放在 github 了