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

一个简单的Hello World插件

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

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

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

一个简单的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文件:

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插件还有可以改进的地方:

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

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

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

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

最终效果

home页:

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

参考资料:

VSCode API官网

react-router-dom

官方Demo

相关推荐
y先森1 小时前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy1 小时前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu10830189111 小时前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿2 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡3 小时前
commitlint校验git提交信息
前端
虾球xz4 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇4 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒4 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员4 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐4 小时前
前端图像处理(一)
前端