原生 CSS Custom Highlight 終於來了~

XboxYan發表於2023-02-13
歡迎關注我的公眾號:前端偵探

介紹一個比較前沿但是非常有用的新特性:一個瀏覽器原生支援的 CSS 文字高亮高亮功能,官方名稱叫做 CSS Custom Highlight API,有了它,可以在不改變 dom 結構的情況下自定義任意文字的樣式,例如

image.png

再例如搜尋詞高亮

image.png

還可以輕易實現程式碼高亮

image.png

多麼令人興奮的功能啊,現在在 Chrome 105 中已經正式支援了(無需開啟實驗特性),一起學習一下吧

一、偽元素 ::highlight()

要自定義任意文字樣式需要 CSSJS 的共同作用。

首先來看 CSS 部分,一個新的偽元素,非常簡單

::highlight(custom-highlight-name) {
  color: red
}

::selection這類偽元素比較類似,僅支援部分文字相關樣式,如下

  • 文字顏色 color
  • 背景顏色 background-color
  • 文字修飾 text-decoration
  • 文字陰影 text-shadow
  • 文字描邊 -webkit-text-stroke
  • 文字填充 -webkit-text-fill-color
注意,注意,注意不支援background-image,也就是漸變之類的也不支援

但是,僅僅知道這個偽類是沒用的,她還需要一個“引數”,也就是上面的custom-highlight-name,表示高亮的名稱,那這個是怎麼來的呢?或者換句話說,如何去標識頁面中需要自定義樣式的那部分文字呢?

這就需要藉助下面的內容了,看看如何生成這個“引數”,這才是重點

二、CSS Custom Highlight API

在介紹之前,建議先仔細閱讀這篇文章:web 中的“游標”和“選區”

大部分操作其實和這個原理是相同的,只是把拿到的選區做了進一步處理,具體分以下幾步

1. 建立選區(重點)

首先,透過Range物件建立文字選擇範圍,就像用滑鼠滑過選區一樣,這也是最複雜的一部分,例如

const parentNode = document.getElementById("foo");

const range1 = new Range();
range1.setStart(parentNode, 10);
range1.setEnd(parentNode, 20);

const range2 = new Range();
range2.setStart(parentNode, 40);
range2.setEnd(parentNode, 60);

這樣可以得到選區物件range1range2

2. 建立高亮

然後,將建立的選區高亮例項化,需要用到Highlight物件

const highlight = new Highlight(range1, range2, ...);

當然也可以根據需求建立多個

const highlight1 = new Highlight(user1Range1, user1Range2);
const highlight2 = new Highlight(user2Range1, user2Range2, user2Range3);

這樣可以得到高亮物件highlight1highlight2

3. 註冊高亮

接著,需要將例項化的高亮物件透過CSS.Highlight](https://developer.mozilla.org...))註冊到頁面

有點類似於Map物件的操作

CSS.highlights.set("highlight1", highlight1);
CSS.highlights.set("highlight2", highlight2);

目前相容性比較差,所以需要額外判斷一下

if (CSS.highlights) {
  //...支援CSS.highlights
}

注意看,上面註冊的key名,highlight1就是上一節提到的高亮名稱,也就是 CSS 中需要的“引數”

4. 自定義樣式

最後,將定義的高亮名稱結合::highlight,這樣就可以自定義選中樣式了

::highlight(highlight1) {
  background-color: yellow;
  color: black;
}

以上就是全部過程了,稍顯複雜,但是還是比較好理解的,關鍵是第一步建立選區的過程,最為複雜,再次推薦仔細閱讀這篇文章:web 中的“游標”和“選區”,下面用一張圖總結一下

image.png

原理就是這樣,下面看一些例項

三、彩虹文字

現在來實現文章開頭圖示效果,彩虹文字效果。總共7種顏色,文字依次變色,不斷迴圈,而且僅有一個標籤

<p id="rainbow-text">CSS Custom Highlight API</p>

這裡總共有7種顏色,所以需要建立7個高亮區域,可以先定義高亮 CSS,如下

::highlight(rainbow-color-1) { color: #ad26ad;  text-decoration: underline; }
::highlight(rainbow-color-2) { color: #5d0a99;  text-decoration: underline; }
::highlight(rainbow-color-3) { color: #0000ff;  text-decoration: underline; }
::highlight(rainbow-color-4) { color: #07c607;  text-decoration: underline; }
::highlight(rainbow-color-5) { color: #b3b308;  text-decoration: underline; }
::highlight(rainbow-color-6) { color: #ffa500;  text-decoration: underline; }
::highlight(rainbow-color-7) { color: #ff0000;  text-decoration: underline; }

現在肯定不會有什麼變化,因為還沒建立選區

image.png

先建立一個高亮區域試試,比如第一個文字

const textNode = document.getElementById("rainbow-text").firstChild;
if (CSS.highlights) {
  const range = new Range();
  range.setStart(textNode, 0); // 選區起點
  range.setEnd(textNode, 1); // 選區終點
  const Highlight = new Highlight(range);
  CSS.highlights.set(`rainbow-color-1`, Highlight);
}

效果如下

image.png

下面透過迴圈,建立7個高亮區域

const textNode = document.getElementById("rainbow-text").firstChild;

if (CSS.highlights) {

  const highlights = [];
  for (let i = 0; i < 7; i++) {
    // 給每個顏色例項化一個Highlight物件
    const colorHighlight = new Highlight();
    highlights.push(colorHighlight);

    // 註冊高亮
    CSS.highlights.set(`rainbow-color-${i + 1}`, colorHighlight);
  }

  // 遍歷文字節點
  for (let i = 0; i < textNode.textContent.length; i++) {
    // 給每個字元建立一個選區
    const range = new Range();
    range.setStart(textNode, i);
    range.setEnd(textNode, i + 1);

    // 新增到高亮
    highlights[i % 7].add(range);
  }
}

這樣就在不改變dom的情況下實現了彩虹文字效果

image.png

完整程式碼可以檢視以下任意連結:(注意需要Chrome 105+)

四、文字搜尋高亮

大家都知道瀏覽器的搜尋功能,ctrl+f就可以快速對整個網頁就行查詢,查詢到的關鍵詞會新增黃色背景的高亮,如下

image-20230210142509747

以前一直很疑惑這個顏色是怎麼新增的,畢竟沒有任何包裹標籤。現在有了CSS Custom Highlight API ,完全可以手動實現一個和原生瀏覽器一模一樣的搜尋高亮功能。

到目前為止,還無法自定義原生搜尋高亮的黃色背景,以後可能會開放

假設HTML結構是這樣的,一個搜尋框和一堆文字

<label>搜尋 <input id="query" type="text"></label>
<article>
  <p>
    閱文旗下囊括 QQ 閱讀、起點中文網、新麗傳媒等業界知名品牌,匯聚了強大的創作者陣營、豐富的作品儲備,覆蓋 200 多種內容品類,觸達數億使用者,已成功輸出《慶餘年》《贅婿》《鬼吹燈》《全職高手》《斗羅大陸》《琅琊榜》等大量優秀網文 IP,改編為動漫、影視、遊戲等多業態產品。
  </p>
  <p>
    《盜墓筆記》最初連載於起點中文網,是南派三叔成名代表作。2015年網劇開播首日點選破億,開啟了盜墓文學 IP 年。電影於2016年上映,由井柏然、鹿晗、馬思純等主演,累計票房10億元。
  </p>
  <p>
    慶餘年》是閱文集團白金作家貓膩的作品,自2007年在起點中文網連載,持續保持歷史類收藏榜前五位。改編劇整合為2019年現象級作品,播出期間登上微博熱搜百餘次,騰訊影片、愛奇藝雙平臺總播放量突破160億次,並榮獲第26屆白玉蘭獎最佳編劇(改編)、最佳男配角兩項大獎。
  </p>
  <p>《鬼吹燈》是天下霸唱創作的經典懸疑盜墓小說,連載於起點中文網。先後進行過漫畫、遊戲、電影、網路電視劇的改編,均取得不俗的成績,是當之無愧的超級IP。</p>
</article>

簡單美化一下後效果如下

image.png

然後就是監聽輸入框,遍歷文字節點(推薦使用原生的treeWalker,當然普通的遞迴也可以),根據搜尋詞建立選區,詳細程式碼如下

const query = document.getElementById("query");
const article = document.querySelector("article");

// 建立 createTreeWalker 迭代器,用於遍歷文字節點,儲存到一個陣列
const treeWalker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
const allTextNodes = [];
let currentNode = treeWalker.nextNode();
while (currentNode) {
  allTextNodes.push(currentNode);
  currentNode = treeWalker.nextNode();
}

// 監聽inpu事件
query.addEventListener("input", () => {
  // 判斷一下是否支援 CSS.highlights
  if (!CSS.highlights) {
    article.textContent = "CSS Custom Highlight API not supported.";
    return;
  }

  // 清除上個高亮
  CSS.highlights.clear();

  // 為空判斷
  const str = query.value.trim().toLowerCase();
  if (!str) {
    return;
  }

  // 查詢所有文字節點是否包含搜尋詞
  const ranges = allTextNodes
    .map((el) => {
      return { el, text: el.textContent.toLowerCase() };
    })
    .map(({ text, el }) => {
      const indices = [];
      let startPos = 0;
      while (startPos < text.length) {
        const index = text.indexOf(str, startPos);
        if (index === -1) break;
        indices.push(index);
        startPos = index + str.length;
      }

      // 根據搜尋詞的位置建立選區
      return indices.map((index) => {
        const range = new Range();
        range.setStart(el, index);
        range.setEnd(el, index + str.length);
        return range;
      });
    });

  // 建立高亮物件
  const searchResultsHighlight = new Highlight(...ranges.flat());

  // 註冊高亮
  CSS.highlights.set("search-results", searchResultsHighlight);
});

最後,透過CSS設定高亮的顏色

::highlight(search-results) {
  background-color: #f06;
  color: white;
}

實時搜尋效果如下

Kapture 2023-02-10 at 14.51.51.gif

完整程式碼可以檢視以下任意連結:(注意需要Chrome 105+)

還可以將高亮效果改成波浪線

::highlight(search-results) {
  text-decoration: underline wavy #f06;
}

效果如下,是不是也可用作錯別字標識呢?

image.png

除了避免dom操作帶來的便利外,效能也能得到極大的提升,畢竟建立、移除dom也是效能大戶,下面是一個測試 demo,搬運自

https://ffiori.github.io/highlight-api-demos/demo-performance.html

測試程式碼可以檢視以下任意連結:

測試效果如下

image.png

10000個節點的情況下,兩者相差100倍的差距!而且數量越大,效能差距越明顯,甚至直接導致瀏覽器卡死!

五、程式碼高亮編輯器

最後再來看一個非常實用的例子,可以輕易實現一個程式碼高亮的編輯器。

假設 HTML結構是這樣的,很簡單,就一個純文字的標籤

<pre class="editor" id="code">ul{
  min-height: 0;
}
.sub {
  display: grid;
  grid-template-rows: 0fr;
  transition: 0.3s;
  overflow: hidden;
}
:checked ~ .sub {
  grid-template-rows: 1fr;
}
.txt{
  animation: color .001s .5 linear forwards;
}
@keyframes color {
  from {
    color: var(--c1)
  }
  to{
    color: var(--c2)
  }
}</pre>

簡單修飾一下,設定為可編輯元素

.editor{
  white-space: pre-wrap;
  -webkit-user-modify: read-write-plaintext-only; /* 讀寫純文字 */
}

效果如下

image.png

那麼,如何讓這些程式碼高亮呢?

這就需要對內容進行關鍵詞分析提取了,我們可以用現有的程式碼高亮庫,比如highlight.js

 hljs.highlight(pre.textContent, {
   language: 'css'
 })._emitter.rootNode.children

透過這個方法可以獲取到CSS語言的關鍵詞以及型別,如下

image.png

簡單解釋一下,這是一個陣列,如果是純文字,表示普通的字元,如果是物件,表示是關鍵詞,例如第一個,children裡面的ul就是關鍵詞,型別是selector-tag,也就是選擇器,除此之外,還有attributenumberselector-class等各種型別。有了這些關鍵詞,我們就可以把這些文字單獨選取出來,然後高亮成不同的顏色。

接下來,就需要對程式碼內容進行遍歷了,方法也是類似的,如下

const nodes = pre.firstChild
const text = nodes.textContent
const highlightMap = {}
let startPos = 0;
words.filter(el => el.scope).forEach(el => {
  const str = el.children[0]
  const scope = el.scope
  const index = text.indexOf(str, startPos);
  if (index < 0) {
    return
  }
  const item = {
    start: index,
    scope: scope,
    end: index + str.length,
    str: str
  }
  if (highlightMap[scope]){
    highlightMap[scope].push(item)
  } else {
    highlightMap[scope] = [item]
  }
  startPos = index + str.length;
})
Object.entries(highlightMap).forEach(function([k,v]){
  const ranges = v.map(({start, end}) => {
    const range = new Range();
    range.setStart(nodes, start);
    range.setEnd(nodes, end);
    return range;
  });
  const highlight = new Highlight(...ranges.flat());
  CSS.highlights.set(k, highlight);
})
}
highlights(code)
code.addEventListener('input', function(){
  highlights(this)
})

最後,根據不同的型別,定義不同的顏色就行了,如下

::highlight(built_in) {
    color: #c18401;
  }
::highlight(comment) {
  color: #a0a1a7;
  font-style: italic;
  }
::highlight(number),
::highlight(selector-class){
    color: #986801;
  }
::highlight(attr) {
    color: #986801;
  }
::highlight(string) {
    color: #50a14f;
  }
::highlight(selector-pseudo) {
    color: #986801;
  }
::highlight(attribute) {
    color: #50a14f;
  }
::highlight(keyword) {
    color: #a626a4;
  }

這樣就得到了一個支援程式碼高亮的簡易編輯器了

image.png

相比傳統的編輯器而言,這個屬於純文字編輯,非常輕量,在高亮的同時也不會影響游標,因為不會生成新的dom,效能也是超級棒??

image.png

完整程式碼可以檢視以下任意連結:

六、最後總結一下

以上就是關於CSS Custom Highlight API的使用方式以及應用示例了,下面再來回顧一下使用步驟:

  1. 建立選區,new Range
  2. 建立高亮,new Highlight
  3. 註冊高亮,CSS.highlights.set
  4. 自定義樣式,::highlight()

相比傳統使用標籤的方式而已,有很多優點

  1. 使用場景更廣泛,很多情況下不能修改dom或者成本極大
  2. 效能更好,避免了操作dom帶來的額外開銷,在dom較多情況下效能差異至少100
  3. 幾乎沒有副作用,能有效減少dom變化引起的其他影響,比如游標選區的處理

其實歸根結底,都是dom變化帶來的,而Highlight API恰好能有效避開這個問題。當然也有一些缺陷,由於僅僅能改變文字相關樣式,所以也存在一些侷限性,這個就需要權衡了,目前相容性也還不足,僅適用於內部專案,敬請期待

最後,如果覺得還不錯,對你有幫助的話,歡迎點贊、收藏、轉發❤❤❤

歡迎關注我的公眾號:前端偵探

本文參與了「SegmentFault 思否寫作挑戰賽」,歡迎正在閱讀的你也加入。

相關文章