简单聊一聊 React 和 VSCode Webview (二)
hacker0limbo opened this issue · 2 comments
上一篇文章 主要讲了如何配置 Webview 的 React 开发环境. 这篇文章主要想谈谈开发 Webview 时候可能遇到的场景和问题, 一些细节就不展开了.
代码地址: https://github.com/hacker0limbo/vscode-webview-react-boilerplate
需求
需求很简单, 主要实现三个页面, 一个导航栏以及一个刷新按钮:
- 导航栏: 点击能够跳转到三个页面
- 刷新按钮: 点击能够刷新整个 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, 这里不展开
消息
消息的传递
Extension
和 Webview
本身都支持接收和发送消息, 消息的类型没有限制, 官方给的都是 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
为消息的数据, 可选. 有点像 Redux
的 Action
了...
至于是否需要定义消息是属于发送还是接收, 我个人觉得没有太大意义, 由于只存在 Extension
和 Webview
两个载体, 也就是一对一关系, 在任何一方做接收或者发送的时候其实就已经能很清楚的知道这个消息的起始点或者重点对应是哪一方. 而对于定义消息的类型 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
. 这个参数我们可以选择让用户在 VSCode
的 settings
里自行配置, 然后 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/#/
新年快乐!
有个专门的 package @types/vscode-webview 定义了 acquireVsCodeApi 的返回类型。
另外 https://zhuanlan.zhihu.com/p/483842887 讲了怎么实现热更新
@tjx666 多谢, 我之前写这篇文章的时候貌似是还没有对应这个类型的包, 当时的解决办法也是去 stackoverflow 上问别人给的答案. 现在来看文章其实很多地方过时了, 自己也很长一段时间没去碰过 vscode 的插件开发. 有空我去研究拜读一些你的文章, 感谢大佬!