初次学习 VScode 插件的开发与总结

440 阅读8分钟

VScode Extension

前言

最近接到了一个开发微信开发者工具编辑器插件的需求,初次接触该类需求,带着不少疑问去网上调研了一番,其实微信开发者工具编辑器是在 VScode 的基础上包了一层壳,所以实质上该编辑器插件,等同于开发一个 VScode 插件,只是引入跟打包的方式有点不同而已,由项目需求驱动学习是我比较喜欢的一种学习方式,所以本文记录了我初次学习插件开发过程中接触到的知识点和遇到问题排查后总结的注意事项,希望能带大家快速上手 VScode 插件的开发和避免踩同样的坑(p.s:由于在开发中发现该类需求的资料在网上相对较少,而且讲述的一般都不是很复杂的功能,所有有些 VScode Extension 比较冷门或者比较复杂的业务场景才用到的 API 使用或者排查都是个难题,我大多数情况也是通过阅读扩展商城中比较出名的插件源码,借鉴别人的用法来解决遇到的业务难题,也让我获益匪浅,借此也希望能给大家提供一种排查的思路)

快速搭建

安装官方提供的脚手架

npm install -g yo generator-code

通过脚手架快速生成插件项目

yo code

970949da06b3c024ab42d919a0b9ade.jpg

运行与调试

项目生成后,通过以下命令编译 VScode 插件源文件

// 首先需要编译源文件
npm run compile  // "compile": "tsc -p ./"
// 或者
npm run watch  // "watch": "tsc -watch -p ./"

然后在 launch.json 中配置插件调试的 extensionDevelopmentPath(告诉 VScode 当前开发的插件路径) 和 extensionTestsPath(编译后的 mocha 测试入口 runTest.js),上述步骤就绪后,就可以键入 F5 打开一个加载了你的插件的新窗口,通过调试控制台可以看到插件的输出信息,进而通过打断点的方式调试我们的插件,再深究一下,我们键入 F5 后 VScode 做了什么:

  1. .vscode/launch.json 指示 VScode 先运行一个叫做 npm(我们在 task 中定义的任务)的任务
  2. .vscode/tasks.json 定义了 npm 任务,其实就是 npm run compile 的脚本命令
  3. package.json 定义 compile 为 tsc -watch -p ./
  4. 然后会调用 node_modules 文件夹下的 Typescript 编译器,然后编译生成out/extension.js 和 out/extension.js.map
  5. Typescript编译成功之后,生成 code --extensionDevelopmentPath=${workspaceFolder} 进程(即 launch.json 中的 Extension Tests)
  6. 新建一个 VScode 实例去作为宿主环境,搜寻我们配置的 ${workspaceFolder} 下的插件并加载激活

TDD

前段时间跟着崔大学习 vue3 源码 时体会到了 TDD 带来的好处,所以在这个自己主导的项目中,秉着学以致用的精神,也考虑到提高代码的质量和后期该项目的可维护性,决定尝试学着拆分插件需求,从测试用例驱动出具体的功能

mocha

脚手架生成的插件项目默认内置了 mocha 集成测试,当我们运行 test 命令启动测试时,它会帮我们下载并解压最新的 VScode 版本并将我们插件的测试入口运行到一个特殊的 VScode 实例中,即调试用的宿主环境,也叫做扩展的开发环境,它内部为插件开放了 VScode API 的全部权限,使我们插件得以运行和调试,我们只需要在测试套件 suite 目录下加入我们的测试脚本,然后执行scripts 的 test 命令,就会执行 mocha 经 tsc 编译后输出到 out 目录下的测试入口文件 runTest.js 启动集成测试,更多有关测试插件可前往 VScode 测试插件 查阅

npm run test  // "test": "node ./out/test/runTest.js"

2073adf66e9db554a55d55620f2c1d3.jpg

本地开发结合 tsc 和 mocha

VSCode 天然支持 TypeScript,帮助开发者写出更加稳定、安全的代码,为开发插件提供最佳体验,开发时候通过 build 命令或者 watch 模式将其编译输出到 out 目录下,那么我们如果需要将其结合 mocha 使用的话,可以在 script 中通过 && 顺序执行多条命令,也就是执行 build 编辑完成后执行 test 运行我们的单测,但是这样的话就需要每次更新文件后,需要重复去执行这一步骤,而 tsc 的 watch 模式下,只有第一次运行的时候,会去运行单测,后续更新文件后监听到,只会重新执行 build 命令进行重新编译,但是不会再去执行我们的单测,这时候就需要一个钩子,去监听到我们每次成功编译文件后都去运行一下我们的单测

tsc-watch

考虑到上述开发要求,需要借助 ts 的第三方工具 tsc-watch 来让我们可以监听到 ts 编译流程中的生命周期,如下图所示:

image.png

从 tsc-watch 提供的钩子来看,我们可以通过 onSuccess 监听到 tsc 在 watch 模式下每次更新编译完成后执行我们插入该钩子的 test 命令来完善我们 TDD 的开发流程,这样我们每次编写更新完测试用例或者插件功能后都能自动跑一遍单测来验证,以确保我们优化/重构代码时候的准确性

npm run watch:test  // "tsc-watch -p ./ --onSuccess \"npm run test\""

发布

可通过 VScode 提供的发布工具 vsce 将你的插件发布到市场上,详细使用方式可前往 VScode 发布插件 查阅,里面详细描述了每个步骤及发布必备的描述文件与配置清单字段

VScode 插件的两个关键部分

(p.s:简单总结一下基本的配置用处,如已对其有一定了解,可跳过该部分)

插件配置清单:package.json

每一个 VScode 插件都必须包含该文件,用作描述当前插件的功能配置清单,是基于 Node.js 的 package.json 基础上扩展了一些插件独有的字段,如 publisher、activationEvents、contributes 等,这里我们着重关注 activationEvents 和 contributes

  • activationEvents:在 VScode 中,插件的加载形式都是懒加载的,所以我们需要在此为插件提供一个激活的时机,去监测用户如果执行指定操作后匹配到插件的运行时机后,就会去调用插件的 activate 函数

    • 通过特定语言文件激活:如我们日常开发中经常查看的 README.md,通过"onLanguage:markdown" 进行匹配

    • 通过显式的命令调用来激活"onCommand:extension.sayHello",如我们在 activate 中通过 vscode.commands.registerCommand 注册的命令及其触发后的回调

    • 还可以通过文件系统中的文件后缀,文件协议等来进行激活事件的配置,更多方式可前往 VS Code 插件开发文档 查阅

  • contributes:VScode 插件通过发布内容配置来让用户自定义扩展 VScode 的能力,是 VScode 插件的核心能力

    • configuration:该配置项内容会暴露给用户,供其在用户/工作区设置中修改插件暴露的选项,然后在插件内部可以通过 vscode.workspace.getConfiguration('myExtenstionName') 来读取值后做相应的操作
    • configurationDefaults:为特定语言配置编辑器的默认值,它会覆盖掉原编辑器已经为该语言提供的默认配置
    • commands:为插件命令在 ctrl+shift+p 或 cmd+shift+p 调出的命令面板中设置其命令标题和命令体,命令多的情况下还可以通过添加 category 前缀进行分类的显示,当我们在命令面板中选中命令后或者通过配置的组合键进行调用时,就会去触发上述我们在 activationEvents 中配置的激活事件 onCommand:${command},然后调用插件入口的激活函数
    • menus:为编辑器或文件管理器设置命令的菜单项,如平时我们在使用 VScode 时在侧边栏右键弹出的菜单中操作新建文件/新建文件夹,亦或是对当前选中文件进行右键菜单的复制/删除之类的操作。该配置子项中,至少需要包含两个字段,一个是该 submenu 显示的时机,用 when 来表示,另一个是选中该 submenu 后所调用的命令,用 command 进行配置,如果需要配置成可选模式,可以使用 alt 来进行定义,当键入 alt 时菜单才会显示该子项,另外我们还可以为其配置对应的 icon,如平时打开的 markdown 文件,右上角在 editor 的 title 区域,会有对应的“放大镜”图标显示及其 hover 时候的功能描述弹框,这些都是通过 menus 进行配置的,而且还可以通过其他字段来控制菜单项的显示控制等
    • keybindings:配置触发插件命令的组合键,具体规则可参考 Key Bindings for VScode
    • 更多扩展能力可前往 插件发布内容配置 查阅

插件入口:extension.ts

插件入口需要对外暴露两个函数,分别是插件激活时候运行的 activate 和插件禁用/卸载前清理时候运行的 deactivate

  • activate:在 activationEvents 中注册的激活事件被触发的时候,VScode 会调用一次这个函数

  • deactivate:在我们对插件在全局/当前工作区操作禁用/卸载的时候,就会去调用该函数执行清理任务

    • 异步的清理任务,在该函数中必须返回一个 Promise
    • 同步的清理任务,可以返回 undefined

Virtual Documents

在该需求中需要实现一个功能,把指定 uri 的内容展示到一个只读区域并在其之上做一些选中高亮的操作,通过翻阅文档想到的就是使用虚拟文档的方式实现,它是通过 VScode 的文本内容供应器为任意来源的文件创建只读文档

  • TextDocumentContentProvider:声明供应器函数,然后返回需要显示到虚拟文档上面的文本内容,它的工作需要依赖于 uri 协议,通过以下方式进行注册,将供应器函数与协议关联起来

    vscode.workspace.registerTextDocumentContentProvider(myScheme, myProvider);
    
  • 创建自定义的 uri,其中 parse 中的参数即为打开后的虚拟文档的标题

    const uri = vscode.Uri.parse('xxx');
    
  • 通过上一步创建的 uri 来调用步骤一中与之关联的供应器函数并获取到返回的文本内容

    const doc = await vscode.workspace.openTextDocument(uri);
    
  • 把步骤二中通过供应器函数返回来的内容显示到只读的文档当中

    await vscode.window.showTextDocument(doc, { preview: false });
    
  • 通过 showTextDocument 第二个参数配置项的 selection 字段来对文档进行选中的操作

    await vscode.window.showTextDocument(doc,  {
      //  用 Range 定义范围,用 Position 来定义具体的起止点
      selection: new vscode.Range(new vscode.Position(line, col), new vscode.Position(line, col)),
      preview: false,
      viewColumn: vscode.ViewColumn.Two
    };);
    
  • 通过 API createTextEditorDecorationType 给文档创建一个装饰器,然后可以为文档加上一些自定义的样式

    let decorationType = vscode.window.createTextEditorDecorationType({
        backgroundColor: 'red',
    });
    editor.setDecorations(decorationType, [new vscode.Range(new vscode.Position(line, col), new vscode.Position(line, col + 1))]);
    

这样我们就完成了上述功能了,更多关于虚拟文档的能力如更新,注册 UI 命令等可前往 VScode 虚拟文档 查阅

注意事项

下面来讲讲,我在开发过程中遇到的一些问题:

1、webview 加载本地资源

描述:webview 运行在独立的环境中,因此不能直接访问本地资源,这是出于安全性考虑的做法。这也意味着要想从你的插件中加载图片、样式等其他资源,或是从用户当前的工作区加载任何内容的话,你必须使用 webview 中的vscode-resource:协议,所以我们创建 webview 时候,如果指定的 html 内容中包含了 link 和 script 之类的标签的时候,我们需要对其 src 进行处理,才能从插件内加载到我们的资源

解决:

// 首先在给 webview 设置 html 内容前,需要对 html 进行改造,如将需要加载的本地资源都改成以 @PWD 开头,然后在插件内部设置 html 时,将其更换成经过 vscode.Uri.file 处理的路径,这样我们就可以在 webview 中请求插件目录下的资源(另外可以搭配 localResourceRoots 来限制插件,只能从某个目录下加载文件资源)
const content = readFileSync(
    'xxx.html',
    "utf-8"
).replace(
  new RegExp("@PWD", "g"), pwd);  // pwd 即下面我们处理后的静态资源文件根路径
vscode.Uri.file(
  // extensionPath:运行插件时 VScdoe 传入的上下文时可以拿到插件安装的目录
  // staticPath:需要加载的静态资源目录
  join(extensionPath, staticPath)
).with({ scheme: 'vscode-resource' }).toString();
// 经过处理后的 url:vscode-resource:/Users/xxx/projects/vscode-extension-demo/static/xxx.js

2、webview 内容持久化

描述:webview 内容在非激活状态(如切换到了其他 tab)下会被销毁,在可见时重新创建,所以需要对其内容进行持久化的处理,用到了 webviewPanelSerialize 的 setState 跟 getState 储存状态,在 webview 再次可见或者 vscode 重启后,恢复内容到 webview panel 上

vscode.window.registerWebviewPanelSerializer('xxx', new xxxSerializer());

class xxxSerializer implements vscode.WebviewPanelSerializer {
    async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, state: any) {
    // state是 webview 内调用 setState 储存的内容
    console.log(Got state: ${state});

    // 需要注意,这里通过 state 恢复的是我们之前保存的文本内容,如 md 重启后可以看到之前的静态内容,但是如果我们的插件需要与 webview 之间进行通信的话,需要在插件中重新监听 webview 中的 message 事件,否则之前如果有在 webview 上操作后通知插件做处理的就无法响应了
    webviewPanel.webview.html = getWebviewContent();
    }
}

// 处理 webview 中的信息(在重启恢复后需要重新监听)
panel.webview.onDidReceiveMessage(message => {
    switch (message.command) {
        case 'alert':
            vscode.window.showErrorMessage(message.text);
            return;
    }
}, undefined, context.subscriptions);

注意:最后还有一个地方需要注意,就是正常情况下,我们的 webview panel 在 VScode 关闭后,宿主环境默认是会自动帮我们将其进行销毁,而假如我们通过 webviewPanelSerialize 进行持久化处理后,那么 VScode 就会将其交由我们自行处理,所以假如我们需要在用户关闭 VScode 后对 webview 进行关闭的话,就需要自己在 deactivate 中处理,否则,下次进来的时候还保留着上次的 panel,然后进入到 deserializeWebviewPanel 进行内容恢复

总结

以上是我在初次接触 VScode Extension 开发中学习与总结,如有理解或写描述上的错误,还请大家指出,希望能带着大家了解下 VScode 插件是如何构成的,其中最为重要的就是 package.json 中的 contribute 字段,里面提供了太多实用且便捷的扩展宿主 VScode 的能力,也深感 VScode 插件机制的强大之处,如果对其感兴趣的小伙伴快快动手一起学习实现啦

参考文献

www.bookstack.cn/read/VS-Cod…