“高亮”功能,個人覺得沒必要再解釋什么了。作為一名程序猿,天天都會接觸高亮:寫代碼時的語法高亮;使用搜索引擎時的搜索結果高亮。作為一名前端,如果你做過與搜索相關的功能,那么你很有可能就實現過高亮,本文也主要從前端的角度復盤一下“高亮”功能實現的關鍵知識點。

高亮實現思路

對用戶的輸入進行分詞得到關鍵詞,根據關鍵詞搜索得到搜索結果。再次使用關鍵詞從搜索結果中找到匹配,對匹配加上高亮樣式,即完成高亮。細分這個過程,會有以下細節點:
-
對用戶輸入分詞得到關鍵詞
-
根據關鍵詞得到搜索結果
-
關鍵詞匹配
-
對匹配使用高亮樣式
前兩步一般在后台完成,后兩步才是我們前端的工作,下面通過具體例子來實際演練一下。
普通文本高亮
比如我們有這樣的文本:“我是中國人,我愛中華人民共和國,中華人民共和國萬歲!”,這時我們的關鍵詞是“我”,即要高亮文本中所有的我。代碼實現完整如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>普通文本高亮</title>
<style>
.keyword-match {
color: red;
}
</style>
</head>
<body>
<div>
原文本:我是中國人,我愛中華人民共和國,中華人民共和國萬歲!<br />
關鍵詞: 我 <br />
結果如下:<br /><br />
</div>
<div id="content"></div>
<script>
const content = document.getElementById('content');
const text = '我是中國人,我愛中華人民共和國,中華人民共和國萬歲!';
const keyword = '我';
// 根據關鍵詞,對匹配加上高亮樣式
let hightlightText = text.replace(new RegExp(`(${keyword})`, 'g'), `<span class="keyword-match">$1</span>`);
// 通過innerHTML寫入匹配后的內容
content.innerHTML = hightlightText;
</script>
</body>
</html>
上面的demo運行效果如下

上面的例子雖然很簡單,但已經完整實現了高亮功能,說明了高亮的實現原理。在實際應用中,我們的關鍵詞多半不會是一個簡簡單單的“我”,而是一個List,下面我們將關鍵詞改成:我,中華。只需稍加修改,就可以實現同時對“我”,“中華”兩個關鍵詞高亮。
<body>
<div>
原文本:我是中國人,我愛中華人民共和國,中華人民共和國萬歲!<br />
關鍵詞: 我,中華 <br />
結果如下:<br /><br />
</div>
<div id="content"></div>
<script>
const content = document.getElementById('content');
const text = '我是中國人,我愛中華人民共和國,中華人民共和國萬歲!';
const keyword = ['我', '中華']; // 關鍵詞是一個數組
// new RegExp(`(${keyword})` 改成 new RegExp(`(${keyword.join('|')})`
let hightlightText = text.replace(new RegExp(`(${keyword.join('|')})`, 'g'), `<span class="keyword-match">$1</span>`);
// 通過innerHTML寫入匹配后的內容
content.innerHTML = hightlightText;
</script>
</body>
運行效果:

上面的代碼似乎已經完美了。但如果我們將關鍵詞改成:我,中華,中華人民共和國。再次運行看結果,會發現“中華人民共和國”這個關鍵詞沒有被高亮,而“中華”高亮了,但這並不是我們想要的結果。
注意:如果一個關鍵詞包含另一個關鍵詞,要優先高亮長度較長的詞才對。要修復這個Bug也很簡單,只需要將字數多的關鍵詞放到關鍵詞數組的最前面即可。
<body>
<div>
原文本:我是中國人,我愛中華人民共和國,中華人民共和國萬歲!<br />
關鍵詞: 我,中華,中華人民共和國 <br />
結果如下:<br /><br />
</div>
<div id="content"></div>
<div id="content2"></div>
<script>
const content = document.getElementById('content');
const text = '我是中國人,我愛中華人民共和國,中華人民共和國萬歲!';
const keyword = ['我', '中華', '中華人民共和國']; // 關鍵詞是一個數組
let hightlightText = text.replace(new RegExp(`(${keyword.join('|')})`, 'g'), `<span class="keyword-match">$1</span>`);
// 通過innerHTML寫入匹配后的內容
content.innerHTML = hightlightText;
</script>
<script>
const content2 = document.getElementById('content2');
const text2 = '我是中國人,我愛中華人民共和國,中華人民共和國萬歲!';
const keyword2 = ['中華人民共和國', '中華', '我']; // 將字數多的關鍵詞放到前面,保證字數多的關鍵詞優先匹配
let hightlightText2 = text.replace(new RegExp(`(${keyword2.join('|')})`, 'g'), `<span class="keyword-match">$1</span>`);
content2.innerHTML = hightlightText2;
</script>
</body>
效果如下:

富文本高亮
說完了普通文本的高亮,我們來說說富文本的高亮。所謂富文本,即待高亮的字符串不再是單獨的文本,而是html代碼。如果你需要富文本編輯器,可以考慮百度的UEditor,富文本編輯器生成的代碼就是可以直接插入到頁面的html串。
// 普通文本
我是中國人,我愛中華人民共和國,中華人民共和國萬歲!
// 富文本,即html串
<div>我是<span style="font-size: 20px; font-weight: bold; background-color: cyan;">中國人</span>,我愛中華人民共和國,中華人民共和國萬歲!</div>
<body>
富文本高亮
<br />
<br />
<h3>未高亮</h3>
<div id="text">
<div style="font-style: italic;">我是<span style="font-size: 20px; font-weight: bold; background-color: cyan;">中國人</span>,我愛中華人民共和國,中華人民共和國萬歲!</div>
</div>
<br />
<h3>高亮效果</h3>
<div id="content"></div>
<script>
const content = document.getElementById('content');
const text = document.getElementById('text').innerHTML; // 富文本串
const keyword = ['中華人民共和國', '中華', '我'];
let hightlightText = text.replace(new RegExp(`(${keyword.join('|')})`, 'g'), `<span class="keyword-match">$1</span>`);
content.innerHTML = hightlightText;
</script>
</body>

這里的高亮實現直接使用了前面普通文本高亮,似乎也能正常工作。但前提是這個富文本串太簡單了。我們現在考慮這樣一種情況,如果富文本串中的元素屬性具有完全匹配關鍵詞的內容,會發生什么呢?
// 待高亮富文本串
<div style="font-style: italic;" data-attr="中華人民共和國">我是<span style="font-size: 20px; font-weight: bold; background-color: cyan;">中國人</span>,我愛中華人民共和國,中華人民共和國萬歲!</div>
對於上面的字符串,直接使用前面的高亮邏輯,得到的字符串是:
<div style="font-style: italic;" data-attr="<span class="keyword-match">中華人民共和國</span>"><span class="keyword-match">我</span>是<span style="font-size: 20px; font-weight: bold; background-color: cyan;">中國人</span>,<span class="keyword-match">我</span>愛<span class="keyword-match">中華人民共和國</span>,<span class="keyword-match">中華人民共和國</span>萬歲!</div>
顯然,第一個div屬性中的“中華人民共和國”不應該被匹配到。如果直接匹配會破壞原來的富文本串,使之不再是一個有效的html串。這是富文本高亮的最大難題,那這個難題怎么解決呢?
當然,第一反應可能就是改正則。但根據個人經驗,對於一個模式內包含自己時,正則幾乎無能為力。比如下面我們常見的字符串結構:
// 這是常見的less語法。現在如果要求使用正則將最未的選擇器名稱加上“$”,大家可以試一下,能否做到
// 原串
@media (max-width: 600px) {
.header {
.hd {
width: 100px;
}
.bd {
background-color: #fff;
}
}
}
// 要求結果
@media (max-width: 600px) {
.header {
.hd$ { // 加上$
width: 100px;
}
.bd$ {
background-color: #fff;
}
}
}
對於富文本串,它也可能是模式自包含的字符串類型,比如
// 這是一個標准得不能再標准的html串了
<div data-html="<div>hello world!</div>">hello world!</div>
如果要使用正則去匹配上面字符串的hello內容,這個正則應該怎么寫?先提醒一下,真正的富文本串要比這復雜太多太多,真實的用戶輸入也比這復雜太多太多。我當時做富文本高亮時,遇到這個問題也是一時找不到辦法。某天,突然靈光一閃,不要硬碰硬啊,曲線救國嘛(這其實應該早就想到,只是走入正則的死胡同了):如果能夠先把富文本串中的html標簽去掉,剩下的不就是普通文本了嗎?匹配普通文本簡直是不要太簡單了哦。匹配完成后,再把去掉的html還原,就完成高亮匹配了。不過這里有幾個難題:
-
如何去掉html標簽。別笑,這真心難,不信你試下
-
占位符一定要夠特殊,不能在關鍵詞匹配時被破壞
-
如何還原html串

根據上面的思路,完成了新一版的高亮邏輯,代碼如下:
富文本高亮,關鍵詞:['中華人民共和國', '中華', '我'];
<br />
<h3>未高亮</h3>
<div id="text">
<div style="font-style: italic;" data-attr="中華人民共和國">我是<span style="font-size: 20px; font-weight: bold; background-color: cyan;">中國人</span>,我愛中華人民共和國,中華人民共和國萬歲!</div>
</div>
<h3>高亮效果</h3>
<div id="content"></div>
<script>
const content = document.getElementById('content');
const text = document.getElementById('text').innerHTML; // 富文本串
const keyword = ['中華人民共和國', '中華', '我'];
let hightlightText = hightlightKeyword(text, keyword.join('|'));
content.innerHTML = hightlightText;
/**
* 高亮
* @param input - 待高亮的富文本串
* @param keyword - 由關鍵詞生成的匹配串,格式 'xxxx|xxx|x'
* @returns {string}
*/
function hightlightKeyword(input, keyword) {
let store = {
length: 0
};
try {
return input
.replace(/^\s+/, ' ') // 去掉多余的空白
// 去掉Html標簽,並使用特殊占位符占位,方便后面還原
.replace(/(<\w+[^>]*?>)|(<\/\w+[^>]*?>)/g, function(match) {
var key = '\t' + store.length++; // 注意這里使用了\t
store[key] = match;
return key;
})
// 關鍵詞高亮
.replace(new RegExp('(' + keyword + ')', 'gi'), '<span class="keyword-match">' + '$1' + '</span>')
// html標簽還原
.replace(/\t\d+/g, function(match) {
return store[match] || '';
});
} catch (e) {
return input;
}
}
</script>
上面的hightlightKeyword函數已經能夠滿足大多數情況下富文本高亮,博主曾經負責的一個項目,使用這個高亮邏輯安全運行1年多,也沒出現大問題。但其實,上面的高亮邏輯還是有bug,對於一些十分特殊的富文本串還是存在問題,比如下面的富文本串
<div>
<div data-html="<div>hello world!</div>">
hello world!
<div data-html="<div>hello world!</div>">hello world!</div>
</div>
</div>
經過多次嘗試,碰壁,最后決定借用瀏覽器來將html轉成DOM,通過DOM操作來完成高亮。下面是高亮終極版本
<body>
富文本高亮,關鍵詞:['中華人民共和國', '中華', '我'];
<br />
<h3>未高亮</h3>
<div id="text">
<div data-html="<div>hello world!</div>">hello world!</div>
<div>
<div data-html="<div>hello world!</div>">
hello world!
<div data-html="<div>hello world!</div>">hello world!</div>
</div>
</div>
<div style="font-style: italic;" data-attr="中華人民共和國">
我是
<span style="font-size: 20px; font-weight: bold; background-color: cyan;">中國人</span>
,我愛中華人民共和國,中華人民共和國萬歲!
</div>
</div>
<h3>高亮效果</h3>
<div id="content"></div>
<script>
const content = document.getElementById('content');
const text = document.getElementById('text').innerHTML; // 富文本串
const keyword = ['中華人民共和國', 'hello', '中華', '我'];
let hightlightText = hightlightKeyword(text, keyword.join('|'));
content.innerHTML = hightlightText;
/**
* 借助瀏覽器完成高亮。
* 深度優先遍歷所有的節點,對文本節點進行高亮
*
* @param input - 待高亮的富文本串
* @param keyword - 由關鍵詞生成的匹配串,格式 'xxxx|xxx|x'
* @returns {string}
*/
function hightlightKeyword(html, keyword) {
// 復制一個節點去進行遍歷操作
let wrap = document.createElement('div');
wrap.innerHTML = html;
return DFSTraverseAndHightlight(wrap);
function DFSTraverseAndHightlight (node) {
const rootNodes = node.childNodes;
const childNodes = Array.from(rootNodes);
for(let i = 0, len = childNodes.length; i < len; i++) {
const node = childNodes[i];
// 文本節點,要進行高亮
if (node.nodeType === 3) {
let span = document.createElement('span');
let a = span.innerHTML = node.nodeValue.replace(new RegExp(`(${keyword})`, 'g'), `<span class="keyword-match">$1</span>`);
console.log(node.nodeValue);
node.parentNode.insertBefore(span, node);
node.parentNode.removeChild(node);
}
//文本節點不會有childNodes屬性,如果有子節點,繼續遍歷
if (node.childNodes.length) {
DFSTraverseAndHightlight(node);
}
}
return node.innerHTML;
}
}
</script>
</body>
簡單的分詞實現
文章最開始說了,分詞邏輯一般是后台通過專門的庫來完成的。但其實,前端也可以自己實現一個簡單的分詞,只不過會產生許多無意義的詞而已,思路大家一看就明白了
// 簡單,粗暴分詞
function splitWord(word) {
var len = word.length,
splitWordList = word.split(''); // 一字分組
// 分詞
for(var i = 2; i <= len; i++) {
for (var j = 0; j + i <= len; j++) {
splitWordList.push(word.slice(j, j+i));
}
}
// 必須把長度最長的放到最前面,否則會造成匹配不全的情況
return splitWordList.reverse();
}
運行效果

小結
本文主要從前端的角度,介紹了如何實現高亮功能,包括普通文本高亮和富文本高亮。關鍵的知識點是:
-
利用new RegExp((${keyword})
, 'g')方式動態創建正則
-
利用str.replace(regexp, <span class="keyword-match">$1</span>
)為匹配加上高亮樣式
-
最長的關鍵詞一定要優先匹配,否則會造成匹配不全的情況
-
富文本匹配,用正則很難做到100%精確。但如果有瀏覽器環境,可以借助瀏覽器先將富文本串轉換成DOM,通過DOM操作來實現一個更精確的富文本高亮
-
前端也可以自己實現分詞,只不過會產生大量無意義詞組而已
原文鏈接:https://segmentfault.com/a/1190000009956571?utm_source=sf-similar-article