HTML-Parser

简介: 背景:需求需要把 html 字符串转成 DOM 对象树或者 js 对象树,然后进行一些处理/操作。htmlparser 这个库还行,但是对 attribute 上一些特殊属性值转换不行,同时看了看开标签语法(syntax-start-tag:whatwg)、html-attribute 的支持规则(attributes:whatwg) 和一些其他库的实现,在一些边界场景(特殊属性值和web component)处理还是缺少,算了... 自己撸了个 html parser 的函数么好了。

背景:需求需要把 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 = /&quot;/g
const sqRegBk = /&#39;/g
const ltRegBk = /&lt;/g
const gtRegBk = /&gt;/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, '&lt;')
      .replace(gtReg, '&gt;')
      .replace(sqAttrReg, v => {
        return v.replace(qReg, '&quot;')
      })
      .replace(qAttrReg, v => {
        return v.replace(sqReg, '&#39;')
      })
  })
}

结果如下:

;`<div id="container">
  <div class="test" data-html="&lt;p&gt;hello 1&lt;/p&gt;">
    <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="&lt;p&gt;hello 1&lt;/p&gt;">", "<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 招聘

相关文章
|
6月前
|
JavaScript 前端开发
js如何获取触发复制操作
在JavaScript中,可以通过以下方式来获取触发复制操作: 1. 使用`document.execCommand('copy')`来触发复制操作。可以通过事件监听,如点击按钮触发复制操作。 ```javascript document.querySelector('#copyButton').addEventListener('click', function() { document.execCommand('copy'); }); ``` 2. 监听`copy`事件,并在事件处理函数中进行相应的操作。 ```javascript document.addEventListe
|
前端开发 算法 JavaScript
深入理解并解决 npm ERESOLVE (Peer Conflict) 问题
如果你持续使用 LTS 版本的 Node.js,或者主动更新了 npm 到 7+,一定见过下面这个难懂的报错: “unable to resolve dependency tree",字面意思就是无法解析依赖树,然后下面一大长串东西都在尝试告诉开发者无法解析的原因,并建议修复依赖间的冲突,但很尴尬的一点是……可能看了之后还是不知道,我要修复什么冲突呢?
4548 1
深入理解并解决 npm ERESOLVE (Peer Conflict) 问题
|
JavaScript
vite原理之解析.vue文件
vite就是在本地启用了一个http服务器,然后将本地的文件通过浏览器的请求将本地的文件返回到浏览器;当然其中会有大量的解析,用于将文件内容解析为浏览器可以理解的内容
|
10月前
|
移动开发 前端开发 JavaScript
Webpack 如何配置热更新 #96
Webpack 如何配置热更新 #96
197 0
|
机器学习/深度学习 人工智能 搜索推荐
【推荐系统】Facebook经典模型GBDT+LR代码实践
【推荐系统】Facebook经典模型GBDT+LR代码实践
134 0
|
11月前
|
机器学习/深度学习 人工智能 测试技术
这是Meta版ChatGPT雏形?开源、一块GPU就能跑,1/10参数量打败GPT-3(1)
这是Meta版ChatGPT雏形?开源、一块GPU就能跑,1/10参数量打败GPT-3
112 0
|
JavaScript
面试官:Vue 每个生命周期都做了什么?
前言 这是一道非常常问的面试题,一般如果面试官准备问Vue相关的问题,那么往往都会拿Vue的生命周期作为开头。很多小伙伴可能觉得这道题非常简单,所以往往就不去准备,结果导致每次回答得都不够全面,而面试官就会觉得你这个人态度不端正。 所以,我们还是好好学一学基础的东西吧!至少态度端正!
253 0
|
数据可视化 前端开发
前端可视化大屏适配方案
前端可视化大屏适配方案
前端可视化大屏适配方案
|
Web App开发 JavaScript 前端开发
【跨域】一篇文章彻底解决跨域设置cookie问题!
之前做项目的时候发现后端传过来的 SetCookie 不能正常在浏览器中使用。是因为谷歌浏览器新版本Chrome 80将Cookie的SameSite属性默认值由None变为Lax。接下来带大家解决该问题。
2536 0