一、DevTools 的通信挑战
Vite 插件在 dev server 端运行于 Node.js,而 DevTools 的 UI 运行在浏览器。两端需要频繁通信——客户端要查询组件树、读静态资源、安装依赖包;服务端要推送文件变化、终端输出、状态更新。
最朴素的方案是 HTTP 轮询——客户端定时发请求拉取数据。但这对 DevTools 是灾难:组件树几秒一变、文件事件高频触发、终端输出需要实时流式,轮询既延迟又浪费带宽。
更优的方案是 WebSocket 双向通信——服务端能主动推送,客户端能随时调用。但裸 WebSocket 只提供原始消息收发,每条消息的语义要自己定义。如果手写"消息类型 + 参数解析 + 路由分发",代码会迅速膨胀。
本文拆解一个基于双向 RPC 库的通信方案——它把 WebSocket 通道包装成可像调用本地函数一样调用的 RPC 接口,让两端通信代码极简而类型安全。
二、birpc:双向 RPC 的核心抽象
2.1 RPC 的基本思想
RPC(Remote Procedure Call)的核心思想是"远程函数调用的本地化"——让代码调用远程服务器的函数像调用本地函数一样。调用方传参数,RPC 框架负责把参数序列化、发送、在远端调用、把返回值传回。
双向 RPC 是双方都既可以是调用方也可以是被调方。这在 DevTools 场景下很自然——客户端调用服务端的 getComponentGraph,服务端也可以调用客户端的 onFileWatch 推送文件变化。
2.2 birpc 的设计
import { cachedMap, createBirpc, createBirpcGroup } from 'birpc'
import type { BirpcGroupReturn, ChannelOptions } from 'birpc'
birpc 是一个轻量双向 RPC 库。它的核心抽象是:
- Channel:通信通道,只需提供 on(fn) 接收消息和 post(data) 发送消息两个方法。
- functions:本地要暴露给远端调用的函数集合。
- birpc 实例:包装 Channel,对外暴露远端函数的本地代理。
这种设计让 birpc 与具体的传输层解耦——Channel 可以是 WebSocket、HMR、postMessage、MessagePort,任何能收发消息的通道都行。
三、服务端:createRPCServer
export function createRPCServer<ClientFunction = {}, ServerFunctions = {}>(
name: string,
ws: WebSocketServer,
functions: ServerFunctions,
) {
const event = `${name}:rpc`
const group = createBirpcGroup<ClientFunction, ServerFunctions>(
functions,
() => cachedMap(
Array.from(ws?.clients || []),
(socket): ChannelOptions => {
return {
on: (fn) => {
ws.on(event, (data: any, source: WebSocketClient) => {
if (socket === source)
fn(data)
})
},
post: (data) => {
socket.send(event, data)
},
}
},
),
{
timeout: –1,
},
)
ws.on('connection', () => {
group.updateChannels()
})
return group.broadcast
}
3.1 事件名的命名空间
const event = `${name}:rpc`
每个 RPC 实例用 name:rpc 作为 WebSocket 事件名。如 name 是 'devtools',事件就是 'devtools:rpc'。这让多个 RPC 实例能复用同一个 WebSocket 连接而不互相干扰——每个实例只收发自己事件名的消息。
这是命名空间隔离——多个 RPC 服务(如 devtools、inspect、hmr)共用一个 WebSocket,靠事件名区分。
3.2 多客户端的 group 模式
const group = createBirpcGroup<ClientFunction, ServerFunctions>(
functions,
() => cachedMap(
Array.from(ws?.clients || []),
(socket): ChannelOptions => { /* … */ },
),
{ timeout: –1 },
)
createBirpcGroup 创建一个 RPC group——它管理多个客户端连接,每个客户端是一个独立的 Channel。
第二个参数是"获取 channels 的函数"——它返回一个 Channel 数组。这里用 cachedMap 把 ws.clients(WebSocket 客户端集合)转换成 Channel 数组。每个 WebSocket client 被包装成一个 ChannelOptions:
(socket): ChannelOptions => {
return {
on: (fn) => {
ws.on(event, (data, source) => {
if (socket === source)
fn(data)
})
},
post: (data) => {
socket.send(event, data)
},
}
}
这是 birpc 与 Vite WebSocket 的适配层:
- on(fn):监听 WebSocket 的 event 事件。Vite 的 WebSocketServer 的 on 回调签名是 (data, source)——source 是发送消息的 client。通过 if (socket === source) 过滤,确保每个 Channel 只收到它对应的 client 发的消息。
- post(data):调用 socket.send(event, data) 向这个 client 发送消息。
3.3 source 过滤的必要性
ws.on(event, (data, source) => {
if (socket === source)
fn(data)
})
为什么要过滤 source?因为 Vite 的 WebSocketServer 是广播式的——所有 client 都连到同一个 server。当 client A 发消息,ws.on(event) 会被触发一次(不是每个 client 触发一次)。如果不过滤,每个 Channel 的 fn 都会被调用,导致消息被重复处理。
if (socket === source) 让只有与发送者匹配的 Channel 处理这条消息——每个 Channel 只处理它对应 client 的请求。
3.4 connection 时的动态更新
ws.on('connection', () => {
group.updateChannels()
})
新的浏览器 tab 连上 dev server 时,ws 触发 connection 事件。这时 ws.clients 集合变了(多了一个 client),需要调用 group.updateChannels() 重建 channel 列表。
这是动态连接管理——客户端可以随时连上/断开,RPC group 需要感知这种变化并更新内部 channel 列表。如果不更新,新连上的 client 无法被 RPC 调用。
3.5 返回 broadcast
return group.broadcast
返回的是 group.broadcast——一个能向所有 client 广播消息的方法。服务端用这个方法向所有连接的客户端推送事件(如文件变化、终端输出)。
注意返回的不是整个 group,而是 broadcast。这限制了调用方的权限——只能广播,不能针对特定 client 发送。这是设计选择:DevTools 的推送都是广播式的(所有 tab 都应收到文件变化),不需要点对点。
3.6 timeout: -1 的无限等待
{ timeout: –1 }
RPC 调用默认有超时机制——调用远端函数后,如果指定时间内没收到响应,抛错。timeout: -1 表示无限等待。
这对 DevTools 是必要的——某些操作(如 installPackage)可能耗时很久(npm install 几十秒到几分钟),如果默认超时(如 5 秒),调用会失败。无限等待让长操作能正常完成。
代价是:如果远端函数崩溃不响应,调用方永远卡住。但 DevTools 场景下,服务端是自己控制的,崩溃会通过其他途径(进程退出)暴露。
四、客户端:createRPCClient
export function createRPCClient<ServerFunctions = {}, ClientFunctions = {}>(
name: string,
hot: ViteHotContext | Promise<ViteHotContext>,
functions: ClientFunctions = {} as ClientFunctions,
) {
const event = `${name}:rpc`
return createBirpc<ServerFunctions, ClientFunctions>(
functions,
{
on: async (fn) => {
(await hot).on(event, fn)
},
post: async (data) => {
(await hot).send(event, data)
},
timeout: –1,
},
)
}
4.1 复用 Vite HMR 通道
客户端的 Channel 不是新建 WebSocket,而是复用 Vite 的 HMR context(hot):
- on(fn):hot.on(event, fn) 监听 HMR 通道的事件。
- post(data):hot.send(event, data) 通过 HMR 通道发送事件。
这是关键优化——Vite 已经为 HMR 建立了浏览器到 dev server 的 WebSocket 连接,客户端复用这条连接,不需要新建。好处是:
4.2 hot 的 Promise 处理
hot: ViteHotContext | Promise<ViteHotContext>
// …
on: async (fn) => {
(await hot).on(event, fn)
}
hot 可能是 Promise——在某些场景下 HMR context 是异步初始化的。await hot 等待它就绪后再注册监听。
这要求 on/post 是 async 函数——birpc 支持 async 的 Channel 方法。这是 birpc 对异步通道的适配。
4.3 functions 的类型推导
export function createRPCClient<ServerFunctions = {}, ClientFunctions = {}>(
name: string,
hot: …,
functions: ClientFunctions = {} as ClientFunctions,
)
两个泛型参数:
- ServerFunctions:服务端暴露的函数类型。客户端通过 RPC 调用这些函数。
- ClientFunctions:客户端暴露给服务端的函数类型。服务端通过 broadcast 调用这些函数。
functions 是客户端要暴露的函数集合,默认空对象。birpc 返回的实例既包含对 ServerFunctions 的代理(调用会发 RPC 到服务端),也接受服务端对 ClientFunctions 的调用。
这种双向类型让两端都有类型安全——调用方有类型提示,被调方有参数类型检查。
五、服务端 RPC 函数清单
const rpc = createRPCServer<RPCFunctions>(server.ws, {
root: () => config.root,
inspectClientUrl: () => `${config.base || '/'}__inspect/`,
componentGraph: () => getComponentsRelationships(inspect.api.rpc),
staticAssets: () => getStaticAssets(config),
getImageMeta,
getTextAssetContent,
deleteStaticAsset,
renameStaticAsset,
getPackages: () => getPackages(config.root),
getVueSFCList: () => getVueSFCList(config.root),
getComponentInfo: (filename: string) => getComponentInfo(config.root, filename),
installPackage: (packages: string[], options: ExecNpmScriptOptions = {}) => execNpmScript(packages, {
…options,
type: 'install',
cwd: config.root,
callback: (type: string, data: string) => {
if (type === 'data')
rpc.onTerminalData({ data })
else if (type === 'exit')
rpc.onTerminalExit({ data })
},
}),
uninstallPackage: (packages: string[], options: ExecNpmScriptOptions = {}) => execNpmScript(packages, {
…options,
type: 'uninstall',
cwd: config.root,
callback: (type: string, data: string) => {
if (type === 'data')
rpc.onTerminalData({ data })
else if (type === 'exit')
rpc.onTerminalExit({ data })
},
}),
})
5.1 查询类函数(同步返回)
root: () => config.root,
inspectClientUrl: () => `${config.base || '/'}__inspect/`,
componentGraph: () => getComponentsRelationships(inspect.api.rpc),
staticAssets: () => getStaticAssets(config),
getPackages: () => getPackages(config.root),
getVueSFCList: () => getVueSFCList(config.root),
getComponentInfo: (filename: string) => getComponentInfo(config.root, filename),
这类函数是"客户端请求 → 服务端立即返回数据"的模式。如 getVueSFCList 让客户端拿到项目里所有 .vue 文件列表,getComponentInfo 查询单个组件的元信息。
注意 componentGraph 委托给 inspect 插件的 RPC——这是一个 RPC 调用另一个 RPC 的例子。本插件没有自己实现模块图分析,而是复用 inspect 插件已建好的 RPC 通道,拿到它的模块图数据。这是插件间协作的优雅方式。
5.2 资源操作类函数
getImageMeta,
getTextAssetContent,
deleteStaticAsset,
renameStaticAsset,
这些函数操作文件系统——读图片元信息、读文本资源内容、删除/重命名静态资源。它们让 DevTools UI 能直接管理项目的静态资源。
这些操作走 RPC 而非 HTTP 是因为:
5.3 长操作 + 回调推送
installPackage: (packages, options = {}) => execNpmScript(packages, {
…options,
type: 'install',
cwd: config.root,
callback: (type, data) => {
if (type === 'data')
rpc.onTerminalData({ data })
else if (type === 'exit')
rpc.onTerminalExit({ data })
},
}),
installPackage 是长操作的典型——npm install 可能耗时几十秒。它的设计是:
这就是双向 RPC 的价值——客户端调用服务端(installPackage)启动操作,服务端反向调用客户端(onTerminalData/onTerminalExit)推送进度。一个操作的两向通信,同一 RPC 通道完成。
5.4 RPCFunctions 的类型定义
createRPCServer<RPCFunctions> 的泛型 RPCFunctions 是一个合并类型——它包含服务端暴露的函数(installPackage 等)和客户端暴露的函数(onTerminalData/onTerminalExit)。
这种合并类型让 TypeScript 知道两端都能调用什么——服务端能调客户端的 onTerminalData,客户端能调服务端的 installPackage。类型安全覆盖双向通信。
六、文件监听 + 推送
server.watcher.on('all', (event, path) => {
rpc.onFileWatch({ event, path })
})
除了 installPackage 的反向调用,还有文件监听的反向调用:
客户端收到 onFileWatch 后,可以决定是否刷新组件树、重载静态资源列表等。这是"服务端感知变化 → 推送 → 客户端响应"的实时同步链路。
七、设计权衡
7.1 RPC vs 裸 WebSocket 消息
裸 WebSocket 需要手动定义消息格式:
// 裸 WebSocket
ws.send(JSON.stringify({ type: 'getComponentGraph', id: 123, args: [] }))
ws.on('message', (msg) => {
const data = JSON.parse(msg)
if (data.type === 'getComponentGraph' && data.id === 123) {
// 处理响应
}
})
RPC 封装后:
// RPC
const graph = await rpc.componentGraph()
RPC 的优势:
代价是引入 birpc 依赖。但 birpc 是轻量库,体积小、无运行时开销,是合理取舍。
7.2 broadcast vs 点对点
服务端返回 group.broadcast,只能广播,不能点对点。这限制了某些场景——如果需要给特定 tab 发消息(如只通知触发操作的 tab),做不到。
但 DevTools 场景下,广播是合理的:
- 文件变化所有 tab 都应感知。
- 终端输出虽然只一个 tab 触发 install,但其他 tab 也在开发同一项目,看到 install 进度无害。
- 简化了 API——不需要管理 client id 和路由。
如果未来需要点对点,可以扩展返回值为整个 group,让调用方自行选择 broadcast 或针对特定 channel 调用。
7.3 复用 HMR 通道 vs 独立 WebSocket
客户端复用 Vite HMR 的 WebSocket,而非新建独立连接。好处是省一个连接、生命周期绑定 Vite。坏处是 RPC 与 HMR 共享通道,如果 HMR 消息频率很高,可能影响 RPC 响应。
实际工程中 HMR 消息频率不高(只在文件变化时),且 WebSocket 是全双工,消息不会互相阻塞。复用是合理选择。
但要注意一个潜在问题:如果 HMR 连接断开重连,RPC 的状态(如未完成的 installPackage)会丢失。这是共享通道的固有风险,需要调用方处理重连后的状态恢复。
7.4 timeout: -1 的取舍
无限超时让长操作(installPackage)能正常完成,但增加了卡住的风险。替代方案是:
- 分阶段反馈:installPackage 立即返回一个操作 id,后续用 onTerminalData 推送进度,完成时用 onTerminalExit 推送结果。调用方不需要 await installPackage 的返回值。
- 长超时:设置 5 分钟超时,覆盖绝大多数 npm install 场景。
实际代码用 timeout: -1 是最简方案——调用方可以 await,也可以不 await(只关心 onTerminalData 推送)。简单是首要考量。
八、可复用模式
模式一:Channel 抽象 + 传输层解耦
interface ChannelOptions {
on: (fn: (data: any) => void) => void
post: (data: any) => void
}
RPC 不直接依赖 WebSocket,而是依赖 Channel 抽象(只需 on/post)。任何能收发消息的通道(WebSocket、postMessage、MessagePort、HMR context)都能作为 Channel。这让 RPC 可以在不同环境(浏览器-Node、Worker-主线程、iframe-父窗口)复用。
模式二:多客户端 group + 动态更新
const group = createBirpcGroup(functions, () => getClientsAsChannels(), options)
ws.on('connection', () => group.updateChannels())
group 管理多个客户端 channel,连接变化时 updateChannels() 重建列表。适用于"一个服务端 + 多个客户端"的场景(如多 tab 连同一 dev server)。
模式三:双向 RPC 的反向调用
// 客户端调用服务端
const graph = await rpc.componentGraph()
// 服务端反向调用客户端(broadcast)
rpc.onTerminalData({ data })
双向 RPC 让同一通道支持两向调用——客户端调服务端是"请求-响应",服务端调客户端是"推送"。长操作(如 installPackage)用"客户端发起 + 服务端反向推送进度"的模式,避免长 await。
模式四:命名空间事件隔离
const event = `${name}:rpc`
ws.on(event, handler)
ws.send(event, data)
多个 RPC 实例复用同一 WebSocket,靠 name:rpc 事件名区分。这是命名空间隔离——让多个服务共享传输层而不互相干扰。
九、小结
DevTools 的双向通信基于 birpc 的 RPC 抽象:
核心设计是 Channel 抽象——RPC 不依赖具体传输层,任何能收发消息的通道都能用。这让同一套 RPC 代码可以适配 WebSocket、HMR、postMessage 等多种传输,是通信层的可复用基石。



