从零开始构建 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 的关键字段
{
"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 配置
{
"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)
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 文件:
npx @vscode/dts dev # 下载 vscode.proposed.*.d.ts
npx @vscode/dts main # 下载 vscode.d.ts在 package.json 的 postinstall 中自动执行这一步是个好习惯:
{
"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 扩展有两个生命周期函数:
import * as vscode from "vscode";
export function activate(context: vscode.ExtensionContext) {
// 扩展被激活时调用
// 在这里注册命令、Provider、监听事件等
}
export function deactivate() {
// 扩展被卸载或 VSCode 关闭时调用
// 清理资源(定时器、子进程等)
}activate 的参数 context 非常重要——它提供了 subscriptions 数组,所有注册的 Disposable 都应该推入这个数组。VSCode 会在扩展停用时自动调用它们的 dispose() 方法:
export function activate(context: vscode.ExtensionContext) {
const disposable = vscode.commands.registerCommand("myExt.hello", () => {
vscode.window.showInformationMessage("Hello!");
});
context.subscriptions.push(disposable);
}激活事件
VSCode 不会在启动时加载所有扩展——它使用延迟激活策略。你需要在 package.json 中声明激活条件:
{
"activationEvents": [
"onCommand:myExt.hello",
"onLanguage:python",
"onStartupFinished",
"*"
]
}* 表示无条件激活(VSCode 启动后立即加载),但这通常不推荐,因为会拖慢启动速度。好消息是,如果你的 contributes 中声明了 commands 或 languageModelChatProviders,VSCode 会自动推断激活事件,很多时候不需要手写。
Contributes:声明扩展能力
contributes 是 package.json 中信息密度最高的部分。它告诉 VSCode 这个扩展提供了什么。
Commands
{
"contributes": {
"commands": [
{
"command": "myExt.configure",
"category": "My Extension",
"title": "Configure Settings"
}
]
}
}声明后用户可以在命令面板(Ctrl+Shift+P)中搜索到这个命令。但声明只是注册了 UI 入口,实际逻辑需要在 activate() 中用 vscode.commands.registerCommand() 绑定:
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
{
"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() 读取:
const config = vscode.workspace.getConfiguration("myExt");
const serverUrl = config.get<string>("serverUrl", "http://localhost:3000");
const verbose = config.get<boolean>("verbose", false);一个实用的模式是把配置加载封装成一个函数,返回强类型的配置对象:
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 注册自定义模型。声明方式:
{
"enabledApiProposals": ["chatProvider"],
"contributes": {
"languageModelChatProviders": [
{
"vendor": "my-vendor",
"displayName": "My Custom LLM",
"managementCommand": "myExt.configure"
}
]
}
}这部分涉及的内容比较多,会在另一篇文章中详细展开。
Proposed API 的使用
Proposed API 是 VSCode 尚未正式发布的实验性 API。使用它需要两步:
步骤一:在 package.json 中声明
{
"enabledApiProposals": ["chatProvider"]
}步骤二:在用户机器上启用
Proposed API 在正式发布的 VSCode 中默认不可用。对于从 VSIX 侧载的扩展,需要用户手动在 ~/.vscode/argv.json 中添加:
{
"enable-proposed-api": ["your-publisher.your-extension"]
}这意味着使用 Proposed API 的扩展无法通过 Marketplace 分发(Marketplace 会拒绝包含 enabledApiProposals 的扩展)。只能通过 VSIX 文件手动安装。
日志与调试
Output Channel
VSCode 扩展不应该用 console.log——它的输出用户看不到。正确做法是使用 Output Channel:
const channel = vscode.window.createOutputChannel("My Extension");
export function log(message: string) {
const ts = new Date().toISOString();
channel.appendLine(`[${ts}] ${message}`);
}用户可以在 Output 面板的下拉菜单中选择你的 Channel 查看日志。这是扩展与用户沟通调试信息的标准方式。
条件日志
对于高频输出(比如流式数据的每一行),用一个 verbose 开关控制:
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:
{
"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 包装:
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: [] };
}超时控制
外部进程可能挂住,扩展必须有超时机制:
const child = spawn(cmd, args);
const timeout = setTimeout(() => child.kill("SIGKILL"), 300_000);
child.on("close", () => {
clearTimeout(timeout);
});流式输出处理
逐行读取子进程的 stdout:
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
npx @vscode/vsce package -o my-extension.vsixvsce 会执行 vscode:prepublish 脚本(通常是 npm run compile),然后按照 .vscodeignore 规则打包。输出的 .vsix 文件本质是一个 ZIP 包,包含 extension.vsixmanifest(从 package.json 自动生成的 XML 清单)和 extension/ 目录。
安装
code --install-extension my-extension.vsix注意:如果你的环境变量被修改(比如 USERPROFILE 被重定向),code 命令可能把扩展装到错误的位置。可以用 --extensions-dir 显式指定目标:
code --install-extension my-extension.vsix --extensions-dir "C:\Users\YourName\.vscode\extensions"同版本号覆盖安装时,VSCode 有时会缓存旧文件。保险的做法是:先卸载 → 删除残留目录 → 再安装。
实用技巧
Quick Pick 菜单
vscode.window.showQuickPick 是实现交互式菜单的最佳方式:
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(...)) 直接在聊天窗口输出格式化的错误信息。
配置变更监听
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("myExt")) {
// 重新加载配置
config = loadConfig();
}
});小结
VSCode 扩展开发的核心心智模型是:声明 + 注册 + 响应。在 package.json 中声明能力(commands、configuration、providers),在 activate() 中注册处理逻辑,在回调中响应用户操作。所有资源通过 context.subscriptions 管理生命周期,不需要手动清理。
掌握这套模式后,剩下的就是查 API 文档和处理平台差异。Windows 的 .cmd 参数传递、环境变量重定向、Proposed API 的启用方式——这些都是文档里不太明显但实际开发中一定会遇到的坑。