原文:Andy Jiang、Nathan Whitaker,2024 年 6 月 20 日,Deno Blog。阅读英文原文。本文为中文翻译整理,保留原文的调查过程和技术结论;编辑补注单独标明。
Deno 希望让编程更简单,因此把常用工具、TypeScript 支持与 Web 标准 API 放进同一个运行时。语言服务器也是其中的重要一环:自动补全、悬停文档、静态检查和格式化,都需要它在编辑器背后及时响应。
一位客户报告,大型代码库中的 Deno 语言服务器已经慢到影响日常开发。团队围绕这个真实场景开展调查,最终把自动补全时间从约 8 秒缩短到不到 1 秒。改进随 Deno 1.43 发布。这个案例最有价值的地方,是团队如何找到主要开销,以及如何减少 Rust 和 TypeScript 编译器之间的状态同步成本。

先把客户的问题变成可重现的工作负载
客户提供了仓库规模、使用的机器以及其他复现线索。Deno 团队很快建立专门的沟通频道,汇总信息、讨论调查方向。这个项目包含超过 7.5 万行 TypeScript 业务代码,依赖中的 TypeScript 代码又超过 75 万行。小项目上的流畅体验无法说明它在这种规模下也能正常工作。
Deno 本来就在持续集成中运行性能基准,每个拉取请求都会接受关键指标检查。但语言服务器高度交互:打开文件、切换位置、输入字符、请求补全,不同操作顺序都会影响缓存和响应。传统的一次性函数基准很难覆盖所有真实使用方式。
团队没有试图一次模拟所有用户,而是为这个已知的问题编写了针对大型项目的基准:模拟一系列语言服务器 API 请求,重现用户在大型代码库中切换、浏览文件的过程。这样,每次改动是否改善客户的具体场景,就有了直接的判断依据。与此同时,团队补充了性能埋点,以观察一段时间内的资源消耗。
语言服务器的请求经过哪些层
语言服务器协议(LSP)为编辑器和语言服务器定义了统一接口。编辑器不必独立实现每门语言的语义分析,就可以请求补全、跳转定义、悬停文档等能力。
Deno 的语言服务器由 Rust 编写,但 TypeScript 分析依靠 TypeScript 编译器 tsc。因此,一个编辑器补全请求会先到达 Deno 扩展,再进入 Rust 服务;Rust 与运行在 JavaScript 一侧的 tsc 交换信息,最后把结果返回编辑器。性能问题既可能出在编译器计算本身,也可能出在这些层之间的数据准备和传递上。
同时看 Rust 和 JavaScript 两侧的火焰图
团队分别记录 Rust 与 JavaScript 的性能图。Rust 侧的结果交给 Jaeger 查看,JavaScript 侧的结果可在 Chrome 开发者工具中打开。原文将不同颜色用于区分 Rust、JavaScript 和 TypeScript 编译器活动。
图上最醒目的是一大片 TypeScript 编译器区段:它在向 Rust 服务询问文件内容、同步项目状态。紧随其后的真正业务工作——例如计算补全候选项——反而只占很小的一段。这个差异改变了优化方向:首先要缩短同步过程,而不是先去优化补全算法。
编辑补注:原文把火焰图横轴解释为执行时间。更精确地说,常规聚合火焰图的横向宽度表示该调用栈累积的采样占比,横向位置通常不表示时间先后;时间线式 flame chart 才保留时间顺序。这里应读取“状态同步占用很宽”这一证据,不能仅凭图形位置推断所有调用的先后关系。
第一步:少问那些不会改变的文件
为了给出准确建议,tsc 必须维护一份与用户当前代码一致的项目模型。在原先的实现中,每当 Rust 请求补全等结果时,编译器都会向 Rust 询问代码库里所有文件的最新状态,其中包括直接依赖和间接依赖。
规模一大,这意味着重复查询大量文件,甚至搬运大段文本。Rust 与 JavaScript 之间传递字符串并不便宜,把这种固定开销乘以整个项目的文件数,状态同步就可能比编译器的实际分析更慢。
团队首先缩小需要询问的范围:只检查本地文件。通过 npm:、jsr: 或远程 URL 导入的依赖已经缓存在本地,通常不会随着每次键盘输入而改变,没有必要用与正在编辑的源码相同的频率重新确认。
这里的前提是依赖缓存和项目状态管理能够正确反映依赖变更。它不是建议所有程序都永久忽略依赖变化,而是利用 Deno 当时的模块来源和缓存机制,避免无意义的重复工作。
第二步:在 JavaScript 一侧回答重复问题
减少文件数量之后,团队继续降低每次查询的成本。此前,编译器为了获得文件版本、源代码等信息,会频繁从 JavaScript 调回 Rust。即便“最新版本是多少”本身很容易回答,跨语言调用和数据转换的成本仍然存在。
解决办法是在 Rust 与 tsc 之间加入缓存层,把项目状态保留在 JavaScript 一侧。编译器再次询问同一个文件时,尽可能直接使用缓存值,省掉跨语言调用和文本复制;很多情况下,只需要传递已经在内存中的对象引用。
缓存的难点在于正确失效。如果文件已变,缓存还返回旧文本,补全、诊断和类型信息就可能出错。因此团队让 Rust 服务把文件变更通知 JavaScript 接口,只清除那些可能受到变更影响的缓存项。未变化的文件继续复用,发生变化的文件才重新读取。
原文把相关改动归纳为三个方向:减少编译器需要询问的文件数;加快模块解析等由编译器请求的操作;更聪明地缓存未改变文件,降低项目同步成本。它们共同减少了请求路径上的冗余工作。
结果与适用边界
在 Deno 1.43 的性能图中,先前占据大量空间的状态同步区段显著变窄。客户所遇到的补全延迟从约 8 秒降到不到 1 秒。团队在一个发布周期内完成了反馈收集、场景复现、观测和优化。
这个结果不意味着所有 Deno 项目都能获得相同倍数的提升。原文讲述的是 2024 年的大型项目问题,版本号 1.43 是历史背景,不是当前安装建议。项目规模、依赖来源、编辑器请求序列以及运行硬件都会影响实际收益。
如果把这个方法用到自己的工具上,可以依次回答三个问题:能否重现用户实际操作;时间花在计算还是数据同步;哪些数据只有变化后才值得重新处理。缓存最终必须接受正确性检验,而不只是让图表变窄。
审核说明:本文未提供需执行的优化代码,也未运行 Deno、性能基准或采集火焰图。所有性能数字和成功结论均为原作者报告。静态阅读没有构成安全测试或性能复现。
原文图示与结果








图注补充:客户的早期反馈提到语言服务器占用约 5 GB 内存、数秒后崩溃,重启数次后停止工作;这是原文保存的历史报告。客户后来的反馈认为体验明显改善,但同时说明自己刚升级了机器、并非完全确定,因此这条反馈有硬件变化这一混杂因素,不能视作控制变量实验。上面的优化前后性能图横轴范围不同(约 30 秒与约 14 秒),不能直接按显示宽度计算速度比率。原文的相关 Chrome 图是保留时间顺序的 flame chart;红框表明当时项目状态同步占用了显著时间。
原文结语强调,具体客户反馈帮助团队在一个发布周期内完成改进,并邀请使用者经 GitHub 或 Discord 提交问题。












暂无评论内容