开发项目时,本地前端服务跑在 3000 端口,后端接口跑在 8080,浏览器一请求就报跨域。很多人第一反应是让后端加 CORS 头,或者用浏览器插件临时绕过,但这些都治标不治本。React 脚手架自带的代理配置,就是针对这个场景设计的标准解法,也是我每次新起前端项目时最先落地的基础设施之一。
这篇总结不是抄官方文档,而是把我从“只会配 proxy 字段”到“能处理多个后端、路径重写、Cookie 丢失、生产环境失效”这一路踩过的坑和验证过的方案完整梳理一遍。无论你是刚接触脚手架的新手,还是已经被代理问题折磨过的老手,这篇文章都值得花十分钟读完。
1. 本地开发为什么要一张“中转的车票”:跨域问题的本质
浏览器安全策略里的同源限制,是前端开发中绕不开的一道墙。所谓同源,指的是协议、域名、端口三者完全一致。开发时前端跑在http://localhost:3000,后端跑在http://localhost:8080,端口不同,已经不是同源了,浏览器自然拦截跨域响应。
1.1 为什么不能直接让后端放开 CORS 一劳永逸
后端加 CORS 头确实能解决一部分问题,但在开发阶段往往不现实。比如后端是某个老项目,代码里没有统一的请求入口,Header 配置分散在各处;或者后端环境由别人维护,你提出加跨域配置,对方要排期、要测试、要走发布流程,一折腾就是半天。
就算后端真的加了Access-Control-Allow-Origin: *,麻烦也还在。某些时候浏览器会先发预检请求(OPTIONS),后端如果没有处理 OPTIONS 请求的逻辑,预检直接失败,正式请求根本发不出去。还有携带 Cookie 的场景,Access-Control-Allow-Credentials和前端的withCredentials必须同时配置,任何一边漏掉,Cookie 就带不上。
1.2 代理方案的实际原理:同源请求“借道中转”
代理的本质,是把原本跨域的请求“伪装”成同源请求。前端页面请求http://localhost:3000/api/user,脚手架启动的开发服务器拦截到/api开头的请求,转发给http://localhost:8080/api/user,服务端没有跨域概念,正常返回数据;开发服务器拿到响应后,再原样返回给浏览器。此时浏览器看到的是来自localhost:3000的响应,同源,不拦截。
这个过程中,请求头和响应头在转发时通常都会被保留,适合绝大多数 API 场景。我一直把代理理解成“中转车票”:前端买的票是去 3000 站的,代理帮你把行李(请求)转送到 8080 站,取回行李(响应)再交给你,全程没有让浏览器直接接触 8080,所以“跨域”这张墙纸根本不会被触发。
提示:代理只存在于开发服务器层面,上线部署后脚手架开发服务器就不再参与,代理规则也就不生效了。生产环境的跨域问题要靠 Nginx 反向代理或后端 CORS 解决,这是完全不同的另一套逻辑,后面专门说。
2. 一条字段走天下的阶段:package.json 里加 proxy
用 React 脚手架创建项目之后,最省事的代理配置方式就是直接在package.json里配一个proxy字段。这个字段是开发服务器(底层是 webpack-dev-server)预留的快捷入口,专门给没有复杂代理需求的场景用。
2.1 配置的最小写法和它能匹配的路径
在package.json中加上:
{ "name": "my-app", "proxy": "http://localhost:8080" }保存后重启开发服务器(一般需要重启才能生效),然后前端代码里把请求地址写成相对路径:
fetch('/api/user/list') .then(res => res.json()) .then(data => console.log(data));请求发到http://localhost:3000/api/user/list,开发服务器发现proxy配置了目标地址http://localhost:8080,就会把请求完整转发过去。后端收到的仍然是/api/user/list,前端拿到的响应体来自后端,一切正常。
2.2 用 proxy 字段时最容易踩到的三个隐藏规则
第一,proxy字段的值只能是一个字符串,也就是说只能指定一个后端地址。如果你同时要对接 A 服务的登录接口、B 服务的列表接口、C 服务的文件上传接口,这个方案就撑不住了。
第二,前端请求必须是以相对路径形式发出。如果代码里写的是fetch('http://localhost:8080/api/user/list'),直接指向了后端地址,那么请求根本不会经过开发服务器的代理转发,跨域问题依然存在。
第三,代理转发是“无脑透传”的,不会帮你做路径改写。什么意思?fetch('/api/user')转发后,后端看到的是/api/user,不是/user。如果后端接口设计没有/api前缀,而前端为了区分代理请求统一加了/api,后端就会找不到路由,返回 404。这种场景要用路径重写,在第二种方案里展开说。
2.3 为什么很多人点开 Network 面板后一脸懵:请求并没有变成后端地址
这是一个非常容易误解的地方。配置代理后,你在浏览器开发者工具里看到的请求地址,依然是http://localhost:3000/api/user/list,而不是http://localhost:8080/api/user/list。因为浏览器只跟 3000 端口交互,代理转发是在“网络层之下”完成的,浏览器感知不到真实后端地址。
所以判断代理是否生效,不要只看 Network 面板的 URL,要看 Response Headers 里的内容是不是来自后端,或者直接观察后端日志是否有请求打进来。
# 后端控制台如果打印了请求日志,说明代理转发成功 [INFO] 2024-05-20 14:32:11 - GET /api/user/list 200如果后端没有任何日志,说明请求根本没有到达后端,要么代理配置没生效,要么请求路径没匹配上。
3. 多后端并存时的升级打法:http-proxy-middleware 配合 setupProxy.js
项目一旦需要对接多个后端地址,或者需要对不同前缀做不同转发规则,package.json里的proxy字段就必须要让位给更灵活的http-proxy-middleware。这个中间件是 React 脚手架官方文档推荐的替代方案,配置写在src/setupProxy.js里。
3.1 安装和基本配置结构
首先安装依赖:
npm install http-proxy-middleware --save-dev # 或者用 yarn yarn add http-proxy-middleware --dev然后在src目录下新建setupProxy.js:
const { createProxyMiddleware } = require('http-proxy-middleware'); module.exports = function(app) { app.use( '/api', createProxyMiddleware({ target: 'http://localhost:8080', changeOrigin: true, }) ); };这个文件的加载是脚手架自动完成的,开发服务器启动时会读取src/setupProxy.js并应用里面的 Express 中间件逻辑,不需要手动引入到组件里,也不需要修改webpack配置。
3.2 多路由规则的最常用排列
当后端不止一个时,可以写多个中间件规则:
const { createProxyMiddleware } = require('http-proxy-middleware'); module.exports = function(app) { // 用户相关接口,转发到 A 服务 app.use( '/api/user', createProxyMiddleware({ target: 'http://localhost:8081', changeOrigin: true, }) ); // 商品相关接口,转发到 B 服务 app.use( '/api/product', createProxyMiddleware({ target: 'http://localhost:8082', changeOrigin: true, }) ); // 文件上传,单独走 C 服务 app.use( '/upload', createProxyMiddleware({ target: 'http://localhost:8083', changeOrigin: true, }) ); };配置changeOrigin: true的目的,是把请求头里的Host字段改成目标域名。有些后端会根据Host做不同处理,不设置的话,后端看到 Host 是localhost:3000,可能会报错或返回异常内容。
3.3 pathRewrite 的用处:去掉前缀、改写路径
刚才说过,proxy字段没法改写路径。而http-proxy-middleware提供了pathRewrite,让你可以随心所欲调整转发到后端的路径。
比如前端统一用/api开头,后端路由没有这个前缀:
app.use( '/api', createProxyMiddleware({ target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '', // 把 /api 前缀剥掉 }, }) );这样前端请求/api/user/list,后端实际收到的是/user/list。正则写法稍微有点绕,但核心就是一个:'^/api': ''表示把所有以/api开头的路径中的/api替换为空字符串。
还可以做更复杂的改写,比如把版本号从路径里剥离:
pathRewrite: { '^/api/v1': '/v1', '^/api/v2': '/v2', }如果你的后端同一个环境里既有 v1 又有 v2 接口,这种写法可以一个代理入口统一转发,后端根据路径区分版本。
3.4 带 Cookie 和 WebSocket 的代理细节
开发时涉及登录态,需要把 Cookie 带到后端,代理配置里要增加:
createProxyMiddleware({ target: 'http://localhost:8080', changeOrigin: true, cookieDomainRewrite: 'localhost', // 把 Set-Cookie 里的域改写成 localhost onProxyReq: function(proxyReq, req, res) { // 必要时手动设置 Cookie const cookie = req.headers.cookie; if (cookie) { proxyReq.setHeader('cookie', cookie); } }, });cookieDomainRewrite解决的是后端种 Cookie 时指定了域,导致浏览器无法保存的问题。如果不做改写,后端返回Set-Cookie: Domain=.example.com; ...,浏览器发现当前域名是localhost,不匹配,Cookie 就被拒绝了,登录态自然丢失。
WebSocket 代理需要额外传ws: true:
createProxyMiddleware({ target: 'http://localhost:8080', changeOrigin: true, ws: true, });如果你的项目用到 WebSocket(比如推送消息),不开启这个选项,连接会被开发服务器默默丢弃,前端一直收不到消息,且网络面板里看不出明显报错,非常隐蔽。
4. 两种方案到底怎么选:场景判断与决策清单
proxy字段和setupProxy.js不是二选一的严格排他关系,但在实际项目中,我的习惯是:单一后端、路径不需要改写、临时联调用,用proxy字段;只要涉及多个后端、需要改写路径、开启 WebSocket 或 Cookie 改写,直接上setupProxy.js。下面这个表格是我在项目评审时常拿出来的对比:
| 对比维度 | package.json 的 proxy | setupProxy.js + http-proxy-middleware |
|---|---|---|
| 配置位置 | 单一字符串 | 独立 JS 文件,可编程 |
| 多个后端支持 | 不支持 | 支持 |
| 路径改写 | 不支持 | 支持(pathRewrite) |
| Cookie 域改写 | 不支持 | 支持(cookieDomainRewrite) |
| WebSocket 支持 | 默认支持(简单场景) | 可显式控制(ws: true) |
| 按环境动态配置 | 很难 | 可根据环境变量灵活配置 |
| 底层原理 | 脚手架封装 | Express 中间件直管 |
4.1 一个反直觉的事实:url 前缀不匹配时,proxy 字段也能转发
proxy字段有个冷门行为:请求路径不管有没有特殊前缀,只要当前域名端口是开发服务器自己,它都会尝试转发到目标地址。也就是说,fetch('/random/path')也会被转发到后端,只是后端可能返回 404 而已。
而http-proxy-middleware的规则则必须指定匹配路径前缀(比如/api),不在前缀范围内的请求会正常走开发服务器返回前端资源,不会转发。
这个差异会带来一个开发时的“看似正常实则奇怪”的现象:用proxy字段时,前端写一个/api/xxx的错误路径,可能后端返回 404 页面,而前端却拿到了 200 的 HTML 内容;用http-proxy-middleware时同样的请求可能直接被开发服务器当成静态资源请求处理,返回index.html,数据却是 0 字节。
排查这类问题时,我的经验是先看后端有没有请求日志。有日志说明代理规则生效了,再看返回内容。没有日志,则优先检查规则前缀是否匹配。
4.2 实际项目中的三个典型选型场景
场景一:你在做一个内部管理后台,后端只有一个 Java 服务,接口都带/api前缀,前后端联调文件不多。这种直接配proxy字段,十分钟搞定,少即是多。
场景二:公司整体微服务化,不同模块对应不同后端服务,前端需要同时访问用户服务、订单服务、支付服务。这种就只能用setupProxy.js,每个服务一条规则,清晰可维护。
场景三:项目同时接后端 API 和第三方地图服务,地图服务需要特殊 Header,API 服务需要 Cookie。这种更复杂的场景,setupProxy.js里可以在onProxyReq拦截器里按目标地址动态添加请求头。
app.use( '/map', createProxyMiddleware({ target: 'https://map.example.com', changeOrigin: true, onProxyReq(proxyReq, req, res) { proxyReq.setHeader('X-Custom-Key', process.env.MAP_KEY); }, }) );前端写fetch('/map/place/search?kw=xxx'),代理会自动加上密钥 Header,第三方服务无需暴露密钥给浏览器。
5. 那些让排查成本飙升的报错:现象、根因和药方
代理配置本身并不难,难的是报错时一脸茫然。我把自己在项目中遇到的高频问题整理成了一份排查手册,按现象分类,可以直接照着操作。
5.1 现象一:Network 面板显示 404,但浏览器页面正常
最常见的情况,大概占了代理问题的 40%。请求发到了http://localhost:3000/api/user,开发服务器找不到静态资源,返回了index.html,内容类型是text/html,所以接口虽然不是 200,但页面没坏。
排查步骤:
- 确认后端是否启动,端口是否正确;
- 确认代理规则的路径前缀是否匹配;
- 确认
target是否写错了协议(http还是https); - 确认后端路由是否真的存在
/api/user。
5.2 现象二:报错Error occurred while trying to proxy: localhost:8080/api/user
这个错误是代理转发到目标地址时失败,常见于:
- 后端服务没启动;
- 后端服务启动在其他端口,比如改成了 8081;
- 后端需要 HTTPS,但代理配置写的是 HTTP;
- 后端有 IP 白名单,
changeOrigin: true后 Host 头变成了 localhost,白名单校验失败。
处理方法:
# 先确认后端端口是否真的在监听 curl http://localhost:8080/health如果curl有响应,说明代理配置或路径有问题;如果curl都连不上,先去启动后端。
5.3 现象三:请求发出去了,但响应一直 pending 不回来
这种情况多数是因为:
- 代理配置成了 WebSocket,但后端没有对 WebSocket 请求做响应;
- 后端接口本身死循环或请求阻塞;
- 代理网络层因为
keepAlive保持了一个不健康的连接。
我的处理方式是先断开所有代理规则,直接用curl调后端接口,看后端自身是否健康。后端没问题,再逐步加代理规则测试。
5.4 现象四:代码里请求地址带了完整域名,代理完全不生效
fetch('http://localhost:8080/api/user/list')这是很多从其他框架转来的人最容易犯的错误。代理只拦截“相对路径”请求,浏览器发出的绝对 URL 如果指向了非开发服务器地址,就不会经过代理转发,跨域问题再次出现。
规范做法是,项目内维护一个统一的请求基础路径:
// src/utils/request.js const BASE_URL = '/api'; export function get(url) { return fetch(`${BASE_URL}${url}`); }这样请求全部走相对路径,代理规则才能兜住。
5.5 现象五:代理在 Windows 上启动时报 EADDRINUSE
EADDRINUSE表示端口被占用。开发服务器的默认端口 3000 被其他进程占用了,需要换端口启动:
# 脚手架支持直接指定端口 npm start -- --port 3001然后用http://localhost:3001访问,代理目标不变。
6. 接口请求封装与代理联动的细节:axios 里的 baseURL 和 withCredentials
很多人配好了代理,却发现 axios 发起的请求仍然有问题。核心原因是 axios 的默认配置和代理规则没有对齐。
6.1 baseURL 到底该写什么
在开发环境,baseURL 不应该写成后端地址,应该写成相对路径/api:
import axios from 'axios'; const service = axios.create({ baseURL: '/api', timeout: 10000, });请求示例:
service.get('/user/list');最终发出的 URL 是/api/user/list,代理规则匹配/api前缀,转发到后端。
有些人在 axios 里写了baseURL: 'http://localhost:8080/api',代理基本等于白配,跨域问题原样出现。axios 的 baseURL 和浏览器fetch一样,走的是浏览器网络层,请求会直接发给 8080,跳过开发服务器的转发。
6.2 withCredentials 和代理的联手
如果项目里使用 Cookie 保持登录态,axios 需要开启:
const service = axios.create({ baseURL: '/api', timeout: 10000, withCredentials: true, });此时代理配置里也要确保cookieDomainRewrite已配置,否则后端Set-Cookie可能会因为域名不匹配被浏览器丢弃。两端都正常,登录态才能完整穿透开发服务器。
6.3 响应拦截器里的常见误判
我在项目里常写一个响应拦截器,当 HTTP 状态码为 401 时自动跳转登录页。但代理配好后,如果后端接口有鉴权校验,返回 401 是很正常的。拦截器逻辑要确保只在真实业务场景触发,不要因为代理在 Network 里显示 401 就以为是代理故障。
service.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { window.location.href = '/login'; } return Promise.reject(error); } );如果后端接口本来就不需要鉴权,却出现 401,则要检查代理是否把请求头丢掉了,常见的是AuthorizationHeader 在转发过程中被剥离。
6.4 代理对请求方法敏感吗
http-proxy-middleware默认对所有方法都生效,包括 POST、PUT、DELETE 和 OPTIONS。但如果你在中间件里写了自己的逻辑,或者后端对 OPTIONS 预检有要求,可以增加方法过滤:
app.use( '/api', createProxyMiddleware({ target: 'http://localhost:8080', changeOrigin: true, methods: ['GET', 'POST', 'PUT', 'DELETE'], }) );不写methods时默认全部方法都代理,一般不用刻意设置,除非你有特殊需求。
7. 配置完代理后还要做的事:验证清单和常见遗漏
代理配置完成后,很多人只在前端页面点两下,看到数据出来了就以为结束了,其实很多隐患没暴露。我个人的习惯是跑一套简单的验证清单。
7.1 验证清单详情
- 检查 Network 面板,请求 URL 依然以
localhost:3000开头; - 检查 Response Headers,字段来自后端(比如
Server头、自定义的X-Request-Id等); - 打开后端控制台,确认请求日志有输出;
- 测试 GET、POST、PUT 各至少一次;
- 测试带 Cookie 的请求,确认 Set-Cookie 被浏览器接受;
- 如果有 WebSocket,测试连接能建立、能收发消息;
- 切换接口路径,比如从
/api/user切到/api/product,确认规则匹配正确。
7.2 容易被忽略的一个文件:.env.development
代理规则里如果写死了后端地址,不同开发人员本地后端端口不一致时会互相踩坑。我建议把目标地址抽到环境变量里:
const { createProxyMiddleware } = require('http-proxy-middleware'); const target = process.env.REACT_APP_PROXY_TARGET || 'http://localhost:8080'; module.exports = function(app) { app.use( '/api', createProxyMiddleware({ target, changeOrigin: true, }) ); };然后在项目根目录建.env.development:
REACT_APP_PROXY_TARGET=http://localhost:8080其他人拉代码后,修改.env.development里对应后端地址即可,不需要改动任何 JS 文件。
7.3 关于 https 目标地址的一个提示
如果后端地址是https://api.example.com,而本地开发服务器的证书是自签名的,代理可能会因为证书验证失败而报错。这种情况通过在配置里加入secure: false解决:
createProxyMiddleware({ target: 'https://api.example.com', changeOrigin: true, secure: false, // 忽略自签名证书错误 })secure: false只影响代理到后端的连接,不会强制浏览器信任证书,所以生产环境不受影响。
8. 生产环境还配不配:部署阶段的跨域策略
开发环境的代理解决的是本地联调问题,一旦打包部署,setupProxy.js和proxy字段都不再生效。生产环境的跨域处理是另一套逻辑,需要提前规划。
8.1 Nginx 反向代理的写法参考
最常见的方式是 Nginx 将/api前缀的请求转发到后端服务:
server { listen 80; server_name example.com; location /api/ { proxy_pass http://backend-server:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { root /usr/share/nginx/html; index index.html; } }注意proxy_pass结尾的/api/和没有/api/的区别,这决定了是透传完整路径还是剥离前缀,和开发环境pathRewrite的思维完全一致。
8.2 后端 CORS 的兜底方案
如果你无法控制 Nginx 配置,或者后端部署在云函数等平台,后端 CORS 是兜底手段。前端请求必须指向完整的后端地址,然后后端在响应里加:
Access-Control-Allow-Origin: https://example.com Access-Control-Allow-Credentials: true Access-Control-Allow-Headers: Content-Type, Authorization这种方式只适合后端接口不多、域名固定的情况,不然通配符失控,安全性和可维护性都不够。
8.3 上线前必须检查的跨域配置项
上线前的自测清单,我通常包含:
- 浏览器直接请求后端接口,查看响应头是否包含允许跨域的字段;
- 模拟 POST 带 JSON 请求,确认预检请求能被后端正确响应;
- 登录后刷新页面,确认 Cookie 或 Token 存储方式在跨域情况下依然有效;
- 检查是否有任何前端代码残留了
localhost:8080等开发地址。
这条很关键:开发时为了快速调试,经常有人把baseURL临时改成后端地址,上线前忘记改回来,导致线上请求直接打到 localhost,所有用户调用失败。建议在项目里加一个环境判断工具函数:
const isProduction = process.env.NODE_ENV === 'production'; const BASE_URL = isProduction ? 'https://api.example.com' : '/api';只要所有请求都通过这一个入口发出去,就不会出现开发地址泄漏到生产环境的问题。
9. 从代理配置延伸出去的思考:前端工程化里的边界意识
代理配置看起来只是一个不起眼的开发辅助功能,但把它放到整个前端工程体系里看,背后反映的是边界意识。前端的代码运行在浏览器里,天然受同源策略约束,所有跨域方案本质都是在“绕”这个约束,只是绕的位置不同:开发环境在 Node 层绕,生产环境在网关层绕。
我见过不少项目,代理配好了,但团队里每个人都在自己的代码里写死http://localhost:8080,或者把后端地址塞进请求工具函数里,导致代理规则形同虚设。这不是配置的问题,而是工程规范的问题——代理不是写给某一个人用的,它应该成为项目基础设施的一部分,统一封装、统一维护,每个人开箱即用。
如果你的团队项目里还没有统一的请求封装,我建议在配代理的同时,把请求工具函数也一起规范化:baseURL 统一用环境变量驱动,超时时间、拦截器、错误提示全部集中管理。代理解决的是“请求怎么出去”,请求封装解决的是“代码怎么写清晰”,两者配合才是完整的解决方案。
另外一个容易忽略的点是,代理规则也会影响前端静态资源的访问。比如你配了/static前缀的代理,但前端本身有静态资源目录,就会和真实资源路径冲突。所以代理前缀的选择要避开脚手架默认的静态资源路径(比如public/目录下的资源通常不走代理中间件)。
我在新项目里通常用/api作为所有后端请求的统一前缀,因为public目录和大多数业务的路径都不冲突,且一眼就能识别哪些请求是需要代理的后端接口。
10. 一些值得保留的实操习惯和最终建议
最后分享几条我在这类配置问题上沉淀的个人习惯,不保证绝对正确,但长期用下来省了很多排查时间。
第一,开发服务器重启频率要高。修改package.json的proxy字段后必须重启开发服务器,修改setupProxy.js后部分情况下脚手架会自动重启,但如果不放心,手动重启一次最稳妥。我在团队里遇到过有人改了配置但没重启,白白排查了半小时。
第二,代理的报错不一定显示在浏览器控制台。很多代理错误是静默的,浏览器只看到 404 或 pending,真实原因在后端日志里。排查时一定要两端同时打开日志,前端控制台和后端控制台对照着看。
第三,不要把代理配置写在组件里或者工具函数里。它应该独立存在,路径清晰、注释完善,属于项目根级别的基础设置,任何开发人员拉下代码后不需要额外沟通就能理解。
第四,提交代码时注意不要把个人的后端地址带上。我见过团队成员本地用localhost:9090,提交代码时把setupProxy.js里的 target 也一起提交了,其他人拉下来代理全部指向错误端口。解决方式是用环境变量隔离,每人本地.env.development文件不同,而setupProxy.js提交的是统一模板。
如果你现在正被本地跨域问题困扰,先不要急着给后端提工单,按照这篇文章的顺序,先确认后端端口健康,再确认请求是相对路径,最后用setupProxy.js完成规则匹配。大概率二十分钟内就能跑通。代理配置本身不难,难的是理解它到底在哪一层解决问题,以及遇到问题时的排查路径是否能覆盖所有可能性。把这些搞清楚了,React 脚手架的代理对你就不再是一个黑盒。