欢迎光临
我们一直在努力

Vite双向RPC实战:基于WebSocket的DevServer与浏览器通信机制全解析

一、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 连接,客户端复用这条连接,不需要新建。好处是:

  • 无额外连接:不新建 WebSocket,省一个连接的资源。
  • 生命周期绑定:HMR 连接的生命周期由 Vite 管理,dev server 重启时自动重连,RPC 也跟着恢复。
  • 统一通道:所有 Vite 相关的通信走同一通道,简化网络管理。
  • 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 是因为:

  • 类型安全:RPC 有类型签名,HTTP 要手动解析。
  • 统一通道:所有 DevTools 操作走同一 WebSocket,不需要为每个操作建 HTTP 路由。
  • 权限控制:所有操作都经过 RPC 函数,可以在函数里加权限检查或路径校验。
  • 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 可能耗时几十秒。它的设计是:

  • 客户端调用 installPackage:RPC 请求到达服务端。
  • 服务端启动子进程:execNpmScript 用 child_process 执行 npm install。
  • 子进程输出通过 callback 推送:每有输出(type: ‘data’),调用 rpc.onTerminalData({ data })——这是服务端反向调用客户端的函数,把终端输出推给 UI。
  • 进程结束推送:退出时(type: ‘exit’)调用 rpc.onTerminalExit。
  • 这就是双向 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 的反向调用,还有文件监听的反向调用:

  • 服务端用 chokidar 监听文件变化:server.watcher 是 Vite 的文件监听器。
  • 任一文件变化触发回调:on('all', …) 监听所有事件(add/change/unlink)。
  • 广播给所有客户端:rpc.onFileWatch({ event, path }) 是 broadcast 调用——所有连接的 tab 都收到文件变化通知。
  • 客户端收到 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 的优势:

  • 类型安全:调用有类型签名,参数和返回值都类型检查。
  • Promise 自动包装:调用返回 Promise,await 即得结果。RPC 框架自动处理请求/响应配对。
  • 代码简洁:不需要手写消息路由、id 配对、序列化。
  • 代价是引入 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 抽象:

  • 服务端:createRPCServer 把 Vite WebSocketServer 的每个 client 包装成 Channel,用 group 管理多客户端。返回 broadcast 方法供推送使用。
  • 客户端:createRPCClient 复用 Vite HMR 的 WebSocket 通道,避免新建连接。
  • 双向调用:客户端调用服务端的查询/操作函数,服务端反向调用客户端的推送函数(onTerminalData/onFileWatch)。长操作用"发起 + 反向推送进度"的模式。
  • 隔离机制:name:rpc 事件名做命名空间隔离,timeout: -1 支持长操作,source 过滤确保多 client 消息不串台。
  • 核心设计是 Channel 抽象——RPC 不依赖具体传输层,任何能收发消息的通道都能用。这让同一套 RPC 代码可以适配 WebSocket、HMR、postMessage 等多种传输,是通信层的可复用基石。

    赞(0)
    未经允许不得转载:171主机测评 » Vite双向RPC实战:基于WebSocket的DevServer与浏览器通信机制全解析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址