前言
很多人第一次看 VS Code 或 Electron 应用时,会下意识把它理解成“一个套了桌面壳的网页”。这个理解不能说完全错,但如果停在这里,就很难解释为什么读文件、开终端、跑扩展、响应命令这些能力不能都塞进同一个页面里。
这一篇我们先不急着看复杂源码,而是先把 VS Code 最核心的进程模型理清楚:main、preload、renderer、extensionHost 分别负责什么,它们之间为什么要隔离,又是如何通过 IPC 和 RPC 协作完成一次真实的编辑器操作。
mini-vscode: github.com/zenoskongfu…
既然 VS Code 看起来是个页面,为什么还要分多个进程?
首先 VSCode 将系统分成了四个进程:
- main 负责与底层交互
- preload 做安全桥
- renderer 渲染页面与用户交互
- extensionHost 跑扩展代码
Vscode 确实像一个前端页面:左边是文件树,中间是编辑器,底部可能还有终端,看起来就是 React 组件拼出来的
但问题是一个单纯的页面,做不了读写文件,启动终端,调试代码这些事情,所以需要其他进程的参与
Electron 里的 main 进程,到底是不是“主线程”?
Electron 里有一个 main 进程,,它是整个桌面引用的入口,负责创建窗口、管理应用生命周期、调用 NodeJs 的能力。但它不是 VS Code 架构里说的那个 Main Thread;
VSCode 说的 Main Thread,指的是 workbench 那一侧,也就是 renderer 里核心工作台逻辑,真正负责组织编辑器、命令、Service、状态流转。
main 进程负责什么?为什么它能接触本地系统?
main 进程运行在 NodeJs 环境里,所以它可以做浏览器页面不能做的事。比如读取文件、写入文件、监听目录变化、管理窗口、启动 extensionHost 等等。
renderer 层不能直接碰 Node API,但它可以通过 IPC 请求 main 帮它做事。比如 renderer 想读一个文件,不是自己调用fs.readFile,而是通过 preload 暴露出来的 API 请求,最后由 main 里的文件服务真正调用 Node 的fs的能力。
renderer 进程只是负责渲染页面吗?
renderer 层不止是渲染页面这么简单,它是一个复杂的庞大的系统,还做了下面这些事情
- 管理 monaco editor,编辑器状态
- DI 服务系统
- 快捷键、命令面板、菜单触发
- 与 main 通过 IPC 交互,插件进程通过 RPC 交互
举个例子,用户按Cmd + Shift + P:
按下Cmd + Shift + P:
-> KeybindingService 找 commandId
-> CommandService.executeCommand
-> 执行对应的handler(读取文件、打开tab、打开终端、更换主题等等可能会与main产生交互)
可以看到,之后最后一步可能有 main 的参与,前面的都是 renderer 层内部处理
renderer 想读写本地文件时,完整链路是怎样的?
读文件
点开文件,在编辑器显示文件内容。下面一起看看 mini-vscode 内部发生了什么
用户点击文件
<Sidebar
className="workbench__sidebar"
activeView={activeView}
//点击调用onOpenFile
onOpenFile={path => editorService.openEditor(path)}
/>
调用editorService 能力editorService.openEditor
async openEditor(path: string): Promise<void> {
const existing = this._tabs.find((t) => t.path === path);
if (existing) {
this._setActive(path);
return;
}
let content = "";
try {
content = await window.electronAPI.fs.readFile(path);
} catch {
content = "";
}
const tab: EditorTab = {
path,
name: path.split("/").pop() ?? path,
savedContent: content,
content,
dirty: false,
};
this._tabs = [...this._tabs, tab];
this._onDidChangeTabs.fire();
this._setActive(path);
}
这里会看是否已经打开了,如果没有打开,就会向 main 进程索要对应的文件内容。索要到文件内容后,就会新建一个 tab,放进 tab 数组中。并且触发组件更新
调用window.electronAPI.fs.readFile获得 file 内容,然后打开 tab,显示文件内容
let content = "";
try {
content = await window.electronAPI.fs.readFile(path);
} catch {
content = "";
}
触发组件更新:this._onDidChangeTabs.fire
export function EditorArea({ className = "", onCursorChange }: EditorAreaProps): React.JSX.Element {
const tabs = useEvent(editorService.onDidChangeTabs, () => editorService.tabs);
const activePath = useEvent(editorService.onDidChangeActiveEditor, () => editorService.activePath);
const activeTab = tabs.find((t) => t.path === activePath) ?? null;
return <MonacoEditor
path={activeTab.path}
value={activeTab.content}
onChange={handleChange}
onSave={handleSave}
onCursorChange={onCursorChange}
/>
}
当this._onDidChangeTabs.fire被执行,数组中的 tabs 就会重新获取,编辑器中,就会呈现对应的新的内容
写文件
经典的场景,就是编辑代码后,按下cmd + s,文件保存
按下 cmd + s
register(
'workbench.action.files.save',
'Save',
'File',
() => { if (editorService.activePath) editorService.save(editorService.activePath) },
'mod+s'
)
先注册cmd + s的快捷键对应的命令,执行动作是:
// 如果当前有打开的文件,就对当前文件执行保存操作
if (editorService.activePath) editorService.save(editorService.activePath)
执行 save 命令
export class EditorService{
async save(path: string): Promise<void> {
const tab = this._tabs.find((t) => t.path === path);
if (!tab) return;
await window.electronAPI.fs.writeFile(path, tab.content);
tab.savedContent = tab.content;
tab.dirty = false;
// 换新数组引用:原地清了 tab.dirty,否则 useEvent 检测不到 → 圆点不消失(同 #7)
this._tabs = [...this._tabs];
this._onDidChangeTabs.fire();
}
}
- 确保传进来的文件是打开的文件
- 调用
window.electronAPI.fs.writeFile(path, tab.content) - 去除
ditry 标记:tab.dirty = false - 触发组件更新:
this._onDidChangeTabs.fire
调用window.electronAPI.fs.readFile
去除 ditry 标记
为什么终端输出、文件变化这类数据,不适合一直用 invoke 请求?
invoke/handle 是 IPC 一问一答机制,比如调用读取文件,向 main 进程读取文件,就需要 main 进程返回文件的内容,这样的一问一答
还有一种是 main 进程直接 send 信息给 renderer 进程的:mainWinodw.webContents.sned('event:name', ...args);
下面我们看看终端和文件变化是怎么发送的
终端
在讲代码之前,先理清终端是个什么东西,这对理解架构很有帮助:
在古老时代,打字员通过打字机给电脑打字,需要的设备如下:
人 -> 打字机 -> 线缆 -> 主机 shell -> 主机系统
这里的终端是打字机+线缆,shell 是命令解释器,主机系统是真生执行命令的地方
那放在现代个人电脑中,就是这样:
人 -> 终端窗口 -> pty程序 -> shell/zsh/bash -> 操作系统
也就是说,与我们交互的窗口仅仅是个 UI,pyt 程序才是终端,shell 是命令解释器,操作系统是真正执行命令的地方。
在代码中,也是这个架构。先用 pty 连接 shell,然后 renderer 层起一个终端窗口,连接 pty,就可以开始交互了。
先从 pty 开始:
import * as pty from '@homebridge/node-pty-prebuilt-multiarch'
export class TerminalService{
private defaultShell(): string {
if (process.platform === 'win32') {
return process.env.COMSPEC || 'powershell.exe'
}
return process.env.SHELL || '/bin/bash'
}
create(id: string, cwd: string | undefined, mainWindow: BrowserWindow): void {
if (this.disposed) return
if (this.terminals.has(id)) return
const shell = this.defaultShell()
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: cwd || os.homedir(),
env: process.env as Record<string, string>
})
// 将 shell 输出流式推送到 renderer
ptyProcess.onData(data => {
if (mainWindow.isDestroyed()) return
mainWindow.webContents.send('terminal:data', id, data)
})
// shell 退出时通知 renderer
ptyProcess.onExit(({ exitCode }) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('terminal:exit', id, exitCode)
}
this.terminals.delete(id)
})
this.terminals.set(id, ptyProcess)
}
}
- 使用 pty 构建终端进程
- 监听终端事件,
onData、onExit - 在
onData中,直接通过mainWindow.webContents.send('terminal:exit')将shell的信息推送到renderer层
preload 层监听terminal:exit,注册回调事件:
// /src/preload/index.ts
contextBridge.exposeInMainWorld('electronAPI', {
terminal: {
onData: (cb: (id: string, exitCode:number)=>void)=>{
const listen = (_: unknown, id: string, code: number): void => cb(id, code);
ipcRender.on('terminal:data', listener as never)
return () => ipcRenderer.removeListener('terminal:data', listener as never)
},
input: (id: string, data: string) =>
ipcRenderer.invoke('terminal:input', id, data),
}
})
mini-vscode 没有在 renderer 中直接监听,而是在 preload层监听
renderer 层的接收:
// src/renderer/services/terminal/terminalService.ts
export class TerminalService {
/** id → 输出接收器(已挂载 xterm 的 write 方法) */
private _dataSinks = new Map<string, (data: string) => void>()
constructor(@IWorkspaceService private readonly workspaceService: IWorkspaceService) {
// 只建立一次 IPC 订阅,再按 id 分发
window.electronAPI.terminal.onData((id, data) => {
this._dataSinks.get(id)?.(data)
})
window.electronAPI.terminal.onExit(id => {
this.closeTerminal(id)
})
}
onTerminalData(id: string, cb: (data: string) => void): () => void {
this._dataSinks.set(id, cb)
return () => {
if (this._dataSinks.get(id) === cb) this._dataSinks.delete(id)
}
}
write(id: string, data: string): void {
window.electronAPI.terminal.input(id, data)
}
}
在构造函数中,注入onData监听的函数。其中逻辑是拿到id对应的终端回调,然后调用这个回调,并传入 data 作为参数。
那么this._dataSinks中的回调是哪里来的?是来自this.onTerminalData中,这个函数会在组件层被调用。
组件层的消费:
// src/renderer/components/terminal/TerminalView.tsx
export function TerminalView({id}){
const terminalService = useService(ITerminalService);
useEffect(()=>{
const term = new Terminal({
fontFamily: 'var(--font-family-mono)',
fontSize: 13,
cursorBlink: true,
// 将 xterm 主题映射到 workbench 调色板
theme: buildXtermTheme()
})
// 用户输入 → pty
const inputSub = term.onData(data => terminalService.write(id, data))
// pty 输出 → terminal
const unsubData = terminalService.onTerminalData(id, data => term.write(data))
},[])
}
- 终端窗口想要与
main进程的pty,还需要经过两层,preload以及terminalService - 在
TerminalView中,创建终端窗口new Terminal(),然后监听onData事件以及 往terminalService.onTerminalData注入监听事件 term.onData,用户在窗口输入内容,就会触发这个事件,然后将输入的字符原封不动地传给pty- 一旦终端有内容输出,就会触发
terminalService.onTerminalData,然后调用term.write,将内容写入到终端窗口中。
OK,回归主题,我们已经知道终端的内容是怎么被发送到 renderer 层了,而且这种发送是不需要一问一答的回复,所以就选择了send这种方式。对于文件变化的过程,其实也是一样的,我们简单过一下
文件变化
main 层:
// src/main/services/file-system-service.ts
import { watch, FSWatcher } from 'chokidar'
export class FileSystemService {
watchStart(){
const watcher = watch(rootPath);
const send = (type: FileChangeEvent['type']) => (filePath: string): void => {
if (mainWindow.isDestroyed()) return
const event: FileChangeEvent = { type, path: filePath }
mainWindow.webContents.send('fs:onChange', event)
}
watcher.on('change', send('change'))
}
}
通过chokidar的 watch API,监听文件的变化,然后将变化的消息通过mainWindow.webContents.send('fs:onChange')发送给 renderer 层
preload 层:
contextBridge.exposeInMainWorld('electronAPI', {
fs:{
onChange: (cb: (event: unknown) => void) => {
ipcRenderer.on('fs:onChange', (_, e) => cb(e))
return () => ipcRenderer.removeAllListeners('fs:onChange')
}
}
})
和终端一样,也是在 preload 层监听对应事件,然后接收来自 renderer 层的回调函数
renderer 层:
// 当变化直接发生在当前目录内时重新拉取
useEffect(() => {
if (!dirPath) return
const cleanup = window.electronAPI.fs.onChange((event: unknown) => {
const e = event as FileChangeEvent
const parent = e.path.substring(0, e.path.lastIndexOf('/'))
if (parent === dirPath) load()
})
return cleanup
}, [dirPath, load])
直接在 hook 中调用window.electronAPI.fs.onChange,这是不太和规范的,还是放在 service 层比较好
extensionHost 为什么要单独放到一个进程里?
插件的内容是不确定的,不可信的,不稳定的。所以为了安全考虑,不污染主进程的运行,就放在单独进程中
插件可能来自不同的作者,质量不一样,稳定性也不一样,甚至可能写得很重,很慢。如果插件直接跑在 renderer 里,一个插件卡住了,就可以能把整个界面卡住。如果插件能直接访问主界面和本地能力,也会带来安全和稳定性的问题
能力方面,这可以通过 API 的设计去规避它,主要还是安全问题
这样的设计是很关键的,可以拓展 VSCode 能力,但是不能随便侵入 VSCode
代码
main 进程启动代码:
// src/main/index.ts
import { ExtensionHost } from "./extensions/extensionHostProcess";
import { WindowManager } from "./window-manager";
aap.whenReady().then(()=>{
const windowManager = new WindowManager();
const mainWindow = windowManager.createMainWindow();
// 启动隔离的拓展宿主,并转交它的通信端口
const extensionHost = new ExtensionHost();
extensionHost.start(mainWindow);
})
启动拓展宿主的代码:
import { utilityProcess, type BrowserWindow, type UtilityProcess } from 'electron'
export class ExtensionHost {
start(mainWindow: BrowserWindow):void{
// 由 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'
})
}
}
上面可以看到,通过electron内置的 API utilityProcess启动一个进程,来执行extensionHost.js,这个文件就是调度,管理各个拓展的。管理包括安装、激活、卸载、执行对应命令等操作。所以拓展确实是放在一个单独的进程来管理运行的
main 为什么只负责建立 extensionHost 和 renderer 的通信通道,而不是一直当中间人?
先说结论:
main 进程充当中间人,技术上完全可行的,但却是没有必要的。在 extensionHost 和 renderer 之间会有大量的 RPC 调用,比如插件注册命令、执行命令、语言能力请求,、状态同步,如果每条消息都经过 main、main 就会变成一个不必要的中转站
更合理的方式是 main 只负责牵线。她创建 MessagePort,把一段交给 renderer,把另一端交给 extensionHost,之后 renderer 和 extensionHost 就可以直接通信。
这也体现了 VS Code 的架构思路:每个进程只做自己该做的事,main 管理本地能力和进程晟敏周期,renderer 管工作台,extensionHost 管插件,preload 管安全边界
代码
承接上个小节,创建进程后,就开始在 extensionHost 和 renderer 之间牵线了
import { utilityProcess, MessageChannelMain, type BrowserWindow, type UtilityProcess } from 'electron'
class ExtensionHost {
start(mainWindow: BrowserWindow):void{
// 由 electron-vite 随 main 进程一起构建(见 vite 配置入口)
const entry = path.join(__dirname, 'extensionHost.js')
// 内置扩展(只读)+ 用户安装扩展(可写)两套目录,扩展宿主都要扫描
const builtinExtensionsDir = getBuiltinExtensionsDir()
const userExtensionsDir = getUserExtensionsDir()
this.child = utilityProcess.fork(entry, [], {
stdio: 'inherit', // 将 ExtHost 的 console.* 输出到 main 终端
serviceName: 'mini-vscode-extension-host'
})
const { port1, port2 } = new MessageChannelMain()
this.child.on('spawn', () => {
if (this.disposed || !this.child) return
// 把其中一个端口和初始化载荷交给扩展宿主
this.child.postMessage({ builtinExtensionsDir, userExtensionsDir }, [port1])
// 在 renderer webContents 就绪后,把另一个端口交给 renderer
const send = (): void => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.postMessage('exthost:port', null, [port2])
}
}
if (mainWindow.webContents.isLoading()) {
mainWindow.webContents.once('did-finish-load', send)
} else {
send()
}
})
}
}
我们把重点放在this.child.on('spawn')中。这里会做两件事:
- 将
MessageChannelMain的port1,传给exntensionHost
// 把其中一个端口和初始化载荷交给扩展宿主
this.child.postMessage({ builtinExtensionsDir, userExtensionsDir }, [port1])
// 在 renderer webContents 就绪后,把另一个端口交给 renderer
- 将
MessageChannelMain的port2,传给renderer
// 在 renderer webContents 就绪后,把另一个端口交给 renderer
const send = (): void => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.postMessage('exthost:port', null, [port2])
}
}
if (mainWindow.webContents.isLoading()) {
mainWindow.webContents.once('did-finish-load', send)
} else {
send()
}
在给 renderer之前,会看 renderer 的窗口是否加载好了,所以会有个isLoading()的判断。真正传给 renderer 的是:mainWindow.webContents.postMessage('exthost:port', null, [port2])
那它们两个是怎么接收的呢?先看 extensionhost
// ── 启动:等待 main 传来的端口与初始化消息 ──
const parentPort = process.parentPort;
parentPort.once("message", (e) => {
// 接受port对象
const port = e.ports[0];
port.start();
// 创建RPC协议实例,使用MessagePort进行通信
const protocol: IMessagePassingProtocol = {
send: (m) => port.postMessage(m),
onMessage: (cb) => port.on("message", (ev) => cb(ev.data)),
};
const rpc = new RPCProtocol(protocol);
});
代码很清楚:
- 先拿到进程 port 对象,监听 message 事件,拿到 channel 的 port
- 拿到channel 的 port,调用
port.start();表示可以开始接收信息了 - 然后创建RPC协议实例,使用MessagePort进行通信。这里要使用
port.postMessage向channel发送数据,使用port.on监听Channel的数据
再看renderer:
import { extHostPortPromise } from "../../platform/extHostPort";
export class ExtensionService{
async start(){
const port = await extHostPortPromise;
port.start();
const protocol: IMessagePassingProtocol = {
send: (m) => port.postMessage(m),
onMessage: (cb) => {
port.onmessage = (ev): void => cb(ev.data);
},
};
const rpc = new RPCProtocol(protocol);
}
}
这里会 await extHostPortPromise拿到 Channel 的 port,然后通过这个 port 创建RPC协议实例,和 extensionHost 一样。
问题是,为什么这里的 port 会来自一个 promise?答案在../../platform/extHostPort
let resolvePort: (port: MessagePort) => void
export const extHostPortPromise = new Promise<MessagePort>(resolve => {
resolvePort = resolve
})
if (typeof window !== 'undefined') {
window.addEventListener('message', event => {
if (event.data === 'exthost:port' && event.ports[0]) {
resolvePort(event.ports[0])
}
})
}
在这里,会监听message事件,然后通过判断事件类型是否为exthost:port,如果是,就将参数传给 promise 的 resolve,这样上面的 await promise 就可以往下走了。
但是,要知道 MessagePort 不能作为普通 contextBridge API 值直接暴露到 renderer 主世界;它需要通过支持 transfer 列表的 postMessage 转移。所以在 preload 中,有这么一段代码作为中间桥梁:
ipcRenderer.on('exthost:port', e => {
window.postMessage('exthost:port', '*', e.ports)
})
OK,上面完整地呈现了 main 进程是如何建立extensionHost 和 renderer 的通信通道。但你知道,文中提到的 RPC 协议是怎么回事吗?我觉得这个非常有意思,在之后的文章里和你分享:)
总结
理解这四个进程之后,再看 VS Code 的架构就会清楚很多:main 负责系统能力,preload 负责安全桥接,renderer 负责工作台和用户交互,extensionHost 负责隔离运行扩展代码。
这套设计的重点不是“把代码拆成很多份”,而是把权限、性能、稳定性和扩展能力分开管理。renderer 可以专注组织编辑器体验,main 统一接触本地系统,扩展代码即使出问题也不会直接污染工作台。后面继续看命令系统、DI 服务、扩展激活和语言能力时,其实都是在这套进程分工之上继续展开。