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

pekonchan發表於2018-12-27

前言

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

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

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

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

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

寫該文章的目的只有兩個:

  • 讓缺乏這方面經驗的人能夠信手拈來一個較為全面的方案,對自己對公司相對負責,別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資訊了
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'),然後去掉這層包裹層就好了。

// 記住這個函式名,下面不贅述,直接呼叫
function closeHighlight() {
    let highlightNodeList = document.querySelectorAll('[data-highlight=yes]');
    for (let n = 0; n < highlightNodeList.length; n++) {
        let parentNode = highlightNodeList[n].parentNode;
        // 把高亮包裹層裡面的文字生成一個新的文字節點
        let textNode = document.createTextNode(highlightNodeList[n].innerText);
        // 用新的文字節點替換高亮的節點
        parentNode.replaceChild(textNode, highlightNodeList[n]);
        // 把相鄰的文字節點合成一個文字節點
        parentNode.normalize();
    }
}
複製程式碼

然後針對新的關鍵詞高亮,再執行上述封裝的高亮函式。

關於normalize的解釋,詳見:

developer.mozilla.org/en-US/docs/…

這裡的意思就是把相鄰的文字節點合成一個文字節點,避免把文字給截斷了,之後高亮其他關鍵詞不管用了。如:

<div>hello大家好</div>
複製程式碼

第一個關鍵詞“hello”,高亮後關閉,原本的div只有只有一個文字子節點,現在變成了兩個了,分別為“hello”“大家好”。那麼在此匹配“o大”這個關鍵詞時,就匹配不了。因為不在一個節點上了。

小結

至此,一個關於能過多次使用的單個關鍵詞高亮的方案已經落幕了。有個選擇: 只對原始資料處理關閉舊高亮開啟新高亮 。各有優缺點,大家根據自己實際專案需求取捨,甚至要求更低的,直接採用最上面的各個簡單方案。

多個關鍵詞同時高亮

這裡的及以下的方案,都是基於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');
複製程式碼

之後的操作跟上述的“關閉舊高亮開啟新高亮方案”的流程是一樣的,只是對關鍵詞的處理不同而已。

缺點

高亮存在先後順序。什麼意思?舉例子說明,如有一組關鍵詞['證件照', '照換'],在下面的一個元素裡要高亮:

<div>證件照換背景顏色</div>
複製程式碼

用上述方法高亮後結果為:

<div><span data-highlight="yes">證件照<span>換背景顏色</div>
複製程式碼

結果看到,只有“證件照”產生了高亮,是因為在生成匹配的正則時,“證件照”在前的。假設換個順序['照換', '證件照'],那麼結果就是:

<div>證件<span data-highlight="yes">照換<span>背景顏色</div>
複製程式碼

這種問題,說實在的,我現在也無能為力解決,如果大家有更好的方案,請告訴我學習一下~

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

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

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


紅框部分是一個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>可口可樂</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';
            // 把匹配結果的文字儲存在dataset裡,用於關閉高亮,詳解見下面
            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);
        }
    }
}
複製程式碼

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

有沒有留意到,上述新增了這麼一句程式碼highlightEl.dataset.highlightMatch = matchResult[0];

這句用意是,用於下面說的分組關閉高亮的。根據這個資訊來區分,我要關閉哪些符合內容的高亮節點,不能統一依據highlignth=yes來處理。例如,這個高亮節點匹配的是“Hello”,那麼highlightEl.dataset.highlightMatch就是“Hello”,要關閉這個因為“Hello”產生的高亮節點,就要判斷highlightEl.dataset.highlightMatch == 'Hello'

為什麼我這裡會選擇用dataset的形式存關鍵詞內容,可能大家會覺得直接判斷元素裡面的innerText或者firstChid文字節點不就好了嗎,實際上,這種情況就不好使了:

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

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

關閉高亮也要分組關閉

改造原本的關閉高亮函式closeHighlight,不能像之前那樣統一關閉了,在分組前,先對之前改造匹配關鍵詞的地方,再做一些補充:

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

為什麼pattern跟之前的會有區分,因為要完全符合(不能是包含關係)關鍵詞的時候才能設定節點高亮關閉。如要關閉關鍵詞為“Hello”的高亮,在下面元素裡是不應該關閉的,要完全符合“Hello”才行

<div data-highlight="no" data-highlightMatch="showHello"></div>
複製程式碼

接下來是改造原本的關閉高亮函式:

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)) {
            // 記錄要關閉的高亮節點的父節點
            let parentNode = highlightNodeList[n].parentNode;
            // 記錄要關閉的高亮節點的子節點
            let childNodes = highlightNodeList[n].childNodes;
            let childNodesLen = childNodes.length;
            // 記錄要關閉的高亮節點的下個兄弟節點
            let nextSibling = highlightNodeList[n].nextSibling;
            // 把高亮節點的子節點移動到兄弟節點前面
            for (let k = 0; k < childNodesLen; k++) {
                parentNode.insertBefore(childNodes[0], nextSibling);
            }
            // 建立空白文字節點並替換原本的高亮節點
            let flagNode = document.createTextNode('');
            parentNode.replaceChild(flagNode, highlightNodeList[n]);
            // 合併鄰近文字節點
            parentNode.normalize();
        }
    }
}
複製程式碼

大家明顯看到,之前的只有innerText實現替換高亮節點的方式已經沒了,因為不管用了,因為有可能出現這種情況:

<h1>
    <span data-highlight="yes" data-highlightIndex="1" data-highlight-match="證件照">
        證件照
        <span data-highlight="yes" data-highlightIndex="2">lo</span>
    </span>
    , I'm pekonChan
</h1>
複製程式碼

如果還是用原本的方式那麼裡面那層第二組的高亮也沒了:

<h1>
    證件照, I'm pekonChan
</h1>
複製程式碼

因為要把高亮節點的所有子節點,都要保留下來,我們只是移除個包裹層而已。

注意裡面的一個for迴圈,由於每移動一次,childNodes就會變化一次,因為insertBefore方法是如果原本沒有要插入的節點,就新增插入,如果已經存在,就會剪下移動插入,移動後舊節點就會沒了。因此childNodes會變化,所以我們只利用childNodes一開始的長度,每次插入childNodes的第一個節點(因為原本的第一個節點被移走了,第二個就會變成第一個)

缺點

其實這裡的缺點,跟上節的多個關鍵詞高亮是一樣的 傳送門

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

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

/**
 * 高亮關鍵字
 * @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,但是目前我還不知道該怎麼實現。

總結

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

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

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

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

於發文後,修改了一次,修改於2018/12/28 12:16

暫且最全方案

高亮函式

/**
 * 高亮關鍵字
 * @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 {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)) {
            var parentNode = highlightNodeList[n].parentNode;
            var childNodes = highlightNodeList[n].childNodes;
            var childNodesLen = childNodes.length;
            var nextSibling = highlightNodeList[n].nextSibling;
            for (var k = 0; k < childNodesLen; k++) {
                parentNode.insertBefore(childNodes[0], nextSibling);
            }
            var flagNode = document.createTextNode('');
            parentNode.replaceChild(flagNode, highlightNodeList[n]);
            parentNode.normalize();
        }
    }
}
複製程式碼

基礎應用

// 只高亮一次
// 要匹配的關鍵詞
var keywords = 'Hello';
var patterns = hanldeKeyword(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);
// 針對新關鍵詞高亮
for (var i = 0; i < bodyChildren.length; i++) {
    highlightKeyword(bodyChildren[i], pattern[0]);
}
複製程式碼
// css
.highlight {
    background: yellow;
    color: red;
}
複製程式碼

相關文章