关键词高亮: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


码代码五分钟,写博客两小时....

相关文章
相关标签/搜索