關鍵詞高亮:HTML字符串中匹配跨標籤關鍵詞

本文發佈於我的網站: https://wintc.top/article/59,轉載請註明

好久以前寫過一個Vue組件,能夠匹配文本內容中的關鍵詞高亮,相似瀏覽器ctrl+f搜索結果。實現方案是,將文本字符串中的關鍵字搜索出來,而後使用特殊的標籤(好比font標籤)包裹關鍵詞替換匹配內容,最後獲得一個HTML字符串,渲染該字符串並在font標籤上使用CSS樣式便可實現高亮的效果。javascript

當時的實現過於簡單,沒有支持接收HTML字符串做爲內容進行關鍵詞匹配。這兩天有同窗問到,就又思考了這個問題,發現並非那麼麻煩,寫了幾行代碼解決一下。html

1、匹配關鍵字:HTML字符串與文本字符串對比

1. 純文本字符串的處理

對於純文本字符串,如:「江畔何人初見月?江月何年初照人? 」,假如咱們想匹配「江月」這個關鍵字,則匹配結果可處理爲:vue

江畔何人初見月?<font style="background: #ff9632">江月</font>何年初照人?

這樣「江月」兩個字被font標籤包裹,在font標籤上應用特殊的背景樣式以達到關鍵字高亮的效果。java

2. 對HTML字符串的處理

對於上述例子,若是內容字符串是一個HTML文本:node

江畔何人初見<b>月</b>?江<b>月</b>何年初照人?

對於一樣的關鍵詞「江月」,怎樣處理它呢?由於關鍵詞中的字在不一樣的標籤內,因此只能分別用font標籤進行替換:git

江畔何人初見<b>月</b>?<font style="background: #ff9632">江</font><b><font style="background: #ff9632">月</font></b>何年初照人?

這是比較簡單的狀況,實際狀況下關鍵字則可能跨多級、多層標籤。github

2、跨標籤匹配關鍵詞

跨標籤解析關鍵詞,其實就是對於匹配到的關鍵詞,提取出各標籤中對應的子片斷,而後用font之類的標籤包裹,再將高亮樣式用於font標籤便可。數組

對於整個HTML內容而言,渲染出來的文本由各種標籤內的文本節點組成。由於關鍵詞匹配的內容會跨標籤,因此須要將各文本節點有序取出,並將節點內容拼接起來進行匹配。拼接時記下節點文本在拼接串中的起止位置,以便關鍵詞匹配到拼接串的某位置時截取文本片斷並使用font標籤包裹。瀏覽器

1. 深度優先遍歷DOM樹取出文本節點

深度優先能夠採用循環或者遞歸的方式遍歷,這裏採用循環實現,按取出某個元素下全部文本節點(利用nodeType判斷文本節點):dom

function getTextNodeList (dom) {
  const nodeList = [...dom.childNodes]
  const textNodes = []
  while (nodeList.length) {
    const node = nodeList.shift()
    if (node.nodeType === node.TEXT_NODE) {
      textNodes.push(node)
    } else {
      nodeList.unshift(...node.childNodes)
    }
  }
  return textNodes
}

2. 取出全部文本內容進行拼接

獲取到了文本節點列表,能夠取出全部文本內容並記錄每一個文本片斷在拼接結果中的開始、結束索引:

function getTextInfoList (textNodes) {
  let length = 0
  const textList = textNodes.map(node => {
    let startIdx = length, endIdx = length + node.wholeText.length
    length = endIdx
    return {
      text: node.wholeText,
      startIdx,
      endIdx
    }
  })
  return textList
},

拼接文本:

const content = textList.map(({ text }) => text).join('')

3. 匹配關鍵詞

得到了拼接文本,能夠利用拼接文本獲取全部的拼接結果了。這裏偷個懶直接用正則匹配吧,得把正則用到的一些特殊符號進行轉義一下:

function getMatchList (content, keyword) {
  const characters = [...'[]()?.+*^${}:'].reduce((r, c) => (r[c] = true, r), {})
  keyword = keyword.split('').map(s => characters[s] ? `\\${s}` : s).join('[\\s\\n]*')
  const reg = new RegExp(keyword, 'gmi')
  return [...content.matchAll(reg)] // matchAll結果是個迭代器,用擴展符展開獲得數組
}

關鍵詞字符轉義處理後,字符與字符之間中間插入了正則中的空白符和換行符(\s\n),以在匹配時忽略一些看不見的字符。上述代碼使用了matchAll函數,匹配結果展開後獲得的結果是一個數組,數組中的每一項都包含了匹配文本、匹配索引等。matchAll的一個簡單例子:

4. 關鍵詞使用font標籤替換

根據關鍵詞匹配結果索引,以及每一個文本節點的起止索引,能夠計算出每一個關鍵詞匹配了哪幾個文本節點,其中對於開始和結束的文本節點,可能只是部分匹配到,而中間的文本節點的全部內容都是匹配到的。

好比對於HTML文本:

<span>江畔何人初見<b>月</b>?江月何年初照人?</span>

其DOM樹對應的的文本節點有3個:

假如關鍵字是「何人初見月?」,那此時,對於第一個文本節點匹配了後半部分,第二個文本節點徹底匹配,第三個文本節點匹配了第一個字符。三個節點中匹配的部分須要分別用font標籤替換:

<span>江畔<font>何人初見</font><b><font>月</font></b><font>?</font>江月何年初照人?</span>

默認狀況下,連續的文字會在同一個文本節點中,而對於匹配了部份內容的文本節點,就須要將它一分爲二,能夠利用Text.splitText()")API來分割文本節點,API接收一個索引值,從索引位置將文本節點後半部分切割並返回包含後半部份內容的新文本節點。上述例子中匹配的是3個節點,拆分後就會獲得5個文本節點:

中間三個文本節點便是須要被替換的節點,使用replaceChild就能夠直接將文本節點替換爲font標籤。

對於整個HTML字符串,同一個關鍵詞可能同時有多處匹配結果,所以要對全部匹配結果進行上述處理。使用前幾步獲取的textNodes、textList、matchList,代碼實現以下:

function replaceMatchResult (textNodes, textList, matchList) {
  // 對於每個匹配結果,可能分散在多個標籤中,找出這些標籤,截取匹配片斷並用font標籤替換出
  for (let i = matchList.length - 1; i >= 0; i--) {
    const match = matchList[i]
    const matchStart = match.index, matchEnd = matchStart + match[0].length // 匹配結果在拼接字符串中的起止索引
    // 遍歷文本信息列表,查找匹配的文本節點
    for (let textIdx = 0; textIdx < textList.length; textIdx++) {
      const { text, startIdx, endIdx } = textList[textIdx] // 文本內容、文本在拼接串中開始、結束索引
      if (endIdx < matchStart) continue // 匹配的文本節點還在後面
      if (startIdx >= matchEnd) break // 匹配文本節點已經處理完了
      let textNode = textNodes[textIdx] // 這個節點中的部分或所有內容匹配到了關鍵詞,將匹配部分截取出來進行替換
      const nodeMatchStartIdx = Math.max(0, matchStart - startIdx) // 匹配內容在文本節點內容中的開始索引
      const nodeMatchLength = Math.min(endIdx, matchEnd) - startIdx - nodeMatchStartIdx // 文本節點內容匹配關鍵詞的長度
      if (nodeMatchStartIdx > 0) textNode = textNode.splitText(nodeMatchStartIdx) // textNode取後半部分
      if (nodeMatchLength < textNode.wholeText.length) textNode.splitText(nodeMatchLength)
      const font = document.createElement('font')
      font.innerText = text.substr(nodeMatchStartIdx, nodeMatchLength)
      textNode.parentNode.replaceChild(font, textNode)
    }
  }
}

代碼裏對匹配結果遍歷時,採用的是倒序遍歷,緣由是遍歷過程對textNodes存在反作用:在遍歷中會對textNodes中的文本節點進行切割。假設同一個文本節點中有多處匹配,會進行屢次分割,而textNodes裏引用的是原文本節點即前半部分,所以從後往前遍歷會確保未處理的匹配文本節點的完整。

同時代碼中省去了font節點的樣式設置,這個能夠根據本身的邏輯來設置。

3、完整代碼調用

上述步驟描述了HTML字符串跨標籤匹配關鍵詞的全部流程實現,下面是完整的代碼調用示例:

function replaceKeywords (htmlString, keyword) {
  if (!keyword) return htmlString
  const div = document.createElement('div')
  div.innerHTML = htmlString
  const textNodes = getTextNodeList(div)
  const textList = getTextInfoList(textNodes)
  const content = textList.map(({ text }) => text).join('')
  const matchList = getMatchList(content, keyword)
  replaceMatchResult(textNodes, textList, matchList)
  return div.innerHTML
}

輸入一個HTML字符串和關鍵詞,將HTML串中的關鍵詞用font標籤包裹後返回。

4、總結

上述實現方案中有一些簡單的細節省去了,好比設置font標籤的樣式、隱藏的dom匹配時忽略等。

font標籤樣式設置看使用場景吧,若是是長HTML字符串匹配建議是不要直接設置style屬性,而是操做樣式表來達到目的。能夠給font標籤設置特殊的屬性,而後使用屬性選擇器來設置樣式。好比能夠給font設置highlight="${i}"屬性,來針對匹配的關鍵詞應用不一樣的樣式。操做樣式表能夠給style標籤設置innerText或者調用CSSStyleSheet.insertRule()")和CSSStyleSheet.deleteRule()")。

demo: https://wintc.top/laboratory/#/search-highlight
github查看源碼:https://github.com/Lushenggang/vue-search-highlight


碼代碼五分鐘,寫博客兩小時....

相關文章
相關標籤/搜索