本节目标:分清 TS 生态里三种「插件」的边界,掌握语言服务插件的模块导出约定与
Proxy包装手法,学会读取Program与TypeChecker做语义分析并注入自定义诊断,同时知道哪些做法会让整台 TS Server 崩掉或变卡。读完本节,你写出的插件将能在编辑器里真实生效。
6.2 自定义 tsPlugin
上一节我们把 TS Server 当成一个黑盒服务来驱动。这一节反过来:TS Server 在启动时会读 tsconfig.json 里的 plugins 字段,把列出的模块加载进来,并把语言服务对象交给它们包装。也就是说,我们可以往编辑器内部塞自己的逻辑——这正是 VS Code 里那些「内联提示」「字段自动补全」「框架语法检查」的实现方式。
先说清楚一件事:tsPlugin 不是一个官方术语。它至少指三种不同的东西,混着看会浪费很多时间。
6.2.1 「插件」这个词的三种含义
| 类型 | 挂载点 | 配置位置 | 能否影响类型检查结果 | 典型用途 |
|---|---|---|---|---|
| 语言服务插件(tsserver plugin) | tsserver 进程内的 LanguageService | tsconfig.json 的 plugins | 只在编辑器内影响提示,不影响 tsc 产出 | 悬停增强、额外诊断、模板字符串补全 |
| 编译期 transformer 插件 | tsc 的 emit 阶段 | ts-patch / 自定义 tsc 包装 | 直接改变产出的 JS 与 .d.ts | 自动注入元数据、宏展开 |
| 编辑器扩展 | VS Code / Vim 扩展宿主 | 编辑器自身的市场 | 不直接改语言服务 | 命令、面板、主题 |
本节讲第一种。第二种我们在 自定义 transformer 已经拆解过,它属于编译期,和本节的服务层插件是两套机制。第三种属于编辑器工程,不在本书范围。
为什么这个区分如此重要? 因为语言服务插件有一个反直觉的性质:它只在编辑器里生效,CI 里跑的 tsc 完全不知道它的存在。 所以插件适合做「开发体验增强」,绝不适合做「必须拦住的规则」——后者要落到 ESLint 或编译期 transformer。
6.2.2 在 tsconfig 里声明插件
语言服务插件通过 compilerOptions.plugins 声明:
{
"compilerOptions": {
"plugins": [
{ "name": "ts-plugin-my-rules", "level": "warn" },
{ "name": "./tools/local-plugin" }
]
}
}
三条容易踩的规则:
tsc会静默忽略plugins。它不报错,也不加载。所以「我配了插件为什么命令行没用」不是 bug。name按 Node 的解析规则解析,基准目录是 tsconfig 所在目录。ts-plugin-my-rules会去找node_modules/ts-plugin-my-rules;./tools/local-plugin是相对路径,本地开发时最省事。plugins里除name外的字段会原样传给插件的init,这就是插件的配置入口(上面的level)。不要自己发明顶层字段,compilerOptions的未知字段会报 TS5023。
改完 plugins 必须重启 TS Server 才生效。VS Code 里是命令面板的「TypeScript: Restart TS Server」。
6.2.3 插件的模块导出约定
一个语言服务插件是一个 CommonJS 模块,默认导出一个 init 函数,init 返回 { create }:
import type * as ts from "typescript/lib/tsserverlibrary";
function init(modules: { typescript: typeof ts }) {
const ts = modules.typescript;
function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
info.project.projectService.logger.info("[my-rules] 插件已加载");
// 以旧语言服务为原型建一个代理对象
const proxy = Object.create(null) as ts.LanguageService;
const oldLS = info.languageService;
for (const key of Object.keys(oldLS) as (keyof ts.LanguageService)[]) {
(proxy as Record<string, unknown>)[key] = (...args: unknown[]) =>
(oldLS[key] as (...a: unknown[]) => unknown).apply(oldLS, args);
}
return proxy;
}
return { create };
}
export = init;
四个关键点:
modules.typescript才是编译器实例。绝不要import ts from "typescript"——那会加载第二份编译器,两份实例的类型对象互不相等,instanceof、isXxxNode判断全都会莫名失败。- 类型导入必须用
import type,因为tsserverlibrary只提供类型,运行时不加载它。 info是插件的上下文:info.languageService是原服务,info.project是当前项目(可读getCompilerOptions()),info.config是 tsconfig 里那份配置对象。- 返回的代理必须覆盖全部方法。只覆盖你关心的那一个、其余不转发的话,编辑器会看到「跳转定义失灵」这种大面积退化。
配置项从 info.config 读,它就是 tsconfig 里 plugins 数组中该项的对象:
interface MyPluginConfig {
level?: "warn" | "error";
}
function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
const config = (info.config ?? {}) as MyPluginConfig;
const category =
config.level === "error"
? ts.DiagnosticCategory.Error
: ts.DiagnosticCategory.Warning;
// ……后续诊断都用 category
return proxy;
}
info.config 的类型是 any,必须自己断言成接口——这是插件配置唯一的类型入口,别指望编译器帮你检查 tsconfig 里写错的字段。
6.2.4 覆盖一个方法:给悬停加内容
getQuickInfoAtPosition 返回悬停提示。我们保留原结果,只往 documentation 里追加一段:
proxy.getQuickInfoAtPosition = (fileName: string, position: number) => {
const prior = oldLS.getQuickInfoAtPosition(fileName, position);
if (!prior) return prior;
const parts = prior.documentation ? [...prior.documentation] : [];
parts.push({ text: "\n\n—— 由 my-rules 补充", kind: ts.ScriptElementKind.text });
return { ...prior, documentation: parts };
};
documentation 的类型是 SymbolDisplayPart[],每项形如 { text, kind },编辑器按 kind 决定是否套用代码样式。ts.ScriptElementKind.text 表示纯文本。
注意返回的是新对象而不是就地改 prior。语言服务内部可能缓存了这份结果,就地修改会让缓存被污染,表现为「第一次悬停对、之后全错」这种极难复现的 bug。
6.2.5 拿到 Program 与 TypeChecker
真正的威力在语义层。oldLS.getProgram() 返回当前 Program,从中可以取到 TypeChecker:
const program = oldLS.getProgram();
if (!program) return; // 项目还没加载完,Program 可能为 undefined
const checker = program.getTypeChecker();
const source = program.getSourceFile(fileName);
if (!source) return;
// 找到所有「声明类型为 any」的参数
ts.forEachChild(source, function visit(node: ts.Node) {
if (ts.isParameter(node) && node.type) {
const type = checker.getTypeAtLocation(node);
if (type.flags & ts.TypeFlags.Any) {
const name = node.name.getText(source);
info.project.projectService.logger.info(`[my-rules] any 参数: ${name}`);
}
}
ts.forEachChild(node, visit);
});
getTypeAtLocation 接受任意节点,返回其推导出的类型,这正是我们在 类型推导算法与上下文类型
里讨论的那套算法的运行时入口。用 checker.typeToString(type) 可以把类型还原成源码里的写法,做提示或诊断文本时很方便。
一个硬性前提:必须判空。编辑器打开文件后、项目加载完成前,getProgram() 可能返回 undefined。不判空就会抛异常,而插件的异常会直接冒泡到服务层,日志里出现 Exception on executing command。
6.2.6 注入自定义诊断
把上面的分析结果变成波浪线,要覆盖 getSemanticDiagnostics:
proxy.getSemanticDiagnostics = (fileName: string) => {
const prior = oldLS.getSemanticDiagnostics(fileName);
const program = oldLS.getProgram();
const source = program?.getSourceFile(fileName);
if (!program || !source) return prior;
const checker = program.getTypeChecker();
const extra: ts.Diagnostic[] = [];
ts.forEachChild(source, function visit(node: ts.Node) {
if (ts.isParameter(node) && node.type) {
if (checker.getTypeAtLocation(node).flags & ts.TypeFlags.Any) {
extra.push({
file: source,
start: node.getStart(source),
length: node.getWidth(source),
category: ts.DiagnosticCategory.Warning,
code: 90001,
source: "my-rules",
messageText: "参数类型是 any,请显式标注具体类型",
});
}
}
ts.forEachChild(node, visit);
});
return [...prior, ...extra];
};
三个细节决定成败:
code要选一个不撞车的号段。TypeScript 自身占用 1xxxx 段,社区惯例是用 9xxxx 或带命名空间的号段,并配上source字段,编辑器会把source显示在诊断右侧。start/length是相对source的偏移,不是行列。用node.getStart(source)与node.getWidth(source)取,不要手算。category决定严重级别。Error会让文件「编译失败」的观感出现,插件诊断通常用Warning或Suggestion更合适。
6.2.7 性能:缓存是必需品,不是优化
getSemanticDiagnostics 在你每次打字后都会被调用(服务器有防抖,但频率仍然很高)。对同一份没变的源码反复遍历 AST 是纯浪费。缓存键必须带上文件版本,否则改了代码还在用旧结果:
const cache = new WeakMap<ts.SourceFile, { version: string; diags: ts.Diagnostic[] }>();
function computeDiags(
source: ts.SourceFile,
checker: ts.TypeChecker,
build: () => ts.Diagnostic[],
): ts.Diagnostic[] {
const version = (source as unknown as { version?: string }).version ?? "";
const hit = cache.get(source);
if (hit && hit.version === version) return hit.diags;
const diags = build();
cache.set(source, { version, diags });
return diags;
}
用 WeakMap 而不是普通 Map:源码文件被移出项目后缓存会自动被 GC 回收,不会随编辑时长无限增长。这是插件写久了必然撞上的内存泄漏点。
6.2.8 同一条规则,两套实现
前面反复强调插件在 CI 里不生效,所以团队规则要按「载体」拆开落地:
| 场景 | 载体 | 能否自动修复 | 是否进 CI |
|---|---|---|---|
| 编辑时即时提示 | 语言服务插件 | 否,只提示 | 否 |
| 提交前拦截 | ESLint 规则 | 是(--fix) | 是 |
| 编译期改写 | transformer | 是,直接改产物 | 是 |
ESLint 侧可以复用同一套语义判断:
import { ESLintUtils } from "@typescript-eslint/utils";
const createRule = ESLintUtils.RuleCreator(() => "https://example.com/my-rules");
export const noAnyParam = createRule({
name: "no-any-param",
meta: {
type: "suggestion",
docs: { description: "禁止参数类型为 any" },
messages: { noAny: "参数类型是 any,请显式标注具体类型" },
schema: [],
},
defaultOptions: [],
create(context) {
const services = ESLintUtils.getParserServices(context);
const checker = services.program.getTypeChecker();
return {
Parameter(node) {
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
if (checker.getTypeAtLocation(tsNode).flags & 1 /* TypeFlags.Any */) {
context.report({ node, messageId: "noAny" });
}
},
};
},
});
这里 flags & 1 用了字面量只是为了让片段自包含;工程里请从统一的编译器实例取 ts.TypeFlags.Any,硬编码数字迟早出错。可以看到,两套实现的判断逻辑完全一致,只是宿主不同——把判断抽成一个纯函数(输入 Node 与 TypeChecker,输出诊断数组),插件与 ESLint 规则都能复用,这才是长期可维护的写法。
6.2.9 调试与常见坑
| 现象 | 根因 | 处理 |
|---|---|---|
| 插件完全不生效,日志无输出 | name 解析失败或没重启 TS Server | 先用相对路径 ./tools/x 验证,再重启 |
Debug Failure. False expression: Expected file to be in project. | 传了不在项目里的文件名 | 用 program.getSourceFile() 判空后再用 |
| 跳转定义、补全大面积失灵 | 代理没有转发全部方法 | 用循环转发,只覆盖需要改的方法 |
| 编辑器卡顿,CPU 飙高 | 在 getSemanticDiagnostics 里遍历全项目 | 只处理传入的 fileName,重活加缓存 |
Cannot find module 'typescript/lib/tsserverlibrary' | 用了 import ts from 而非 import type | 改成 import type * as ts |
| 类型判断莫名失败 | 加载了第二份编译器实例 | 统一从 modules.typescript 取 |
还有两条工程纪律:
- 每个覆盖方法都包
try/catch,出错时return原结果。插件抛异常不是「只坏这一个功能」,而是污染整条请求链路,用户看到的是「TS 服务挂了」。 - 不要在插件里做 I/O。读文件、发网络请求会阻塞语言服务的单线程循环;确实需要就缓存到内存、异步刷新。
最后强调一次边界:插件的规则只有编辑器看得见。要让它成为团队强制标准,同一套判断逻辑必须用 ESLint 或 transformer 再实现一遍,在 CI 里跑。参考 TypeScript 工程化进阶 里对「编辑器内提示 vs CI 门禁」的分层建议。
下一节我们把视角转到「批量改写代码」:当规则不是提示而是自动修复时,就进入了 codemod 的地盘。
小结
tsPlugin一词至少含三种东西:语言服务插件、编译期 transformer、编辑器扩展。本节只讲第一种。- 语言服务插件由
compilerOptions.plugins声明,tsc会静默忽略它,因此它不能替代 CI 门禁。 - 模块约定是默认导出
init(modules) → { create(info) → LanguageService };modules.typescript是唯一正确的编译器来源。 - 用
Object.create(null)+ 循环转发构造代理,只覆盖需要改的方法,其余必须原样委托。 - 通过
getProgram()拿TypeChecker做语义分析,通过覆盖getSemanticDiagnostics注入诊断;code、start、length、source四个字段都要给对。 - 三条红线:必须判空、必须 try/catch、不得阻塞——任何一条破了都会让整台 TS Server 出问题。
下一节 重构工具与 codemod ,我们把「提示」升级为「自动改写」。
阅读导航:上一节:6.1 TS Server 与 LSP · 下一节:6.3 重构工具与 codemod 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。