VS Code 插件开发入门:从零开发一个实用的开发工具

前言

VS Code 是目前最流行的代码编辑器,其强大的插件生态是其核心竞争力之一。无论你是想提升自己的开发效率,还是想为社区贡献工具,开发一个 VS Code 插件都是一项值得投资的技能。

本文将从零开始,手把手带你开发一个真正实用 的 VS Code 插件------"Code Snippet Manager"(代码片段管理器),它能够:

  • 📋 收藏和管理常用的代码片段
  • 🔍 快速搜索和插入片段
  • 📁 按项目/语言分类组织
  • 🌐 支持导入导出,跨设备同步

通过这个实战项目,你将掌握 VS Code 插件开发的完整流程。


一、VS Code 插件基础概念

1.1 插件能做什么?

VS Code 插件可以扩展以下能力:

扩展类型 说明 示例
命令(Commands) 注册自定义命令,绑定快捷键 格式化代码、生成注释
菜单(Menus) 在编辑器、资源管理器中添加菜单项 右键菜单、顶部菜单栏
快捷键(Keybindings) 绑定自定义快捷键 Ctrl+Shift+Y 触发操作
视图(Views) 在侧边栏创建自定义面板 输出面板、树形视图
Webview 创建完整的 HTML/CSS/JS 界面 可视化配置、预览面板
Language Server 提供语言支持(补全、诊断等) Python、Go 语言插件
Debug Adapter 调试器集成 Chrome Debugger
Task Provider 自定义任务系统 构建任务、测试运行

1.2 核心技术栈

复制代码
┌─────────────────────────────────────┐
│         VS Code Extension API        │  ← TypeScript API
├─────────────────────────────────────┤
│  TypeScript / JavaScript (Node.js)   │  ← 开发语言
├─────────────────────────────────────┤
│     package.json (插件清单)          │  ← 配置文件
├─────────────────────────────────────┤
│         webpack / esbuild            │  ← 构建工具
└─────────────────────────────────────┘

为什么用 TypeScript?

  • VS Code 本身就是用 TypeScript 写的
  • 官方 API 提供完整的类型定义
  • 编译时就能发现大部分错误
  • 社区生态和示例几乎都是 TypeScript

二、环境搭建与项目初始化

2.1 前置要求

bash 复制代码
# 1. 安装 Node.js (v18+)
node --version    # 应显示 v18.x 或更高
npm --version     # 应显示 9.x 或更高

# 2. 安装 VS Code
# 从 https://code.visualstudio.com/ 下载安装

# 3. 安装插件开发工具
npm install -g @vscode/vsce    # 打包发布工具
npm install -g @esbuild/esbuild-watcher  # 开发热重载

2.2 使用脚手架创建项目

bash 复制代码
# 创建项目目录
mkdir code-snippet-manager && cd code-snippet-manager

# 使用官方脚手架初始化
npx --yes yo code

# 交互式选择:
# ? What type of extension do you want to create? → New Extension (TypeScript)
# ? What's the name of your extension? → code-snippet-manager
# ? What's the identifier of your extension? → code-snippet-manager
# ? What's the description of your extension? → 管理和快速插入常用代码片段
# ? Initialize a git repository? → Yes
# ? Bundle the source code with webpack? → No (使用 esbuild,更快)
# ? Which package manager to use? → npm

2.3 项目结构解析

初始化完成后,项目结构如下:

复制代码
code-snippet-manager/
├── .vscode/                 # VS Code 工作区配置
│   ├── launch.json          # 调试启动配置
│   ├── tasks.json           # 构建任务
│   └── extensions.json      # 推荐扩展
├── src/                     # 源代码目录
│   ├── test/                # 测试文件
│   │   └── extension.test.ts
│   └── extension.ts         # 插件入口文件 ⭐ 核心
├── .gitignore
├── .eslintrc.json           # ESLint 配置
├── package.json             # 插件清单 ⭐ 重要!
├── tsconfig.json            # TypeScript 配置
├── vsc-extension-quickstart.md
└── README.md

2.4 理解 package.json(插件清单)

这是 VS Code 插件最重要的配置文件:

json 复制代码
{
  "name": "code-snippet-manager",
  "displayName": "Code Snippet Manager",
  "description": "管理和快速插入常用代码片段",
  "version": "0.0.1",
  "engines": {
    "vscode": "^1.85.0"  // 支持的 VS Code 版本范围
  },
  "categories": [           // 插件分类(影响在市场的展示)
    "Other"
  ],
  "activationEvents": [],    // 激活事件(何时加载插件)
  "main": "./out/extension.js",  // 编译后的入口文件
  "contributes": {           // 向 VS Code 注册能力的核心配置
    "commands": [
      {
        "command": "snippetManager.addSnippet",
        "title": "Snippet Manager: 添加当前选中的代码为片段"
      },
      {
        "command": "snippetManager.openPanel",
        "title": "Snippet Manager: 打开片段管理面板"
      },
      {
        "command": "snippetManager.insertSnippet",
        "title": "Snippet Manager: 搜索并插入片段"
      }
    ],
    "keybindings": [
      {
        "command": "snippetManager.addSnippet",
        "key": "ctrl+shift+s",
        "mac": "cmd+shift+s",
        "when": "editorTextFocus && !editorReadonly"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "command": "snippetManager.addSnippet",
          "group": "navigation@1",
          "when": "editorTextFocus && !editorReadonly"
        }
      ]
    ]
  },
  "scripts": {
    "vscode:prepublish": "npm run compile",
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./",
    "lint": "eslint src --ext ts",
    "test": "node ./out/test/runTest.js"
  },
  "devDependencies": {
    "@types/node": "^18.x",
    "@types/vscode": "^1.85.0",
    "@vscode/test-electron": "^2.3.0",
    "@typescript-eslint/eslint-plugin": "^6.x",
    "@typescript-eslint/parser": "^6.x",
    "eslint": "^8.x",
    "typescript": "^5.x"
  }
}

contributes 字段详解:

字段 作用 必填
commands 注册命令
keybindings 绑定快捷键
menus 添加到菜单
views 注册侧边栏视图
languages 注册语言支持
configuration 注册设置项

三、核心功能实现:代码片段管理器

3.1 数据模型设计

首先定义代码片段的数据结构:

typescript 复制代码
// src/models.ts

/**
 * 单个代码片段的数据结构
 */
export interface Snippet {
  /** 唯一 ID */
  id: string;
  /** 片段标题 */
  title: string;
  /** 代码内容 */
  code: string;
  /** 适用语言 */
  language: string;
  /** 分类标签 */
  tags: string[];
  /** 创建时间 */
  createdAt: number;
  /** 最后使用时间 */
  lastUsedAt?: number;
  /** 使用次数 */
  useCount: number;
  /** 备注 */
  note?: string;
}

/**
 * 分类信息
 */
export interface SnippetCategory {
  id: string;
  name: string;
  icon?: string;  // emoji 图标
}

/**
 * 全局状态
 */
export interface SnippetManagerState {
  snippets: Snippet[];
  categories: SnippetCategory[];
}

3.2 存储层:使用 VS Code 的 GlobalState

VS Code 为每个插件提供了持久化存储能力,无需引入数据库:

typescript 复制代码
// src/storage.ts
import * as vscode from 'vscode';
import { Snippet, SnippetCategory, SnippetManagerState } from './models';

const SNIPPETS_KEY = 'codeSnippets';
const CATEGORIES_KEY = 'snippetCategories';

export class SnippetStorage {
  private context: vscode.ExtensionContext;

  constructor(context: vscode.ExtensionContext) {
    this.context = context;
  }

  // ════════════════════════════════════════
  // 片段 CRUD 操作
  // ════════════════════════════════════════

  /** 获取所有片段 */
  async getAllSnippets(): Promise<Snippet[]> {
    const data = this.context.globalState.get<Snippet[]>(SNIPPETS_KEY, []);
    return data || [];
  }

  /** 获取单个片段 */
  async getSnippet(id: string): Promise<Snippet | undefined> {
    const snippets = await this.getAllSnippets();
    return snippets.find(s => s.id === id);
  }

  /** 添加片段 */
  async addSnippet(snippet: Snippet): Promise<void> {
    const snippets = await this.getAllSnippets();
    snippets.push(snippet);
    await this.context.globalState.update(SNIPPETS_KEY, snippets);
  }

  /** 更新片段 */
  async updateSnippet(id: string, updates: Partial<Snippet>): Promise<void> {
    const snippets = await this.getAllSnippets();
    const index = snippets.findIndex(s => s.id === id);
    if (index !== -1) {
      snippets[index] = { ...snippets[index], ...updates };
      await this.context.globalState.update(SNIPPETS_KEY, snippets);
    }
  }

  /** 删除片段 */
  async deleteSnippet(id: string): Promise<void> {
    const snippets = await this.getAllSnippets();
    const filtered = snippets.filter(s => s.id !== id);
    await this.context.globalState.update(SNIPPETS_KEY, filtered);
  }

  // ════════════════════════════════════════
  // 搜索功能
  // ════════════════════════════════════════

  /**
   * 搜索片段(支持模糊匹配)
   * @param query 搜索关键词
   * @param language 可选的语言过滤
   */
  async searchSnippets(query: string, language?: string): Promise<Snippet[]> {
    let snippets = await this.getAllSnippets();

    // 语言过滤
    if (language) {
      snippets = snippets.filter(s => 
        s.language.toLowerCase() === language.toLowerCase()
      );
    }

    if (!query.trim()) {
      // 无搜索词时按最后使用时间排序
      return snippets.sort((a, b) => (b.lastUsedAt || 0) - (a.lastUsedAt || 0));
    }

    const lowerQuery = query.toLowerCase();

    return snippets
      .map(snippet => {
        let score = 0;

        // 标题完全匹配得分最高
        if (snippet.title.toLowerCase().includes(lowerQuery)) {
          score += 100;
        }
        // 标签匹配
        if (snippet.tags.some(t => t.toLowerCase().includes(lowerQuery))) {
          score += 50;
        }
        // 代码内容匹配
        if (snippet.code.toLowerCase().includes(lowerQuery)) {
          score += 30;
        }
        // 备注匹配
        if (snippet.note?.toLowerCase().includes(lowerQuery)) {
          score += 20;
        }
        // 频繁使用的片段优先
        score += snippet.useCount;

        return { snippet, score };
      })
      .filter(item => item.score > 0)
      .sort((a, b) => b.score - a.score)
      .map(item => item.snippet);
  }

  // ════════════════════════════════════════
  // 统计功能
  // ════════════════════════════════════════

  /** 记录使用 */
  async recordUsage(id: string): Promise<void> {
    const snippets = await this.getAllSnippets();
    const snippet = snippets.find(s => s.id === id);
    if (snippet) {
      snippet.useCount++;
      snippet.lastUsedAt = Date.now();
      await this.context.globalState.update(SNIPPETS_KEY, snippets);
    }
  }

  /** 获取统计信息 */
  async getStats(): Promise<{ total: number; totalUsage: number }> {
    const snippets = await this.getAllSnippets();
    return {
      total: snippets.length,
      totalUsage: snippets.reduce((sum, s) => sum + s.useCount, 0)
    };
  }

  // ════════════════════════════════════════
  // 导入导出
  // ════════════════════════════════════════

  /** 导出所有数据为 JSON */
  exportData(): { snippets: Snippet[]; categories: SnippetCategory[] } {
    return {
      snippets: this.context.globalState.get(SNIPPETS_KEY, []) || [],
      categories: this.context.globalState.get(CATEGORIES_KEY, []) || []
    };
  }

  /** 从 JSON 导入数据 */
  async importData(data: { snippets: Snippet[]; categories: SnippetCategory[] }): Promise<void> {
    await this.context.globalState.update(SNIPPETS_KEY, data.snippets);
    await this.context.globalState.update(CATEGORIES_KEY, data.categories);
  }
}

3.3 核心命令实现

typescript 复制代码
// src/extension.ts
import * as vscode from 'vscode';
import { SnippetStorage } from './storage';
import { Snippet } from './models';

let storage: SnippetStorage;

// ════════════════════════════════════════════════
// 插件激活入口
// ════════════════════════════════════════════════

export function activate(context: vscode.ExtensionContext) {
  console.log('Code Snippet Manager 已激活');

  // 初始化存储
  storage = new SnippetStorage(context);

  // 注册所有命令
  registerCommands(context);

  // 显示欢迎消息(首次激活时)
  showWelcomeMessage(context);
}

export function deactivate() {
  console.log('Code Snippet Manager 已停用');
}

// ════════════════════════════════════════════════
// 命令注册
// ════════════════════════════════════════════════

function registerCommands(context: vscode.ExtensionContext) {

  // ── 命令 1:添加当前选中代码为片段 ──────────
  const addSnippetCmd = vscode.commands.registerCommand(
    'snippetManager.addSnippet',
    async () => await addCurrentSelectionAsSnippet()
  );
  context.subscriptions.push(addSnippetCmd);

  // ── 命令 2:打开管理面板 ─────────────────────
  const openPanelCmd = vscode.commands.registerCommand(
    'snippetManager.openPanel',
    () => openManagementPanel()
  );
  context.subscriptions.push(openPanelCmd);

  // ── 命令 3:搜索并插入片段 ───────────────────
  const insertCmd = vscode.commands.registerCommand(
    'snippetManager.insertSnippet',
    () => searchAndInsert()
  );
  context.subscriptions.push(insertCmd);

  // ── 命令 4:导出数据 ─────────────────────────
  const exportCmd = vscode.commands.registerCommand(
    'snippetManager.export',
    () => exportSnippets()
  );
  context.subscriptions.push(exportCmd);

  // ── 命令 5:导入数据 ─────────────────────────
  const importCmd = vscode.commands.registerCommand(
    'snippetManager.import',
    () => importSnippets()
  );
  context.subscriptions.push(importCmd);
}

// ════════════════════════════════════════════════
// 命令实现
// ════════════════════════════════════════════════

/** 命令 1:将当前选中的代码保存为片段 */
async function addCurrentSelectionAsSnippet() {
  const editor = vscode.window.activeTextEditor;
  
  if (!editor) {
    vscode.window.showWarningMessage('请先打开一个编辑器');
    return;
  }

  const selection = editor.selection;
  const selectedText = editor.document.getText(selection);

  if (!selectedText) {
    vscode.window.showWarningMessage('请先选中要保存的代码');
    return;
  }

  // 弹出输入框让用户填写信息
  const title = await vscode.window.showInputBox({
    prompt: '输入片段标题',
    placeHolder: '例如:HTTP 请求封装函数',
    validateInput: (value) => {
      if (!value?.trim()) return '标题不能为空';
      return undefined;
    }
  });

  if (!title) return; // 用户取消

  // 自动检测语言
  const languageId = editor.document.languageId;

  // 输入标签
  const tagsInput = await vscode.window.showInputBox({
    prompt: '输入标签(用逗号分隔)',
    placeHolder: '例如:网络请求, 封装, 工具'
  });

  const tags = tagsInput
    ? tagsInput.split(',').map(t => t.trim()).filter(Boolean)
    : [];

  // 输入备注
  const note = await vscode.window.showInputBox({
    prompt: '输入备注(可选)',
    placeHolder: '这段代码的用途说明...'
  });

  // 创建片段对象
  const snippet: Snippet = {
    id: generateId(),
    title: title.trim(),
    code: selectedText,
    language: languageId,
    tags,
    createdAt: Date.now(),
    useCount: 0
  };

  if (note?.trim()) {
    snippet.note = note.trim();
  }

  // 保存
  await storage.addSnippet(snippet);

  vscode.window.showInformationMessage(
    `✅ 片段 "${title}" 已保存!共 ${tags.length} 个标签`,
    '管理片段'
  ).then(choice => {
    if (choice === '管理片段') {
      openManagementPanel();
    }
  });
}

/** 命令 2:搜索并插入片段 */
async function searchAndInsert() {
  const editor = vscode.window.activeTextEditor;
  if (!editor) {
    vscode.window.showWarningMessage('请先打开一个编辑器');
    return;
  }

  // 显示搜索 QuickPick
  const allSnippets = await storage.getAllSnippets();

  if (allSnippets.length === 0) {
    vscode.window.showInformationMessage(
      '暂无已保存的片段。请先选中代码按 Ctrl+Shift+S 保存。',
      '了解如何使用'
    );
    return;
  }

  // 创建 QuickPick 项目
  const items: vscode.QuickPickItem[] = allSnippets.map(s => ({
    label: `$(code) ${s.title}`,
    description: s.language,
    detail: s.code.substring(0, 80) + (s.code.length > 80 ? '...' : ''),
    tooltip: `${s.tags.join(', ')} | 使用 ${s.useCount} 次`,
    // 用于后续识别是哪个片段
    snippet: s as any
  }));

  // 创建可搜索的 QuickPick
  const quickPick = vscode.window.createQuickPick();
  quickPick.items = items;
  quickPick.placeholder = '搜索代码片段...(输入关键词过滤)';
  quickPick.matchOnDescription = true;
  quickPick.matchOnDetail = true;

  // 选择处理
  quickPick.onDidAccept(async () => {
    const selected = quickPick.selectedItems[0] as any;
    if (selected?.snippet) {
      const snippet: Snippet = selected.snippet;

      // 在光标位置插入代码
      await editor.edit(editBuilder => {
        editBuilder.insert(editor.selection.active, snippet.code);
      });

      // 记录使用
      await storage.recordUsage(snippet.id);

      vscode.window.setStatusBarMessage(
        `✅ 已插入片段: ${snippet.title}`,
        3000
      );
    }
    quickPick.dispose();
  });

  // 取消处理
  quickPick.onDidHide(() => quickPick.dispose());

  quickPick.show();
}

/** 命令 3:打开管理面板(Webview) */
function openManagementPanel() {
  SnippetPanel.createOrShow(contextForExtension());
}

/** 命令 4:导出片段 */
async function exportSnippets() {
  const data = storage.exportData();

  // 让用户选择保存路径
  const uri = await vscode.window.showSaveDialog({
    defaultUri: vscode.Uri.file(`snippets-export-${Date.now()}.json`),
    filters: { 'JSON': ['json'] },
    saveLabel: '导出'
  });

  if (!uri) return;

  await vscode.workspace.fs.writeFile(
    uri,
    Buffer.from(JSON.stringify(data, null, 2), 'utf-8')
  );

  vscode.window.showInformationMessage(
    `✅ 已导出 ${data.snippets.length} 个片段到 ${uri.fsPath}`
  );
}

/** 命令 5:导入片段 */
async function importSnippets() {
  const uris = await vscode.window.showOpenDialog({
    canSelectMany: false,
    filters: { 'JSON': ['json'] },
    openLabel: '选择要导入的 JSON 文件'
  });

  if (!uris || uris.length === 0) return;

  try {
    const content = await vscode.workspace.fs.readFile(uris[0]);
    const data = JSON.parse(content.toString());

    if (!data.snippets || !Array.isArray(data.snippets)) {
      throw new Error('无效的导入文件格式');
    }

    await storage.importData(data);

    vscode.window.showInformationMessage(
      `✅ 成功导入 ${data.snippets.length} 个片段`
    );
  } catch (error) {
    vscode.window.showErrorMessage(
      `❌ 导入失败: ${error instanceof Error ? error.message : String(error)}`
    );
  }
}

// ════════════════════════════════════════════════
// 辅助函数
// ════════════════════════════════════════════════

let _context: vscode.ExtensionContext | null = null;

function contextForExtension(): vscode.ExtensionContext {
  if (!_context) {
    throw new Error('Extension context not initialized');
  }
  return _context;
}

/** 生成唯一 ID */
function generateId(): string {
  return Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
}

/** 首次激活时显示欢迎消息 */
function showWelcomeMessage(context: vscode.ExtensionContext) {
  _context = context;

  const hasShownWelcome = context.globalState.get<boolean>('hasShownWelcome');

  if (!hasShownWelcome) {
    vscode.window.showInformationMessage(
      '🎉 Code Snippet Manager 已就绪!\n' +
      '选中代码后按 Ctrl+Shift+S 即可保存为片段。',
      '查看使用指南',
      '不再提示'
    ).then(choice => {
      if (choice === '查看使用指南') {
        vscode.env.openExternal(vscode.Uri.parse(
          'https://github.com/your-repo/code-snippet-manager#readme'
        ));
      }
      context.globalState.update('hasShownWelcome', true);
    });
  }
}

3.4 Webview 管理面板

这是插件最有价值的部分------一个可视化的管理界面:

typescript 复制代码
// src/snippetPanel.ts
import * as vscode from 'vscode';

export class SnippetPanel {
  public static currentPanel: SnippetPanel | undefined;
  private readonly _panel: vscode.WebviewPanel;
  private readonly _extensionUri: vscode.Uri;
  private _disposables: vscode.Disposable[] = [];

  /**
   * 创建或显示面板(单例模式)
   */
  public static createOrShow(extensionUri: vscode.Uri) {
    const column = vscode.window.activeTextEditor
      ? vscode.window.activeTextEditor.viewColumn
      : undefined;

    // 如果已有面板,聚焦它
    if (SnippetPanel.currentPanel) {
      SnippetPanel.currentPanel._panel.reveal(column);
      return;
    }

    // 创建新面板
    const panel = vscode.window.createWebviewPanel(
      'snippetManager',           // 标识符
      '📋 代码片段管理器',         // 标题
      column || vscode.ViewColumn.One,  // 显示在哪一列
      {
        enableScripts: true,
        retainContextWhenHidden: true  // 保持状态
      }
    );

    SnippetPanel.currentPanel = new SnippetPanel(panel, extensionUri);
  }

  private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
    this._panel = panel;
    this._extensionUri = extensionUri;

    // 设置 HTML 内容
    this._panel.webview.html = this._getHtmlForWebview();

    // 监听面板关闭事件
    this._panel.onDidDispose(() => this.dispose(), null, this._disposables);

    // 处理来自 Webview 的消息
    this._panel.webview.onDidReceiveMessage(
      async (message) => {
        await this._handleMessage(message);
      },
      null,
      this._disposables
    );
  }

  /** 处理 Webview 发来的消息 */
  private async _handleMessage(message: any) {
    switch (message.command) {
      case 'getSnippets':
        // Webview 请求数据
        const snippets = await storage.getAllSnippets();
        this._panel.webview.postMessage({
          command: 'updateSnippets',
          data: snippets
        });
        break;

      case 'deleteSnippet':
        // 删除片段
        await storage.deleteSnippet(message.id);
        const updated = await storage.getAllSnippets();
        this._panel.webview.postMessage({
          command: 'updateSnippets',
          data: updated
        });
        vscode.window.showInformationMessage('片段已删除');
        break;

      case 'insertSnippet':
        // 插入片段到编辑器
        const editor = vscode.window.activeTextEditor;
        if (editor) {
          const snippet = await storage.getSnippet(message.id);
          if (snippet) {
            await editor.edit(eb => eb.insert(editor.selection.active, snippet.code));
            await storage.recordUsage(message.id);
          }
        }
        break;

      case 'copyToClipboard':
        // 复制到剪贴板
        const s = await storage.getSnippet(message.id);
        if (s) {
          await vscode.env.clipboard.writeText(s.code);
          vscode.window.showInformationMessage('已复制到剪贴板');
        }
        break;
    }
  }

  /** 生成 Webview HTML */
  private _getHtmlForWebview(): string {
    const nonce = getNonce();

    return /*html*/ `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="Content-Security-Policy"
    content="default-src 'none'; style-src ${this._panel.webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
  <title>代码片段管理器</title>
  <style>
    /* ══════════════════════════════════════
       CSS 样式
       ══════════════════════════════════════ */
    :root {
      --bg-primary: var(--vscode-editor-background);
      --bg-secondary: var(--vscode-sideBar-background);
      --text-primary: var(--vscode-editor-foreground);
      --text-secondary: var(--vscode-descriptionForeground);
      --border-color: var(--vscode-panel-border);
      --accent: var(--vscode-button-background);
      --accent-hover: var(--vscode-button-hoverBackground);
      --danger: #f44336;
      --success: #4caf50;
    }

    * { margin: 0; padding: 0; box-sizing: border-box; }

    body {
      font-family: var(--vscode-font-family);
      background: var(--bg-primary);
      color: var(--text-primary);
      padding: 16px;
      line-height: 1.5;
    }

    /* 头部 */
    .header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 16px;
      padding-bottom: 12px;
      border-bottom: 1px solid var(--border-color);
    }

    .header h1 {
      font-size: 18px;
      font-weight: 600;
    }

    .stats {
      font-size: 12px;
      color: var(--text-secondary);
    }

    /* 搜索栏 */
    .search-bar {
      width: 100%;
      padding: 8px 12px;
      margin-bottom: 16px;
      border: 1px solid var(--border-color);
      border-radius: 4px;
      background: var(--bg-secondary);
      color: var(--text-primary);
      font-size: 14px;
      outline: none;
    }

    .search-bar:focus {
      border-color: var(--accent);
    }

    /* 片段卡片 */
    .snippet-list {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }

    .snippet-card {
      border: 1px solid var(--border-color);
      border-radius: 6px;
      padding: 12px;
      transition: border-color 0.2s;
    }

    .snippet-card:hover {
      border-color: var(--accent);
    }

    .card-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 6px;
    }

    .card-title {
      font-weight: 600;
      font-size: 14px;
    }

    .card-lang {
      font-size: 11px;
      padding: 2px 8px;
      border-radius: 10px;
      background: var(--bg-secondary);
      color: var(--text-secondary);
    }

    .card-tags {
      display: flex;
      gap: 4px;
      flex-wrap: wrap;
      margin-bottom: 6px;
    }

    .tag {
      font-size: 11px;
      padding: 1px 6px;
      border-radius: 3px;
      background: rgba(66, 133, 244, 0.15);
      color: #4285f4;
    }

    .card-code {
      font-family: var(--vscode-editor-font-family, 'Consolas', monospace);
      font-size: 12px;
      background: var(--bg-secondary);
      padding: 8px;
      border-radius: 4px;
      overflow-x: auto;
      white-space: pre-wrap;
      max-height: 120px;
      overflow-y: auto;
      margin-bottom: 8px;
    }

    .card-meta {
      display: flex;
      justify-content: space-between;
      align-items: center;
      font-size: 11px;
      color: var(--text-secondary);
    }

    .card-actions {
      display: flex;
      gap: 8px;
    }

    .btn {
      padding: 4px 10px;
      border: none;
      border-radius: 3px;
      cursor: pointer;
      font-size: 12px;
      transition: opacity 0.2s;
    }

    .btn:hover { opacity: 0.85; }

    .btn-primary {
      background: var(--accent);
      color: var(--vscode-button-foreground);
    }

    .btn-danger {
      background: transparent;
      color: var(--danger);
      border: 1px solid var(--danger);
    }

    .btn-ghost {
      background: transparent;
      color: var(--text-secondary);
    }

    /* 空状态 */
    .empty-state {
      text-align: center;
      padding: 48px 24px;
      color: var(--text-secondary);
    }

    .empty-state h2 {
      font-size: 18px;
      margin-bottom: 8px;
      color: var(--text-primary);
    }

    .empty-icon {
      font-size: 48px;
      margin-bottom: 16px;
    }

    /* 过滤栏 */
    .filter-bar {
      display: flex;
      gap: 8px;
      margin-bottom: 12px;
      flex-wrap: wrap;
    }

    .filter-chip {
      padding: 4px 10px;
      border-radius: 12px;
      font-size: 12px;
      cursor: pointer;
      border: 1px solid var(--border-color);
      background: transparent;
      color: var(--text-secondary);
      transition: all 0.2s;
    }

    .filter-chip:hover,
    .filter-chip.active {
      background: var(--accent);
      color: var(--vscode-button-foreground);
      border-color: var(--accent);
    }
  </style>
</head>
<body>
  <!-- HTML 结构 -->
  <div class="header">
    <h1>📋 代码片段管理器</h1>
    <div class="stats" id="stats">加载中...</div>
  </div>

  <input type="text" class="search-bar" id="searchInput"
    placeholder="🔍 搜索片段...">

  <div class="filter-bar" id="filterBar"></div>

  <div class="snippet-list" id="snippetList"></div>

  <script nonce="${nonce}">
    // ══════════════════════════════════════
    // JavaScript 逻辑
    // ══════════════════════════════════════

    let allSnippets = [];
    let activeFilter = 'all';

    // 初始化:请求数据
    window.addEventListener('load', () => {
      acquireVsCodeApi().postMessage({ command: 'getSnippets' });
    });

    // 接收来自插件的响应
    window.addEventListener('message', event => {
      const message = event.data;
      if (message.command === 'updateSnippets') {
        allSnippets = message.data || [];
        renderSnippets();
        updateStats();
        renderFilters();
      }
    });

    // 渲染片段列表
    function renderSnippets() {
      const container = document.getElementById('snippetList');
      const query = document.getElementById('searchInput').value.toLowerCase();

      let filtered = allSnippets;

      // 搜索过滤
      if (query) {
        filtered = filtered.filter(s =>
          s.title.toLowerCase().includes(query) ||
          s.code.toLowerCase().includes(query) ||
          s.tags.some(t => t.toLowerCase().includes(query))
        );
      }

      // 分类过滤
      if (activeFilter !== 'all') {
        filtered = filtered.filter(s =>
          s.language === activeFilter ||
          s.tags.includes(activeFilter)
        );
      }

      if (filtered.length === 0) {
        container.innerHTML = \`
          <div class="empty-state">
            <div class="empty-icon">📝</div>
            <h2>\${allSnippets.length === 0 ? '还没有保存任何片段' : '没有找到匹配的片段'}</h2>
            <p>\${allSnippets.length === 0
              ? '选中代码后按 Ctrl+Shift+S 保存第一个片段吧!'
              : '试试其他关键词?'}</p>
          </div>
        \`;
        return;
      }

      container.innerHTML = filtered.map(s => \`
        <div class="snippet-card" data-id="\${s.id}">
          <div class="card-header">
            <span class="card-title">\${escapeHtml(s.title)}</span>
            <span class="card-lang">\${s.language}</span>
          </div>
          <div class="card-tags">
            \${s.tags.map(t => \`<span class="tag">\${escapeHtml(t)}</span>\`).join('')}
          </div>
          <pre class="card-code">\${escapeHtml(truncate(s.code, 200))}</pre>
          <div class="card-meta">
            <span>使用 \${s.useCount} 次 · \${formatTime(s.createdAt)}</span>
            <div class="card-actions">
              <button class="btn btn-primary" onclick="insertSnippet('\${s.id}')">
                📎 插入
              </button>
              <button class="btn btn-ghost" onclick="copySnippet('\${s.id}')">
                📋 复制
              </button>
              <button class="btn btn-danger" onclick="deleteSnippet('\${s.id}')">
                🗑️
              </button>
            </div>
          </div>
        </div>
      \`).join('');
    }

    // 渲染过滤器
    function renderFilters() {
      const bar = document.getElementById('filterBar');
      const languages = [...new Set(allSnippets.map(s => s.language))];
      const allTags = [...new Set(allSnippets.flatMap(s => s.tags))];

      let html = '<button class="filter-chip ' + (activeFilter === 'all' ? 'active' : '') +
        '" onclick="setFilter(\'all\')">全部 (' + allSnippets.length + ')</button>';

      languages.forEach(lang => {
        const count = allSnippets.filter(s => s.language === lang).length;
        html += '<button class="filter-chip ' + (activeFilter === lang ? 'active' : '') +
          '" onclick="setFilter(\'' + lang + '\')">' + escapeHtml(lang) +
          ' (' + count + ')</button>';
      });

      bar.innerHTML = html;
    }

    // 更新统计
    function updateStats() {
      const total = allSnippets.length;
      const usage = allSnippets.reduce((sum, s) => sum + s.useCount, 0);
      document.getElementById('stats').textContent =
        total + ' 个片段 · 总计使用 ' + usage + ' 次';
    }

    // 搜索监听
    document.getElementById('searchInput').addEventListener('input', renderSnippets);

    // 操作函数
    function setFilter(filter) {
      activeFilter = filter;
      renderFilters();
      renderSnippets();
    }

    function insertSnippet(id) {
      acquireVsCodeApi().postMessage({ command: 'insertSnippet', id });
    }

    function copySnippet(id) {
      acquireVsCodeApi().postMessage({ command: 'copyToClipboard', id });
    }

    function deleteSnippet(id) {
      if (confirm('确定删除这个片段吗?')) {
        acquireVsCodeApi().postMessage({ command: 'deleteSnippet', id });
      }
    }

    // 工具函数
    function escapeHtml(str) {
      const div = document.createElement('div');
      div.textContent = str;
      return div.innerHTML;
    }

    function truncate(str, len) {
      return str.length > len ? str.substring(0, len) + '...' : str;
    }

    function formatTime(timestamp) {
      const d = new Date(timestamp);
      return d.toLocaleDateString('zh-CN', {
        month: 'short', day: 'numeric'
      });
    }
  </script>
</body>
</html>`;
  }

  /** 清理资源 */
  public dispose() {
    SnippetPanel.currentPanel = undefined;
    this._panel.dispose();
    while (this._disposables.length) {
      const x = this._disposables.pop();
      if (x) x.dispose();
    }
  }
}

/** 生成随机 nonce(安全策略需要) */
function getNonce(): string {
  let text = '';
  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  for (let i = 0; i < 32; i++) {
    text += possible.charAt(Math.floor(Math.random() * possible.length));
  }
  return text;
}

四、调试与测试

4.1 启动调试

VS Code 插件的调试体验非常出色:

  1. 按 F5 启动调试 --- 会自动打开一个新的 Extension Development Host 窗口
  2. 在新窗口中测试你的插件
  3. 在原窗口可以打断点、查看日志

4.2 调试配置解读

.vscode/launch.json 已经由脚手架自动生成:

json 复制代码
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}"
      ],
      "outFiles": ["${workspaceFolder}/out/**/*.js"],
      "preLaunchTask": "${defaultBuildTask}"
    }
  ]
}

4.3 编写单元测试

typescript 复制代码
// src/test/extension.test.ts
import * as assert from 'assert';
import * as vscode from 'vscode';

suite('Extension Test Suite', () => {
  test('插件应该正确激活', async () => {
    // 插件已在 launch.json 中配置为自动激活
    const ext = vscode.extensions.getExtension('your-publisher.code-snippet-manager');
    assert.ok(ext);
    await ext!.activate();
    assert.ok(ext!.isActive);
  });

  test('命令应该被注册', async () => {
    const commands = await vscode.commands.getCommands();
    assert.ok(commands.includes('snippetManager.addSnippet'));
    assert.ok(commands.includes('snippetManager.insertSnippet'));
    assert.ok(commands.includes('snippetManager.openPanel'));
  });
});

运行测试:

bash 复制代码
npm test

五、打包与发布

5.1 本地打包(.vsix 文件)

bash 复制代码
# 先编译
npm run compile

# 打包成 .vsix 文件
vsce package

# 生成的文件:
# code-snippet-manager-0.0.1.vsix

5.2 安装本地 .vsix 包

bash 复制代码
# 方式 1:命令行安装
code --install-extension code-snippet-manager-0.0.1.vsix

# 方式 2:VS Code GUI
# 扩展面板 → ... → 从 VSIX 安装

5.3 发布到 VS Code 插件市场

步骤 1:创建发布者账号

  1. 访问 https://marketplace.visualstudio.com/manage
  2. 点击 "Create publisher"
  3. 填写名称和个人访问令牌(PAT)

步骤 2:认证

bash 复制代码
vsce login your-publisher-name
# 会提示输入 PAT(个人访问令牌)

步骤 3:发布

bash 复制代码
vsce publish

发布成功后,其他用户就可以在 VS Code 扩展市场中搜索并安装你的插件了!

5.4 发布前检查清单

  • 插件图标(128x128 PNG)
  • README.md(包含截图和使用说明)
  • CHANGELOG.md(版本更新记录)
  • LICENSE 文件
  • package.json 中 publisher 字段已填写
  • 所有命令都有清晰的描述
  • 没有 console.log 残留(用 console.debug 替代)
  • 通过了 npm run lint 检查
  • 测试用例通过

六、进阶技巧

6.1 TreeDataProvider:侧边栏视图

除了 Webview 面板,还可以在侧边栏创建树形视图:

typescript 复制代码
// src/snippetTreeProvider.ts
import * as vscode from 'vscode';
import { Snippet } from './models';

export class SnippetTreeItem extends vscode.TreeItem {
  constructor(
    public readonly snippet: Snippet
  ) {
    super(snippet.title, vscode.TreeItemCollapsibleState.None);
    
    this.tooltip = `\`${snippet.code.substring(0, 100)}...\``;
    this.description = `${snippet.language} · ${snippet.useCount}次`;
    this.iconPath = new vscode.ThemeIcon('code');
    
    this.contextValue = 'snippet';
    this.command = {
      command: 'snippetManager.insertFromTree',
      title: '插入片段',
      arguments: [snippet.id]
    };
  }
}

export class SnippetTreeProvider implements vscode.TreeDataProvider<SnippetTreeItem> {
  private _onDidChangeTreeData = new vscode.EventEmitter<
    SnippetTreeItem | undefined
  >();
  readonly onDidChangeTreeData = this._onDidChangeTreeData.event;

  refresh(): void {
    this._onDidChangeTreeData.fire(undefined);
  }

  async getChildren(element?: SnippetTreeItem): Promise<SnippetTreeItem[]> {
    if (element) return []; // 无嵌套
    
    const snippets = await storage.getAllSnippets();
    return snippets
      .sort((a, b) => (b.lastUsedAt || 0) - (a.lastUsedAt || 0))
      .map(s => new SnippetTreeItem(s));
  }

  getTreeItem(element: SnippetTreeItem): vscode.TreeItem {
    return element;
  }
}

注册到 package.json 的 contributes 中:

json 复制代码
{
  "contributes": {
    "viewsContainers": {
      "activitybar": [{
        "id": "snippet-manager-container",
        "title": "Snippet Manager",
        "icon": "$(code-snippet)"
      }]
    },
    "views": {
      "snippet-manager-container": [{
        "type": "tree",
        "id": "snippetTreeView",
        "name": "我的片段",
        "treeDataProvider": "snippetTreeProvider",
        "icon": "$(files)"
      }]
    }
  }
}

6.2 设置项(Configuration)

让用户可以自定义插件行为:

json 复制代码
// package.json contributes.configuration
{
  "configuration": {
    "title": "Code Snippet Manager",
    "properties": {
      "snippetManager.maxSnippets": {
        "type": "number",
        "default": 500,
        "description": "最大片段数量限制"
      },
      "snippetManager.autoBackup": {
        "type": "boolean",
        "default": true,
        "description": "是否自动备份片段数据"
      },
      "snippetManager.backupInterval": {
        "type": "number",
        "default": 30,
        "description": "自动备份间隔(分钟)"
      },
      "snippetManager.defaultLanguage": {
        "type": "string",
        "default": "",
        "description": "默认编程语言(留空则自动检测)"
      }
    }
  }
}

在代码中读取设置:

typescript 复制代码
const config = vscode.workspace.getConfiguration('snippetManager');
const maxSnippets = config.get<number>('maxSnippets'); // 500
const autoBackup = config.get<boolean>('autoBackup');  // true

6.3 快捷键最佳实践

json 复制代码
// package.json contributes.keybindings
[
  {
    "command": "snippetManager.addSnippet",
    "key": "ctrl+shift+s",
    "mac": "cmd+shift+s",
    "when": "editorTextFocus && !editorReadonly"
  },
  {
    "command": "snippetManager.insertSnippet",
    "key": "ctrl+shift+i",
    "mac": "cmd+shift+i",
    "when": "editorTextFocus && !editorReadonly"
  },
  {
    "command": "snippetManager.openPanel",
    "key": "ctrl+shift+p",
    "mac": "cmd+shift+p",
    "when": "!inQuickOpen"
  }
]

快捷键设计原则:

  • 遵循平台惯例(Ctrl+S 保存,不要覆盖)
  • Mac 和 Windows 分别配置
  • 使用 when 条件限定触发场景
  • 高频操作用短组合键,低频操作用长组合键

6.4 性能优化要点

typescript 复制代码
// 1. 避免阻塞 UI 线程
async function heavyOperation() {
  // ✅ 正确:异步执行
  await vscode.window.withProgress(
    { location: vscode.ProgressLocation.Notification, title: '处理中...' },
    async (progress) => {
      for (let i = 0; i < 100; i++) {
        progress.report({ increment: 1, message: `${i}%` });
        await doChunk(i);  // 分批处理
      }
    }
  );
}

// 2. 合理使用 GlobalState(避免存大量数据)
// ✅ 正确:只存必要数据
await context.globalState.update('key', compactData);

// ❌ 错误:存大文本或二进制
await context.globalState.update('bigData', hugeString);

// 3. 及时清理 Disposable
class MyFeature implements vscode.Disposable {
  private disposables: vscode.Disposable[] = [];

  activate() {
    const disposable = vscode.commands.registerCommand(...);
    this.disposables.push(disposable);
  }

  dispose() {
    this.disposables.forEach(d => d.dispose());
  }
}

七、完整项目文件清单

最终的项目结构:

复制代码
code-snippet-manager/
├── .vscode/
│   ├── launch.json          # 调试配置
│   ├── tasks.json           # 构建任务
│   └── extensions.json       # 推荐扩展
├── src/
│   ├── models.ts            # 数据模型定义
│   ├── storage.ts           # 存储层(CRUD + 搜索)
│   ├── extension.ts         # 插件入口 + 命令注册
│   ├── snippetPanel.ts      # Webview 管理面板
│   ├── snippetTreeProvider.ts # 侧边栏树形视图
│   └── test/
│       └── extension.test.ts # 单元测试
├── images/
│   └── icon.png             # 插件图标 (128x128)
├── package.json             # 插件清单 ⭐
├── tsconfig.json
├── .eslintrc.json
├── README.md                # 使用文档
├── CHANGELOG.md             # 更新记录
├── LICENSE                  # 许可证
└── .gitignore

八、常见问题 FAQ

Q1: Webview 中的样式和 VS Code 主题不协调?

使用 VS Code 的 CSS 变量来适配主题:

css 复制代码
color: var(--vscode-editor-foreground);
background: var(--vscode-editor-background);

这样你的插件会自动跟随用户选择的亮色/暗色主题。

Q2: 插件激活太慢怎么办?

检查 activationEvents 是否合理:

  • 只在需要时才激活(如 onCommand:xxx
  • 避免使用 *(启动即激活)
  • 将耗时操作延迟到第一次调用时执行

Q3: 如何支持多语言(i18n)?

使用 VS Code 的 package.nls.json 机制:

json 复制代码
// package.json
{ "title": "%extension.title%" }

// package.nls.json(中文)
{ "extension.title": "代码片段管理器" }

// package.nls.en.json(英文)
{ "extension.title": "Code Snippet Manager" }

Q4: GlobalState 有大小限制吗?

有,约 1MB。如果数据量大,建议:

  1. 用文件系统存储(vscode.workspace.fs
  2. 或压缩后再存储(gzip)

Q5: 如何让插件支持 Remote Development(SSH / WSL / Container)?

确保你的插件不依赖本地文件路径,使用 vscode.Urivscode.workspace.fs 进行文件操作即可。Remote 场景下这些 API 会自动代理到远程。


总结

你学到了什么

技能 掌握程度
VS Code 插件项目搭建 ✅ 脚手架 + 目录结构
package.json 清单配置 ✅ commands / keybindings / menus / views
Extension API 使用 ✅ 命令注册 / 存储 / 编辑器操作
Webview 开发 ✅ HTML/CSS/JS + 消息通信
TreeDataProvider ✅ 侧边栏树形视图
调试与测试 ✅ F5 调试 + 单元测试
打包与发布 ✅ vsce 打包 + 市场发布

下一步学习方向

  1. Language Server Protocol (LSP) ------ 开发语言支持插件(语法高亮、智能补全、错误检查)
  2. Debug Adapter Protocol (DAP) ------ 开发调试器插件
  3. Source Control API ------ 开发版本控制集成
  4. Notebook API ------ 开发 Jupyter Notebook 类似的交互式文档

一个建议

从小处着手,快速迭代。 不要试图一次性做一个完美的插件------先做一个最小可用版本(MVP),发布收集反馈,然后持续改进。本文的代码片段管理器就是一个很好的起点:核心功能只有 3 个命令,但已经足够实用。


本文基于 VS Code 1.85+ 版本编写。如有问题欢迎评论区讨论。

相关推荐
Gene_202214 小时前
ubuntu22.04在vscode使用codex
ide·vscode·编辑器
lqqjuly18 小时前
vscode+remote-ssh+claude code+mimo-v2.5配置
ide·vscode·编辑器
MrXun_1 天前
vscode中同时连接多个远程并同时登录使用codex
ide·vscode·编辑器·codex
李白的天不白1 天前
VSCODE 配置文件的方法
ide·vscode·编辑器
菜鸟是大神1 天前
03-替换DeepSeek模型和VSCode中的使用
ide·vscode·编辑器
2501_915921431 天前
Xcode与iOS SDK完整教程:从下载安装到配置优化全解析
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
bestlanzi2 天前
vscode 常用的配置内容
ide·vscode·编辑器
jinglong.zha2 天前
别再只用命令行!Claude Code接入VSCode和PyCharm,这些技巧让你爽到飞起!
ide·vscode·pycharm·claude code
kkoral2 天前
Vue3 图片标框功能实现方案
前端·vue.js·vscode·typescript