无需后端配合,前端如何优雅解决跨域并实现多环境切换?

5 阅读6分钟

无需后端配合,前端如何优雅解决跨域并实现多环境切换?

上周三下午,团队遇到一个棘手问题:需要在 5 个不同环境(开发、测试、预发布、UAT)之间来回切换验证功能,每次都要改配置文件、重启服务,折腾半小时才能开始开发调试。更麻烦的是,直接调用跨域 API 时被 CORS 挡在门外,等后端配置又要排期。

这种情况你肯定也遇到过。今天分享一个我们实际在用的方案:动态代理 + 请求头路由,专门针对开发和调试阶段,不用改后端代码,不用重启服务,在页面上点一下就能切换环境,多人同时开发不同环境也互不干扰。

1. 问题到底卡在哪里

先说说我们当时的处境,看看有没有同款痛点。

1.1 环境切换太麻烦

开发和调试时,经常要在多个环境之间切换验证。传统做法是改 .env 文件或者在代码里写死 API 地址:

// 改之前
const API_BASE = 'https://dev.example.com'

// 改之后
const API_BASE = 'https://uat.example.com'

每次切换都要:

  1. 修改配置文件
  2. 重启开发服务器
  3. 重新登录系统

最坑的是,调试到一半忘记改回来,把其他环境的配置提交上去了...

1.2 CORS 错误绕不过

开发和调试时,经常会直接访问不同环境的 API,浏览器直接拦截:

Access to XMLHttpRequest at 'https://test-api.example.com'
from origin 'http://localhost:3000' has been blocked by CORS policy

等后端配置 CORS?沟通协调至少半天,开发进度直接卡住。而且不同环境的 CORS 配置策略不同,临时请求调整也很麻烦。

1.3 多人协作互相干扰

团队 5 个人同时开发,如果共用一套环境配置,张三切到测试环境,李四的请求也跟着过去了,排查问题都找不到北。特别是多人并行开发不同环境时,这种干扰会严重影响效率。

2. 解决思路

核心想法其实不复杂:让每个请求自己携带环境信息,代理服务器根据这个信息动态转发

浏览器前端 → 携带 X-Env-Target 请求头 → 代理服务器 → 动态转发到目标开发/测试环境

具体分三步:

  1. 前端在界面选择目标环境,存到 localStorage,每次请求自动带上环境信息
  2. 代理服务器读取请求头,动态转发到对应环境
  3. 代理层统一处理 CORS,绕过目标环境的跨域限制

3. 具体实现

3.1 前端部分

3.1.1 环境选择 UI

我们在登录页面加了一个环境选择器,开发者可以直接选择目标环境(开发、测试、UAT 等):

// src/pages/Login/index.tsx

interface EnvConfig {
  name: string
  url: string
}

const DEFAULT_ENVS: EnvConfig[] = [
  { name: '开发', url: 'https://dev-api.example.com' },
  { name: '测试', url: 'https://test-api.example.com' },
  { name: 'UAT', url: 'https://uat-api.example.com' }
]

const STORAGE_KEY_CURRENT_ENV = 'current_env_url'

const handleEnvChange = (value: string) => {
  setCurrentEnvUrl(value)
  localStorage.setItem(STORAGE_KEY_CURRENT_ENV, value)
  message.success('Switching environment...')
  setTimeout(() => {
    window.location.reload()
  }, 200)
}

这里有个细节:切换环境后需要刷新页面,因为有些全局配置是在初始化时读取的。

3.1.2 请求拦截器注入环境信息

axios 拦截器里自动添加环境请求头,业务代码完全无感知:

// src/utils/request.ts

const HttpService = axios.create({
  baseURL: import.meta.env.DEV ? 'http://localhost:3001' : import.meta.env.VITE_API_BASE_URL,
  timeout: 60000,
  headers: { 'Content-Type': 'application/json' }
})

HttpService.interceptors.request.use(config => {
  if (import.meta.env.DEV) {
    const envTarget = localStorage.getItem('current_env_url')
    if (envTarget) {
      config.headers['X-Env-Target'] = envTarget
    }
  }
  return config
})

为什么用请求头而不是 URL 参数?

一开始我们试过把环境信息放在 URL 里,比如 /api/users?env=test,但很快发现问题:

  • 每个 API 调用都要处理这个参数
  • 容易遗漏,导致请求发到错误环境
  • URL 变长,日志里也不好看

用请求头就干净多了,拦截器统一处理,业务代码完全不用管。

3.2 代理服务器

3.2.1 初始化
npm install express http-proxy-middleware
3.2.2 完整实现
// proxy-server.js

import express from 'express'
import { createProxyMiddleware } from 'http-proxy-middleware'

const app = express()

// 处理 OPTIONS 预检请求
app.options('*', (req, res) => {
  res.header('Access-Control-Allow-Origin', '*')
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-env-target')
  res.header('Access-Control-Max-Age', '86400')
  res.sendStatus(200)
})

const proxy = createProxyMiddleware({
  target: 'http://localhost:3000',
  changeOrigin: true,
  secure: false,
  logLevel: 'warn',

  // 转发前检查请求头
  onProxyReq: (proxyReq, req, res) => {
    const envTarget = req.headers['x-env-target']
    if (!envTarget || typeof envTarget !== 'string') {
      return res.status(400).json({
        error: 'Bad Request',
        message: 'Missing X-Env-Target header'
      })
    }
    console.log(`[Proxy] ${req.method} ${req.url} -> ${envTarget}${req.url}`)
  },

  // 响应返回前添加 CORS 头
  onProxyRes: (proxyRes, req, res) => {
    res.header('Access-Control-Allow-Origin', '*')
    res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-env-target')
  },

  // 动态路由:根据请求头决定转发目标
  router: req => {
    const envTarget = req.headers['x-env-target']
    if (envTarget && typeof envTarget === 'string') {
      return envTarget
    }
    throw new Error('Missing X-Env-Target header')
  }
})

app.use('/api', proxy)

app.get('/health', (req, res) => {
  res.json({ status: 'ok', mode: 'dynamic-routing' })
})

const PORT = process.env.PORT || 3001
app.listen(PORT, () => {
  console.log(`Dynamic Proxy Server running on port ${PORT}`)
})
3.2.3 踩坑记录

第一次调试时,我们发现 OPTIONS 预检请求也被转发到了目标服务器,导致 CORS 错误。后来才明白,浏览器在发送正式请求前会先发 OPTIONS 请求确认跨域权限,这个请求需要在代理层直接返回,不能转发。

另外,Access-Control-Max-Age 设为 86400(24 小时)很重要,这样浏览器会缓存预检结果,避免每个请求都发 OPTIONS,能明显提升性能。

3.3 多用户并发支持

这个方案最爽的一点是天然支持多用户并发开发不同环境

用户 A (Chrome)  → localStorage AX-Env-Target: 开发环境
用户 B (Safari)  → localStorage BX-Env-Target: 测试环境
用户 C (Firefox) → localStorage CX-Env-Target: UAT 环境

隔离原理很简单:

  • localStorage 在每个浏览器里独立存储
  • 每个请求携带自己的环境信息
  • 代理服务器无状态,根据每个请求独立转发

我们团队 5 个人同时开发,有人测开发环境,有人测测试环境,有人测 UAT,互不影响。

4. 使用流程

  1. 启动代理服务器

    npm run proxy
    
  2. 启动开发服务器

    npm run dev
    
  3. 登录页面选择环境(开发/测试/UAT 等)

  4. 打开 DevTools 查看请求

    Request URL: http://localhost:3001/api/xxx
    Request Headers:
      X-Env-Target: https://test-api.example.com
    
  5. 查看代理日志

    [Proxy] GET /api/closingReport/getAllList -> https://test-api.example.com/api/closingReport/getAllList
    [Proxy] Response status: 200
    

5. 这个方案适合什么场景

我们用了半年,感觉这些场景特别合适:

  • 开发阶段:需要频繁切换多个开发环境或分支
  • 联调阶段:前端需要连接不同环境的服务
  • UAT 验证:需要临时切换到 UAT 环境验证功能

不太适合的场景:

  • 性能要求极高的场景(代理会增加一点延迟)
  • 需要严格权限控制的环境(请求头可以被伪造)

6. 还能怎么扩展

如果你有更多需求,可以考虑这些方向:

环境配置管理:把环境信息抽离成配置文件,支持变量模板,比如 {{BASE_URL}}/api/users

请求记录回放:拦截并保存历史请求,方便复现和调试问题

环境健康检查:定期检查各开发环境可用性,异常时自动切换或告警

7. 最后

这个方案在我们团队落地后,开发和调试阶段的环境切换从"改配置 - 重启 - 登录"的 5 分钟流程,变成了"点一下 - 刷新"的 10 秒操作。

当然,代理服务器本身也需要维护,偶尔会遇到一些边界情况。但总体来说,投入产出比很高,特别适合开发和调试阶段使用。