
越暗的地方你越亮,透過 JS 標記關鍵字吧 
製作關鍵字篩選資料功能時常常想,如果能像瀏覽器內建的關鍵字搜尋功能那樣,標記符合的關鍵字,使用體驗應該會更好。
研究了一輪,發現還真的可以做到,讓我們一步一步來實作看看吧!
不知道甚麼是文字標記效果?那就先體驗看看成果。( ´ ▽ ` )ノ
| id | name | value | 
|---|---|---|
| 1 | Cod | 鱈魚是一種生活在寒冷海域的魚類,肉質細嫩,常用於西式料理和炸魚薯條。聽說少數個體會使用電腦,真是匪夷所思 | 
| 2 | Salmon | 鮭魚富含 Omega-3 脂肪酸,常見於生魚片、燒烤及煙燻料理,是營養價值極高的魚類。 | 
| 3 | Tuna | 鮪魚體型巨大,肉質結實,常用於壽司和罐頭食品,也是重要的經濟魚種。 | 
| 4 | Mackerel | 鯖魚油脂豐富,味道濃郁,適合煎烤或醃漬,是亞洲料理中常見的食材。 | 
可以注意到 table 內的文字會自動標記符合的關鍵字!(≖‿ゝ≖)✧
關鍵字過濾 
首先讓我們做一個基本的 table 元件,讓使用者可以輸入關鍵字來篩選資料。
| id | name | value | 
|---|---|---|
| 1 | Cod | 鱈魚是一種生活在寒冷海域的魚類,肉質細嫩,常用於西式料理和炸魚薯條。聽說少數個體會使用電腦,真是匪夷所思 | 
| 2 | Salmon | 鮭魚富含 Omega-3 脂肪酸,常見於生魚片、燒烤及煙燻料理,是營養價值極高的魚類。 | 
| 3 | Tuna | 鮪魚體型巨大,肉質結實,常用於壽司和罐頭食品,也是重要的經濟魚種。 | 
| 4 | Mackerel | 鯖魚油脂豐富,味道濃郁,適合煎烤或醃漬,是亞洲料理中常見的食材。 | 
可以看到當我們輸入關鍵字時,table 會自動篩選出符合的資料。
如果能夠再標記符合的關鍵字,可以大幅提升使用體驗。( •̀ ω •́ )✧
所以應該怎麼做呢?將關鍵字使用 <mark> 標籤包起來嗎?
這的確是最經典的做法,但現在有更容易的方法了!( ‧ω‧)ノ╰(‧ω‧ )
Highlight API 
現在可以使用 Highlight API 實現!─=≡Σ((( つ•̀ω•́)つ
不過目前(2025/08/26)支援度為「Baseline 2025 Newly available」,如果有舊瀏覽器需求,就只能乖乖回去用 <mark> 標籤了。( ˘・з・)
所以該怎麼使用呢?概念為:
- 取得要標記的文字目標 DOM
- 使用 Range設定標記範圍
- 使用這個 Range建立一個Highlight物件。
- 將這個 Highlight物件透過 CSS.highlights.set 註冊到瀏覽器,並指定名稱為'my-custom-highlight'。
- 設定 ::highlight(my-custom-highlight)樣式內容
以下是範例程式:
content\blog-ocean-world\js-highlight-api\text-highlight.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,讓使用更簡單、方便。
需求與規格 
第一步當然是來個需求分析:
- 產生唯一名稱避免互相影響,並注入對應的 CSS: css- ::highlight(highlight-xxxxx) { background-color: yellow; color: black; }
- 關鍵字或 DOM 有變動時: - 走訪容器裡的所有文字節點
- 找出每個文字節點中「關鍵字」的所有出現位置。
- 對每個命中的片段建立 Range,加到Highlight裡。
- 最後使用 CSS.highlights.set將Highlight註冊到瀏覽器
 
- 功能(元件)移除時自動移除標記與樣式 
定義參數 
interface UseTextHighlightOptions {
  /** 搜尋目標,會標記以下文字節點 */
  target?: MaybeElementRef;
  /** 標記底色 */
  background?: MaybeRefOrGetter<string>;
  /** 標記文字顏色 */
  color?: MaybeRefOrGetter<string>;
  /** 額外的觸發條件,變動時會更新標記
   *
   * 可確保外部依賴資料更新後也會更新標記
   */
  triggerOn?: MaybeRefOrGetter;
  /** 強制延遲指定時間後再執行
   *
   * 若 target DOM 更新自動偵測無效,可以使用此參數
   */
  delay?: number;
}
export function useTextHighlight(
  keyword: MaybeRefOrGetter<string>,
  options: UseTextHighlightOptions = {},
) {}目標元素與樣式 
接著取得目標元素並產生唯一名稱與樣式:
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 物件與自動清除邏輯:
const highlightSet = new Highlight()
tryOnMounted(() => {
  CSS.highlights.set?.(highlightName, highlightSet)
})
tryOnBeforeUnmount(() => clear())
function clear() {
  CSS.highlights.delete?.(highlightName)
  highlightSet.clear?.()
}這個部分就與前面介紹的 Highlight API 用法相同。
產生標記 
再來是最重要的部分了,讓我們加入尋找文字、產生標記的邏輯:
async function highlight(keyword: string) {
  // @ts-expect-error TS 誤報
  highlightSet.clear?.()
  if (!keyword)
    return
  // 確保 DOM 已更新
  await nextTick()
  await promiseTimeout(options.delay ?? 0)
  const nodeIterator = document.createNodeIterator(
    targetEl.value,
    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。
觸發與更新 
核心邏輯完成,最後就是觸發更新的部分:
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,
})這個部分很單純,基本上就是當 keyword、triggerOn 或 target 變動時更新標記。
以上我們完成了 useTextHighlight 功能了!完整程式碼可以在這裡取得。◝( •ω• )◟
成果 
把此功能加入剛剛做的 table 中。
content\blog-ocean-world\js-highlight-api\highlight-table.vue
<template>
  <div ref="tableRef">
    <filter-table v-model="keyword" />
  </div>
</template>
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useTextHighlight } from '../../../web/composables/use-text-highlight'
import FilterTable from './filter-table.vue'
const tableRef = useTemplateRef('tableRef')
const keyword = ref('')
useTextHighlight(keyword, {
  target: tableRef,
})
</script>輸入文字看看吧!
| id | name | value | 
|---|---|---|
| 1 | Cod | 鱈魚是一種生活在寒冷海域的魚類,肉質細嫩,常用於西式料理和炸魚薯條。聽說少數個體會使用電腦,真是匪夷所思 | 
| 2 | Salmon | 鮭魚富含 Omega-3 脂肪酸,常見於生魚片、燒烤及煙燻料理,是營養價值極高的魚類。 | 
| 3 | Tuna | 鮪魚體型巨大,肉質結實,常用於壽司和罐頭食品,也是重要的經濟魚種。 | 
| 4 | Mackerel | 鯖魚油脂豐富,味道濃郁,適合煎烤或醃漬,是亞洲料理中常見的食材。 | 
簡單易用,體驗大幅提升!
還可以自由調整標記顏色:
content\blog-ocean-world\js-highlight-api\highlight-color.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 '../../../web/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>| id | name | value | 
|---|---|---|
| 1 | Cod | 鱈魚是一種生活在寒冷海域的魚類,肉質細嫩,常用於西式料理和炸魚薯條。聽說少數個體會使用電腦,真是匪夷所思 | 
| 2 | Salmon | 鮭魚富含 Omega-3 脂肪酸,常見於生魚片、燒烤及煙燻料理,是營養價值極高的魚類。 | 
| 3 | Tuna | 鮪魚體型巨大,肉質結實,常用於壽司和罐頭食品,也是重要的經濟魚種。 | 
| 4 | Mackerel | 鯖魚油脂豐富,味道濃郁,適合煎烤或醃漬,是亞洲料理中常見的食材。 | 
| id | name | value | 
|---|---|---|
| 1 | Cod | 鱈魚是一種生活在寒冷海域的魚類,肉質細嫩,常用於西式料理和炸魚薯條。聽說少數個體會使用電腦,真是匪夷所思 | 
| 2 | Salmon | 鮭魚富含 Omega-3 脂肪酸,常見於生魚片、燒烤及煙燻料理,是營養價值極高的魚類。 | 
| 3 | Tuna | 鮪魚體型巨大,肉質結實,常用於壽司和罐頭食品,也是重要的經濟魚種。 | 
| 4 | Mackerel | 鯖魚油脂豐富,味道濃郁,適合煎烤或醃漬,是亞洲料理中常見的食材。 | 
可以注意到不同元件的標記完全不會互相干擾!◝(≧∀≦)◟
總結 🐟 
- 標記相符的關鍵字,可以提升使用者體驗
- 使用 Highlight API 可以更方便地實現文字標記效果
- 封裝成 useTextHighlight,可以在不同場景中重複使用