很久之前(好像剛好是一年前)寫過一個(gè)Vue組件,匹配文本內(nèi)容中的關(guān)鍵詞高亮,類似瀏覽器ctrl+f搜索結(jié)果。實(shí)現(xiàn)方案是,將文本字符串中的關(guān)鍵字搜索出來,然后使用特殊的標(biāo)簽(比如font標(biāo)簽)包裹關(guān)鍵詞替換匹配內(nèi)容,最后得到一個(gè)HTML字符串,渲染該字符串并在font標(biāo)簽上使用CSS樣式即可實(shí)現(xiàn)高亮的效果。
當(dāng)時(shí)的實(shí)現(xiàn)過于簡單,沒有支持接收HTML字符串作為內(nèi)容進(jìn)行關(guān)鍵詞匹配。這兩天有同學(xué)問到,就又思考了這個(gè)問題,發(fā)現(xiàn)并不是那么麻煩,寫了幾行代碼解決了一下。
對于純文本字符串,如:“江畔何人初見月?江月何年初照人? ”,假如我們想匹配“江月”這個(gè)關(guān)鍵字,則匹配結(jié)果可處理為:
江畔何人初見月?<font style="background: #ff9632">江月</font>何年初照人?
這樣“江月”兩個(gè)字被font標(biāo)簽包裹,在font標(biāo)簽上應(yīng)用特殊的背景樣式以達(dá)到關(guān)鍵字高亮的效果。
對于上述例子,如果內(nèi)容字符串是一個(gè)HTML文本:
江畔何人初見<b>月</b>?江<b>月</b>何年初照人?
對于同樣的關(guān)鍵詞“江月”,怎樣處理它呢?因?yàn)殛P(guān)鍵詞中的字在不同的標(biāo)簽內(nèi),所以只能分別用font標(biāo)簽進(jìn)行替換:
江畔何人初見<b>月</b>?<font style="background: #ff9632">江</font><b><font style="background: #ff9632">月</font></b>何年初照人?
這是比較簡單的情況,實(shí)際情況下關(guān)鍵字則可能跨多級、多層標(biāo)簽。
跨標(biāo)簽解析關(guān)鍵詞,其實(shí)就是對于匹配到的關(guān)鍵詞,提取出各標(biāo)簽中對應(yīng)的子片段,然后用font之類的標(biāo)簽包裹,再將高亮樣式用于font標(biāo)簽即可。
對于整個(gè)HTML內(nèi)容而言,渲染出來的文本由各類標(biāo)簽內(nèi)的文本節(jié)點(diǎn)組成。因?yàn)殛P(guān)鍵詞匹配的內(nèi)容會跨標(biāo)簽,所以需要將各文本節(jié)點(diǎn)有序取出,并將節(jié)點(diǎn)內(nèi)容拼接起來進(jìn)行匹配。拼接時(shí)記下節(jié)點(diǎn)文本在拼接串中的起止位置,以便關(guān)鍵詞匹配到拼接串的某位置時(shí)截取文本片段并使用font標(biāo)簽包裹。
深度優(yōu)先可以采用循環(huán)或者遞歸的方式遍歷,這里采用循環(huán)實(shí)現(xiàn),按取出某個(gè)元素下所有文本節(jié)點(diǎn)(利用nodeType判斷文本節(jié)點(diǎn)):
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
}
獲取到了文本節(jié)點(diǎn)列表,可以取出所有文本內(nèi)容并記錄每個(gè)文本片段在拼接結(jié)果中的開始、結(jié)束索引:
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('')
獲得了拼接文本,可以利用拼接文本獲取所有的拼接結(jié)果了。這里偷個(gè)懶直接用正則匹配吧,得把正則用到的一些特殊符號進(jìn)行轉(zhuǎn)義一下:
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')
const matchList = []
let match = reg.exec(content)
while (match) {
matchList.push(match)
match = reg.exec(content)
}
return matchList
},
關(guān)鍵詞字符轉(zhuǎn)義處理后,字符與字符之間中間插入了正則中的空白符和換行符(\s\n),以在匹配時(shí)忽略一些看不見的字符。上述代碼循環(huán)使用使用RegExp.prototype.exec匹配拼接字符串中的所有關(guān)鍵詞,每一次匹配的結(jié)果中都包含了本次匹配到的文本、匹配索引等,一個(gè)簡單的例子:
注:String.prototype.matchAll可以一次查出所有匹配結(jié)果,但是瀏覽器兼容性不好,matchAll的匹配示例:
根據(jù)關(guān)鍵詞匹配結(jié)果索引,以及每個(gè)文本節(jié)點(diǎn)的起止索引,可以計(jì)算出每個(gè)關(guān)鍵詞匹配了哪幾個(gè)文本節(jié)點(diǎn),其中對于開始和結(jié)束的文本節(jié)點(diǎn),可能只是部分匹配到,而中間的文本節(jié)點(diǎn)的所有內(nèi)容都是匹配到的。
比如對于HTML文本:
<span>江畔何人初見<b>月</b>?江月何年初照人?</span>
其DOM樹對應(yīng)的的文本節(jié)點(diǎn)有3個(gè):
假如關(guān)鍵字是“何人初見月?”,那此時(shí),對于第一個(gè)文本節(jié)點(diǎn)匹配了后半部分,第二個(gè)文本節(jié)點(diǎn)完全匹配,第三個(gè)文本節(jié)點(diǎn)匹配了第一個(gè)字符。三個(gè)節(jié)點(diǎn)中匹配的部分需要分別用font標(biāo)簽替換:
<span>江畔<font>何人初見</font><b><font>月</font></b><font>?</font>江月何年初照人?</span>
默認(rèn)情況下,連續(xù)的文字會在同一個(gè)文本節(jié)點(diǎn)中,而對于匹配了部分內(nèi)容的文本節(jié)點(diǎn),就需要將它一分為二,可以利用Text.splitText()API來分割文本節(jié)點(diǎn),API接收一個(gè)索引值,從索引位置將文本節(jié)點(diǎn)后半部分切割并返回包含后半部分內(nèi)容的新文本節(jié)點(diǎn)。上述例子中匹配的是3個(gè)節(jié)點(diǎn),拆分后就會得到5個(gè)文本節(jié)點(diǎn):
中間三個(gè)文本節(jié)點(diǎn)即是需要被替換的節(jié)點(diǎn),使用replaceChild就可以直接將文本節(jié)點(diǎn)替換為font標(biāo)簽。
對于整個(gè)HTML字符串,同一個(gè)關(guān)鍵詞可能同時(shí)有多處匹配結(jié)果,因此要對所有匹配結(jié)果進(jìn)行上述處理。使用前幾步獲取的textNodes、textList、matchList,代碼實(shí)現(xiàn)如下:
function replaceMatchResult (textNodes, textList, matchList) {
// 對于每一個(gè)匹配結(jié)果,可能分散在多個(gè)標(biāo)簽中,找出這些標(biāo)簽,截取匹配片段并用font標(biāo)簽替換出
for (let i = matchList.length - 1; i >= 0; i--) {
const match = matchList[i]
const matchStart = match.index, matchEnd = matchStart + match[0].length // 匹配結(jié)果在拼接字符串中的起止索引
// 遍歷文本信息列表,查找匹配的文本節(jié)點(diǎn)
for (let textIdx = 0; textIdx < textList.length; textIdx++) {
const { text, startIdx, endIdx } = textList[textIdx] // 文本內(nèi)容、文本在拼接串中開始、結(jié)束索引
if (endIdx < matchStart) continue // 匹配的文本節(jié)點(diǎn)還在后面
if (startIdx >= matchEnd) break // 匹配文本節(jié)點(diǎn)已經(jīng)處理完了
let textNode = textNodes[textIdx] // 這個(gè)節(jié)點(diǎn)中的部分或全部內(nèi)容匹配到了關(guān)鍵詞,將匹配部分截取出來進(jìn)行替換
const nodeMatchStartIdx = Math.max(0, matchStart - startIdx) // 匹配內(nèi)容在文本節(jié)點(diǎn)內(nèi)容中的開始索引
const nodeMatchLength = Math.min(endIdx, matchEnd) - startIdx - nodeMatchStartIdx // 文本節(jié)點(diǎn)內(nèi)容匹配關(guān)鍵詞的長度
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)
}
}
}
代碼里對匹配結(jié)果遍歷時(shí),采用的是倒序遍歷,原因是遍歷過程對textNodes存在副作用:在遍歷中會對textNodes中的文本節(jié)點(diǎn)進(jìn)行切割。假設(shè)同一個(gè)文本節(jié)點(diǎn)中有多處匹配,會進(jìn)行多次分割,而textNodes里引用的是原文本節(jié)點(diǎn)即前半部分,因此從后往前遍歷會確保未處理的匹配文本節(jié)點(diǎn)的完整。
同時(shí)代碼中省去了font節(jié)點(diǎn)的樣式設(shè)置,這個(gè)可以根據(jù)自己的邏輯來設(shè)置。
上述步驟描述了HTML字符串跨標(biāo)簽匹配關(guān)鍵詞的所有流程實(shí)現(xiàn),下面是完整的代碼調(diào)用示例:
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
}
輸入一個(gè)HTML字符串和關(guān)鍵詞,將HTML串中的關(guān)鍵詞用font標(biāo)簽包裹后返回。
上述實(shí)現(xiàn)方案中有一些簡單的細(xì)節(jié)省去了,比如設(shè)置font標(biāo)簽的樣式、隱藏的dom匹配時(shí)忽略等。
font標(biāo)簽樣式設(shè)置看使用場景吧,如果是長HTML字符串匹配建議是不要直接設(shè)置style屬性,而是操作樣式表來達(dá)到目的??梢越ofont標(biāo)簽設(shè)置特殊的屬性,然后使用屬性選擇器來設(shè)置樣式。比如可以給font設(shè)置highlight="${i}"屬性,來針對匹配的關(guān)鍵詞應(yīng)用不同的樣式。操作樣式表可以給style標(biāo)簽設(shè)置innerText或者調(diào)用CSSStyleSheet.insertRule()和CSSStyleSheet.deleteRule()。
demo: https://wintc.top/laboratory/#/search-highlight
github查看源碼:https://github.com/wintc23/vue-search-highlight