VSCode插件开发入门
内容提要
- VSCode 组成结构
- 插件在 VSCode 中能做什么
- 编写 Hello world 了解插件生命周期
- 主要配置和 APIs
- Web View 示例
VSCode 组成结构
VSCode 是基于 Electron 构建的,主要由三部分构成:
- Electron: UI
- Monaco Editor
- Extension Host
- Language Server Protocol & Debug Adapter Protocol
VSCode 中的大部分功能都是通过 Extension Host 来实现的。符合 LSP 的插件对应的高亮等语言特性就会反映到 Monaco Editor 上。从源码的 extensions 目录中可以看到,VSCode 默认集成了各种语言的插件。
Monaco Editor
是一个基于网页的编辑器,有符合 LSP 的插件就可以进行高亮、悬停提示,导航到定义、自动补全、格式化等功能。它的代码位于 monaco-editor
Extension Host
VSCode 的主进程和插件进程是分开管理的,Extension Host
就是用来管理插件进程的。
Extension Host 是用来确保插件:
- 不影响启动速度
- 不会减低 UI 响应速度
- 不会改变 UI 样式
因此保证 VSCode 的稳定和快速的密码就在于使用 Extension Host 将主进程和插件进程分开,使插件不会影响到 VSCode 主进程的性能和稳定。
在编写插件的时候 VSCode 可以让插件设置 Activation Events
来对插件懒加载。比如只有打开了 Markdown 文件才打开对应的插件。这样可以降低无谓的 CPU 和内存使用。
Language Server Protocol & Debug Adapter Protocol
这两个协议主要是为了将编辑器和编程语言/调试服务的功能分离开,实现任何语言只要编写对应的语言服务即可。目前各大编辑器都已经支持了这个协议。
插件在 VSCode 中能做什么
- 主题
- 界面和文本(TextMate 语法)主题色
- 图标样式
- 通用功能
- 添加命令
- 添加配置项
- 添加快捷键
- 添加菜单项
- 添加右键菜单
- 从文本输入框获取输入(QuickPick)
- 存储数据(localStorage)
- 工作区扩展
- 活动栏项目
- 显示提示框
- 状态栏信息
- 显示进度条
- 打开文件
- 显示网页(web view)
- 程序语言
- 实现新语言的高亮
- 实现新语言的调试器
- 代码库管理
- 定义和执行 Task
- 定义 snippet
主题
可以修改的内容如下图(来自VSCode 官方文档)所示,主要是背景和文字的颜色,各类图标等。
通用功能
工作区扩展
因为有 web view 并且底层是 node.js 虽然官方不推荐,但是实际是可以做到非常多的事情。
另外底层的 Electron 是阉割版的,如果需要的功能没有,也可以下载官方的 Electron 替换掉 VSCode 中的版本。
程序语言实现
实现编程语言的高亮、悬停提示,导航到定义、自动补全、格式化、调试等功能。
下面是 mssql 的架构和数据流:
编写 Hello world 了解插件生命周期
编写插件
至少需要 Javascript 或 Typescript 来做入口。
除了入口必须用 JS 或 TS,具体实现完全可以用你熟悉的任何语言,只要在 VSCode 的电脑上可以执行。
例如 Java Language Server 插件的大部分功能都是由 Java 实现的,插件和 Java 代码之间通过 json-RPC 来进行通信。
Hello world 介绍
以下内容使用了 VSCode 插件文档中的 your first extension,原始代码在 helloworld-sample
这个插件主要功能是运行 hello world
命令,弹出消息。
生命周期
从生命周期上来看,插件编写有三大个部分:
- Activation Event:设置插件激活的时机。位于 package.json 中。
- Contribution Point:设置在 VSCode 中哪些地方添加新功能,也就是这个插件增强了哪些功能。位于 package.json 中。
- Register:在
extension.ts
中给要写的功能用vscode.commands.register...
给Activation Event
或Contribution Point
中配置的事件绑定方法或者设置监听器。位于入口文件(默认是extension.ts
)的activate()
函数中。
packages.json
package 中和插件有关的主要内容是如下几个项目,其中 main 是插件代码的入口文件。
"activationEvents": [
"onCommand:extension.helloWorld"
],
"main": "./out/extension.js",
"contributes": {
"commands": [
{
"command": "extension.helloWorld",
"title": "Hello World"
},
{
"command": "extension.helloVscode",
"title": "Hello vscode"
}
]
},
activationEvents
官方文档: activation-events
原来的 "Hello World" 只是在执行 extension.helloWorld
的命令后才会激活插件,所以如果需要在其他情况下激活插件的话,则需要添加对应的命令才行。所以新添加了 onCommand:extension.helloVscode
。
"activationEvents": [
"onCommand:extension.helloWorld",
"onCommand:extension.helloVscode"
],
如果忘记添加这个命令则会造成执行命令后,插件并没有启动,命令执行失败。
Contribution Points
官方文档: contribution-points
这个是在 package.json
中配置的项目。说明了插件对哪些项目进行了增强。
对于 "hello world" 示例,如果需要在原有功能上添加一个命令 hello Vscode
,下面的 command
为 "extension.helloVscode" 的就是新添加的命令了。
"contributes": {
"commands": [
{
"command": "extension.helloWorld",
"title": "Hello World"
},
{
"command": "extension.helloVscode",
"title": "Hello vscode"
}
]
},
Extension.ts
这个文件是插件的入口,一般包括两个函数 activate
和 deactivate
。其中 activate
函数是插件激活时也就是在注册的 Activation Event 发生的时候就会执行。deactivate
中放的是插件关闭时执行的代码。
在 activate()
函数中通过 return
返回的数据或函数可以作为接口供其他插件使用。
VSCode 在运过程中,会通过 Extension Host
来管理所有插件中这两个函数的生命周期。
import * as vscode from "vscode";
// 插件激活时的入口
export function activate(context: vscode.ExtensionContext) {
// 注册 extension.helloWorld 命令
let disposable = vscode.commands.registerCommand("extension.helloWorld", () => {
vscode.window.showInformationMessage("Hello World!");
});
// 给插件订阅 helloWorld 命令
context.subscriptions.push(disposable);
// 新增的代码
let helloVscode = vscode.commands.registerCommand("extension.helloVscode", () => {
vscode.window.showInformationMessage("Hello Vscode");
});
context.subscriptions.push(helloVscode);
// return 的内容可以作为这个插件对外的接口
return {
hello() {
return "hello world";
}
};
}
// 插件释放的时候触发
export function deactivate() {}
activate
ExtensionContext
字面上意思是上下文信息,实际上就是当前插件的状态信息。
registerCommand 和 subscriptions.push()
完整的 API 是:registerCommand(command: string, callback: (args: any[]) => any, thisArg?: any): Disposable
这个的主要功能是给功能代码(callback
)注册一个命令(command
),然后通过 subscriptions.push()
给插件订阅对应的 command
事件。
return 给其他插件提供接口
如果需要使用其他插件提供的接口,则可以在 package.json
中将对应插件添加到 extensionDependency
中,然后使用 getExtension
函数中的 export
属性。
export function activate(context: vscode.ExtensionContext) {
let api = {
hello() {
return "hello world";
}
};
return api;
}
// 引入其他插件接口
let helloWorld = extensions.getExtension("helloWorld");
let importedApi = helloWorld.exports;
console.log(importedApi.hello());
Debug Extension
在 launch.json
中添加一个扩展开发的配置,然后按F5就可以打开一个新的 VSCode,然后就可以在这个新的 VSCode 中进行插件测试。并且也可以在插件代码的那个 VSCode 中打断点调试。建议在“args”中添加"--disable-extensions",不然要调试的插件加载太慢,还以为写的有问题。
Unit Test
具体文档请参考 testing extension
测试插件可以使用 vscode-test
API 来做测试。需要给它的 runTests
提供 extensionDevelopmentPath, extensionTestsPath
即开发目录和测试文件目录。测试则使用习惯的单元测试框架即可。
import * as path from "path";
import { runTests } from "vscode-test";
async function main() {
try {
// The folder containing the Extension Manifest package.json
// Passed to `--extensionDevelopmentPath`
const extensionDevelopmentPath = path.resolve(__dirname, "../../");
// The path to the extension test script
// Passed to --extensionTestsPath
const extensionTestsPath = path.resolve(__dirname, "./suite/index");
// Download VS Code, unzip it and run the integration test
await runTests({ extensionDevelopmentPath, extensionTestsPath });
} catch (err) {
console.error("Failed to run tests");
process.exit(1);
}
}
main();
如果要对测试做Debug的话,则可以参考下面内容配置 launch.json
。其中设置关闭了其他的插件,如果需要打开其他插件,则删掉 --disable-extensions。也可以通过给 runTests
再添加一个参数 launchArgs: ['--disable-extensions']
来关闭其他插件。
{
"version": "0.2.0",
"configurations": [
{
"name": "Extension Tests",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
],
"outFiles": ["${workspaceFolder}/out/test/**/*.js"]
}
]
}
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: ["--disable-extensions"]
});
主要配置和 APIs
Activation Events
这个项目定义的是插件打开的时机,可以在以下情况时打开:
- onLanguage: 在打开对应语言文件时
- onCommand: 在执行对应命令时
- onDebug: 在 debug 会话开始前
- onDebugInitialConfigurations: 在初始化 debug 设置前
- onDebugResolve: 在 debug 设置处理完之前
- workspaceContains: 在打开一个文件夹后,如果文件夹内包含设置的文件名模式时
- onFileSystem: 打开的文件或文件夹,是来自于设置的类型或协议时
- onView: 侧边栏中设置的 id 项目展开时
- onUri: 在基于 vscode 或 vscode-insiders 协议的 url 打开时
- onWebviewPanel: 在打开设置的 webview 时
- *: 在打开 vscode 的时候,如果不是必须一般不建议这么设置
官方文档:activation-events
Contribution Points
这个是用来用来描述你所写的插件在哪些地方添加了功能,是什么样的功能,添加的内容会显示到界面上,前面的 hello world
示例就是在 commands
中添加了相应的 hello world
命令,然后这个命令就可以在命令窗口执行了。
- configuration
- configurationDefaults
- commands
- menus
- keybindings
- languages
- debuggers
- breakpoints
- grammars
- themes
- snippets
- jsonValidation
- views
- viewsContainers
- problemMatchers
- problemPatterns
- taskDefinitions
- colors
- typescriptServerPlugins
- resourceLabelFormatters
官方文档:contribution-points
APIs
所有的 API 定义在 vscode.d.ts 中,其注释也写的非常详细。主要是以下各类 API:
API 设计的模式
Promise
VSCode API 中异步操作使用的是 Promise,所以可以使用 Then 或者 await。大部分情况下 Thenable 是可选的,如果 promise 是可选的,则会有一个可选类型。
provideNumber(): number | Thenable<number>
Cancellation Tokens
在一个操作完成前,会开始于一个不稳定的状态。比如在开始代码智能提示时,最开始的操作会因为后面持续输入的内容过时。
很多 API 会有一个 CancellationToken
,来检查操作是否取消 (isCancellationRequested
),或者在发生取消操作时得到通知 (onCancellationRequested
)。这个 Token 一般是函数的最后一个可选(回调)参数。
Disposables
VSCode API 对使用的各类资源利用 dispose pattern
来进行释放。应用于事件监听、命令、UI 交互等。
例如:对于 setStatusBarMessage(value: string)
(给状态栏显示消息)函数返回一个 Disposable
类型,然后可以通过调用它的 dispose
来移除信息。
Events
事件在 VSCode API 里面是通过订阅监听函数来实现的。订阅后会返回一个支持 Disposable
接口的变量。调用 dispose
就可以取消监听。
var listener = function(event) {
console.log("It happened", event);
};
// 开始监听
var subscription = fsWatcher.onDidDelete(listener);
// 搞事情
subscription.dispose(); // 停止监听
对于事件的命名遵循 on[Will|Did]VerbNoun?
模式。
- onWill:即将发生
- onDid:已经发生
- verb:发生了什么
- noun:事件所处环境,如果发生在所处的环境则可以不加。
例如:window.onDidChangeActiveTextEditor
。
Web View 示例
web view 是 VSCode 插件里面比较有意思并且写起来也相对自由的部分,web view 用好了可以做出非常棒的插件,例如下面这个 Visual Embedded Rust。
web view 生命周期
生命周期包括三部分:
- 创建:
panel = vscode.window.createWebviewPanel()
- 显示:
panel.webview.html = htmlString
- 关闭:
panel.dispose()
主动关闭,panel.onDidDispose
设置关闭时清理的内容。
export function webViewPanel(context: vscode.ExtensionContext) {
// 1. 使用 createWebviewPanel 创建一个 panel,然后给 panel 放入 html 即可展示 web view
const panel = vscode.window.createWebviewPanel(
'helloWorld',
'Hello world',
vscode.ViewColumn.One, // web view 显示位置
{
enableScripts: true, // 允许 JavaScript
retainContextWhenHidden: true // 在 hidden 的时候保持不关闭
}
);
const innerHtml = `<h1>Hello Web View</h1>`;
panel.webview.html = getWebViewContent(innerHtml);
// 2. 周期性改变 html 中的内容,因为是直接给 webview.html 赋值,所以是刷新整个内容
function changeWebView() {
const newData = Math.ceil(Math.random() * 100);
panel.webview.html = getWebViewContent(`${innerHtml}<p>${newData}</p>`);
}
const interval = setInterval(changeWebView, 1000);
// 3. 可以通过设置 panel.onDidDispose,让 webView 在关闭时执行一些清理工作。
panel.onDidDispose(
() => {
clearInterval(interval);
},
null,
context.subscriptions
);
}
function getWebViewContent(body: string, pic?: vscode.Uri) {
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="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
${body}
<br />
<img
id="picture"
src="${pic || 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif'}"
width="300" />
</body>
</html>
`;
}
读取本地文件
一般情况下 web view 是不能直接访问本地文件的,需要使用vscode-resource
开头的地址。并且只能访问插件所在目录和当前工作区。
在高于 1.38 版 VSCode 下可以使用 panel.webview.asWebviewUri(onDiskPath)
生成对应的地址,否则需要使用onDiskPath.with({ scheme: 'vscode-resource' })
。
export function webViewLocalContent(context: vscode.ExtensionContext) {
const panel = vscode.window.createWebviewPanel(
'HelloWebViewLocalContent',
'Web View Local Content',
vscode.ViewColumn.One,
{
localResourceRoots: [
vscode.Uri.file(path.join(context.extensionPath, 'media'))
]
}
);
const onDiskPath = vscode.Uri.file(
path.join(context.extensionPath, 'media', 'cat.jpg')
);
// 生成一个特殊的 URI 来给 web view 用。
// 实际是:vscode-resource: 开头的一个 URI
// 资源文件只能放到插件安装目录或则用户当前工作区里面
// 1.38以后才有这个 API,前面版本可以用onDiskPath.with({ scheme: 'vscode-resource' });
const catPicSrc = panel.webview.asWebviewUri(onDiskPath);
const body = `<h1>hello local cat</h1>`;
panel.webview.html = getWebViewContent(body, catPicSrc);
}
web view 和插件通信
向 web view 发信息是通过 currentPanel.webview.postMessage({})
发送一个json数据。在 web view 中通过window.addEventListener('message', callback)
监听message
信息。
// 插件发送数据
currentPanel.webview.postMessage({ command: 'hello web view' });
// web 接收
window.addEventListener('message', event => {
const message = event.data;
console.log(message.command);
})
由于 web view 不能直接调用 vscode 的 API,不过 vscode 还是给它提供了一个 acquireVsCodeApi()
的函数,它返回的对象中有一个 postMessage 方法。web view 可以通过这个方法给 vscode 发送消息。插件端则通过currentPanel.webview.onDidReceiveMessage()
订阅接收消息的事件。
// web view 向插件端发送数据
const vscode = acquireVsCodeApi();
vscode.postMessage({
command: 'alert',
text: 'hello vscode'
});
// 插件端接收数据
currentPanel.webview.onDidReceiveMessage(
message => {
switch (message.command) {
case 'alert':
vscode.window.showInformationMessage(message.text);
return;
}
},
undefined,
context.subscriptions
);
调试 web view
可以使用下面 web view develop tool 命令。
打开后可以看到 web view 包在一个 iframe 中,断点什么的都是支持的,内容如下: