💡第五篇:VSCode插件是如何与主进程通信的?

前言

写 VS Code 插件时,我们经常会看到一个现象:插件代码明明可以调用 vscode.commandsvscode.windowvscode.languages,但它却不能直接操作 VS Code 的主界面。

这不是能力不足,而是 VS Code 插件体系最核心的设计:插件运行在独立的 extensionHost 中,主界面运行在 renderer 中。两者被刻意隔离,然后通过稳定的 API 和 RPC 协议协作。

这一篇就从插件为什么要隔离、RPC 是怎么工作的、命令从声明到执行经历了什么,逐步拆开看看:extensionHost 为什么不能直接操作主界面。

本篇基于mini-vscode:github.com/zenoskongfu...

为什么 VS Code 一定要支持插件?

一是功能拓展的需求,VSCode 不可能支持所有功能,不可能满足所有用户的需求,这部分是需要交给用户 DIY 的。如果嵌入大量的功能到 VSCode 中,会导致 VSCode 变得非常臃肿。

二来,落到现实,当时 VSCode 团队开发插件的直接驱动就是,VSCode 支持的语言有限,不能支持所有语言。想通过插件的方式,让其他开发者开发想要适配的语言,为此还专门制定了 LSP (language server protocl)。当时第一个试验品就是 VSCode 自己。他们自己为了插件的功能的稳定,就将 JS 的语言服务放到插件中,随 VScode 一起发布

其中语言服务,包含:代码跳转、语法诊断、代码高亮等等

插件为什么不能直接跑在 renderer 里?

主要还是为了安全考虑。

插件是由第三方编写开发的,如果任由其跑在主线程中,会有两个问题:

  1. 稳定性问题:插件如果死循环、抛异常,占用大量的 CPU,就会导致整个工作台卡顿
  2. 边界问题,如果插件直接访问 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 内部再去做什么,两者完全隔离,这样才是稳定的,可持续的,可拓展的。

RPC 通信是什么?

要读懂代码里的 RPC,就得知道 proxy 拦截方法调用是怎么工作的:

javascript 复制代码
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')

输出大概是:

javascript 复制代码
你读取了方法名: sayHello
你调用了方法: sayHello
参数是: ['张三', 18]

你读取了方法名: openFile
你调用了方法: openFile
参数是: ['/a/b.txt']

proxy 拦截方法其实分成两步:

javascript 复制代码
const fn = proxy.sayHello  // 触发 get,methodName = 'sayHello'
fn('张三')          

所以 Proxy 的 get 拦截的是"读取属性":proxy.sayHello,而不是sayHello()

通过 proxy,可以获取调用的方法,以及传入方法的参数。也就意味着可以调用任意方法。理解了这个,就可以看项目中的 RPC 了

来看 extensionHost:

javascript 复制代码
const rpc = new RPCProtocol(protocol);
const extHostCommands = rpc.set("ExtHostCommands", new ExtHostCommands());

这里rpc.set了一个key - value, 在 rpc 内部用 map 存储:

javascript 复制代码
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 层:

javascript 复制代码
// 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 是什么意思:

javascript 复制代码
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

javascript 复制代码
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.sendthis._protocol来自构造函数,还记得构造函数传进来的是什么东西吗?

javascript 复制代码
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的逻辑是什么:

javascript 复制代码
class RPCProtocol {
  constructor(private readonly _protocol: IMessagePassingProtocol) {
    this._protocol.onMessage(msg => this._receive(msg as ProtocolMessage));
  }
}

在构造函数中注册了port.onmessage,即this._receive:

javascript 复制代码
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]
  • 执行 fnresult = await fn.apply(target, msg.args)
  • 告知执行结果:this._protocol.send({ type: 'reply', id: msg.id, result })

也就是说,在 channel 的一端调用 proxy 对象的任意方法,都会发送至 channel 的另一端,调用指定对象的指定的方法。这就是 RPC 的通信本质,即像调用真实对象的方法一样,调用另一个进程的对象方法。

代码中,还有一个很有意思的细节,就是this._pending, 这个机制让 RPC 不仅仅可以调用另一个进程的方法,还可以升级成可以像调用异步函数的一样等待远端结果。这一块比较简单,就不带大家一起看了:)

为什么 extensionHost 和 renderer 要通过 RPC 协作?

上面讲了为什么 extensionHost 和 renderer 要个隔离成不同的进程,但进程之间通信为什么要选择 RPC 进行通信呢?因为 RPC 可以实现调用远程对象方法,就像调用当前进程方法一样,这就是原因。RPC 是什么?新的通信方式?并不是。

RPC 不是替代 postMessage,而是建立在 postMessage 这类底层通信之上的一层协议。它让 rendererextensionHost 在保持进程隔离的同时,还能像本地对象一样协作。

postMessage 解决的是"消息怎么发过去",RPC 解决的是"怎么把跨进程通信组织成可调用、可等待、可返回、可维护的方法调用"。

package.json 里的 contributes 到底声明了什么?

contributes是插件的静态能力声明。告诉 VSCode,我贡献了哪些东西。

json 复制代码
{
  "contributes": {
    "commands": [
      {
        "command": "demo.sayHello",
        "title": "Say Hello"
      }
    ]
  }
}

这段配置的意思不是"命令已经能执行了",而是:

latex 复制代码
这个插件声明了一个 command id
这个 command id 可以出现在命令面板里
这个 command id 可以被主程序识别

真正的执行逻辑,是在插件激活之后,通过代码注册的:

javascript 复制代码
vscode.commands.registerCommand('demo.sayHello', () => {
  // 真正执行的 handler
})

也就是说,这里分成了两部,第一步通过 package.json 告诉 vscode,我有这些命令,第二步代码注册,是真正把命令的逻辑告诉了 vscode。

为什么要这么设计?最大的目的,就是为了懒加载。

比如,在命令面板中,会显示所有插件的命令,也支持搜索插件的命令,但如果没有静态插件的能力说明,VSCode 想要拿到这些命令,就得激活所有的插件。如果有 50 个,每次启动 VSCode,都得加载这 50 个插件,而且插件的激活方法中,可能会有不少的耗时操作,这样 VSCode 会越来越慢,越来越卡。这肯定不是我们想要的。

我们既想要开始拿到所有插件的命令,又不想 VSCode 变卡,就必须懒加载,静态声明就是一个非常的好的方式。

与懒加载相关的是activationEvents的参数,它的作用就是告诉 VSCode 什么时候激活该插件:

json 复制代码
{
  "activationEvents": [
    "onCommand:demo.sayHello",
    "onLanguage:typescript"
  ]
}

一个插件命令从声明到可执行,完整经历了哪些步骤?

  1. 加载插件,获取静态说明
  2. 命令面板触发命令
  3. 判断是否满足激活条件,根据 activationEvents 激活插件
    1. extensionHost 加载插件入口 extension.js
    2. 插件执行 activate,并调用 vscode.command.registerCommand
    3. handler 放进 extensionHost,renderer 侧注册代理命令
  4. 如果不满足activationEvents,就执行先激活,后执行。
  5. 执行方式,通过 RPC,告知 extensionHost 执行对应的 handler,并把执行结果通过 RPC 告知 renderer

下面来看详细代码

加载插件

在 Electron 启动时,就会创建 extensionHost 进程:

javascript 复制代码
// src/main/index.ts
app.whenReady().then(()=>{
  // 启动隔离的扩展宿主(utilityProcess),并转交它的通信端口
	extensionHost = new ExtensionHost();
	extensionHost.start(mainWindow);
})
javascript 复制代码
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 进程在:"" 有更详细的讲解

在启动 ExtensionHost 之后,就会和 renderer 进程建立起 RPC 协议进行通信。RPC 协议细节在下文中就有讲解。在建立 RPC 通信之后,就可以加载已经安装的插件了

javascript 复制代码
// 添加extensionHost的extension service到rpc中
const extService = new ExtHostExtensionService(
		[init.builtinExtensionsDir, init.userExtensionsDir],
		rpc,
		extHostCommands,
		extHostLanguageFeatures,
		extHostDocuments,
		extHostDiagnostics
	);
	rpc.set(ExtHostContext.ExtHostExtensionService, extService);
// 扫描当前的已安装的路径中,有多少插件
	extService.scan();
javascript 复制代码
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._extensionsthis._extensions中就包含插件的activationEvents以及contributes,也就是插件的静态扫描!

真正初始化激活,触发在 renderer 层

javascript 复制代码
// 连接扩展宿主(浏览器预览中不会收到端口,因此是 no-op)
instantiationService.get(IExtensionService).start()

renderer 层中连接 extensionHost,并且连接 RPC 通信。并且在 start 中

javascript 复制代码
// 扩展宿主连接完成后,再加载已安装扩展

// 这是连接成功的第一个RPC通信,告诉拓展宿主哪些插件被用户禁用了
await this._extHostExtensions.$setDisabledExtensions([...this._disabled]);
// 这是第二个RPC通信,获取当前有哪些拓展
this._installed = await this._extHostExtensions.$getExtensions();
// 注册插件的静态命令
this._reconcileCommands();
// 启动即激活  onStartupFinished 扩展(懒激活扩展仍等各自命令触发)
await this._extHostExtensions.$activateByEvent("onStartupFinished");
javascript 复制代码
	/** 为已安装且启用的扩展注册命令,并注销其他扩展的命令 */
	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插件

javascript 复制代码
	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);
			}
		}
	}

先排除已经激活的、正在激活的、被禁用的,然后开始激活。

命令面板触发命令

显示命令面板时,会显示所有的命令:

javascript 复制代码
const commands = commandService.getCommands()

并且还需要显示命令对应的快捷键:

javascript 复制代码
 keybindingService.lookupKeybinding(command.id)

github.com/zenoskongfu...

用户选中对应的命令,执行:

javascript 复制代码
commandService.executeCommand(command.id)
javascript 复制代码
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 发送请求的过程,对应的代码是:

javascript 复制代码
async (...args: unknown[]) => {
  // 懒激活扩展,然后在扩展宿主中执行真正的处理器
  // 先手动激活扩展(可能是 onCommand:xxx 触发的懒激活),再对应的执行命令
  await this._extHostExtensions.$activateByEvent(`onCommand:${cmd.command}`);
  return this._extHostCommands.$executeContributedCommand(cmd.command, args);
}

extensionHost 收到请求之后,先激活对应的 command event,然后再执行命令。激活的过程和上面的一样。执行的话,代码也很简单,通过 id 找 handler 罢了

javascript 复制代码
  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)
  }

很简单:)

插件激活

插件激活其实挺多细节的,放在下面展开讲了

讲完了,从扫描插件到初始化,然后激活插件,执行插件的命令。就这些,还挺简单的吧?那下面看看插件是具体是怎么被激活的吧

插件是怎么被激活的?

最简单的插件代码:

javascript 复制代码
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,两个问题:

  1. 解决extesion.js 加载自身其他文件的路径问题
  2. 注入定制化的 vscode 库

第一个问题:解决extesion.js 加载自身其他文件的路径问题

javascript 复制代码
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 做手脚:

javascript 复制代码
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.commandsvscode.windowvscode.languagesvscode.workspace

准备 vscode 对象:

javascript 复制代码
// 每个扩展独享一个 `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 对象:

javascript 复制代码
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会做两件事,一是向 extensionHostcommandService 注册一个命令,二是向 renderer 层的 commnadService 注册一个命令。

静态注册已经在 renderer 注册了一遍了,为什么这里还要注册一遍?这是有些开发者会在静态声明中漏掉一些命令,在插件激活之后,无论有没有静态说明,都会被 renderer 层的命令系统收录进去。

executeCommand就做一件事,告诉 renderer 层执行哪个命令。

了解createVSCodeApi后,是不是对插件的 active更加了解呢:)?

展开

renderer 层的命令 handler 是存在 renderer 层中,而 extensionHost 的命令,就是存在extensionHost中,两个进程可以相互调用命令。

为什么 main 只负责建立 MessagePort 通道,而不是一直当中间人?

  1. 在技术上允许 extensionHost 和 renderer 层直接通信,少一跳,性能更好
  2. 如果让 main 当中间通信层,那想实现 RPC 协议会更加复杂难以维护
  3. 插件与 renderer 层的通信非常频繁,不应该让这些通信拖慢 main 的性能

为什么 VSCode 可以支持海量的语言?

VSCode 支持海量语言的同时,还能保证软件主体不臃肿,这是怎么做到的?原因就在于 VSCode 讲语言服务外包给了插件。

VSCode 只提供平台能力:识别文件类型、加载对应插件、插件注册语言能力、调用插件语言能力。而具体某个语言怎么补全、怎么跳转、怎么诊断、都交给插件去做。

下篇文章就来讲讲语言服务!

总结

回头看,extensionHost 不能直接操作主界面,并不是 VS Code 故意增加复杂度,而是为了让插件体系长期稳定。

插件可以由任何第三方开发,如果允许它们直接访问 renderer、DOM 或内部服务,那么插件的一次异常、一次死循环、一次错误修改,都可能拖垮整个编辑器。更麻烦的是,一旦主界面内部实现变化,大量依赖内部结构的插件都会失效。

所以 VS Code 选择了一条更稳的路:插件运行在独立的 extensionHost,通过 vscode API 表达意图,再由 VS Code 内部决定如何执行。RPC 则负责把这种跨进程协作包装成类似本地方法调用的体验。

也正因为有这样的边界,VS Code 才能放心地把命令、语言能力、诊断、跳转、格式化等能力交给插件生态扩展。核心编辑器保持轻量稳定,具体语言和功能交给插件补齐,这就是 VS Code 能支持海量语言和丰富能力的关键。

相关推荐
锋行天下16 小时前
打造企业内部知识库系统RAG全栈项目
前端·后端·架构
竹林81816 小时前
用 ethers.js 连接 MetaMask 实现钱包登录:一个真实项目中的完整踩坑记录
前端·javascript
虚惊一场16 小时前
在浏览器里跑 Prettier:格式化 Markdown 的四个难点
前端·javascript·vue.js
前端炒粉16 小时前
手撕小汇总
java·前端·javascript
r_oo_ki_e_16 小时前
vue快速入门
前端·vue.js
虚惊一场17 小时前
把 CodeMirror 6 调教成 Markdown 编辑器:扩展、装饰与门面
前端·javascript·vue.js
lauo17 小时前
掌心核爆:iQOO首款小平板搭载2nm骁龙8E6,开启AI原生计算的移动终端新纪元
前端·人工智能·智能手机·重构·电脑·ai-native
虚惊一场17 小时前
一条 Markdown 渲染管线的全部细节:Worker、源行标注与按需加载
前端·javascript·vue.js
CoovallyAIHub17 小时前
当能源行业遇上 AI 智能体:Coco 为什么选择留在本地
前端·agent