前端技术
#CSS#Custom Highlight API#文本高亮#性能#代码编辑器

CSS Custom Highlight API 如何实现文本高亮:从选区样式到性能边界

本文介绍 CSS Custom Highlight API 如何通过 ::highlight() 伪元素为任意文本范围应用样式,并与 ::selection 对比,分析其性能影响(如重绘范围),以代码编辑器语法高亮为例,对比传统 span 包裹方案,说明适用边界。

从一次卡顿说起

假设你在维护一个基于 Web 的代码编辑器,用户打开一个上千行的 JavaScript 文件,编辑器需要为关键字、字符串、注释分别着色。传统做法是用 <span> 包裹每个需要着色的文本片段,再通过 CSS 类名控制颜色。文件不大时一切正常,但当文件行数增多、语法高亮频繁更新(比如用户连续输入触发重新着色)时,页面会出现明显的卡顿——输入一个字符,往往要等上百毫秒才能看到新的高亮。

问题出在哪里?<span> 包裹方案会修改 DOM 结构:每次高亮更新都要创建、插入或移除大量 span 元素,这会引起 DOM 树的重新布局和重绘。更糟的是,如果高亮范围跨越多个元素,还需要拆分子节点,DOM 操作量成倍增加。有没有一种方式,既能精确控制文本样式,又不触碰 DOM 结构?CSS Custom Highlight API 正是为此设计的。

认识 Custom Highlight API

CSS Custom Highlight API 是一种让开发者通过 JavaScript 创建任意文本范围,并用 CSS 为这些范围应用样式的机制。它扩展了 ::selection::spelling-error 等浏览器内置的高亮伪元素,使开发者可以定义自己的高亮类型。

关键概念有三个:Range 对象、Highlight 对象和 HighlightRegistryRange 是 DOM 标准中已有的概念,表示文档中的一个连续区域,可以跨越元素边界。Highlight 是一个集合,可以包含多个 Range,并带有 prioritytype 属性。HighlightRegistry 是一个类似 Map 的对象,通过 CSS.highlights 访问,用于注册命名的高亮。

使用流程分四步:创建 Range、创建 Highlight、注册到 HighlightRegistry、在 CSS 中用 ::highlight() 伪元素设置样式。下面是一个简单的例子:

const parentNode = document.getElementById('code');
const range1 = new Range();
range1.setStart(parentNode, 10);
range1.setEnd(parentNode, 20);

const highlight = new Highlight(range1);
CSS.highlights.set('keyword', highlight);
::highlight(keyword) {
  color: #0077aa;
  font-weight: bold;
}

这段代码将 parentNode 中第 10 到第 20 个字符着色为蓝色加粗。::highlight() 伪元素接受一个自定义标识符,对应注册时使用的名称。

::selection 相比,::selection 只能样式化用户选中的文本,而 Custom Highlight API 可以样式化任意范围,且多个高亮可以同时存在、相互叠加。

与传统 span 包裹方案的对比

传统方案中,高亮文本需要被 <span> 包裹,例如:

<code>const <span class="keyword">let</span> x = <span class="number">1</span>;</code>

这种方案的优点是兼容性极好,几乎在所有浏览器中都能工作。但它的代价是 DOM 结构被高亮逻辑污染:高亮更新时,需要查找、创建、修改或删除 span 元素,这些操作会触发浏览器的样式计算和布局。更严重的是,如果高亮范围跨越多个元素,比如一个关键字被拆成两半,就需要在 DOM 中插入额外的节点来包裹,这会使 DOM 树变得复杂,增加内存占用和操作成本。

Custom Highlight API 则完全避免了这些问题。高亮信息存储在 JavaScript 对象中,不修改 DOM 结构。浏览器在渲染时,会根据 Highlight 中的 Range 信息,在绘制阶段直接应用样式。这意味着高亮更新不需要操作 DOM,只需要更新 Range 对象或注册表,浏览器会自动重绘受影响的部分。

下面用表格对比两种方案的关键差异:

维度span 包裹方案Custom Highlight API
DOM 结构需要插入 span 元素,可能改变 DOM 树不修改 DOM 结构,高亮信息独立存储
更新成本需要增删改 span,触发重排和重绘只需更新 Range 或注册表,重绘范围更小
跨越元素需要拆分节点,处理复杂天然支持跨元素范围
样式能力可使用任意 CSS 属性仅支持部分属性(如颜色、背景、文本装饰)
兼容性所有浏览器Baseline 2025,较新浏览器支持
可访问性可通过语义化标签(如 <mark>)增强需通过 type 属性或额外手段提供语义

性能分析:重绘范围与内存开销

性能是 Custom Highlight API 的核心优势之一。当高亮范围变化时,浏览器需要重绘的范围有多大?这取决于高亮是如何实现的。

在 span 方案中,修改 DOM 会导致整个文档或至少部分子树重新进行样式计算和布局。例如,在一个大文件中插入一个 span,浏览器可能需要重新计算该元素及其兄弟元素的样式,并重新布局。如果频繁操作,性能会急剧下降。

Custom Highlight API 将高亮样式作为独立的绘制层处理。浏览器在绘制文本时,会检查每个文本片段是否属于某个高亮范围,如果是,就应用相应的样式。当高亮范围变化时,浏览器只需要重绘受影响的文本区域,而不是整个页面。这种机制类似于 ::selection 的重绘方式,但更加灵活。

不过,性能优势并非没有代价。Highlight 对象中的 Range 是动态的,当 DOM 发生变化时,Range 会自动调整其边界。这种调整需要浏览器维护额外的数据结构,可能会增加内存开销。如果高亮范围非常多,比如一个大型代码编辑器中有成千上万个高亮片段,那么维护这些 Range 的成本也不可忽视。

此外,Range 对象是动态的,当 DOM 变化时,Range 会自动更新。如果高亮范围不需要跟随 DOM 变化,可以使用 StaticRange 来避免这种动态调整的开销。规范建议,在不需要动态更新的场景下,优先使用 StaticRange

代码编辑器场景:从概念到实践

让我们回到代码编辑器的场景。假设我们要为一个简单的 JavaScript 代码片段实现语法高亮,包括关键字、字符串和注释。

首先,我们需要获取代码文本的 DOM 节点。通常,编辑器会将代码放在一个 <pre><code> 元素中。我们可以通过 Range 来指定高亮范围。

const codeElement = document.getElementById('code');
const textNode = codeElement.firstChild;

// 假设我们有一个函数,根据代码内容返回高亮范围数组
const highlights = getSyntaxHighlights(textNode.textContent);

// 创建三个 Highlight 对象,分别对应关键字、字符串、注释
const keywordHighlight = new Highlight();
const stringHighlight = new Highlight();
const commentHighlight = new Highlight();

highlights.forEach(({ type, start, end }) => {
  const range = new Range();
  range.setStart(textNode, start);
  range.setEnd(textNode, end);
  if (type === 'keyword') keywordHighlight.add(range);
  else if (type === 'string') stringHighlight.add(range);
  else if (type === 'comment') commentHighlight.add(range);
});

CSS.highlights.set('keyword', keywordHighlight);
CSS.highlights.set('string', stringHighlight);
CSS.highlights.set('comment', commentHighlight);
::highlight(keyword) {
  color: #0077aa;
  font-weight: bold;
}
::highlight(string) {
  color: #dd1144;
}
::highlight(comment) {
  color: #999;
  font-style: italic;
}

这里的关键是,我们只需要在代码内容变化时重新计算高亮范围,并更新 Highlight 对象。由于不修改 DOM,编辑器的主线程可以专注于文本编辑和渲染,减少卡顿。

下面用流程图展示高亮更新的流程:

flowchart TD
    A[用户输入代码] --> B[编辑操作更新文本节点]
    B --> C[重新计算高亮范围]
    C --> D[更新 Highlight 对象中的 Range]
    D --> E[浏览器检测到高亮变化]
    E --> F[重绘受影响的文本区域]
    F --> G[屏幕显示新的高亮]

适用边界与限制

尽管 Custom Highlight API 强大,但它并非万能。首先,它只能应用于文本内容,不能用于元素或图像。其次,::highlight() 伪元素支持的 CSS 属性有限,规范规定只能使用颜色、背景、文本装饰等与文本呈现相关的属性,不能使用 displayposition 等布局属性。这意味着你无法通过高亮来改变元素的尺寸或位置。

另一个限制是兼容性。根据 MDN 的说明,Custom Highlight API 在 2025 年 6 月成为 Baseline,意味着它可以在最新的浏览器中工作,但旧浏览器可能不支持。如果你的用户群体使用较旧的浏览器,可能需要考虑降级方案。

此外,高亮本身不提供语义信息。虽然 Highlight 对象有 type 属性(highlightspelling-errorgrammar-error),但辅助技术对它的支持可能有限。如果你需要为视力障碍用户提供高亮内容的语义,可能需要使用 <mark> 等语义元素,或者通过 ARIA 属性补充。

失败模式与诊断

使用 Custom Highlight API 时,可能会遇到一些常见问题。

高亮不显示:最常见的原因是 Highlight 未正确注册或 ::highlight() 中的名称不匹配。检查 CSS.highlights 中是否包含该名称,以及 CSS 规则是否拼写正确。

高亮位置错误:如果 Range 的边界设置不正确,高亮可能会覆盖错误的文本。特别是当文本节点包含多个子节点时,setStartsetEnd 的偏移量是相对于节点内容的,需要仔细计算。

性能问题:虽然 API 设计为高效,但如果高亮范围数量巨大(例如数百万个),仍然可能导致性能下降。此时可以考虑合并相邻的 Range,或者使用 StaticRange 减少动态更新开销。

重绘范围过大:如果高亮范围跨越整个文档,任何高亮变化都可能触发大面积重绘。尽量缩小高亮范围,只包含必要的文本。

诊断这些问题时,可以使用浏览器的开发者工具,检查 CSS.highlights 的内容,以及高亮元素的渲染情况。

总结

CSS Custom Highlight API 为文本高亮提供了一种不修改 DOM 的优雅方案,特别适合需要频繁更新高亮的场景,如代码编辑器、协作编辑、拼写检查等。它通过将高亮信息从 DOM 中分离,减少了重绘范围,提升了性能。然而,它也有自己的限制,如属性支持有限、兼容性要求较新。在选择方案时,需要权衡 DOM 操作成本、性能需求和兼容性要求。对于现代浏览器环境下的动态文本高亮,Custom Highlight API 是一个值得考虑的选择。

资料来源

  1. CSS Custom Highlight API Module Level 1
  2. CSS Custom Highlight API - MDN
  3. CSS Custom Highlight API Module Level 1
  4. CSS Custom Highlight API Module Level 1