1. 程式人生 > >高亮:單關鍵詞、多關鍵詞、多組多關鍵詞,從簡單到複雜實現滿足多方面需求的頁面關鍵詞高亮

高亮:單關鍵詞、多關鍵詞、多組多關鍵詞,從簡單到複雜實現滿足多方面需求的頁面關鍵詞高亮

前言

我的前言都是良心話,還是姑且看一下吧:

別人一看這個標題,心想,“怎麼又是一個老到掉牙的需求,網上一搜一大堆解決方案啦!”。沒錯!這個需求實在老土得不能再老土了,我真不想寫這樣一種需求的文章,無奈!無奈!

現實的情況是我想這麼舊的需求網上資料一大把一大把,雖然我知道網上資料可能有坑,但是我總不信找不到一篇好的全面的資料的。然而現實又是一次啪啪的打臉,我是沒找到,而且很多資料都是一個拷貝一個,質量參差不齊,想必很多找資料的人也深有體會

為了讓別人不再走我的老路,特此寫了此篇文章和大家分享

我不能說我寫的文章質量槓槓滴。但是我會在這裡,客觀地指出我方案的缺點,不忽悠別人。

寫該文章的目的

只有兩個:

  • 讓缺乏這方面經驗的人能夠信手拈來一個較為全面的方案,對自己對公司相對負責,別qa提很多bug啦(我也是這麼過來,純粹想幫助小白)
  • 讓更有能力的人,補充完善我的方案,或者借鑑我的經驗,造出更強更全面的方案,當然,我也希望能讓我學習一下就最好了。

目錄

需求

還是說一下這到底是個什麼需求吧。想必大家都試過在一個網頁上,按下“ctrl + F”,然後輸入關鍵詞來找到頁面上匹配的。

沒錯,就是這麼一種類似的簡單的需求。但是這麼一個簡單的需求,卻暗藏殺機。這種需求(非就是這種形式)用文字明確描述一下:

頁面上有一個按鈕,或者一個輸入框,進行操作時,針對某些關鍵詞(任意字串都可以,除換行符),在頁面上進行高亮顯示,注意此頁面內容是有任何可能的網頁

描述很抽象?那我就乾脆定一個明確的需求:

實現一個外掛,在任何別人的網頁上高亮想要的關鍵詞。

這裡不說實現外掛的本身,只描述高亮的方案。

接下來我將循序漸進地從一個個簡單的需求到複雜的需求,告訴你這裡邊到底需要考慮什麼。

一個最簡單的方案

第一反應,想必大家都覺得用字串來處理了吧,在字串裡找到匹配的文字,然後用一個html元素包圍著,加上類名,css高亮!對吧,一切都感覺如此自然順利~
我先不說這方案的雞肋之處,光說落實到實際處理的時候,需要做些什麼。

超簡單處理

// js
var keyword = '關鍵詞1';    // 假設這裡的關鍵詞為“關鍵詞1”
var bodyContent = document.body.innerHTMl;  // 獲取頁面內容
var contentArray = bodyContent.split(keyword);
document.body.innerHTMl = contentArray.join('<span>' + keyword + '</span>');
複製程式碼
// css
.highlight {
    background: yellow;
    color: red;
}
複製程式碼

簡單處理二

這裡相對上面還沒那麼簡單,至於為啥我說這個方案的原因是,在後面講的複雜方案裡,需要用到這些知識。

關鍵詞的處理

上面說需求的時候講過,是針對任意關鍵詞(除換行符)進行的高亮,如果更簡單點,說只針對英文或中文,那麼可以直接匹配了,如str.match('keyword');。但是我們是要做一個通用的功能的話,還是要特別針對一些轉義字元做處理的,不然如關鍵詞為?keyword',用'?keyword'.match('?keyword');,會報錯。

我找了各種特殊字元進行了測試,最終形成了以下方法針對各種特殊字元進行了處理。

// string為原本要進行匹配的關鍵詞
// 結果transformString為進行處理後的要用來進行匹配的關鍵詞
var transformString = string.replace(/[.[*?+^$|()/]|\]|\\/g, '\\$&');
複製程式碼

看不懂?想深究,可以看一下這邊文章: 這是一篇男女老少入門精通咸宜的正則筆記
反正這裡的意思就是把各種轉義字元變成普通字元,以便可以匹配出來。

匹配高亮

// js部分
var bodyContent = document.body.innerHTMl;  // 獲取頁面內容
var pattern = new RegExp(transformString, 'g'); // 生成正則表示式
// 匹配關鍵詞並替換
document.body.innerHTMl = bodyContent.replace(pattern, '<span class="highlight">$&</span>');
複製程式碼
// css
.highlight {
    background: yellow;
    color: red;
}
複製程式碼

缺點

把頁面的內容當成一個字串來處理,存在很多預想不到的情況。

  • script標籤內有匹配文字,新增高亮html元素後,導致指令碼報錯。
  • 標籤屬性(特別是自定義屬性,如dats-*)存在匹配文字,新增高亮後,破壞原有功能
  • 剛好匹配文字跟某內聯樣式文字匹配上,如<div style="width: 300px;"></div>,關鍵詞剛好為width,這時候就尷尬了,替換結果為<div style="<span class="highlight">width</span>: 300px;"><div。這樣就破壞了原本的樣式了。
  • 還有一種情況,如<div>右</div>,關鍵詞為>右,這時候替換結果為<div<span class="highlight">>右</span></div>,同樣破壞了結構。
  • 以及還有很多很多情況,以上僅是我羅列的一些,未知的情況實在太多了

利用DOM節點高亮(基礎版)

既然字串的方法太多弊端了,那隻能捨棄掉了,另尋他法。 這節內容就考大家的基礎知識扎不紮實了

頁面的內容有一個DOM樹構成,其中有一種節點叫文字節點,就是我們頁面上所能看到的文字(大部分,圖片等除外),那麼我們只要在這些文字節點裡找到是否有我們匹配的關鍵詞,匹配上的就對該文字節點做改造就好了。

封裝一個函式做上述處理(註釋中一個個解釋), ①內容為上述講過:


// ①
// string為原本要進行匹配的關鍵詞
// 結果transformString為進行處理後的要用來進行匹配的關鍵詞
var transformString = string.replace(/[.[*?+^$|()/]|\]|\\/g, '\\$&');
var pattern = new RegExp(transformString, 'i'); // 這裡不區分大小寫

/**
 * ② 高亮關鍵字
 * @param node - 節點
 * @param pattern - 用於匹配的正則表示式,就是把上面的pattern傳進來
 */
function highlightKeyword(node, pattern) {
    // nodeType等於3表示是文字節點
    if (node.nodeType === 3) {
        // node.data為文字節點的文字內容
        var matchResult = node.data.match(pattern);
        // 有匹配上的話
        if (matchResult) {
            // 建立一個span節點,用來包裹住匹配到的關鍵詞內容
            var highlightEl = document.createElement('span');
            // 不用類名來控制高亮,用自定義屬性data-*來標識,
            // 比用類名更減少概率與原本內容重名,避免樣式覆蓋
            highlightEl.dataset.highlight = 'yes';
            // splitText相關知識下面再說,可以先去理解了再回來這裡看
            // 從匹配到的初始位置開始截斷到原本節點末尾,產生新的文字節點
            var matchNode = node.splitText(matchResult.index);
            // 從新的文字節點中再次截斷,按照匹配到的關鍵詞的長度開始截斷,
            // 此時0-length之間的文字作為matchNode的文字內容
            matchNode.splitText(matchResult[0].length);
            // 對matchNode這個文字節點的內容(即匹配到的關鍵詞內容)創建出一個新的文字節點出來
            var highlightTextNode = document.createTextNode(matchNode.data);
            // 插入到建立的span節點中
            highlightEl.appendChild(highlightTextNode);
            // 把原本matchNode這個節點替換成用於標記高亮的span節點
            matchNode.parentNode.replaceChild(highlightEl, matchNode);
        }
    } 
    // 如果是元素節點 且 不是script、style元素 且 不是已經標記過高亮的元素
    // 至於要區分什麼元素裡的內容不是你想要高亮的,可自己補充,這裡的script和style是最基礎的了
    // 不是已經標記過高亮的元素作為條件之一的理由是,避免進入死迴圈,一直往裡套span標籤
    else if ((node.nodeType === 1)  && !(/script|style/.test(node.tagName.toLowerCase())) && (node.dataset.highlight !== 'yes')) {
        // 遍歷該節點的所有子孫節點,找出文字節點進行高亮標記
        var childNodes = node.childNodes;
        for (var i = 0; i < childNodes.length; i++) {
            highlightKeyword(childNodes[i], pattern);
        }
    }
}
複製程式碼

注意這裡的pattern引數,就是上述關鍵詞處理後的正則表示式

/** css高亮樣式設定 **/
[data-highlight=yes] {
    display: inline-block;
    background: #32a1ff;
}
複製程式碼

這裡用的是屬性選擇器

splitText

這個方法針對文字節點使用,IE8+都能使用。它的作用是能把文字節點按照指定位置分離出另一個文字節點,作為其兄弟節點,即它們是同父同母哦~ 看圖理解更清楚:

雖然這個div原本是隻有一個文字節點,後來變成了兩個,但是對實際頁面效果,看起來還是一樣的。

語法

/**
 * @param offset 指定的偏移量,值為從0開始到字串長度的整數
 * @returns replacementNode - 截出的新文字節點,不含offset處文字
 */
replacementNode = textnode.splitText(offset)
複製程式碼

例子

<body>
  <p id="p">example</p>

  <script type="text/javascript">
    var p = document.getElementById('p');
    var textnode = p.firstChild;

    // 將原文字節點分割成為內容分別為exa和mple的兩個文字節點
    var replacementNode = textnode.splitText(3);

    // 建立一個包含了內容為' new span '的文字節點的span元素
    var span = document.createElement('span');
    span.appendChild(document.createTextNode(' new span '));
    // 將span元素插入到後一個文字節點('bar')的前面
    p.insertBefore(span, replacementNode);

    // 現在的HTML結構成了<p id="p">exa<span>new span</span>mple</p>
  </script>
</body>
複製程式碼

例子中的最後一個插入span節點的作用,就是讓大家看清楚,實際上原本一個文字節點“example”的確變成了兩個“exa”“mple”,不然加入的span節點不會處於二者中間了。

缺點

一個基礎版的高亮方案已經形成了,解決了上述用字串方案遇到的問題。然而,這裡也存在還需額外處理或考慮的事情。

這裡的方案一次性高亮是沒問題的,但是需要多次不同關鍵詞高亮呢?所以還缺少了關閉舊的高亮的操作,以及再次高亮時,會遇到什麼問題?最大的問題是,已經汙染過了的DOM樹

多次高亮

實現多高亮,就是實現第二次高亮的時候,把上一次的高亮痕跡給抹掉,這裡會有兩個思路:

  • 每一次高亮只對原始資料進行處理。
  • 需要一個關閉舊的高亮,然後重新對新關鍵詞高亮

只對原始資料處理

這個想法其實很好,因為感覺處理起來會很簡單,每次都用基礎版的高亮方案做一次就好了,也不存在什麼汙染DOM的問題(這裡說的是在已經汙染DOM的基礎上再處理高亮)。主要處理手段:

// 剛進入別人頁面時就要儲存原始DOM資訊了
const originalDom = document.querySelector('body').innerHTML;
複製程式碼
// 高亮邏輯開始...
let bodyNode = document.querySelector('body');
// 把原始DOM資訊重新賦予body
bodyNode.innerHTML = originalDom
// 把原始DOM資訊再次轉化為節點物件
let bodyChildren = bodyNode.childNodes;
// 針對內容進行高亮處理
for (var i = 0; i < bodyChildren.length; i++) {
    // 這裡的pattern就是上述經過處理後的關鍵詞生成的正則,不再贅述了
    highlightKeyword(bodyChildren[i], pattern);
}
複製程式碼

這裡就是做一次高亮的主要邏輯,如果要多次高亮,重複執行這裡的邏輯,把關鍵詞改變一下就好了。還有這裡需要理解的是,因為高亮的函式是針對節點物件來處理的,所以一定要把儲存起來的DOM資訊(此時為字串)再轉化為節點物件。

此方案的確很簡單,看似很完美,但是這裡還是有些問題不得不考慮一下:

  • 我一向不傾向這種把物件轉為字串再轉化為物件的做法,因為我不得知轉化裡頭會是否完全把資訊給搞過來還是會丟失一些資訊,正如大家常用的深拷貝一個方法JSON.parse(JSON.stringify())的弊端一樣。我們永遠不知道別人的網站是如何生成的,會不會根據一些剛好轉化時丟失的資訊來生成,這些我們都無法保證。因此我不太建議使用這種方法。在這次我這裡簡單做了個小測試,發現還是有些資訊會丟失,test的資訊不見了。
  • 在實際應用上,存在侷限性,例如有一個場景使用該方法不是個好主意:chrome extension是作為iframe嵌入到別人的網頁的。使用該方法的話,由於body直接通過innerHTML重新賦值了,頁面的內容會重新刷了一遍(瀏覽器效能不好的話可能還會看到一瞬間的閃爍),而這個外掛iframe也不例外,這樣的話,原本外掛上的未儲存內容或操作內容都會重新整理成初始情況了,反正就是把外掛iframe的情況也改了就不好了。

關閉舊高亮開啟新高亮

除了上述方法,還有這裡的一個方法。大家肯定想,關閉不就是設定高亮樣式沒了嘛,對的,是這樣的,但是總的想法歸總的想法,落實到實踐,要考慮的地方卻往往不像想象中那麼easy。總體思路很簡單,找到已經高亮的節點(dataset.highlight = 'yes'),然後設定為no就好了。

// 記住這個函式,下面不贅述,直接呼叫
function closeHighlight() {
    let highlightNodeList = document.querySelectorAll('[data-highlight=yes]');
    for (let n = 0; n < highlightNodeList.length; n++) {
        highlightNodeList[n].dataset.highlight = 'no';
    }
}
複製程式碼

然後針對新的關鍵詞高亮,再執行上述封裝的高亮函式。如意算盤敲得哐哐哐響, but...這樣就存在一些弊端了,DOM已經被汙染了,高亮次數越多,高亮的結果變得越不準確。例如:

1)高亮存在先後順序

<div>Hello, I'm pekonChan</div>
複製程式碼

針對上述DOM內容進行高亮,關鍵詞為“Hello”,高亮後變成:

<div><span data-highlight="yes">Hello</span>, I'm pekonChan</div>
複製程式碼

接著需要進行第二次高亮,關鍵詞改為“o,”,先關閉高亮:

<div><span data-highlight="no">Hello</span>, I'm pekonChan</div>
複製程式碼

接著遍歷節點找“o,”,咦?結果發現匹配結果是沒有的,因此剛好被第一次高亮的span標籤給隔斷了,但是檢視上顯示別人是不知道,只看到明明有“o,”卻不給我高亮出來。

即如果先高亮的剛好把第二次需要高亮的關鍵詞給截斷或者攔截了,就不能高亮了。

2)會出現一直巢狀的情況

如果剛好之後的高亮關鍵詞,是之前已經高亮過的關鍵詞,如還是上述例子,第三次高亮關鍵詞變成“Hello”,那麼運用上述封裝的基礎版高亮函式,結果為:

<div>
    <span data-highlight="no">
        <span data-highlight="yes">Hello</span>
    </span>
    , I'm pekonChan
</div>
複製程式碼

雖然這樣不會影響什麼很大的明顯效果,但是!過多的Dom結構導致頁面效能不好!重要的是!作為處女座的我(假裝處女座),不太能接受這樣無節制的巢狀下去。而且倒黴點的,還會造成樣式發生變化,例如別人的頁面剛好有類似這樣的處理:

 span > span {...}
 span span {...}
複製程式碼

3)之前高亮過的關鍵詞可能不再能高亮了

在這種情況下,第一次高亮關鍵詞為“Hello”,第二次為“lo”,如果第三次還是變回“Hello”,那麼,這時候就不能再高亮Hello了,因為被截斷了。

<div>
    <span data-highlight="no">Hel<span data-highlight="yes">lo</span></span>
    , I'm pekonChan
</div>
複製程式碼

小結

個人覺得,DOM汙染是個很嚴重的問題,如果你沒有第一個方法缺點的顧慮的話,還是強烈建議使用第一個方法的。如果實屬要考慮第二個方法,那麼很遺憾,關於“高亮存在先後順序”的這個問題,我暫且想不到什麼好辦法解決,不過針對另外兩個問題,是可以解決的,讓我們繼續往下增強上述的基礎版高亮函式吧。

增強版單關鍵詞高亮方案

區分第一次高亮和後續高亮

此方案建立在上述的 基礎版 之上以解決 關閉舊高亮開啟新高亮 產生的第2、3個問題。

思路:可以用一個數組儲存每次高亮的關鍵詞,每次在高亮前判斷當前需要高亮的關鍵詞是否存在該陣列內,如存在,即高亮過了。針對第二次高亮,就獲取當前data-highlight=no的節點(因為高亮前都需要關閉之前高亮的),然後匹配該節點內容是否跟高亮關鍵詞一致,如果是的話,就只需開啟data-highlight=yes即可了。

需要對基礎版高亮函式進行一點小改造(加一行程式碼):

function highlightKeyword(node, pattern) {
    if (node.nodeType === 3) {
        let matchResult = node.data.match(pattern);
        if (matchResult) {
            var highlightEl = document.createElement('span');
            highlightEl.dataset.highlight = 'yes';
            // 寫以下這一行程式碼,把匹配上的關鍵詞內容寫在dataset裡
            highlightEl.dataset.highlightMatch = matchResult[0];
            var matchNode = node.splitText(matchResult.index);
           ...
        }
    } 
    else if ...
}
複製程式碼

用以下方法處理已經高亮過的(①部分為上述講過,但是有點區分):

// ①
// string為原本要進行匹配的關鍵詞
let transformString = string.replace(/[.[*?+^$|()/]|\]|\\/g, '\\$&');
// 這裡有區分,變成頭尾都要匹配,用於第二次高亮開啟
let wholePattern = new RegExp(`^${transformString}$`, 'i');
// 用於第一次高亮匹配
let pattern = new RegExp(transformString, 'i');

// ②
// 先關閉先前的高亮
closeHighlight();
// 假設之前已經有兩個關鍵詞進行過高亮了
let keywordArray = ['Hello', 'ol'];
// 判斷這次高亮關鍵詞是否已經高亮過一次
if (keywordArray.indexOf(string) > -1) { // 如果存在
    // 對高亮過的需要再次高亮的標記為yes
    let unHighlightNodeList = document.querySelectorAll('[data-highlight=no]');
    for (let j = 0; j < unHighlightNodeList.length; j++) {
        if (wholePattern.test(unHighlightNodeList[j].dataset.highlightMatch)) {
            unHighlightNodeList[j].dataset.highlight = 'yes';
        }
    }
} else {
    // 用回上述的highlightKeyword函式處理
    // highlightKeyword();
}

複製程式碼

這裡有個注意的兩個小地方

  1. 為什麼pattern跟之前的會有區分,因為要完全符合(不能是包含關係)關鍵詞的時候才能設定節點高亮開啟。如關鍵詞為“Hello”,在下面元素裡是不能開啟為yes的
<div data-highlight="no" data-highlightMatch="showHello"></div>
複製程式碼
  1. 為什麼我這裡會選擇用dataset的形式存關鍵詞內容,可能大家會覺得直接判斷元素裡面的innerText或者firstChid文字節點不就好了嗎,實際上,這種情況就不好使了:
<div>
    <span data-highlight="no">Hel<span data-highlight="yes">lo</span></span>
    , I'm pekonChan
</div>
複製程式碼

當裡面的hello被拆成了幾個節點後,用innerText或者firstChid都不好使。

小結

至此,一個關於單個關鍵詞高亮的方案已經落幕了。有個選擇: 只對原始資料處理 和 這裡的增強版方案。各有優缺點,大家根據自己實際專案需求取捨,甚至要求更低的,直接採用最上面的各個簡單方案。

多個關鍵詞同時高亮

這裡的及以下的方案,都是基於DOM高亮—增強版方案下處理的。其實有了以上的基礎,接下來的需求都是錦上添花,不會過於複雜。

首先對關鍵詞的處理上:

// 要進行匹配的多個關鍵詞
let keywords = ['Hello', 'pekonChan'];
let wordMatchString = ''; // 用來形成最終多個關鍵詞特殊字元處理後的結果
keywords.forEach(item => {
    let transformString = item.replace(/[.[*?+^$|()/]|\]|\\/g, '\\$&');
    wordMatchString += `|(${transformString})`;
});
wordMatchString = wordMatchString.substring(1);
// 形成匹配多個關鍵詞的正則表示式,用於第一次高亮
let pattern = new RegExp(wordMatchString, 'i');
// 形成匹配多個關鍵詞的正則表示式(無包含關係),用於第二次高亮
let wholePattern = new RegExp(`^${wordMatchString}$`, 'i');
複製程式碼

之後的操作跟上述的增強版的方案的流程是一樣的,只是對關鍵詞的處理不同而已。

分組情況下的多個關鍵詞的高亮

這裡的需求我用例子來闡述,如圖


紅框部分是一個chrome擴充套件,左邊部分為任意的別人的網頁(高亮的頁面物件),擴充套件裡有一個表格,

  • 其中每行都會有一組關鍵詞,
  • 視角詞露出次數列上有個眼睛的圖示,點一下就開啟該行下的關鍵詞高亮,再點一下就關閉高亮。
  • 每行之間的高亮操作可以同時高亮,都是獨立操作的

我們先看一下我們已有的方案(在多個關鍵詞同時高亮方案的基礎上)在滿足以上需求的不足之處

例如第一組關鍵詞高亮了,設定為yes,第二組關鍵詞需要高亮的文字恰恰在第一組高亮文字內,是被包含關係。由於第一組關鍵詞高亮文字已經設為yes了,所以第二組關鍵詞開啟高亮模式的時候不會對第一組的已經高亮的節點繼續遍歷下去。不幸的是,這就造成了當第一組關鍵詞關閉高亮模式後,第二組雖然開始顯示為開啟高亮模式,但是由於剛剛沒有遍歷,所以原本應該在第一組高亮詞內高亮的文字,卻沒有高亮

文字不好理解?看例子,第一組關鍵詞(假設都為單個)為“可口可樂”,第二組為“可樂”

表格第一行開啟高亮模式,結果:

<div>
    <span data-highlight="yes" data-highlightMatch="Hello">可口可樂</span>
</div>
複製程式碼

接著,第二行也開啟高亮模式,執行highlightKeyword函式的else if這裡,由於可口可樂外層的span已經設為yes了,所以不再往下遍歷了。

function highlightKeyword(node, pattern) {
    if (node.nodeType === 3) {
        ...
    } else if ((node.nodeType === 1)  && !(/script|style/.test(node.tagName.toLowerCase())) && (node.dataset.highlight !== 'yes')) {
        ...
    }
}
複製程式碼

此時結果仍為:

<div>
    <span data-highlight="yes" data-highlightMatch="Hello">可口可樂</span>
</div>
複製程式碼

然而,當關閉第一行的高亮模式時,此時結果為:

<div>
    <span data-highlight="no" data-highlightMatch="Hello">可口可樂</span>
</div>
複製程式碼

但是我只關了第一行的高亮,第二行還是顯示這高亮模式,然而第二行的“可樂”關鍵詞卻沒有高亮。這就是弊端了!

設定分組

要解決上述問題,需要也為高亮的節點設定分組。highlightKeyword函式需要做點小改造,加個index引數,並繫結在dataset裡,else if的判斷條件也需要作出一些改變,都見註釋部分:

/**
 * 高亮關鍵字
 * @param node 節點
 * @param pattern 匹配的正則表示式
 * @param index - 表示第幾組關鍵詞
 */
function highlightKeyword(node, pattern, index) {
    if (node.nodeType === 3) {
        let matchResult = node.data.match(pattern);
        if (matchResult) {
            let highlightEl = document.createElement('span');
            highlightEl.dataset.highlight = 'yes';
            highlightEl.dataset.highlightMatch = matchResult[0];
            // 記錄第幾組關鍵詞
            highlightEl.dataset.highlightIndex = index; 
            let matchNode = node.splitText(matchResult.index);
            ...
        }
    } else if ((node.nodeType === 1)  && !(/script|style/.test(node.tagName.toLowerCase()))) {
        // 如果該節點為外掛的iframe,不做高亮處理
        if (node.className === 'extension-iframe') {
            return;
        }
        // 如果該節點標記為yes的同時,又是該組關鍵詞的,那麼就不做處理
        if (node.dataset.highlight === 'yes') {
            if (node.dataset.highlightIndex === index.toString()) {
                return;
            }
        }
        let childNodes = node.childNodes;
        for (let i = 0; i < childNodes.length; i++) {
            highlightKeyword(childNodes[i], pattern, index);
        }
    }
}
複製程式碼

這樣的話,包含在第一組關鍵詞裡的別組關鍵詞也可以繼續標為高亮了。

關閉高亮也要分組關閉

改造原本的關閉高亮函式closeHighlight,不能像之前那樣統一關閉了:

function closeHighlight() {
    let highlightNodeList = document.querySelectorAll('[data-highlight=yes]');
    for (let n = 0; n < highlightNodeList.length; n++) {
        // 這裡的wholePattern就是上述的完全匹配關鍵詞正則表示式
        if (wholePattern.test(highlightNodeList[n].dataset.highlightMatch)) {
            highlightNodeList[n].dataset.highlight = 'no';
        }
    }
}
複製程式碼

能返回匹配個數的高亮方案

看到上面的那個需求,表格視角詞露出次數列眼睛圖示旁邊還有個數字,這個其實就是能高亮的關鍵詞個數。那麼這裡也是做點小改造就能順帶計算出個數了(改動在註釋部分):

/**
 * 高亮關鍵字
 * @param node 節點
 * @param pattern 匹配的正則表示式
 * @param index - 表示第幾組關鍵詞
 * @returns exposeCount - 露出次數
 */
function highlightKeyword(node, pattern, index) {
    let exposeCount = 0;    // 露出次數變數
    if (node.nodeType === 3) {
        let matchResult = node.data.match(pattern);
        if (matchResult) {
            let highlightEl = document.createElement('span');
            highlightEl.dataset.highlight = 'yes';
            highlightEl.dataset.highlightMatch = matchResult[0];
            highlightEl.dataset.highlightIndex = index;
            let matchNode = node.splitText(matchResult.index);
            matchNode.splitText(matchResult[0].length);
            let highlightTextNode = document.createTextNode(matchNode.data);
            highlightEl.appendChild(highlightTextNode);
            matchNode.parentNode.replaceChild(highlightEl, matchNode);
            exposeCount++;  // 每高亮一次,露出次數加一次
        }
    } else if ((node.nodeType === 1)  && !(/script|style/.test(node.tagName.toLowerCase()))) {
        if (node.className === 'eod-extension-iframe') {
            return;
        }
        if (node.dataset.highlight === 'yes') {
            if (node.dataset.highlightIndex === index.toString()) {
                return;
            }
        }
        let childNodes = node.childNodes;
        for (let i = 0; i < childNodes.length; i++) {
            highlightKeyword(childNodes[i], pattern, index);
        }
    }
    return exposeCount; // 返回露出次數
}
複製程式碼

缺點

因為統計露出次數是跟著實際高亮一起統計的,而正如前面所說的,這種高亮方案存在 高亮存在先後順序 的問題,因此統計的個數也會不會準確。

如果你不在乎高亮個數和統計個數一定要一致的話,想要很精準的統計個數的話,我可以提供兩個思路,但由於篇幅問題,我就不寫出來了,看了這篇文章的都對我提的思路不會覺得很難,就是繁瑣而已:

  1. 運用上述的 只對原始資料處理 方案,針對每個關鍵詞,都“假”做一遍高亮處理,個數跟著高亮次數而計算,但是要注意,這裡只為了統計個數,不要真的對頁面進行高亮(如果你不要這種高亮處理的話),就可以統計準確了。
  2. 不使用“只對原始資料處理”方案,在原本這個方案裡,可以在data-highlight="yes"又是同組關鍵詞下,判斷被包含的視角詞是否存在,存在就露出次數加1,但是目前我還不知道該怎麼實現。

總結

感覺寫了很多很多,我覺得我應該講得比較清楚吧,哪種方案由哪種弊端。但我要明確的是,這裡沒有說哪種方案更好!只有恰好合適的滿足需求的方案才是好方案,如果你只是用來削蘋果的,不拿水果刀,卻拿了把殺豬刀,是可以削啊,還能削很多東西呢。但是你覺得,這樣好嗎?

這裡也正是這個意思,我為什麼不直接寫個最全面的方案出來,大家直接複製貼上拿走不送就好了,還要囉囉嗦嗦那麼多,為的就是讓大家自個兒根據自身需求找到更合適自己的方式就好了!

本文最後提供一個暫且最全面的方案,以方便真的著急做專案而沒空詳細閱讀我文章或不想考慮那麼多的人兒。

若本文對您有幫助,請點個贊,轉載請註明來源,寫文章不易吶,都是花寶貴時間寫的~

暫且最全方案

高亮函式

/**
 * 高亮關鍵字
 * @param node 節點
 * @param pattern 匹配的正則表示式
 * @param index - 可選。本專案中特定的需求,表示第幾組關鍵詞
 * @returns exposeCount - 露出次數
 */
function highlightKeyword(node, pattern, index) {
    var exposeCount = 0;
    if (node.nodeType === 3) {
        var matchResult = node.data.match(pattern);
        if (matchResult) {
            var highlightEl = document.createElement('span');
            highlightEl.dataset.highlight = 'yes';
            highlightEl.dataset.highlightMatch = matchResult[0];
            (index == null) || highlightEl.dataset.highlightIndex = index;
            var matchNode = node.splitText(matchResult.index);
            matchNode.splitText(matchResult[0].length);
            var highlightTextNode = document.createTextNode(matchNode.data);
            highlightEl.appendChild(highlightTextNode);
            matchNode.parentNode.replaceChild(highlightEl, matchNode);
            exposeCount++;
        }
    }
    // 具體條件自己加,這裡是基礎條件
    else if ((node.nodeType === 1)  && !(/script|style/.test(node.tagName.toLowerCase()))) {
        if (node.dataset.highlight === 'yes') {
            if (index == null) {
                return;
            }
            if (node.dataset.highlightIndex === index.toString()) {
                return;
            }
        }
        let childNodes = node.childNodes;
        for (var i = 0; i < childNodes.length; i++) {
            highlightKeyword(childNodes[i], pattern, index);
        }
    }
    return exposeCount;
}
複製程式碼

之前高亮過的再次執行方法

/**
 * @param pattern 匹配的正則表示式
 */
function reHighlight(pattern) {
    var unHighlightNodeList = document.querySelectorAll('[data-highlight=no]');
    for (var j = 0; j < unHighlightNodeList.length; j++) {
        if (pattern.test(unHighlightNodeList[j].dataset.highlightMatch)) {
            unHighlightNodeList[j].dataset.highlight = 'yes';
        }
    }
}
複製程式碼

對關鍵詞進行處理(特殊字元轉義),形成匹配的正則表示式

/**
 * @param {String | Array} keywords - 要高亮的關鍵詞或關鍵詞陣列
 * @returns {Array}
 */
function hanldeKeyword(keywords) {
    var wordMatchString = '';
    var words = [].concat(keywords);
    words.forEach(item => {
        let transformString = item.replace(/[.[*?+^$|()/]|\]|\\/g, '\\$&');
        wordMatchString += `|(${transformString})`;
    });
    wordMatchString = wordMatchString.substring(1);
    // 用於再次高亮與關閉的關鍵字作為一個整體的匹配正則
    var wholePattern = new RegExp(`^${wordMatchString}$`, 'i');
    // 用於第一次高亮的關鍵字匹配正則
    var pattern = new RegExp(wordMatchString, 'i');
    return [pattern, wholePattern];
}
複製程式碼

關閉高亮函式

/**
 * @param pattern 匹配的正則表示式
 */
function closeHighlight(pattern) {
    var highlightNodeList = document.querySelectorAll('[data-highlight=yes]');
    for (var n = 0; n < highlightNodeList.length; n++) {
        if (pattern.test(highlightNodeList[n].dataset.highlightMatch)) {
            highlightNodeList[n].dataset.highlight = 'no';
        }
    }
}
複製程式碼

基礎應用

// 只高亮一次
// 要匹配的關鍵詞
var keywords = 'Hello';
var patterns = hanldeKeyword(keywords);
// 儲存高亮歷史記錄
var highlightHistory = [];
highlightHistory.push(keywords);
// 針對body內容進行高亮
var bodyChildren = window.document.body.childNodes;
for (var i = 0; i < bodyChildren.length; i++) {
    highlightKeyword(bodyChildren[i], pattern[0]);
}


// 接著高亮其他關鍵詞
// 可能需要先抹掉不需要之前不需要高亮的
keywords = 'World'; // 新關鍵詞
closeHighlight(patterns[1]);
patterns = hanldeKeyword(keywords);
// 針對新關鍵詞高亮
// 如果已經高亮過一次
if (highlightHistory.indexOf(keywords) > -1) {
    reHighlight(patterns[1]);
} else { // 沒高亮過
    for (var i = 0; i < bodyChildren.length; i++) {
        highlightKeyword(bodyChildren[i], pattern[0]);
    }
}
複製程式碼
// css
.highlight {
    background: yellow;
    color: red;
}
複製程式碼