Vite代理运行时动态切换:告别反复重启dev server

发布时间:2026/10/9 13:06:48
Vite代理运行时动态切换:告别反复重启dev server
先问一个真实场景你本地跑着一个 Vite 项目联调环境有三套前端一套、后端一套、测试一套今天调环境 A 的接口明天切环境 B后天又得回环境 A。vite.config.ts 里的 proxy target 改来改去每次改完还得 CtrlC 重启 npm run dev等白屏结束再刷新页面验证一趟十分钟就没了。我身边有同事一天光重启 dev server 就能耗上半小时后来实在忍不了花了一个下午把运行时切换代理这事儿给啃下来了。这篇文章就是来分享这套玩法的。我会说清楚 Vite 代理底层到底怎么工作、为什么改配置必须重启以及如何写一个自定义插件让代理目标在 dev server 运行过程中动态切换不用重启、不用刷新改完立即生效。适合所有被多环境联调折磨的前端开发也适合团队里想要统一代理管理方案的工程化负责人。1. 为什么我们需要运行时切换代理1.1 开发环境里换后端有多麻烦先说个我自己的经历。去年做一个中后台项目后端分了三个环境本地开发环境、测试联调环境、预发布环境。接口地址长得还不一样有的带/api前缀有的不带后端同事还会时不时告诉你今天你切到我本地 IP 吧我的服务挂在 192.168.1.108:8080。你算算一个人要维护多少个代理目标常规做法是改 vite.config.ts把server.proxy里的 target 替换一下保存重启。Vite 启动速度已经很快了但架不住频繁切尤其是项目大起来、依赖多的时候冷启动也要十几秒热起来之后还得等浏览器重新发请求。这还只是单机开发要是团队里每个人都这么干代码仓库里就天天出现改 proxy target的提交记录review 的时候又得解释半天这不是功能变更这只是本地联调配置。我最初还试过用环境变量文件来缓解.env.development里写VITE_PROXY_TARGEThttp://192.168.1.108:8080然后 vite.config.ts 里读process.env.VITE_PROXY_TARGET。听起来挺优雅但环境变量一样是启动时读的改完还是得重启。而且 env 文件一旦提交到仓库容易把本地配置带入流水线反而不安全。1.2 Vite 代理到底做了什么要解决运行时切换的问题必须先搞清楚 Vite 代理的执行机制。Vite 的 dev server 在启动时会读server.proxy配置内部使用的是http-proxy这个库把它封装成一个中间件挂到 dev server 的请求处理链上。这个中间件做的事情很简单收到一个请求看它的路径是否命中proxy配置里的 key比如/api如果命中就把请求转发到配置的target地址同时处理好 Host、Origin 这些请求头。关键在于这段中间件是在 dev server 启动时生成的target是写死在一个闭包里的。你改了 vite.config.ts不改重启的话运行的还是旧的那份中间件。这就是为什么常规方案必须重启。但中间件机制本身给了我们一个口子。Vite 的 dev server 底层用的是 Connect 框架请求会依次经过一串中间件。如果我们能抢先在这些中间件之前插入一段自定义逻辑在请求到达 Vite 内置代理中间件之前先判断这次请求要转发到哪个目标然后直接转发那不就能做到运行时切换了吗这个思路就是整套方案的核心。2. 运行时切换代理的方案选型2.1 静态配置的几种弯路先盘点一下我踩过或者看到别人踩过的弯路避免你再走一遍。第一种是上面说的改vite.config.ts重启。最原始但频率一高心态容易崩。第二种是准备多份配置文件比如vite.config.local.ts、vite.config.test.ts然后用 npm script 切换。这看起来比手动改文件高级但本质上还是要重启只是省了改 target这一步。而且 config 文件一多公共配置怎么抽离、环境变量怎么合并都是维护成本。第三种是用系统 hosts 文件做域名映射所有环境都用同一个域名后端在网关层面分流。这个我试过一段时间可行但前提是公司有统一的网关帮你做路由否则每个后端本地 IP 要手动写 hosts改完还得刷新 DNS 缓存在 Windows 上尤其麻烦。而且 hosts 是系统级配置误改了会影响别的项目。第四种是直接绕过 Vite 代理用 axios 的 baseURL 动态切换。前端发请求时axios.defaults.baseURL http://192.168.1.108:8080全局一换就生效。这条路的问题在于跨域。你从localhost:5173直接请求192.168.1.108:8080浏览器默认会拦截除非后端接口开了 CORS。我能控制后端还好说但大多数情况是历史项目后端没加 CORS 头只能靠代理转发来规避浏览器跨域限制。2.2 运行时方案对比与选择排除了上面几种我最后聚焦到两个可行的运行时方案上。第一个方案是写一个 Vite 插件在configureServer钩子里往 dev server 插入自定义中间件拦截特定请求用http-proxy转发到动态目标。这个方案最贴近 Vite 的底层机制改动最小我可以完全掌控代理行为。第二个方案是干脆不用 Vite 的 proxy前端所有请求都经过一个本地小服务由小服务来转发。相当于自己写一个简单的 mock/gateway 服务。这个方案灵活但等于放弃 Vite 自带的代理能力还要自己处理静态资源服务的协同成本偏高没有隔壁方案来得直接。我选第一个方案。原因有三一是 Vite 插件的生态 API 稳定从 Vite 2 到现在的 Vite 5 都能用二是可以随时回退到 Vite 内置代理兼容性最好三是动态转发逻辑可以设计成可选只有满足条件的请求才走自定义转发其他请求全部交给 Vite 默认代理不影响原有配置。2.3 核心思路在中间件层做转发决策说白了我们做的事情就是绕开 Vite 内置代理或者说是把内置代理替换成一个智能代理。智能的地方在于它每次收到请求时不是读一个启动时写死的 target而是从请求现场提取出来。提取的方式有两种一种是通过请求头。前端在发请求时统一附加一个自定义 header比如X-Proxy-Target: http://192.168.1.108:8080。中间件拿到这个 header就知道这次请求要转发到哪。另一种是通过 URL 路径。比如把请求从/api/xxx改写成/__proxy/http://192.168.1.108:8080/xxx中间件从路径里解析出编码过的目标地址。很多现成的代理工具用的就是这个思路好处是调试时一眼就能看出来请求去到了哪个环境不需要打开控制台翻请求头。两种方式我后面都会给出完整代码。它们也可以混合用用 header 指定目标用路径里的前缀决定是否走自定义转发逻辑。3. 手写一个运行时代理切换插件3.1 准备工作初始化 Vite 项目先创建一个 Vite 项目来演示。我用的是 React TypeScript 模板其他框架也一样这个方案不依赖具体前端框架。npm create vitelatest runtime-proxy-demo -- --template react-ts cd runtime-proxy-demo npm install然后安装http-proxy虽然 Vite 内部依赖里也有但我们自己写的插件直接引用这个库更稳妥声明也更清晰。npm install http-proxy npm install -D types/http-proxy注意版本我实测用 http-proxy 1.18.1配合 Vite 3/4/5 都没问题。如果你用 Vite 2插件 API 有点差异但 configureServer 钩子是一样存在的。3.2 核心插件代码实现插件的核心是一个runtimeSwitchProxy函数返回一个 Vite Plugin 对象。我在项目根目录下新建scripts/runtime-switch-proxy.ts内容如下import type { Plugin } from vite import httpProxy from http-proxy // 创建一个代理服务器实例复用连接避免每个请求都重新握手 const proxy httpProxy.createProxyServer({ changeOrigin: true, ws: true }) // 代理转发错误统一处理 proxy.on(error, (err, req, res) { console.error([runtime-switch-proxy] proxy error:, err.message) if (!res.headersSent) { res.writeHead(502, { Content-Type: text/plain; charsetutf-8 }) } res.end(代理目标不可用: err.message) }) function normalizeTarget(input: string): string { let target input.trim() if (!/^https?:\/\//i.test(target)) { target http:// target } return target.replace(/\/$/, ) } export function runtimeSwitchProxy(): Plugin { return { name: runtime-switch-proxy, apply: serve, configureServer(server) { // 中间件要注册在 Vite 内置中间件之前 server.middlewares.use((req, res, next) { // 只处理我们关心的路径避免影响静态资源和 Vite 内部请求 const url req.url || if (!url.startsWith(/api/) !url.startsWith(/__proxy)) { return next() } // 方式一从请求头 X-Proxy-Target 取目标地址 const headerTarget req.headers[x-proxy-target] as string | undefined // 方式二从 /__proxy/{encodedTarget}/xxx 路径里取目标地址 let pathTarget: string | undefined if (url.startsWith(/__proxy/)) { const segments url.split(/) // /__proxy/http:%2F%2F192.168.1.108:8080/api/users const encoded segments[2] if (encoded) { try { pathTarget decodeURIComponent(encoded) } catch { console.error([runtime-switch-proxy] 无法解析路径目标:, encoded) } } } const target headerTarget || pathTarget if (!target) { // 没有动态目标走 Vite 默认代理或其他中间件 return next() } const normalized normalizeTarget(target) // 去掉 /__proxy/{encodedTarget} 前缀让请求保持原有路径 if (url.startsWith(/__proxy/)) { const slashIndex url.indexOf(/, 9) // 找到 /__proxy/ 后的下一个 / if (slashIndex -1) { // 重写 URL只保留 /api/xxx 部分 req.url url.slice(slashIndex) } else { req.url / } } // 用 http-proxy 转发 proxy.web(req, res, { target: normalized }, (err) { if (err) { console.error([runtime-switch-proxy] web proxy error:, err) if (!res.headersSent) { res.writeHead(502) } res.end(Bad Gateway) } }) }) } } }这里有几个细节要说明。changeOrigin: true是必须的它会把请求转发出去时的 Host 头改为目标服务器的 Host否则后端域名校验很可能失败。我在第 4 部分会展开讲。ws: true用来支持 WebSocket 代理因为 dev server 的 HMR 也是走 WebSocket 的如果你开发的页面里有实时通信功能这个配置能一并处理。proxy.on(error)这个监听很重要不然目标服务器挂了请求会直接抛出未处理的异常dev server 可能崩掉。实测中后端服务重启或 IP 失效时错误监听能保证 dev server 活着只是返回 502。3.3 请求头方式实现我在 3.2 的代码里已经把这套逻辑写进去了。实际用的时候请求头是最干净的因为不用改动 URL 结构对后端透明。前端怎么配合呢假设你封装了一个request.tsimport axios from axios // 这段代码放在一个可全局修改的配置模块里 export const proxyConfig { target: http://localhost:8080 } // 每次请求从 proxyConfig 读取当前目标 axios.interceptors.request.use((config) { if (proxyConfig.target) { config.headers[X-Proxy-Target] proxyConfig.target } return config }) export function requestT(options: AxiosRequestConfig): PromiseT { return axios.requestT({ ...options, baseURL: /api }) }这样你想切环境时只要在浏览器控制台执行proxyConfig.target http://192.168.1.108:8080下一次请求就会自动带上新的 header中间件收到后立即转发到新目标。我用这个玩法在浏览器 devTools 里直接切换不需要改任何代码、不需要刷新页面体验非常顺滑。3.4 路径参数方式实现路径参数方式适合不需要改前端请求头的场景。比如后端总是调同一个域名你想通过 URL 直接绕过前端封装的逻辑。实现思路我刚才写过了把请求 URL 改写成/__proxy/{encodeURIComponent(target)}/api/xxx。前端调用方式示例const target encodeURIComponent(http://192.168.1.108:8080) fetch(/__proxy/${target}/api/users).then(res res.json())中间件解析/__proxy/后面的部分解码成目标地址然后去掉前缀把req.url改回/api/users再转发给目标服务器。后端收到的路径和原来一样。这种方式尤其适合你在调试接口文档时临时指定一个地址或者给别人发一个链接时带上目标环境。我用它做过一个快速分享功能把带目标参数的 URL 复制给同事他打开就能直接看到同一环境下的页面效果不用在我们本地再配置一套代理。3.5 配置一个可视化的切换入口光有代码还不够开发体验要想真正起飞得有个可视化入口。我在项目里加了一个极简的切换面板只在 dev 模式下渲染。思路是提供一个全局对象面板改动它请求拦截器读取它。界面不依赖 UI 框架一个 div 几个按钮就行。核心逻辑如下// src/dev/proxy-panel.ts export function mountProxyPanel() { const box document.createElement(div) box.id runtime-proxy-panel box.innerHTML div styleposition:fixed;bottom:20px;right:20px;z-index:9999;background:#111;color:#fff;padding:12px;border-radius:8px;font-size:12px; div stylemargin-bottom:8px;font-weight:bold;代理切换/div input idproxy-target-input stylewidth:180px;padding:4px;margin-right:4px; placeholderhttp://localhost:8080 / button idproxy-target-apply stylepadding:4px 8px;cursor:pointer;切换/button div idproxy-target-status stylemargin-top:6px;opacity:.8;/div /div document.body.appendChild(box) const input box.querySelector(#proxy-target-input) as HTMLInputElement const status box.querySelector(#proxy-target-status) as HTMLDivElement const applyBtn box.querySelector(#proxy-target-apply) as HTMLButtonElement applyBtn.addEventListener(click, () { const value input.value.trim() if (value) { // 更新全局代理目标axios 请求拦截器会读取 ;(window as any).__PROXY_TARGET__ value status.textContent 已切换: value } }) }然后在入口文件里判断import.meta.env.DEV只在开发环境挂载if (import.meta.env.DEV) { import(./dev/proxy-panel).then(m m.mountProxyPanel()) }这样团队里的非前端同事也可以用这个面板自己切换目标不用他们打开 vite.config.ts 去改配置降低了协作门槛。插件代码和应用面板合起来就是一个完整的运行时代理切换方案。4. 常见问题与排查技巧实录4.1 代理没有生效请求还是 404这是我被问得最多的问题。排查顺序很关键。第一确认中间件是不是真的注册到了 Vite 内置代理之前。Vite 插件的configureServer钩子默认在内部中间件添加前调用但如果你用了server.middlewares.use之前有return了或者你在插件对象里写了多个configureServer顺序可能出问题。我建议在插件开头打一行日志确认configureServer被调用了。第二确认路径匹配。我的代码里只处理/api/和/__proxy/开头的请求如果你的项目请求前缀是/api2或/v1就得改匹配条件或者直接去掉前缀限制统一走自定义逻辑。第三404 要区分是 Vite 返回的还是后端返回的。如果你看到响应体里有 Vite 的默认 HTML 错误页说明请求根本没进入代理转发如果响应体是后端返回的 JSON说明代理已经通是后端那个环境本身没有这个接口。4.2 changeOrigin 到底要不要开我在插件里默认打开了changeOrigin。这是因为很多后端的网关或框架会校验请求的 Host 头比如 Spring Cloud Gateway如果 Host 还是localhost:5173就会判定为非法请求。但有个反向场景有些内网开发环境是 IP 白名单校验而不是 Host 校验。如果你开了changeOriginHost 变成了目标的 IP反而可能绕过某些只认局域网域名的策略。我遇到过几次后来把changeOrigin做成可配置项从请求头或面板里传x-proxy-change-origin: false就能临时关掉。4.3 WebSocket 连接怎么代理如果你开发的页面里有 WebSocket 长连接比如聊天、实时推送不能用普通的proxy.web转发还得处理 upgrade 事件。http-proxy自带ws: true的选项但 Vite 的 dev server 自己也要使用 WebSocket 做 HMR所以中间件要留意别把 HMR 请求给劫持了。我的处理办法是在中间件里判断请求路径只对/ws或特定前缀做 WS 转发/vite/开头的路径一律 next() 放行。具体代码server.httpServer?.on(upgrade, (req, socket, head) { const url req.url || if (url.includes(/ws)) { const target // 从 req.headers[x-proxy-target] 或路径里获取 proxy.ws(req, socket, head, { target }, (err) { console.error([runtime-switch-proxy] ws proxy error:, err) socket.destroy() }) } })这个钩子在 Vite 插件里要用configureServer(server) { server.httpServer?.on(upgrade, ...) }来挂载确保不影响 Vite 自身的 HMR socket。4.4 切换目标后 Session 掉了怎么办后端常用 Cookie 保存登录态Cookie 是绑定域名的。你从环境 A 切到环境 BCookie 的 domain 还是 A 的B 环境识别不了登录态直接失效。这个问题不是代理层能完全解决的但我有一条实用经验尽量让所有环境共用同一个域名通过端口或根路径区分。比如localhost:8080、localhost:8081这样 Cookie 的 domain 可以保持在localhost切换端口不会丢。如果你一定要用不同 IP 或不同域那就需要后端在网关层做会话共享比如用 Redis 统一存储 session前端只传一个 sessionId代理切换时不影响。另外还要检查 Cookie 的SameSite属性。开发环境下跨端口请求时如果后端返回的Set-Cookie里SameSiteLax浏览器可能不携带 Cookie。我一般会在中间件里把转发响应头的Set-Cookie打印出来调试用。4.5 代理目标的连接复用http-proxy默认对相同目标地址会复用连接池按理说性能没问题。但我实测中发现如果频繁在目标 A 和目标 B 之间切换连接池里会残留旧目标的连接。虽然不会导致错误但长期运行会占用文件描述符。我建议给代理实例增加自动删除失效 socket 的逻辑监听proxy.on(proxyReq)或定期清理空闲连接。另一个更简单的办法每个目标都新建一个http-proxy实例用 Map 缓存切换目标时直接取对应实例。这样连接池和错误事件都是隔离的好排查。5. 更多玩法与个人建议5.1 把切换配置同步给团队自己本地切换爽了但团队协作时容易乱。我后来把目标环境列表集中到了一个 JSON 文件面板下拉选择而不是手动填 IP。这样每个人都能快速选到测试环境联调环境等固定选项避免手滑填错。// proxy-targets.json { local: http://localhost:8080, test: http://10.10.1.20:8080, staging: http://staging-api.example.com }面板从这份 JSON 读取选项选择后写入proxyConfig.target。团队维护这份配置文件比各自改vite.config.ts规范多了。5.2 性能注意点与缓存我一开始的实现每个请求都做字符串解析、正则匹配虽然开发环境流量不大但接口频繁刷新时还是能感觉到一点点延迟。后来我把目标解析的结果做了个 Map 缓存key 是目标地址字符串value 是解析后的完整 URL。另外路径方式的目标编码解析也缓存起来避免重复 decodeURIComponent。对于 dev server 进程来说这种优化属于锦上添花但养成习惯没坏处。5.3 什么时候别用这套方案说完优点也得泼盆冷水。如果你的项目是单环境、代理目标一年到头不变真心没必要上这套运行时切换方案。静态配置简单直接恰恰是最好的方案。如果你有严格的合规审计要求代理目标的变更需要留痕那也建议用配置文件 环境变量每次变更都记录在案而不是在运行时随意切换。我的建议是先看团队有没有多环境频繁切换的真实痛点有再上只是偶尔切一次还是老老实实改配置吧。根据我个人经验最舒服的用法是把运行时切换代理跟环境变量结合起来默认走.env.development里的静态目标需要临时切换时直接在面板或请求头里指定新目标。这样一来平常开发不受影响特殊场景又能灵活应对。这个插件我一写出来就用在了实际项目里后来旁边组的人也拿去复刻了一套到现在还在正常运行。