本文最后更新于:2022年3月31日 凌晨
前言
vscode 扩展市场
这个 idea 起源于吾辈从 webstorm 切换到 vscode 的几周后,在上个周末,吾辈想到,为什么 Jetbrains IDE 都有 项目创建引导面板,而 vscode 却并不支持,而只能使用命令行工具呢?念及于此,吾辈便打算自行在 vscode 中实现对等插件。
吾辈之前也为 webstorm 开发了一个生成 vite 项目的插件 Vite Integrated
最终效果
![new project.gif](/resources/6ac71ef7d19d409c8e7cef995c396876.gif)
思路
基本思路是通过 vscode webview 渲染配置,然后在主线程拼接执行 npx 命令来创建项目。具体来说,从用户的角度而言,分为以下几步
- 通过命令或 ui 上的菜单打开项目创建面板
- 选择使用的生成器
- 选择生成项目的位置
- 选择或输入一些生成器需要的配置
- 创建项目
- 如果是当前项目的子目录,打开目录中的 package.json,否则打开一个新的窗口
遇到的一些问题
- 如何使用 react 开发视图层
- 如何保证 webview 的界面风格与 vscode 保持一致
- 如何实现渲染层与主线程通信
- 使用 npm create 还是 npx
- 如何持久化状态
如何使用 react 开发视图层
吾辈起初想将 webview 视图层独立开发部署至 github pages 上,而 vscode 插件的部分仅包含 bundle。但 vscode 默认需要使用 html 字符串作为入口,因而让事情变得有些复杂,幸好吾辈找到了官方的示例项目 hello-world-vite,这让集成吾辈熟悉的技术栈变得简单了一些,该项目便是在此模板上修改。
如何保证 webview 的界面风格与 vscode 保持一致
这个问题在早前其实会有点复杂,但随着 vscode 官方推出 ui 库 vscode-webview-ui-toolkit,并且在近期推出了默认集成 react 之后,这个问题变得简单多了 – 当然,并不意味着尽善尽美了。
官方示意图
![toolkit-artwork](/resources/4d90e30cfde74c23bef01991a8ea261b.png)
它提供一些基本的组件
badge
button
checkbox
data-grid
divider
dropdown
link
option
panels
progress-ring
radio
radio-group
tag
text-area
text-field
但是,吾辈在实际使用时仍然发现一些问题,主要包括
- 组件的类型定义不准确,导致严重需要依赖于 storybook 的文档,梦回 JavaScript
- 一些组件不符合直觉,例如
panels/select/text-field
和一般做法存在些许不同,panels
tab/panel 要写两次,select
没有分离 value/label
,text-field
使用
- ui 库不支持 tree shaking,导致 bundle 比较大,项目占比 439.98kb/74.79%
依赖树实在可怕,正因如此,吾辈才没有去替换 react => preact
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @vscode/codicons 0.0.29 @vscode/webview-ui-toolkit 0.9.3 ├── @microsoft/fast-element 1.8.0 ├─┬ @microsoft/fast-foundation 2.37.2 │ ├── @microsoft/fast-element 1.8.0 │ ├── @microsoft/fast-web-utilities 5.1.0 │ ├── tabbable 5.2.1 │ └── tslib 1.14.1 ├─┬ @microsoft/fast-react-wrapper 0.1.43 │ ├── @microsoft/fast-element 1.8.0 │ ├─┬ @microsoft/fast-foundation 2.37.2 │ │ ├── @microsoft/fast-element 1.8.0 │ │ ├── @microsoft/fast-web-utilities 5.1.0 │ │ ├── tabbable 5.2.1 │ │ └── tslib 1.14.1 │ └── react 17.0.2 peer └── react 17.0.2 peer
|
![Snipaste\_2022-03-29\_22-24-32](/resources/8cb9112697704e6699908d0537c647fe.png)
如何实现渲染层与主线程通信
由于核心是通过 postMessage/onmessage
实现,所以与 web worker 或 iframe 通信非常相似。由于代码比较简单,所以吾辈并未封装复杂的通信逻辑。
- 确定了通信的基础数据结构
- 在主线程部分使用 map 保存
channel => handle
向渲染线程暴露的功能,在接收到渲染现成的消息后执行相应的方法并通过 postMessage 扔到渲染线程
- 在渲染线程简单封装,让调用时传入
channel + data
,即可调用到主线程暴露的方法并异步获取到结果
关键代码
主线程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const map = new Map<string, (...args: any[]) => any>() map.set('hello', (name: string) => `hello ${name}`) webview.onDidReceiveMessage( async (message: any) => { const { command, data = [], callback } = message if (!map.has(command)) { throw new Error(`找不到命令 ${command}`) } const res = await map.get(command)!(...data) if (callback) { console.log('callback: ', callback) this._panel.webview.postMessage({ command: callback, data: [res], }) } }, undefined, this._disposables, )
|
渲染线程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| import type { WebviewApi } from 'vscode-webview'
class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi<unknown> | undefined
constructor() { if (typeof acquireVsCodeApi === 'function') { this.vsCodeApi = acquireVsCodeApi() } }
public postMessage(message: unknown) { if (this.vsCodeApi) { this.vsCodeApi.postMessage(message) } else { console.log(message) } }
async invoke(options: { command: string default?: any args?: any[] }): Promise<any> { return await new Promise<string>((resolve) => { if (typeof acquireVsCodeApi !== 'function') { resolve(options.default) return } const id = Date.now() + '_' + Math.random() const listener = (message: MessageEvent) => { const data = message.data if (data.command === id) { resolve(data.data[0]) window.removeEventListener('message', listener) } } window.addEventListener('message', listener) vscode.postMessage({ command: options.command, data: options.args, callback: id, }) }) } }
export const vscode = new VSCodeAPIWrapper()
console.log(await vscode.invoke({ command: 'world' }))
|
使用 npm create 还是 npx
例如使用 vite 创建一个 react 项目的命令是
1
| npx --yes --package create-vite create-vite path --template=react-ts
|
使用 create-react-app 创建一个项目的命令是
1
| npx --yes --package create-react-app create-react-app path --template=typescript
|
可能有人会问了:“为什么没有使用类似 npm create
这个命令来创建项目呢?”
这背后确有缘由,乍看上去 create
命令已经被主流的包管理器 npm/yarn/pnpm
都支持了,但它有一些局限性
- npm 包名存在限制,不包含组织名的部分必须以
create-
开头,但事实上不是所有 cli 都以 create-
开头,例如 @angular/cli
pnpm create
功能和 npm 不对等,需要分别适配每个包管理器
- webstorm 仍然在使用 npx 实现项目创建,这也影响了吾辈的选择
所以选择 npx 也便理所当然了,后续可能支持全局配置使用 pnpx
。
如何持久化状态
vscode 其实提供了官方的解决方案,实现 WebviewPanelSerializer
即可让 acquireVsCodeApi()
的 getState/setState
都能被持久化,但它存在一些问题导致吾辈最终没有使用它。
- 仅在 webview 第一次渲染时能够恢复状态,但在没有关闭 vscode 之前多次更改都不会持久化,即如果你需要创建两个项目,第一次创建时选择了一些配置项,但第二次创建时配置项没有被缓存,而只有重开 vscode 时才会生效,这实在很奇怪(也有可能是吾辈用错了)
- 接口不支持 kv 存储,仅支持获取全量状态或设置全量状态
因而吾辈选择使用 context.globalState
持久化数据
主线程
1 2 3 4 5
| map.set('getState', (key: string) => this.globalState.get(key)) map.set('setState', (key: string, value: any) => this.globalState.update(key, value), )
|
渲染线程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import type { WebviewApi } from 'vscode-webview'
class VSCodeAPIWrapper { async getState(key: string): Promise<unknown | undefined> { if (this.vsCodeApi) { return await this.invoke({ command: 'getState', args: [key] }) } else { const state = localStorage.getItem(key) return state ? JSON.parse(state) : undefined } }
async setState<T extends unknown | undefined>( key: string, newState: T, ): Promise<void> { if (this.vsCodeApi) { return await this.invoke({ command: 'setState', args: [key, newState] }) } else { localStorage.setItem(key, JSON.stringify(newState)) } } }
export const vscode = new VSCodeAPIWrapper()
const store = (await vscode.getState(props.id)) as { location: string }
|
现在可以支持选择的生成器和配置项在下次使用时仍然会被缓存,这对于需要使用相同的生成器创建项目确实很方便,尤其是生成器选项比较多的情况下。
结语
vscode 目前在一些方面仍然比不上 webstorm,但其流行趋势简直势不可挡,许多前端项目甚至官方仅支持 vscode 编辑器,这让在某些时候变得没有选择(例如 vue3、nx)。所以与其抱怨,还不如尝试让它变得更好。