《TypeScript高级编程》6.2 自定义 tsPlugin

本节先厘清「tsPlugin」在 TS 生态里的三种含义:语言服务插件、编辑器扩展与编译期 transformer 极易混淆。重点是语言服务插件——模块导出约定、用 Proxy 包装 LanguageService 覆盖方法、拿 Program 与 TypeChecker 做语义分析、注入自定义诊断,以及调试与性能红线。读完本节,你能写出真正生效、可调试、不拖垮 TS Server 的插件。

本节目标:分清 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 进程内的 LanguageServicetsconfig.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" }
    ]
  }
}

三条容易踩的规则:

  1. tsc 会静默忽略 plugins。它不报错,也不加载。所以「我配了插件为什么命令行没用」不是 bug。
  2. name 按 Node 的解析规则解析,基准目录是 tsconfig 所在目录。ts-plugin-my-rules 会去找 node_modules/ts-plugin-my-rules;./tools/local-plugin 是相对路径,本地开发时最省事。
  3. 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 取

还有两条工程纪律:

  1. 每个覆盖方法都包 try/catch,出错时 return 原结果。插件抛异常不是「只坏这一个功能」,而是污染整条请求链路,用户看到的是「TS 服务挂了」。
  2. 不要在插件里做 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes