Skip to content

从零开始构建 VSCode 扩展:一份面向实战的开发指南

VSCode 扩展是 JavaScript/TypeScript 程序,运行在 VSCode 宿主进程提供的沙箱环境中,通过 vscode 命名空间的 API 与编辑器交互。听起来简单,但当你真正开始写的时候,会发现 VSCode 扩展开发有一套相当独特的约定和心智模型——和写普通 Node.js 应用的感觉完全不同。

本文以实际开发一个 Language Model Chat Provider 扩展(cursor-copilot-bridge)为背景,覆盖从项目初始化到打包发布的全流程。重点放在那些官方文档写得比较分散、实际踩坑比较多的地方。

项目脚手架

最小可运行结构

一个 VSCode 扩展的最小项目结构只需要三个文件:

my-extension/
├── package.json       # 扩展清单(必须)
├── tsconfig.json      # TypeScript 配置
└── src/
    └── extension.ts   # 入口文件

package.json 在 VSCode 扩展开发中扮演的角色远不止依赖管理——它同时是扩展的声明文件。VSCode 在加载扩展时会读取它来确定:这个扩展提供什么能力、什么时候激活、有哪些配置项、注册了哪些命令。

package.json 的关键字段

json
{
  "name": "my-extension",
  "publisher": "your-publisher-id",
  "displayName": "My Extension",
  "description": "一句话描述扩展功能",
  "version": "0.1.0",
  "engines": {
    "vscode": "^1.104.0"
  },
  "main": "./out/extension.js",
  "contributes": {},
  "scripts": {
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./"
  }
}

几个容易忽略的点:

  • engines.vscode 声明最低兼容版本。如果你用到了新版 API(比如 Language Model Chat Provider 需要 1.104+),必须在这里标明,否则低版本 VSCode 会尝试加载并报错。
  • main 指向编译后的 JS 文件,不是 TS 源码。VSCode 运行时直接 require() 这个文件。
  • publisher 在发布到 Marketplace 时必须和你注册的 Publisher ID 一致。本地开发可以随便填,但 VSIX 打包时会校验格式。

TypeScript 配置

json
{
  "compilerOptions": {
    "module": "Node16",
    "target": "ES2022",
    "outDir": "out",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

module: "Node16" 是目前推荐的选择——它对应 VSCode 宿主的 Node.js 运行时。vscode 模块不需要安装为 npm 依赖(它由宿主注入),但需要类型定义文件来获得 IntelliSense。

类型定义的获取方式

这是新手最容易困惑的地方之一。vscode 模块是运行时由宿主提供的,npm 上装不到。获取类型定义有两种方式:

方式一:@types/vscode(稳定 API)

bash
npm install -D @types/vscode

这是最简单的方式,适用于只使用正式发布 API 的扩展。版本号对应 VSCode 版本,比如 @types/vscode@1.104.0

方式二:@vscode/dts(Proposed API)

如果你要用 Proposed API(比如 chatProvider),@types/vscode 里没有对应的类型。这时需要用 @vscode/dts 工具下载特定的 .d.ts 文件:

bash
npx @vscode/dts dev    # 下载 vscode.proposed.*.d.ts
npx @vscode/dts main   # 下载 vscode.d.ts

package.jsonpostinstall 中自动执行这一步是个好习惯:

json
{
  "scripts": {
    "download-api": "npx @vscode/dts dev && npx @vscode/dts main",
    "postinstall": "npm run download-api"
  }
}

下载的文件会出现在项目根目录,记得在 .gitignore 中排除:

vscode.d.ts
vscode.proposed.*.ts

扩展的生命周期

activate 和 deactivate

VSCode 扩展有两个生命周期函数:

typescript
import * as vscode from "vscode";

export function activate(context: vscode.ExtensionContext) {
  // 扩展被激活时调用
  // 在这里注册命令、Provider、监听事件等
}

export function deactivate() {
  // 扩展被卸载或 VSCode 关闭时调用
  // 清理资源(定时器、子进程等)
}

activate 的参数 context 非常重要——它提供了 subscriptions 数组,所有注册的 Disposable 都应该推入这个数组。VSCode 会在扩展停用时自动调用它们的 dispose() 方法:

typescript
export function activate(context: vscode.ExtensionContext) {
  const disposable = vscode.commands.registerCommand("myExt.hello", () => {
    vscode.window.showInformationMessage("Hello!");
  });

  context.subscriptions.push(disposable);
}

激活事件

VSCode 不会在启动时加载所有扩展——它使用延迟激活策略。你需要在 package.json 中声明激活条件:

json
{
  "activationEvents": [
    "onCommand:myExt.hello",
    "onLanguage:python",
    "onStartupFinished",
    "*"
  ]
}

* 表示无条件激活(VSCode 启动后立即加载),但这通常不推荐,因为会拖慢启动速度。好消息是,如果你的 contributes 中声明了 commands 或 languageModelChatProviders,VSCode 会自动推断激活事件,很多时候不需要手写。

Contributes:声明扩展能力

contributespackage.json 中信息密度最高的部分。它告诉 VSCode 这个扩展提供了什么

Commands

json
{
  "contributes": {
    "commands": [
      {
        "command": "myExt.configure",
        "category": "My Extension",
        "title": "Configure Settings"
      }
    ]
  }
}

声明后用户可以在命令面板(Ctrl+Shift+P)中搜索到这个命令。但声明只是注册了 UI 入口,实际逻辑需要在 activate() 中用 vscode.commands.registerCommand() 绑定:

typescript
vscode.commands.registerCommand("myExt.configure", async () => {
  const choice = await vscode.window.showQuickPick(
    ["Option A", "Option B"],
    { title: "Choose Configuration" }
  );
  if (choice) {
    vscode.window.showInformationMessage(`Selected: ${choice}`);
  }
});

Configuration

json
{
  "contributes": {
    "configuration": {
      "title": "My Extension",
      "properties": {
        "myExt.serverUrl": {
          "type": "string",
          "default": "http://localhost:3000",
          "description": "The server URL to connect to."
        },
        "myExt.verbose": {
          "type": "boolean",
          "default": false,
          "description": "Enable verbose logging."
        }
      }
    }
  }
}

声明后用户可以在 Settings UI 中看到和编辑这些配置。代码中通过 vscode.workspace.getConfiguration() 读取:

typescript
const config = vscode.workspace.getConfiguration("myExt");
const serverUrl = config.get<string>("serverUrl", "http://localhost:3000");
const verbose = config.get<boolean>("verbose", false);

一个实用的模式是把配置加载封装成一个函数,返回强类型的配置对象:

typescript
export interface MyConfig {
  serverUrl: string;
  verbose: boolean;
}

export function loadConfig(): MyConfig {
  const cfg = vscode.workspace.getConfiguration("myExt");
  return {
    serverUrl: cfg.get<string>("serverUrl", "http://localhost:3000"),
    verbose: cfg.get<boolean>("verbose", false),
  };
}

Language Model Chat Provider

这是比较新的 API(目前仍处于 Proposed 阶段),用于向 Copilot Chat 注册自定义模型。声明方式:

json
{
  "enabledApiProposals": ["chatProvider"],
  "contributes": {
    "languageModelChatProviders": [
      {
        "vendor": "my-vendor",
        "displayName": "My Custom LLM",
        "managementCommand": "myExt.configure"
      }
    ]
  }
}

这部分涉及的内容比较多,会在另一篇文章中详细展开。

Proposed API 的使用

Proposed API 是 VSCode 尚未正式发布的实验性 API。使用它需要两步:

步骤一:在 package.json 中声明

json
{
  "enabledApiProposals": ["chatProvider"]
}

步骤二:在用户机器上启用

Proposed API 在正式发布的 VSCode 中默认不可用。对于从 VSIX 侧载的扩展,需要用户手动在 ~/.vscode/argv.json 中添加:

json
{
  "enable-proposed-api": ["your-publisher.your-extension"]
}

这意味着使用 Proposed API 的扩展无法通过 Marketplace 分发(Marketplace 会拒绝包含 enabledApiProposals 的扩展)。只能通过 VSIX 文件手动安装。

日志与调试

Output Channel

VSCode 扩展不应该用 console.log——它的输出用户看不到。正确做法是使用 Output Channel:

typescript
const channel = vscode.window.createOutputChannel("My Extension");

export function log(message: string) {
  const ts = new Date().toISOString();
  channel.appendLine(`[${ts}] ${message}`);
}

用户可以在 Output 面板的下拉菜单中选择你的 Channel 查看日志。这是扩展与用户沟通调试信息的标准方式。

条件日志

对于高频输出(比如流式数据的每一行),用一个 verbose 开关控制:

typescript
export function logVerbose(enabled: boolean, message: string) {
  if (!enabled) return;
  const ts = new Date().toISOString();
  channel.appendLine(`[${ts}] [verbose] ${message}`);
}

开发调试

按 F5 启动调试时,VSCode 会打开一个「扩展开发宿主」窗口(Extension Development Host),你的扩展在其中运行。断点、变量查看、调用栈都正常工作。

但对于使用 Proposed API 的扩展,F5 调试需要额外配置 .vscode/launch.json

json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}",
        "--enable-proposed-api=your-publisher.your-extension"
      ]
    }
  ]
}

子进程管理

很多扩展需要调用外部命令行工具。在 VSCode 扩展中 spawn 子进程和普通 Node.js 基本一致,但有几个 Windows 特有的坑:

.cmd 脚本的参数传递

Windows 上很多 CLI 工具通过 .cmd 脚本分发。Node.js 的 spawn 调用 .cmd 文件时,实际上是通过 cmd.exe /c 来执行。这意味着参数中的特殊字符(<, >, &, |, 换行符)会被 cmd.exe 误解释。

解决办法是直接调用底层的 node.exe,绕过 .cmd 包装:

typescript
function resolveAgent(agentPath: string): { cmd: string; prefixArgs: string[] } {
  if (process.platform === "win32" && /\.cmd$/i.test(agentPath)) {
    const dir = path.dirname(path.resolve(agentPath));
    const nodeBin = path.join(dir, "node.exe");
    const script = path.join(dir, "index.js");
    if (fs.existsSync(nodeBin) && fs.existsSync(script)) {
      return { cmd: nodeBin, prefixArgs: [script] };
    }
  }
  return { cmd: agentPath, prefixArgs: [] };
}

超时控制

外部进程可能挂住,扩展必须有超时机制:

typescript
const child = spawn(cmd, args);
const timeout = setTimeout(() => child.kill("SIGKILL"), 300_000);

child.on("close", () => {
  clearTimeout(timeout);
});

流式输出处理

逐行读取子进程的 stdout:

typescript
let lineBuffer = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
  lineBuffer += chunk;
  const lines = lineBuffer.split("\n");
  lineBuffer = lines.pop() ?? "";
  for (const line of lines) {
    if (line.trim()) processLine(line);
  }
});

注意 data 事件可能在任意位置截断,所以需要一个 lineBuffer 来拼接不完整的行。

打包与分发

.vscodeignore

类似 .gitignore,控制哪些文件被打入 VSIX 包。一个合理的配置:

**/*.ts
!out/**/*.js
src/
node_modules/
.vscode/
*.vsix
**/*.map

关键原则:只打包运行时需要的文件(编译后的 JS、package.json、README、图标),排除所有源码和开发文件。Source map 通常也不需要。

构建 VSIX

bash
npx @vscode/vsce package -o my-extension.vsix

vsce 会执行 vscode:prepublish 脚本(通常是 npm run compile),然后按照 .vscodeignore 规则打包。输出的 .vsix 文件本质是一个 ZIP 包,包含 extension.vsixmanifest(从 package.json 自动生成的 XML 清单)和 extension/ 目录。

安装

bash
code --install-extension my-extension.vsix

注意:如果你的环境变量被修改(比如 USERPROFILE 被重定向),code 命令可能把扩展装到错误的位置。可以用 --extensions-dir 显式指定目标:

bash
code --install-extension my-extension.vsix --extensions-dir "C:\Users\YourName\.vscode\extensions"

同版本号覆盖安装时,VSCode 有时会缓存旧文件。保险的做法是:先卸载 → 删除残留目录 → 再安装。

实用技巧

Quick Pick 菜单

vscode.window.showQuickPick 是实现交互式菜单的最佳方式:

typescript
const choice = await vscode.window.showQuickPick(
  [
    { label: "$(gear) Settings", description: "Open configuration", action: "settings" },
    { label: "$(refresh) Refresh", description: "Reload data", action: "refresh" },
    { label: "$(output) Logs", description: "Show output", action: "logs" },
  ],
  { title: "My Extension" }
);

if (choice?.action === "settings") {
  vscode.commands.executeCommand("workbench.action.openSettings", "myExt");
}

$(icon-name) 语法可以在标签中嵌入 VSCode 内置的 Codicon 图标。

错误直接反馈到 UI

扩展的错误不应该只记日志——用户可能根本不看日志。对于面向用户的操作,错误信息应该出现在用户正在看的地方。如果是 Chat Provider,可以通过 progress.report(new vscode.LanguageModelTextPart(...)) 直接在聊天窗口输出格式化的错误信息。

配置变更监听

typescript
vscode.workspace.onDidChangeConfiguration((e) => {
  if (e.affectsConfiguration("myExt")) {
    // 重新加载配置
    config = loadConfig();
  }
});

小结

VSCode 扩展开发的核心心智模型是:声明 + 注册 + 响应。在 package.json 中声明能力(commands、configuration、providers),在 activate() 中注册处理逻辑,在回调中响应用户操作。所有资源通过 context.subscriptions 管理生命周期,不需要手动清理。

掌握这套模式后,剩下的就是查 API 文档和处理平台差异。Windows 的 .cmd 参数传递、环境变量重定向、Proposed API 的启用方式——这些都是文档里不太明显但实际开发中一定会遇到的坑。

最后更新于:

Hosted by GitHub Pages