配了八年代码编辑器,今天才发现它的「语法高亮」从来没离开过 span 嵌套——Custom Highlight API 把这件事彻底原生化了
做过代码编辑器的人都踩过这个坑:每高亮一个关键词,就要在 AST 解析后往 DOM 树里塞一个 <span class=”keyword”>。一个 200 行的文件,保守估计塞进去三四百个 span,DOM 节点直接翻倍,渲染帧率肉眼可见地掉。
这事儿今天被 CSS Custom Highlight API 从根上翻了。
旧方案:span 嵌套的性能税
传统语法高亮的做法:Parser 解析出 token 流 → 每个 token 包一个 span → class 绑定颜色。流程没问题,但代价是:
DOM 节点数爆炸。 拿一段 50 行的 JavaScript 来说,const、function、字符串、正则、注释……加起来轻松 300+ 个 span。一个编辑器加载 10 个文件,DOM 节点直接多出几千个。
每次光标移动都要触发 reflow。 highlight 变化 → span 增加/删除 → 浏览器重新算布局 → 输入延迟。用户打字的时候感觉「跟不上」,就是这个原因。
多用户协作时 span 会打架。 两个用户同时选区高亮,各自的 span 嵌套顺序一错乱,显示就乱了。
内存占用直接翻倍。 Chrome DevTools 抓一下,span 满天飞的时候 JS Heap 能多出 30-50MB。
新方案:Range + Highlight + CSS.highlights
CSS Custom Highlight API 的思路是:DOM 只保留文本,所有 highlight 全部交给浏览器合成层单独绘制。
具体三步:
// 第一步:遍历文本节点,建 Range
const treeWalker = document.createTreeWalker(codeEl, NodeFilter.SHOW_TEXT);
const ranges = [];
while (treeWalker.nextNode()) {
const node = treeWalker.currentNode;
const text = node.textContent;
// 用正则匹配 token,建 Range
for (const match of tokenMatches(text)) {
const r = new Range();
r.setStart(node, match.start);
r.setEnd(node, match.end);
ranges.push(r);
}
}
// 第二步:按 token 类型打包成 Highlight
const hlKeyword = new Highlight(...keywordRanges);
const hlString = new Highlight(...stringRanges);
const hlComment = new Highlight(...commentRanges);
// 第三步:注册到 CSS.highlights(Map)
CSS.highlights.set("syntax-keyword", hlKeyword);
CSS.highlights.set("syntax-string", hlString);
CSS.highlights.set("syntax-comment", hlComment);
然后 CSS 里直接写:
::highlight(syntax-keyword) { color: #ff7b72; font-weight: 600; }
::highlight(syntax-string) { color: #a5d6ff; }
::highlight(syntax-comment) { color: #8b949e; font-style: italic; }
浏览器看到这些规则,直接在合成层绘制 highlight 覆盖层,DOM 树完全不动。
三个数字说清楚差距
实测数据来自 Pavi2410 的基准测试,对比同一个代码编辑器段落(3000+ 注释高亮):
DOM 节点数: span 方案 3000+ 个节点 → Highlight API 0 个额外节点
渲染帧率: span 方案 8fps → Highlight API 60fps
更新延迟: 光标移动后 highlight 重新计算,span 方案触发完整 layout → Highlight API 只触发合成层重绘,延迟 < 1ms
协作文本编辑器也是同样的逻辑
多个用户同时编辑,每个人有自己的选区高亮。在旧方案里,这意味着每个用户的选区都要包 span,多用户一叠加,span 嵌套层数直接爆炸。
Highlight API 的解法:每个用户一个命名 highlight,浏览器自动处理优先级叠加:
::highlight(cursor-alice) { background: rgba(88, 166, 255, 0.25); border-bottom: 2px solid #58a6ff; }
::highlight(cursor-bob) { background: rgba(63, 185, 80, 0.25); border-bottom: 2px solid #3fb950; }
用户选区变化 → 更新对应 Highlight 的 Range 集合 → CSS 驱动渲染,DOM 零改动。
三个坑
坑一:Safari 支持是 17.2+,Baseline 2025。 Safari 16 和 Firefox 119 之前没有 CSS.highlights,渐进增强方案是 feature detection:
if (CSS.highlights) {
// 用 Highlight API
} else {
// 回退到 span 方案(或降级纯文本)
}
坑二:highlight 层只支持部分 CSS 属性。 颜色、背景、下划线可以用,但不支持 padding/margin/display 等影响布局的属性,因为 highlight 是渲染在合成层的,不是真实 DOM。
坑三:Range 不追踪 DOM 变化。 文本内容变了之后,原来的 Range 偏移量就错了。需要重新走一遍 TreeWalker 重建 Range,或者监听文本节点变化重新计算。
三步下一步
第一步(5 分钟): 在本地跑一个 Demo,用 TreeWalker + 正则匹配一段 JS 代码,验证语法高亮能不能跑起来。参考 MDN 的 Search Highlight 示例即可。
第二步(半天): 把现有编辑器的 token → span 流程替换成 Range → Highlight,重点测 Safari fallback 是否工作正常。
第三步(一周): 如果是多用户编辑器,给每个用户建独立 Highlight,测高并发下合成层性能是否稳定。
CSS Custom Highlight API 解决的不是「颜色怎么配」的问题,而是「highlight 到底该画在哪一层」的架构问题。把渲染层和结构层分开,是前端工具链这些年一直在做的事,CSS 这两年终于把它带到了文本渲染这个环节。
评论区
登录后可评论。