前言
大家好,我是祯民。今天想和大家聊的是前端基建中的重要一环,vscode 插件开发。对于大部分前端同学,相信都用过不少 vscode 插件,比如我们耳熟能详的美化插件 prettier,又或是类 web gpt 的插件 code-gpt,一个好的 vscode 插件在我们的开发流程中,可以起到显著的提效,对开发的流畅度又或是上限提高都是非常重要的。
因为工作内容的关系,很多前端同学并不会自己去开发一个 vscode 插件,更多的是和 web 环境打交道。但事实上,不论是从技术提升上,还是个人发展上,我都非常建议大家学习 & 具备 vscode 插件开发的能力。
从技术提升上, vscode 插件不同于 web 环境,运行于 node 运行时下,对于一个前端服务领域相关知识的有着显著帮助 ;而从个人发展上,作为前端基建的重要一环,不管是面试大厂业务团队,还是架构团队,掌握 vscode 插件开发都是一个不错的加分项。例如下面字节 web-infra 的一条职位要求中,就明确要求了需要熟悉 vscode 插件开发。
但 vscode 插件开发 API, 分支都有一定的学习成本,加上文档全英文,对一些新入门的前端同学上手的确不够友好。出于这个背景,所以我打算出一些关于 vscode 插件实战相关的技术系列文章,帮助大家更快上手 & 具备 vscode 插件开发的基本能力,今天这一节我将为大家介绍 vscode 插件的一种常见载体 vscode 任务栏插件。
vscode 任务栏插件是一种集成到 vscode 左侧任务栏的插件形式,通过在任务栏注册 webview 来实现自己插件能力的常驻化,在这种模式下,用户可以通过点击任务栏完成 webview 的插件操作,适合需要图形界面 & 频繁引流的插件场景。比如下图的 code-gpt 插件
因为篇幅限制,今天的这节分享里我不会过多介绍 vscode 插件的一些基础 API 和项目架构,在学习过程中有遇到不清楚的 API 或是目录结构大家可以到官方文档中查询或者直接评论交流都是可以的。本次分享的内容包含以下几个模块:
- 如何注册一个可以绑定在任务栏的 webview?
- 如何使用 react 开发 webview?
- 如何完成 webview 和 extension 的相互通信?
- 如何在三方逻辑中调起已注册 webview?
下面我们进入正题,开始这四个模块的学习
正文
如何注册一个可以绑定在任务栏的 webview?
Webview提供了标准的WebAPI和JavaScript接口,可以与VSCode API交互,并与VSCode本身的功能进行合作。例如,开发者可以使用Webview在VSCode中添加自定义的编辑器窗口,使用V Code API进行代码编辑和高亮。同时,Webview还提供了协议处理、界面元素和用户界面管理等丰富的功能,使得开发者可以打造出丰富的应用程序。
webview 简单来说就是一个内置的 web 浏览器,我们可以通过类开发 web 的方式完成 vscode 插件图形界面的开发。在 VSCode 官方文档中,webview 被区分为两种类型, 任务栏 Webview 称为 StatusBarItem
,而通常的 Webview 称为 WebviewPanel
,它们在 VS Code UI 中所处的区域也不同。
关于 WebviewPanel,在文档中是这样描述的:
WebviewPanel
是可以在编辑器区域打开的 Webview,它包含了一个 HTML 渲染引擎和一个 JavaScript 运行时,与 VS Code 上下文相隔离。
而针对 StatusBarItem,则在文档中被描述为:
StatusBarItem
表示现实在 VS Code 编辑器下方的状态栏中的一个项目,该项目可用于显示信息或进行交互。
这样区分的原因也是为了在交互设计上,让开发者将真正重要需要引流的 webview 放到 StatusBarItem,从而避免了各个插件 webview 位置百花齐放,造成的视觉负面冲击。
所以对于任务栏的 webview 我们使用 WebviewPanel
的常规注册方式,不论我们如何调整位置也是无法将它移动到 StatusBarItem 区域的,下面是一个 WebviewPanel 的注册方式。
typescript
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const panel = vscode.window.createWebviewPanel(
'myWebview',
'My Webview',
vscode.ViewColumn.One,
{}
);
panel.webview.html = `
<html>
<body>
<h1>Hello, World!</h1>
</body>
</html>
`;
context.subscriptions.push(
vscode.commands.registerCommand('myExtension.showWebview', () => {
panel.reveal();
})
);
}
那么言归正传,我们应该如何创建一个任务栏的 webview 呢?StatusBarItem
的注册并不能直接通过某个 api 完成,我们需要继承一个 vscode.WebviewViewProvider
的基类,实现一个 provider 类后完成对应 webview 的注入,例如下面的 case
typescript
// mainWebviewProvider.ts
import * as path from 'path';
import * as vscode from 'vscode';
/**
* 主任务 webview provider
*/
export class MainWebviewProvider implements vscode.WebviewViewProvider {
private mainContext: vscode.ExtensionContext;
private dirPath = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
protected view: vscode.WebviewView | null = null;
constructor(context: vscode.ExtensionContext, path?: string) {
this.mainContext = context;
if (path) {
this.dirPath = path;
}
}
public getInstance() {
return this.view;
}
/**
* webview provider 入口函数
* @param webviewView
*/
public resolveWebviewView(webviewView: vscode.WebviewView) {
this.view = webviewView;
this.view.webview.options = {
enableScripts: true,
localResourceRoots: [vscode.Uri.file(path.join(this.mainContext.extensionPath, 'dist'))] // 安全路径
};
this.view?.webview.onDidReceiveMessage((data) => {
this.dirPath = data.path;
});
this.view.webview.html = this.getWebviewContent();
// TODO demo, webview 调起 vscode 示例, 根据实际情况调整
this.view.webview.onDidReceiveMessage(
(message) => {
if (message.method === 'showMessage') {
vscode.window.showInformationMessage(message.params.text);
}
},
undefined,
this.mainContext.subscriptions
);
}
public getWebviewContent() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>test</title>
<script defer="defer" src="${this.view?.webview.asWebviewUri(
vscode.Uri.file(path.join(this.mainContext.extensionPath, 'dist', 'bundle.js'))
)}"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>`;
}
}
在上面的 case 中,resolveWebviewView
作为整个 provider 类初始化的入口函数,其中 view 的 protected 变量则为 provider 类创建出来的实例,通过这个实例,我们可以远程控制 webview 的一些操作,也可以进行一些通信。完成这个 provider 类后,我们就可以在 extensions 入口文件完成注册。
typescript
// extension.ts
export function activate(context: vscode.ExtensionContext) {
const sidebarPanel = new MainWebviewProvider(context);
const mianWebview = vscode.window.registerWebviewViewProvider('test', sidebarPanel, {
webviewOptions: { retainContextWhenHidden: true }
});
context.subscriptions.push(mianWebview, duplicateCode);
}
我们通过 vscode.window.registerWebviewViewProvider
完成 webview 的注册,并注入给 vscode 插件实例集context.subscriptions
。其中 test 为 webview 的 唯一 id,这部分需要在 package.json 中体现出来,因为 vscode 相关的配置都通过 package.json 读取。
json
// package.json
"views": {
"test": [
{
"type": "webview",
"id": "test",
"name": ""
}
]
},
"viewsContainers": {
"activitybar": [
{
"id": "test",
"title": "test",
"icon": "images/code.svg"
}
]
},
在上面的配置中,views 对应的注册的所有 webview,而 activitybar 则对应 vscode 插件的任务栏插件注册,id 的部分与 views 中注册的 webview 对应,title 的部分则是任务栏 hover 上去后展示的 tooltip, icon 则对应任务栏展示的 icon,在完成上面的部分以后我们启动插件就能在任务栏左侧看到对应的插件项了,例如下图
如何使用 react 开发 webview?
对于 webview 开发,官方提供的案例是使用原生的 html 和 js 完成开发,就比如上面提到的 case
typescript
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const panel = vscode.window.createWebviewPanel(
'myWebview',
'My Webview',
vscode.ViewColumn.One,
{}
);
panel.webview.html = `
<html>
<body>
<h1>Hello, World!</h1>
</body>
</html>
`;
context.subscriptions.push(
vscode.commands.registerCommand('myExtension.showWebview', () => {
panel.reveal();
})
);
}
对于不少架构组的同学,他们开发 vscode 插件也是这么写的,但对我来说,原生 html + js 写一些相对复杂的页面交互实在是痛不欲生,不是原生写不了,而是 react 更具性价比^^。
上面的 case 中webview.html
注入的 html 字符串,所以我们只需要拿到目标页面的 html 即可,至于过程是用什么框架还是原生就并不重要了,下面我们提供两种方案思路供大家参考。
方案一:服务器端渲染
react 服务器渲染的 api 中有一个rendertoString
方法,可以拿到渲染组件的 html 字符串,但这时候生成的 html 字符串是没有 css 和绑定事件的,我们可以按照服务器端渲染的方式实现一遍。具体的写法大家可以参考 《架构实现(二):如何实现 SSR 的静态页面渲染》,里面有提供详细的思路和 demo,这里就不再赘述了。
方案二:bundle 注入
react 转 html 大部分同学可能第一反应会想到使用 html-webpack-plugin
转换,但在 vscode 场景下是不能走通的,所有的 cdn 资源会走 vscode 的路径特殊处理,仅用相对路径生成的 html 是没办法软链到对应的 bundle 的。
既然如此,但我们可以手动完成 bundle 的注入,首先我们额外配置一个 webpack config 用于 webview 资源的打包,比如下面的 case。
javascript
'use strict';
const path = require('path');
const webviewConfig = {
target: 'node',
mode: 'production',
entry: './src/webview/App.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
node: {
__dirname: false,
},
externals: {
vscode: 'commonjs vscode' // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/
},
resolve: {
extensions: ['.ts', '.js', '.tsx', '.jsx']
},
module: {
rules: [
{
test: /.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /.tsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
}
};
module.exports = webviewConfig;
也就是说,我们需要两套 webpack 配置,一套是脚手架默认生成的,用于打包 node 环境下的 extension 代码,另一个则是上面提到的,用来将 dom 打包成对应的 bundle,用于页面的注入。
然后我们正常配置 react 的相关项目配置,配置一个入口文件,将相关的内容注入到指定 dom 中。
typescript
import React from 'react';
import { render } from 'react-dom';
import { TestComponent } from './components/TestComponent';
import useParams from './hooks/useParams';
const App = () => {
// ... your dom
};
render(<App />, document.getElementById('root'));
紧接着通过 webpack 打包我们不难拿到一个 bundle 文件,这时候我们手动将 bundle 注入到 webview 的 html 中
typescript
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Code Deduplication</title>
<script defer="defer" src="${this.view?.webview.asWebviewUri(
vscode.Uri.file(path.join(this.mainContext.extensionPath, 'dist', 'bundle.js'))
)}"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>`;
}
其中 asWebviewUri 可以将本地资源转化为 webview 中的 uri,而 vscode.Uri.file 则是帮我们拿到具体资源在 vscode 中的实际位置,通过这种方式我们就可以成功链接到我们的本地的 bundle 资源。
需要值得一提的是,在 webview 实体注册的时候,我们需要申请安全资源路径,来保证这次访问不会被 vscode 拦截,如下。
typescript
this.view.webview.options = {
enableScripts: true,
localResourceRoots: [vscode.Uri.file(path.join(this.mainContext.extensionPath, 'dist'))] // 安全路径
};
到这里我们 react 的配置就完成了,我们启动 extension 就可以看到对应的 dom 了
那么一些同学可能会有疑问,在 vscode webview 开发中,怎么看控制台呢?我们可以调起 vscode 提供的开发者工具,就可以和浏览器开发页面一样开发 webview 了。
如何完成 webview 和 extension 的相互通信?
第三个模块就是 webview 开发的重头戏了,我们知道 vscode extensions 是运行在 node 运行时下的,相比常规的浏览器环境,node 是 v8 引擎的一个修改版,提供了文件系统等交互能力,而 webview 运行在浏览器环境中。这也导致两种环境原则上是无法直接交互的(比如依赖调用等)。
但 webview 的本质作用就是希望在图形界面的基础上,调起 vscode 的插件能力,那么它们之间的相互通信就显得尤为重要了。
webview 向 extension 通信
vscode 的 webview 与常规浏览器有所不同,它在全局环境下注入了一个 acquireVsCodeApi,通过使用这个 api 完成实例的注册,我们就可以在 webview 沙盒中与 extension 产生通信,比如下面的例子
typescript
// 浏览器环境,webview
const vscode = acquireVsCodeApi();
vscode.postMessage({
method: 'showMessage',
params: {
text: `click the dirPath`
}
});
typescript
// node 环境,extension
this.view.webview.onDidReceiveMessage(
(message) => {
if (message.method === 'showMessage') {
vscode.window.showInformationMessage(message.params.text);
}
},
undefined,
this.mainContext.subscriptions
);
不过需要注意的是,acquireVsCodeApi 只能被调用一次,当调用多次的时候会报错,所以我们这部分逻辑不要在 react function 中实现,避免重渲染机制导致的实例重建。
typescript
// app.tsx
import React from 'react';
import { render } from 'react-dom';
import { TestComponent } from './components/TestComponent';
import useParams from './hooks/useParams';
// @ts-ignore
const vscode = acquireVsCodeApi();
const App = () => {
// 注入到组件中,避免重复注册
return <TestComponent vscode={vscode} />;
};
render(<App />, document.getElementById('root'));
extension 向 webview 通信
extension 向 webview 的通信我们可以使用 postMessage 完成,因为 webview 本质是一个 vscode 环境下的一个沙盒浏览器,在 webview 中我们通过监听 message 事件来拿到指定方法
typescript
// node 环境,extension
this.view?.webview.postMessage({
// ...
});
webview 中的读取我们可以封装成一个 hooks
typescript
import { useState, useEffect } from 'react';
const useParams = () => {
const [webviewParams, setWebviewParams] = useState<Record<string, any>>({});
useEffect(() => {
const messageHandler = (event) => {
setWebviewParams(event.data);
};
window.addEventListener('message', messageHandler);
return () => window.removeEventListener('message', messageHandler);
}, []);
return webviewParams;
};
export default useParams;
如何在三方逻辑中调起已注册 webview?
上面我们介绍了怎么开发一个 vscode 任务栏插件,那么如果我们除了希望能从左侧任务栏打开,还想能通过其他三方途径调起我们应该怎么做呢?我们来看下面的 case
typescript
import * as vscode from 'vscode';
import MainWebviewProvider from './cores/main-webview-provider/mainWebviewProvider';
export function activate(context: vscode.ExtensionContext) {
const sidebarPanel = new MainWebviewProvider(context);
const mainWebview = vscode.window.registerWebviewViewProvider('test', sidebarPanel, {
webviewOptions: { retainContextWhenHidden: true }
});
const duplicateCode = vscode.commands.registerCommand('test.other-command', (uri) => {
vscode.commands.executeCommand('workbench.view.extension.test');
const dirPath = `/${uri.path.substring(1)}`;
sidebarPanel.setDirPath(dirPath);
});
context.subscriptions.push(mainWebview, duplicateCode);
}
export function deactivate() { }
在上面的 case 中,我们使用了vscode.commands.executeCommand
在test.other-command
命令中手动调起了上面我们注册的 test webview,通过这种方式我们可以在三方调起插件工作空间内注册的 webview,这样我们的任务栏插件启动也就并非只能局限在任务栏了。
小结
到这里我们今天的分享就结束了,在今天这节中,我们学习了 vscode 任务栏插件开发的相关知识,包括如何注册一个任务栏 webview,如何使用 react 开发 webview 图形界面,webview 与 extension 的通信交互,以及如何在三方逻辑中调起已注册的 webview 等。相信大家在实操后,对 vscode 任务栏插件开发也可以有一个基本全面的认知。
除 vscode 任务栏插件外,vscode 插件还有很多其他的分支,比如无图形交互的命令行插件,可以集成到 CICD 中的流程化插件,这个后面有机会我再给大家进一步举例介绍。感谢大家的时间,耐心完成了本次学习,如果有讲解不清晰的地方,也欢迎评论区提问交流。