越暗的地方你越亮,透過 JS 標記關鍵字吧
製作關鍵字篩選資料功能時常常想,如果能像瀏覽器內建的關鍵字搜尋功能那樣,標記符合的關鍵字,使用體驗應該會更好。
研究了一輪,發現還真的可以做到,讓我們一步一步來實作看看吧!
不知道甚麼是文字標記效果?那就先體驗看看成果。( ´ ▽ ` )ノ
可以注意到 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)
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
。
觸發與更新
核心邏輯完成,最後就是觸發更新的部分:
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 '../../../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
<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
,可以在不同場景中重複使用