大家好,我是CC,在这里欢迎大家的到来~
背景
在传统实现文本高亮时通常使用 span 标签包裹文本,再给 span 标签添加相应高亮背景色。这种方式会修改原本的 DOM 结构,逻辑复杂,也会频繁导致页面重绘,消耗浏览器性能。而基于 HighLight 的 CSS 自定义高亮 API 的这种方式可以实现在渲染层处理文本高亮,既不影响 DOM 树,而且完全独立于文档结构。
实现步骤
创建 Range 对象
标识想要高亮的文本范围。
javascript
<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 对象关联,这样会以相同方式高亮显示多个文本片段。
javascript
const highlight = new Highlight(range1, range2);
当然也可以在某些场景下创建多个 Highlight 对象,比如在使用协作文本编辑器中每个用户使用不同的文本颜色。
javascript
const user1Highlight = new Highlight(user1Range1, user1Range2);
const user2Highlight = new Highlight(user2Range1, user2Range2, user2Range3);
HighlightRegistry 注册
注册表是一个 Map 对象,通过名称注册高亮。
javascript
CSS.highlights.set("user-1-highlight", user1Highlight);
CSS.highlights.set("user-2-highlight", user2Highlight);
当然除了注册之外,也支持删除和清除。
javascript
CSS.highlights.delete("user-1-highlight");
CSS.highlights.clear();
::highlight()伪元素定义高亮样式
为文本片段添加自定义样式进行高亮。
css
::highlight(user-1-highlight) {
background-color: yellow;
color: black;
}
::highlight(user-2-highlight) {
background-color: black;
color: yellow;
}
应用场景
这里举例两个 CSS 自定义高亮 API 的应用场景。
搜索高亮文本
在多段文本中搜索检索到文本后直接高亮展示,方便用户查找。
tsx
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;
文本差异对比
对两段文本进行对比时,左侧高亮"不存在于右侧"的文本为绿色(删除),右侧高亮"多于左侧"的文本为红色(新增),可以直观看到差异。
tsx
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 就是网页文本高亮的"神器"。特别适合那些需要疯狂标记、又不能动原文档结构的应用,比如在线文档、代码编辑器。只要浏览器支持,用它就对了,绝对是未来的趋势。