💡第五篇:VSCode插件是如何与主进程通信的?
这是vscode架构的第五篇,基于mini-vscode:github.com/zenoskongfu…
我不会教你怎么写vs code插件,但是能让你更懂插件
前言
写 VS Code 插件时,我们经常会看到一个现象:插件代码明明可以调用 vscode.commands、vscode.window、vscode.languages,但它却不能直接操作 VS Code 的主界面。
这不是能力不足,而是 VS Code 插件体系最核心的设计:插件运行在独立的 extensionHost 中,主界面运行在 renderer 中。两者被刻意隔离,然后通过稳定的 API 和 RPC 协议协作。
这一篇就从插件为什么要隔离、RPC 是怎么工作的、命令从声明到执行经历了什么,逐步拆开看看:extensionHost 为什么不能直接操作主界面。
本篇基于
为什么 VS Code 一定要支持插件?
一是功能拓展的需求,VSCode 不可能支持所有功能,不可能满足所有用户的需求,这部分是需要交给用户 DIY 的。如果嵌入大量的功能到 VSCode 中,会导致 VSCode 变得非常臃肿。
二来,落到现实,当时 VSCode 团队开发插件的直接驱动就是,VSCode 支持的语言有限,不能支持所有语言。想通过插件的方式,让其他开发者开发想要适配的语言,为此还专门制定了 LSP (language server protocl)。当时第一个试验品就是 VSCode 自己。他们自己为了插件的功能的稳定,就将 JS 的语言服务放到插件中,随 VScode 一起发布
其中语言服务,包含:代码跳转、语法诊断、代码高亮等等
插件为什么不能直接跑在 renderer 里?
主要还是为了安全考虑。
插件是由第三方编写开发的,如果任由其跑在主线程中,会有两个问题:
稳定性问题:插件如果死循环、抛异常,占用大量的 CPU,就会导致整个工作台卡顿
边界问题,如果插件直接访问 DOM、React 组件、就可以绕开设计好的 API, 随便修改 VSCode 内部状态。
extensionHost 到底隔离了什么?
隔离了很多东西:
隔离了运行环境:插件代码跑在独立的进程中
隔离了 UI实现:插件不直接操作 React/DOM
隔离了故障影响: 插件出错,不应该直接拖垮工作台
隔离了访问权限:插件只能通过 vscode API 请求能力
在 vscode 中,main 进程会启动一个独立的utilityProcess作为 extensionHost。这个进程专门负责扫描拓展入口文件、注入假的 vscode API、执行插件的 activate()
如果插件想要修改 renderer 中的内容,就必须通过 RPC 通信协议,告诉 renderer,而不是直接修改。
插件为什么不能直接操作 renderer?
在设计层面,不允许高度耦合,如果插件可以直接修改 renderer、那么一旦 renderer 内部的元素修改了,名称或者类型都修改了,那插件就坏了,这非常不稳定。每次 VSCode 想要修改东西,还得顾及这些插件,影响功能的开发进度,真是本末倒置。
真正稳定的是协议,是开放的 API。插件通过协议/开放 API 告诉 VSCode 想要做什么,然后 VSCode 内部再去做什么,两者完全隔离,这样才是稳定的,可持续的,可拓展的。
为什么 extensionHost 和 renderer 要通过 RPC 协作?
上面讲了为什么 extensionHost 和 renderer 要个隔离成不同的进程,但进程之间通信为什么要选择 RPC 进行通信呢?因为 RPC 可以实现调用远程对象方法,就像调用当前进程方法一样,这就是原因。RPC 是什么?新的通信方式?并不是。
RPC 不是替代 postMessage,而是建立在 postMessage 这类底层通信之上的一层协议。它让 renderer 和 extensionHost 在保持进程隔离的同时,还能像本地对象一样协作。
postMessage 解决的是“消息怎么发过去”,RPC 解决的是“怎么把跨进程通信组织成可调用、可等待、可返回、可维护的方法调用”。
RPC 到底通信是什么?
读懂代码里的 RPC,能帮助你了解后面的源码
不过要学会,首先就得知道 proxy 拦截方法调用是怎么工作的:
const proxy = new Proxy({}, {
get(target, methodName) {
console.log('你读取了方法名:', methodName)
return (...args) => {
console.log('你调用了方法:', methodName)
console.log('参数是:', args)
}
}
})
proxy.sayHello('张三', 18)
proxy.openFile('/a/b.txt')输出大概是:
你读取了方法名: sayHello
你调用了方法: sayHello
参数是: ['张三', 18]
你读取了方法名: openFile
你调用了方法: openFile
参数是: ['/a/b.txt']proxy 拦截方法其实分成两步:
const fn = proxy.sayHello // 触发 get,methodName = 'sayHello'
fn('张三')所以 Proxy 的 get 拦截的是“读取属性”:proxy.sayHello,而不是sayHello()。
通过 proxy,可以获取调用的方法,以及传入方法的参数。也就意味着可以调用任意方法。理解了这个,就可以看项目中的 RPC 了
来看 extensionHost:
const rpc = new RPCProtocol(protocol);
const extHostCommands = rpc.set("ExtHostCommands", new ExtHostCommands());这里rpc.set了一个key - value, 在 rpc 内部用 map 存储:
class RPCProtocol {
private readonly _locals = new Map<string, Record<string, unknown>>()
/** 暴露一个可通过 `id` 寻址的本地实现 */
set<T extends object>(id: string, instance: T): T {
this._locals.set(id, instance as Record<string, unknown>)
return instance
}
}ExtHostCommands中保存了拓展注册的命令,并在 workbench 请求的时候执行它们。
在后面可以看到 renderer 层是如何通过 RPC 协议让 extendsionHost 执行对应命令的。
renderer 层:
// src/renderer/services/extensions/extensionService.ts
export class ExtensionService {
async start(){
const rpc = new RPCProtocol(protocol);
this._extHostExtensions = rpc.getProxy<ExtHostExtensionServiceShape>(ExtHostContext.ExtHostExtensionService);
// 扩展宿主连接完成后,再加载已安装扩展
await this._extHostExtensions.$setDisabledExtensions([...this._disabled]);
this._installed = await this._extHostExtensions.$getExtensions();
}
private _onRegisterCommand(id: string): void {
// ...
const d = this.commandService.registerCommand({
id,
title: id, // 无贡献元数据可用,退回显示原始 id
handler: (...args: unknown[]) => this._extHostCommands.$executeContributedCommand(id, args),
});
// ...
}
}在注册命令的时候,会拼接一个对象,包含:id、title、handler。后面通过命令面板调用,或者代码调用对应命令的时候,就会执行 handler
而 handler 中是执行一个 proxy 对象中的方法:this._extHostCommands.$executeContributedCommand(id, args)
先看 rpc.getProxy 是什么意思:
class RPCProtocol {
getProxy<T extends object>(id: string): T {
return new Proxy(Object.create(null), {
get: (_target, method: string) => {
return (...args: unknown[]): Promise<unknown> => this._remoteCall(id, method, args)
}
}) as T
}
}这个函数平平无奇,就是返回一个 proxy 对象,然后对象中会引用传入的 id。引用 id 的地方是this._remoteCall,也就意味着,拿着这个 proxy 对象,调用任意方法,最后都会调用this._remoteCall
class RPCProtocol {
private readonly _pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
constructor(private readonly _protocol: IMessagePassingProtocol) {
this._protocol.onMessage(msg => this._receive(msg as ProtocolMessage));
}
private _remoteCall(proxyId: string, method: string, args: unknown[]): Promise<unknown> {
const id = ++this._lastId
return new Promise((resolve, reject) => {
this._pending.set(id, { resolve, reject })
this._protocol.send({ type: 'req', id, proxyId, method, args } satisfies RequestMessage)
})
}
}在 _remoteCall 中我们需要关注的重点是,调用了this._protocol.send,this._protocol来自构造函数,还记得构造函数传进来的是什么东西吗?
const protocol: IMessagePassingProtocol = {
send: (m) => port.postMessage(m),
onMessage: (cb) => {
port.onmessage = (ev): void => cb(ev.data);
},
};这里的 send,是指向 channel 中发送消息,也就意味着所有调用 proxy 对象的方法,都是想 channel 发送消息,并且发送的消息包括:消息 type, 对象 ID,方法名,方法的参数,那么 channel 另一边是不是就可以拿着对象,调用对应的方法,然后传入对应的参数?
那 channel 那边这么接收的呢?接收就看port.onmessage的逻辑是什么:
class RPCProtocol {
constructor(private readonly _protocol: IMessagePassingProtocol) {
this._protocol.onMessage(msg => this._receive(msg as ProtocolMessage));
}
}在构造函数中注册了port.onmessage,即this._receive:
private async _receive(msg: ProtocolMessage): Promise<void> {
if (msg.type === 'req') {
const target = this._locals.get(msg.proxyId)
const fn = target?.[msg.method]
if (typeof fn !== 'function') {
this._protocol.send({
type: 'reply',
id: msg.id,
error: `[RPC] no handler for ${msg.proxyId}.${msg.method}`
} satisfies ReplyMessage)
return
}
try {
const result = await (fn as (...a: unknown[]) => unknown).apply(target, msg.args)
this._protocol.send({ type: 'reply', id: msg.id, result } satisfies ReplyMessage)
} catch (e) {
const error = e instanceof Error ? e.message : String(e)
this._protocol.send({ type: 'reply', id: msg.id, error } satisfies ReplyMessage)
}
return
}
// 收到回复
const pending = this._pending.get(msg.id)
if (!pending) return
this._pending.delete(msg.id)
if (msg.error !== undefined) pending.reject(new Error(msg.error))
else pending.resolve(msg.result)
}this._receive的逻辑是这样的:
判断消息类型是不是 req
从
this._locals中获取 id 对应的对象:this._locals.get(msg.proxyId)获取方法名对应的方法:
fn = target?.[msg.method]执行
fn:result = await fn.apply(target, msg.args)告知执行结果:
this._protocol.send({ type: 'reply', id: msg.id, result })
也就是说,在 channel 的一端调用 proxy 对象的任意方法,都会发送至 channel 的另一端,调用指定对象的指定的方法。这就是 RPC 的通信本质,即像调用真实对象的方法一样,调用另一个进程的对象方法。
代码中,还有一个很有意思的细节,就是this._pending, 这个机制让 RPC 不仅仅可以调用另一个进程的方法,还可以升级成可以像调用异步函数的一样等待远端结果。这一块比较简单,就不带大家一起看了:)
package.json 里的 contributes 到底声明了什么?
contributes是插件的静态能力声明。告诉 VSCode,我贡献了哪些东西。
{
"contributes": {
"commands": [
{
"command": "demo.sayHello",
"title": "Say Hello"
}
]
}
}这段配置的意思不是“命令已经能执行了”,而是:
这个插件声明了一个 command id
这个 command id 可以出现在命令面板里
这个 command id 可以被主程序识别真正的执行逻辑,是在插件激活之后,通过代码注册的:
vscode.commands.registerCommand('demo.sayHello', () => {
// 真正执行的 handler
})也就是说,这里分成了两部,第一步通过 package.json 告诉 vscode,我有这些命令,第二步代码注册,是真正把命令的逻辑告诉了 vscode。
为什么要这么设计?最大的目的,就是为了懒加载。
比如,在命令面板中,会显示所有插件的命令,也支持搜索插件的命令,但如果没有静态插件的能力说明,VSCode 想要拿到这些命令,就得激活所有的插件。如果有 50 个,每次启动 VSCode,都得加载这 50 个插件,而且插件的激活方法中,可能会有不少的耗时操作,这样 VSCode 会越来越慢,越来越卡。这肯定不是我们想要的。
我们既想要开始拿到所有插件的命令,又不想 VSCode 变卡,就必须懒加载,静态声明就是一个非常的好的方式。
与懒加载相关的是activationEvents的参数,它的作用就是告诉 VSCode 什么时候激活该插件:
{
"activationEvents": [
"onCommand:demo.sayHello",
"onLanguage:typescript"
]
}一个插件命令从声明到可执行,完整经历了哪些步骤?
加载插件,获取静态说明
命令面板触发命令
判断是否满足激活条件,根据
activationEvents激活插件extensionHost 加载插件入口 extension.js
插件执行 activate,并调用
vscode.command.registerCommandhandler 放进 extensionHost,renderer 侧注册代理命令
如果不满足
activationEvents,就执行先激活,后执行。执行方式,通过 RPC,告知 extensionHost 执行对应的 handler,并把执行结果通过 RPC 告知 renderer
下面来看详细代码
加载插件
在 Electron 启动时,就会创建 extensionHost 进程:
// src/main/index.ts
app.whenReady().then(()=>{
// 启动隔离的扩展宿主(utilityProcess),并转交它的通信端口
extensionHost = new ExtensionHost();
extensionHost.start(mainWindow);
})export class ExtensionHost{
start(mainWindow: BrowserWindow): void {
if (this.disposed) return
// 由 electron-vite 随 main 进程一起构建(见 vite 配置入口)
const entry = path.join(__dirname, 'extensionHost.js')
this.child = utilityProcess.fork(entry, [], {
stdio: 'inherit', // 将 ExtHost 的 console.* 输出到 main 终端
serviceName: 'mini-vscode-extension-host'
})
}启动 ExtensionHost 进程的细节,在:💡第四篇:VSCode中Monaco model、URI、dirty 状态和 tabs 是怎么协作的? 有更详细的讲解
在启动 ExtensionHost 之后,就会和 renderer 进程建立起 RPC 协议进行通信。RPC 协议细节在下文中就有讲解。在建立 RPC 通信之后,就可以加载已经安装的插件了
// 添加extensionHost的extension service到rpc中
const extService = new ExtHostExtensionService(
[init.builtinExtensionsDir, init.userExtensionsDir],
rpc,
extHostCommands,
extHostLanguageFeatures,
extHostDocuments,
extHostDiagnostics
);
rpc.set(ExtHostContext.ExtHostExtensionService, extService);
// 扫描当前的已安装的路径中,有多少插件
extService.scan();class ExHostExtensionService {
scan(): void {
// 扫描多套目录(内置只读 + 用户可写);同 id 时后扫的覆盖(用户覆盖内置)
const byId = new Map<string, ExtensionDescription>();
for (const baseDir of this.extensionDirs) {
let entries: string[] = [];
try {
entries = fs.readdirSync(baseDir);
} catch {
continue; // 目录不存在(如 userData/extensions 尚未创建)→ 跳过
}
for (const name of entries) {
const dir = path.join(baseDir, name);
const manifestPath = path.join(dir, "package.json");
if (!fs.existsSync(manifestPath)) continue;
try {
const m = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
byId.set(m.name, {
id: m.name,
name: m.name,
displayName: m.displayName,
main: m.main,
activationEvents: m.activationEvents ?? [],
contributes: m.contributes ?? {},
extensionPath: dir,
});
} catch (e) {
console.error("[ExtHost] bad manifest", manifestPath, e);
}
}
}
this._extensions = [...byId.values()];
}
}代码中提到的两个路径:内置只读和用户可写
内置只读:表示内置插件市场的目录。插件市场本应该是来自后端接口的,因为 mini-vscode 没有现成的接口,就本地 mock 了插件市场
用户可写:表示插件实际安装的目录
scan 就是做了一件事,找到路径中所有的插件,然后放到this._extensions,this._extensions中就包含插件的activationEvents以及contributes,也就是插件的静态扫描!
真正初始化激活,触发在 renderer 层
// 连接扩展宿主(浏览器预览中不会收到端口,因此是 no-op)
instantiationService.get(IExtensionService).start()renderer 层中连接 extensionHost,并且连接 RPC 通信。并且在 start 中
// 扩展宿主连接完成后,再加载已安装扩展
// 这是连接成功的第一个RPC通信,告诉拓展宿主哪些插件被用户禁用了
await this._extHostExtensions.$setDisabledExtensions([...this._disabled]);
// 这是第二个RPC通信,获取当前有哪些拓展
this._installed = await this._extHostExtensions.$getExtensions();
// 注册插件的静态命令
this._reconcileCommands();
// 启动即激活 onStartupFinished 扩展(懒激活扩展仍等各自命令触发)
await this._extHostExtensions.$activateByEvent("onStartupFinished");/** 为已安装且启用的扩展注册命令,并注销其他扩展的命令 */
private _reconcileCommands(): void {
const shouldHave = new Set(this._installed.filter((e) => !this._disabled.has(e.id)).map((e) => e.id));
// 注销当前已禁用/已卸载扩展的命令
for (const id of [...this._commandDisposables.keys()]) {
if (!shouldHave.has(id)) this._disposeCommands(id);
}
// 注册新启用扩展的命令
for (const ext of this._installed) {
if (shouldHave.has(ext.id) && !this._commandDisposables.has(ext.id)) {
this._registerCommands(ext);
}
}
}
private _registerCommands(ext: ExtensionDescription): void {
const disposables: IDisposable[] = [];
for (const cmd of ext.contributes.commands ?? []) {
disposables.push(
// 命令面板可以看到这个命令,但此时拓展还是没有被激活
this.commandService.registerCommand({
id: cmd.command,
title: cmd.title,
category: cmd.category ?? ext.displayName ?? ext.name,
handler: async (...args: unknown[]) => {
// 懒激活扩展,然后在扩展宿主中执行真正的处理器
// 先手动激活扩展(可能是 onCommand:xxx 触发的懒激活),再对应的执行命令
await this._extHostExtensions.$activateByEvent(`onCommand:${cmd.command}`);
return this._extHostCommands.$executeContributedCommand(cmd.command, args);
},
})
);
}
this._commandDisposables.set(ext.id, disposables);
}详细代码查看:github.com/zenoskongfu…
在注册命令时,只注册没有被禁用的插件命令。注册插件的命令细节很意思,在 handler 这里。
首先遍历插件的contributes.commands列表,依次注册。由于执行的具体工作来自插件,所以 handler 中,就只能告诉 extensionHost,我要执行某某命令。当然,执行之前,需要激活对应插件。在这里注册了插件的命令,就可以在命令面板中显示出来了
激活onStartupFinished或*的 activationEvents插件
async $activateByEvent(event: string): Promise<void> {
for (const ext of this._extensions) {
if (
this._activatedExtensions.has(ext.id) ||
this._activating.has(ext.id) ||
this._disabled.has(ext.id)
)
continue;
if (ext.activationEvents.includes(event) || ext.activationEvents.includes("*")) {
await this._activate(ext);
}
}
}先排除已经激活的、正在激活的、被禁用的,然后开始激活。
命令面板触发命令
显示命令面板时,会显示所有的命令:
const commands = commandService.getCommands()并且还需要显示命令对应的快捷键:
keybindingService.lookupKeybinding(command.id)用户选中对应的命令,执行:
commandService.executeCommand(command.id)async executeCommand<T = unknown>(id: string, ...args: unknown[]): Promise<T | undefined> {
const command = this._commands.get(id)
if (!command) {
console.warn(`[CommandService] command not found: ${id}`)
return undefined
}
return (await command.handler(...args)) as T
}先找到对应的命令,然后执行 handler。handler 中有向 extensionHost 发送请求的过程,对应的代码是:
async (...args: unknown[]) => {
// 懒激活扩展,然后在扩展宿主中执行真正的处理器
// 先手动激活扩展(可能是 onCommand:xxx 触发的懒激活),再对应的执行命令
await this._extHostExtensions.$activateByEvent(`onCommand:${cmd.command}`);
return this._extHostCommands.$executeContributedCommand(cmd.command, args);
}extensionHost 收到请求之后,先激活对应的 command event,然后再执行命令。激活的过程和上面的一样。执行的话,代码也很简单,通过 id 找 handler 罢了
async $executeContributedCommand(id: string, args: unknown[]): Promise<unknown> {
const handler = this._handlers.get(id)
if (!handler) {
throw new Error(`[ExtHost] command not registered: ${id}`)
}
return await handler(...args)
}很简单:)
插件激活
插件激活其实挺多细节的,放在下面展开讲了
讲完了,从扫描插件到初始化,然后激活插件,执行插件的命令。就这些,还挺简单的吧?那下面看看插件是具体是怎么被激活的吧
插件是怎么被激活的?
最简单的插件代码:
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const disposable = vscode.commands.registerCommand('myHello.sayHello', () => {
vscode.window.showInformationMessage('Hello World from my extension!');
});
context.subscriptions.push(disposable);
}
export function deactivate() {}上面的插件注册了一个myHello.sayHello的命令
插件的激活有什么好讲的?其实想一想,每个插件都是一个 js 文件的集合,入口文件是 extension.js, extensionHost 进程,要动态加载并执行入口文件,extesion.js 加载自身其他插件文件是相对自身路径来引入的,这个怎么解决? 然后代码中使用 vscode 库,是定制化的,即 extensionHost 注入的,这个又怎么解决?
为什么 vscode 库是 extensionHost 临时注入的,在编写插件的时候,并没有安装这样一个库,安装的只是
@types/vscode
ok,两个问题:
解决extesion.js 加载自身其他文件的路径问题
注入定制化的 vscode 库
第一个问题:解决extesion.js 加载自身其他文件的路径问题
import Module, { createRequire } from "node:module";
const context: ActivationRecord["context"] = {
subscriptions: [],
extensionPath: ext.extensionPath,
globalState: new Map<string, unknown>(),
};
const req = createRequire(path.join(ext.extensionPath, "package.json"));
mod = req(ext.main) as ActivationRecord["module"];
if (typeof mod?.activate === "function") {
await mod.activate(context);
console.log(`[ExtHost] activated ${ext.id}`);
}先创建一个 require 的函数,用来加载extesion.js,传入的参数是package.json的绝对路径,所以在加载extesion.js就会以插件项目作为加载的根路径。
加载拿到extesion.js的模块,然后执行其中的activate,并传入 context。
第二个问题:注入定制化的 vscode 库
怎么注入?对 commonjs 的 Module 做手脚:
const moduleLoad = Module._load;
Module._load = function (
this: unknown,
request: string,
...rest: unknown[]
) {
if (request === "vscode") {
return (currentExtensionId && extensionApis.get(currentExtensionId)) || {};
}
return moduleLoad.call(this, request, ...rest);
};代码中覆盖了Module._load函数,拿到 request 判断,如果是vscode,就返回准备好的对象。这个对象就是 vscode,上面有各种各样的 api:vscode.commands、vscode.window、vscode.languages、vscode.workspace
准备 vscode 对象:
// 每个扩展独享一个 `vscode` API 对象,API 会绑定到该扩展的
// id(这样命令注册就能追踪到所属扩展)。
// 在加载扩展模块与调用 activate 期间设置 `currentExtensionId`,这样
// require('vscode') 拦截器就能返回对应扩展的 API。
const extensionApis = new Map<string, Record<string, unknown>>();
let currentExtensionId: string | null = null;
extensionApis.set(
// 插件的name
ext.id,
createVSCodeApi(
this.rpc,
this.extHostCommands,
this.extHostLanguageFeatures,
this.extHostDocuments,
this.extHostDiagnostics,
ext.id
)
);这里用extensionApis保存每个插件的 vscode 对象,为什么要每个插件单独分开?因为在 vscode.command 注册命令的时候,需要和插件 id 绑定。
通过createVSCodeApi创建 vscode 对象:
export function createVSCodeApi(
rpc: RPCProtocol,
extHostCommands: ExtHostCommands,
extHostLanguageFeatures: ExtHostLanguageFeatures,
extHostDocuments: ExtHostDocuments,
extHostDiagnostics: ExtHostDiagnostics,
extensionId: string
): Record<string, unknown> {
// 获取proxy对象,直接调用renderer层的对象的方法
const mainCommands = rpc.getProxy<MainThreadCommandsShape>(MainContext.MainThreadCommands)
const mainMessage = rpc.getProxy<MainThreadMessageShape>(MainContext.MainThreadMessageService)
return {
commnad: {
registerCommand(id: string, handler: (...args: unknown[]) => unknown): Disposable {
extHostCommands.registerCommand(extensionId, id, handler)
// 告诉 workbench:这个贡献命令现在已经有可调用的处理器
mainCommands.$registerCommand(id)
// dispose 时双向清理:本地处理器 + 通知 workbench 移除命令
return {
dispose: () => {
extHostCommands.unregister(id)
mainCommands.$unregisterCommand(id)
}
}
},
executeCommand(id: string, ...args: unknown[]): Promise<unknown> {
return mainCommands.$executeCommand(id, args)
}
},
// widnow:{...},
// languages: {...},
// workspace: {...}
}
}着重讲 vscode.command.registerCommand,以及vscode.command.executeCommand
在registerCommand会做两件事,一是向 extensionHost 的 commandService 注册一个命令,二是向 renderer 层的 commnadService 注册一个命令。
静态注册已经在 renderer 注册了一遍了,为什么这里还要注册一遍?这是有些开发者会在静态声明中漏掉一些命令,在插件激活之后,无论有没有静态说明,都会被 renderer 层的命令系统收录进去。
在executeCommand就做一件事,告诉 renderer 层执行哪个命令。
了解createVSCodeApi后,是不是对插件的 active更加了解呢:)?
展开
renderer 层的命令 handler 是存在 renderer 层中,而 extensionHost 的命令,就是存在extensionHost中,两个进程可以相互调用命令。
为什么 main 只负责建立 MessagePort 通道,而不是一直当中间人?
在技术上允许 extensionHost 和 renderer 层直接通信,少一跳,性能更好
如果让 main 当中间通信层,那想实现 RPC 协议会更加复杂难以维护
插件与 renderer 层的通信非常频繁,不应该让这些通信拖慢 main 的性能
为什么 VSCode 可以支持海量的语言?
VSCode 支持海量语言的同时,还能保证软件主体不臃肿,这是怎么做到的?原因就在于 VSCode 讲语言服务外包给了插件。
VSCode 只提供平台能力:识别文件类型、加载对应插件、插件注册语言能力、调用插件语言能力。而具体某个语言怎么补全、怎么跳转、怎么诊断、都交给插件去做。
下篇文章就来讲讲语言服务!
总结
回头看,extensionHost 不能直接操作主界面,并不是 VS Code 故意增加复杂度,而是为了让插件体系长期稳定。
插件可以由任何第三方开发,如果允许它们直接访问 renderer、DOM 或内部服务,那么插件的一次异常、一次死循环、一次错误修改,都可能拖垮整个编辑器。更麻烦的是,一旦主界面内部实现变化,大量依赖内部结构的插件都会失效。
所以 VS Code 选择了一条更稳的路:插件运行在独立的 extensionHost,通过 vscode API 表达意图,再由 VS Code 内部决定如何执行。RPC 则负责把这种跨进程协作包装成类似本地方法调用的体验。
也正因为有这样的边界,VS Code 才能放心地把命令、语言能力、诊断、跳转、格式化等能力交给插件生态扩展。核心编辑器保持轻量稳定,具体语言和功能交给插件补齐,这就是 VS Code 能支持海量语言和丰富能力的关键。
