搜索高亮别急着包 span:CSS Custom Highlight API 的边界和取舍

搜索高亮一开始通常很简单:拿到一段文本,匹配关键词,把命中的部分包进 <mark>,再加一点背景色。这个方案直观、语义明确,小组件里也很好维护。

真正麻烦的是它很容易从“小功能”长成“页面覆盖层”。搜索框要跟着输入实时更新;一篇长文里可能有几百个命中;命中词可能跨过多个内联元素;用户自己的选区、评论锚点、拼写提示、代码 token 还会和搜索命中叠在一起。为了画一层黄色背景,不断切文本节点、塞临时 DOM、再还原 DOM,开始变得不划算。

CSS Custom Highlight API 解决的就是这类问题里最容易被忽略的一层:绘制。它允许 JavaScript 把命中的文本位置做成 Range,注册成 Highlight,再交给 CSS 的 ::highlight() 伪元素去画。页面原本的 DOM 结构不用被高亮逻辑反复拆装。

不过,高亮不只有“画出来”这一件事。它至少有三层问题:怎么找到文本、怎么表达语义、怎么绘制视觉。::highlight() 主要解决第三层。匹配、索引、兼容、可访问性仍然要自己想清楚。

它到底改了哪一层

传统搜索高亮大多是这个流程:

  1. 遍历 DOM 里的文本节点。
  2. 找到命中的字符串位置。
  3. 把原文本节点切开。
  4. <mark class="hit">...</mark><span class="hit">...</span> 包住命中片段。
  5. 查询变化时再把旧节点还原,重新包一遍。

这在小块内容里完全够用。问题会出现在高亮变成一个频繁变化的“覆盖层”之后:用户每输入一个字就要重算;多个高亮层可能互相重叠;复制、事件委托、选区恢复、框架渲染都可能被临时插入的 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(),它会清掉页面上所有注册过的自定义高亮;组件应该只更新自己的名字。
  • 不要高亮 scriptstyletextareainputselect 里的文本,也要允许业务用 data-no-highlight 排除区域。
  • @supports selector(::highlight(...)) 只能判断 CSS 解析能力,JS 仍然要检测 CSS.highlightsHighlightRange
  • 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-errorgrammar-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。

真正稳定的实现大概会长成三段:

  1. matcher:负责字符串、正则、索引、大小写、跨节点、国际化。
  2. semantic layer:决定哪些高亮必须进入 DOM 或文档模型,哪些只是视觉状态。
  3. renderer:现代浏览器用 CSS Custom Highlight API,旧环境退回 <mark> / <span>

这样看,::highlight() 不是 <mark> 的替身,也不是搜索库的替身。它是浏览器终于给前端的一支“高亮画笔”。画笔很好,但要画得稳,还是得先知道自己要标的是文本、语义,还是一层随时会变的视觉状态。