PIERRE COMPUTER COMPANY

← 返回首页

关于代码高亮

Pierre Computer — 代码高亮 A black-and-white printed PIERRE COMPUTER poster with Chinese text meaning code highlighting. Click to reveal flying chrome lettering, flames, and a colorful animated backdrop.

(点击点亮海报 — 光敏性癫痫警告)

今天,我们很高兴正式发布一个新的开源库:Highlights。

Highlights 是一个用 WebAssembly Text(简称 WAT)编写的语法高亮器,内置 70 多种语言,使用兼容 Zed 的主题,可以输出 HTML 或带主题样式的 token。它还支持流式输入和增量编辑,让应用在代码变化时持续更新高亮。

在下面的大文件测试用例中,Highlights 生成 HTML 的吞吐量最高达到 Shiki 的 298 倍。

HTML 吞吐量MiB/s · 越高越好 →
CSS · 379 KiB244× 相对于 Shiki
Highlights
297
Tree-sitter (N-API)
11.3
Shiki
1.22
HTML · 474 KiB220× 相对于 Shiki
Highlights
615
Tree-sitter (N-API)
—
Shiki
2.8
JSONC · 292 KiB157× 相对于 Shiki
Highlights
928
Tree-sitter (N-API)
25.6
Shiki
5.89
TypeScript · 517 KiB298× 相对于 Shiki
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-highlight 1.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 标签的一致程度,而非语义正确性。

高亮 10 份 three.min.js耗时中位数 · 越低越好 ←
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 会创建对象;直接使用原始记录的调用方则可以推迟这一步。

进程峰值内存峰值 RSS 中位数 · MiB · 越低越好 ←
2 KiB → HTMLTypeScript
Highlights
37
Shiki JS
80
Shiki Wasm
148
2 KiB → tokensTypeScript
Highlights
37
Shiki JS
78
Shiki Wasm
147
31 KiB → HTMLTypeScript
Highlights
38
Shiki JS
91
Shiki Wasm
181
31 KiB → tokensTypeScript
Highlights
41
Shiki JS
91
Shiki Wasm
171
517 KiB → HTMLTypeScript
Highlights
50
Shiki JS
155
Shiki Wasm
349
517 KiB → tokensTypeScript
Highlights
55
Shiki JS
112
Shiki Wasm
303
5 MiB → HTMLTypeScript
Highlights
104
Shiki JS
739
Shiki Wasm
963
5 MiB → tokensTypeScript
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 封装和主题资源另计。

加载的库体积KB · 越低越好 ←
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。

Diffs 集成 · 分支对比耗时中位数 · 越低越好 ←
DOM:前 100 行(虚拟列表)38.7× 报告的加速比
Shiki WASM
925.5ms
Highlights
23.9ms
DOM:100 行 + Worker 池(虚拟列表)14.1× 报告的加速比
Shiki WASM
989.9ms
Highlights
70.0ms
DOM:完整文件,10,825 行4× 报告的加速比
Shiki WASM
1,270.3ms
Highlights
320.7ms
DOM:并排差异视图3.7× 报告的加速比
Shiki WASM
2,679.2ms
Highlights
724.2ms
DOM:统一差异视图6× 报告的加速比
Shiki WASM
2,291.5ms
Highlights
382.9ms
SSR:完整文件63.4× 报告的加速比
Shiki WASM
978.0ms
Highlights
15.4ms
SSR:完整差异44.1× 报告的加速比
Shiki WASM
2,012.6ms
Highlights
45.6ms
流式处理:4 KiB 分块25.3× 报告的加速比
Shiki WASM
452.3ms
Highlights
17.9ms
Worker 池:8 个文件,1 个 Worker43× 报告的加速比
Shiki WASM
7,811.8ms
Highlights
181.5ms
Worker 池:8 个文件,4 个 Worker21.8× 报告的加速比
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 即可安装,也可以阅读文档或查看实现代码。