天天看點

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... 還算行,先用着。

最後

寫代碼麼...開心就好

如果您對我們團隊感興趣,歡迎加入,期待您的加入,可以投遞我的郵箱 [email protected] !

更多崗位可以檢視

Tuya 招聘

繼續閱讀