Skip to content

js-highlight-api

越暗的地方你越亮,透過 JS 標記關鍵字吧

製作關鍵字篩選資料功能時常常想,如果能像瀏覽器內建的關鍵字搜尋功能那樣,標記符合的關鍵字,使用體驗應該會更好。

研究了一輪,發現還真的可以做到,讓我們一步一步來實作看看吧!

不知道甚麼是文字標記效果?那就先體驗看看成果。( ´ ▽ ` )ノ

可以注意到 table 內的文字會自動標記符合的關鍵字!(≖‿ゝ≖)✧

關鍵字過濾

首先讓我們做一個基本的 table 元件,讓使用者可以輸入關鍵字來篩選資料。

idnamevalue
1Cod鱈魚是一種生活在寒冷海域的魚類,肉質細嫩,常用於西式料理和炸魚薯條。聽說少數個體會使用電腦,真是匪夷所思
2Salmon鮭魚富含 Omega-3 脂肪酸,常見於生魚片、燒烤及煙燻料理,是營養價值極高的魚類。
3Tuna鮪魚體型巨大,肉質結實,常用於壽司和罐頭食品,也是重要的經濟魚種。
4Mackerel鯖魚油脂豐富,味道濃郁,適合煎烤或醃漬,是亞洲料理中常見的食材。

可以看到當我們輸入關鍵字時,table 會自動篩選出符合的資料。

如果能夠再標記符合的關鍵字,可以大幅提升使用體驗。( •̀ ω •́ )✧

所以應該怎麼做呢?將關鍵字使用 <mark> 標籤包起來嗎?

這的確是最經典的做法,但現在有更容易的方法了!( ‧ω‧)ノ╰(‧ω‧ )

Highlight API

現在可以使用 Highlight API 實現!─=≡Σ((( つ•̀ω•́)つ

不過目前(2025/08/26)支援度為「Baseline 2025 Newly available」,如果有舊瀏覽器需求,就只能乖乖回去用 <mark> 標籤了。( ˘・з・)

所以該怎麼使用呢?概念為:

  1. 取得要標記的文字目標 DOM
  2. 使用 Range 設定標記範圍
  3. 使用這個 Range 建立一個 Highlight 物件。
  4. 將這個 Highlight 物件透過 CSS.highlights.set 註冊到瀏覽器,並指定名稱為 'my-custom-highlight'
  5. 設定 ::highlight(my-custom-highlight) 樣式內容

以下是範例程式:

content\blog-ocean-world\js-highlight-api\text-highlight.vue

vue
<template>
  <div class="text-highlight w-full border rounded p-4">
    這段文字的鱈魚會被標記
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue'

onMounted(() => {
  const parentNode = document.querySelector('.text-highlight')
  if (!parentNode) {
    throw new Error('Cannot find .text-highlight element')
  }

  const textNode = parentNode.firstChild
  if (!textNode) {
    throw new Error('Cannot find text node')
  }

  const range = new Range()
  range.setStart(textNode, 6)
  range.setEnd(textNode, 8)

  const highlight = new Highlight(range)

  // @ts-expect-error TS 誤判
  CSS.highlights.set('my-custom-highlight', highlight)
})
</script>

<style lang="sass">
::highlight(my-custom-highlight)
  background-color: #ffeb3b
</style>

實際效果如下:

這段文字的鱈魚會被標記

可以注意到文字不需要被 <mark> 包起來,Highlight API 會自動幫我們標記符合的關鍵字。

這樣就不用調整 HTML 結構了,是不是很方便啊!∠( ᐛ 」∠)_

來個封裝

知道怎麼用了,現在讓我們把這個功能封裝成 useTextHighlight,讓使用更簡單、方便。

需求與規格

第一步當然是來個需求分析:

  1. 產生唯一名稱避免互相影響,並注入對應的 CSS:

    css
    ::highlight(highlight-xxxxx) {
      background-color: yellow;
      color: black;
    }
  2. 關鍵字或 DOM 有變動時:

    1. 走訪容器裡的所有文字節點
    2. 找出每個文字節點中「關鍵字」的所有出現位置。
    3. 對每個命中的片段建立 Range,加到 Highlight 裡。
    4. 最後使用 CSS.highlights.setHighlight 註冊到瀏覽器
  3. 功能(元件)移除時自動移除標記與樣式

定義參數

ts
interface UseTextHighlightOptions {
  /** 搜尋目標,會標記以下文字節點 */
  target?: MaybeElementRef;
  /** 標記底色 */
  background?: MaybeRefOrGetter<string>;
  /** 標記文字顏色 */
  color?: MaybeRefOrGetter<string>;
  /** 額外的觸發條件,變動時會更新標記
   *
   * 可確保外部依賴資料更新後也會更新標記
   */
  triggerOn?: MaybeRefOrGetter;
  /** 強制延遲指定時間後再執行
   *
   * 若 target DOM 更新自動偵測無效,可以使用此參數
   */
  delay?: number;
}
export function useTextHighlight(
  keyword: MaybeRefOrGetter<string>,
  options: UseTextHighlightOptions = {},
) {}

目標元素與樣式

接著取得目標元素並產生唯一名稱與樣式:

ts
const highlightName = `highlight-${Math.random().toString(36).slice(2)}`

const targetEl = computed<HTMLElement | SVGElement>(() => {
  const target = toValue(options.target)
  if (!target) {
    return document.documentElement
  }

  if (target instanceof Element) {
    return target
  }

  if (target.$el instanceof HTMLElement) {
    return target.$el
  }

  throw new Error('Invalid target')
})

const style = computed(() => {
  const background = toValue(options.background) ?? '#ffeb3b'
  const color = toValue(options.color) ?? 'inherit'

  return `::highlight(${highlightName}) {
    background: ${background};
    color: ${color};
  }`
})
useStyleTag(style)

useStyleTag 是 VueUse 的功能,可以動態產生 <style> 標籤並插入樣式。

建立 Highlight

建立 Highlight 物件與自動清除邏輯:

ts
const highlightSet = new Highlight()
tryOnMounted(() => {
  CSS.highlights.set?.(highlightName, highlightSet)
})
tryOnBeforeUnmount(() => clear())

function clear() {
  CSS.highlights.delete?.(highlightName)
  highlightSet.clear?.()
}

這個部分就與前面介紹的 Highlight API 用法相同。

產生標記

再來是最重要的部分了,讓我們加入尋找文字、產生標記的邏輯:

ts
async function highlight(keyword: string) {
  // @ts-expect-error TS 誤報
  highlightSet.clear?.()

  if (!keyword)
    return

  // 確保 DOM 已更新
  await nextTick()
  await promiseTimeout(options.delay ?? 0)

  targetEl.value?.querySelectorAll('*').forEach((el) => {
    const nodeIterator = document.createNodeIterator(
      el,
      NodeFilter.SHOW_TEXT,
      {
        acceptNode(node) {
          const parentElement = node.parentElement
          // 排除不該進去的區塊
          if (!parentElement || /^(?:SCRIPT|STYLE|NOSCRIPT|TEXTAREA|TITLE|IFRAME)$/.test(parentElement.tagName)) {
            return NodeFilter.FILTER_REJECT
          }

          return NodeFilter.FILTER_ACCEPT
        },
      },
    )

    let node = nodeIterator.nextNode()
    while (node) {
      if (!(node instanceof Text)) {
        node = nodeIterator.nextNode()
        continue
      }

      const txt = node.data
      let index = txt.toLocaleLowerCase().indexOf(keyword.toLocaleLowerCase())

      while (index !== -1) {
        const range = new Range()
        range.setStart(node, index)
        range.setEnd(node, index + keyword.length)

        // @ts-expect-error TS 誤報
        highlightSet.add?.(range)
        index = txt.indexOf(keyword, index + keyword.length)
      }
      node = nodeIterator.nextNode()
    }
  })
}
  • 使用 NodeIterator 只抓文字節點(SHOW_TEXT),並用 acceptNode 過濾掉不該搜尋的標籤。
  • 每個命中片段用 Range 設定起訖位置並放入 Highlight

觸發與更新

核心邏輯完成,最後就是觸發更新的部分:

ts
watchThrottled(() => ({
  keywordValue: toValue(keyword),
  waitFor: toValue(options.triggerOn),
}), async ({ keywordValue }) => {
  highlight(keywordValue)
}, {
  throttle: 100,
  leading: true,
  trailing: true,
})

useMutationObserver(targetEl, () => {
  highlight(toValue(keyword))
}, {
  childList: true,
  characterData: true,
  subtree: true,
})

這個部分很單純,基本上就是當 keywordtriggerOntarget 變動時更新標記。

以上我們完成了 useTextHighlight 功能了!完整程式碼可以在這裡取得◝( •ω• )◟

成果

把此功能加入剛剛做的 table 中。

content\blog-ocean-world\js-highlight-api\highlight-table.vue

vue
<template>
  <div ref="tableRef">
    <filter-table v-model="keyword" />
  </div>
</template>

<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useTextHighlight } from '../../../composables/use-text-highlight'
import FilterTable from './filter-table.vue'

const tableRef = useTemplateRef('tableRef')

const keyword = ref('')
useTextHighlight(keyword, {
  target: tableRef,
})
</script>

輸入文字看看吧!

簡單易用,體驗大幅提升!

還可以自由調整標記顏色:

content\blog-ocean-world\js-highlight-api\highlight-color.vue

vue
<template>
  <div ref="tableRef">
    <div class=" flex items-center gap-1 border rounded p-2 mb-2">
      <label>背景顏色</label>
      <input
        v-model="style.backgroundColor"
        type="color"
      >
      <label class="ml-2">文字顏色</label>
      <input
        v-model="style.color"
        type="color"
      >
    </div>

    <filter-table v-model="keyword" />
  </div>
</template>

<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useTextHighlight } from '../../../composables/use-text-highlight'
import FilterTable from './filter-table.vue'

const tableRef = useTemplateRef('tableRef')

const keyword = ref('')
const style = ref({
  backgroundColor: '#ffff00',
  color: '#000000',
})
useTextHighlight(keyword, {
  target: tableRef,
  background: () => style.value.backgroundColor,
  color: () => style.value.color,
})
</script>

可以注意到不同元件的標記完全不會互相干擾!◝(≧∀≦)◟

總結 🐟

  • 標記相符的關鍵字,可以提升使用者體驗
  • 使用 Highlight API 可以更方便地實現文字標記效果
  • 封裝成 useTextHighlight,可以在不同場景中重複使用