前言
在上一篇里,我们已经把 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 执行的到底是什么?
注册是 id 和 handler 的映射关系,执行的是 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 的“中间货币”,是因为不同入口、不同模块、甚至不同进程之间,都可以围绕它完成协作。后面再看命令面板、插件贡献命令、扩展激活时,这个模型会反复出现。