搜索高亮别急着包 span:CSS Custom Highlight API 的边界和取舍
搜索高亮一开始通常很简单:拿到一段文本,匹配关键词,把命中的部分包进 <mark>,再加一点背景色。这个方案直观、语义明确,小组件里也很好维护。
真正麻烦的是它很容易从“小功能”长成“页面覆盖层”。搜索框要跟着输入实时更新;一篇长文里可能有几百个命中;命中词可能跨过多个内联元素;用户自己的选区、评论锚点、拼写提示、代码 token 还会和搜索命中叠在一起。为了画一层黄色背景,不断切文本节点、塞临时 DOM、再还原 DOM,开始变得不划算。
CSS Custom Highlight API 解决的就是这类问题里最容易被忽略的一层:绘制。它允许 JavaScript 把命中的文本位置做成 Range,注册成 Highlight,再交给 CSS 的 ::highlight() 伪元素去画。页面原本的 DOM 结构不用被高亮逻辑反复拆装。
不过,高亮不只有“画出来”这一件事。它至少有三层问题:怎么找到文本、怎么表达语义、怎么绘制视觉。::highlight() 主要解决第三层。匹配、索引、兼容、可访问性仍然要自己想清楚。
它到底改了哪一层
传统搜索高亮大多是这个流程:
- 遍历 DOM 里的文本节点。
- 找到命中的字符串位置。
- 把原文本节点切开。
- 用
<mark class="hit">...</mark>或<span class="hit">...</span>包住命中片段。 - 查询变化时再把旧节点还原,重新包一遍。
这在小块内容里完全够用。问题会出现在高亮变成一个频繁变化的“覆盖层”之后:用户每输入一个字就要重算;多个高亮层可能互相重叠;复制、事件委托、选区恢复、框架渲染都可能被临时插入的 DOM 打扰;富文本编辑器和代码编辑器里,文档模型还可能根本不希望你直接改真实 DOM。
MDN 的 CSS Custom Highlight API 文档把流程拆成四步:创建 Range,创建 Highlight,注册到 CSS.highlights,再用 ::highlight() 写样式。关键点是,高亮范围来自 JavaScript,绘制来自 CSS,中间不需要改变页面原本的 DOM 结构。
一个最小例子长这样:
const supportsCustomHighlight =
typeof CSS !== 'undefined' &&
'highlights' in CSS &&
'Highlight' in window &&
'Range' in window;
const text = document.querySelector('#content')?.firstChild;
if (supportsCustomHighlight && text?.nodeType === Node.TEXT_NODE) {
const range = new Range();
const value = text.nodeValue ?? '';
range.setStart(text, 0);
range.setEnd(text, value.length);
const highlight = new Highlight(range);
CSS.highlights.set('search-hit', highlight);
}@supports selector(::highlight(search-hit)) {
::highlight(search-hit) {
color: #111827;
background-color: #fde68a;
}
}这里有个很小但真实的坑:Range#setEnd(node, offset) 的 offset 是结束边界,不是最后一个字符下标。要选中整个文本节点,应该传 text.length,不是 text.length - 1。这个错在示例里不一定马上显眼,但放进搜索高亮就会稳定漏掉最后一个字符。
搜索高亮更需要一层封装
如果只是演示 API,几行代码就够了。如果要放进产品里的搜索框,至少要多处理几件事:
- 空查询要先返回,否则
indexOf('', pos)会一直命中当前位置,循环无法前进。 - 不要随手
CSS.highlights.clear(),它会清掉页面上所有注册过的自定义高亮;组件应该只更新自己的名字。 - 不要高亮
script、style、textarea、input、select里的文本,也要允许业务用data-no-highlight排除区域。 @supports selector(::highlight(...))只能判断 CSS 解析能力,JS 仍然要检测CSS.highlights、Highlight和Range。textContent可能是null,文本节点上用nodeValue ?? ''更稳。
下面这个可编辑 demo 用 Sandpack 跑了一个最小页面。预览里可以改搜索词和正文文本,也可以切换「Custom Highlight」和「DOM wrapper」两种模式;左侧代码同样能直接改。观察右上角的 DOM 统计会更直观:前者只更新 CSS.highlights 里的 ranges,后者会把命中词包成真实 <mark> 节点。
核心逻辑可以压成这样:
type ApplySearchHighlightOptions = {
root: ParentNode;
query: string;
name?: string;
caseSensitive?: boolean;
exclude?: string;
};
const DEFAULT_EXCLUDE =
'script, style, noscript, textarea, input, select, [hidden], [aria-hidden="true"], [data-no-highlight]';
export function applySearchHighlight({
root,
query,
name = 'search-results',
caseSensitive = false,
exclude = DEFAULT_EXCLUDE,
}: ApplySearchHighlightOptions) {
if (!supportsCustomHighlight) {
return { supported: false, count: 0 };
}
const highlight = CSS.highlights.get(name) ?? new Highlight();
highlight.clear();
const needle = caseSensitive ? query.trim() : query.trim().toLocaleLowerCase();
if (!needle) {
CSS.highlights.set(name, highlight);
return { supported: true, count: 0 };
}
let count = 0;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || parent.closest(exclude)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
while (walker.nextNode()) {
const node = walker.currentNode;
const rawText = node.nodeValue ?? '';
const haystack = caseSensitive ? rawText : rawText.toLocaleLowerCase();
let start = 0;
while (start < haystack.length) {
const index = haystack.indexOf(needle, start);
if (index === -1) break;
const range = new Range();
range.setStart(node, index);
range.setEnd(node, index + needle.length);
highlight.add(range);
count += 1;
start = index + needle.length;
}
}
CSS.highlights.set(name, highlight);
return { supported: true, count };
}这段仍然只是“普通字符串搜索”的最小版本。它没有处理正则、同义词、变音符、跨节点命中,也没有为超大文档做索引。也就是说,CSS Custom Highlight API 没有帮你省掉 matcher,只是让 matcher 的输出可以不再落成一堆临时 DOM 节点。
如果内容会频繁重排或由框架重新渲染,旧的 Range 还可能随着 live DOM 变化而变得不符合业务预期。对静态内容可以在内容更新后重算;对大型编辑器,通常要接到编辑器自己的文档模型或变更范围里。Blink 当年的 Intent to Ship 里也提到过,StaticRange 可以作为 live Range 的替代,因为它不会在 DOM mutation 时产生相同的维护成本。
它适合什么场景
我会把 ::highlight() 看成“绘制层原语”,适合这些场景:
- 页面内搜索:查询变化频繁,命中数量多,希望更新视觉而不打扰原 DOM。
- 长文档、虚拟文档、电子书:可见区域和真实 DOM 不一定稳定,直接包节点会让恢复和同步变麻烦。
- 编辑器高亮:拼写错误、语法错误、搜索命中、协作选区、评论锚点,本来就是覆盖在文本上的状态。
- 多层高亮:同一段文字既是搜索命中,又被用户选中,还带评论或拼写提示时,
Highlight.priority可以帮你处理重叠样式的优先级。 - 交互高亮:新版
CSS.highlights.highlightsFromPoint()可以按坐标查到命中的高亮和范围,适合做 tooltip、上下文菜单、拼写建议这类点击后浮层。
这些场景的共同点是:高亮主要是视觉状态,不应该污染内容结构。
它不该替代什么
::highlight() 不是更酷的 <mark>。
如果高亮本身有语义,比如搜索结果页摘要里的命中词、文档里被作者主动标出的重点、需要被复制/保存/序列化的标记,<mark> 或编辑器文档模型里的 mark 仍然更合适。MDN 的可访问性说明也很明确:自定义高亮不会天然给文档结构增加语义;Highlight.type 可以表达 spelling-error、grammar-error 这类类型,但辅助技术支持会受平台和类型影响。重要信息不能只靠一层视觉颜色。
样式能力也有限。::highlight() 允许的 CSS 属性主要是颜色、背景色、文字装饰、文字阴影和少量 WebKit 文本描边相关属性,background-image 会被忽略。它不会给你布局盒子,也不适合做圆角胶囊、渐变底、图标、按钮、浮层这些需要真实元素参与布局的 UI。
兼容性上也要谨慎。MDN 在 2026 年 8 月的页面里把 CSS Custom Highlight API 标成 Baseline 2025,把 ::highlight() 标成 Baseline 2026。这个判断的意思是“最新设备和浏览器版本可用”,不是“所有用户都可用”。如果你的业务要覆盖旧浏览器,仍然要保留 <mark> / <span> 兜底。
开源库怎么选
高亮方案最好按数据形态选,而不是按“是不是新 API”选。
| 场景 | 更合适的方案 | 取舍 |
|---|---|---|
| 普通网页 DOM 搜索,要求老浏览器、正则、变音符、iframe、跨元素匹配 | mark.js | 功能很全,但会插入 <mark> 或自定义元素,需要处理还原和框架重渲染边界 |
| React 里渲染一段普通字符串 | react-highlight-words / highlight-words-core |
适合纯文本组件,默认用 <mark> 包裹;不适合任意已有 DOM 的页面级搜索 |
| 搜索结果摘要、服务端或客户端字符串片段 | @orama/highlight | 返回位置和 HTML,适合 snippet;不是 live DOM 上的 Range 绘制 |
| 代码编辑器 | CodeMirror search 和 decorations | 应该走编辑器状态和 decoration 系统,而不是直接操作编辑器 DOM |
| 富文本编辑器 | Tiptap Decorations API / ProseMirror decorations | 视图层标记不污染文档 JSON;能按文档事务和 changed ranges 更新 |
| 语法高亮但不想包一堆 span | syntax-highlight-element | 用 Prism tokenizer 找 token,再用 CSS Custom Highlight API 绘制,思路很贴近这个平台能力 |
| 现代浏览器里的动态页面搜索 | CSS Custom Highlight API + 自己的 matcher + <mark> 兜底 |
DOM 干净、更新轻,但匹配能力和降级策略要自己补 |
mark.js 这类库解决的是“怎么找、怎么拆、怎么包、怎么还原”;::highlight() 解决的是“我已经知道范围了,怎么不改 DOM 地画出来”。这两个方向并不冲突。甚至未来完全可以有一个库用 mark.js 级别的 matcher,再根据能力检测选择 Custom Highlight 或 DOM wrapper 两种 renderer。
我的落点
如果只是做一个小组件,内容就是一段字符串,继续用 <mark> 很正常。它简单、语义明确、SSR 友好,也方便做复制和快照测试。
如果做的是长文档搜索、阅读器、知识库、代码编辑器、富文本编辑器,::highlight() 就很值得进入方案池。它最大的价值是把视觉高亮从内容结构里挪出来,而不是少写一个 span。这样搜索命中、拼写提示、协作选区、评论锚点可以像图层一样叠在文本上,更新时不必反复拆装 DOM。
真正稳定的实现大概会长成三段:
- matcher:负责字符串、正则、索引、大小写、跨节点、国际化。
- semantic layer:决定哪些高亮必须进入 DOM 或文档模型,哪些只是视觉状态。
- renderer:现代浏览器用 CSS Custom Highlight API,旧环境退回
<mark>/<span>。
这样看,::highlight() 不是 <mark> 的替身,也不是搜索库的替身。它是浏览器终于给前端的一支“高亮画笔”。画笔很好,但要画得稳,还是得先知道自己要标的是文本、语义,还是一层随时会变的视觉状态。