VScode 插件开发指北(一) 结构与规范

1,295 阅读10分钟

这并不是一篇 VScode 插件 API 字典,因为这是 code 官方文档 的职责。本文旨在让你了解我们在开发 VScode 插件时的思考过程,帮助你掌握查阅文档的能力。

换句话说,本文的核心目的是帮助读者理清,开发 VScode 插件时我们会想些什么。

TIPS:

本文章在 “开发” 章节之前主要输出一些关键的点,不涉及完整的开发细节。 如果你想直接了解怎么写一个插件,可以从 “开发” 开始看,如果你想先对 vscode 插件有一个相对完整的认知,再了解具体怎么写,请顺序阅读。

1. VScode 基本结构

在正式进入 VScode 插件开发之前,我们先简单了解一下 VScode 本身。

1.1 基于 Electron 的 VScode(架构)

VScode 是基于 electron 构建的项目。其宏观上的运行结构如下图:

  • Chromium: Blink(渲染) + V8(前端脚本,Web API)

  • Node.js: 操作系统级别能力(文件读写,进程管理,网络管理)

  • Native API: 特定平台的操作系统交互能力(对话框,窗口事件...)

1.2 进程结构(运行)

Electron 程序是多进程的。

  • 主进程 Main Process: 应用启动第一个进程是主进程,负责原生 GUI, 窗口管理,控制整个应用的生命周期,访问底层操作系统 API

  • 渲染进程 Renderer Process: 被主进程管理,每个窗口通常对应一个渲染进程,负责前端渲染,页面脚本执行。通过 IPC 与主进程通信。

  • GPU 进程 (可选): GPU 加速任务

在 VScode 这个应用的场景中:

其结构示意图如下:

  • 主进程:整个应用的生命周期,处理菜单等全局UI,快捷键

  • 渲染进程:VSCode 主窗口的渲染,处理用户交互文本编辑,文件树视图

  • 拓展主机进程 Extension Host:隔离运行插件(可以有多个该进程,用于不同的拓展插件)

  • LSP 进程: 关于 LSP 又是一个庞大的内容,它抽象了对语言的支持,比如代码补全,快速跳转,语法错误诊断...

  • ....

VScode 运行时,活动监控截图:

VScode 插件运行在单独的进程中

这样做明显的好处是提高了安全性(因为插件的来源是非信任的,假设插件崩了,VScode 本身应该保持稳定)。

插件激活时,新的插件进程启动,执行插件逻辑。

开屏动画播完了,现在,我们正式开始学习插件开发:

从宏观上看,学习插件开发的两个关键是:

  • 边界: 所谓边界,就是我们的插件能增强 VScode 一些什么?(显然取决于 VScode 提供的 api 的边界)
  • 规范: 所谓规范,就是我们如何告诉 VScode 我们要干嘛?( 不妨想想如何你是设计者,你会如何做?常见的就是特定格式的配置文件解析 + 回调函数注册 )

我们围绕上面两个两个问题展开。

2. 边界

2.1 VScode 本身是什么?

从产品的角度来看,VScode 作为一款代码编辑器,所有的功能点无非是围绕着开发体验。

编辑器(Editor)是根,其他 UI 用来辅助开发。

2.2 VScode 插件能做些什么

在探讨如何编写 VScode 插件之前,先来考虑插件可能有哪些能力,即他能拓展 VScode 什么?

  • 语言支持: 代码补全提示,格式化,跳转,代码检查...

  • 主题配色:语法高亮,主题配置

  • 编辑器增强: 扩充编辑器的功能,举例:Auto Import(自动导入引用),Image preview(预览导入的图片)...

  • VScode 通用功能: 快捷键,命令,消息通知...

  • UI 扩充: 侧边栏,底部状态栏,Panel...(见上图)

  • ...

但总的来看,插件主要可以做两件事:

  1. 增强代码编辑体验(围绕文本编辑)

  2. 扩充 UI(嵌入一些除了编辑器以外的其他UI)

3. 规范

先介绍后实践,此处只是大致描述让大家产生一些必要的概念认识,详细的实践在后面 “开发” 一章中

VScode 本身是一个庞大的程序。如何拓展一个已有的程序?根据我们的经验也可以想到:

  1. 编辑配置文件(基本信息)

  2. 注册回调函数(逻辑)

3.1 配置文件(基本信息)

VScode 插件的配置文件就是项目根目录下的 package.json 文件

配置文件能声明哪些信息?

  • 基本信息(插件名,作者,版本,描述,图标,分类,标签)

    •   name, version, publisher, description, author, icon ...
  • 引擎和依赖(引擎负责标识插件适用的 VScode 版本,依赖则是该项目的依赖文件)

    •   engines, dependencies, devDependencies ...

    •   很多功能都是见名知意,举个🌰:

    • {
        "name": "demo",
        "displayName": "一个很强大的插件",
        "version": "0.1.0",
        "publisher": "cosine",
        "description": "这个插件可以用来实现你的三个愿望",
        "author": {
          "name": "cosine"
        },
        "categories": ["Other"],//vscode问你,该插件是干啥的,主题?语言支持?或者其他
        "icon": "images/icon.png",
        "engines": {
          "vscode": "^1.0.0"
        },
        "main": "./out/extension",
        "scripts": {
          "vscode:prepublish": "node ./node_modules/vscode/bin/compile",
          "compile": "node ./node_modules/vscode/bin/compile -watch -p ./"
        },
        "devDependencies": {
          "@types/vscode": "^0.10.x",
          "typescript": "^1.6.2"
        },
        "license": "SEE LICENSE IN LICENSE.txt",
        "bugs": {
          "url": "https://github.com/microsoft/vscode-wordcount/issues",
          "email": "sean@contoso.com"
        },
        "repository": {
          "type": "git",
          "url": "https://github.com/microsoft/vscode-wordcount.git"
        },
        "homepage": "https://github.com/microsoft/vscode-wordcount/blob/main/README.md"
      }
      
  • 激活事件

    •   该字段告诉 VScode 该插件什么时候激活
    •   为什么需要设置激活事件?我们的插件应该“按需”加载,节省资源,所谓的需就是通过这个字段配置
    • {
          //其他配置,   
          "activationEvents": [ 
              "onLanguage:json", 
              "onLanguage:markdown",  
              "onLanguage:typescript"
           ]
       }
      

举个🌰,这段配置表示当打开 json , md ,ts 类型文件时,插件激活

  • 入口文件 main(指定了插件脚本的入口文件,见下文)

  • 贡献点 contributes (即拓展了什么功能) (划重点)

    •   如下图,是 pitaya 插件在商店中展示的截图, 功能贡献 中展示了该插件的激活事件贡献点
    •   这俩信息说明了插件在什么情况下会被激活(打开特定类型的文件?还是点了插件图标,还是通过快捷键,命令...),以及该插件都拓展了什么(拓展UI?增强语言?)

这里掏出几种带大家感受:

新增一个 Command:

{
  // 其他配置...
  "contributes": {
    // TIPS:
    // 此处只是告诉 VScode 我有这么一个 command。
    // 至于这个 command 触发后会发生什么?需要通过下文的回调函数声明
    "commands": [
      {
        "command": "extension.sayHello",
        "title": "Hello World",
        "category": "Hello",
      }
    ]
  }
}

新增一个 MenuItem

{
  // 其他配置
  "contributes": {
    "menus": {
      "view/title": [
        {
          "command": "terminalApi.sendText",
          "when": "view == terminal",
          "group": "navigation"
        }
      ]
    }
  }
}

总而言之, contributes 字段是整个配置文件的核心,是告诉 VScode 我这个插件都想干啥。

小提醒,这里只是告诉了 VScode 我们想做什么?而具体怎么做的,是通过 main 字段指定的脚本实现的

关于更加完整详细,请看 VCR:

package.json 配置详情

code.visualstudio.com/api/referen…

Contributes 字段配置详情:

code.visualstudio.com/api/referen…

激活事件配置详情:

code.visualstudio.com/api/referen…

3.2 回调函数(插件逻辑)

前文提到,可以在 package.json 中的 main 字段可声明入口文件地址,如:

{
    //...其他字段
    "main": "./dist/extension.js",
}

小学二年级学过,回调函数的关键有俩,1. 运行时机 2. 调用者传入的参数。

3.2.1 运行时机

VScode 会根据 main 字段读取该文件夹下的两个函数:

  • 入口函数 reactive ****:在插件激活时调用,用来声明插件本身的逻辑

  • 出口函数 deactivate:在插件销毁时调用,用来清理一些内存资源

🌰 举个例子:

// 激活时注册插件逻辑:
export function activate(context: ExtensionContext) {  
  // ... 一些业务逻辑:
  Logger.showDefaultLog();
  const provider = registerWebviewView(context);
  registerCommands(context, provider);
}


// 插件销毁时调用,无参数
export function deactivate() {
  // ...释放插件中的一些资源
  Logger.dispose();
}

3.2.2 VScode API(划重点)

在插件中,你需要使用的 API 主要来自两个地方

  • 导入 vscode import * as vscode from 'vscode' : vscode 环境交互通用功能
  • 回调参数 context : 当前拓展实例的一些信息

我们统称为 VScode API

关于 context:

activate 函数接收参数 context: VScode.ExtensionContext (附上源码链接)

学习 VScode 插件开发的本质就是熟悉 VScode API 提供了哪些能力。

因为 VScode 插件能干啥,取决于 VScode API 提供的能力。

作为 VScode API 的用户,不妨先想想自己可能有哪些需求需要被 VScode API 满足:

  1. 命令的注册(当命令触发时我要干嘛)

  2. UI 的注册(为了扩充 UI,我们应该有能力告诉 VScode 在哪个位置显示什么)

  3. 文件操作能力(读写用户 Workspace 中的文件)

  4. Workspace 配置的读写(即修改和读取用户的 VScode 配置)

  5. Editor 内容的读写(比如检测当前光标的文本,单词,格式化文本的能力)

  6. ....

是的,vscode API 都会满足你。下面举一些简单的🌰

  1. 注册一个命令:
import * as vscode from "vscode";

export function activate(context: vscode.ExtensionContext) {
  let disposable = vscode.commands.registerCommand(
    //在 package.json contributions 字段中注册的 Command ID
    "myExtension.helloWorld", 
    
    // command 触发时的回调函数
    () => {
      vscode.window.showInformationMessage("Hello World from My Extension!"); 
    }
  );

  context.subscriptions.push(disposable);
}

export function deactivate() {}
  1. context.subscriptions

值得一提的是,context.subscriptions 是一个用来释放资源的栈,vscode 会在插件销毁的时候调用栈中的函数,因此将释放资源的函数 push 进去吧。

比如在上面的代码中, vscode.commands.registerCommand 这个函数就会返回释放资源的函数。

  1. 注册 webview 视图

大致看个眼熟就行,下文“视图”章节会补充细节

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    const provider = new MyWebViewProvider(context.extensionUri);
    const dispose = vscode.window.registerWebviewViewProvider('myExtension.webviewView', provider)
    context.subscriptions.push(dispose);
}

class MyWebViewProvider implements vscode.WebviewViewProvider {
    constructor(private readonly _extensionUri: vscode.Uri) {}

    // vscode 会调用这个方法,传入 webviewView 实例,详情下面将
    resolveWebviewView(webviewView: vscode.WebviewView) {
        webviewView.webview.options = {
            enableScripts: true,
            localResourceRoots: [this._extensionUri]
        };

        webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
    }

    private _getHtmlForWebview(webview: vscode.Webview) {
        return `<!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>My WebView</title>
        </head>
        <body>
            <h1>Hello from My Extension!</h1>
            <p>This is a simple WebView in a custom ViewContainer.</p>
        </body>
        </html>`;
    }
}

export function deactivate() {}
  1. 操作 workspace 配置

案例:注册两个 command 来读取和更新 vscode 配置

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    // 注册一个命令来读取配置
    let readConfigCmd = vscode.commands.registerCommand('myExtension.readConfig', () => {
        const config = vscode.workspace.getConfiguration('myExtension');
        const sampleSetting = config.get('sampleSetting');
        vscode.window.showInformationMessage(`Current value: ${sampleSetting}`);
    });

    // 注册一个命令来更新配置
    let updateConfigCmd = vscode.commands.registerCommand('myExtension.updateConfig', async () => {
        const config = vscode.workspace.getConfiguration('myExtension');
        const newValue = await vscode.window.showInputBox({
            prompt: "Enter new value for sampleSetting",
            value: config.get('sampleSetting')
        });

        if (newValue !== undefined) {
            await config.update('sampleSetting', newValue, vscode.ConfigurationTarget.Workspace);
            vscode.window.showInformationMessage(`Updated sampleSetting to: ${newValue}`);
        }
    });

    context.subscriptions.push(readConfigCmd, updateConfigCmd);
}

export function deactivate() {}

关于 VS Code API 详情请看 VCR:

code.visualstudio.com/api/referen…

一些个人感想

无论是学习框架,语言,一套解决方案...

本质上,我们都是他们的用户,作为用户,可以先想想自己有哪些需求要满足,带着这些需求再审视学习的内容,一些内容就会 “对号入座”,减少学习难度。

3.3 一个值得思考的问题(可跳过)

为什么在package.json 中需要 contributes 这个字段?

再描述清楚一点问题,为什么需要先在 contributes 注册拓展功能,再在口入文件中实现?为什么不直接使用入口文件,省去注册。

下面是我的一些想法:

  1. 关注点分离

在 package.json 中声明我要干啥。

在 入口文件 中说明怎么干。

用户也可以通过阅读 package.json 大致了解这个插件拓展了哪些内容。

从这个角度来看,方便思考,方便概览。

  1. 自动集成

插件有一些视图是在插件激活前就需要有的,比如侧边的按钮,或者命令菜单中的选项。因此,他们一定不可能写在入口文件中(因为这个脚本只有插件激活时才初次运行)。

因此,有一些功能是 vscode 根据配置文件自动集成的,而不是通过我们的入口文件手动去写。

比如下文会提到的 viewContainer , vscode 会自动在目标位置渲染在 package.json 中注册的按钮。再比如命令的视图,当你按下 cmd+shift+p ,会在命令菜单中看到我们在 package.json 中注册的命令 title, icon.

从这个角度来看,这是必要的,也降低了开发人员的心智负担。

  1. 安全性

配置文件是静态的,vscode 分析这个静态能容来了解开发者申请了哪些功能,然后给到不同权限的沙箱。

而且,用户可以通过禁用 contributes 中的某些项,来禁用某部分拓展功能。