一个简单的Hello World插件
根据VSCode官网给的教程搭建一个基本的插件开发框架: VSCode插件开发官网
通过以上步骤可以开发出一个Hello World插件,通过配置的command触发。
VSCode插件不仅有Command
类型,还有诸如Theme Color
、File Icon Theme
、Debug
等,其中Webview
类型的插件可以像浏览器一样在编辑器里打开网页。
一个简单的Webview插件
根据官网教程创建完后的项目目录如下:
项目默认用Webpack
进行编译,此时还只是一个Command类型的插件,还需要做些改动才能变成Webview
插件。
Panel Provider
一个用来管理Webview
状态 和行为 的类,注意这不是类组件 ,而是一个普通的class
。
由于Webview
本身是一个沙箱 环境,里面运行的HTML、CSS和JS是与插件上下文 (extension context
)隔离的,所以需要一个方法让Webview
和extension context
进行通信。
可以将
Webview
看作是视图层 ,extension context
看作是逻辑层
该Provider
类需要包含如下方法:
- 创建和渲染
Webview
面板(一个render
函数) - 一个处理面板关闭的
dispose
函数 - 设置
Webview
要渲染的HTML
- 一个
listener
监听函数用来监听Webview
和插件的数据传递,即视图层 和逻辑层的通信
在src
目录下创建panel
文件夹,然后新建一个HelloWorldPanelProvider.ts
文件:
typescript
import { Disposable, Webview, WebviewPanel, window, Uri, ViewColumn } from "vscode";
import { getUri } from "../utilities/getUri";
import { getNonce } from "../utilities/getNonce";
export class HelloWorldPanelProvider {
public static currentPanel: HelloWorldPanelProvider | undefined;
private readonly _panel: WebviewPanel;
private _disposables: Disposable[] = [];
private constructor(panel: WebviewPanel, extensionUri: Uri) {
this._panel = panel;
// 面板关闭时触发的函数
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
// 面板要渲染的HTML内容
this._panel.webview.html = this._getWebviewContent(this._panel.webview, extensionUri);
// 监听函数
this._setWebviewMessageListener(this._panel.webview);
}
// 渲染当前的Webview面板,如果当前面板不存在,那么重新创建一个Webview Panel
public static render(extensionUri: Uri) {
if (HelloWorldPanel.currentPanel) {
HelloWorldPanel.currentPanel._panel.reveal(ViewColumn.One);
} else {
const panel = window.createWebviewPanel(
"showHelloWorld", // panel类型
"Hello World", // panel title
ViewColumn.One,
{
enableScripts: true, // 是否在面板内执行js
localResourceRoots: [Uri.joinPath(extensionUri, "out")], // panel视图加载out路径下的资源文件(可以是打包后的js和css文件,具体在_getWebviewContent函数内)
}
);
HelloWorldPanel.currentPanel = new HelloWorldPanel(panel, extensionUri);
}
}
// 视图关闭
public dispose() {
HelloWorldPanel.currentPanel = undefined;
this._panel.dispose();
while (this._disposables.length) {
const disposable = this._disposables.pop();
if (disposable) {
disposable.dispose();
}
}
}
// webview内容
private _getWebviewContent(webview: Webview, extensionUri: Uri) {
const webviewUri = getUri(webview, extensionUri, ["out", "webview.js"]); // 这里是通过一个函数来加载编译后的js文件,可以作为module导入
const nonce = getNonce(); // 一个工具函数,保证js脚本引用的唯一性和安全性
return /*html*/ `
<!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'; script-src 'nonce-${nonce}';">
<title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
<vscode-button id="howdy">Howdy!</vscode-button>
<script type="module" nonce="${nonce}" src="${webviewUri}"></script>
</body>
</html>
`;
}
// webview的监听函数,用来坚挺从webview发送过来的data
private _setWebviewMessageListener(webview: Webview) {
webview.onDidReceiveMessage(
(message: any) => {
const command = message.command;
const text = message.text;
switch (command) {
case "hello":
window.showInformationMessage(text);
return;
}
},
undefined,
this._disposables
);
}
}
Webview
既然是Webview
插件,那必然要有个视图层
同样是在src
目录下新建一个webview
文件夹,然后再新建一个main.ts
文件:
typescript
import { provideVSCodeDesignSystem, vsCodeButton, Button } from "@vscode/webview-ui-toolkit"; // 需要引入webview-ui,里面包含了用于webview插件的组件
// 注册组件
// provideVSCodeDesignSystem().register(
// vsCodeButton(),
// vsCodeCheckbox()
// );
//
provideVSCodeDesignSystem().register(vsCodeButton()); // 注册Button组件
// 获取插件API
const vscode = acquireVsCodeApi();
// 这里需要先load webview
window.addEventListener("load", main);
// 待webview load完成后,获取dom节点信息
function main() {
// 通过在Provider class里渲染的节点ID来获取dom节点
const howdyButton = document.getElementById("howdy") as Button;
howdyButton?.addEventListener("click", handleHowdyClick);
}
function handleHowdyClick() {
// 通过postMessage API进行数据传递
vscode.postMessage({
command: "hello",
text: "Hey there partner! 🤠",
});
}
修改activate函数
在extension.ts
文件中,有个activate
函数,该函数是整个插件的入口函数,通过注册一个command
来调用panel render
方法,然后通过context
上下文订阅这个command
:
ts
import { commands, ExtensionContext } from "vscode";
import { HelloWorldPanelProvider } from "./panels/HelloWorldPanelProvider";
export function activate(context: ExtensionContext) {
const showHelloWorldCommand = commands.registerCommand("hello-world.showHelloWorld", () => {
HelloWorldPanelProvider.render(context.extensionUri);
});
context.subscriptions.push(showHelloWorldCommand);
}
配置view contributes
contributes
的配置在VSCode插件开发中是必不可少的部分,它决定了插件的类型。
打开package.json
文件:
默认是通过command命令启动插件的,即ctrl + shift + p
,然后输入对应的command来启动插件。
运行
command
npm run wtach
此命令可以开启一个
Webpack
热更新,当监听到文件改动时可以自动编译。
编译完成后项目中会多一个out
文件夹,该输出路径可以在package.json
中通过main
属性修改。
然后按下键盘上的F5
开启debug模式,会新打开一个VSCode window,在新的窗口中按下ctrl + shift + p
(Mac将ctrl
替换成cmd
),然后在文本框中输入Hello World
(即在package.json
中配置的command),最后按下回车就可看到Webview
。
点击Button
可以看到右下角输出的message:
引入React和Vite
跟着前面的步骤构建了一个Webview
,再深入一点思考的话,这个Webview
插件还有可以改进的地方:
- 视图层 可以单独抽离成
SAP
- 最终呈现的效果像是打开了一个文件,如果能像左侧
Product Bar
一样嵌入Webview
,如下图所示:
针对第一点,通过Vite
脚手架创建一个webview-ui
的React
项目:
command
pnpm create vite webview-ui --template react-ts
然后就可以像开发SPA
项目一样开发插件的视图层 。而之前项目中的webview
文件夹就可以删掉了,只保留PanelProvider
相关代码即可:
ts
import { Webview, Uri, WebviewView, WebviewViewResolveContext } from "vscode";
import { getNonce, getUri } from "../utils";
export class HelloWorldPanelProvider {
public static readonly viewType = "helloworld";
private readonly _extensionUri: Uri;
constructor(_extensionUri: Uri) {
this._extensionUri = _extensionUri;
}
public resolveWebviewView(webviewView: WebviewView, context: WebviewViewResolveContext) {
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [
Uri.joinPath(this._extensionUri, "out"),
Uri.joinPath(this._extensionUri, "webview-ui/build"), // 添加webview-ui项目打包的输出路径
],
};
webviewView.webview.html = this._getWebviewContent(webviewView.webview, this._extensionUri);
this._setWebviewMessageListener(webviewView);
}
private _getWebviewContent(webview: Webview, extensionUri: Uri) {
// webview-ui子项目中,css样式文件打包的输出路径
const stylesUri = getUri(webview, extensionUri, ["webview-ui", "build", "assets", "index.css"]);
// webview-ui子项目中,js文件打包的输出路径
const scriptUri = getUri(webview, extensionUri, ["webview-ui", "build", "assets", "index.js"]);
const nonce = getNonce();
return /*html*/ `
<!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}; script-src 'nonce-${nonce}';">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<title>Hello World</title>
</head>
<body>
<div id="root"></div>
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>
`;
}
private _setWebviewMessageListener(webviewView: WebviewView) {
webviewView.webview.onDidReceiveMessage((message: any) => {
const { command, api, key } = message;
console.log(command, api, key);
switch (command) {
case "codegpt":
webviewView.webview.postMessage({
command: "codegpt",
payload: JSON.stringify({ api, key }),
});
break;
}
});
}
}
除此之外,因为要在新的视图层 里调用VSCode API
,需在webview-ui
子项目里安装两个package
:
command
pnpm add @vscode/webview-ui-toolkit
pnpm add @types/vscode-webview -D
然后再通过一个包装类将VSCode API
导出:
typescript
import type { WebviewApi } from "vscode-webview";
/**
* 1. 暴露VSCode API给webview层调用
* 2. webview层与extension context的数据传递
*/
class VSCodeAPIWrapper {
private readonly vsCodeApi: WebviewApi<unknown> | undefined;
constructor() {
if (typeof acquireVsCodeApi === "function") {
this.vsCodeApi = acquireVsCodeApi();
}
}
/**
* 发送data给插件上下文
*/
public postMessage(message: unknown) {
if (this.vsCodeApi) {
this.vsCodeApi.postMessage(message);
} else {
console.log(message);
}
}
/**
* 获取数据,如果是web browser环境则直接从localStorage取
*/
public getState(): unknown | undefined {
if (this.vsCodeApi) {
return this.vsCodeApi.getState();
} else {
const state = localStorage.getItem("vscodeState");
return state ? JSON.parse(state) : undefined;
}
}
/**
* 数据持久化存储,如果是web browser环境则直接调用localStorage.setItem()
*/
public setState<T extends unknown | undefined>(newState: T): T {
if (this.vsCodeApi) {
return this.vsCodeApi.setState(newState);
} else {
localStorage.setItem("vscodeState", JSON.stringify(newState));
return newState;
}
}
}
// 导出API
export const vscode = new VSCodeAPIWrapper();
在webview
里的任何page
就可以导入这个API Wrapper
,比如创建一个home.tsx
:
tsx
import { vscode } from "@/utils/vscode"; // 引入API Wrapper
import {
VSCodeButton,
VSCodeDropdown,
VSCodeOption,
VSCodeTextField,
} from "@vscode/webview-ui-toolkit/react"; // VSCode组件库
import { TextField, Dropdown } from "@vscode/webview-ui-toolkit";
import styles from "../App.module.css";
function Home() {
function handleHowdyClick() {
const key = document.getElementById("key") as TextField;
// 调用API的setState方法持久化数据
vscode.setState({
key: key.value,
});
}
return (
<main>
<h1>Hello World!</h1>
<section>
<VSCodeTextField
className={styles.textField}
id="key"
placeholder="Please input the key"
value=""></VSCodeTextField>
</section>
<VSCodeButton className={styles.confirmBtn} onClick={handleHowdyClick}>
Confirm
</VSCodeButton>
</main>
);
}
export default Home;
引入react-router-dom
既然已经把webview
抽离成了一个SPA
子项目,那么就涉及到路由 的问题,但是VSCode
环境不同于浏览器,没有location
,所以就不能用createBrowserRouter
函数和<BrowserRouter/>
组件来创建路由,而是要用createMemoryRouter
函数或<MemoryRouter/>
组件:
tsx
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import Home from "@/pages/home";
import Conversation from "@/pages/conversation";
function App() {
const router = createMemoryRouter(
[
{
path: "/",
element: <Home />,
},
{
path: "/conversation",
element: <Conversation />,
},
],
{
initialEntries: ["/"],
initialIndex: 1,
}
);
return <RouterProvider router={router} />;
}
export default App;
在对应的组件里,则可以用react-router-dom
提供的useNavigate() hook
进行路由间的切换:
tsx
import { vscode } from "@/utils/vscode";
import {
VSCodeButton,
VSCodeDropdown,
VSCodeOption,
VSCodeTextField,
} from "@vscode/webview-ui-toolkit/react";
import { TextField, Dropdown } from "@vscode/webview-ui-toolkit";
import { useNavigate } from "react-router-dom";
import styles from "../App.module.css";
function Home() {
const navigate = useNavigate(); // 使用useNavigate()
function handleHowdyClick() {
const key = document.getElementById("key") as TextField;
vscode.setState({
key: key.value,
});
navigate("./conversation"); // 传入path
}
return (
<main>
<h1>Hello World!</h1>
<section>
<VSCodeTextField
className={styles.textField}
id="key"
placeholder="Please input the key"
value=""></VSCodeTextField>
</section>
<VSCodeButton className={styles.confirmBtn} onClick={handleHowdyClick}>
Confirm
</VSCodeButton>
</main>
);
}
export default Home;
修改contributes
最后就是修改根目录 下的pacakge.json
中的contributes
属性(注意不是webview-ui目录):
json
"contributes": {
"viewsContainers": {
"activitybar": [
{
"title": "Hello World",
"id": "helloworld",
"icon": "assets/activity_icon.svg" // 提供一个icon,即编辑器左侧Bar的图标,通常是24*24的svg
}
]
},
"views": {
"helloworld": [ //这里的值要和activitybar中的id一致
{
"id": "helloworld", // 这个id要和activitybar中的id一致
"name": "Hello Wolrd",
"type": "webview"
}
]
}
},
其中viewsContainers
是webview
插件的容器,该容器是一个Activity Bar
,即编辑器左侧那一列,views
表示该插件的类型。
最终效果
home
页:
点击Confirm
按钮后的路由跳转:
参考资料: