关于代码高亮
(点击点亮海报 — 光敏性癫痫警告)
今天,我们很高兴正式发布一个新的开源库:Highlights。
Highlights 是一个用 WebAssembly Text(简称 WAT)编写的语法高亮器,内置 70 多种语言,使用兼容 Zed 的主题,可以输出 HTML 或带主题样式的 token。它还支持流式输入和增量编辑,让应用在代码变化时持续更新高亮。
在下面的大文件测试用例中,Highlights 生成 HTML 的吞吐量最高达到 Shiki 的 298 倍。
- Highlights
- 297
- Tree-sitter (N-API)
- 11.3
- Shiki
- 1.22
- Highlights
- 615
- Tree-sitter (N-API)
- —
- Shiki
- 2.8
- Highlights
- 928
- Tree-sitter (N-API)
- 25.6
- Shiki
- 5.89
- Highlights
- 320
- Tree-sitter (N-API)
- 9
- Shiki
- 1.07
Bun 1.4.0 · Apple M4 Pro · 2026 年 10 月 5 日 · — 未测量
这组测试记录来自 2026 年 10 月 5 日,测试环境为 Apple M4 Pro 和 Bun 1.4.0。Highlights 的耗时包含 UTF-8 编码和 HTML 解码;Shiki 以字符串 API 为基准,Tree-sitter 使用
tree-sitter-highlight1.1.2。每个用例先预热 200 ms,再用 1.5 秒采样,至少采集 20 个样本,并轮换各高亮器的测试顺序。每批调用持续约 5 ms,先计算每批的单次调用平均耗时,再取中位数来计算吞吐量。要复现这些结果,请查看基准测试。
背景
语法高亮的开销大得出乎意料。
在 Diffs 中,我们一直使用出色的 Shiki 库,再通过 Worker 把这项工作移出主线程,用缓存避免重复计算。
这种方式一直够用。但随着我们不断投入编辑功能的开发,语法高亮越来越成为一个无法回避的瓶颈。
主流代码高亮器如何工作
高亮器会把代码拆成 token。在 const greeting = "hello"; 中,它把
const 识别为关键字,把
"hello" 识别为字符串,再根据主题分配颜色。它可以返回 token
供渲染器使用,也可以返回带有样式化 span 的 HTML。大部分工作都花在识别 token 上。
正则表达式
正则表达式是性能的死敌。—— @aidenybai
Shiki、Prism、highlight.js 和 Rangi 都用正则表达式匹配 token。正则表达式能简洁地描述关键字、数字或引号内字符串的形式。在 Prism 这样的引擎中,添加一门语言只需要定义这些模式,再交给同一个引擎匹配。这让语言支持更容易实现和扩展。
开销在于匹配的次数。不同规则可能反复扫描同一段代码,各自寻找自己的模式。复杂的表达式还可能回溯:匹配失败时,引擎会重新访问之前的字符,尝试另一条路径。单个正则表达式可以很快,但应用一整套语法规则,就要付出成功匹配、失败尝试和重复扫描的全部成本。面对大文件,或每次编辑后都要重新高亮的代码,这些开销会不断累积。
语法树
Tree-sitter 采用了另一种方式。它把源代码解析成具体语法树,保留标点等细节,再通过查询为树上的节点指定高亮名称,最后由主题将这些名称映射为样式。
借助这种结构,高亮规则可以根据节点在代码中的位置,区分函数名和参数。构建、存储和查询这棵树需要时间和内存,但它也能支持代码导航等功能。编辑发生后,Tree-sitter 可以增量更新语法树,复用未改变的部分。整文件基准测试衡量的工作负载,与用户输入时持续维护语法树并不相同。
在 GPU 上运行的学习型词法分析器
最近,Shu Ding
介绍了 Vercel Labs 的 gpu-lexer。这是一个实验性的高亮器,使用运行在 WebGPU
上的小型学习模型。一个模型就能处理不同语言,无需传入语言参数,也不需要为每门语言单独准备语法规则。
CPU 先把源码拆成单词、空白和符号等简单片段。GPU
处理这些片段及其上下文的特征,再预测它们属于九种显示类别中的哪一种。公开 API
await parse(code) 返回带分类的文本区间,位置用 JavaScript
字符串偏移量表示。项目 README
介绍了这条处理流程,也说明了它需要在浏览器的安全上下文中使用 WebGPU。
这种学习方法有意思的地方在于:即使没有明确配置过某门语言,它也可以尝试为其高亮。效果取决于模型学到了什么,以及这些知识能否迁移到当前输入。模型卡片说明,评估衡量的是输出与 Shiki 标签的一致程度,而非语义正确性。
- Highlights0.2.0 · 带主题样式的 token
- 81.6ms
- gpu-lexerspans
- 400.8ms
- Sugar HighHTML
- 761.6ms
- Prism.jsHTML
- 994.4ms
- Highlight.jsHTML
- 1.35s
- Starry NightHAST
- 10.50s
- Shikitokens
- 28.28s
Chromium 153 · Apple M4 Pro · 2026 年 10 月 21 日
图表展示了 2026 年 10 月 21 日的测试结果,Highlights 使用 npm 发布的 0.2.0 版本。耗时取三轮测试的中位数,每个样本都在新建的 Worker 中预热一次;不计初始化和 DOM 渲染。Highlights 和 Shiki 使用
codeToTokens(),返回带主题样式的 JavaScript token。gpu-lexer 返回带分类的文本区间;Starry Night 返回 HAST 树;Sugar High、Prism.js 和 Highlight.js 返回 HTML。采样记录包含版本和运行条件。这项测试衡量的是这些 API 处理同一个大型 JavaScript 输入时的耗时,不衡量高亮质量。
@pierre/highlights 如何工作
核心流程很简单:
JavaScript 负责公开 API、输入编码和主题准备。WebAssembly 模块扫描源码,输出 HTML 或 token 记录,整个过程不需要构建语法树。架构说明更详细地介绍了这些职责边界。
为每门语言编写词法分析器
词法分析器遍历源文本,识别关键字、数字、注释和字符串等有意义的区间。在 Highlights 中,这些词法分析器用 WAT 手写,并共用处理常见操作的扫描器。
更复杂的语言需要维护更多状态。处理嵌入的语言时,还需要把控制权交给另一个词法分析器。例如,JavaScript
词法分析器可以在 HTML 的 <script> 区域内完成扫描,再把控制权交还给 HTML
词法分析器。受支持的 Markdown 围栏代码块也采用类似的方式。
这样一来,语言支持的方式就很明确:新增一门语言,就要实现并维护它的词法分析器。现有的 TextMate 语法不能直接加载到 Highlights 中,输出的代码分类也不一定与 Shiki 或 Tree-sitter 完全相同。
例如,在 TypeScript 语句 const greeting = 1; 中,Highlights 把
greeting 归类为 variable,而 Shiki 为它分配的 TextMate 作用域是
variable.other.constant.ts。使用各自内置的 GitHub Dark 主题时,这个标识符在
Highlights 中使用前景色,在 Shiki
中则显示为蓝色。如果你需要与现有高亮保持一致,切换前应先选取有代表性的文件进行比较。
一次扫描更多字节
源码中有很多不需要特殊处理的连续片段。在行注释内,词法分析器主要是在找下一个换行符。在引号包围的字符串内,它可以一直向前跳过字符,直到遇到引号、转义符或其他需要特殊处理的字符。
Highlights 在其中几类扫描中使用了 WebAssembly SIMD。SIMD 让一条指令同时操作多个值;在这里,扫描器可以一次加载 16 个字节,并将它们全部与目标字符比较。比较结果生成位掩码,用来确定第一个匹配的位置。
这样,扫描器就能以每次 16 个字节的步幅遍历长注释。短小而常见的情况也很重要:扫描 token 之间的短空白时,会走标量路径,避免在待扫描内容很少时付出 SIMD 的准备开销。
关键字查找也会使用构建时准备好的表。查找时,根据选定的字节和单词长度计算哈希,再精确核对候选项的字节。语言关键字由此成为词法分析器可以直接查询的数据。
边扫描边输出
生成 HTML 时,输出器会把转义后的文本和带样式的 span 直接写入 WebAssembly 内存。JavaScript 提前准备好主题;普通的颜色主题会变成一张紧凑的表,记录每种 token 类型的颜色和字体设置。
样式相同的相邻区间可以共用一个 span,包括它们之间的空白。span 的起始标签会被缓存以便复用。这些细节既减少了生成 HTML 的工作量,也减少了浏览器接收的标记数量。
API 以 UTF-8 字节的形式返回输出:
import { codeToHtml } from '@pierre/highlights';
import { pierreDark } from '@pierre/highlights/themes';
const bytes = codeToHtml('const greeting = "hello";', {
lang: 'js',
theme: pierreDark,
});
const html = new TextDecoder().decode(bytes);
这个包可以在 Node.js、浏览器和 Cloudflare Workers 中运行。它会自动初始化 WebAssembly,因此导入后的高亮调用是同步的。
Highlights 接受兼容 Zed 的主题对象。你可以从 @pierre/highlights/themes 导入
pierreDark 或 githubLight 等内置主题,也可以提供自己的主题。
流式处理与增量编辑
快速高亮完整文件很有用,但实时界面还需要处理正在传入或编辑的文本。
StreamTokenizer
接收文本块或 UTF-8 字节块,为完整的行输出带主题样式的
token。它会在块与块之间保留词法分析器状态,让注释或字符串可以延续到下一个块,无需重新扫描之前的行。末尾不完整的行会暂存在缓冲区中,直到收到换行符,或调用方用
end() 结束流。
import { StreamTokenizer } from '@pierre/highlights';
import { pierreDark } from '@pierre/highlights/themes';
const response = await fetch('https://example.com/source.ts');
const lines = response.body.pipeThrough(
new StreamTokenizer({ lang: 'ts', theme: pierreDark })
);
for await (const tokens of lines) {
console.log(tokens);
}
LiveTokenizer
处理对现有文本的编辑。它在独立的 WebAssembly 实例中保存文档、每行的 token
记录和词法分析器状态。编辑后,它会重新对受影响的行做词法分析,并继续向后处理,直到在一处未改变的边界上,输出状态与之前保存的状态相同。此后的结果便可以复用。
import { LiveTokenizer } from '@pierre/highlights';
import { pierreDark } from '@pierre/highlights/themes';
const live = new LiveTokenizer({
code: 'let count = 1;\nconsole.log(count);',
lang: 'js',
theme: pierreDark,
});
live.applyEdits([{
range: { start: { line: 0, character: 12 }, end: { line: 0, character: 13 } },
newText: '2',
}]);
console.log(live.getLineText(0)); // let count = 2;
console.log(live.getLineTokens(0).tokens);
live.dispose();
有些编辑仍会影响整个文件。在文件开头附近加上一个多行注释的起始标记,可能改变后续每一行的词法分析结果。设置
renderRange
后,实时词法分析器会处理到可见范围的末尾,包括前面为确定词法分析器状态而必须重新处理的行。剩余工作会分成小段在后台完成,并通过
onDeferTokenize 交付
token。这样,编辑器可以先更新可见行,再等待其余部分完成。
为什么选择 WAT
WAT 是 WebAssembly 指令的文本表示形式。手写 WAT 意味着直接处理内存读写、分支和向量操作。用它来写语法高亮器,确实不太常见。
Rust 和 Zig 都能编译到 WebAssembly,也都能控制内存和 SIMD。我们偏好 WAT,主要是因为它更适合我们的开发方式:源码直接呈现了我们想要检查和调优的加载、比较和分支指令。比如,一次扫描 16 字节注释的逻辑,就是直接用向量加载、比较和位掩码检查写出来的。对于一个主要由这类扫描操作组成的程序,直接在指令层面工作很方便。当然,这也意味着更多细节需要我们自己维护。
WAT 预处理
如果每张表、每个常量都要手写,词法分析器会很难维护。我们的预处理器在源码中增加了几种写法,构建时再把它们展开成标准 WAT。
本地导入把共享代码合并到同一个模块中,每个文件只引入一次。枚举为 token 类型分配数字 ID,命名常量则让扫描器可以通过名称引用内存地址,不必反复填写偏移量。
(import "./langs/tsx.wat")
WABT 将展开后的 WAT 汇编成 WebAssembly,再由 Binaryen 优化。这些方便开发的写法都在构建时处理完毕,运行时仍然使用普通的 WebAssembly 指令扫描字节。
控制内存
模块使用 WebAssembly 的线性内存,也就是一块通过字节偏移量寻址、可以扩容的缓冲区。Highlights 为控制数据、表、词法分析器状态、输入和输出分别分配空间。字符串可以直接编码到输入区,输出器则写入按需扩容的输出区。
因此,在生成 HTML 时,可以直接处理 token 对应的文本区间,不必为每个 token 分配一个 JavaScript 对象。实时词法分析器有独立的堆,用来保存文档文本、行记录和状态,并在多次编辑之间保留这些数据。
API 也明确体现了内存的归属和使用规则。codeToHtml()
返回的字节位于可复用的内存中:你应该像上面的示例一样立即解码,或者在下一次高亮或词法分析调用之前,用
.slice() 复制一份。请求带主题样式的 JavaScript token
会创建对象;直接使用原始记录的调用方则可以推迟这一步。
- Highlights
- 37
- Shiki JS
- 80
- Shiki Wasm
- 148
- Highlights
- 37
- Shiki JS
- 78
- Shiki Wasm
- 147
- Highlights
- 38
- Shiki JS
- 91
- Shiki Wasm
- 181
- Highlights
- 41
- Shiki JS
- 91
- Shiki Wasm
- 171
- Highlights
- 50
- Shiki JS
- 155
- Shiki Wasm
- 349
- Highlights
- 55
- Shiki JS
- 112
- Shiki Wasm
- 303
- Highlights
- 104
- Shiki JS
- 739
- Shiki Wasm
- 963
- Highlights
- 133
- Shiki JS
- 326
- Shiki Wasm
- 488
每个用例使用五个新进程 · Bun 1.4.0 · Apple M4 Pro · 2026 年 10 月 5 日
RSS 包含运行时、编译后的代码、Wasm 和内存分配器占用的空间;Highlights 生成 HTML 的过程包含解码。两个库都使用 GitHub Dark。Highlights 包含全部内置词法分析器;Shiki 只加载 TypeScript,并关闭行长度和时间限制。这组测试记录来自 2026 年 10 月 5 日,测试环境为 Apple M4 Pro 和 Bun 1.4.0。基准数据记录了测试方法。
控制体积
扫描器、关键字表、语言分派逻辑和输出器都在同一个模块里。不同词法分析器可以复用通用操作,构建过程也能控制表和常量的表示方式。
举个小例子:一个每个字节都相同的向量。把完整的向量常量编码进去,比加载一个小整数、再将它复制到整个向量中更占空间。构建过程会在优化后改写这类重复字节常量,省下这些字节。
已发布的 Highlights 0.2.0 中,Wasm 模块为 183,180 字节,使用 gzip 级别 9 压缩后为 84,987 字节。这个数字包含全部 80 种内置语言,JavaScript 封装和主题资源另计。
- Sugar High六种主要 Web 语言
- 5.4KB
- Prism.js六种主要 Web 语言
- 8.6KB
- Sugar High全部 29 种语言
- 8.8KB
- Highlight.js六种主要 Web 语言
- 14.9KB
- gpu-lexer一个模型
- 27.4KB
- Highlights 0.2.0全部 80 种语言
- 99.0KB
- Prism.js全部 297 种语言
- 162.1KB
- Starry Night六种主要 Web 语言
- 185.3KB
- Shiki六种主要 Web 语言
- 213.9KB
- Highlight.js全部 193 种语言
- 240.4KB
- Shiki全部 242 种语法
- 991.5KB
- Starry Night全部 710 种语法
- 1.46MB
代码压缩 + Brotli · 运行时 + 语言 · Highlights 测量于 2026 年 10 月 21 日
浏览器包经过代码压缩和 Brotli 压缩,包含运行时和所选语言,不含主题。这里的 KB 为 1,024 字节。Highlights 0.2.0 从 npm 安装,于 2026 年 10 月 21 日测量;其他库的数据取自 9 月 9 日记录的 gpu-lexer 体积对比。体积数据记录了语言覆盖范围、构建设置和产物哈希。
自己掌控这些细节的代价
写 WAT 也意味着,很多通常由高级语言协助管理的细节,现在都由项目自己负责:缓冲区边界、临时工作空间、状态布局,以及每个词法分析器面对不完整输入时的行为。
运行时的差异仍然不可忽视。构建中包含针对 JavaScriptCore 的兼容处理,涉及 SIMD 调用约定和某些整数表达式。这些处理有测试覆盖,但也提醒我们:更接近 WebAssembly,并不意味着可以忽略执行它的引擎。
这种设计适合状态紧凑、反复扫描字节的程序。随着实现越来越复杂,让这些边界始终清晰易懂,也是维护工作的一部分。
接入 @pierre/diffs
我们启动这个项目,就是为了降低 Diffs 中代码高亮的开销。词法分析器快一些固然有用,但真正要检验的是:把它接到文件渲染器、编辑器或 Worker 池后,效果如何。
集成 PR 为
File、FileDiff、Editor、FileStream、服务端渲染和
Worker 增加了可选的 Highlights 后端。默认仍然使用 Shiki 的 JavaScript 引擎。要选择
Highlights,设置 preferredHighlighter 即可:
import { FileDiff } from '@pierre/diffs';
const diff = new FileDiff({
theme: { dark: 'pierre-dark', light: 'pierre-light' },
diffStyle: 'split',
preferredHighlighter: 'highlights',
});
各后端按需加载。如果组件使用 Worker 池,后端由池来选择,因此也要在池的
highlighterOptions 中设置
preferredHighlighter。内置主题名称在不同后端都能使用;自定义 Highlights
主题采用 Zed 格式。自定义 TextMate 语法仍需使用 Shiki,Highlights
会把不支持的语言作为纯文本渲染。
底层通过统一的高亮器接口处理 token、主题、流式输入和实时编辑。各后端把结果交给同一个渲染器,生成每行的 HTML 标记和差异装饰。这也意味着,Diffs 的服务端 HTML 渲染要经过 JavaScript token 和共享渲染器,并不使用前面展示的、直接从 Wasm 输出 HTML 的独立流程。
实际效果
这个 PR 对比了使用 Shiki WASM 的 main 分支和接入 Highlights 的分支。Diffs
默认使用 Shiki JS,但这次没有测量它,因此下面的提升都是相对于 WASM 基准而言。对于一个 529
KB 的 TypeScript 文件,使用虚拟列表渲染前 100 行的耗时从 925.5 ms 降到了 23.9 ms;渲染全部
10,825 行的耗时从 1,270.3 ms 降到了 320.7
ms。整文件渲染的提升幅度小一些并不奇怪:即使高亮更快,浏览器仍然要处理几千行 DOM。
- Shiki WASM
- 925.5ms
- Highlights
- 23.9ms
- Shiki WASM
- 989.9ms
- Highlights
- 70.0ms
- Shiki WASM
- 1,270.3ms
- Highlights
- 320.7ms
- Shiki WASM
- 2,679.2ms
- Highlights
- 724.2ms
- Shiki WASM
- 2,291.5ms
- Highlights
- 382.9ms
- Shiki WASM
- 978.0ms
- Highlights
- 15.4ms
- Shiki WASM
- 2,012.6ms
- Highlights
- 45.6ms
- Shiki WASM
- 452.3ms
- Highlights
- 17.9ms
- Shiki WASM
- 7,811.8ms
- Highlights
- 181.5ms
- Shiki WASM
- 2,114.4ms
- Highlights
- 97.2ms
529 KB TypeScript · 10,825 行 · 每组柱条使用独立的线性比例尺
数据为 PR #1141 报告的中位数。测试使用 Apple M4 Pro,SSR 和 tokenizer 流式处理使用 Bun 1.4.0,浏览器渲染使用 Chrome 151。输入是一个 529 KB、共 10,825 行的 TypeScript 文件,使用单个深色主题和完全展开的并排差异视图。虚拟列表设置和 Worker 数量按图中标注变化。这些结果比较的是两个分支的整体表现,包含接入 Highlights 时的改动。基准数据保留了报告中的耗时和加速比;每组柱条使用独立的线性比例尺。
其他场景也有提升:整文件 SSR 从 978.0 ms 降到了 15.4 ms,以 4 KiB 分块进行的 tokenizer 流式处理从 452.3 ms 降到了 17.9 ms。Worker 仍然能帮助分摊工作,而 Highlights 减少了每个 Worker 处理这些工作的开销。
Highlights 仍处于实验阶段,词法分析结果可能与 Shiki 不同。你可以在在线示例中试用这次集成。
写在最后
Highlights 让语法高亮的处理开销小到可以用在代码持续传入、不断变化的场景中。词法分析器直接扫描字节,输出器生成紧凑的结果,流式和实时 API 则保留可以复用的工作。
这也有取舍。手写词法分析器需要持续维护语言支持。TextMate 语法拥有更广泛的生态,而需要语法树的应用也能从这种结构中受益。高亮更快之后,浏览器仍然要处理布局、绘制和 DOM 的开销。
对于需要高亮 HTML 或 token 的应用,Highlights
提供了一个小巧的引擎,让你明确控制何时处理、处理多少。运行
pnpm add @pierre/highlights 即可安装,也可以阅读文档或查看实现代码。