CSS Custom Highlight:不改 DOM 也能做文本高亮

翻 MDN 的时候,我在 CSS.highlights 这个条目上停了很久。

这是个我之前没注意过的 API,规范里叫 CSS Custom Highlight。它承诺的事情很直接:不往 DOM 里插任何标签,就能给指定的文本范围加背景色、下划线这类样式。我们后台的站内搜索一直用最土的办法做高亮——把命中的词替换成 <mark>,在纯文本里没问题,可一旦内容里夹着代码块、链接、加粗文本,字符串替换就变得很危险,用户复制文字时还会把插进去的结构带出奇怪效果。所以看到这个 API,我第一反应是想试试,但心里也清楚,「规范里有」和「能上生产」是两回事,得先自己把边界摸一遍。

先说结论式的观望:Chrome 和 Edge 从 105 起就支持了,我在自己机器上验证没问题;但 Safari 到现在还不支持,看 caniuse 也没给出明确的落地版本。所以这篇是我读文档加动手验证的笔记,能用的场景我只敢先放在内部工具和可控环境里试,面向外部用户的页面还得老老实实带降级。

它到底解决了什么

传统高亮就是往内容里插标签:

1这是一段 <mark>需要高亮</mark> 的文字

静态内容这么写没毛病。但高亮范围如果是动态算出来的——搜索关键词、拼写检查、评论定位——就得不断改 DOM,插标签、清旧标签、合并被拆开的文本节点,一路都是坑。

Custom Highlight 换了个思路,把这件事拆成三步:

  • 用 JavaScript 创建文本 Range
  • 把这个 Range 注册进浏览器的高亮集合 CSS.highlights
  • 用 CSS 的 ::highlight() 伪元素控制样式

文本节点本身一个字都不用动。对富文本编辑器、在线文档、代码阅读器来说,这点很关键,因为它们的 DOM 结构还同时承担着内容模型、选区、光标和事件处理,往里硬插节点等于在雷区里施工。

把 API 跑通

先来一段文本:

1<p id="content">前端开发需要关注性能、交互和可维护性。</p>

创建一个 Range,注册成高亮:

1const textNode = document.querySelector("#content").firstChild;
2
3const range = new Range();
4range.setStart(textNode, 0);
5range.setEnd(textNode, 4);
6
7CSS.highlights.set("keyword", new Highlight(range));

再用 CSS 给这个名字的高亮上样式:

1::highlight(keyword) {
2  background: #ffe58f;
3  color: #111;
4}

这样「前端开发」这四个字就高亮了。我实际跑的时候印象最深的一点是:整个过程 DOM 里连一个多余节点都没有,#contentinnerHTML 完全没变。选中、复制出来的还是原始那句话,不会夹带任何标签碎片。这正是 <mark> 方案给不了的。

这里有个命名的小细节要注意:CSS.highlights.set 的第一个参数是高亮名,::highlight() 里必须写一模一样的名字才能对上。名字写错、或者 CSS 里写成 ::highlight(keywords) 多了个 s,高亮就是不显示,控制台还不报错——排查时先核对这两处名字是否严格一致,能省不少冤枉时间。

搜索关键词高亮

真实项目里高亮范围通常来自搜索词,得先在文本里找到所有匹配位置,再给每个匹配建一个 Range

1function highlightKeyword(element, keyword) {
2  if (!CSS.highlights || !keyword) {
3    return;
4  }
5
6  const textNode = element.firstChild;
7
8  if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
9    return;
10  }
11
12  const text = textNode.textContent;
13  const ranges = [];
14  let startIndex = 0;
15
16  while (startIndex < text.length) {
17    const index = text.indexOf(keyword, startIndex);
18
19    if (index === -1) {
20      break;
21    }
22
23    const range = new Range();
24    range.setStart(textNode, index);
25    range.setEnd(textNode, index + keyword.length);
26    ranges.push(range);
27
28    startIndex = index + keyword.length;
29  }
30
31  CSS.highlights.set("search-result", new Highlight(...ranges));
32}

一个 Highlight 里可以塞任意多个 Range,所有命中的词共用同一套 ::highlight(search-result) 样式,这比给每个词单独插标签清爽多了。

但这段代码有个我验证时立刻撞上的限制:它只处理单个文本节点。真实文章里,一句话中间夹个 <strong>,关键词就可能横跨好几个文本节点,element.firstChild 只能拿到第一段。要正经支持多节点,得用 TreeWalker 把元素下所有文本节点遍历出来,逐个计算匹配范围:

1const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
2const ranges = [];
3let node;
4
5while ((node = walker.nextNode())) {
6  const text = node.textContent;
7  let from = 0;
8  let hit;
9  while ((hit = text.indexOf(keyword, from)) !== -1) {
10    const range = new Range();
11    range.setStart(node, hit);
12    range.setEnd(node, hit + keyword.length);
13    ranges.push(range);
14    from = hit + keyword.length;
15  }
16}

就算这样,关键词恰好被 <strong> 从中间劈开的情况还是覆盖不到。要彻底解决,得维护一套文本索引,把 DOM 文本节点和全局字符偏移映射起来,再把跨节点的匹配拆成多个 Range 拼回去。这个成本不低,所以别看 API 优雅就低估了搜索匹配本身的复杂度——API 省掉的是「插标签」,没省掉「找到底在哪儿」。

多个高亮层可以叠加

CSS.highlights 是个类 Map 的集合,可以同时注册多个命名高亮,它们能叠在同一段文本上。这在需要区分「当前命中项」和「其他命中项」时很好用——比如搜索结果里,所有匹配用浅黄底,用户正跳到的那一个用深橙底:

1CSS.highlights.set("search-all", new Highlight(...allRanges));
2CSS.highlights.set("search-current", new Highlight(currentRange));
1::highlight(search-all) { background: #fff3bf; }
2::highlight(search-current) { background: #ffa94d; }

多个高亮重叠时,谁盖谁由 priority 决定。Highlight 实例有个 priority 属性,数值大的画在上面;默认都是 0,这时按注册顺序,后注册的在上:

1const current = new Highlight(currentRange);
2current.priority = 1; // 确保当前项盖过整体高亮
3CSS.highlights.set("search-current", current);

这套「一个 all 层加一个 current 层」的结构,比往 DOM 里插两种不同 class 的 <mark> 干净太多——切换当前项时,只要换掉 search-current 里那个 Range,不用去 DOM 里挪 class。

和原生 Selection 的区别

有人会想:浏览器不是本来就有选区高亮吗,用 window.getSelection() 加 Range 不也能高亮?两者看着像,但不是一回事。Selection 是用户选中文本那套,全页只有一个、样式基本不可控(就是系统的选区蓝底),而且会和用户真实的选择操作冲突——你程序化设一个 selection,用户一点别处就没了。

Custom Highlight 是独立的一层:它不占用 selection,用户照样能正常选中、复制文本,两套高亮互不干扰。样式也完全由 ::highlight() 自定义。所以做「搜索命中」这种需要长期停留、又不能妨碍用户操作的标注,Custom Highlight 才是对的工具,Selection 顶多适合「程序临时选中一段让用户看到」的一次性场景。

它适合哪些场景

摸下来,这类功能最契合 Custom Highlight:

  • 页面内搜索关键词高亮
  • 在线文档里定位并高亮某段内容
  • 代码编辑器标记错误或警告区间
  • 富文本批注、划词评论
  • 拼写检查、语法提示

共同点是:高亮范围频繁变,但你并不想动原始 DOM。拿搜索举例,每输入一个字符都重新插一遍 <mark>,就要处理旧标签清理、文本节点合并、选区丢失一堆问题;换成 Custom Highlight,「内容结构」和「视觉标注」彻底分开了,改的只是注册进去的 Range,DOM 纹丝不动。

代码编辑器标错误区间是另一个很贴的例子。像 lint 波浪线、语法错误下划线,本质就是「在某段文本下画条线」,既不能改动源码文本节点、又要随光标和编辑频繁移动。以前 CodeMirror、Monaco 这类编辑器都是自己用绝对定位的覆盖层硬画,Custom Highlight 出来后,这类标注理论上能交给浏览器原生去画,省掉一整套坐标计算——当然前提还是那句,Safari 没支持之前,编辑器类库也只能把它当增强,不能当唯一方案。

需要提前想清楚的边界

第一位的还是兼容性。Chrome/Edge 能用,Safari 现在不支持,所以用之前必须探测:

1if ("highlights" in CSS && "Highlight" in window) {
2  // 走 Custom Highlight
3} else {
4  // 降级到 mark / span,或者只定位不高亮
5}

我给自己定的规矩是:只要页面会被外部用户在 Safari 里打开,就默认走降级分支,Custom Highlight 只作为「支持就更好」的增强,绝不当成唯一实现。内部后台工具全员 Chrome,才敢直接上。

降级分支我倾向做成「同一套匹配逻辑,两种渲染出口」,而不是两套完全不同的实现。前面用 TreeWalker 找到的那批匹配位置,支持 API 就注册成 Range,不支持就退回给命中处包 <mark>——匹配这一层复用,只有最后「怎么把高亮画出来」分叉:

1function applyHighlight(element, keyword) {
2  const supported = "highlights" in CSS && "Highlight" in window;
3  const matches = findMatches(element, keyword); // 两条路共用
4
5  if (supported) {
6    CSS.highlights.set("search-result", new Highlight(...toRanges(matches)));
7  } else {
8    wrapWithMark(element, matches); // 老办法兜底
9  }
10}

这样将来 Safari 补上支持,删掉 else 分支就行,匹配逻辑不用重写。渐进增强的关键就是让「能力探测」只影响最末端那一步,别让它把整条逻辑劈成两份各自维护。

第二,Range 的起止位置绑在具体文本节点上。内容一旦重新渲染,原来的文本节点可能就没了,高亮也得跟着重算。React/Vue 这类会频繁重建 DOM 的场景尤其要小心:虚拟 DOM diff 后复用的可能是新的文本节点,旧 Range 指向的节点已经被替换,高亮要么错位要么消失。稳妥的做法是把「重算高亮」这件事挂到内容真正变更的那个信号上,而不是指望 Range 自己跟着 DOM 走——它不会。

第三,::highlight() 能设的 CSS 属性有限。规范把它归到 highlight 伪元素那一类,只允许一小撮和文本着色相关的属性生效:colorbackground-colortext-decoration 系列、text-shadow 这些,还有 -webkit-text-strokemarginpaddingbordertransform 这类会影响布局的属性直接被忽略,写了也没用。所以它能做的就是「给文字换个颜色、加条线」,做不了「给命中词加个圆角小胶囊背景」这种带盒模型的效果——真要那种视觉,还是得回到 <mark> 加 CSS 的老路,权衡就又回到「值不值得动 DOM」上了。

还有个容易忽略的点是生命周期。高亮不是注册完就一劳永逸,搜索词变了、文章重渲染了、主题切换了,都得清掉旧的或重算:

1CSS.highlights.delete("search-result");

不清理的话,旧 Range 可能继续挂着,甚至指向已经被替换掉的文本节点。在框架里,最稳妥的是组件卸载时把对应高亮 delete 掉,避免跨页面残留。这一点和 IntersectionObserver、事件监听那些「注册了要记得注销」的资源是一个道理。

在 Vue 里我会把整套注册和清理收进一个 composable,卸载时自动收尾:

1export function useHighlight(elementRef, keywordRef) {
2  const NAME = "search-result";
3
4  watchEffect(() => {
5    if (!("highlights" in CSS)) return;
6    const el = elementRef.value;
7    if (!el) return;
8    CSS.highlights.delete(NAME); // 每次重算前先清旧的
9    if (keywordRef.value) {
10      highlightKeyword(el, keywordRef.value);
11    }
12  });
13
14  onBeforeUnmount(() => {
15    if ("highlights" in CSS) {
16      CSS.highlights.delete(NAME);
17    }
18  });
19}

关键是 watchEffect 每次跑之前先 delete 一遍。搜索词从 ab 变成 abc,如果不先清,abc 的新 Range 会叠在 ab 的旧 Range 上,出现残影。React 里对应的就是 useEffect 的 cleanup 函数里 delete,思路一样:注册和清理必须成对,且更新时先清后建。

性能上它其实占优

<mark> 方案有个隐性代价:往文本里插标签会拆分文本节点、触发局部重排,命中很多时会明显卡。Custom Highlight 因为不动 DOM 结构,注册 Range 只是往一个集合里塞对象,浏览器在绘制阶段单独画高亮层,不引起 DOM 重排。命中几百上千处时,这个差距会拉得很开。

但也别以为它零成本。创建大量 Range、用 TreeWalker 全文遍历、字符串多次 indexOf,这些计算量还在。真正做大文档搜索时,我会给 highlightKeyword 加防抖,用户停止输入一小段时间再算,而不是每敲一个字符全文重扫一遍。高亮的绘制便宜了,匹配计算这块该省还是得省。

我现在会怎么选

做高亮,我会先问一句:内容结构能不能改。

纯静态内容用 <mark> 最省心,简单、语义也清楚,Safari 也照样支持。而且 <mark> 是有语义的元素,屏幕阅读器能识别,对可访问性友好;Custom Highlight 画的高亮目前对辅助技术基本是透明的,纯视觉层,这也是选型时要顺带考虑的一点——如果高亮承载的是「这段很重要」这类语义,<mark> 反而更合适。

文章、富文本、代码块、编辑器这类结构一复杂,往 DOM 里硬插标签就容易牵出选区、复制、事件、重渲染一连串问题,Custom Highlight 把结构和标注分开的价值这时才显出来。

但它现在还不是能无脑替换 <mark> 的方案。兼容性摆在那儿——Safari 还没跟上,我只在内部可控环境里放心用;对外的页面一律带降级,先保证「能搜到、能定位」,高亮只是锦上添花,不该为了它让页面在半数用户那里变脆。

我打算把它先在团队内部的一个文档阅读器里试起来:那是个只在 Chrome 环境用的内部工具,正好有「全文搜索并高亮命中」的需求,内容还带代码块和加粗,用 <mark> 改结构一直很别扭。这种可控、又确实吃到它长处的场景,是现在唯一敢上的地方。等哪天 Safari 补上支持、caniuse 上绿成一片,再谈把它铺到面向用户的产品里也不迟——在那之前,观望和降级都不丢人。