CSS 自定义高亮 API(Highlight API)实现代码语法高亮的纯前端方案
一、"不再需要 Prism.js 了"——一个让你两难的技术决策
代码块语法高亮是技术博客的标配。传统方案是 build 时预编译(Prism.js / Shiki),将高亮的 HTML 写入静态页面。优点:零运行时开销。缺点:内容不能动态变化(用户输入、实时预览、协同编辑)。
如果你需要在编辑器中实时高亮用户输入的代码,或者在一个可编辑的代码块中即时反馈,你就需要运行时的语法高亮。CSS Highlight API(CSS.highlights)正是为此而生——它不需要修改 DOM、不引入额外的 HTML 标签,直接在文本节点上通过选区范围(Range)标记样式。
二、Highlight API 的工作原理
Highlight API 的工作流程主要包含以下几个步骤:
- JS 解析器解析代码文本,生成 Token 列表。
- 为每个 Token 创建对应的Range对象。
- 通过
highlights.set('code-syntax', Highlight)将 Range 集合注册到CSS.highlights中,其中 Highlight 对象本质上是 Range 的集合。 - 触发::highlight 伪元素样式。
- 渲染引擎仅渲染被 Range 覆盖的文本节点特定区域。
整个过程中,文本节点保持完整,实现了零 DOM 变化。
关键区别:传统高亮方案(Prism.js)会修改 DOM,把<span>插入文本节点中;Highlight API 通过Range直接在文本渲染层标记样式,DOM 保持原样。
三、纯前端代码高亮实现
// highlight/code-highlighter.ts // 基于 CSS Highlight API 的代码语法高亮 interface Token { type: 'keyword' | 'string' | 'number' | 'comment' | 'function' | 'operator' | 'punctuation'; --- value: string; start: number; // 在源代码中的起始位置 end: number; // 在源代码中的结束位置 } /** * 基于 Highlight API 的代码语法高亮器 * * 设计意图:替代 Prism.js / highlight.js 的运行时高亮方案 * 优势:不修改 DOM、支持实时编辑、零额外标签开销 * * 局限:需要浏览器支持 CSS Highlight API(Chrome 105+, Safari 17.2+) */ class HighlightAPISyntaxHighlighter { private textNode: Text | null = null; private tokens: Token[] = []; /** * 对代码块应用语法高亮 * * @param codeElement - 包含代码的 <pre><code> 元素 * @param language - 编程语言('javascript', 'typescript', 'css' 等) */ highlight(codeElement: HTMLElement, language: string = 'javascript'): void { // 获取代码文本节点(<code> 的直接子文本节点) const code = codeElement.textContent || ''; this.textNode = this.getTextNode(codeElement); if (!this.textNode) { console.warn('无法找到文本节点,回退到传统的 span 高亮方案'); this.fallbackHighlight(codeElement, language); return; } // 1. 词法分析:将代码文本分解为 Token 序列 this.tokens = this.tokenize(code, language); // 2. 创建 CSS Highlight 注册 const highlights = this.createHighlights(); // 3. 将 Highlight 对象注册到 CSS.highlights CSS.highlights.clear(); // 清除之前的高亮 for (const [type, highlight] of Object.entries(highlights)) { CSS.highlights.set(`code-${type}`, highlight); } } /** * 获取代码元素中的文本节点 * * 注意:<code> 内可能有多个文本节点(因为有换行符分割) * 我们需要把它们视为一个整体的文本区域 */ private getTextNode(element: HTMLElement): Text | null { const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, null ); // 找到第一个非空文本节点 let node = walker.nextNode(); while (node) { // 忽略回车符/换行符产生的空文本节点 if ((node.textContent?.length || 0) > 0) { return node as Text; } node = walker.nextNode(); } return null; } /** * 简化的词法分析器 * * 将源代码分割为 Token 序列 * * 注意:这是一个简化版实现,仅支持 JavaScript/TypeScript 的基本语法。 * 完整的词法分析器应考虑: * - 正则表达式字面量(/pattern/) * - 模板字符串(`text`) * - JSX 标记(<Component>) */ private tokenize(code: string, language: string): Token[] { const tokens: Token[] = []; let pos = 0; // Token 模式定义 const patterns: Array<{ type: Token['type']; regex: RegExp; }> = [ // 注释(优先级最高——其他模式在注释内不生效) { type: 'comment', regex: /\/\/.*$|\/\*[\s\S]*?\*\//m }, // 字符串 { type: 'string', regex: /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/ }, // 数字 { type: 'number', regex: /\b\d+(\.\d+)?\b/ }, // 关键字 { type: 'keyword', regex: /\b(const|let|var|function|return|if|else|for|while|class|import|export|from|async|await|try|catch|throw|new|typeof|instanceof)\b/ }, // 函数名(标识符后跟括号) { type: 'function', regex: /\b([a-zA-Z_$][\w$]*)\s*(?=\()/ }, ]; while (pos < code.length) { let matched = false; for (const { type, regex } of patterns) { regex.lastIndex = pos; const match = regex.exec(code); if (match && match.index === pos) { tokens.push({ type, value: match[0], start: pos, end: pos + match[0].length }); pos += match[0].length; matched = true; break; } } if (!matched) { // 未匹配任何模式 → 普通文本,推进一个字符 pos++; } } return tokens; } /** * 创建 Highlight 对象 * * 按 Token 类型分组,每个分组创建一个 Highlight 对象 */ private createHighlights(): Record<string, Highlight> { const highlights: Record<string, Highlight> = {}; if (!this.textNode) return highlights; for (const token of this.tokens) { const type = token.type; if (!highlights[type]) { highlights[type] = new Highlight(); } // 创建 Range 覆盖 Token 对应的文本区域 const range = new Range(); range.setStart(this.textNode, token.start); range.setEnd(this.textNode, token.end); // 将 Range 添加到对应类型的 Highlight 中 highlights[type].add(range); } return highlights; } /** * 传统方案降级(修改 DOM) * * 当浏览器不支持 Highlight API 时使用 */ private fallbackHighlight(codeElement: HTMLElement, language: string): void { const code = codeElement.textContent || ''; const tokens = this.tokenize(code, language); // 从后往前替换(避免位置偏移问题) tokens.sort((a, b) => b.start - a.start); for (const token of tokens) { const span = document.createElement('span'); span.className = `token-${token.type}`; span.textContent = token.value; // 创建 Range 来替换 Token 位置的内容 const textNode = this.getTextNode(codeElement); if (!textNode) continue; const range = new Range(); range.setStart(textNode, token.start); range.setEnd(textNode, token.end); range.deleteContents(); range.insertNode(span); } } }/* * ========================================== * CSS ::highlight 伪元素样式 * ========================================== * * ::highlight() 是 CSS Highlight API 的样式入口 * 括号中的参数对应 CSS.highlights.set(name, highlight) 中的 name * * ⚠️ 注意:::highlight 支持的属性很少—— * 仅 color, background-color, text-decoration, * text-shadow, -webkit-text-stroke 等少数属性 * * font-family, font-size 等排版属性在 ::highlight 中无效——这是规范限制 */ /* 关键字(const, let, function, return...) */ ::highlight(code-keyword) { color: #d73a49; font-weight: bold; } /* 字符串("hello", 'world') */ ::highlight(code-string) { color: #032f62; } /* 数字(42, 3.14) */ ::highlight(code-number) { color: #005cc5; } /* 注释(// comment, /* block */) */ ::highlight(code-comment) { color: #6a737d; font-style: italic; } /* 函数名(myFunction()) */ ::highlight(code-function) { color: #6f42c1; }// highlight/react-use-highlight.ts // React Hook 封装 import { useEffect, useRef } from 'react'; /** * React Hook:对代码块应用语法高亮 * * 使用方式: * function CodeBlock({ code }: { code: string }) { * const ref = useCodeHighlight('typescript'); * return <pre ref={ref}><code>{code}</code></pre>; * } */ function useCodeHighlight(language: string) { const ref = useRef<HTMLPreElement>(null); const highlighter = useRef(new HighlightAPISyntaxHighlighter()); useEffect(() => { if (ref.current) { const code = ref.current.querySelector('code'); if (code) { highlighter.current.highlight(code, language); } } }, [language]); return ref; }四、Highlight API 的三个限制
样式属性限制。::highlight只支持极少的 CSS 属性(color、background-color、text-decoration、text-shadow)。不能改变font-size、font-family,不能添加padding/margin/border。这意味着高亮只能改变颜色,不能改变文字的排版表现。
Range 不能跨文本节点。如果你的代码块 DOM 是多文本节点的(如<code><span>const</span> a = 1</code>),Highlight API 的 Range 只能覆盖单个文本节点。对于已有混合标签的代码块,Highlight API 无法工作。
浏览器兼容性。Chrome 105+(2022.8)、Safari 17.2+(2023.12)、Firefox 尚未支持(截至 2024)。国内移动端覆盖率约 70%。对于需要广泛兼容的项目,Highlight API 只能作为增强方案,不能作为唯一方案。
五、总结
CSS Highlight API 是代码语法高亮的第三种方案:
| 方案 | 时机 | DOM 修改 | 动态内容 |
|---|---|---|---|
| Build 预编译 | 构建时 | 是(span 标签) | 不支持 |
| JS 运行时高亮 | 运行时 | 是(span 标签) | 支持 |
| Highlight API | 运行时 | 否 | 支持 |
Highlight API 的核心优势是不修改 DOM——这在协同编辑、实时预览、代码 Diff 展示等场景中至关重要。但目前仍需要降级方案(传统 span 高亮)保持兼容性。最佳实践:先检测CSS.highlights是否可用,可用则用 API,不可用则降级。