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

一个简单的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类需要包含如下方法:

  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-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按钮后的路由跳转:

参考资料:

VSCode API官网

react-router-dom

官方Demo

相关推荐
miss16 分钟前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan1 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真1 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment1 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan1 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么1 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技2 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue
西安小哥2 小时前
端侧跑大模型,真正的门槛不在参数量:一次 596 秒到 10 秒的压缩路径复盘
前端·人工智能
陈随易2 小时前
Cloudflare 出钱,Vite 团队挑人,44 个维护者分 30 万美元
前端·后端·程序员
恋猫de小郭3 小时前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter