从一次卡顿说起
假设你在维护一个基于 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 对象和 HighlightRegistry。Range 是 DOM 标准中已有的概念,表示文档中的一个连续区域,可以跨越元素边界。Highlight 是一个集合,可以包含多个 Range,并带有 priority 和 type 属性。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 属性有限,规范规定只能使用颜色、背景、文本装饰等与文本呈现相关的属性,不能使用 display、position 等布局属性。这意味着你无法通过高亮来改变元素的尺寸或位置。
另一个限制是兼容性。根据 MDN 的说明,Custom Highlight API 在 2025 年 6 月成为 Baseline,意味着它可以在最新的浏览器中工作,但旧浏览器可能不支持。如果你的用户群体使用较旧的浏览器,可能需要考虑降级方案。
此外,高亮本身不提供语义信息。虽然 Highlight 对象有 type 属性(highlight、spelling-error、grammar-error),但辅助技术对它的支持可能有限。如果你需要为视力障碍用户提供高亮内容的语义,可能需要使用 <mark> 等语义元素,或者通过 ARIA 属性补充。
失败模式与诊断
使用 Custom Highlight API 时,可能会遇到一些常见问题。
高亮不显示:最常见的原因是 Highlight 未正确注册或 ::highlight() 中的名称不匹配。检查 CSS.highlights 中是否包含该名称,以及 CSS 规则是否拼写正确。
高亮位置错误:如果 Range 的边界设置不正确,高亮可能会覆盖错误的文本。特别是当文本节点包含多个子节点时,setStart 和 setEnd 的偏移量是相对于节点内容的,需要仔细计算。
性能问题:虽然 API 设计为高效,但如果高亮范围数量巨大(例如数百万个),仍然可能导致性能下降。此时可以考虑合并相邻的 Range,或者使用 StaticRange 减少动态更新开销。
重绘范围过大:如果高亮范围跨越整个文档,任何高亮变化都可能触发大面积重绘。尽量缩小高亮范围,只包含必要的文本。
诊断这些问题时,可以使用浏览器的开发者工具,检查 CSS.highlights 的内容,以及高亮元素的渲染情况。
总结
CSS Custom Highlight API 为文本高亮提供了一种不修改 DOM 的优雅方案,特别适合需要频繁更新高亮的场景,如代码编辑器、协作编辑、拼写检查等。它通过将高亮信息从 DOM 中分离,减少了重绘范围,提升了性能。然而,它也有自己的限制,如属性支持有限、兼容性要求较新。在选择方案时,需要权衡 DOM 操作成本、性能需求和兼容性要求。对于现代浏览器环境下的动态文本高亮,Custom Highlight API 是一个值得考虑的选择。