likes
comments
collection
share

使用React + Vite + react-router-dom开发VSCode Webview插件

作者站长头像
站长
· 阅读数 4

一个简单的Hello World插件

根据VSCode官网给的教程搭建一个基本的插件开发框架: VSCode插件开发官网

通过以上步骤可以开发出一个Hello World插件,通过配置的command触发。

VSCode插件不仅有Command类型,还有诸如Theme ColorFile Icon ThemeDebug等,其中Webview类型的插件可以像浏览器一样在编辑器里打开网页。

使用React + Vite + react-router-dom开发VSCode Webview插件

一个简单的Webview插件

根据官网教程创建完后的项目目录如下:

使用React + Vite + react-router-dom开发VSCode Webview插件

项目默认用Webpack进行编译,此时还只是一个Command类型的插件,还需要做些改动才能变成Webview插件。

Panel Provider

一个用来管理Webview状态行为的类,注意这不是类组件,而是一个普通的class

由于Webview本身是一个沙箱环境,里面运行的HTML、CSS和JS是与插件上下文extension context)隔离的,所以需要一个方法让Webviewextension context进行通信。

可以将Webview看作是视图层extension context看作是逻辑层

Provider类需要包含如下方法:

  1. 创建和渲染Webview面板(一个render函数)
  2. 一个处理面板关闭的dispose函数
  3. 设置Webview要渲染的HTML
  4. 一个listener监听函数用来监听Webview和插件的数据传递,即视图层逻辑层的通信

src目录下创建panel文件夹,然后新建一个HelloWorldPanelProvider.ts文件:

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文件:

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

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文件:

使用React + Vite + react-router-dom开发VSCode Webview插件

默认是通过command命令启动插件的,即ctrl + shift + p,然后输入对应的command来启动插件。

运行

npm run wtach

此命令可以开启一个Webpack热更新,当监听到文件改动时可以自动编译。

编译完成后项目中会多一个out文件夹,该输出路径可以在package.json中通过main属性修改。

使用React + Vite + react-router-dom开发VSCode Webview插件

然后按下键盘上的F5开启debug模式,会新打开一个VSCode window,在新的窗口中按下ctrl + shift + p(Mac将ctrl替换成cmd),然后在文本框中输入Hello World(即在package.json中配置的command),最后按下回车就可看到Webview

使用React + Vite + react-router-dom开发VSCode Webview插件

使用React + Vite + react-router-dom开发VSCode Webview插件

点击Button可以看到右下角输出的message:

使用React + Vite + react-router-dom开发VSCode Webview插件

引入React和Vite

跟着前面的步骤构建了一个Webview,再深入一点思考的话,这个Webview插件还有可以改进的地方:

  1. 视图层可以单独抽离成SAP
  2. 最终呈现的效果像是打开了一个文件,如果能像左侧Product Bar一样嵌入Webview,如下图所示:

使用React + Vite + react-router-dom开发VSCode Webview插件

针对第一点,通过Vite脚手架创建一个webview-uiReact项目:

pnpm create vite webview-ui --template react-ts

使用React + Vite + react-router-dom开发VSCode Webview插件

然后就可以像开发SPA项目一样开发插件的视图层。而之前项目中的webview文件夹就可以删掉了,只保留PanelProvider相关代码即可:

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

pnpm add @vscode/webview-ui-toolkit

pnpm add @types/vscode-webview -D

然后再通过一个包装类将VSCode API导出:


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

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/>组件:

使用React + Vite + react-router-dom开发VSCode Webview插件

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进行路由间的切换:

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目录):

"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"
        }
      ]
    }
  },

其中viewsContainerswebview插件的容器,该容器是一个Activity Bar,即编辑器左侧那一列,views表示该插件的类型。

最终效果

home页: 使用React + Vite + react-router-dom开发VSCode Webview插件

点击Confirm按钮后的路由跳转:

使用React + Vite + react-router-dom开发VSCode Webview插件

参考资料:

VSCode API官网

react-router-dom

官方Demo