【W(wǎng)eb技術(shù)】791- HTML字符串中匹配關(guān)鍵詞高亮

來(lái)源:木馬啊
轉(zhuǎn)載自:https://wintc.top/article/59
很久之前寫(xiě)過(guò)一個(gè)Vue組件,可以匹配文本內(nèi)容中的關(guān)鍵詞高亮,類(lèi)似瀏覽器ctrl+f搜索結(jié)果。實(shí)現(xiàn)方案是,將文本字符串中的關(guān)鍵字搜索出來(lái),然后使用特殊的標(biāo)簽(比如font標(biāo)簽)包裹關(guān)鍵詞替換匹配內(nèi)容,最后得到一個(gè)HTML字符串,渲染該字符串并在font標(biāo)簽上使用CSS樣式即可實(shí)現(xiàn)高亮的效果。
當(dāng)時(shí)的實(shí)現(xiàn)過(guò)于簡(jiǎn)單,沒(méi)有支持接收HTML字符串作為內(nèi)容進(jìn)行關(guān)鍵詞匹配。這兩天有同學(xué)問(wèn)到,就又思考了這個(gè)問(wèn)題,發(fā)現(xiàn)并不是那么麻煩,寫(xiě)了幾行代碼解決一下。
一、匹配關(guān)鍵字:HTML字符串與文本字符串對(duì)比
1. 純文本字符串的處理
對(duì)于純文本字符串,如:“江畔何人初見(jiàn)月?江月何年初照人?”,假如我們想匹配“江月”這個(gè)關(guān)鍵字,則匹配結(jié)果可處理為:
江畔何人初見(jiàn)月?<font?style="background:?#ff9632">江月font>何年初照人?
這樣“江月”兩個(gè)字被font標(biāo)簽包裹,在font標(biāo)簽上應(yīng)用特殊的背景樣式以達(dá)到關(guān)鍵字高亮的效果。
2. 對(duì)HTML字符串的處理
對(duì)于上述例子,如果內(nèi)容字符串是一個(gè)HTML文本:
江畔何人初見(jiàn)<b>月b>?江<b>月b>何年初照人?
對(duì)于同樣的關(guān)鍵詞“江月”,怎樣處理它呢?因?yàn)殛P(guān)鍵詞中的字在不同的標(biāo)簽內(nèi),所以只能分別用font標(biāo)簽進(jìn)行替換:
江畔何人初見(jiàn)<b>月b>?<font?style="background:?#ff9632">江font><b><font?style="background:?#ff9632">月font>b>何年初照人?
這是比較簡(jiǎn)單的情況,實(shí)際情況下關(guān)鍵字則可能跨多級(jí)、多層標(biāo)簽。
二、跨標(biāo)簽匹配關(guān)鍵詞
跨標(biāo)簽解析關(guān)鍵詞,其實(shí)就是對(duì)于匹配到的關(guān)鍵詞,提取出各標(biāo)簽中對(duì)應(yīng)的子片段,然后用font之類(lèi)的標(biāo)簽包裹,再將高亮樣式用于font標(biāo)簽即可。
對(duì)于整個(gè)HTML內(nèi)容而言,渲染出來(lái)的文本由各類(lèi)標(biāo)簽內(nèi)的文本節(jié)點(diǎn)組成。因?yàn)殛P(guān)鍵詞匹配的內(nèi)容會(huì)跨標(biāo)簽,所以需要將各文本節(jié)點(diǎn)有序取出,并將節(jié)點(diǎn)內(nèi)容拼接起來(lái)進(jìn)行匹配。拼接時(shí)記下節(jié)點(diǎn)文本在拼接串中的起止位置,以便關(guān)鍵詞匹配到拼接串的某位置時(shí)截取文本片段并使用font標(biāo)簽包裹。
1. 深度優(yōu)先遍歷DOM樹(shù)取出文本節(jié)點(diǎn)
深度優(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
}
2. 取出所有文本內(nèi)容進(jìn)行拼接
獲取到了文本節(jié)點(diǎn)列表,可以取出所有文本內(nèi)容并記錄每個(gè)文本片段在拼接結(jié)果中的開(kāi)始、結(jié)束索引:
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. 匹配關(guān)鍵詞
獲得了拼接文本,可以利用拼接文本獲取所有的拼接結(jié)果了。這里偷個(gè)懶直接用正則匹配吧,得把正則用到的一些特殊符號(hào)進(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')
??return?[...content.matchAll(reg)]?//?matchAll結(jié)果是個(gè)迭代器,用擴(kuò)展符展開(kāi)得到數(shù)組
}
關(guān)鍵詞字符轉(zhuǎn)義處理后,字符與字符之間中間插入了正則中的空白符和換行符(\s\n),以在匹配時(shí)忽略一些看不見(jiàn)的字符。上述代碼使用了matchAll函數(shù),匹配結(jié)果展開(kāi)后得到的結(jié)果是一個(gè)數(shù)組,數(shù)組中的每一項(xiàng)都包含了匹配文本、匹配索引等。matchAll的一個(gè)簡(jiǎn)單例子:

4. 關(guān)鍵詞使用font標(biāo)簽替換
根據(jù)關(guān)鍵詞匹配結(jié)果索引,以及每個(gè)文本節(jié)點(diǎn)的起止索引,可以計(jì)算出每個(gè)關(guān)鍵詞匹配了哪幾個(gè)文本節(jié)點(diǎn),其中對(duì)于開(kāi)始和結(jié)束的文本節(jié)點(diǎn),可能只是部分匹配到,而中間的文本節(jié)點(diǎn)的所有內(nèi)容都是匹配到的。
比如對(duì)于HTML文本:
<span>江畔何人初見(jiàn)<b>月b>?江月何年初照人?span>
其DOM樹(shù)對(duì)應(yīng)的的文本節(jié)點(diǎn)有3個(gè):

假如關(guān)鍵字是“何人初見(jiàn)月?”,那此時(shí),對(duì)于第一個(gè)文本節(jié)點(diǎn)匹配了后半部分,第二個(gè)文本節(jié)點(diǎn)完全匹配,第三個(gè)文本節(jié)點(diǎn)匹配了第一個(gè)字符。三個(gè)節(jié)點(diǎn)中匹配的部分需要分別用font標(biāo)簽替換:
<span>江畔<font>何人初見(jiàn)font><b><font>月font>b><font>?font>江月何年初照人?span>
默認(rèn)情況下,連續(xù)的文字會(huì)在同一個(gè)文本節(jié)點(diǎn)中,而對(duì)于匹配了部分內(nèi)容的文本節(jié)點(diǎn),就需要將它一分為二,可以利用Text.splitText()")API來(lái)分割文本節(jié)點(diǎn),API接收一個(gè)索引值,從索引位置將文本節(jié)點(diǎn)后半部分切割并返回包含后半部分內(nèi)容的新文本節(jié)點(diǎn)。上述例子中匹配的是3個(gè)節(jié)點(diǎn),拆分后就會(huì)得到5個(gè)文本節(jié)點(diǎn):

中間三個(gè)文本節(jié)點(diǎn)即是需要被替換的節(jié)點(diǎn),使用replaceChild就可以直接將文本節(jié)點(diǎn)替換為font標(biāo)簽。
對(duì)于整個(gè)HTML字符串,同一個(gè)關(guān)鍵詞可能同時(shí)有多處匹配結(jié)果,因此要對(duì)所有匹配結(jié)果進(jìn)行上述處理。使用前幾步獲取的textNodes、textList、matchList,代碼實(shí)現(xiàn)如下:
function?replaceMatchResult?(textNodes,?textList,?matchList)?{
??//?對(duì)于每一個(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???????const?{?text,?startIdx,?endIdx?}?=?textList[textIdx]?//?文本內(nèi)容、文本在拼接串中開(kāi)始、結(jié)束索引
??????if?(endIdx?continue?//?匹配的文本節(jié)點(diǎn)還在后面
??????if?(startIdx?>=?matchEnd)?break?//?匹配文本節(jié)點(diǎn)已經(jīng)處理完了
??????let?textNode?=?textNodes[textIdx]?//?這個(gè)節(jié)點(diǎn)中的部分或全部?jī)?nèi)容匹配到了關(guān)鍵詞,將匹配部分截取出來(lái)進(jìn)行替換
??????const?nodeMatchStartIdx?=?Math.max(0,?matchStart?-?startIdx)?//?匹配內(nèi)容在文本節(jié)點(diǎn)內(nèi)容中的開(kāi)始索引
??????const?nodeMatchLength?=?Math.min(endIdx,?matchEnd)?-?startIdx?-?nodeMatchStartIdx?//?文本節(jié)點(diǎn)內(nèi)容匹配關(guān)鍵詞的長(zhǎng)度
??????if?(nodeMatchStartIdx?>?0)?textNode?=?textNode.splitText(nodeMatchStartIdx)?//?textNode取后半部分
??????if?(nodeMatchLength???????const?font?=?document.createElement('font')
??????font.innerText?=?text.substr(nodeMatchStartIdx,?nodeMatchLength)
??????textNode.parentNode.replaceChild(font,?textNode)
????}
??}
}
代碼里對(duì)匹配結(jié)果遍歷時(shí),采用的是倒序遍歷,原因是遍歷過(guò)程對(duì)textNodes存在副作用:在遍歷中會(huì)對(duì)textNodes中的文本節(jié)點(diǎn)進(jìn)行切割。假設(shè)同一個(gè)文本節(jié)點(diǎn)中有多處匹配,會(huì)進(jìn)行多次分割,而textNodes里引用的是原文本節(jié)點(diǎn)即前半部分,因此從后往前遍歷會(huì)確保未處理的匹配文本節(jié)點(diǎn)的完整。
同時(shí)代碼中省去了font節(jié)點(diǎn)的樣式設(shè)置,這個(gè)可以根據(jù)自己的邏輯來(lái)設(shè)置。
三、完整代碼調(diào)用
上述步驟描述了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)簽包裹后返回。
四、總結(jié)
上述實(shí)現(xiàn)方案中有一些簡(jiǎn)單的細(xì)節(jié)省去了,比如設(shè)置font標(biāo)簽的樣式、隱藏的dom匹配時(shí)忽略等。
font標(biāo)簽樣式設(shè)置看使用場(chǎng)景吧,如果是長(zhǎng)HTML字符串匹配建議是不要直接設(shè)置style屬性,而是操作樣式表來(lái)達(dá)到目的??梢越ofont標(biāo)簽設(shè)置特殊的屬性,然后使用屬性選擇器來(lái)設(shè)置樣式。比如可以給font設(shè)置highlight="${i}"屬性,來(lái)針對(duì)匹配的關(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/Lushenggang/vue-search-highlight

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點(diǎn)擊“閱讀原文”查看 80+ 篇原創(chuàng)文章
