⚠️本文为掘金社区首发签约文章,未获授权禁止转载
温馨提示:结合本文配套源码阅读体验更佳!
前言
在团队降本提效的基建中,紫升开发了一款 vscode 插件,第一版我使用的是 vscode 内置 UI,虽说也能用,但是用户体验欠佳。由于 vscode 内置 UI 不够灵活,一番调研后我决定使用 webview 重构。
开发过 vscode 插件的同学可能对插件开发知识点多、文档阅读困难、参考资料少有所体会。基于 webview 开发插件更是如此,寻遍网络,虽然有优秀的项目,但却没有完整且优秀的教程。为了修炼 vscode 开发灵力,不妨和紫升一起挑战从零到一开发一款基于 webview 的 vscode 插件。
Hello vscode
英雄多起于市井,高楼皆起于平地。再伟大的软件也都是从 Hello World 开始的,本章尽量用最简洁的语言描述一个 vscode 插件 Hello World 的诞生。
初始化项目
安装 Yeoman 和 VS Code Extension Generator:
1 | $ npm install -g yo generator-code |
这个脚手架会生成一个可以立马开发的项目。运行生成器,然后填好下列字段:
1 | $ yo code |
提交记录:hello world
代码规范
默认的脚手架生成的也有 ESLint 配置,但是 Editor、Prettier 的配置都没有,并且 ESLint 配置也不符合我的习惯。紫升关于前端工程化的包都在 youngjuning/luozhu, ESlint 配置的包是 @luozhu/eslint-config-*
。由于我们开发插件使用的是 Typescript,所以我们选择 @luozhu/eslint-config-typescript
。
安装依赖:
1 | $ yarn add @luozhu/eslint-config-typescript @luozhu/prettier-config prettier -D |
具体配置:
配置涉及文件较多,请参考 coding-style,不关心的同学也可以直接略过。
提交检测:
安装依赖:
1 | $ yarn add lint-staged yorkie -D |
修改配置:
1 | // package.json |
eslint –fix:
修改完配置之后需要执行 fix 对所有文件格式化一次。
1 | $ yarn lint --fix |
约定式提交
约定式提交我使用的是渐进式脚手架 @luozhu/create-commitlint
,在项目中执行 npx @luozhu/create-commitlint
即可使项目符合规范化提交的配置。对规范化提交不了解的同学,强烈建议读一下 一文搞定 Conventional Commits 。
调试
按下 F5
开启调试会出现[扩展开发宿主]窗口,然后按 Command+Shift+P
组件键输入 Hello World
命令。如下图所示 vscode 弹出了 Hello World from Juejin Posts!
的提示。
同时我们的开发窗口中,会出现一个 watch 任务的终端:
开发窗口的调试控制台会输出插件运行日志(忽略红色的警告):
调试执行的任务是在 .vscode/tasks.json
中配置的:
1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 |
打包
我们的插件开发完成前,想要分享给小伙伴体验可以吗?答案是肯定的,vscode 为我们提供了 vsce 实现这个需求,我们将 vsce 模块安装到全局,然后使用 vsce package
命令尝试打包:
1 | $ vsce package |
啊,咋还报错了?publisher
是啥??一脸懵逼。不慌,按链接 我知道了 publisher 是一个可以将扩展发布到Visual Studio Code Marketplace 的身份。每个扩展都需要在其 package.json
文件中包含一个发布者名称。如果注册发布者我们后面详说,这里我们把 publisher
设置为 luozhu
。
1 | $ vsce package |
额,裂开,这咋还报错,假装淡定,读一下提示原来是要我们编辑一下 README.md,没错,vscode 模板里有初始的 README,我们需要编辑一下才可以打包。修改后再次尝试 vsce package
:
终于,打包成功!为了追求完美,最后我们再来做一些优化工作:
- 执行
vsce package
的时候加上--no-yarn
- 在 package.json 中加上
repository
字段即可看不到任何警告。 - 为了便捷,我们将 vsce 安装到项目中,然后把
vsce package --no-yarn
添加到 npm scripts 中。 - package.json 加上
license
字段。
然后再次尝试 yarn package
就完美了:
提示:vsce package 会先执行
vscode:prepublish
这个预发布脚本去编译项目。
打包原理
如过你也跟着一路敲到了这里,此时你会在项目根目录发现 vsix
结尾的文件:
这就是 vscode 插件的安装包,我们先不急着安装,先一起来看一下这个文件是个什么东西。尝试用归档工具解压后得到如下目录文件夹:
我们可以看到编译后的文件夹 out
和其他一些文件是被直接压缩进安装包的,聪明的你肯定发现了 .cz-config.js
、.prettierrc.js
和 commitlint.config.js
这种开发时文件也被压缩了,运行插件完全用不到,这明显不合理。其实和其他插件体系一样,vscode 也提供了 .vscodeignore
来实现打包忽略配置,我们将以上无关文件忽略重新打包即可。
原理就这?不存在的,我们打开 extension.js
会发现引用了 vscode
这个包:
但是我们的安装包中并没有 _node_modules_,那么 vscode 这个包存在在哪里呢?我猜的是挂在 node 环境上了,读了源码后我发现我竟然是对的:
vscode 实现了拦截器在加载 Node 环境的时候将 vscode 给添加到了内置包中,这样的好处是减小插件的体积。
那么我们如果使用三方插件呢?以常用的 lodash 为例,安装 lodash 之后重新打包:
1 | $ yarn package |
这个时候提示我们有 1000 多个文件,大概率 node_modules 文件夹被打包了,我们来解压下见证一下:
不出所料,vscode 默认的打包方式就是简单的编译拷贝,通过忽略文件减小体积也是杯水车薪。而且 vscode 扩展的规模往往增长很快。它们是在多个源文件中编写的,并依赖于 npm 的模块。分解和重用是开发的最佳实践,但在安装和运行扩展时,它们是有代价的。加载 100 个小文件要比加载一个大文件慢得多。这就是我们推荐捆绑的原因。捆绑是将多个小的源文件合并成一个文件的过程。
在 JavaScript 中,有不同的打包工具可以用,流行的有 rollup.js、Parcel、esbuild 和 webpack,官方脚手架默认只能选 webpack,我们这里推荐直接使用更快更强的 esbuild。
提交记录:chore: ignore config file when package、chore: add esModuleInterop to tsconfig
使用 esbuild 优化打包
安装依赖:
1 | $ yarn add -D esbuild |
npm scripts:
1 | "scripts": { |
注意:由于 watch 改成了 esbuild-watch,所以 .vscode/tasks.json 中的 scripts 子段也需要做相应修改。
vscode tasks:
理论上我们把打包命令改成 esbuild 之后,应该将 vscode 任务中的问题匹配程序设置为 $esbuild-watch
,但是 vscode 会提示我们无法识别的问题匹配程序:
尝试搜索扩展,果然有一个 esbuild Problem Matchers 插件,我们将其安装并添加 "connor4312.esbuild-problem-matchers"
到 .vscode/extensions.json 文件的 recommendations
中。
忽略文件:
我们使用 esbuild 打包后会将使用到的代码都打包进 out/extension.js
,但是 vsce 的打包机制是不管你有没有用到都会把 dependencies
中的包打进安装包中,所以我们需要将 node_modules 忽略掉。
成果展示:
从图中我们可以看到,安装包的体积大大减小了。
集成 umijs
初始化 umi 项目
使用 umi 脚手架在根目录新建一个 web 目录。
1 | $ mkdir web && cd web |
通过官方工具创建项目:
1 | $ yarn create @umijs/umi-app |
修改 .umirc.ts 配置:
1 | import { defineConfig, IConfig } from 'umi'; |
修改 package.json
加入 name
、version
、description
:
1 | { |
忽略文件
.gitignore:
将 vscode 扩展和 umijs 脚手架生成的 gitignore 合并为一下内容:
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. |
.vscodeignore:
由于 vscode 打包的时候只需要获取 umijs 打包后的产物,所有加入 web/**
和 !web/dist/**
将无用的文件忽略掉。
1 | .vscode/** |
yarn workspace
由于我们的项目是 vscode 扩展和 web 项目混合的项目。为了方便管理脚本和依赖,我们引入了 yarn workspace
来管理项目。在根目录的 package.json 中加入以下配置即可:
1 | { |
调试
由于我们的 web 项目也需要编译,所以我们需要修改一下 vscode launch.json
加入 web 项目的编译任务。配置参考了 appworks。
首先在根目录的 package.json
的 scripts 中添加:
1 | { |
然后修改 .vscode/launch.json 配置为:
1 | // A launch configuration that compiles the extension and then opens it inside a new window |
完成后进入 VS Code,按下F5
,你会立即看到一个插件发开主机窗口,其中就运行着插件。这时候运行你会发现控制台报一下错误 ❌:
1 | error TS6059: File '/Users/luozhu/Desktop/github/juejin-posts/web/src/pages/index.tsx' is not under 'rootDir' '/Users/luozhu/Desktop/github/juejin-posts/src'. 'rootDir' is expected to contain all source files. |
原因是因为 umi 的约定的项目结构和 vscode extension 都包含 src 目录。由于 vscode 插件和 umi 的编译是分开的,我们在根目录的 tsconfig.json 中将 web 目录忽略即可:
1 | { |
现在,你可以按下 F5
看到插件发开主机窗口的同时还会看到两个调试任务:
注意📢:请选择 Debug Extension 调试任务而不是 Run Extension
其他优化工作
- 由于基于 yarn workspace,我们把公用的依赖合并
- 合并 Eslint 配置并使用
@luozhu/eslint-config-react-typescrip
- 合并 Editorconfig 和 Prettier 配置
- 添加
prestart
和prebuild
script - 设置
HTML=none umi build
提交记录:chore: config umijs
vscode 插件开发核心概念
在开始 webview 能力开发之前,我们有必要了解一下 vscode 插件开发的核心概念。为了有个全局的理解,我们先来看下我们现在项目的主要目录结构:
1 | . |
从目录结构可以看出,关键的文件是 package.json
和 extension.ts
,我们以 helloWorld 命令为例介绍下 vscode 插件的三个核心概念。
1. 激活事件
激活事件是在 package.json
中的 activationEvents
字段声明的一个 JSON 数组对象。为了注册 helloWorld 这个命令,第一步就是注册激活事件,激活事件类型有很多,注册命令的激活事件是 onCommand
:
1 | { |
2. 发布内容配置
发布内容配置( 即 VS Code 为插件扩展提供的配置项)是 package.json
的 contributes
字段,你可以在其中注册各种配置项扩展 VS Code 的能力。上一步我们注册的 helloWorld 激活事件只是告诉了 vscode 可以通过 juejin-posts.helloWorld
命令触发。我们还需要再 contributes.commands
中注册我们的 juejin-posts.helloWorld
命令:
1 | { |
3. VS Code API
VS Code API 是 VS Code 提供给插件使用的一系列 Javascript API。通过前两个核心概念的能力,我们已经注册好了命令和事件,那么下一步必然就是注册事件回调。事件回调在 vscode 中是通过 vscode.commands.registerCommand
函数来注册的,下面 👇🏻 是我们在入口文件 src/extension.ts
中注册 juejin-posts.helloWorld
命令。
1 | // vscode 这个模块包含了 VS Code 扩展的 API |
集成 webview
注册命令
1、package.json 激活事件(activationEvents
)中添加 "onCommand:juejin-posts.start"
2、package.json 命令(commands
)中添加:
1 | { |
3、src/extension.ts 中注册命令
1 | context.subscriptions.push( |
创建 webview 面板
创建一个空白的面板
1 | import vscode from 'vscode'; |
我们使用了 window.createWebviewPanel API 创建了一个 webview 面板,现在我们尝试运行 juejin-posts.start
就可以打开一个 webview 面板:
给面板设置内容
上面我们创建了一个空白的面板,那么我们如何给面板添加内容呢?我们可以使用 panel.webview.html
来设置 HTML 内容:
1 | function getWebviewContent() { |
重新使用 juejin-posts.start
命令就可以调戏悠悠船长了:
限制 webview 视图为一个
1 | export function activate(context: vscode.ExtensionContext) { |
- vscode.window.activeTextEditor:获取当前活动的文本编辑器
- currentPanel.reveal():调用
reveal()
或者拖动 webview 面板到新的编辑布局中去。
设置 Icon
1 | // 设置 Logo |
在 vscode 扩展中我们需要通过 vscode.Uri.file
方法获取磁盘上的资源路径。
webview 获取内容的 Uri
你应该使用 asWebviewUri
管理插件资源。不要硬编码 vscode-resource://
,而是使用 asWebviewUri
确保你的插件在云端环境也能正常运行。
在 @luozhu/vscode-utils 中我们对获取本地资源路径做了封装:
1 | // 获取内容的 Uri |
使用 umi 开发 webview
上一节我们通过调戏悠悠船长熟悉了 webview 面板的创建,这一节我们来看下如何使用 umijs 来代替 HTML 的内容。
panel.webview.html
中的内容其实就是正常的 HTML+JavaScript+CSS 代码。你可以使用任何前端技术去编写它的内容,比如 jquery、bootstrap、Vue 以及 React。虽然本文的例子是基于 umijs 开发 webview 的内容,但是其他技术原理是一样的,紫升在后续也会提供多个技术的 vscode webview 开发脚手架。
封装获取 umijs 打包产物的方法
我们知道 umi build
命令会在 web/dist 产生 index.html、umi.js、umi.css 三个文件,我们根据 index.html 改造前面的 getWebviewContent 方法如下:
1 | import vscode from 'vscode'; |
提示:上面的方法我已经封装在 @luozhu/vscode-utils 的中。
我们使用 getUmiContent 重新前面的代码:
1 | import { getUmiContent } from '@luozhu/vscode-utils'; |
优化打包
由于我们封装了 getUmiContent
方法,umi build
生成的 index.html 就没有用了,我们可以使用 HTML=none umi build
命令在打包的时候不生成 index.html 文件。
另外目前 umijs 的 mfsu 不支持 writeToDisk 方法,如果后续支持了可以使用 mfsu 优化调试速度。
创建 webview 面板的任务大部分都比较重复,为了沉淀最佳实践,我在 @luozhu/vscode-utils 封装了 createUmiWebviewPanel 方法。
给 webview 内容加上主题
webview 可以基于当前的 VS Code 主题和 CSS 改变自身的样式。VS Code 将主题分成 3 种类别,而且在 body 元素上加上了特殊类名以表明当前主题,我们在 umi 中全局加入下面的样式:
1 | body.vscode-light { |
由于这部分适配大部分是通用的,所以我也将它封装进了 @luozhu/vscode-utils
的 getUmiContent
中了。
webview 与 vscode 交互
webview 中执行脚本
vscode 中的 webview 本质就是一个 iframe,因此我们是可以再 webview 中执行脚本的,只不过在 vscode 中 webview 默认禁用了 JavaScript,我们在调用 createWebviewPanel
API 时传入 enableScripts: true
即可。
插件传递信息给 webview
webview 的脚本能做到任何普通网页脚本能做到的事情,但是 webview 运行在自己的上下文中,脚本是不能访问 VS Code API 的。我们需要借助 postMessage 这种事件的方式传递信息。在 vscode 中,我们在 vscode 侧可以使用 Webview.postMessage 发布事件并发送任何序列化的 JSON 数据,在 webview 侧则使用 window.addEventListener('message' event => { ... })
来处理这些信息:
vscode 侧:
1 | // 注册一个新的命令 |
webview 侧:
1 | import { Modal } from 'antd'; |
效果:
webview 传递信息给插件
webview 反向传递信息给插件的原理也是一样的,只不过由于 webview 的上下文限制,我们只能通过 acquireVsCodeApi
函数获取阉割版的 VS Code API 对象,这个阉割的对象上有一个 postMessage
函数可以供我们发送事件用。注意 acquireVsCodeApi
个会话中只能调用一次,重复调用会报错。而在插件侧则可以通过 Webview.onDidReceiveMessage 处理 webview 传递的信息。我们来写一个在 webview 中调用 vscode.window.showInformationMessage
的例子:
webview 侧:
1 | const vscode = acquireVsCodeApi(); |
插件侧:
1 | // 处理 webview 中的信息 |
效果:
在 webview 中请求接口
一开始,我以为这是个轻松的工作,直到遇到跨域半天解决不了后我绝望了,在 VSCode WebView插件(扩展)开发实战 一文中我终于知道了 vscode webview 内部是不允许发送 ajax 请求,所有 ajax 请求都是跨域的,因为 webview 本身是没有 host 的。
人裂开了,这什么鬼呀,我们核心的需求就是请求掘金的接口获取我们的文章列表呀,那我们还有办法吗?答案是肯定的,其实还是借助上面我们提到的通信机制把请求接口的任务交给 vscode 去处理,完事再让 vscode 把数据通过 postMessage
返回给我们,多说无益,我们来看代码:
webview 侧:
1 | React.useEffect(() => { |
vscode 侧:
1 | // 处理 webview 中的信息,并返回接口请求的数据 |
@luozhu/vscode-channel
前面我们知道了使用 Webview.postMessage、Webview.onDidReceiveMessage、acquireVsCodeApi().postMessage
和 window.addEventListener
就可以满足各种通信需求了,那 @luozhu/vscode-channel
又是什么呢?
受 js-channel 启发,@luozhu/vscode-channel
主要是封装了 webview 与 vscode 交互流程,核心原理是通过暴露 call
、bind
方法抹平 API 的差异,减少重复代码量。其中参考 appworks 和 cs-channel 使用 uuid 保证交互的可靠性。Talk is cheap, show you the code:
webview 侧:
1 | // 创建 channel 对象 |
webview 中由于 acquireVsCodeApi 只能调用一次,之后又需要在多个地方使用,所以我们在
wev/src/layouts/index.ts
中创建一次并挂载到window
对象上比较合适。
vscode 侧:
1 | // vscode 侧的 channel 需要依赖上下文和 WebviewPanel 实例 |
vscode 国际化
我们都知道 vscode 中是可以切换语言环境的,一款优秀的 vscode 扩展至少要支持中英两种语言。而且支持国际化可以让你的插件受众直接突破国界限制。vscode 国际化分为三部分,一部分是配置的国际化,一部分是代码中的国际化,另一部分则是 webview 中 umijs 的国际化。本章我们就来具体看一下如何在 vscode 中实现国际化。
配置国际化
我们已经知道 vscode 中的配置都是在 package.json 中,而配置的国际化是约定在 package.nls.json
和 package.nls.zh-cn.json
这种文件中编写。比如我们要在中英文环境下命令配置中英文版本,我们可以在 package.nls.json
中写:
1 | { |
在 package.nls.zh-cn.json
写:
1 | { |
然后 package.json
中写:
1 | { |
代码中国际化
推荐使用紫升贡献过代码的 vscode-nls-i18n,使用方法也很简单,配置的话和上一节一样,在 src/extension.ts
中使用 init
方法初始化,然后使用 localize
方法实现国际化:
1 | import { init, localize } from 'vscode-nls-i18n'; |
umijs 国际化
umijs 的国际化需要使用 @umijs/plugin-locale
插件支持,这个插件封装了 react-intl
,配置方式如下:
1、.umirc.ts 中配置 local
1 | locale: {} |
2、在 src 目录下创建 locales
并创建 en.ts
或 zh-CN.ts
1 | // src/locales/en.js |
1 | // src/locales/zh-CN.js |
3、使用国际化
1 | import React from 'react'; |
4、切换语言
切换语言,我们需要使用 setLocale
方法,需要注意的是我们给这个方法第二个参数传入 false
来实现无刷新动态切换。
1 | import { setLocale } from 'umi'; |
不过,切换语言的时机在什么时候呢?切换时机就是我们语言环境改变的时机。在 vscode webview 环境中,其实当使用 Config display language
方法切换语言环境后,会要求 vscode 重启。也就说我们只需要在 webview 创建时设置一次语言环境即可。由于 vscode 和 webview 传值太困难,我们选择在 getUmiHTMLContent
时传如 vscode.env
:
1 | <script> |
然后,我们在 web/src/layouts/index.ts
中设置一下即可:
1 | setLocale(window.vscodeEnv.language, false); |
“掘金一下” 扩展核心实现
灵感来源于现实,作为掘金的重度使用者,几乎每篇文章和笔记都同步在这里。当有些知识忘记需要查阅或拷贝代码时,我就有在掘金搜索我的文章的需求。但是掘金的搜索是全站的,就算加上自己的名字搜索也会出现大量无关记录。“掘金一下” 这个名字就像插件功能一样,在你想搜索自己掘金文章的时候就可以打开插件“掘金一下” 进行搜索。
其实为了只搜索到自己的文章,我想到的还有开发 chrome 插件来实现。但是考虑到市场和便捷性,我最终还是决定开发 vscode 插件来落地这个灵感。本章就是综合前面的经验实现 “掘金一下” 的核心逻辑。
juejin-me.start
命令
vscode 侧开启 channel 通信
vscode 侧通过 channel.bind
绑定一个事件处理函数。
1 | import events from './events'; |
注意:我们不需要给定监听事件名,内部会根据 eventId 保证可靠性和全局唯一性
注册 events
events/index.ts:
1 | import requests from './requests'; |
events/requests:
1 | import vscode from 'vscode'; |
utils/request:
这里简单封装了基于 axios 的请求对象。
1 | /* eslint-disable no-param-reassign */ |
webview 中调用接口
channel 是在 web/src/layouts/index.tsx
中初始化并挂载到 window 上的,我们在 web/src/pages/index.tsx
中调用 window.channel.call
即可调用指定接口。由于我们需要模糊搜索所有的文章,所以我们需要在初始化页面时一次请求完所有数据。
1 | const Homepage = () => { |
更多具体实现细节就是一些页面编写逻辑,不是本文的重点,感兴趣的同学可以直接进查看源码。
配置掘金 ID
声明配置:
vscode 的配置我们需要借助 package.json 的 contributes.configuration
属性,我们的掘金 ID 是 string,所以声明如下:
1 | { |
修改配置的命令:
让用户打开设置去修改配置也可以,但是为了用户体验,我们提供了 juejin-me.configUserId
命令,我们来看下命令的实现:
1 | context.subscriptions.push( |
- vscode.window.showInputBox:打开一个输入框,提示用户输入掘金用户 ID
- vscode.workspace.getConfiguration:获取工作空间的配置对象
- WorkspaceConfiguration.update:更新一个配置值。
- InputBoxOptions.validateInput:一个可选的函数,被调用来验证输入信息并提示用户
插件效果展示
感兴趣的话你也可以直接在扩展中搜索“掘金一下”自行体验。
彩蛋
@luozhu/create-vscode-webview
本文中有很多最佳实践,为了方便之后创建新的项目时减少重复工作,紫升抽离出了一个简单的模板。掘友直接使用 yarn create @luozhu/vscode-webview myvscode
即可创建出一个属于自己的 vscode 扩展。参考本文的一些实践再加一些你的创意即可完成一个出色的基于 webview 的 vscode 扩展。
Word Count Juejin
为了答谢掘金平台和掘友一直以来的支持,我编写了一款专为掘金适配的 Markdown 文件字数统计 VS Code 扩展,字数统计会实时显示在状态栏。比起来 vscode 官方的 Word Count,我们支持中文字数统计,比起来 Word Count CJK,我们支持中英文混排。如果你也喜欢使用 VS Code 的 Markdown 编辑能力,那么一定不要错过紫升的这款插件,下载请认准:
如果你还在犹豫要不要下载,那不妨看下三个插件的统计对比,我们拿 i love juejin. 我爱掘金
这个字符串测试一下三款插件的功能:
Word Count | Word Count CJK | Word Count Juejin |
---|---|---|
4 个字 | 4 个字 | 7 个字 |
中文算成了一个字 | 直接忽略了英文 | 中文4 个字加英文三个字,格局正好 |
vscode api cn
在学习和开发 vscode 插件的过程中,最大的痛点无过于 API 文档翻译的缺失。哪怕是硬着头皮看英文原版 API 文档,阅读体验也很差。为了方便自己、回馈社区,我和 寒草 等小伙伴决定翻译 vscode api 类型声明并使用 Typedoc 承载,另外在完工后我们也会输出 @types/vscode-cn
类型包代替 @types/vscode
进一步方便 vscode 插件开发者。团队成员现状:
翻译是一件带有侠义精神的事业,欢迎更多的小伙伴加入我们。你可以浏览仓库和官网了解具体情况。
后记
这是第一次尝试写这么长的文章,断断续续经历了有半个月,本着对读者负责任的态度,文中的实践都是经过反复测试以及和同事朋友的讨论。当然 vscode 插件开发的概念和 API 比较多,一篇文章也很难讲全,讲透彻。如果大家感兴趣,可以在评论区告诉紫升,我可以继续更新这方面的教程。
本文首发于「掘金专栏」,同步于公众号「程序人生」。