大家好,我是CC,在这里欢迎大家的到来~
背景
在传统实现文本高亮时通常使用span标签包裹文本,再给 span 标签添加相应高亮背景色。这种方式会修改原本的DOM结构,逻辑复杂,也会频繁导致页面重绘,消耗浏览器性能。而基于HighLight的CSS自定义高亮API的这种方式可以实现在渲染层处理文本高亮,既不影响 DOM 树,而且完全独立于文档结构。
实现步骤
创建 Range 对象
标识想要高亮的文本范围。
<div id="foo">纯 CSS 实现文本高亮</div> const parentNode = document.getElementById("foo"); const range1 = new Range(); range1.setStart(parentNode, 1); range1.setEnd(parentNode, 2); const range2 = new Range(); range2.setStart(parentNode, 4); range2.setEnd(parentNode, 6);为 Range 对象添加 Highlight 对象
多个 Range 对象可以与同一个 Highlight 对象关联,这样会以相同方式高亮显示多个文本片段。
const highlight = new Highlight(range1, range2);当然也可以在某些场景下创建多个 Highlight 对象,比如在使用协作文本编辑器中每个用户使用不同的文本颜色。
const user1Highlight = new Highlight(user1Range1, user1Range2); const user2Highlight = new Highlight(user2Range1, user2Range2, user2Range3);HighlightRegistry 注册
注册表是一个 Map 对象,通过名称注册高亮。
CSS.highlights.set("user-1-highlight", user1Highlight); CSS.highlights.set("user-2-highlight", user2Highlight);当然除了注册之外,也支持删除和清除。
CSS.highlights.delete("user-1-highlight"); CSS.highlights.clear();::highlight()伪元素定义高亮样式
为文本片段添加自定义样式进行高亮。
::highlight(user-1-highlight) { background-color: yellow; color: black; } ::highlight(user-2-highlight) { background-color: black; color: yellow; }应用场景
这里举例两个CSS自定义高亮API的应用场景。
搜索高亮文本
在多段文本中搜索检索到文本后直接高亮展示,方便用户查找。
import { message } from "antd"; import { useEffect, useRef } from "react"; import "./index.less"; const HighlightText = ({ text, highlightedWords, type = "text" }: { text: string; highlightedWords: string[]; type?: "text" | "html"; }) => { // 将 Node 改为更具体的 HTMLDivElement 以修复 ref 类型错误 const textRef = useRef<HTMLDivElement | null>(null); useEffect(() => { if (!CSS.highlights) { message.warning("CSS Custom Highlight API not supported."); return; } CSS.highlights.clear(); // 支持多个词语:去重、trim,并过滤空字符串 const words = Array.from(new Set(highlightedWords.map((w) => w.trim()).filter(Boolean))); if (!words.length) { return; } if (!textRef.current) return; const treeWalker = document.createTreeWalker(textRef.current, NodeFilter.SHOW_TEXT); const allTextNodes: Text[] = []; let currentNode = treeWalker.nextNode() as Text | null; while (currentNode) { allTextNodes.push(currentNode); currentNode = treeWalker.nextNode() as Text | null; } // 为所有词语在所有文本节点中生成 range const ranges: Range[] = []; for (const el of allTextNodes) { const content = el.textContent || ""; for (const word of words) { let startPos = 0; while (startPos < content.length) { const index = content.indexOf(word, startPos); if (index === -1) break; const range = new Range(); range.setStart(el, index); range.setEnd(el, index + word.length); ranges.push(range); startPos = index + word.length; } } } if (ranges.length) { // 统一用一个高亮名称,样式在 ::highlight(search-results) 中定义 const searchResultsHighlight = new Highlight(...ranges); CSS.highlights.set("search-results", searchResultsHighlight); } }, [highlightedWords, text]); return ( <> {type === "html" ? ( <div ref={textRef} dangerouslySetInnerHTML={ { __html: text } }> </div> ) : ( <div ref={textRef}>{text}</div> )} </> ); }; export default HighlightText;文本差异对比
对两段文本进行对比时,左侧高亮“不存在于右侧”的文本为绿色(删除),右侧高亮“多于左侧”的文本为红色(新增),可以直观看到差异。
import { message } from "antd"; import { useEffect, useRef } from "react"; import "./index.less"; type DiffProps = { left: string; right: string; type?: "text" | "html"; }; const TextDiff = ({ left, right, type = "text" }: DiffProps) => { const leftRef = useRef<HTMLDivElement | null>(null); const rightRef = useRef<HTMLDivElement | null>(null); useEffect(() => { if (!("highlights" in CSS)) { message.warning("CSS Custom Highlight API not supported."); return; } const collectNodes = (root: HTMLElement | null) => { if (!root) return { nodes: [] as Text[], starts: [] as number[], text: "" }; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes: Text[] = []; const starts: number[] = []; let text = ""; let cur = walker.nextNode() as Text | null; while (cur) { nodes.push(cur); starts.push(text.length); text += cur.textContent || ""; cur = walker.nextNode() as Text | null; } return { nodes, starts, text }; }; const { nodes: leftNodes, starts: leftStarts, text: leftText } = collectNodes(leftRef.current); const { nodes: rightNodes, starts: rightStarts, text: rightText } = collectNodes(rightRef.current); const n = leftText.length; const m = rightText.length; const dp: number[][] = Array(n + 1) .fill(0) .map(() => Array(m + 1).fill(0)); for (let i = 1; i <= n; i++) { for (let j = 1; j <= m; j++) { if (leftText[i - 1] === rightText[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1; else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); } } type Op = { t: "equal" | "del" | "add"; ai?: number; bi?: number }; const ops: Op[] = []; let i = n, j = m; while (i > 0 || j > 0) { if (i > 0 && j > 0 && leftText[i - 1] === rightText[j - 1]) { ops.push({ t: "equal", ai: i - 1, bi: j - 1 }); i--; j--; } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { ops.push({ t: "add", bi: j - 1 }); j--; } else if (i > 0) { ops.push({ t: "del", ai: i - 1 }); i--; } } ops.reverse(); type Span = { start: number; length: number }; const leftDelSpans: Span[] = []; const rightAddSpans: Span[] = []; let posA = 0; let posB = 0; let delRunStart: number | null = null; let delRunLen = 0; let addRunStart: number | null = null; let addRunLen = 0; const flushDel = () => { if (delRunStart !== null && delRunLen > 0) leftDelSpans.push({ start: delRunStart, length: delRunLen }); delRunStart = null; delRunLen = 0; }; const flushAdd = () => { if (addRunStart !== null && addRunLen > 0) rightAddSpans.push({ start: addRunStart, length: addRunLen }); addRunStart = null; addRunLen = 0; }; for (const op of ops) { if (op.t === "equal") { flushDel(); flushAdd(); posA++; posB++; } else if (op.t === "del") { if (delRunStart === null) delRunStart = posA; delRunLen++; flushAdd(); posA++; } else if (op.t === "add") { if (addRunStart === null) addRunStart = posB; addRunLen++; flushDel(); posB++; } } flushDel(); flushAdd(); const locate = (starts: number[], nodes: Text[], pos: number) => { let idx = 0; while (idx < nodes.length) { const nodeLen = (nodes[idx].textContent || "").length; const s = starts[idx]; if (pos < s + nodeLen) return { nodeIndex: idx, offset: pos - s }; idx++; } const lastIdx = nodes.length - 1; return { nodeIndex: Math.max(0, lastIdx), offset: (nodes[lastIdx]?.textContent || "").length }; }; const spansToRanges = (spans: Span[], starts: number[], nodes: Text[]) => { const ranges: Range[] = []; for (const { start, length } of spans) { const end = start + length; const sLoc = locate(starts, nodes, start); const eLoc = locate(starts, nodes, end); const r = new Range(); r.setStart(nodes[sLoc.nodeIndex], sLoc.offset); r.setEnd(nodes[eLoc.nodeIndex], eLoc.offset); ranges.push(r); } return ranges; }; const leftRanges = spansToRanges(leftDelSpans, leftStarts, leftNodes); const rightRanges = spansToRanges(rightAddSpans, rightStarts, rightNodes); if (leftRanges.length) CSS.highlights.set("diff-del-left", new Highlight(...leftRanges)); else CSS.highlights.delete("diff-del-left"); if (rightRanges.length) CSS.highlights.set("diff-add-right", new Highlight(...rightRanges)); else CSS.highlights.delete("diff-add-right"); }, [left, right, type]); return ( <div className="text-diff-container"> {type === "html" ? ( <div className="text-diff-pane" ref={leftRef} dangerouslySetInnerHTML={{ __html: left }} /> ) : ( <div className="text-diff-pane" ref={leftRef}> {left} </div> )} {type === "html" ? ( <div className="text-diff-pane" ref={rightRef} dangerouslySetInnerHTML={{ __html: right }} /> ) : ( <div className="text-diff-pane" ref={rightRef}> {right} </div> )} </div> ); }; export default TextDiff;总结
目前来看,CSS Custom Highlight API 就是网页文本高亮的“神器”。特别适合那些需要疯狂标记、又不能动原文档结构的应用,比如在线文档、代码编辑器。只要浏览器支持,用它就对了,绝对是未来的趋势。