💡第三篇:VSCode命令系统:为什么 command id 是 VSCode 的公共语言?

1 阅读7分钟

前言

在上一篇里,我们已经把 VS Code 的进程模型拆开看了一遍:main 负责系统能力,renderer 负责工作台,extensionHost 负责运行扩展。那么问题来了:这么多入口、这么多服务、这么多进程之间,用户的一个操作到底靠什么被统一起来?

答案就是命令系统。VS Code 没有让快捷键、菜单、命令面板、插件各自去直接调用具体功能,而是把“保存文件”“打开终端”“切换主题”这些动作都抽象成一个个 command id。理解了 command id,就等于理解了 VS Code 如何把触发者和实现者隔开,也能更容易看懂后面的插件扩展机制。

mini-vscode: github.com/zenoskongfu…

为什么 IDE 里的很多操作,都可以被理解成“命令”?

在 VSCode 中,很多用户的操作本质上都是“我要执行一个动作”。比如保存文件、打开命令面板、切换侧边栏、创建终端、启动调试、选择主题,这些看起来属于不同功能,但抽象一下,都是一个个可以被触发的动作

所以 VSCode 会把这些动作统一理解成 command,这些系统就不用为“快捷键触发保存”、“菜单触发保存”、“插件触发保存”各写一套逻辑,而是先把“保存”定义成一个命令。需要触发这个动作的时候,触发这个命令就可以了

在 mini-vscode 中,注册、管理各种命令的地方在commandService.ts

如果快捷键、菜单、按钮都直接调用功能,会乱在哪里?

就拿保存功能来说,如果快捷键、菜单、按钮都直接调用功能,那么就会变成调用方和具体实现方的高度耦合。

有保存功能,切换侧边栏功能,打开终端的功能,切换主题的功能,打开设置的功能等等,直接调用的话,就得知道负责这些功能的 service 在哪里,具体调用 service 的哪个方法。

更难搞的是,对于某一个功能,可能有多个入口,比如保存功能,有EditorService,也可以直接调用window.electronAPI.fs.writeFile,导致多个入口直接依赖具体实现

问题的本质是:要求调用者知道很多,而且调用者是不可控的。

所以需要一个中间层,将触发者和实现者隔离开来

command id 为什么能把“触发者”和“实现者”隔开?

command id 就是这个中间层

比如workbench.action.files.save,它只是一个字符串,但这个字符串非常关键。快捷键、菜单、命令面板、插件都可以只认识这个 id,而不直接认识具体的实现

实现者只需要提前注册这个命令就好了

const register = (
  id: string,
  title: string,
  category: string | undefined,
  handler: () => void,
  chord?: string
): void => {
  commandService.registerCommand({ id, title, category, handler })
  if (chord) keybindingService.registerKeybinding(chord, id)
}

register(
  'workbench.action.files.save',
  'Save',
  'File',
  () => { if (editorService.activePath) editorService.save(editorService.activePath) },
  'mod+s'
)

在注册的时候,需要提供命令 ID、handler,以及快捷键组合。

可以看到这个保存的命令,ID 是workbench.action.files.save,handler 的核心动作是:editorService.save(editorService.activePath),快捷键是:mod+s

调用的时候,只需要:

commandService.executeCommand('workbench.action.files.save')

非常方便

registerCommand 注册的到底是什么?executeCommand 执行的到底是什么?

注册是 idhandler 的映射关系,执行的是 id 对应的 handler

下面看代码:

export interface ICommand {
  id: string
  title: string
  category?: string
  handler: (...args: unknown[]) => unknown | Promise<unknown>
}

export class CommandService implements ICommandService {

  private readonly _commands = new Map<string, ICommand>()

  private readonly _onDidRegisterCommand = new Emitter<string>()
  readonly onDidRegisterCommand = this._onDidRegisterCommand.event

  registerCommand(command: ICommand): IDisposable {
    this._commands.set(command.id, command)
    this._onDidRegisterCommand.fire(command.id)
    return toDisposable(() => this._commands.delete(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
  }
}

可以看到 registerCommand中存储的是 command.id 以及 command 本身,而 command 本身最重要的就是 handler 了。

executeCommand中,接收两个东西,一个是command.id,一个是 handler 的参数。在 map 中找到 id 对应的 handler,然后调用它

代码非常简单:)

快捷键按下后,是怎么一步步变成执行 command id 的?

这里牵扯到两个 services,一个是 keybindingService,一个是 CommnadService。

在注册命令的同时,还会注册快捷键。keybindingService 监听整个页面的按键事件,一旦键位匹配,就会执行 command id

代码

注册的代码:

const register = (
  id: string,
  title: string,
  category: string | undefined,
  handler: () => void,
  chord?: string
): void => {
  commandService.registerCommand({ id, title, category, handler })
  if (chord) keybindingService.registerKeybinding(chord, id)
}

register(
  'workbench.action.files.save',
  'Save',
  'File',
  () => { if (editorService.activePath) editorService.save(editorService.activePath) },
  'mod+s'
)

keybindingService

export class KeybindingService{
  private readonly _chordToCommand = new Map<string, string>();
	private readonly _commandToChord = new Map<string, string>();
  
  registerKeybinding(chord: string, commandId: string): IDisposable {
		this._chordToCommand.set(chord, commandId);
		this._commandToChord.set(commandId, chord);
		return toDisposable(() => {
			this._chordToCommand.delete(chord);
			this._commandToChord.delete(commandId);
		});
	}
}
  • chord 是快捷键的键位
  • commandId 是命令 Id

这里有个细节,键位和 commnad id 存了两遍。一个是 chord to command id,另一个是 command id to chord。一般来说,只需要存chord to command id,就可以在按下快捷键的时候,找到对应的命令了,那command id to chord是什么需求,通过命令找快捷键?什么时候会用到?

在命令面板的时候,会用到,需要显示命令对应的快捷键位

那么keybindingService是如何监听键位的,又是如何找到键位对应的 command id 的?

export class KeybindingService{
  private readonly _chordToCommand = new Map<string, string>();
	private readonly _commandToChord = new Map<string, string>();
  
  constructor(@ICommandService private readonly commandService: ICommandService) {
		super();
		const handler = (e: KeyboardEvent): void => this._onKeyDown(e);
		// 捕获
		document.addEventListener("keydown", handler, true);
		this._register(toDisposable(() => document.removeEventListener("keydown", handler, true)));
	}

  private _onKeyDown(e: KeyboardEvent): void {
		const chord = eventToChord(e);
		if (!chord) return;
		const commandId = this._chordToCommand.get(chord);
		if (!commandId) return;
		// 匹配到绑定后,接管浏览器/编辑器默认行为并执行命令。
		// 阻止自然行为
		e.preventDefault();
		// 阻止冒泡
		e.stopPropagation();
		this.commandService.executeCommand(commandId);
	}
}

eventToChord 会将 KeyboardEvent 转换为标准按键和弦字符串,主修饰键(Win/Linux 上的 Ctrl,macOS 上的 Cmd)会标准化为 "mod"。最后得到的 chord 就是键位的字符串

代码很简单,我就不解释了:)总之,快捷链路是这样的:

KeyboardEvent -> chord -> command id -> executeCommand -> handler

命令 handler 里为什么通常会去调用 Service?

这是应为 command handler 不应该存放具体的业务实现,而是调用各种能力。命令系统解决的是怎么触发能力,Service 解决的是能力在哪里实现。hanlder 只是一层很薄的胶水层。

比如:

  • 比如保存命令的 handler,可以调用 EditorService 的 save。
  • 切换侧边栏的 handler,可以去调用 LayoutService
  • 创建终端的 handler,可以去调用 TerminalService

职责很清楚:命令系统管注册和执行,具体的 Service 管真正的业务能力。handler 把两者接起来。

插件为什么也要通过命令系统接入主程序能力?

插件不能直接操作 renderer 里的组件,也不应该直接调用主程序内部对象。那插件怎么调用能力呢,命令系统就是很好的方式。

在 renderer 层想要调用插件的能力,那通过命令系统也是一个很好的方式。

插件即可以贡献命令,也可以执行已有的命令,插件和主程序之间就不需要直接互相依赖了,非常好。

小结

这一篇的核心其实很简单:VS Code 不希望每个入口都直接依赖具体功能,所以引入了 command id 作为中间层。快捷键、菜单、命令面板、插件只需要知道 command id;真正的实现则提前通过 registerCommand 注册到 CommandService 中。

这样一来,触发者和实现者就被隔离开了。KeybindingService 只负责把按键转换成 command id,CommandService 只负责根据 id 找到 handler 并执行,具体能力仍然放在各个 Service 里。命令系统本身不负责保存文件、创建终端、切换主题,它负责把这些能力统一组织起来。

所以 command id 之所以像 VS Code 的“中间货币”,是因为不同入口、不同模块、甚至不同进程之间,都可以围绕它完成协作。后面再看命令面板、插件贡献命令、扩展激活时,这个模型会反复出现。