作者:@marvinh.dev
📖 核心提要:随着语言服务器协议(LSP)的兴起,基于查询的编译器成为一种全新的架构模式。这种架构与 UI 渲染中的信号机制的相似性和差异性,都远超我最初的设想。
寒假期间,我出于好奇,花了大量时间研究现代编译器在 LSP 时代和深度编辑器集成的背景下,是如何实现交互性的。结果发现,现代编译器的设计核心与 UI 渲染中的信号机制一脉相承,只是在部分设计选择上存在有趣的差异。
传统架构:流水线式编译
编译器相关的经典教材中,都将其描述为线性的阶段序列,代码会依次经过这些阶段,最终生成目标二进制文件。如果所处理的编程语言足够简单,这种流水线式的编译器实现起来会非常直接(而 JavaScript 的语法极其复杂,与 “简单” 毫无关系)。
源代码文本 -> 抽象语法树(AST)-> 中间表示(IR)-> 汇编代码 -> 链接器 -> 二进制文件
首先,源代码会被转换为抽象语法树(AST),这一步会将纯文本的代码转换为结构化的对象 / 结构体,语法错误、语法规则错误等问题都会在这一阶段被检测出来。
举个例子:下面这段 JavaScript 代码……
const a = 42;
…… 会被转换为类似这样的抽象语法树:
{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "NumericLiteral",
"value": 42
}
}
]
}
抽象语法树会继续经过后续的多个处理阶段,最终生成二进制文件。
这些阶段的具体细节并非本文的重点,而且我们在此的描述也做了大量简化。但需要明确的是,编译器通常会将代码经过多轮不同的处理,才能让其最终可执行。整个过程耗时不菲,根本无法在用户的每次按键操作后都完整执行一遍。
现代架构:基于查询的编译器
当开发者在编辑器中修改单个文件的一个字符时,背后其实需要执行大量操作。理想情况下,我们希望尽可能减少不必要的计算。当然,你可以在每个编译阶段都添加缓存,并设计一套高效的缓存失效策略,但这种方式的维护成本会迅速攀升,显然不是最优解。
现代编译器的核心设计转变在于:不再将编译器视为单纯的转换流水线,而是一个可执行查询的工具。当用户在编辑器中输入内容时,语言服务器协议(LSP)会向编译器发起查询:在该文件的当前光标位置,有哪些代码提示?当你对一个标识符点击 “跳转到定义” 时,其实是在让编译器返回该标识符的跳转目标(如果存在)。
本质上,这些需求都是向编译器发起的查询请求,而编译器只需专注于尽快回答这些问题,其余无关的工作则完全忽略。
正是这种设计思路的转变,让现代编译器具备了极强的交互性。但这种架构的底层实现原理是什么?它又与信号机制有何关联?
查询、输入与 “数据库”
基于查询的编译器有三个核心组成部分:查询(Queries) 、输入(Inputs) 和 “数据库”(Database)。其核心设计理念是:所有功能、所有逻辑,全部由查询和输入构成。如果没有执行查询,整个编译器不会有任何默认的计算行为。
编译器顶层会有一个核心查询:“生成目标二进制文件”,这个查询会触发多个子查询,比如 “生成中间表示(IR)”、“生成抽象语法树(AST)” 等。整个编译过程,就是一层套一层的查询调用。
除此之外,编译器还会处理各类辅助查询,例如:“文件 X 中光标位置 Y 处的标识符类型是什么?”。这个查询会先触发另一个子查询,将当前文件解析为抽象语法树;接着再通过查询获取光标位置的标识符;然后再发起查询,将该标识符解析到其定义位置;如果该定义位于另一个文件中,就会继续发起查询,解析对应的文件,以此类推。
这种架构的精妙之处在于:编译器不会花费时间处理与当前查询完全无关的文件,只会处理响应当前查询所必需的内容。如果某个源文件与正在执行的查询毫无关联,它永远不会被编译器处理。
无处不在的缓存
为了进一步提升执行效率,查询操作可以被轻松缓存 —— 因为查询被设计为纯函数,不会产生任何副作用。这意味着,只要输入参数不变,重复执行同一个查询,总能得到完全相同的结果。这一特性让查询操作天生适合做缓存处理。
查询结果会被自动缓存,当缓存占用的内存过高时,直接清空即可。当下一次执行该查询时,编译器发现缓存中无对应结果,会重新执行查询逻辑并再次缓存结果。这个过程可能会让单次查询的执行速度稍慢,但绝不会返回错误的结果。
要保证缓存的正确性,有一个关键细节:缓存的哈希键必须包含传入查询的参数。这意味着,当同一个查询(如查询 A)在不同地方被调用,且传入的参数不同时,每个参数都会生成该查询的一个新实例,并拥有独立的缓存返回值。
查询的结构
一个查询通常被定义为包含两个参数的函数:
- 数据库(Database)
- 参数(Argument,有时也被易混淆地称为 “输入”)
用 TypeScript 可以表示为:
type Query<T, R> = (db: Database, arg: T) => R;
db参数是所有查询的载体,调用其他查询时,需通过db.call_other_query(someArg)的方式。为了让语法更简洁,减少对数据库对象的直接依赖,大多数实现都会通过宏或装饰器做一层语法糖封装:
class MyDatabase extends Database {
@query
getTypeAtCursor(file: string, offset: number): Type {
const id = this.getIdentifierAtCursor(file, offset);
const type = this.getTypeFromId(id);
return type;
}
}
输入:唯一的事实来源
当磁盘上的文件发生修改时,需要告知编译器让该文件的缓存失效,这样当下一次有查询请求该文件时,编译器会重新处理它。这一功能正是通过输入(Inputs) 实现的。输入是一个可写入的有状态对象,通常也挂载在数据库对象上。
watch(directory, ev => {
if (ev.type === "change") {
const content = readTextFile(ev.path);
db.updateFile(name, content);
}
});
在查询中读取输入,通常需要调用输入对象的方法或访问其专属属性:
class MyDatabase extends Database {
files = new Map<string, FileInput>()
// 写入输入的辅助方法
updateFile(name: string, content: string) {
const input = this.files.get(name) ?? new Input<string>()
input.write(content)
this.files.set(name, input)
}
@query
parseFile(file: string): AST | null {
const fileInput = this.files.get(file)
if (fileInput === null) return null;
// 读取输入内容
const code = fileInput.read()
return parse(code)
}
}
执行触发的差异:主动变更 vs 按需查询
这也是基于查询的编译器与信号机制最核心的区别:向输入对象写入内容后,编译器不会有任何主动的行为,相关的查询不会被自动重新执行,也不会触发任何连锁反应。
这与 UI 编程中的信号机制形成鲜明对比:信号机制通常是一种 “实时” 的订阅模式 。当源信号发生变更时,会被标记为 “脏状态”,随后编译器会遍历所有活跃的订阅关系,将所有派生 / 计算信号也标记为脏状态,直到追溯到订阅的触发点(这个触发环节通常被称为副作用(Effect) )。
简单来说,信号机制中,变更会被主动推送到整个系统,当变更传递到副作用环节时,副作用会重新执行,并拉取最新的信号值。当然,各类信号库会采用不同的优化策略,这并非本文的讨论范围。需要记住的核心点是:信号机制在处理写入操作时,本质上是一种 “推 - 拉” 结合的架构。
这种 “推 - 拉” 架构非常适合 UI 渲染场景:界面的变更需要实时展示,且必须保证整个页面的状态同步。页面上渲染的所有信号,必须始终展示同一版本的数值。绝对不能出现页面上半部分显示新值、下半部分仍渲染旧值的情况 —— 这种现象通常被称为 “闪烁(glitch)”。
将变更主动推送到整个系统,是避免页面闪烁的优雅方案。这种架构本质上是以更多的内存占用为代价,换取更快的执行速度,以及能保证无闪烁的渲染结果。
而基于查询的编译器则采用完全不同的模式:按需驱动。只有主动发起查询,编译器才会重新执行相关逻辑。它并不要求所有操作都在同一个事件循环周期内完成:代码自动提示的查询结果先返回,而类型错误的查询结果晚几毫秒返回,这在编译器场景中是完全可以接受的。
编译器无需像 UI 那样,保证每一次帧渲染的状态同步,当然正确性依然是硬性要求,只是对执行时机的要求相对宽松。
如果像信号机制那样,将所有变更主动推送到整个系统,对于编译器来说成本过高 —— 根据项目规模的不同,基于查询的编译器中很容易出现超过 10 万个节点。在这种规模下,内存占用会成为影响性能的关键问题。
为了降低内存消耗,与信号机制双向追踪依赖关系不同,基于查询的系统仅做单向的依赖追踪。
核心实现:版本号机制
即便如此,和信号机制一样,正确性也是基于查询的系统的硬性要求。那么,当不同的查询在不同时间完成执行时,如何保证结果始终正确?
核心设计思路在于:查询本质上是基于输入的纯函数—— 给定相同的输入,必然得到相同的结果,因此只要输入确定,结果就一定是正确的。
在基于查询的编译器内部,存在一个全局版本号计数器,每当有输入发生变更,该计数器就会自增。系统中的每个节点都包含两个字段:changed_at和verified_at,用于校验缓存值的有效性。
interface Node<T> {
changed_at: Revision; // 节点最后一次变更的版本号
verified_at: Revision; // 节点最后一次验证的版本号
value: T; // 节点的缓存值
dependencies: Node<any>[]; // 节点的依赖项
}
通过这两个字段,就能判断一个节点的缓存结果是否可以复用。需要注意的是,由于系统仅做单向的依赖追踪,除非节点的verified_at与当前全局版本号一致(可提前终止校验) ,否则需要从该查询节点开始,逐层校验所有依赖项,直到叶子节点。
校验的逻辑如下:
- 当追溯到叶子节点时,若发现尽管全局版本号已自增,但该节点并未发生任何变更,则将所有父节点的
verified_at更新为当前全局版本号; - 若某个输入或查询结果发生了变更,则同时更新该节点的
changed_at和verified_at为当前全局版本号; - 若某个查询的依赖项发生了变更,但该查询的执行结果未变,则同样提前终止校验,仅在栈回溯时更新该节点的
verified_at字段。
一大杀手锏:多线程支持
这是基于查询的系统的一个核心优势。根据目标编译语言的特性,编译器可以将文件解析等任务大规模并行化处理。只要保证同一个查询同一时间仅由一个线程执行,就能实现大量任务的并行计算。
由于查询的粒度通常非常精细,编译器甚至可以随时终止某个线程,再基于最新的版本号重新创建线程并执行查询。这一点我自己也需要做更深入的研究,但这种系统支持 “中断并重新执行任务” 的特性,本身就非常值得探究。
孰优孰劣?
答案是:视使用场景而定。
信号机制更适合 UI 渲染场景,而基于查询的架构则更适配编译器的需求。二者没有绝对的优劣,只有是否适合具体的使用场景。
但让我觉得有趣的是,为了实现增量式计算,这两种看似完全不同的系统,在底层却演化出了相似的组成模块和设计理念。
我不禁思考:如果 JavaScript 的各类工具从底层开始就被设计为增量式的,会是什么样子?如果 Vite 这样的构建工具基于查询的架构实现,又会有怎样的表现?
从设计理念来看,开发服务器其实与基于查询的系统有相似之处:除了热更新(HMR)是由服务器主动推送之外,开发过程中我们一直在不断地向开发服务器发起数据查询。或许,将信号机制与基于查询的架构相结合,才是这类工具的最优解?