前言
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 插件的调试体验非常出色:
- 按 F5 启动调试 --- 会自动打开一个新的 Extension Development Host 窗口
- 在新窗口中测试你的插件
- 在原窗口可以打断点、查看日志
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:创建发布者账号
- 访问 https://marketplace.visualstudio.com/manage
- 点击 "Create publisher"
- 填写名称和个人访问令牌(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 变量来适配主题:
csscolor: 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。如果数据量大,建议:
- 用文件系统存储(
vscode.workspace.fs)- 或压缩后再存储(gzip)
Q5: 如何让插件支持 Remote Development(SSH / WSL / Container)?
确保你的插件不依赖本地文件路径,使用
vscode.Uri和vscode.workspace.fs进行文件操作即可。Remote 场景下这些 API 会自动代理到远程。
总结
你学到了什么
| 技能 | 掌握程度 |
|---|---|
| VS Code 插件项目搭建 | ✅ 脚手架 + 目录结构 |
| package.json 清单配置 | ✅ commands / keybindings / menus / views |
| Extension API 使用 | ✅ 命令注册 / 存储 / 编辑器操作 |
| Webview 开发 | ✅ HTML/CSS/JS + 消息通信 |
| TreeDataProvider | ✅ 侧边栏树形视图 |
| 调试与测试 | ✅ F5 调试 + 单元测试 |
| 打包与发布 | ✅ vsce 打包 + 市场发布 |
下一步学习方向
- Language Server Protocol (LSP) ------ 开发语言支持插件(语法高亮、智能补全、错误检查)
- Debug Adapter Protocol (DAP) ------ 开发调试器插件
- Source Control API ------ 开发版本控制集成
- Notebook API ------ 开发 Jupyter Notebook 类似的交互式文档
一个建议
从小处着手,快速迭代。 不要试图一次性做一个完美的插件------先做一个最小可用版本(MVP),发布收集反馈,然后持续改进。本文的代码片段管理器就是一个很好的起点:核心功能只有 3 个命令,但已经足够实用。
本文基于 VS Code 1.85+ 版本编写。如有问题欢迎评论区讨论。