Vite动态代理切换实战:告别反复重启的联调利器 后端同事在群里喊一句“切到测试环境看看”前端就得把流程在心里过一遍打开 vite.config.ts找到 server.proxy把 target 换掉重启 dev server等编译完成再刷新页面。运气不好登录态丢了又得重新走一遍验证流程。一天切个七八次人真的会麻。后来我实在受不了直接写了一个能在 Vite 运行时切换代理目标的小插件。开发服务器不用重启当前页面状态不丢命令敲一下或者面板点一下请求就实时改道。这篇文章就把这套方案的完整思路和落地代码分享出来内容包括底层原理、核心中间件实现、三种切换入口以及切换代理后各种容易踩的坑。适合被多环境联调折腾过的人、需要在 mock 和真实后端之间来回切的人以及做微前端多团队协作的开发者。1. 联调中反复“改配置-重启-刷新”的窘境1.1 最普通的写法有多痛几乎所有 Vite 项目里代理配置都是长这样的// vite.config.ts export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, })这个写法本身没问题问题出在“环境切换”这件事上。今天联调开发环境后端说 bug 只在测试环境复现你就得把 target 改成http://test-backend:8080保存等 Vite 检测到配置文件变化、自动重启浏览器再来一次整页刷新。听起来只是几十秒的事但这个操作一旦高频起来痛点会被放大重启后的首次编译要重新走一遍依赖预构建慢的项目五十秒都见过。页面刷新后SPA 路由、表单填写、调试断点全部丢失。如果切换前后登录态不一致还得重新登录、重新导航时间成本翻倍。同时开了多个环境验证时配置文件反复横跳改着改着自己都忘了当前到底指向谁。手动改配置这个操作本质上是把“开发服务器的内部状态”和“源代码文件”耦合在了一起。我们真正想要的是让 target 成为一个可随时修改的变量而不是一次写入就必须重启的常量。1.2 半自动方案环境变量加重启有人会说可以用环境变量来做不需要手动改文件。比如server: { proxy: { /api: { target: process.env.API_TARGET ?? http://localhost:8080, }, }, },启动时指定API_TARGEThttp://test-backend:8080 npm run dev这个方案确实比手改配置好一点适合“一次性启动、全程不换”。但如果开发过程中想从测试环境切回本地 mock你还是得 CtrlC、重新敲命令、等重启。它解决的是“不同启动会话之间切换”的问题没有解决“同一个开发会话内实时切换”的问题。1.3 我们真正需要的“运行时切换”所谓运行时切换至少要满足三点dev server 持续运行不重启、不重新编译。切换动作立刻生效下一次请求就走到新目标。页面不需要刷新正在调试的组件状态和断点都保留。想要做到这三点就得往 Vite 的中间件机制里钻。Vite 的开发者服务器本身是一个 Node HTTP 服务所有请求都经过一段中间件栈。配置文件里的 proxy 只是在这个栈里挂了一个固定目标的转发器。我们要做的就是在它旁边再挂一个能动态决定目标地址的转发器。2. 为什么 Vite 的 proxy 配置做不到运行时切换2.1 中间件栈请求是怎么被处理的先看 Vite dev server 处理请求的基本模型。一个请求进来会按顺序经过一串中间件每个中间件可以选择直接响应请求也可以调用next()把它交给下一个。Vite 内置的中间件大致包括源码编译、静态资源、HMR、history 回退、以及我们在server.proxy里配置的代理。这个模型很像流水线。产线上某个工位决定“这个订单我来处理”就不继续往下传决定不处理就放行给下个工位。Vite 的代理本质上是流水线上的一个工位它只认自己配置好的规则。2.2 server.proxy 被编译成固定实例当我们写下server.proxy配置Vite 在启动阶段会做这些事情遍历配置里的每个 key比如/api。对每个 key 创建一个http-proxy的代理实例。把实例封装成一个中间件挂到中间件栈里。关键在于第 2 步。http-proxy实例创建的时候target是作为核心参数传入的。后续虽然也能在某些请求里覆盖 target但 Vite 并没有把“动态覆盖 target”的能力暴露给普通配置。所以我们改配置文件里的 target本质上是在改一份启动参数。而启动参数当然只能重启后生效。2.3 为什么改配置会触发整站重启Vite 对配置文件有监听机制。vite.config.ts一旦发生变化它认为整个开发服务器的配置都可能变了于是执行server.restart()。这不是我们手动重启但效果一样服务重启、客户端刷新。所以问题的根子不在“Vite 不让你热更新配置”而在“proxy 被设计成启动期固定的转发规则”。想实现运行时切换就必须绕开启动期编译自己在中间件这一层手动接管转发逻辑。3. 手写动态转发中间件让 target 变成变量3.1 插件骨架在 configureServer 里插入中间件Vite 插件系统提供了configureServer钩子它会在 dev server 创建完成后、内部中间件注册前后被调用。我们在这个钩子里用server.middlewares.use()注册一段自己的逻辑即可。一个最小可用的动态代理插件骨架是这样的// vite-plugin-dynamic-proxy.ts import { createProxyServer } from http-proxy import type { Plugin, ViteDevServer } from vite export interface DynamicProxyOptions { prefix?: string target?: string envs?: Recordstring, string pathTargets?: Recordstring, string rewrite?: Recordstring, string } export function dynamicProxy(options: DynamicProxyOptions {}): Plugin { const prefix options.prefix ?? /api let currentTarget options.target ?? Object.values(options.envs ?? {})[0] ?? const proxy createProxyServer({ changeOrigin: true, secure: false, xfwd: true, }) return { name: dynamic-proxy, configureServer(server: ViteDevServer) { server.middlewares.use((req, res, next) { const url req.url ?? / // 只拦截约定的前缀比如 /api if (!url.startsWith(prefix)) { next() return } proxy.web(req, res, { target: currentTarget }, (err) { if (err) next(err) }) }) }, } }这段代码干了一件很朴素的事所有/api开头的请求都被我们自己的中间件接管转发到currentTarget。currentTarget是闭包里的普通变量修改它下一次请求自然就改了目标。这就是“运行时切换”的全部秘密。3.2 动态 target 解析与路径级路由实际开发中只支持一个全局 target 往往不够。微前端场景下不同子应用可能部署在不同的后端联调时甚至需要/api/user走开发机 A/api/order走开发机 B。所以我在中间件里加了一层路径级路由先看有没有针对某个路径前缀的特殊目标有就优先生效没有就落到当前默认目标。匹配时取前缀最长的那条规则避免细粒度规则被粗粒度规则覆盖。function resolveTarget(url: string): string { const pathTargets options.pathTargets ?? {} let matchedPrefix: string | undefined let matchedTarget: string | undefined for (const [prefixPath, target] of Object.entries(pathTargets)) { if (url.startsWith(prefixPath)) { if (matchedPrefix undefined || prefixPath.length matchedPrefix.length) { matchedPrefix prefixPath matchedTarget target } } } return matchedTarget ?? currentTarget }这个实现还有一个附带好处它天然支持“环境路由”的需求。比如某条接口只有预发环境才有而其他接口可以继续走测试环境那就单独给那条接口配置一个路径规则不需要切换全局环境。3.3 转发细节path rewrite 与错误处理很多后端接口路径和前端代理前缀不一致。Vite 自带rewrite能力我们自己写中间件也得补上。我提供了一层简单的 rewrite 配置用正则替换请求路径function applyRewrite(url: string): string { for (const [pattern, replacement] of Object.entries(options.rewrite ?? {})) { url url.replace(new RegExp(pattern), replacement) } return url }在转发前执行const originalUrl req.url ?? / req.url applyRewrite(originalUrl) proxy.web(req, res, { target: resolveTarget(originalUrl) }, (err) { ... })注意这里resolveTarget使用的是原始 URL而不是 rewrite 之后的 URL。因为路径规则通常是按前端视角配置的前后端路径不一致时先定位目标再改写路径语义更清晰。错误处理也不能省。后端服务挂了、网络不通、证书不信任都会让proxy.web回调触发错误。我默认返回 502并把错误信息透传出去方便排查proxy.on(error, (err, _req, res) { if (res writeHead in res) { ;(res as ServerResponse).writeHead(502, { Content-Type: text/plain; charsetutf-8 }) res.end(代理转发失败: ${err.message}) } })3.4 在 vite.config.ts 中启用插件写好后接入方式很简单// vite.config.ts import { dynamicProxy } from ./vite-plugin-dynamic-proxy export default defineConfig({ plugins: [ dynamicProxy({ target: http://localhost:8080, envs: { mock: http://localhost:3000, dev: http://dev-backend:8080, test: http://test-backend:8080, prod: http://preview-backend:8080, }, pathTargets: { /api/payment: http://payment-dev:8080, /api/report: http://report-dev:8080, }, rewrite: { ^/api: , }, }), ], })因为插件已经接管了所有带前缀的请求server.proxy就不需要在配置里重复声明了。如果两者同时配置先匹配到的中间件会先响应配置一旦混乱容易出诡异问题。我的建议是用动态插件时把server.proxy留空职责单一。4. 三种切换入口快捷键、HTTP 接口、可视化面板4.1 命令行快捷键在configureServer钩子里接管process.stdin按行解析命令。我最常用的约定是输入环境名直接切换输入target:http://xxx切换任意地址输入ls列出所有环境。import readline from node:readline function startCli() { const rl readline.createInterface({ input: process.stdin, terminal: false, }) rl.on(line, (line) { const trimmed line.trim() if (!trimmed) return if (trimmed ls) { console.log([proxy] 当前目标:, currentTarget) console.log([proxy] 可切换环境:, Object.entries(options.envs ?? {})) return } if (trimmed.startsWith(target:)) { const target trimmed.slice(7).trim() if (target) { currentTarget target logSwitch(target) } return } const target envMap.get(trimmed) if (target) { currentTarget target logSwitch(target) } else { console.log([proxy] 未知环境可用命令为 env/ls/target:url) } }) }envs配置里的 key 是“环境名”value 是“目标地址”。启动时把两者放进映射表命令行里直接敲环境名比如输入test就切到http://test-backend:8080。这个入口适合前端本人用但不太适合丢给团队里不怎么碰命令行的同事。好在我们有第二个入口。4.2 HTTP 切换接口在同一个中间件里加一组“特殊路径”处理。我命名为/__proxy/switch路径里带着下划线前缀明显区别于业务接口也不容易和别人冲突。server.middlewares.use((req, res, next) { const url req.url ?? / if (url.startsWith(/__proxy/switch)) { const u new URL(url, http://localhost) const env u.searchParams.get(env) const target u.searchParams.get(target) if (env envMap.get(env)) { currentTarget envMap.get(env)! logSwitch(env${env} (${currentTarget})) res.setHeader(Content-Type, application/json) res.end(JSON.stringify({ ok: true, current: currentTarget })) return } if (target) { currentTarget target logSwitch(target${target}) res.setHeader(Content-Type, application/json) res.end(JSON.stringify({ ok: true, current: currentTarget })) return } res.statusCode 400 res.end(JSON.stringify({ ok: false, message: 缺少 env 或 target 参数 })) return } // 其余逻辑... })有了 HTTP 接口一切能发起请求的东西都能成为切换工具。写自动化脚本时在跑测试用例前先请求一下切换接口给 C 端演示时用一个网页按钮就能把整个前端的数据源从 mock 切到真实服务甚至可以用 Postman、curl或者手机浏览器直接调。4.3 可视化面板光有接口还不够直观。我给团队做了一个极简面板挂在/__proxy/panel路径下。面板返回一段纯 HTML内联 CSS 和 JS不依赖任何前端框架server.middlewares.use(/__proxy/panel, (_req, res) { const envs options.envs ?? {} const envButtons Object.entries(envs) .map(([name, target]) { return button onclickswitchEnv(${name})${name}br/small${target}/small/button }) .join() res.setHeader(Content-Type, text/html; charsetutf-8) res.end( !doctype html html head meta charsetutf-8 / title代理切换面板/title style body { font-family: system-ui, sans-serif; padding: 24px; background: #fafafa; } .btn { display: inline-block; margin: 6px; padding: 12px 16px; border: 1px solid #ddd; border-radius: 8px; cursor: pointer; background: #fff; } .btn:hover { background: #f0f0f0; } .current { font-weight: 700; margin-bottom: 16px; } input { padding: 8px 12px; width: 400px; } /style /head body div classcurrent idcurrent正在加载.../div div idenvs${envButtons}/div hr / input idcustomTarget placeholder自定义目标如 http://192.168.1.100:8080 / button classbtn onclickswitchTarget()切换/button script async function refreshState() { const res await fetch(/__proxy/state) const data await res.json() document.getElementById(current).textContent 当前目标: data.current } async function switchEnv(env) { await fetch(/__proxy/switch?env env) refreshState() } async function switchTarget() { const val document.getElementById(customTarget).value if (!val) return await fetch(/__proxy/switch?target encodeURIComponent(val)) refreshState() } refreshState() /script /body /html ) })面板里还需要一个/__proxy/state接口用来返回当前目标免得刷新页面后不知道此刻指向哪。这个面板对整个局域网开放后端同事、测试同事也能自己点按钮切环境不用再来打扰前端。4.4 可选进阶监听配置文件命令行和面板还需要人主动操作。再往前走一步可以让切换动作“配置驱动”比如监听一个proxy.config.json文件一改动就自动刷新 target 映射。这样后端同事只要编辑文件前端 dev server 自己就会跟着变。我实现时用的是 Node 的fs.watch注意两点一是监听文件变化有时会触发两次需要加防抖二是不要监听整个目录只监听那一个 JSON 文件减少噪音。import fs from node:fs import path from node:path if (options.configFile) { const filePath path.resolve(process.cwd(), options.configFile) if (fs.existsSync(filePath)) { let timer: NodeJS.Timeout | undefined fs.watch(filePath, () { clearTimeout(timer) timer setTimeout(() { try { const cfg JSON.parse(fs.readFileSync(filePath, utf-8)) if (cfg.envs) { envMap.clear() Object.entries(cfg.envs).forEach(([k, v]) envMap.set(k, v as string)) } if (cfg.current envMap.get(cfg.current)) { currentTarget envMap.get(cfg.current)! logSwitch(从配置文件切换到 ${cfg.current}) } } catch (e) { console.error([proxy] 配置文件解析失败, e) } }, 200) }) } }要注意不要让配置文件中出现不可信的值直接拼进日志。如果一定要记录做一次白名单校验或字符串化处理避免控制台被奇怪的输入干扰。这是安全洁癖但写工具时养成习惯没坏处。5. 切换代理之后踩到的一串坑5.1 changeOrigin 和 Host 白名单校验第一次把动态插件接到后端时我遇到一个诡异现象请求能转发过去但后端返回 403。排查后发现后端做了 Host 白名单校验只认它配置过的域名。而我们的请求转发出去时Host 头还停留在localhost:5173后端一看不是自己的域名直接拒绝。解决方式就是changeOrigin: true。它会让http-proxy把请求头里的 Host 改写为 target 的主机名。大多数写在server.proxy配置里的changeOrigin都是这个作用。如果你的后端也有类似白名单机制务必把这项打开。5.2 后端 Cookie 的 Domain 陷阱联调最常遇到的就是登录态丢失。某种现象是登录接口明明成功了Set-Cookie 也返回了但浏览器就是存不下来。我排查过一次发现后端返回的 Cookie 头长这样Set-Cookie: sessionabc123; Domaintest-backend.com; Path/浏览器在同源策略下发现 Cookie 的 Domain 不属于当前页面域名于是直接丢弃。但开发时我们访问的是localhost:5173域对不上。解决办法是给http-proxy加一层cookieDomainRewrite把响应里的 Domain 重写为本地地址const proxy createProxyServer({ changeOrigin: true, cookieDomainRewrite: , })把 Domain 改写为空字符串会让 Cookie 变成 host-only cookie只属于当前请求的主机这样浏览器就能正常保存和回传了。5.3 自签名 HTTPS 后端证书公司内网的后端服务很多直接上了 HTTPS 但证书是自签名的。Node 默认的证书信任列表里没有它转发就会报self signed certificate错误。开发环境下我会直接设置secure: false跳过证书校验。这个配置只影响本地开发服务器的转发行为不涉及生产环境我觉得在可控的内网环境下问题不大。如果你所在团队的安全要求比较严也可以改成把内网证书导入 Node 的 SSL 证书仓库不过成本会高一些。5.4 WebSocket 和 SSE 不会自动“跟过来”HTTP 请求切了 target 是立刻生效的但 WebSocket 连接不一样。如果业务里用了 WebSocket而且是在页面加载时建立的那么切换代理后旧连接依然停留在旧后端不会自动断开重连。要支持 WebSocket 转发得在configureServer里额外监听upgrade事件server.httpServer?.on(upgrade, (req, socket, head) { const url req.url ?? / if (url.startsWith(/ws)) { proxy.ws(req, socket, head, { target: currentTarget }) } })同样的问题也存在于 SSEServer-Sent Events和长轮询。SSE 本质是 HTTP 连接已经建立的连接不会因为 target 变了而切换必须刷新页面或者让前端代码在收到“环境已切换”的通知后主动重连。我在面板切换成功后会额外打印一句提示“如使用 SSE/WebSocket请刷新页面重连”。这比事后排查省心得多。5.5 局域网里被同事乱切的隐患给 dev server 配上--host 0.0.0.0后手机和同事都能访问你的本地服务但/__proxy/switch接口也一起暴露了。只要有人猜到这个路径就能把你的代理切到任意地址你还在纳闷“怎么接口全变了”排查半天发现是对面工位的人手滑点了一下。稳妥的做法是在切换接口上加身份校验。我用的方案是配置一个 token切换时必须带X-Proxy-Token请求头不知道 token 的人直接返回 403。另外面板页面只需要展示功能不用开放给所有人。如果只是自己用可以限定只允许本机的 IP 访问切换接口。5.6 终端条件差的兜底方式在部分 IDE 内嵌终端或 CI 环境里process.stdin可能不是可读的readline会报错。启动插件时我用 try/catch 包住了整个 CLI 初始化逻辑初始化失败就静默降级只保留 HTTP 接口和面板。另外命令行切换和 HTTP 切换这两个入口最好共用一个内部函数不要各写一套逻辑。否则今天改了命令行解析忘了同步 HTTP 分支就会出现“命令行能切面板切不了”的尴尬。我们内部叫它“单一切换源原则”工具代码短也别偷懒。6. 这套方案的适用边界与落地建议6.1 什么场景真正需要运行时切换我的判断标准很简单如果一天内环境切换次数大于等于三次手动重启就开始明显消耗精力动态代理就值得上。典型场景包括前后端并行开发后端本地服务、测试环境、预发环境同时存在一个前端对接多个后端团队不同模块要访问不同开发机需要给领导或者客户做演示快速在 mock 数据和真实数据之间切换微前端项目里多个子应用分布在不同的本地端口需要统一代理入口。这套方案也是 mock 工具的好搭档。我习惯把 mock 服务也做成一个普通后端地址比如http://localhost:3000动态代理默认指向它。平时开发走 mock遇到需要和真实后端核对问题时一键切回真实服务。mock 和真实后端之间的切换成本几乎降到了零。6.2 什么场景建议别硬上运行时切换不是万能的有些地方不该用它。第一生产环境或预发环境的真实流量调度应该交给网关、负载均衡或专门的流量治理工具而不是前端 dev server 中间件。动态代理面向的是开发调试场景不是流量治理场景。第二如果后端环境之间状态差异很大比如数据库结构不同、依赖服务缺失那么切换过去大概率也是报错。这种问题换代理解决不了应该优先统一环境配置。第三如果项目只有一个后端环境切来切去没有意义老老实实写死 target 就够了。方案的价值取决于真实的切换频率不要为了炫技引入复杂度。6.3 团队落地的一点个人心得我把这套插件在团队里推广时并没有直接让大家背命令行而是先立了一个约定环境名统一命名比如mock、dev、test、pre。所有人在envs配置里保持一致切换接口才能互相通用。后来后端同事也会自己打开面板把代理切到他想验证的那台机器前端不用再当“人肉代理开关”。还有一个很小的细节每次切换我都在终端打一条带时间戳的日志记录切换动作和当前目标。集成测试出问题时翻一下终端日志就能定位是“谁在什么时间把代理切到了哪台后端”。排查效率提升了很多。这个工具没什么高深的技术含量但它在“开发环境可变性管理”这个方向上确实把我从重复劳动里解放出来了。如果你也天天被环境切换折磨不妨照着思路改一版适合自己团队的。