天天看點

HTML實作關鍵詞高亮字元串中比對“跨标簽關鍵詞”

HTML實作關鍵詞高亮字元串中比對“跨标簽關鍵詞”

之前分享過一期《​​Vue關鍵詞搜尋高亮​​》,今天,我們在來分享一期在HTML字元串中比對“跨标簽關鍵詞”高亮實作案例,類似浏覽器ctrl+f搜尋結果。

實作方案是,将文本字元串中的關鍵字搜尋出來,然後使用特殊的标簽(比如font标簽)包裹關鍵詞替換比對内容,最後得到一個HTML字元串,渲染該字元串并在font标簽上使用CSS樣式即可實作高亮的效果。

一、比對關鍵字:HTML字元串與文本字元串對比

1. 純文字字元串的處理

對于純文字字元串,如:“江畔何人初見月?江月何年初照人?”,假如我們想比對“江月”這個關鍵字,則比對結果可處理為:

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

這樣“江月”兩個字被font标簽包裹,在font标簽上應用特殊的背景樣式以達到關鍵字高亮的效果。

2. 對HTML字元串的處理

對于上述例子,如果内容字元串是一個HTML文本:

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

對于同樣的關鍵詞“江月”,怎樣處理它呢?因為關鍵詞中的字在不同的标簽内,是以隻能分别用font标簽進行替換:

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

這是比較簡單的情況,實際情況下關鍵字則可能跨多級、多層标簽。

二、跨标簽比對關鍵詞

跨标簽解析關鍵詞,其實就是對于比對到的關鍵詞,提取出各标簽中對應的子片段,然後用font之類的标簽包裹,再将高亮樣式用于font标簽即可。

對于整個HTML内容而言,渲染出來的文本由各類标簽内的文本節點組成。

因為關鍵詞比對的内容會跨标簽,是以需要将各文本節點有序取出,并将節點内容拼接起來進行比對。

拼接時記下節點文本在拼接串中的起止位置,以便關鍵詞比對到拼接串的某位置時截取文本片段并使用font标簽包裹。

1. 深度優先周遊DOM樹取出文本節點

深度優先可以采用循環或者遞歸的方式周遊,這裡采用循環實作,按取出某個元素下所有文本節點(利用nodeType判斷文本節點):

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. 取出所有文本内容進行拼接

擷取到了文本節點清單,可以取出所有文本内容并記錄每個文本片段在拼接結果中的開始、結束索引:

getTextInfoList (textNodes) {
  let length = 0
  const textList = textNodes.map(text => {
    let start = length, end = length + text.wholeText.length
    length = end
    return [text.wholeText, start, end]
  })
  return textList
}      

拼接文本:

const content = textList.map(([text]) => text).join('')      

3. 比對關鍵詞

獲得了拼接文本,可以利用拼接文本擷取所有的拼接結果了。這裡偷個懶直接用正則比對吧,得把正則用到的一些特殊符号進行轉義一下:

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的一個簡單例子:

HTML實作關鍵詞高亮字元串中比對“跨标簽關鍵詞”

4. 關鍵詞使用font标簽替換

根據關鍵詞比對結果索引,以及每個文本節點的起止索引,可以計算出每個關鍵詞比對了哪幾個文本節點,其中對于開始和結束的文本節點,可能隻是部分比對到,而中間的文本節點的所有内容都是比對到的。

比如對于HTML文本:

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

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

HTML實作關鍵詞高亮字元串中比對“跨标簽關鍵詞”

假如關鍵字是“何人初見月?”,那此時,對于第一個文本節點比對了後半部分,第二個文本節點完全比對,第三個文本節點比對了第一個字元。三個節點中比對的部分需要分别用font标簽替換:

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

預設情況下,連續的文字會在同一個文本節點中,而對于比對了部分内容的文本節點,就需要将它一分為二,可以利用Text.splitText()API來分割文本節點,API接收一個索引值,從索引位置将文本節點後半部分切割并傳回包含後半部分内容的新文本節點。上述例子中比對的是3個節點,拆分後就會得到5個文本節點:

HTML實作關鍵詞高亮字元串中比對“跨标簽關鍵詞”

中間三個文本節點即是需要被替換的節點,使用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節點的樣式設定,這個可以根據自己的邏輯來設定。

三、完整代碼調用

上述步驟描述了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标簽包裹後傳回。

四、總結

上述實作方案中有一些簡單的細節省去了,比如設定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

HTML實作關鍵詞高亮字元串中比對“跨标簽關鍵詞”