VSCode 插件开发(TreeView、WebView、Language Server)
Visual Studio Code 是微软推出的轻量级但功能强大的代码编辑器,其核心能力通过扩展(Extension)机制提供。开发者可以通过开发 VSCode 扩展来增强编辑器的功能,满足特定语言、框架或工作流的定制需求。本文档系统介绍 VSCode 扩展开发的核心概念与实践方法,涵盖基础入门、命令与菜单、TreeView、WebView 以及 Language Server Protocol(LSP)等主题。
1. VSCode 扩展入门
1.1 开发环境搭建
1.1.1 基础工具链
开发 VSCode 扩展需要以下工具:
- Node.js(>= 18.x):扩展运行时的 JavaScript 引擎。
- Yeoman 和 generator-code:用于脚手架生成扩展项目的模板工具。
- Visual Studio Code:开发与调试的主 IDE。
- Extension Development Host(扩展开发宿主):VSCode 内置的独立窗口,用于加载和调试正在开发的扩展。
安装命令如下:
npm install -g yo generator-code1.1.2 创建扩展项目
使用 Yeoman 脚手架创建新项目:
yo code交互式命令行会引导选择扩展类型:
- New Extension (TypeScript):最常用的模板,使用 TypeScript 开发。
- New Extension (JavaScript):纯 JavaScript 模板。
- New Color Theme:创建颜色主题。
- New Language Support:创建语言语法高亮(TextMate 语法)。
- New Code Snippets:创建代码片段。
- New Keymap:创建快捷键绑定。
- New Extension Pack:创建扩展包(将多个扩展打包发布)。
- New Web Extension:创建 Web 端扩展(用于 vscode.dev)。
选择 New Extension (TypeScript) 后,项目目录结构如下:
my-extension/
├── .vscode/
│ ├── launch.json # 调试配置
│ └── tasks.json # 构建任务
├── src/
│ └── extension.ts # 扩展入口文件
├── package.json # 扩展清单文件
├── tsconfig.json # TypeScript 配置
└── README.md # 扩展说明文档1.1.3 运行与调试
在 VSCode 中打开项目目录,按 F5 键启动调试。这会打开一个新的 Extension Development Host 窗口,其中已加载当前扩展。在该窗口中:
- 可以通过
Ctrl+Shift+P打开命令面板,执行扩展注册的命令。 - 在原始窗口中设置断点,进行源码级调试。
- 修改源码后,在调试工具栏点击重启按钮重新加载扩展。
.vscode/launch.json 的核心配置:
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/out/**/*.js"
],
"preLaunchTask": "${defaultBuildTask}"
}
]
}1.2 package.json 配置
package.json 是 VSCode 扩展的清单文件,定义了扩展的所有元数据和能力声明。核心字段如下:
{
"name": "my-extension",
"displayName": "My Extension",
"version": "0.0.1",
"description": "A sample VSCode extension",
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Programming Languages",
"Snippets",
"Themes",
"Other"
],
"icon": "icon.png",
"activationEvents": [
"onCommand:myExtension.helloWorld",
"onLanguage:javascript",
"onView:myTreeView",
"onStartupFinished"
],
"main": "./out/extension.js",
"contributes": {
"commands": [],
"menus": {},
"keybindings": [],
"views": {},
"viewsContainers": {}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./"
},
"devDependencies": {
"@types/vscode": "^1.85.0",
"typescript": "^5.3.0"
}
}1.2.1 关键字段说明
| 字段 | 说明 |
|---|---|
engines.vscode | 声明兼容的 VSCode 最低版本。扩展使用的高版本 API 会拉高此要求。 |
activationEvents | 扩展的激活事件列表,标明何时激活扩展。 |
main | 扩展的入口 JavaScript 文件(由 TypeScript 编译产出)。 |
categories | 扩展在市场上的分类,可选值包括:Programming Languages、Snippets、Themes、Debuggers、Formatters、Keymaps、Linters、Notebooks、Language Packs、Extension Packs、Data Science、Machine Learning、Visualization、Testing、Other。 |
icon | 扩展在市场上的图标文件路径(建议 128x128 像素,PNG 格式)。 |
1.2.2 activationEvents 详解
activationEvents 决定扩展何时被激活(加载)。VSCode 采用懒加载策略,只有在需要时才激活扩展,以保持编辑器启动速度。
常用激活事件:
| 事件 | 含义 |
|---|---|
onCommand:<commandId> | 当执行指定命令时激活 |
onLanguage:<languageId> | 当打开指定语言的文件时激活 |
onView:<viewId> | 当指定视图可见时激活 |
onStartupFinished | 编辑器启动完成后激活(适用于无需立即激活的后台扩展) |
* | 编辑器启动时立即激活(谨慎使用,影响启动性能) |
onFileSystem:<scheme> | 当访问指定文件系统时激活 |
onUri | 当扩展注册的 URI 被打开时激活 |
onCustomEditor:<viewType> | 当自定义编辑器打开时激活 |
onWebviewPanel:<viewType> | 当 WebView 面板创建时激活 |
从 VSCode 1.74 起,也可以通过 activationEvents 字段的别名简化配置:
{
"activationEvents": [
"onLanguage:json",
"onLanguage:markdown"
]
}1.3 扩展类型
VSCode 扩展按能力可分为以下几类:
1.3.1 主题扩展(Theme)
提供编辑器颜色主题和文件图标主题。通过 contributes.themes 和 contributes.iconThemes 声明。
{
"contributes": {
"themes": [
{
"label": "My Dark Theme",
"uiTheme": "vs-dark",
"path": "./themes/my-dark-theme.json"
}
],
"iconThemes": [
{
"id": "my-icons",
"label": "My Icons",
"path": "./icons/my-icon-theme.json"
}
]
}
}1.3.2 语言扩展(Language)
提供语法高亮、代码片段、括号匹配等语言支持。通过 contributes.languages、contributes.grammars 和 contributes.snippets 声明。
{
"contributes": {
"languages": [
{
"id": "mylang",
"extensions": [".my"],
"aliases": ["My Language", "mylang"],
"configuration": "./language-configuration.json"
}
],
"grammars": [
{
"language": "mylang",
"scopeName": "source.my",
"path": "./syntaxes/mylang.tmLanguage.json"
}
],
"snippets": [
{
"language": "mylang",
"path": "./snippets/mylang.code-snippets"
}
]
}
}snippets/mylang.code-snippets 示例:
{
"For Loop": {
"prefix": "for",
"body": [
"for (let ${1:i} = 0; ${1:i} < ${2:n}; ${1:i}++) {",
"\t${0:}",
"}"
],
"description": "For Loop"
}
}1.3.3 命令扩展(Command)
注册可执行命令,通过 contributes.commands 声明并在代码中注册实现。这是最基础的扩展类型,也是 TreeView、WebView 等其他类型的基础。
1.3.4 TreeView 扩展
提供自定义的树形数据视图,显示在 VSCode 的活动栏(Activity Bar)或侧边栏(Side Bar)中。通过 TreeDataProvider 提供数据。
1.3.5 WebView 扩展
在 VSCode 中嵌入完整的 Web 页面,使用 HTML/CSS/JavaScript 构建复杂的自定义 UI。WebView 与扩展宿主之间通过消息机制通信。
1.3.6 Language Server 扩展
基于 Language Server Protocol(LSP)提供智能代码编辑功能,如自动补全、跳转定义、悬停提示、诊断错误等。客户端与语言服务器通过 JSON-RPC 协议通信。
2. 命令与菜单
2.1 命令注册
命令是 VSCode 扩展的核心交互方式。扩展通过 vscode.commands.registerCommand API 注册命令处理函数,并通过 contributes.commands 在 package.json 中声明命令的元数据。
2.1.1 注册命令
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
// 注册命令,返回 Disposable 对象
const disposable = vscode.commands.registerCommand(
'myExtension.helloWorld', // 命令 ID(全局唯一)
() => { // 命令处理函数
vscode.window.showInformationMessage('Hello World from myExtension!');
}
);
// 将 disposable 加入 context.subscriptions 以便在扩展停用时自动清理
context.subscriptions.push(disposable);
}
export function deactivate() {}2.1.2 命令 ID 规范
命令 ID 的推荐命名规则为 <扩展名>.<具体命令>,使用小写驼峰(camelCase)风格,多个单词用点号分隔。例如:
git.commiteditor.action.addCommentLinemyExtension.refreshTree
2.1.3 带参数的命令
命令处理器可以接收任意参数。参数通常由调用方传递:
// 注册带参命令
vscode.commands.registerCommand('myExtension.logMessage', (message: string, level: number) => {
if (level >= 2) {
console.warn(message);
} else {
console.log(message);
}
});
// 命令调用方传参
vscode.commands.executeCommand('myExtension.logMessage', 'Something happened', 1);
// 在 package.json 中声明命令
// "contributes": {
// "commands": [{
// "command": "myExtension.logMessage",
// "title": "Log Message",
// "category": "My Extension"
// }]
// }2.2 contributes.commands
在 package.json 的 contributes.commands 中声明命令的元数据。声明的命令会自动出现在命令面板中。
{
"contributes": {
"commands": [
{
"command": "myExtension.helloWorld",
"title": "Say Hello",
"category": "My Extension",
"icon": {
"light": "icons/light/hello.svg",
"dark": "icons/dark/hello.svg"
}
},
{
"command": "myExtension.refreshTree",
"title": "Refresh Tree",
"category": "My Extension",
"icon": "$(refresh)"
}
]
}
}字段说明:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
command | string | 是 | 命令的唯一标识符 |
title | string | 是 | 命令在 UI 中显示的名称 |
category | string | 否 | 命令的分类前缀,显示为 "category: title" |
icon | object/string | 否 | 命令图标,可使用文件路径或 $(iconId) 引用 Product Icon |
enablement | string | 否 | when 条件表达式,控制命令是否可用 |
shortTitle | string | 否 | 在右键菜单等空间受限处的短标题 |
2.3 contributes.menus
通过 contributes.menus 将命令绑定到 VSCode 的各个菜单位置。
{
"contributes": {
"menus": {
"editor/title": [
{
"command": "myExtension.formatJson",
"group": "navigation@1",
"when": "resourceLang == json"
}
],
"explorer/context": [
{
"command": "myExtension.openWithCustomEditor",
"group": "myGroup@2",
"when": "resourceFilename =~ /\\.md$/"
}
],
"editor/context": [
{
"command": "myExtension.showDefinition",
"group": "navigation",
"when": "editorHasSelection"
}
],
"commandPalette": [
{
"command": "myExtension.helloWorld",
"when": "true"
}
],
"view/title": [
{
"command": "myExtension.refreshTree",
"group": "navigation",
"when": "view == myTreeView"
}
],
"view/item/context": [
{
"command": "myExtension.deleteItem",
"group": "inline",
"when": "view == myTreeView && viewItem == fileItem"
}
]
}
}
}2.3.1 菜单位置
| 菜单位置 | 说明 |
|---|---|
editor/title | 编辑器标题区域(面包屑右侧的工具栏) |
editor/context | 编辑器内的右键菜单 |
explorer/context | 文件资源管理器的右键菜单 |
editor/title/context | 编辑器标签页的右键菜单 |
commandPalette | 命令面板(不声明 when 则默认可见) |
view/title | 自定义视图的标题栏 |
view/item/context | 自定义视图内项目的右键菜单 |
scm/title | 源代码管理视图标题栏 |
scm/resourceGroup/context | SCM 资源组右键菜单 |
scm/resourceState/context | SCM 资源状态右键菜单 |
touchBar | Touch Bar(macOS) |
2.3.2 菜单项属性
| 属性 | 说明 |
|---|---|
command | 要执行的命令 ID |
group | 菜单位置分组及排序。内置分组有 navigation(始终可见)、1_modification、9_cutcopypaste、z_commands 等。可使用 @ 指定组内排序位置。 |
when | when 条件表达式,控制菜单项是否可见。 |
submenu | 指定子菜单 ID(需要同时声明 contributes.submenus)。 |
alt | 按住 Alt 键时替代执行的命令。 |
2.3.3 when 条件表达式
when 条件是 VSCode 中控制 UI 可见性的上下文表达式引擎。支持以下语法:
基础运算符:
| 运算符 | 示例 | 说明 |
|---|---|---|
== | resourceLang == javascript | 等于 |
!= | resourceLang != json | 不等于 |
=~ | resourceFilename =~ /\\.ts$/ | 正则匹配 |
!~ | resourceFilename !~ /\\.d\\.ts$/ | 正则不匹配 |
&& | isFile && resourceExtname == .ts | 逻辑与 |
| ` | ` | |
! | !isFile | 逻辑非 |
in | resourceScheme in {file, untitled} | 集合包含 |
< > <= >= | editorLineNumber > 100 | 数值比较 |
常用上下文键:
| 键 | 类型 | 说明 |
|---|---|---|
resourceLang | string | 当前文件的语言标识 |
resourceFilename | string | 当前文件的文件名 |
resourceExtname | string | 当前文件的扩展名(含点号) |
resourceScheme | string | 当前文件的 URI scheme(file, untitled 等) |
isFile | boolean | 当前资源是否为文件 |
isDirectory | boolean | 当前资源是否为目录 |
editorHasSelection | boolean | 编辑器中是否有选中文本 |
editorLangId | string | 当前激活编辑器的语言 |
view | string | 当前激活的视图 ID |
viewItem | string | 视图项 contextValue(用于自定义 TreeView 右键菜单) |
config.* | 任意 | 访问 VSCode 配置项,如 config.editor.fontSize |
workspaceFolderCount | number | 工作区文件夹数量 |
isLinux / isMac / isWindows | boolean | 操作系统判断 |
2.4 快捷键绑定
快捷键绑定有两种方式:通过 VSCode 的 keybindings.json(用户手动设置)和通过扩展的 contributes.keybindings(扩展声明默认绑定)。
2.4.1 扩展声明快捷键
{
"contributes": {
"keybindings": [
{
"command": "myExtension.helloWorld",
"key": "ctrl+shift+h",
"mac": "cmd+shift+h",
"linux": "ctrl+shift+h",
"when": "editorTextFocus"
},
{
"command": "myExtension.formatJson",
"key": "ctrl+shift+j",
"when": "resourceLang == json"
}
]
}
}字段说明:
| 字段 | 必填 | 说明 |
|---|---|---|
command | 是 | 绑定的命令 ID |
key | 是 | 默认按键组合(跨平台) |
mac | 否 | macOS 专用按键组合 |
linux | 否 | Linux 专用按键组合 |
win | 否 | Windows 专用按键组合 |
when | 否 | 快捷键生效的条件表达式 |
args | 否 | 传递给命令的参数 |
按键组合格式:
按键顺序用空格分隔,组合键用 + 连接。例如:
ctrl+shift+pctrl+k ctrl+f(Chording:先按ctrl+k,再按ctrl+f)alt+cmd+r(macOS)
可用修饰键:ctrl、shift、alt、cmd(macOS)、meta。
2.4.2 用户快捷键设置
用户可以通过 keybindings.json(命令面板搜索 "Open Keyboard Shortcuts (JSON)")覆盖或新增快捷键:
[
{
"key": "ctrl+alt+h",
"command": "myExtension.helloWorld",
"when": "editorTextFocus"
},
{
"key": "ctrl+shift+h",
"command": "-myExtension.helloWorld"
}
]-myExtension.helloWorld 表示取消该命令的现有快捷键绑定。
3. TreeView 开发
TreeView(树形视图)是 VSCode 中最常用的自定义视图类型之一。它允许扩展在侧边栏中以树形结构展示自定义数据,并支持展开/折叠、右键菜单、拖拽等交互。
3.1 TreeDataProvider
TreeDataProvider 是 TreeView 的数据提供接口,扩展需要实现该接口来向视图注入数据。
import * as vscode from 'vscode';
import * as path from 'path';
// 定义树节点类
class MyTreeNode {
constructor(
public readonly label: string,
public readonly collapsibleState: vscode.TreeItemCollapsibleState,
public readonly children?: MyTreeNode[],
public readonly command?: vscode.Command
) {}
// 将节点转换为 TreeItem
toTreeItem(): vscode.TreeItem {
const item = new vscode.TreeItem(this.label, this.collapsibleState);
item.description = `Node: ${this.label}`;
item.tooltip = `This is ${this.label}`;
item.contextValue = this.children ? 'folder' : 'file';
item.iconPath = this.children
? new vscode.ThemeIcon('folder')
: new vscode.ThemeIcon('file');
if (this.command) {
item.command = this.command;
}
return item;
}
}
// 实现 TreeDataProvider
class MyTreeDataProvider implements vscode.TreeDataProvider<MyTreeNode> {
// 用于通知视图数据变更的事件
private _onDidChangeTreeData: vscode.EventEmitter<MyTreeNode | undefined | null | void> =
new vscode.EventEmitter<MyTreeNode | undefined | null | void>();
readonly onDidChangeTreeData: vscode.Event<MyTreeNode | undefined | null | void> =
this._onDidChangeTreeData.event;
private data: MyTreeNode[] = [];
constructor() {
this.initializeData();
}
private initializeData() {
this.data = [
new MyTreeNode('Root 1', vscode.TreeItemCollapsibleState.Collapsed, [
new MyTreeNode('Child 1', vscode.TreeItemCollapsibleState.None, undefined, {
command: 'myExtension.openChild',
title: '',
arguments: ['Child 1']
}),
new MyTreeNode('Child 2', vscode.TreeItemCollapsibleState.None)
]),
new MyTreeNode('Root 2', vscode.TreeItemCollapsibleState.None)
];
}
// 刷新数据
refresh(): void {
this.initializeData();
this._onDidChangeTreeData.fire();
}
// 获取节点的 TreeItem 表示
getTreeItem(element: MyTreeNode): vscode.TreeItem {
return element.toTreeItem();
}
// 获取子节点
getChildren(element?: MyTreeNode): MyTreeNode[] {
if (element === undefined) {
// 返回根节点
return this.data;
}
return element.children ?? [];
}
// 获取父节点(用于 reveal 功能)
getParent?(element: MyTreeNode): MyTreeNode | undefined {
for (const root of this.data) {
if (root.children?.includes(element)) {
return root;
}
}
return undefined;
}
}3.1.1 onDidChangeTreeData 事件
onDidChangeTreeData 是 TreeView 的核心事件机制。当数据发生变化时,通过 _onDidChangeTreeData.fire() 通知视图重新渲染。
// 刷新整棵树
refresh(): void {
this._onDidChangeTreeData.fire();
}
// 刷新指定节点(VSCode 1.67+ 支持传入节点刷新特定项)
refreshNode(node: MyTreeNode): void {
this._onDidChangeTreeData.fire(node);
}3.2 TreeItem
TreeItem 是树视图中每个可显示项的表现形式。TreeDataProvider.getTreeItem() 返回 TreeItem 实例。
// 创建 TreeItem 的完整示例
const item = new vscode.TreeItem('My Label', vscode.TreeItemCollapsibleState.Collapsed);
// 显示文本
item.label = '显示名称'; // 支持 string 或 vscode.TreeItemLabel
item.description = '描述文本'; // 灰色的次要文本
item.tooltip = '鼠标悬停提示'; // 支持 string 或 MarkdownString
// 图标
item.iconPath = vscode.ThemeIcon.File; // 内置主题图标
item.iconPath = new vscode.ThemeIcon('star'); // 通过 ID 引用 Product Icon
item.iconPath = {
light: path.join(__dirname, '..', 'icons', 'light.svg'), // 亮色主题图标
dark: path.join(__dirname, '..', 'icons', 'dark.svg') // 暗色主题图标
};
// 交互状态
item.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed;
// CollapsibleState.None - 不可展开的叶子节点
// CollapsibleState.Collapsed - 可展开,当前收起
// CollapsibleState.Expanded - 可展开,当前展开
// 上下文值(用于 when 表达式和右键菜单匹配)
item.contextValue = 'myFileItem'; // 与 viewItem == myFileItem 匹配
// 命令(点击时执行)
item.command = {
command: 'myExtension.openItem',
title: 'Open',
arguments: [item]
};完整字段清单:
| 字段 | 类型 | 说明 |
|---|---|---|
label | string | TreeItemLabel | 主文本标签。TreeItemLabel 支持高亮部分文本。 |
description | string | 副文本,显示在 label 右侧。 |
tooltip | string | MarkdownString | 鼠标悬停提示,支持 Markdown。 |
iconPath | Uri | ThemeIcon | | 节点图标。 |
collapsibleState | TreeItemCollapsibleState | 展开/折叠状态。 |
command | Command | 点击节点时执行的命令。 |
contextValue | string | 上下文标识,用于菜单 when 条件匹配。 |
resourceUri | Uri | 关联的文件 URI,用于拖拽等操作。 |
checkboxState | TreeItemCheckboxState | | 复选框状态(VSCode 1.68+)。 |
id | string | 唯一标识符,用于视图状态持久化。 |
accessibilityInformation | AccessibilityInformation | 无障碍信息。 |
3.3 自定义视图注册
在 package.json 中声明自定义视图和视图容器,然后在扩展代码中注册 TreeDataProvider。
3.3.1 package.json 配置
{
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "myExtension",
"title": "My Extension",
"icon": "icons/extension-icon.svg"
}
]
},
"views": {
"myExtension": [
{
"id": "myTreeView",
"name": "My Tree View",
"when": "config.myExtension.enableTreeView",
"type": "tree",
"icon": "icons/view-icon.svg",
"contextualTitle": "My Custom View"
}
]
}
}
}viewsContainers 位置:
| 位置 | 说明 |
|---|---|
activitybar | 活动栏(左侧图标栏),添加新的 Tab |
panel | 面板区域(底部) |
views 属性:
| 属性 | 必填 | 说明 |
|---|---|---|
id | 是 | 视图唯一标识符 |
name | 是 | 视图显示名称 |
when | 否 | 何时显示此视图的条件表达式 |
type | 否 | 视图类型:tree(默认)或 webview |
icon | 否 | 视图图标(仅 activitybar 容器内的视图) |
contextualTitle | 否 | 视图的上下文标题,显示在视图标题栏 |
3.3.2 注册 provider
在扩展入口代码中注册 TreeDataProvider:
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
// 创建 provider 实例
const treeDataProvider = new MyTreeDataProvider();
// 注册 provider(方式一):使用 window.registerTreeDataProvider
// 视图自动与 ID 为 myTreeView 的视图关联
context.subscriptions.push(
vscode.window.registerTreeDataProvider('myTreeView', treeDataProvider)
);
// 注册 provider(方式二):使用 window.createTreeView,可获取 TreeView 实例
const treeView = vscode.window.createTreeView('myTreeView', {
treeDataProvider: treeDataProvider,
showCollapseAll: true, // 显示折叠全部按钮
canSelectMany: false, // 是否允许多选
dragAndDropController: undefined // 拖拽控制器(可选)
});
context.subscriptions.push(treeView);
// 注册刷新命令
context.subscriptions.push(
vscode.commands.registerCommand('myExtension.refreshTree', () => {
treeDataProvider.refresh();
})
);
// 注册节点点击命令
context.subscriptions.push(
vscode.commands.registerCommand('myExtension.openItem', (node: MyTreeNode) => {
vscode.window.showInformationMessage(`Clicked: ${node.label}`);
})
);
}window.createTreeView 选项:
interface CreateTreeViewOptions<T> {
treeDataProvider: TreeDataProvider<T>;
showCollapseAll?: boolean; // 是否在标题栏显示折叠全部按钮
canSelectMany?: boolean; // 是否支持多选(Ctrl+Click / Shift+Click)
dragAndDropController?: TreeDragAndDropController<T>; // 拖拽控制器
}3.3.3 TreeView 实例方法
window.createTreeView 返回的 TreeView 实例提供以下方法:
// 选中指定节点并滚动到可见位置
treeView.reveal(node, {
select: true, // 是否选中该节点
focus: true, // 是否聚焦到视图
expand: true // 是否展开父节点(默认为 true)
});
// 获取当前选中的节点
const selection = treeView.selection; // 类型为 readonly T[]
// 获取当前可见的节点
const visible = treeView.visible; // boolean
// 监听选中变化
treeView.onDidChangeSelection(e => {
// e.selection: readonly T[]
});
// 监听可见性变化
treeView.onDidChangeVisibility(e => {
// e.visible: boolean
});
// 消息传递(VSCode 1.67+,用于 webview view)
treeView.onDidChangeCheckboxState(e => {
// e.items: readonly [T, TreeItemCheckboxState][]
});3.4 右键菜单
自定义 TreeView 节点的右键菜单通过 view/item/context 菜单位置声明,并通过节点的 contextValue 匹配。
{
"contributes": {
"menus": {
"view/item/context": [
{
"command": "myExtension.renameItem",
"group": "inline",
"when": "view == myTreeView && viewItem == folder"
},
{
"command": "myExtension.deleteItem",
"when": "view == myTreeView && viewItem =~ /file|folder/"
},
{
"command": "myExtension.copyPath",
"group": "2_copypaste",
"when": "view == myTreeView && viewItem == fileItem"
}
]
}
}
}匹配逻辑:
viewItem == folder:只匹配contextValue精确等于folder的节点。viewItem =~ /file|folder/:匹配contextValue匹配正则file或folder的节点。- 不设置
when中的viewItem条件,则所有节点均显示该菜单项。
右键菜单分组(group)规范:
| group 值 | 说明 |
|---|---|
inline | 行内图标(在节点右侧显示图标按钮) |
navigation | 导航操作(顶部) |
1_modification | 修改操作 |
2_copypaste | 复制粘贴操作 |
3_cutcopypaste | 剪切板操作 |
7_operations | 常规操作 |
9_commands | 其他命令 |
z_commands | 底部命令 |
4. WebView 开发
WebView 允许扩展在 VSCode 中嵌入完整的 Web 页面,使用 HTML、CSS 和 JavaScript 构建复杂的自定义用户界面。WebView 可以显示在编辑器区域(作为编辑器标签页)或侧边栏(作为视图)。
4.1 WebView 创建
使用 vscode.window.createWebviewPanel 创建 WebView 面板:
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand('myExtension.openWebview', () => {
// 创建 WebView 面板
const panel = vscode.window.createWebviewPanel(
'myWebview', // viewType:面板类型标识符(需唯一)
'My WebView', // title:面板标题
vscode.ViewColumn.One, // showOptions:显示位置
{
enableScripts: true, // 允许执行 JavaScript
retainContextWhenHidden: true, // 隐藏时保留上下文(不重新加载)
localResourceRoots: [ // 可加载的本地资源路径白名单
vscode.Uri.joinPath(context.extensionUri, 'media'),
vscode.Uri.joinPath(context.extensionUri, 'dist')
]
}
);
// 设置 HTML 内容
panel.webview.html = getWebviewContent(panel.webview, context.extensionUri);
// 监听面板关闭事件
panel.onDidDispose(() => {
console.log('WebView panel disposed');
}, null, context.subscriptions);
// 恢复时(如面板被隐藏后重新显示)恢复状态
panel.onDidChangeViewState(e => {
if (panel.visible) {
console.log('WebView is now visible');
}
});
})
);
}createWebviewPanel 参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
viewType | string | 面板类型标识,全局唯一。建议格式:<扩展名>.<类型> |
title | string | 面板标题 |
showOptions | ViewColumn | | 显示位置 |
options | WebviewPanelOptions & WebviewOptions | WebView 配置选项 |
WebView 选项:
| 选项 | 类型 | 说明 |
|---|---|---|
enableScripts | boolean | 是否启用 JavaScript,默认 false |
enableForms | boolean | 是否启用表单提交,默认 false |
enableCommandUris | boolean | 是否允许 command: URI 链接 |
retainContextWhenHidden | boolean | 面板隐藏时是否保留 WebView 状态(不重新加载页面) |
localResourceRoots | Uri[] | 允许 WebView 加载的本地资源目录白名单 |
4.2 WebView 与扩展通信
WebView 与扩展宿主之间通过消息机制进行双向通信。
4.2.1 扩展发送消息到 WebView
// 扩展端:发送消息到 WebView
panel.webview.postMessage({
type: 'update',
data: { content: 'Hello from extension!' }
});4.2.2 扩展接收 WebView 消息
// 扩展端:接收来自 WebView 的消息
panel.webview.onDidReceiveMessage(
message => {
switch (message.type) {
case 'alert':
vscode.window.showInformationMessage(message.text);
break;
case 'openFile':
vscode.commands.executeCommand('vscode.open', vscode.Uri.file(message.path));
break;
case 'getData':
// 回复消息
panel.webview.postMessage({
type: 'dataResponse',
data: { items: ['a', 'b', 'c'] }
});
break;
}
},
undefined,
context.subscriptions
);4.2.3 WebView 端消息通信
WebView 中的 HTML 页面通过 acquireVsCodeApi() 获取 vscode API 对象进行通信:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My WebView</title>
</head>
<body>
<h1 id="message">Waiting...</h1>
<button id="btnSend">Send Message</button>
<script>
(function() {
// 获取 VS Code API 对象
const vscode = acquireVsCodeApi();
// 保存上一次的状态(用于 retainContextWhenHidden)
const oldState = vscode.getState();
if (oldState) {
document.getElementById('message').textContent = oldState.message;
}
// 接收来自扩展的消息
window.addEventListener('message', event => {
const message = event.data;
switch (message.type) {
case 'update':
document.getElementById('message').textContent = message.data.content;
// 保存状态
vscode.setState({ message: message.data.content });
break;
}
});
// 向扩展发送消息
document.getElementById('btnSend').addEventListener('click', () => {
vscode.postMessage({
type: 'alert',
text: 'Hello from WebView!'
});
});
})();
</script>
</body>
</html>acquireVsCodeApi() 返回值的方法:
| 方法 | 说明 |
|---|---|
postMessage(message) | 向扩展宿主发送消息 |
getState() | 获取持久化状态 |
setState(state) | 持久化状态(页面刷新后仍可恢复) |
4.3 WebView 安全
WebView 运行在独立的安全上下文中,默认限制访问外部资源。需要正确配置安全策略。
4.3.1 localResourceRoots
通过 localResourceRoots 限制 WebView 可以加载的本地资源路径:
const panel = vscode.window.createWebviewPanel('myWebview', 'My WebView',
vscode.ViewColumn.One,
{
enableScripts: true,
localResourceRoots: [
// 仅允许加载 media 目录下的资源
vscode.Uri.joinPath(context.extensionUri, 'media'),
// 仅允许加载 dist 目录下的资源
vscode.Uri.joinPath(context.extensionUri, 'dist')
]
}
);在 HTML 中引用本地资源时,需要使用 webview.asWebviewUri() 将资源 URI 转换为 WebView 可访问的 URI:
function getWebviewContent(webview: vscode.Webview, extensionUri: vscode.Uri): string {
// 获取本地资源的可访问 URI
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, 'media', 'style.css')
);
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, 'dist', 'app.js')
);
return `<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="${styleUri}">
</head>
<body>
<script src="${scriptUri}"></script>
</body>
</html>`;
}4.3.2 Content-Security-Policy(CSP)
CSP 是 WebView 安全的关键机制,通过 HTTP 头部 <meta> 标签限制可执行的脚本、可加载的资源来源:
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
img-src ${webview.cspSource} https:;
script-src ${webview.cspSource} 'unsafe-inline';
style-src ${webview.cspSource} 'unsafe-inline' https://cdnjs.cloudflare.com;
font-src ${webview.cspSource};
connect-src ${webview.cspSource};
frame-src 'none';">
</head>
<body>
<!-- WebView 内容 -->
</body>
</html>常见 CSP 指令:
| 指令 | 说明 |
|---|---|
default-src | 所有资源类型的默认策略 |
script-src | JavaScript 脚本来源 |
style-src | CSS 样式来源 |
img-src | 图片来源 |
font-src | 字体来源 |
connect-src | 网络请求(fetch/XHR/WebSocket)来源 |
frame-src | iframe 来源 |
media-src | 音视频来源 |
重要限制:
- WebView 不能加载
http://和ftp://协议的资源(仅允许https://)。 - 默认情况下,所有内联脚本都需 CSP 显式允许(
'unsafe-inline')。 webview.cspSource是 VSCode 提供的值,代表 WebView 的资源源标识。
4.3.3 非 HTTPS 资源限制
VSCode WebView 默认拒绝加载非 HTTPS 的外部资源。如果需要加载外部资源,必须使用 HTTPS 或通过 localResourceRoots 加载本地资源。
<!-- 允许:HTTPS 资源 -->
<img src="https://example.com/image.png">
<!-- 允许:本地资源(需在 localResourceRoots 中声明) -->
<img src="${webview.asWebviewUri(localImageUri)}">
<!-- 拒绝:HTTP 资源 -->
<img src="http://example.com/image.png">4.4 WebView 示例:Markdown 预览编辑器
以下是一个完整的 WebView 示例,实现带有自定义样式和语法高亮的 Markdown 预览编辑器:
import * as vscode from 'vscode';
import * as path from 'path';
export function activate(context: vscode.ExtensionContext) {
// 注册命令打开 Markdown 预览
context.subscriptions.push(
vscode.commands.registerCommand('myExtension.previewMarkdown', async () => {
// 获取当前激活编辑器中的文本
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document.languageId !== 'markdown') {
vscode.window.showWarningMessage('Please open a Markdown file first.');
return;
}
const document = editor.document;
const mdContent = document.getText();
// 创建 WebView 面板
const panel = vscode.window.createWebviewPanel(
'markdownPreview',
`Preview: ${path.basename(document.uri.fsPath)}`,
vscode.ViewColumn.Beside,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [
vscode.Uri.joinPath(context.extensionUri, 'media')
]
}
);
// 构建 WebView HTML
panel.webview.html = getPreviewHtml(panel.webview, context.extensionUri, mdContent);
// 监听文档变更自动更新
const changeListener = vscode.workspace.onDidChangeTextDocument(e => {
if (e.document.uri.toString() === document.uri.toString()) {
panel.webview.html = getPreviewHtml(panel.webview, context.extensionUri, e.document.getText());
}
});
panel.onDidDispose(() => {
changeListener.dispose();
}, null, context.subscriptions);
})
);
}
function getPreviewHtml(
webview: vscode.Webview,
extensionUri: vscode.Uri,
markdownContent: string
): string {
// 获取本地资源 URI
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, 'media', 'preview.css')
);
const highlightUri = webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, 'media', 'highlight.min.js')
);
// 转义 Markdown 内容以便在 JS 字符串中使用
const escapedContent = markdownContent
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
return `<!DOCTYPE html>
<html lang="en">
<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 ${webview.cspSource} 'unsafe-inline';
script-src ${webview.cspSource} 'unsafe-inline';
img-src ${webview.cspSource} https: data:;">
<link rel="stylesheet" href="${styleUri}">
<title>Markdown Preview</title>
</head>
<body>
<div id="preview"></div>
<script src="${highlightUri}"></script>
<script>
(function() {
const vscode = acquireVsCodeApi();
// 简单的 Markdown 转 HTML(生产环境建议使用 marked.js 等库)
function renderMarkdown(md) {
// 转义 HTML
let html = md
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
// 处理代码块
html = html.replace(/\`\`\`(\w*)\n([\s\S]*?)\n\`\`\`/g, (match, lang, code) => {
const highlighted = lang && hljs ?
hljs.highlight(code.trim(), { language: lang }).value :
code.trim();
return '<pre><code class="hljs">' + highlighted + '</code></pre>';
});
// 处理行内代码
html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
// 处理标题
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// 处理粗体和斜体
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// 处理链接
html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
// 处理段落
html = html.replace(/\n\n/g, '</p><p>');
html = '<p>' + html + '</p>';
return html;
}
// 渲染并显示
document.getElementById('preview').innerHTML = renderMarkdown(\`${escapedContent}\`);
// 接收扩展的更新消息
window.addEventListener('message', event => {
if (event.data.type === 'update') {
document.getElementById('preview').innerHTML = renderMarkdown(event.data.content);
}
});
})();
</script>
</body>
</html>`;
}5. Language Server Protocol(LSP)
Language Server Protocol(LSP)是微软定义的一套开放协议,用于在代码编辑器(客户端)与语言服务器(Server)之间通信,提供智能代码编辑功能。LSP 的核心思想是将语言分析逻辑与编辑器解耦,一套语言服务器可被任何支持 LSP 的编辑器复用。
5.1 LSP 架构
+------------------+ JSON-RPC +------------------+
| | <------------------> | |
| Editor (Client) | 请求/通知/响应 | Language Server |
| (VSCode) | | (Node.js/Java/ |
| | | Python/Go) |
+------------------+ +------------------+5.1.1 通信机制
LSP 使用 JSON-RPC 2.0 作为通信协议,支持以下三种消息类型:
| 类型 | 说明 | 示例 |
|---|---|---|
| Request(请求) | 客户端发送请求,服务器必须返回响应 | 自动补全请求、跳转定义请求 |
| Notification(通知) | 单向消息,无需响应 | 文档内容变更通知、关闭文档通知 |
| Response(响应) | 对请求的回复 | 返回补全列表、返回位置信息 |
JSON-RPC 消息格式:
// 请求
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/completion",
"params": {
"textDocument": { "uri": "file:///path/to/file.ts" },
"position": { "line": 10, "character": 5 }
}
}
// 响应
{
"jsonrpc": "2.0",
"id": 1,
"result": [
{ "label": "console", "kind": 6 },
{ "label": "const", "kind": 14 }
]
}
// 通知(无 id)
{
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": { "uri": "file:///path/to/file.ts", "version": 2 },
"contentChanges": [{ "text": "new content..." }]
}
}5.1.2 初始化流程
LSP 会话的生命周期:
Client Server
| |
|---- initialize (request) ---->| ① 客户端发送初始化请求
| | (包含 capabilities、rootUri 等)
|<--- initialized (result) -----| ② 服务器返回 capabilities
| |
|---- initialized (notify) ---->| ③ 客户端发送初始化完成通知
| |
|---- textDocument/didOpen ---->| ④ 打开文档时通知服务器
|---- textDocument/didChange -->| ⑤ 文档变更时增量或全量同步
|---- textDocument/completion ->| ⑥ 请求自动补全
|<--- completion items ---------| ⑦ 返回补全列表
|---- textDocument/didClose --->| ⑧ 关闭文档通知
| |
|---- shutdown (request) ------>| ⑨ 关闭请求
|<--- shutdown (result) --------|
| |
|---- exit (notification) ----->| ⑩ 退出通知5.2 LSP 功能
LSP 定义了丰富的语言功能。以下是服务器 capabilities 声明和对应功能的配置:
{
"capabilities": {
"textDocumentSync": {
"change": 2, // 1=Full, 2=Incremental
"openClose": true,
"save": { "includeText": true }
},
"completionProvider": {
"triggerCharacters": [".", ":", ">"],
"allCommitCharacters": ["."],
"resolveProvider": true
},
"hoverProvider": true,
"definitionProvider": true,
"referencesProvider": true,
"documentSymbolProvider": true,
"codeActionProvider": {
"codeActionKinds": ["quickfix", "refactor.extract", "source.organizeImports"]
},
"documentFormattingProvider": true,
"diagnosticsProvider": {
"interFileDependencies": true,
"workspaceDiagnostics": false
},
"renameProvider": {
"prepareProvider": true
},
"foldingRangeProvider": true,
"selectionRangeProvider": true,
"workspaceSymbolProvider": true,
"signatureHelpProvider": {
"triggerCharacters": ["(", ","]
}
}
}核心功能列表:
| 方法 | 说明 |
|---|---|
textDocument/completion | 自动补全建议 |
completionItem/resolve | 补全项详细信息(文档、附加文本等) |
textDocument/hover | 悬停提示 |
textDocument/definition | 跳转到定义 |
textDocument/declaration | 跳转到声明 |
textDocument/implementation | 跳转到实现 |
textDocument/references | 查找引用 |
textDocument/documentSymbol | 文档符号(大纲) |
workspace/symbol | 工作区符号搜索 |
textDocument/codeAction | 代码操作(快速修复、重构) |
textDocument/codeLens | CodeLens 按钮 |
textDocument/documentLink | 文档链接 |
textDocument/highlight | 高亮引用 |
textDocument/formatting | 文档格式化 |
textDocument/rangeFormatting | 范围格式化 |
textDocument/onTypeFormatting | 输入时格式化 |
textDocument/rename | 符号重命名 |
textDocument/foldingRange | 折叠范围 |
textDocument/semanticTokens | 语义令牌着色 |
textDocument/inlayHint | 内联提示(VSCode 1.67+) |
textDocument/inlineValue | 内联值显示(调试时) |
textDocument/diagnostic | 诊断信息(错误/警告/提示) |
textDocument/signatureHelp | 函数签名帮助 |
workspace/didChangeWatchedFiles | 文件系统变更通知 |
5.3 LSP Server 实现
推荐使用官方库 vscode-languageserver 和 vscode-languageserver-textdocument 在 Node.js 中实现语言服务器。
5.3.1 项目结构
my-language-server/
├── package.json
├── tsconfig.json
├── src/
│ ├── server.ts # 语言服务器入口
│ └── ...
└── ...package.json 关键配置:
{
"name": "my-language-server",
"version": "1.0.0",
"main": "./out/server.js",
"dependencies": {
"vscode-languageserver": "^9.0.0",
"vscode-languageserver-textdocument": "^1.0.11"
},
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.3.0"
}
}5.3.2 基础服务器实现
import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
InitializeResult,
CompletionItem,
CompletionItemKind,
TextDocumentPositionParams,
Diagnostic,
DiagnosticSeverity,
TextDocumentSyncKind,
ServerCapabilities
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
// 创建连接(使用标准输入/输出流)
const connection = createConnection(ProposedFeatures.all);
// 创建文档管理器
const documents = new TextDocuments(TextDocument);
// 存储已打开文档的诊断信息
let hasDiagnosticRelatedInformationCapability = false;
// ① 初始化
connection.onInitialize((params: InitializeParams): InitializeResult => {
const capabilities: ServerCapabilities = {
textDocumentSync: TextDocumentSyncKind.Incremental, // 增量同步
completionProvider: {
triggerCharacters: ['.', ':'],
resolveProvider: true
},
hoverProvider: true,
definitionProvider: true,
referencesProvider: true,
documentSymbolProvider: true,
codeActionProvider: {
codeActionKinds: ['quickfix']
},
documentFormattingProvider: true
};
// 检查客户端能力
hasDiagnosticRelatedInformationCapability = !!(
params.capabilities.textDocument &&
params.capabilities.textDocument.publishDiagnostics &&
params.capabilities.textDocument.publishDiagnostics.relatedInformation
);
return { capabilities };
});
// ② 初始化完成
connection.onInitialized(() => {
// 注册文件变更监控
connection.client.register(
DidChangeWatchedFilesNotification.type,
{ watchers: [{ globPattern: '**/*.my' }] }
);
});
// 服务器已启动
connection.listen();
// ④ 文档内容变更时推送诊断
documents.onDidChangeContent(change => {
validateTextDocument(change.document);
});
// 文档保存时也可以触发诊断
documents.onDidSave(change => {
validateTextDocument(change.document);
});
// 诊断函数
async function validateTextDocument(textDocument: TextDocument): Promise<void> {
const diagnostics: Diagnostic[] = [];
const text = textDocument.getText();
const lines = text.split('\n');
// 示例:检查行是否以大写字母开头
lines.forEach((line, lineIndex) => {
const trimmed = line.trim();
if (trimmed.length > 0 && /^[a-z]/.test(trimmed)) {
const diagnostic: Diagnostic = {
severity: DiagnosticSeverity.Warning,
range: {
start: { line: lineIndex, character: 0 },
end: { line: lineIndex, character: trimmed.length }
},
message: 'Lines should start with an uppercase letter.',
source: 'my-lang',
relatedInformation: hasDiagnosticRelatedInformationCapability
? [{
location: {
uri: textDocument.uri,
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 }
}
},
message: 'See style guide section 2.1'
}]
: undefined
};
diagnostics.push(diagnostic);
}
});
// 推送诊断信息
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}5.3.3 实现自动补全
// 自动补全
connection.onCompletion(
(_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => {
const keywords: CompletionItem[] = [
{
label: 'function',
kind: CompletionItemKind.Keyword,
detail: 'Keyword',
documentation: 'Define a function',
insertText: 'function ',
data: 1
},
{
label: 'if',
kind: CompletionItemKind.Keyword,
detail: 'Keyword',
documentation: 'Conditional statement',
insertText: 'if () {\n \n}',
insertTextFormat: 2 // SnippetTextFormat
},
{
label: 'const',
kind: CompletionItemKind.Keyword,
detail: 'Keyword',
documentation: 'Constant declaration',
insertText: 'const = ',
data: 3
},
{
label: 'let',
kind: CompletionItemKind.Keyword,
detail: 'Keyword',
documentation: 'Variable declaration',
insertText: 'let = ',
data: 4
},
{
label: 'return',
kind: CompletionItemKind.Keyword,
detail: 'Keyword',
documentation: 'Return statement',
insertText: 'return ',
data: 5
}
];
return keywords;
}
);
// 补全项详细信息(resolveProvider: true 时调用)
connection.onCompletionResolve(
(item: CompletionItem): CompletionItem => {
if (item.data === 1) {
item.detail = 'Function Definition';
item.documentation = {
kind: 'markdown',
value: 'Defines a named function.\n\n```\nfunction name(params) {\n // body\n}\n```'
};
}
return item;
}
);5.3.4 实现悬停提示
// 悬停提示
connection.onHover((params) => {
const document = documents.get(params.textDocument.uri);
if (!document) return null;
const text = document.getText();
const offset = document.offsetAt(params.position);
// 简单的关键字悬停检测
const wordRange = getWordRange(text, offset);
if (!wordRange) return null;
const word = text.substring(wordRange.start, wordRange.end);
const hoverInfo: Record<string, string> = {
'function': '**function** keyword\n\nDefines a function in the language.',
'if': '**if** keyword\n\nConditional execution.',
'const': '**const** keyword\n\nDeclares an immutable variable.',
'let': '**let** keyword\n\nDeclares a mutable variable.',
'return': '**return** keyword\n\nExits from the current function.'
};
if (hoverInfo[word]) {
return {
contents: {
kind: 'markdown',
value: hoverInfo[word]
},
range: {
start: document.positionAt(wordRange.start),
end: document.positionAt(wordRange.end)
}
};
}
return null;
});
// 辅助函数:获取光标所在单词的范围
function getWordRange(text: string, offset: number): { start: number; end: number } | null {
if (offset >= text.length) return null;
const char = text[offset];
if (!char || !/[a-zA-Z_]/.test(char)) return null;
let start = offset;
let end = offset;
while (start > 0 && /[a-zA-Z0-9_]/.test(text[start - 1])) start--;
while (end < text.length && /[a-zA-Z0-9_]/.test(text[end])) end++;
return { start, end };
}5.3.5 实现跳转定义
// 简单符号表
const symbolTable: Map<string, { uri: string; line: number; character: number }> = new Map();
// 文档打开时解析符号并填充符号表
documents.onDidOpen(event => {
parseSymbols(event.document);
});
documents.onDidChangeContent(event => {
parseSymbols(event.document);
});
function parseSymbols(document: TextDocument) {
const text = document.getText();
const lines = text.split('\n');
// 示例:匹配 "let name = ..." 或 "function name(...)" 形式的定义
const functionRegex = /function\s+([a-zA-Z_]\w*)\s*\(/;
const variableRegex = /(?:let|const)\s+([a-zA-Z_]\w*)\s*=/;
lines.forEach((line, lineIndex) => {
let match;
match = line.match(functionRegex);
if (match) {
symbolTable.set(match[1], {
uri: document.uri,
line: lineIndex,
character: line.indexOf(match[1])
});
}
match = line.match(variableRegex);
if (match) {
symbolTable.set(match[1], {
uri: document.uri,
line: lineIndex,
character: line.indexOf(match[1])
});
}
});
}
// 跳转到定义
connection.onDefinition((params) => {
const document = documents.get(params.textDocument.uri);
if (!document) return null;
const offset = document.offsetAt(params.position);
const wordRange = getWordRange(document.getText(), offset);
if (!wordRange) return null;
const word = document.getText().substring(wordRange.start, wordRange.end);
const symbol = symbolTable.get(word);
if (symbol) {
return {
uri: symbol.uri,
range: {
start: { line: symbol.line, character: symbol.character },
end: { line: symbol.line, character: symbol.character + word.length }
}
};
}
return null;
});5.3.6 实现文档符号(大纲)
import { SymbolKind } from 'vscode-languageserver';
connection.onDocumentSymbol((params) => {
const document = documents.get(params.textDocument.uri);
if (!document) return [];
const text = document.getText();
const lines = text.split('\n');
const symbols = [];
lines.forEach((line, lineIndex) => {
const funcMatch = line.match(/function\s+([a-zA-Z_]\w*)\s*\(/);
if (funcMatch) {
symbols.push({
name: funcMatch[1],
kind: SymbolKind.Function,
range: {
start: { line: lineIndex, character: 0 },
end: { line: lineIndex, character: line.length }
},
selectionRange: {
start: { line: lineIndex, character: line.indexOf(funcMatch[1]) },
end: { line: lineIndex, character: line.indexOf(funcMatch[1]) + funcMatch[1].length }
}
});
}
const varMatch = line.match(/(?:let|const|var)\s+([a-zA-Z_]\w*)\s*=/);
if (varMatch) {
symbols.push({
name: varMatch[1],
kind: SymbolKind.Variable,
range: {
start: { line: lineIndex, character: 0 },
end: { line: lineIndex, character: line.length }
},
selectionRange: {
start: { line: lineIndex, character: line.indexOf(varMatch[1]) },
end: { line: lineIndex, character: line.indexOf(varMatch[1]) + varMatch[1].length }
}
});
}
});
return symbols;
});5.4 LSP Client 注册
在 VSCode 扩展中,通过 vscode-languageclient 模块创建 LSP 客户端,连接语言服务器。
5.4.1 扩展侧 Client 代码
import * as vscode from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: vscode.ExtensionContext) {
// 服务器启动选项
const serverOptions: ServerOptions = {
run: {
module: context.asAbsolutePath('./language-server/out/server.js'),
transport: TransportKind.ipc // 使用 IPC 通信(stdio)
},
debug: {
module: context.asAbsolutePath('./language-server/out/server.js'),
transport: TransportKind.ipc,
options: {
execArgv: ['--nolazy', '--inspect=6009'] // 调试端口
}
}
};
// 客户端选项
const clientOptions: LanguageClientOptions = {
documentSelector: [
{ scheme: 'file', language: 'mylang' }
],
synchronize: {
// 监控工作区文件变更,通知服务器
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.my')
},
diagnosticCollectionName: 'myLanguage',
outputChannelName: 'My Language Server',
// 中间件:在请求发送前或响应返回后进行处理
middleware: {
// 修改自动补全项
provideCompletionItem: (document, position, context, token, next) => {
// 调用默认的补全处理
return next(document, position, context, token);
},
// 处理工作区配置
workspace: {
configuration: (params, token, next) => {
// 如果服务器请求配置,可以在这里提供自定义配置
if (params.items.some(item => item.section === 'myLanguage')) {
return [
{ maxErrors: 10, strictMode: true }
];
}
return next(params, token, next);
}
}
},
// 初始化和启动时传递的额外参数
initializationOptions: {
enableExperimentalFeatures: true
},
// 进程终止时的行为
markdown: {
isTrusted: true, // 支持 Markdown 渲染
supportHtml: true // 支持 HTML 渲染
}
};
// 创建 LanguageClient
client = new LanguageClient(
'myLanguageServer', // id
'My Language Server', // name(显示名称)
serverOptions,
clientOptions
);
// 启动客户端并注册到 context
context.subscriptions.push(client.start());
// 处理服务器端退出事件
client.onReady().then(() => {
console.log('Language server is ready');
}).catch(err => {
console.error('Language server failed to start:', err);
});
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}5.4.2 ServerOptions 与 ClientOptions 详解
ServerOptions 定义如何启动语言服务器进程:
type ServerOptions = {
// 方式一:启动 Node.js 模块作为子进程(最常用)
module: string; // 模块路径
transport?: TransportKind; // TransportKind.ipc(stdio)或 TransportKind.pipe
options?: { // 子进程选项
cwd?: string; // 工作目录
env?: { [key: string]: string }; // 环境变量
execArgv?: string[]; // Node.js 参数(如 --inspect)
};
// 方式二:连接已运行的服务器进程
command: string; // 可执行文件路径
args?: string[]; // 启动参数
transport?: TransportKind;
// 方式三:WebSocket 连接(远程服务器)
webSocket?: WebSocket;
webSocketOptions?: { ... };
}[];TransportKind 枚举值:
| 值 | 说明 |
|---|---|
TransportKind.stdio | 通过标准输入/输出流通信(IPC) |
TransportKind.pipe | 通过命名管道通信 |
TransportKind.socket | 通过 TCP socket 通信 |
ClientOptions 定义客户端行为:
interface ClientOptions {
documentSelector: DocumentFilter[]; // 指定哪些文件应连接到语言服务器
synchronize?: { // 同步选项
configurationSection?: string | string[]; // 配置节,变更时通知服务器
fileEvents?: FileSystemWatcher | FileSystemWatcher[]; // 文件变更事件
};
diagnosticCollectionName?: string; // 诊断集合名称
outputChannelName?: string; // 输出通道名称
middleware?: Middleware; // 中间件,拦截客户端-服务器通信
initializationOptions?: any; // 传递给服务器的初始化参数
markdown?: { // Markdown 渲染选项
isTrusted?: boolean;
supportHtml?: boolean;
};
uriConverters?: { // URI 转换器
code2Protocol?: (uri: Uri) => string;
protocol2Code?: (uri: string) => Uri;
};
initialisationHandler?: (params: InitializeParams) => Promise<any>;
progressOnPart?: boolean; // 显示进度条
stopTimeout?: number; // 停止超时(毫秒)
}5.4.3 Middleware 中间件
中间件提供钩子函数,在客户端将请求发送到服务器之前或收到响应之后进行拦截处理:
const middleware: Middleware = {
// 在自动补全结果返回前修改
provideCompletionItem: async (document, position, context, token, next) => {
const result = await next(document, position, context, token);
if (result) {
// 对结果进行排序或过滤
return result.sort((a, b) => a.label.localeCompare(b.label));
}
return result;
},
// 在发送诊断前修改
handleDiagnostics: (uri, diagnostics, next) => {
// 过滤掉特定类型的诊断
const filtered = diagnostics.filter(d => d.severity !== DiagnosticSeverity.Hint);
next(uri, filtered);
},
// 拦截工作区配置请求
workspace: {
configuration: (params, token, next) => {
// 为语言服务器提供自定义配置
return next(params, token, next);
}
}
};5.5 实例:自定义语言语法高亮 + 自动补全 + 错误检查插件
本节将以上各部分的知识整合,构建一个完整的自定义语言支持插件。假设我们定义一种名为 "MyLang" 的简单语言,支持变量声明、函数定义和基本控制流。
5.5.1 项目结构
mylang-extension/
├── .vscode/
│ └── launch.json
├── language-server/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ └── server.ts # 语言服务器
├── syntaxes/
│ └── mylang.tmLanguage.json # TextMate 语法定义
├── snippets/
│ └── mylang.code-snippets # 代码片段
├── package.json # 扩展清单
├── tsconfig.json
└── src/
└── extension.ts # 扩展入口5.5.2 TextMate 语法高亮
syntaxes/mylang.tmLanguage.json:
{
"scopeName": "source.my",
"name": "MyLang",
"fileTypes": ["my"],
"patterns": [
{
"name": "keyword.control.my",
"match": "\\b(if|else|while|for|return|function)\\b"
},
{
"name": "storage.type.my",
"match": "\\b(let|const|var)\\b"
},
{
"name": "constant.language.my",
"match": "\\b(true|false|null)\\b"
},
{
"name": "string.quoted.double.my",
"begin": "\"",
"end": "\"",
"patterns": [
{ "name": "constant.character.escape.my", "match": "\\\\." }
]
},
{
"name": "comment.line.double-slash.my",
"match": "//.*$"
},
{
"name": "comment.block.my",
"begin": "/\\*",
"end": "\\*/"
},
{
"name": "entity.name.function.my",
"match": "\\b([a-zA-Z_]\\w*)\\s*(?=\\()"
},
{
"name": "variable.other.my",
"match": "\\b[a-zA-Z_]\\w*\\b"
}
]
}5.5.3 package.json 扩展配置
{
"name": "mylang-support",
"displayName": "MyLang Language Support",
"version": "1.0.0",
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Programming Languages",
"Linters"
],
"activationEvents": [
"onLanguage:mylang"
],
"main": "./out/extension.js",
"contributes": {
"languages": [
{
"id": "mylang",
"extensions": [".my"],
"aliases": ["MyLang", "mylang"],
"configuration": "./language-configuration.json"
}
],
"grammars": [
{
"language": "mylang",
"scopeName": "source.my",
"path": "./syntaxes/mylang.tmLanguage.json"
}
],
"snippets": [
{
"language": "mylang",
"path": "./snippets/mylang.code-snippets"
}
],
"commands": [
{
"command": "mylang.checkAll",
"title": "Check All Files",
"category": "MyLang"
}
],
"menus": {
"commandPalette": [
{
"command": "mylang.checkAll",
"when": "resourceLang == mylang"
}
]
},
"keybindings": [
{
"command": "mylang.checkAll",
"key": "ctrl+shift+m",
"when": "resourceLang == mylang"
}
]
},
"dependencies": {
"vscode-languageclient": "^9.0.0"
},
"devDependencies": {
"@types/vscode": "^1.85.0",
"typescript": "^5.3.0"
}
}5.5.4 语言配置
language-configuration.json(控制括号匹配、注释切换、自动闭合等):
{
"comments": {
"lineComment": "//",
"blockComment": ["/*", "*/"]
},
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"", "notIn": ["string"] },
{ "open": "'", "close": "'", "notIn": ["string"] }
],
"surroundingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"" },
{ "open": "'", "close": "'" }
],
"indentationRules": {
"increaseIndentPattern": "^.*\\{[^}\"]*$",
"decreaseIndentPattern": "^.*\\}"
},
"wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\`\\~\\!\\@\\#\\%\\^\\&\\*\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s]+)"
}5.5.5 语言服务器 Server 端完整代码
language-server/src/server.ts(整合诊断、自动补全、悬停提示等功能):
import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
InitializeResult,
CompletionItem,
CompletionItemKind,
TextDocumentPositionParams,
Diagnostic,
DiagnosticSeverity,
TextDocumentSyncKind,
ServerCapabilities
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);
// MyLang 语言的关键字和类型
const keywords = [
'function', 'if', 'else', 'while', 'for', 'return',
'let', 'const', 'var', 'true', 'false', 'null'
];
const builtinFunctions = [
{ name: 'print', detail: 'Print to console', params: ['message'] },
{ name: 'len', detail: 'Get length', params: ['value'] }
];
connection.onInitialize((params: InitializeParams): InitializeResult => {
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: {
triggerCharacters: ['.'],
resolveProvider: true
},
hoverProvider: true,
definitionProvider: true,
documentSymbolProvider: true,
codeActionProvider: {
codeActionKinds: ['quickfix']
}
}
};
});
connection.listen();
// 文档变更时检查错误
documents.onDidChangeContent(change => {
validateDocument(change.document);
});
function validateDocument(doc: TextDocument) {
const diagnostics: Diagnostic[] = [];
const text = doc.getText();
const lines = text.split('\n');
lines.forEach((line, lineIndex) => {
// 检查语法错误:缺少分号的行(非空白、非注释、非代码块结尾)
if (!/^\s*($|\/\/|\}|\{)/.test(line) && !line.trim().endsWith(';') &&
!line.trim().endsWith('{') && !line.trim().endsWith('}') &&
!line.trim().endsWith('(') && !line.trim().endsWith(',')) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: {
start: { line: lineIndex, character: line.length - 1 },
end: { line: lineIndex, character: line.length }
},
message: 'Expected a semicolon at end of statement.',
source: 'mylang',
code: 'mylang.semicolon'
});
}
// 检查变量命名:不允许使用关键字作为标识符
const words = line.match(/\b[a-zA-Z_]\w*\b/g);
if (words) {
words.forEach(word => {
if (keywords.includes(word) &&
!/^(let|const|var|function)\s+/.test(line)) {
// 关键字出现在非声明位置是允许的,此处只检查声明语句
const letMatch = line.match(/^(let|const|var)\s+([a-zA-Z_]\w*)/);
if (letMatch && letMatch[2] && keywords.includes(letMatch[2])) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: {
start: { line: lineIndex, character: line.indexOf(letMatch[2]) },
end: { line: lineIndex, character: line.indexOf(letMatch[2]) + letMatch[2].length }
},
message: `Cannot use '${letMatch[2]}' as variable name (reserved keyword).`,
source: 'mylang',
code: 'mylang.keyword'
});
}
}
});
}
});
connection.sendDiagnostics({ uri: doc.uri, diagnostics });
}
// 自动补全
connection.onCompletion((params: TextDocumentPositionParams): CompletionItem[] => {
const items: CompletionItem[] = [];
// 关键字补全
keywords.forEach(kw => {
items.push({
label: kw,
kind: kw === 'function' ? CompletionItemKind.Function :
['let', 'const', 'var'].includes(kw) ? CompletionItemKind.Variable :
CompletionItemKind.Keyword,
data: kw
});
});
// 内置函数补全
builtinFunctions.forEach(func => {
items.push({
label: func.name,
kind: CompletionItemKind.Function,
detail: func.detail,
insertText: `${func.name}()`,
data: func.name
});
});
return items;
});
connection.onCompletionResolve((item: CompletionItem): CompletionItem => {
const kwDoc: Record<string, string> = {
'function': 'Define a function.\n```\nfunction name(param) {\n // body\n}\n```',
'if': 'Conditional statement.\n```\nif (condition) {\n // body\n}\n```',
'let': 'Declare a mutable variable.\n```\nlet name = value;\n```',
'const': 'Declare an immutable variable.\n```\nconst NAME = value;\n```',
'return': 'Return a value from a function.\n```\nreturn value;\n```'
};
if (typeof item.data === 'string' && kwDoc[item.data]) {
item.documentation = {
kind: 'markdown',
value: kwDoc[item.data]
};
}
return item;
});
// 悬停提示
connection.onHover(params => {
const doc = documents.get(params.textDocument.uri);
if (!doc) return null;
const offset = doc.offsetAt(params.position);
const text = doc.getText();
const word = extractWord(text, offset);
if (!word) return null;
const hoverDocs: Record<string, string> = {
'function': '**function** keyword\n\nDefines a function in MyLang.',
'let': '**let** keyword\n\nDeclares a mutable variable.',
'const': '**const** keyword\n\nDeclares an immutable constant.',
'print': '**print(value)**\n\nOutputs the value to the console.',
'len': '**len(value)**\n\nReturns the length of a string or array.'
};
if (hoverDocs[word]) {
return {
contents: {
kind: 'markdown',
value: hoverDocs[word]
}
};
}
return null;
});
function extractWord(text: string, offset: number): string | null {
if (offset >= text.length) return null;
if (!/[a-zA-Z_]/.test(text[offset])) return null;
let start = offset;
let end = offset;
while (start > 0 && /[a-zA-Z0-9_]/.test(text[start - 1])) start--;
while (end < text.length && /[a-zA-Z0-9_]/.test(text[end])) end++;
return text.substring(start, end);
}5.5.6 扩展入口代码
src/extension.ts:
import * as vscode from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: vscode.ExtensionContext) {
const serverModule = context.asAbsolutePath(
path.join('language-server', 'out', 'server.js')
);
const serverOptions: ServerOptions = {
run: {
module: serverModule,
transport: TransportKind.ipc
},
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: { execArgv: ['--inspect=6010'] }
}
};
const clientOptions: LanguageClientOptions = {
documentSelector: [
{ scheme: 'file', language: 'mylang' }
],
synchronize: {
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.my')
},
diagnosticCollectionName: 'mylang'
};
client = new LanguageClient(
'mylangServer',
'MyLang Language Server',
serverOptions,
clientOptions
);
context.subscriptions.push(client.start());
// 注册手动检查命令
context.subscriptions.push(
vscode.commands.registerCommand('mylang.checkAll', async () => {
const documents = vscode.workspace.textDocuments.filter(
doc => doc.languageId === 'mylang'
);
for (const doc of documents) {
await vscode.commands.executeCommand('vscode.executeDiagnostic', doc.uri);
}
vscode.window.showInformationMessage(
`Checked ${documents.length} MyLang file(s).`
);
})
);
}
export function deactivate(): Thenable<void> | undefined {
return client?.stop();
}通过上述完整的配置和代码,一个支持语法高亮、代码片段、自动补全、悬停提示、错误诊断的自定义语言插件即构建完成。该插件的开发过程展示了 VSCode 扩展开发中 TextMate 语法、LSP 客户端、LSP 服务器三者协同工作的标准模式。