掘金一下 | 从零开发一款基于 webview 的 vscode 扩展

7,291 阅读16分钟

⚠️本文为掘金社区首发签约文章,未获授权禁止转载

大家好,我是洛竹🎋,一只住在杭城的木系前端 🧚🏻‍♀️,如果你喜欢我的文章 📚,可以通过点赞帮我聚集灵力 ⭐️。

温馨提示:结合本文配套源码阅读体验更佳!

前言

在团队降本提效的基建中,洛竹开发了一款 vscode 插件,第一版我使用的是 vscode 内置 UI,虽说也能用,但是用户体验欠佳。由于 vscode 内置 UI 不够灵活,一番调研后我决定使用 webview 重构。

开发过 vscode 插件的同学可能对插件开发知识点多、文档阅读困难、参考资料少有所体会。基于 webview 开发插件更是如此,寻遍网络,虽然有优秀的项目,但却没有完整且优秀的教程。为了修炼 vscode 开发灵力,不妨和洛竹一起挑战从零到一开发一款基于 webview 的 vscode 插件。

Hello vscode

英雄多起于市井,高楼皆起于平地。再伟大的软件也都是从 Hello World 开始的,本章尽量用最简洁的语言描述一个 vscode 插件 Hello World 的诞生。

初始化项目

安装 YeomanVS Code Extension Generator

$ npm install -g yo generator-code

这个脚手架会生成一个可以立马开发的项目。运行生成器,然后填好下列字段:

$ yo code
#     _-----_     ╭──────────────────────────╮
#    |       |    │   Welcome to the Visual  │
#    |--(o)--|    │   Studio Code Extension  │
#   `---------´   │        generator!        │
#    ( _´U`_ )    ╰──────────────────────────╯
#    /___A___\   /
#     |  ~  |
#   __'.___.'__
# ´   `  |° ´ Y `

# ? What type of extension do you want to create? New Extension (TypeScript)
# ? What's the name of your extension? Juejin Posts
# ? What's the identifier of your extension? juejin-posts
# ? What's the description of your extension? 掘金文章管理
# ? Initialize a git repository? Yes
# ? Bundle the source code with webpack? No
# ? Which package manager to use? yarn

$ code ./juejin-posts

提交记录:hello world

代码规范

默认的脚手架生成的也有 ESLint 配置,但是 Editor、Prettier 的配置都没有,并且 ESLint 配置也不符合我的习惯。洛竹关于前端工程化的包都在 youngjuning/luozhu, ESlint 配置的包是 @luozhu/eslint-config-*。由于我们开发插件使用的是 Typescript,所以我们选择 @luozhu/eslint-config-typescript

安装依赖:

$ yarn add @luozhu/eslint-config-typescript @luozhu/prettier-config prettier -D

具体配置:

配置涉及文件较多,请参考 coding-style,不关心的同学也可以直接略过。

提交检测:

安装依赖:

$ yarn add lint-staged yorkie -D

修改配置:

// package.json
{
  "gitHooks": {
    "pre-commit": "lint-staged"
  },
  "lint-staged": {
    "**/*.{js,jsx,ts,tsx}": ["eslint --fix"],
    "**/*.{md,json}": ["prettier --write"]
  }
}

eslint --fix:

修改完配置之后需要执行 fix 对所有文件格式化一次。

$ yarn lint --fix

提交记录:chore: code style config

约定式提交

约定式提交我使用的是渐进式脚手架 @luozhu/create-commitlint,在项目中执行 npx @luozhu/create-commitlint 即可使项目符合规范化提交的配置。对规范化提交不了解的同学,强烈建议读一下 一文搞定 Conventional Commits

提交记录:chore: npx @luozhu/create-commitlint

调试

按下 F5 开启调试会出现[扩展开发宿主]窗口,然后按 Command+Shift+P 组件键输入 Hello World 命令。如下图所示 vscode 弹出了 Hello World from Juejin Posts! 的提示。

同时我们的开发窗口中,会出现一个 watch 任务的终端:

开发窗口的调试控制台会输出插件运行日志(忽略红色的警告):

调试执行的任务是在 .vscode/tasks.json 中配置的:

// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
{
	"version": "2.0.0", // 配置的版本号。
	"tasks": [ // 任务配置。通常是外部任务运行程序中已定义任务的扩充。
		{
			"type": "npm", // 要自定义的任务类型。
			"script": "watch", // 要自定义的 npm 脚本。
			"problemMatcher": "$tsc-watch", // 要使用的问题匹配程序。可以是一个字符串或一个问题匹配程序定义,也可以是一个字符串数组和多个问题匹配程序。
			"isBackground": true, // 执行的任务是否保持活动状态并在后台运行。
			"presentation": { // 配置用于显示任务输出并读取其输入的面板。
				"reveal": "never" // 控制运行任务的终端是否显示。可按选项 "revealProblems" 进行替代。默认设置为“始终”。
			},
			"group": { // 定义此任务属于的执行组。它支持 "build" 以将其添加到生成组,也支持 "test" 以将其添加到测试组。
				"kind": "build", // 任务的执行组。
				"isDefault": true // 定义此任务是否为组中的默认任务。
			}
		}
	]
}

打包

我们的插件开发完成前,想要分享给小伙伴体验可以吗?答案是肯定的,vscode 为我们提供了 vsce 实现这个需求,我们将 vsce 模块安装到全局,然后使用 vsce package 命令尝试打包:

$ vsce package
 ERROR  Missing publisher name. Learn more: https://code.visualstudio.com/api/working-with-extensions/publishing-extension#publishing-extensions

啊,咋还报错了?publisher 是啥??一脸懵逼。不慌,按链接 我知道了 publisher 是一个可以将扩展发布到Visual Studio Code Marketplace 的身份。每个扩展都需要在其 package.json 文件中包含一个发布者名称。如果注册发布者我们后面详说,这里我们把 publisher 设置为 luozhu

$ vsce package
 INFO  Detected presence of yarn.lock. Using 'yarn' instead of 'npm' (to override this pass '--no-yarn' on the command line).
 ERROR  Make sure to edit the README.md file before you package or publish your extension.

额,裂开,这咋还报错,假装淡定,读一下提示原来是要我们编辑一下 README.md,没错,vscode 模板里有初始的 README,我们需要编辑一下才可以打包。修改后再次尝试 vsce package

终于,打包成功!为了追求完美,最后我们再来做一些优化工作:

  1. 执行 vsce package 的时候加上 --no-yarn
  2. package.json 中加上 repository 字段即可看不到任何警告。
  3. 为了便捷,我们将 vsce 安装到项目中,然后把 vsce package --no-yarn 添加到 npm scripts 中。
  4. package.json 加上 license 字段。

然后再次尝试 yarn package 就完美了:

提示:vsce package 会先执行 vscode:prepublish 这个预发布脚本去编译项目。

提交记录:chore: config vsce package

打包原理

如过你也跟着一路敲到了这里,此时你会在项目根目录发现 vsix 结尾的文件:

这就是 vscode 插件的安装包,我们先不急着安装,先一起来看一下这个文件是个什么东西。尝试用归档工具解压后得到如下目录文件夹:

我们可以看到编译后的文件夹 out 和其他一些文件是被直接压缩进安装包的,聪明的你肯定发现了 .cz-config.js.prettierrc.jscommitlint.config.js 这种开发时文件也被压缩了,运行插件完全用不到,这明显不合理。其实和其他插件体系一样,vscode 也提供了 .vscodeignore 来实现打包忽略配置,我们将以上无关文件忽略重新打包即可。

原理就这?不存在的,我们打开 extension.js 会发现引用了 vscode 这个包:

但是我们的安装包中并没有 node_modules,那么 vscode 这个包存在在哪里呢?我猜的是挂在 node 环境上了,读了源码后我发现我竟然是对的:

vscode 实现了拦截器在加载 Node 环境的时候将 vscode 给添加到了内置包中,这样的好处是减小插件的体积。

那么我们如果使用三方插件呢?以常用的 lodash 为例,安装 lodash 之后重新打包:

$ yarn package
yarn run v1.22.10
$ vsce package --no-yarn
Executing prepublish script 'npm run vscode:prepublish'...

> juejin-posts@0.0.1 vscode:prepublish
> yarn run compile

$ tsc -p ./
This extension consists of 1060 files, out of which 1049 are JavaScript files. For performance reasons, you should bundle your extension: https://aka.ms/vscode-bundle-extension . You should also exclude unnecessary files by adding them to your .vscodeignore: https://aka.ms/vscode-vscodeignore
 DONE  Packaged: /Users/luozhu/Desktop/playground/juejin-posts/juejin-posts-0.0.1.vsix (1060 files, 644.72KB)
✨  Done in 5.54s.

这个时候提示我们有 1000 多个文件,大概率 node_modules 文件夹被打包了,我们来解压下见证一下:

不出所料,vscode 默认的打包方式就是简单的编译拷贝,通过忽略文件减小体积也是杯水车薪。而且 vscode 扩展的规模往往增长很快。它们是在多个源文件中编写的,并依赖于 npm 的模块。分解和重用是开发的最佳实践,但在安装和运行扩展时,它们是有代价的。加载 100 个小文件要比加载一个大文件慢得多。这就是我们推荐捆绑的原因。捆绑是将多个小的源文件合并成一个文件的过程。

在 JavaScript 中,有不同的打包工具可以用,流行的有 rollup.js、Parcel、esbuild 和 webpack,官方脚手架默认只能选 webpack,我们这里推荐直接使用更快更强的 esbuild。

提交记录:chore: ignore config file when packagechore: add esModuleInterop to tsconfig

使用 esbuild 优化打包

安装依赖:

$ yarn add -D esbuild

npm scripts:

"scripts": {
-    "vscode:prepublish": "yarn run compile",
-    "compile": "tsc -p ./",
-    "watch": "tsc -watch -p ./",
-    "pretest": "yarn run compile && yarn run lint",
+    "vscode:prepublish": "yarn esbuild-base --minify",
+    "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node",
+    "esbuild": "yarn esbuild-base --sourcemap",
+    "esbuild-watch": "yarn esbuild-base --sourcemap --watch",
+    "test-compile": "tsc -p ./",
+    "pretest": "yarn test-compile && yarn lint",
}

注意:由于 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 忽略掉。

成果展示:

从图中我们可以看到,安装包的体积大大减小了。

提交记录:chore: config esbuild

集成 umijs

初始化 umi 项目

使用 umi 脚手架在根目录新建一个 web 目录。

$ mkdir web && cd web

通过官方工具创建项目:

$ yarn create @umijs/umi-app

修改 .umirc.ts 配置:

import { defineConfig, IConfig } from 'umi';

export default defineConfig({
  nodeModulesTransform: {
    type: 'none',
  },
  routes: [{ path: '/', component: '@/pages/index' }],
  fastRefresh: {}, // 开发时可以保持组件状态,同时编辑提供即时反馈。
  history: {
    type: 'memory', // 默认的类型是 `browser`,但是由于 vscode webview 环境不存在浏览器路由,改成 `memory` 和 `hash` 都可以
  },
  devServer: {
    // 需要在 dev 时写文件到输出目录,这样保证开发阶段有 js/css 文件
    writeToDisk: filePath =>
      ['umi.js', 'umi.css'].some(name => filePath.endsWith(name)),
  },
} as IConfig);

修改 package.json 加入 nameversiondescription

{
  "name": "web",
  "version": "0.0.0",
  "description": "web for juejin-posts"
}

忽略文件

.gitignore:

将 vscode 扩展和 umijs 脚手架生成的 gitignore 合并为一下内容:

# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# vscode
.vscode-test/
*.vsix

# dependencies
node_modules
npm-debug.log
yarn-error.log
package-lock.json

# production
out
dist

# misc
.DS_Store

# umi
**/src/.umi
**/src/.umi-production
**/src/.umi-test
**/.env.local
web/yarn.lock

.vscodeignore:

由于 vscode 打包的时候只需要获取 umijs 打包后的产物,所有加入 web/**!web/dist/** 将无用的文件忽略掉。

.vscode/**
.vscode-test/**
out/test/**

src/**
.gitignore
.yarnrc
vsc-extension-quickstart.md
**/tsconfig.json
**/*.map
**/*.ts

.cz-config.js
.prettierrc.js
.commitlintrc.js
**/node_modules/**
yarn-error.log
web/**
!web/dist/**

yarn workspace

由于我们的项目是 vscode 扩展和 web 项目混合的项目。为了方便管理脚本和依赖,我们引入了 yarn workspace 来管理项目。在根目录的 package.json 中加入以下配置即可:

{
  "private": "true",
  "workspaces": ["web"]
}

调试

由于我们的 web 项目也需要编译,所以我们需要修改一下 vscode launch.json 加入 web 项目的编译任务。配置参考了 appworks

首先在根目录的 package.json 的 scripts 中添加:

{
  "scripts": {
    "web-build": "yarn workspace web run build",
    "web-watch": "yarn workspace web run start"
  },
}

然后修改 .vscode/launch.json 配置为:

// A launch configuration that compiles the extension and then opens it inside a new window
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
{
  "version": "0.2.0",
  "compounds": [
    // 复合列表。每个复合可引用多个配置,这些配置将一起启动。
    {
      "name": "Debug Extension", // 复合的名称。在启动配置下拉菜单中显示。
      "configurations": [
        // 将作为此复合的一部分启动的配置名称。
        "Run Extension",
        "Watch Webview"
      ],
      "presentation": {
        "order": 0
      }
    }
  ],
  "configurations": [
    {
      "name": "Watch Webview",
      "request": "attach",
      "type": "node",
      "preLaunchTask": "npm: web-watch"
    },
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "args": ["--extensionDevelopmentPath=${workspaceFolder}"],
      "outFiles": ["${workspaceFolder}/out/**/*.js"],
      "preLaunchTask": "${defaultBuildTask}"
    }
  ]
}

完成后进入 VS Code,按下F5,你会立即看到一个插件发开主机窗口,其中就运行着插件。这时候运行你会发现控制台报一下错误 ❌:

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.
  The file is in the program because:
    Matched by include pattern '**/*' in '/Users/luozhu/Desktop/github/juejin-posts/tsconfig.json'

原因是因为 umi 的约定的项目结构和 vscode extension 都包含 src 目录。由于 vscode 插件和 umi 的编译是分开的,我们在根目录的 tsconfig.json 中将 web 目录忽略即可:

{
  "exclude": ["web"]
}

现在,你可以按下 F5 看到插件发开主机窗口的同时还会看到两个调试任务:

注意📢:请选择 Debug Extension 调试任务而不是 Run Extension

其他优化工作

  1. 由于基于 yarn workspace,我们把公用的依赖合并
  2. 合并 Eslint 配置并使用 @luozhu/eslint-config-react-typescrip
  3. 合并 Editorconfig 和 Prettier 配置
  4. 添加 prestartprebuild script
  5. 设置 HTML=none umi build

提交记录:chore: config umijs

vscode 插件开发核心概念

在开始 webview 能力开发之前,我们有必要了解一下 vscode 插件开发的核心概念。为了有个全局的理解,我们先来看下我们现在项目的主要目录结构:

.
├── CHANGELOG.md # 基于 standard-version 生成的更新日志文件
├── README.md
├── package.json # vscode 包配置文件,诸如插件 LOGO、名字、描述、注册激活事件
├── src
│   └── extension.ts # 插件入口文件,暴露 activate 方法用于注册命令和初始化一些配置,暴露 deactivate 方法用于插件关闭前执行清理工作
├── tsconfig.json # vscode 的编译配置
├── web # 基于 umi 的 web,也是我们后边 webview 要承载的内容
└── yarn.lock

从目录结构可以看出,关键的文件是 package.jsonextension.ts,我们以 helloWorld 命令为例介绍下 vscode 插件的三个核心概念。

1. 激活事件

激活事件是在 package.json 中的 activationEvents 字段声明的一个 JSON 数组对象。为了注册 helloWorld 这个命令,第一步就是注册激活事件,激活事件类型有很多,注册命令的激活事件是 onCommand:

{
  "activationEvents": ["onCommand:juejin-posts.helloWorld"]
}

2. 发布内容配置

发布内容配置( 即 VS Code 为插件扩展提供的配置项)是 package.jsoncontributes 字段,你可以在其中注册各种配置项扩展 VS Code 的能力。上一步我们注册的 helloWorld 激活事件只是告诉了 vscode 可以通过 juejin-posts.helloWorld 命令触发。我们还需要再 contributes.commands 中注册我们的 juejin-posts.helloWorld 命令:

{
  "contributes": {
    "commands": [
      {
        "command": "juejin-posts.helloWorld",
        "title": "Hello World"
      }
    ]
  }
}

3. VS Code API

VS Code API 是 VS Code 提供给插件使用的一系列 Javascript API。通过前两个核心概念的能力,我们已经注册好了命令和事件,那么下一步必然就是注册事件回调。事件回调在 vscode 中是通过 vscode.commands.registerCommand 函数来注册的,下面 👇🏻 是我们在入口文件 src/extension.ts 中注册 juejin-posts.helloWorld 命令。

// vscode 这个模块包含了 VS Code 扩展的 API
import vscode from 'vscode';

// 这个方法当你的扩展激活时调用,扩展会在命令首次执行时激活
export function activate(context: vscode.ExtensionContext) {
  // 当你的扩展被激活时,这行代码将只被执行一次
  //
  // 使用 console.log 输出日志信息或使用 console.error 输出错误信息。
  //
  console.log('Congratulations, your extension "juejin-posts" is now active!');

  // 入口命令已经在 package.json 文件中定义好了,现在调用 registerCommand 方法
  // registerCommand 中的参数必须与 package.json 中的 command 保持一致
  const disposable = vscode.commands.registerCommand('juejin-posts.helloWorld', () => {
    // 把你的代码写在这里,每次命令执行时都会调用这里的代码
    // 给用户显示一个消息提示
    vscode.window.showInformationMessage('Hello World from Juejin Posts!');
  });

  context.subscriptions.push(disposable);
}

// 当你的扩展被停用时,这个方法被调用。
export function deactivate() {}

集成 webview

注册命令

1、package.json 激活事件(activationEvents)中添加 "onCommand:juejin-posts.start"

2、package.json 命令(commands)中添加:

{
  "command": "juejin-posts.start",
  "title": "start",
  "category": "Juejin Posts"
}

3、src/extension.ts 中注册命令

context.subscriptions.push(
  vscode.commands.registerCommand('juejin-posts.start', () => {
    // Truth is endless. Keep coding...
  })
)

创建 webview 面板

创建一个空白的面板

import vscode from 'vscode';

// 创建并显示新的webview
const panel = vscode.window.createWebviewPanel(
  'juejin-posts', // 只供内部使用,这个 webview 的标识
  'Juejin Posts', // 给用户显示的面板标题
  vscode.vscode.ViewColumn.One, // 给新的 webview 面板一个编辑器视图
  {
    enableScripts: true, // 启用 javascript 脚本
    retainContextWhenHidden: true, // 隐藏时保留上下文
  } // webview 面板的内容配置
);

我们使用了 window.createWebviewPanel API 创建了一个 webview 面板,现在我们尝试运行 juejin-posts.start 就可以打开一个 webview 面板:

给面板设置内容

上面我们创建了一个空白的面板,那么我们如何给面板添加内容呢?我们可以使用 panel.webview.html 来设置 HTML 内容:

function getWebviewContent() {
  return `
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Juejin Posts</title>
        <style>
          html, body {
            padding: 0px;
            height: 100vh;
            position: relative;
            margin: 0;
            padding: 0;
            overflow: hidden;
          }
          #yoyo {
            position: absolute;
            bottom: 50px;
            right: -90px;
            opacity: 0;
            transition: .25s ease-in-out
          }
          #yoyo:hover {
            opacity: 1;
            right: 0;
          }
        </style>
    </head>
    <body>
      <a href="https://juejin.cn"><img id="yoyo" src="https://cdn.jsdelivr.net/gh/youngjuning/images/20210817163229.png" width="100" /></a>
    </body>
    </html>
  `;
}
...
// 给 webview panel 设置 HTML 内容
panel.webview.html = getWebviewContent();
...

重新使用 juejin-posts.start 命令就可以调戏悠悠船长了:

限制 webview 视图为一个

export function activate(context: vscode.ExtensionContext) {
  // 追踪当前 webview 面板
  let currentPanel: vscode.WebviewPanel | undefined = undefined;

  context.subscriptions.push(
    vscode.commands.registerCommand('juejin-posts.start', () => {
      // 获取当前活动的编辑器
      const columnToShowIn = vscode.window.activeTextEditor
        ? vscode.window.activeTextEditor.viewColumn
        : undefined;

      if (currentPanel) {
        // 如果我们已经有了一个面板,那就把它显示到目标列布局中
        currentPanel.reveal(columnToShowIn);
      } else {
        // 不然,创建一个新面板
        currentPanel = vscode.window.createWebviewPanel();
        // 当前面板被关闭后重置
        currentPanel.onDidDispose(
          () => {
            currentPanel = undefined;
          },
          null,
          context.subscriptions
        );
      }
    })
  );
}

设置 Icon

// 设置 Logo
panel.iconPath = vscode.Uri.file(
  path.join(context.extensionPath, 'assets', 'icon-juejin.png')
);

在 vscode 扩展中我们需要通过 vscode.Uri.file 方法获取磁盘上的资源路径。

webview 获取内容的 Uri

你应该使用 asWebviewUri 管理插件资源。不要硬编码 vscode-resource://,而是使用 asWebviewUri 确保你的插件在云端环境也能正常运行。

@luozhu/vscode-utils 中我们对获取本地资源路径做了封装:

// 获取内容的 Uri
const getDiskPath = (fileName: string) => {
  return webviewPanel.webview.asWebviewUri(
    vscode.Uri.file(path.join(context.extensionPath, rootPath, 'dist', fileName))
  );
};

使用 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 方法如下:

import vscode from 'vscode';
import path from 'path';

/**
 * 获取基于 umijs 的 webview 内容
 * @param context 扩展上下文
 * @param webviewPanel webview 面板对象
 * @param rootPath webview 所在路径,默认 web
 * @param umiVersion umi 版本
 * @returns string
 */
export const getUmiContent = (
  context: vscode.ExtensionContext,
  webviewPanel: vscode.WebviewPanel,
  umiVersion?: string,
  rootPath = 'web'
) => {
  // 获取磁盘上的资源路径
  const getDiskPath = (fileName: string) => {
    return webviewPanel.webview.asWebviewUri(
      vscode.Uri.file(path.join(context.extensionPath, rootPath, 'dist', fileName))
    );
  };
  return `
    <html>
      <head>
        <meta charset="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"
        />
        <link rel="stylesheet" href="${getDiskPath('umi.css')}" />
        <style>
          html, body, #root {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
            overflow: hidden;
          }
        </style>
        <script>
          //! umi version: ${umiVersion}
        </script>
      </head>
      <body>
        <div id="root"></div>
        <script src="${getDiskPath('umi.js')}"></script>
      </body>
    </html>
  `;
};

提示:上面的方法我已经封装在 @luozhu/vscode-utils 的中。

我们使用 getUmiContent 重新前面的代码:

import { getUmiContent } from '@luozhu/vscode-utils';
...
panel.webview.html = getUmiContent(context, panel, '3.5.17');

优化打包

由于我们封装了 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 中全局加入下面的样式:

body.vscode-light {
  h1, h2, h3, h4, h5, h6 {
    color: black;
  }
  color: black;
  background-color: var(--vscode-editor-background);
}

body.vscode-dark {
  h1, h2, h3, h4, h5, h6 {
    color: white;
  }
  color: white;
  background-color: var(--vscode-editor-background);
}

body.vscode-high-contrast {
  h1, h2, h3, h4, h5, h6 {
    color: red;
  }
  color: red;
  background-color: var(--vscode-editor-background);
}

由于这部分适配大部分是通用的,所以我也将它封装进了 @luozhu/vscode-utilsgetUmiContent 中了。

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 侧

// 注册一个新的命令
context.subscriptions.push(
  vscode.commands.registerCommand('juejin-me.author', () => {
    if (!currentPanel) {
      return;
    }

    // 把信息发送到 webview
    // 你可以发送任何序列化的 JSON 数据
    currentPanel.webview.postMessage({ method: 'showAuthor' });
  })
);

webview 侧

import { Modal } from 'antd';
...
window.addEventListener('message', event => {
  const message = event.data;
  switch (message.method) {
    case 'showAuthor': {
      Modal.info({
        title: '洛竹',
        content: (
          <div>
            大家好,我是洛竹🎋一只住在杭城的木系前端🧚🏻‍♀️,如果你喜欢我的文章📚,可以通过
            <a href="https://juejin.cn/user/325111174662855/posts">点赞</a>帮我聚集灵力⭐️。
          </div>
        ),
        okText: <a href="https://juejin.cn/user/325111174662855/posts">点赞 o( ̄▽ ̄)d</a>,
      });
      break;
    }
    default:
      break;
  }
});

效果

webview 传递信息给插件

webview 反向传递信息给插件的原理也是一样的,只不过由于 webview 的上下文限制,我们只能通过 acquireVsCodeApi 函数获取阉割版的 VS Code API 对象,这个阉割的对象上有一个 postMessage 函数可以供我们发送事件用。注意 acquireVsCodeApi 个会话中只能调用一次,重复调用会报错。而在插件侧则可以通过 Webview.onDidReceiveMessage 处理 webview 传递的信息。我们来写一个在 webview 中调用 vscode.window.showInformationMessage 的例子:

webview 侧

const vscode = acquireVsCodeApi();
vscode.postMessage({
  method: 'showMessage',
  params: {
    text: `为人民服务`,
  },
});

插件侧

// 处理 webview 中的信息
currentPanel.webview.onDidReceiveMessage(
  message => {
    if (message.method === 'showMessage') {
      vscode.window.showInformationMessage(message.params.content);
    }
  },
  undefined,
  context.subscriptions
);

效果

在 webview 中请求接口

一开始,我以为这是个轻松的工作,直到遇到跨域半天解决不了后我绝望了,在 VSCode WebView插件(扩展)开发实战 一文中我终于知道了 vscode webview 内部是不允许发送 ajax 请求,所有 ajax 请求都是跨域的,因为 webview 本身是没有 host 的。

人裂开了,这什么鬼呀,我们核心的需求就是请求掘金的接口获取我们的文章列表呀,那我们还有办法吗?答案是肯定的,其实还是借助上面我们提到的通信机制把请求接口的任务交给 vscode 去处理,完事再让 vscode 把数据通过 postMessage 返回给我们,多说无益,我们来看代码:

webview 侧

React.useEffect(() => {
  // @ts-ignore
  const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : null;
  vscode.postMessage({
    method: 'queryPosts',
  });
  window.addEventListener('message', event => {
    if (method === 'queryPosts') {
      const message = event.data;
      console.log(message);
    }
  });
}, []);

vscode 侧

// 处理 webview 中的信息,并返回接口请求的数据
currentPanel.webview.onDidReceiveMessage(
  async message => {
    const data = await events(message);
    currentPanel?.webview.postMessage({ data });
  },
  undefined,
  context.subscriptions
);

@luozhu/vscode-channel

前面我们知道了使用 Webview.postMessageWebview.onDidReceiveMessageacquireVsCodeApi().postMessagewindow.addEventListener 就可以满足各种通信需求了,那 @luozhu/vscode-channel 又是什么呢?

js-channel 启发,@luozhu/vscode-channel 主要是封装了 webview 与 vscode 交互流程,核心原理是通过暴露 callbind 方法抹平 API 的差异,减少重复代码量。其中参考 appworks 和 cs-channel 使用 uuid 保证交互的可靠性。Talk is cheap, show you the code:

webview 侧

// 创建 channel 对象
const channel = new Channel();
const getData = async () => {
  // 发起一个请求,并等待其返回数据
  const { payload } = await channel.call({ method: 'queryPosts' });
  console.log(payload);
};

webview 中由于 acquireVsCodeApi 只能调用一次,之后又需要在多个地方使用,所以我们在 wev/src/layouts/index.ts 中创建一次并挂载到 window 对象上比较合适。

vscode 侧

// vscode 侧的 channel 需要依赖上下文和 WebviewPanel 实例
const channel = new Channel(context, currentPanel);
// 绑定一个回调函数,一般只需要创建一个,然后根据约定做分发即可
channel.bind(async message => {
  const { eventType, method, params } = message;
  // 实际发起请求获取数据的地方
  const data = await events[eventType][method](params);
  // 这里将获取的数据直接返回即可,channel 内部会进行消息合并和回传。
  // 如果只是执行一个功能,不写 return 语句即可,内部会进行判断降级成单工通信。
  return data;
});

vscode 国际化

我们都知道 vscode 中是可以切换语言环境的,一款优秀的 vscode 扩展至少要支持中英两种语言。而且支持国际化可以让你的插件受众直接突破国界限制。vscode 国际化分为三部分,一部分是配置的国际化,一部分是代码中的国际化,另一部分则是 webview 中 umijs 的国际化。本章我们就来具体看一下如何在 vscode 中实现国际化。

配置国际化

我们已经知道 vscode 中的配置都是在 package.json 中,而配置的国际化是约定在 package.nls.jsonpackage.nls.zh-cn.json 这种文件中编写。比如我们要在中英文环境下命令配置中英文版本,我们可以在 package.nls.json 中写:

{
  "contributes.category.juejin-me": "Juejin Me"
}

package.nls.zh-cn.json 写:

{
  "contributes.category.juejin-me": "掘金一下"
}

然后 package.json 中写:

{
  "contributes": {
    "commands": [
      {
        ...
        "category": "%contributes.category.juejin-me%"
      },
    ]
  }
}

代码中国际化

推荐使用洛竹贡献过代码的 vscode-nls-i18n,使用方法也很简单,配置的话和上一节一样,在 src/extension.ts 中使用 init 方法初始化,然后使用 localize 方法实现国际化:

import { init, localize } from 'vscode-nls-i18n';
export function activate(context: vscode.ExtensionContext) {
  init(context.extensionPath); // 初始化国际化配置。只用在扩展激活时初始化一次
  console.log(localize('extension.activeLog')); // 之后就可以在各个文件中使用。
}

umijs 国际化

umijs 的国际化需要使用 @umijs/plugin-locale 插件支持,这个插件封装了 react-intl,配置方式如下:

1、.umirc.ts 中配置 local

locale: {}

2、在 src 目录下创建 locales 并创建 en.tszh-CN.ts

// src/locales/en.js
export default {
  WELCOME_TO_UMI_WORLD: "welcome to umi's world",
};
// src/locales/zh-CN.js
export default {
  WELCOME_TO_UMI_WORLD: '欢迎光临  umi  的世界',
};

3、使用国际化

import React from 'react';
import { useIntl } from 'umi';

export default: React.FunctionComponent = (props) => {
  const intl = useIntl()
  return (
     <div>
     {intl.formatMessage(
        {
          id: 'WELCOME_TO_UMI_WORLD',
        }
      )}<div>
   )
}

4、切换语言

切换语言,我们需要使用 setLocale 方法,需要注意的是我们给这个方法第二个参数传入 false 来实现无刷新动态切换。

import { setLocale } from 'umi';
// 不刷新页面
setLocale('zh-CN', false);

不过,切换语言的时机在什么时候呢?切换时机就是我们语言环境改变的时机。在 vscode webview 环境中,其实当使用 Config display language 方法切换语言环境后,会要求 vscode 重启。也就说我们只需要在 webview 创建时设置一次语言环境即可。由于 vscode 和 webview 传值太困难,我们选择在 getUmiHTMLContent 时传如 vscode.env

<script>
  window.vscodeEnv = ${JSON.stringify(vscode.env)}
</script>

然后,我们在 web/src/layouts/index.ts 中设置一下即可:

setLocale(window.vscodeEnv.language, false);

“掘金一下” 扩展核心实现

灵感来源于现实,作为掘金的重度使用者,几乎每篇文章和笔记都同步在这里。当有些知识忘记需要查阅或拷贝代码时,我就有在掘金搜索我的文章的需求。但是掘金的搜索是全站的,就算加上自己的名字搜索也会出现大量无关记录。“掘金一下” 这个名字就像插件功能一样,在你想搜索自己掘金文章的时候就可以打开插件“掘金一下” 进行搜索。

其实为了只搜索到自己的文章,我想到的还有开发 chrome 插件来实现。但是考虑到市场和便捷性,我最终还是决定开发 vscode 插件来落地这个灵感。本章就是综合前面的经验实现 “掘金一下” 的核心逻辑。

juejin-me.start 命令

vscode 侧开启 channel 通信

vscode 侧通过 channel.bind 绑定一个事件处理函数。

import events from './events';
...
context.subscriptions.push(
  vscode.commands.registerCommand('juejin-me.start', async () => {
    currentPanel = createUmiWebviewPanel(
      context,
      'juejin-me',
      localize('extension.webview-panel.title'),
      'assets/icon-luozhu.png',
      '3.5.17'
    );
    // 处理 webview 中的信息
    channel = new Channel(context, currentPanel);
    channel.bind(async message => {
      const { eventType, method, params } = message;
      // 根据事件类型、方法、参数来完成一次 api 调用,内置的 eventType 有 request、command 和 variable。
      const data = await events[eventType][method](params);
      return data;
    }, vscode);
  })
);

注意:我们不需要给定监听事件名,内部会根据 eventId 保证可靠性和全局唯一性

注册 events

events/index.ts

import requests from './requests';

export default {
  request: requests,
};

events/requests

import vscode from 'vscode';
import request from '../utils/request';

const queryPosts = async (params: { cursor: string }): Promise<any> => {
  // 这里我们根据 vscode 配置动态取的用户 id
  const { userId } = vscode.workspace.getConfiguration('juejin-me');

  const { cursor } = params;
  const data = await request.post('/article/query_list', {
    cursor: `${cursor}`,
    sort_type: 2,
    user_id: userId,
  });
  return data;
};

export default {
  queryPosts,
};

utils/request

这里简单封装了基于 axios 的请求对象。

/* eslint-disable no-param-reassign */
import axios from 'axios';
import vscode from 'vscode';
import qs from 'qs';

// 中文文档: http://t.cn/ROfXFuj
// 创建实例
const request = axios.create({
  baseURL: 'https://api.juejin.cn/content_api/v1/',
  timeout: 10000,
});

// 添加请求拦截器
request.interceptors.request.use(
  config => {
    if (config.method === 'get') {
      config.paramsSerializer = params => qs.stringify(params, { arrayFormat: 'repeat' });
    }
    return config;
  },
  error => {
    vscode.window.showErrorMessage(error.message);
    return Promise.reject(error);
  }
);

// 添加响应拦截器
request.interceptors.response.use(
  response => {
    const { data } = response;
    return data;
  },
  error => {
    vscode.window.showErrorMessage(error.message);
    return Promise.reject(error);
  }
);

export default request;

webview 中调用接口

channel 是在 web/src/layouts/index.tsx 中初始化并挂载到 window 上的,我们在 web/src/pages/index.tsx 中调用 window.channel.call 即可调用指定接口。由于我们需要模糊搜索所有的文章,所以我们需要在初始化页面时一次请求完所有数据。

const Homepage = () => {
  const getData = async () => {
    const { payload } = (await window.channel.call({
      eventType: 'request',
      method: 'queryPosts',
      params: { cursor },
    })) as any;
    tempData = tempData.concat(payload.data);
    setData(tempData);
    if (!payload.has_more) {
      setInitLoading(false);
      setCategories(_union(['全部', ...tempData.map(item => item.category.category_name)]));
      tempData = [];
    } else {
      cursor += 10;
      getData();
    }
  };
}

更多具体实现细节就是一些页面编写逻辑,不是本文的重点,感兴趣的同学可以直接进查看源码

配置掘金 ID

声明配置

vscode 的配置我们需要借助 package.json 的 contributes.configuration 属性,我们的掘金 ID 是 string,所以声明如下:

{
  "contributes": {
    "configuration": {
      "title": "%configuration.title%",
      "type": "object",
      "properties": {
        "juejin-me.userId": {
          "type": "string",
          "default": "325111174662855",
          "description": "%configuration.properties.juejin-me.userId%"
        }
      }
    }
  }
}

修改配置的命令

让用户打开设置去修改配置也可以,但是为了用户体验,我们提供了 juejin-me.configUserId 命令,我们来看下命令的实现:

context.subscriptions.push(
  vscode.commands.registerCommand('juejin-me.configUserId', async () => {
    const userId = await vscode.window.showInputBox({
      placeHolder: localize('extension.juejin-me.configUserId.placeHolder'),
      validateInput: value => {
        if (value) {
          return null;
        }
        return localize('extension.juejin-me.configUserId.validateInput');
      },
    });
    const config = vscode.workspace.getConfiguration('juejin-me');

    config.update('userId', userId, true);
  })
);

插件效果展示

感兴趣的话你也可以直接在扩展中搜索“掘金一下”自行体验。

彩蛋

@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 CountWord Count CJKWord Count Juejin
4 个字4 个字7 个字
中文算成了一个字直接忽略了英文中文4 个字加英文三个字,格局正好

vscode api cn

在学习和开发 vscode 插件的过程中,最大的痛点无过于 API 文档翻译的缺失。哪怕是硬着头皮看英文原版 API 文档,阅读体验也很差。为了方便自己、回馈社区,我和 寒草 等小伙伴决定翻译 vscode api 类型声明并使用 Typedoc 承载,另外在完工后我们也会输出 @types/vscode-cn 类型包代替 @types/vscode 进一步方便 vscode 插件开发者。团队成员现状:

翻译是一件带有侠义精神的事业,欢迎更多的小伙伴加入我们。你可以浏览仓库官网了解具体情况。

后记

这是第一次尝试写这么长的文章,断断续续经历了有半个月,本着对读者负责任的态度,文中的实践都是经过反复测试以及和同事朋友的讨论。当然 vscode 插件开发的概念和 API 比较多,一篇文章也很难讲全,讲透彻。如果大家感兴趣,可以在评论区告诉洛竹,我可以继续更新这方面的教程。

近期好文

本文首发于「掘金专栏」,同步于公众号「程序人生」。