一文带你了解VSCode插件

909 阅读12分钟

本文作者:来自 MoonWebTeam 的 brookliang

本文编辑:kanedongliu

1. 引言

vscode是微软开发的代码编辑器,因其轻量、跨平台、多语言支持性而广受欢迎。vscode本身的定位只是一个代码编辑器(editor),却能实现传统ide(集成开发环境,integrated development environment )一样实现一站式开发,支持多语言的代码提示、编译、调试等重要功能。正是因为有强大的插件系统和丰富的插件社区,vscode才能提供如此优秀的代码编写体验。本文将为大家介绍vscode插件的能力,插件系统的原理以及如何开发一个vscode插件。

学习vscode插件,一来可以学习vscode插件系统的优秀设计,二来可以学习如何编写vscode插件,为今后编写cr工具、aigc单测生成插件等开发实用工具插件打好基础、帮助开发提效。

2. 插件能力

我们都能用插件做些什么?vscode为插件提供了以下几种扩展能力:

  • 语言扩展:为新编程语言提供支持,提供代码高亮、代码补齐、错误诊断、跳转定义等功能
  • 工作区域扩展:扩展菜单、侧边栏
  • 调试支持:自定义调试器、调试信息、调试配置
  • 通用能力拓展:命令、快捷键、存储、通知、文件选择、进度条等通用能力
  • 主题修改:代码颜色、编辑器主题、文件图标

image.png

考虑到第三方插件的质量和安全性往往难以保证,为了保障编辑器的核心功能不遭插件破坏,vscode也对插件功能做了一些限制,杜绝插件对界面的篡改以及可能的性能与稳定性影响:

  1. 无法访问VSCode UI DOM。
  2. 无法提供自定义css样式表改变界面。

这些隔离是怎么实现的呢,下面将介绍插件运作机制。

3. 插件系统原理

3.1. electron基础

vscode的底层技术为electron,electron的核心构成分别是:Chromium、Node.js、Native Api。Chromium提供ui 视图,node.js用于操作文件系统和调用本地网络,native api为electron提供原生系统的GUI支持,如调用系统通知、打开系统文件夹等,可以理解是对Nodejs接口的能力拓展。

image.png

electron采用多进程架构,每个electron应用只会启动一个主进程, 在主进程中实例化一至多个BrowserWindow 创建的 Web 页面,每个工作区对应一个进程,称为渲染进程。 每个 Electron 中的 web 页面运行在它自己的渲染进程中。

  • 主进程:连接操作系统和渲染进程,负责管理所有窗口及其对应的渲染进程。
  • 渲染进程:负责完成渲染页面、接收用户输入、相应用户交互等工作。

每个进程都是隔离的。渲染进程间通信使用传统浏览器通信方式,如localstroage、cookie等方式。主进程和渲染进程通信是使用 ipcMain和 ipcRenderer模块,通过开发人员自定义的“通道”传递消息来进行通信。

img

ipcMain /ipcRenderer进程通信示例:

// 主进程
const { ipcMain } = require('electron');

// 主进程响应事件
ipcMain.on('main-msg', (event, arg) => {
  console.log(arg); // ping
  //触发渲染进程响应事件
  event.reply('renderer-msg-reply', 'pong');
})
// 渲染进程(子进程)
const { ipcRenderer } = require('electron');

// 渲染进程响应事件
ipcRenderer.on('renderer-msg-reply', (event, arg) => {
  console.log(arg); // pong
})

// 触发主进程响应事件
ipcRenderer.send('main-msg', 'ping');

3.2. vscode多进程架构

vscode沿用了electron的多进程架构,在主进程和渲染进程之外还额外设计了一些特殊进程,vscode各进程如下:

image.png

  • 主进程:VSCode 的入口进程,负责一些类似窗口管理、进程间通信、自动更新等全局任务
  • 渲染进程:负责一个编辑器页面的渲染
  • 插件宿主进程:不是electron进程,是普通的Node进程,同时被限制了不能使用electron。各插件的代码都会运行在同一个宿主进程中(网上有些文章说每个插件单独一个进程,其实是错误的)。插件不允许访问 UI。
  • Debug 进程:特殊的插件进程,它不运行在插件宿主进程中,而是在每次 debug 的时候由UI单独新开一个进程。
  • Search 进程:搜索是一类计算密集型的任务,单开进程保证软件整体体验与性能

3.3. 插件系统源码解读

网上的其他源码分析文章是基于旧版代码或者网页版vscode做的分析,以下根据vscode最新1.86.0版本代码,分析插件运行机制:

  • 插件系统加载入口

vscode从主类CodeMain到插件服务入口的分层关系如图所示:

img

workbench指的是vscode的ui界面,它包含了编辑器、导航栏、状态栏和侧边栏等元素。在桌面端的 vscode 中,入口文件为 workbench.js,其中引入了脚本 vs/workbench/workbench.desktop.main,上述文件又引入了插件服务:

// src/vs/workbench/workbench.desktop.main.ts
import 'vs/workbench/services/extensions/electron-sandbox/nativeExtensionService';
  • 初始化

lifecycleService是workbench的生命周期类,当workbench依赖的服务初始化完成时,触发LifecyclePhase.Ready生命周期钩子。寻找浏览器空闲时间,在至多50ms内,浏览器执行插件服务的初始化,启动插件进程。

// src/vs/workbench/services/extensions/electron-sandbox/nativeExtensionService.ts
export class NativeExtensionService extends AbstractExtensionService implements IExtensionService {
    constructor() {
		// 监听生命周期钩子,执行初始化
		lifecycleService.when(LifecyclePhase.Ready).then(() => {
			runWhenWindowIdle(mainWindow, () => {
				this._initialize();
			}, 50 /*max delay*/);
		});
    }
    _initialize() {
       // 启动插件进程
		this._startExtensionHostsIfNecessary(true, []);
    }
}
// 全局单例依赖注入
registerSingleton(IExtensionService, NativeExtensionService, InstantiationType.Eager);
  • 创建插件进程

_startExtensionHostsIfNecessar内部会调用ExtensionHostConnection类的start方法,fork当前的渲染进程创建插件子进程,同时建立父子进程的通信通道。cp.fork的第一个参数是子进程中要执行的node脚本,这个脚本中会使用vscode的模块加载系统vscode-loader来加载指定的入口文件。

// src/vs/server/node/extensionHostConnection.ts
import * as cp from 'child_process';
export class ExtensionHostConnection {
	public async start(startParams: IRemoteExtensionHostStartParams): Promise<void> {
		const opts = {
			...,
        // 指明插件进程入口
			VSCODE_AMD_ENTRYPOINT: 'vs/workbench/api/node/extensionHostProcess',
		}
		// fork进程
		this._extensionHostProcess = cp.fork(FileAccess.asFileUri('bootstrap-fork').fsPath, args, opts);
	}
}
  • 插件激活
class AbstractExtHostExtensionService extends Disposable {
	constructor() {
		// 注册事件发射器
		this._activator = this._register(new ExtensionsActivator(/**省略参数*/));
	}

	// 根据activationEvent事件名激活插件,如onCommand
	private _activateByEvent(activationEvent: string, startup: boolean): Promise<void> {
		return this._activator.activateByEvent(activationEvent, startup);
	}
	private _doActivateExtension(/**省略参数*/) {
		// 加载插件入口文件
		return Promise.all([
			this._loadCommonJSModule(/**省略参数*/),
			...
		]).then(values => {
				// 执行插件的active函数
				return AbstractExtHostExtensionService._callActivate(/**省略参数*/);
			})
    }
}

4. 插件开发

4.1. 插件基本结构

插件的基本结构分为配置文件packages.json自定义插件逻辑两部分。

配置文件重要属性:

  • activationEvents:插件激活时机。出于性能考虑,默认不是随时激活。
  • contributes:插件扩展点,如commands命令面板、menus资源管理面板等。
// package.json
{
    ...,
    // 入口
    "main": "./out/extension.js",
    // 扩展点
    "contributes": {
    	// command特指命令面板触发
        "commands": [
            {
              "command": "extension.helloWorld",
               "title": "Hello World"
           }
        ]
    }
    // 插件激活时机
    "activationEvents": [
        // 执行命令时
        // 与扩展点相同的时机不用显式声明
        "onCommand:extension.helloWorld",
        // 打开特定语言的文件时
        "onLanguage:javascript",
        // 指定生命周期执行
        "onDebug",
    ],
    ... 
}

自定义插件逻辑通过packages.json中定义的入口文件暴露。插件加载机制为懒加载,在我们声明的 activationEvents被触发后,会执行activate入口函数。入口函数中可以通过调用 VSCode 提供的 API实现各种扩展功能。

import * as vscode from "vscode";

// 1.插件激活时调用
export function activate(context: vscode.ExtensionContext) {
  // 2: 注册命令, 与packages.json一致
  let disposable = vscode.commands.registerCommand('hello-world.helloWorld', function () {
    // 3: 执行自定义操作,这里触发了一个弹出框
    vscode.window.showInformationMessage('第一个demo弹出信息!');
  });
  // 4: 把这个对象放入上下文中, 使其生效
  context.subscriptions.push(disposable);
}

// 5.插件被销毁时调用的方法
export function deactivate() {}

可以使用vscode提供的脚手架工具来初始化插件项目:

npm install -g yo generator-code
yo my-plugin

4.2. 基于LSP的语言插件开发

-LSP简介-

vscode中为多语言提供支持的最重要的插件就是语言插件,它们为代码提供代码高亮、代码补齐、错误诊断、跳转定义等语言功能。实现语言功能有两种方式:

  • 基于vscode.langueges.* api的编程式语法
  • 基于Language Server Protocol的多进程架构

其中后者因其性能和开发效率上的优势已成为主流开发。LSP(Language Server Protocol,语言服务器协议)本质上是一种基于 JSON-RPC 的进程间通讯协议,专门用于描述 IDE 中,用户行为与响应之间的通讯方式与信息结构。

基于LSP架构的vscode插件由两个部分组成:

  • 语言客户端:用JavaScript / TypeScript编写的普通VS代码扩展。
  • 语言服务器:在独立进程中运行的语言分析工具,语言不限。

使用lsp有以下几个好处:

  • 在单独的进程中运行语言服务器可以避免性能开销,减少卡顿
  • 同一个编程语言不再需要为不同 IDE 重复开发相似的扩展插件

-错误诊断插件开发实战-

下面我们自行实现一个错误诊断的功能,目标扫描txt文件中大写单词并进行错误提示。

image.png

我们需要搭建基本的语言客户端和语言服务端。客户端比较简单,主要是指定服务端的入口、通信方式等信息后启动客户端。

// client/src/client.ts
let client: LanguageClient;

export function activate(context: ExtensionContext) {
	const serverModule = context.asAbsolutePath(
		path.join('server', 'out', 'server.js')
	);
	const serverOptions: ServerOptions = {
		run: { module: serverModule, transport: TransportKind.ipc },
		debug: {
			module: serverModule,
			transport: TransportKind.ipc,
		}
	};
	// 创建客户端
	client = new LanguageClient(
		'languageServerExample',
		'Language Server Example',
		serverOptions,
		clientOptions
	);

	// 启动客户端
	client.start();
}

export function deactivate(): Thenable<void> | undefined {
	if (!client) {
		return undefined;
	}
	return client.stop();
}

具体的语言处理放在服务端进行,要实现一个语服务端,需要实现下面代码块中的5要素。

// server/src/server.ts
// 要素1: 初始化 LSP 连接对象,Node的IPC作为传输。
const connection = createConnection(ProposedFeatures.all);

// 要素2: 创建文档集合对象,用于映射到实际文档
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);

connection.onInitialize((params: InitializeParams) => {
  // 要素3: 显式声明插件支持的语言特性
  const result: InitializeResult = {
    capabilities: {
      hoverProvider: true
    },
  };
  return result;
});

// 要素4: 将文档集合对象关联到连接对象
documents.listen(connection);

// 要素5: 开始监听连接对象
connection.listen();

写完基本的5要素,就可以实现具体的错误诊断功能了。

// server/src/server.ts
// 文本增量变化钩子
documents.onDidChangeContent((change) => { 
  const textDocument = change.document;

  // 获取文本字符串
  const text = textDocument.getText();
  const pattern = /\b[A-Z]{2,}\b/g;
  let m: RegExpExecArray | null;

  const diagnostics: Diagnostic[] = [];
  while ((m = pattern.exec(text))) {
    const diagnostic: Diagnostic = {
      // 错误等级
      severity: DiagnosticSeverity.Warning,
      // 下波浪线范围
      range: {
        start: textDocument.positionAt(m.index),
        end: textDocument.positionAt(m.index + m[0].length),
      },
      // 悬停展示的错误信息
      message: `${m[0]} is all uppercase.`,
      source: "ex",
    };
    // 错误信息放入错误信息列表,可同时显示多条
    diagnostics.push(diagnostic);
  }

  // 通过ipc通道发送错误信息
  connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
});

4.3. 打包及发布

插件打包需要借助vsce工具,插件会被打包成visx格式。

npm i @vscode/vsce -g
cd my-plugin
vsce package

发布流程类似npm,登陆publisher账号,执行发布命令发布到插件市场。具体步骤可参考Publishing Extensions | Visual Studio Code Extension API

vsce publish

vscode插件市场没有类似npm私有仓库的私有市场,如插件不方便发布到公共市场,只能发送visx文件给他人,通过"Install from VSIX"方式安装。

image.png

5. 总结

总的来说,VSCode插件不仅提供了强大的功能,其开发过程也相对直观和简单。要充分利用这些工具,我们还需要深入理解其背后的原理和机制。vscode官方也提供了很多优秀的官方插件,感兴趣的同学可以深入学习。

参考文献:

最后,如果客官觉得文章还不错,👏👏👏欢迎点赞、转发、收藏、关注,这是对小编的最大支持和鼓励,鼓励我们持续产出优质内容。

点赞

6. 关于我们

MoonWebTeam目前成员均来自于腾讯,我们致力于分享有深度的前端技术,有价值的人生思考。

7. 往期推荐

领域驱动设计之聚合和聚合根

一文了解鸿蒙HarmonyOS开发

MoonWebTeam前端技术月刊第2期

领域驱动设计之Domain Primitive

低码编辑器中的“拖拽”是如何实现的