Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

简单聊一聊 React 和 VSCode Webview (二) #20

Open
Tracked by #19
hacker0limbo opened this issue Feb 11, 2021 · 2 comments
Open
Tracked by #19

简单聊一聊 React 和 VSCode Webview (二) #20

hacker0limbo opened this issue Feb 11, 2021 · 2 comments
Labels
react react 笔记整理 typescript typescript 笔记整理 vscode VSCode 笔记整理

Comments

@hacker0limbo
Copy link
Owner

hacker0limbo commented Feb 11, 2021

上一篇文章 主要讲了如何配置 Webview 的 React 开发环境. 这篇文章主要想谈谈开发 Webview 时候可能遇到的场景和问题, 一些细节就不展开了.

代码地址: https://github.com/hacker0limbo/vscode-webview-react-boilerplate

最后的效果:
screenshot

需求

需求很简单, 主要实现三个页面, 一个导航栏以及一个刷新按钮:

  • 导航栏: 点击能够跳转到三个页面
  • 刷新按钮: 点击能够刷新整个 Webview, 类似于浏览器的 Reload 功能
  • 页面:
    • Home: 主页面
    • About: 详情页面, 会发送请求, 得到之后渲染数据
    • Message: 有两个子页面, 一个用于接收从 Extension 发送的消息并实时渲染, 一个类似表单可以往 Extension 发送消息

由于我不会 CSS, 最后效果看上去可能有点丑, 就不要在意这些细节了

导航栏与路由

Webview 默认应该是不支持 URL 的, 所以如果用 BrowserRouter 可能会失效. 为了支持路由, 这里改用 MemoryRouter, 用法也是非常简单, 以我的场景为例, 只需配置一下需要的路由端点即可:

import { MemoryRouter as Router, Link } from 'react-router-dom';

<Router initialEntries={['/', '/about', '/message', '/message/received', '/message/send']}>
  <ul className="navbar">
    <li>
      <Link to="/">Home</Link>
    </li>
    <li>
      <Link to="/about">About</Link>
    </li>
    <li>
      <Link to="/message">Message</Link>
    </li>
  </ul>
</Router>;

更多的用法还是参考官网的 API, 这里不展开

消息

消息的传递

ExtensionWebview 本身都支持接收和发送消息, 消息的类型没有限制, 官方给的都是 any. 这里简单介绍各自一下具体的 API

Extension 里接收和发送消息:

// 初始化
const panel = vscode.window.createWebviewPanel({ ... })

// 接收从 Webview 发送过来的消息
panel.webview.onDidReceiveMessage(
  (message: any) => {
    console.log('message from webview: ': message)
  },
  undefined,
  context.subscriptions
);

// 发送消息给 Webview
panel.webview.postMessage(...);

Webview 里接收和发送消息:

// 接收从 Extension 发送过来的消息
window.addEventListener('message', (event: MessageEvent<any>) => {
  const message = event.data;
  console.log('message from extension: ', message)
});

// 发送消息给 Extension
const vscode = acquireVsCodeApi();
vscode.postMessage(...);

消息的类型

由于默认所以消息类型都是 any, 在开发的时候会有一些不方便. 因此最好规定一下消息的格式. 这里只简单讲一下我的规定.

src/view/message 里新建一个 messageTypes.ts 专门存放消息类型

export type MessageType = 'RELOAD' | 'COMMON';

export interface Message {
  type: MessageType;
  payload?: any;
}

export interface CommonMessage extends Message {
  type: 'COMMON';
  payload: string;
}

export interface ReloadMessage extends Message {
  type: 'RELOAD';
}

Message 为最基本的消息类型, 有两个属性, type 表示当前消息属于哪种类型, payload 为消息的数据, 可选. 有点像 ReduxAction 了...

至于是否需要定义消息是属于发送还是接收, 我个人觉得没有太大意义, 由于只存在 ExtensionWebview 两个载体, 也就是一对一关系, 在任何一方做接收或者发送的时候其实就已经能很清楚的知道这个消息的起始点或者重点对应是哪一方. 而对于定义消息的类型 type 反而很有必要, 目的是为了在一方接收的时候做区分, 不同的消息类型所携带的 payload 也是不同的. 有了 type 之后开发也可以做类型守护或者类型断言来更加严格定义消息的类型.

注入 vscode

关于在 webview 里接收和发送消息, 虽然官方提到可以很简单的使用 const vscode = acquireVsCodeApi(); 来获取 vscode 变量进而发送消息. 但由于我们 webview 使用的是 ts. 这种注入的变量是没有任何类型声明, 编译器不知道它哪来的会直接报错. 这里其实有很多方法来解决, 为了方便我的做法是在 app 文件(也就是 Webview React 目录)下新建一个 global.d.ts, 手动声明 vscode 类型, 同时在之前的 ViewLoader.ts 文件的 render() 方法里提前注入 vscode 变量:

// src/view/ViewLoader.ts

export class ViewLoader {
  // ...
  render() {
    const bundleScriptPath = this.panel.webview.asWebviewUri(
      vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app', 'bundle.js'))
    );

    return `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>React App</title>
        </head>
    
        <body>
          <div id="root"></div>
          <script>
            const vscode = acquireVsCodeApi();
          </script>
          <script src="${bundleScriptPath}"></script>
        </body>
      </html>
    `;
  }
}
// app/global.d.ts

type Message = import('../src/view/messages/messageTypes').Message;

type VSCode = {
  postMessage<T extends Message = Message>(message: T): void;
  getState(): any;
  setState(state: any): void;
};

declare const vscode: VSCode;

这里 postMessage() 简单做一下泛型...

Reload

Webview 本身不提供类似浏览器的刷新按钮, 万幸的是 vscode 提供了一个命令用于刷新所有的 Webview: 'workbench.action.webview.reloadWebviewAction', 对于单个的 Webview 不支持. 具体讨论可以看这个 ISSUE

实现思路很简单, 由于这个命令是需要从 Extension 层面触发, Webview 发送一条消息通知 Extension, Extension 接收消息触发 reload 命令, 简易代码如下:

// app/components/App.tsx

import React from 'react';

export const App = () => {
  const handleReloadWebview = () => {
    vscode.postMessage<ReloadMessage>({
      type: 'RELOAD',
    });
  };

  return <button onClick={handleReloadWebview}>Reload Webview</button>;
};
// src/view/ViewLoader.ts

export class ViewLoader {
  public static currentPanel?: vscode.WebviewPanel;

  private panel: vscode.WebviewPanel;
  private context: vscode.ExtensionContext;
  private disposables: vscode.Disposable[];

  constructor(context: vscode.ExtensionContext) {
    this.context = context;
    this.disposables = [];

    this.panel = vscode.window.createWebviewPanel('reactApp', 'React App', vscode.ViewColumn.One, {
      enableScripts: true,
      retainContextWhenHidden: true,
      localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app'))],
    });

    // render webview
    this.renderWebview();

    // listen messages from webview
    this.panel.webview.onDidReceiveMessage(
      (message: Message) => {
        if (message.type === 'RELOAD') {
          vscode.commands.executeCommand('workbench.action.webview.reloadWebviewAction');
        }
      },
      null,
      this.disposables
    );

    this.panel.onDidDispose(
      () => {
        this.dispose();
      },
      null,
      this.disposables
    );
  }

  // ...
}

Home 页面

没啥说的...

About 页面

这个页面会往服务端发送 Http 请求, API 我直接用的网上给的 Random User API, 请求端点: https://randomuser.me/api/. 该 API 会返回随机伪造的用户数据, 页面会以列表形式渲染用户的姓名, 性别和邮箱地址

同时, 该 API 支持参数, 比如允许请求的用户只为男性, 那么 URL 变为: https://randomuser.me/api?gender=male. 这个参数我们可以选择让用户在 VSCodesettings 里自行配置, 然后 Webview 从中读取配置.

配置

官方文档有详细的说明如何做配置, 这里我做的配置如下:

{
  "contributes": {
    "configuration": {
      "title": "Webview React",
      "properties": {
        "webviewReact.userApiGender": {
          "type": "string",
          "default": "male",
          "enum": ["male", "female"],
          "enumDescriptions": [
            "Fetching user information with gender of male",
            "Fetching user information with gender of female"
          ]
        }
      }
    }
  }
}

最后在 VSCode 的配置 UI 展示为一个下拉框, 默认值为 male.

读取配置也很简单:

// src/config/index.ts

import * as vscode from 'vscode';

export const getAPIUserGender = () => {
  const gender = vscode.workspace.getConfiguration('webviewReact').get('userApiGender', 'male');
  return gender;
};

注入配置到 Webview

和之前注入 vscode 变量一样, 在 render() 方法里注入 gender, 同时在 global.d.ts 文件里声明好类型

// src/view/ViewLoader.ts

export class ViewLoader {
  // ...
  render() {
    const bundleScriptPath = this.panel.webview.asWebviewUri(
      vscode.Uri.file(path.join(this.context.extensionPath, 'out', 'app', 'bundle.js'))
    );

    const gender = getAPIUserGender();

    return `
      <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>React App</title>
        </head>
    
        <body>
          <div id="root"></div>
          <script>
            const vscode = acquireVsCodeApi();
            const apiUserGender = "${gender}"
          </script>
          <script>
            console.log('apiUserGender', apiUserGender)
          </script>
          <script src="${bundleScriptPath}"></script>
        </body>
      </html>
    `;
  }
}
// global.d.ts

type Message = import('../src/view/messages/messageTypes').Message;

type VSCode = {
  postMessage<T extends Message = Message>(message: T): void;
  getState(): any;
  setState(state: any): void;
};

declare const vscode: VSCode;

declare const apiUserGender: string;

画页面

没啥说的, 这部分反而是最简单的

import React, { useState, useCallback, useEffect } from 'react';
import { apiUrl } from '../api';

type UserInfo = {
  name: string;
  gender: string;
  email: string;
};

export const About = () => {
  const [userInfo, setUserInfo] = useState<UserInfo>({
    name: '',
    gender: '',
    email: '',
  });
  const [loading, setLoading] = useState(false);

  const fetchUser = useCallback(() => {
    setLoading(true);
    fetch(apiUrl)
      .then((res) => res.json())
      .then(({ results }) => {
        const user = results[0];
        setLoading(false);
        setUserInfo({
          name: `${user.name.first} ${user.name.last}`,
          gender: user.gender,
          email: user.email,
        });
      })
      .catch((err) => {
        setLoading(false);
      });
  }, []);

  useEffect(() => {
    fetchUser();
  }, [fetchUser]);

  return (
    <div>
      <h1>About</h1>
      <h3>User Info</h3>
      {loading ? (
        <div>Loading...</div>
      ) : (
        <ul>
          <li>Name: {userInfo.name}</li>
          <li>Gender: {userInfo.gender}</li>
          <li>Email: {userInfo.email}</li>
        </ul>
      )}
      <button onClick={fetchUser}>Fetch</button>
    </div>
  );
};

数据请求我就用了原生的 Fetch, 因为我不想再装库了...

这里有一个小 BUG, 如果用户在打开 Webview 之后再更新了配置, 点击按钮之后请求的数据还是更新前的. 原因在于 render() 方法中的 html 并不会根据配置的更新而重新渲染, 要改其实也不难, VSCode 提供了一个 onDidChangeConfiguration 的方法用于监听配置更改, 只要在这个方法中重新渲染 html 即可. 但因为本人比较懒, 就没实现这个需求...

Message

该页面有两个子页面, 一个为 ReceivedMessages.tsx, 一个为 SendMessage.tsx. 前者用于监听 Extension 发送过来的消息, 后者可以发送消息给 Extension.

ReceivedMessages

Extension 端, VSCode 提供一个 InputBox API showInputBox, 可供输入简单的单行文本. 当用户输入文本之后按下 Enter 键, 消息即被发送到 Webview. 如果选择 ESC, 不做任何操作

// src/extension.ts

const disposable = vscode.commands.registerCommand('extension.sendMessage', () => {
  vscode.window
    .showInputBox({
      prompt: 'Send message to Webview',
    })
    .then((result) => {
      result &&
        ViewLoader.postMessageToWebview<CommonMessage>({
          type: 'COMMON',
          payload: result,
        });
    });
});

Webview 端需要做监听, 如果直接放在 ReceivedMessages 这个比较深的组件里, 虽然是可行的. 但切换路由的时候组件就 umount 了, 没有了监听即使 Extension 发送了任何消息过来也不会有任何响应. 所以我选择放在 App.tsx 这个比较顶层的组件, 该组件一直存在. 监听到的消息通过 context 传递给 children 组件. 任何组件有需要消息的, 只要订阅 context 即可.

当然了, 怎么写放在哪还是看具体的业务需求. 这里只是提供思路

// app/context/MessageContext.tsx

import React from 'react';

export const MessagesContext = React.createContext<string[]>([]);
// app/components/App.tsx

export const App = () => {
  const [messagesFromExtension, setMessagesFromExtension] = useState<string[]>([]);

  const handleMessagesFromExtension = useCallback(
    (event: MessageEvent<Message>) => {
      if (event.data.type === 'COMMON') {
        const message = event.data as CommonMessage;
        setMessagesFromExtension([...messagesFromExtension, message.payload]);
      }
    },
    [messagesFromExtension]
  );

  useEffect(() => {
    window.addEventListener('message', (event: MessageEvent<Message>) => {
      handleMessagesFromExtension(event);
    });

    return () => {
      window.removeEventListener('message', handleMessagesFromExtension);
    };
  }, [handleMessagesFromExtension]);

  // ...

  return (
    <MessagesContext.Provider value={messagesFromExtension}>
      <Switch>
        <Route exact path="/">
          <Home />
        </Route>
        <Route path="/about">
          <About />
        </Route>
        <Route path="/message">
          <Message />
        </Route>
      </Switch>
    </MessagesContext.Provider>
  );
};

具体渲染消息的页面就不多说了

SendMessage

Webview 作为发送端, 渲染一个 input 框和一个 button, 不多描述, 代码如下:

import React, { useState } from 'react';
import { CommonMessage } from '../../src/view/messages/messageTypes';

export const SendMessage = () => {
  const [message, setMessage] = useState('');

  const handleMessageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setMessage(e.target.value);
  };

  const sendMessage = () => {
    vscode.postMessage<CommonMessage>({
      type: 'COMMON',
      payload: message,
    });
  };

  return (
    <div>
      <p>Send Message to Extension:</p>
      <input value={message} onChange={handleMessageChange} />
      <button onClick={sendMessage}>Send</button>
    </div>
  );
};

Extension 作为接收端, 在 ViewLoader.ts 里接受消息. 这里选择将接受的消息以 InformationMessage Dialog 的形式展示出来:

this.panel.webview.onDidReceiveMessage(
  (message: Message) => {
    if (message.type === 'RELOAD') {
      vscode.commands.executeCommand('workbench.action.webview.reloadWebviewAction');
    } else if (message.type === 'COMMON') {
      const text = (message as CommonMessage).payload;
      vscode.window.showInformationMessage(`Received message from Webview: ${text}`);
    }
  },
  null,
  this.disposables
);

后记

至此整个项目就算完善了, 可能有更加复杂的业务场景没有考虑到, 后续遇到了也会及时更新. 本人水平也不高, 开发的时候可能也有很多地方有错误, 看代码的话也请及时指出.

Webview 只算开发插件的很小一部分. VSCode 整个生态系统是非常庞大的, 同时也暴露了非常好的接口供开发者自行开发插件, 虽然有些时候文档不一定全, 但大多数问题还是可以通过谷歌, Stackoverflow, 或者搜 Github Issue 来解决的.

社区也有对应的中文文档, 地址: https://liiked.github.io/VS-Code-Extension-Doc-ZH/#/

新年快乐!

@hacker0limbo hacker0limbo added react react 笔记整理 typescript typescript 笔记整理 vscode VSCode 笔记整理 labels Feb 11, 2021
@tjx666
Copy link

tjx666 commented Mar 22, 2022

有个专门的 package @types/vscode-webview 定义了 acquireVsCodeApi 的返回类型。
另外 https://zhuanlan.zhihu.com/p/483842887 讲了怎么实现热更新

@hacker0limbo
Copy link
Owner Author

@tjx666 多谢, 我之前写这篇文章的时候貌似是还没有对应这个类型的包, 当时的解决办法也是去 stackoverflow 上问别人给的答案. 现在来看文章其实很多地方过时了, 自己也很长一段时间没去碰过 vscode 的插件开发. 有空我去研究拜读一些你的文章, 感谢大佬!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
react react 笔记整理 typescript typescript 笔记整理 vscode VSCode 笔记整理
Projects
None yet
Development

No branches or pull requests

2 participants