天天看點

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

前言

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

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

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

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

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

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

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

目錄

  • 需求
  • 一個最簡單的方案
    • 超簡單處理
    • 簡單處理二
    • 缺點
  • 利用DOM節點高亮(基礎版)
    • splitText
    • 缺點
  • 多次高亮(單關鍵詞高亮完成版)
    • 隻對原始資料處理
    • 關閉舊高亮開啟新高亮
    • 小結
  • 多個關鍵詞同時高亮
  • 分組情況下的多個關鍵詞的高亮
  • 能傳回比對個數的高亮方案
  • 總結
  • 不想看了,直接拿代碼用

需求

還是說一下這到底是個什麼需求吧。想必大家都試過在一個網頁上,按下“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

的解釋,詳見:

https://developer.mozilla.org/en-US/docs/Web/API/Node/normalize

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

<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;
}