关于javascript:HTML字符串跨标签匹配关键词高亮

1次阅读

共计 4711 个字符,预计需要花费 12 分钟才能阅读完成。

本文公布于集体网站:https://wintc.top/article/59,转载请注明

很久之前(如同刚好是一年前)写过一个 Vue 组件,能够匹配文本内容中的关键词高亮,相似浏览器 ctrl+ f 搜寻后果。实现计划是,将文本字符串中的关键字搜寻进去,而后应用非凡的标签(本文示例应用 font 标签)包裹关键词替换匹配内容,最初失去一个 HTML 字符串,渲染该字符串并在 font 标签上应用 CSS 款式即可实现高亮的成果。

过后的实现过于简略,没有反对接管 HTML 字符串作为内容进行关键词匹配。这两天有同学问到,就又思考了这个问题,发现并不是那么麻烦,写了几行代码解决一下。

一、匹配关键字: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 
  })
  return textList
}

拼接文本:

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

3. 匹配关键词

取得了拼接文本,能够利用拼接文本获取所有的拼接后果了。这里偷个懒间接用正则匹配吧,得把正则用到的一些特殊符号进行本义一下:

getMatchList (content, keyword) {const characters = [...'[]()?.+*^${}:'].reduce((r, c) => (r = true, r), {})
  keyword = keyword.split('').map(s => characters[s] ? `${s}` : s).join('[sn]*')
  const reg = new RegExp(keyword, 'gmi')
  return [...content.matchAll(reg)] // matchAll 后果是个迭代器,用扩大符开展失去数组
}

关键词字符本义解决后,字符与字符之间两头插入了正则中的空白符和换行符 (sn),以在匹配时疏忽一些看不见的字符。上述代码应用了 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 节点的款式设置,这个能够依据本人的逻辑来设置。

三、残缺代码调用

上述步骤形容了 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


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

正文完
 0