不玩概念!正则表达式实战之手把手实现一个HTML解析函数

正则表达式的规则并不多,1小时足够看完并理解,难的是如何把那些规则串到一起运用起来,我的看法是没有捷径,唯手熟尔,一万小时定律大家应该都听过,但实际上如果只是想掌握一个很细分领域的能力并且只是想达到中上等的水平的话,100小时就足够了

本文不谈具体的正则规则,而是从实战角度,利用正则实现一个 HTML 的解析器,对于给定的 html 合法字符串片段,其能够解析出这个标签片段的 tagnodeType、属性(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*>/ 这个正则匹配出其对应的标签名和属性,但我们知道除了这种非自闭合的标签外,还有一种非自闭合的标签,即 imginputbrhrmetalink

自闭合标签相对于非自闭合标签的区别在于,前者可以在标签的末尾加一个 /,下述写法都是合法的: <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

最后

字节跳动-直播变现与千川部门招聘啦!

不低于 10hc(骗人是小狗),北京、上海、杭州都可以,而且是 急招,就差一个程序员了的那种

经验不限,接受实习

之前投过字节其他部门没过的也没关系(面试没过不一定代表能力不行,也可能是眼缘不够),可以继续面我这个部门(万一就看对眼了呢,毕竟我们部门真的缺人),有兴趣的可将简历发我邮箱 [email protected] ,所有发我简历的人,保证全程跟进并反馈面试进度,有问必答(在不违反公司规章制度的前提下),杜绝简历一投石沉大海的糟糕体验

救救孩子,快来把我的需求分点走吧!

团队介绍

直播变现与千川,负责优化字节跳动中国区流量的直播广告以及短视频电商广告,负责巨量千川的平台建设、算法优化、广告产品和运营策略的落地。首要目标是:借由字节强大的算法工程能力,发挥直播体裁和电商闭环的天然优势,进一步提升中国区商业化收入。 生活服务业务依托于抖音,抖音极速版等平台,致力于促进用户与本地服务的连接; 过去一年,生活服务业务开创了全新的视频种草和交易体验,让更多用户通过抖音发现线下的好去处,也帮助众多本地商家拓展了新的经营阵地。 我们期待你的加入,希望下一个参与服务百万商家,影响亿级消费者,引领营销革命的就是你!

团队优势

  1. 技术:以业务为最终导向,即使作为研发角色,也能够接触一线客户,通过技术方案解决客户问题,技术方案涉及广告投放系统中召回、粗排、精排、出价、排序等多个环节,能够深入全面地理解广告变现系统的各个环节的内部原理。
  2. 成长:字节电商GMV仍然在高速提升,在满足购买需求时,短视频和直播体裁具备颠覆性的优势,业务成长空间非常大。
  3. 机遇:字节电商的购买体验更加多元,包含:商品、视频、直播流、达人、粉丝关系、直播互动等等,相比传统货架式电商,scope更大,个人的发展机会更多。

2.jpg

猜你喜欢

转载自juejin.im/post/7075960770483191822