摘要
在真实前端项目里,接口配置最容易出问题的地方,不是 baseURL 怎么写,而是开发环境和生产环境的行为根本不是一回事。
我最近梳理了一个 React + TypeScript 项目,里面同时存在:
- 自研后端 HTTP
- 自研后端 WebSocket
- 兼容保留的 topic hub WebSocket
- 第三方工具 HTTP
项目这次做的一个关键改造,是把"线上自研后端地址"从固定主机,改成了"前端根据浏览器当前主机自动拼接"。本文就基于这次真实代码实现,讲清楚 4 件事:
- 开发环境到底是谁在代理
/api - 生产环境为什么不能继续依赖 Vite 代理
- 前端如何在运行时拼出 HTTP / WS 地址
- 自研接口和第三方接口应该怎么分层管理
说明:文中的项目名、域名、IP、端口、Key、接口路径均已脱敏。
一、先说结论:这个项目不是"一个 baseURL 走天下"
这个项目里的接口链路分成 4 类:
- 自研后端 HTTP:设备详情、端口管理等业务请求
- 自研后端固定 WebSocket:首页监控、首页告警
- topic hub WebSocket:兼容保留的订阅式实时链路
- 第三方 HTTP:AI 类能力
对应的实现也不是一套代码:
- 自研 HTTP 有统一
fetch封装 - 自研 WS 有统一
realtimeClient - 第三方 HTTP 单独管理基地址和鉴权
也就是说,这不是一个"把 VITE_API_BASE_URL 配好就结束"的项目。
1. 自研 HTTP:统一封装,但不依赖 axios
项目里没有 axios.create,而是用了一个统一的 fetch 包装层,负责:
- 超时控制
- JSON 请求
- 通用响应解包
- mock / real 切换
业务层再通过 deviceApi、portBindingApi 发起请求。
2. 自研 WS:不是一条连接,而是三条链路
WebSocket 这块也不是组件里自己 new WebSocket(...),而是统一交给 realtimeClient 处理:
- 首页监控固定 WS
- 首页告警固定 WS
- topic hub WS
这层还统一处理了:
- 地址解析
- 订阅/取消订阅
- 自动重连
- 心跳与连接状态
- mock / real 切换
3. 第三方 HTTP:单独管理,不复用自研 HTTP Client
第三方接口没有复用自研 HTTP client,而是自己读取基地址和 API Key,直接发流式 fetch 请求。
这其实很合理,因为第三方服务和自研后端关注点完全不同:
- 自研后端更关注
/api、代理、部署和同源问题 - 第三方服务更关注鉴权、安全、频控和服务端代调
二、开发环境为什么能直接写 /api
答案很简单:因为开发环境帮你转发请求的,不是浏览器,也不是前端代码,而是 Vite dev server。
1. /api 只是开发期的代理入口
项目里的开发代理逻辑,本质上是这样:
ts
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
server: {
proxy: {
...(env.VITE_DEV_API_PROXY_TARGET
? {
"/api": {
target: env.VITE_DEV_API_PROXY_TARGET,
changeOrigin: true,
secure: false,
},
}
: {}),
"/third-party-api": {
target: "https://api.example.com/v1",
changeOrigin: true,
secure: true,
rewrite: (path) => path.replace(/^\/third-party-api/, ""),
},
},
},
};
});
这意味着开发环境里:
- 前端代码写的是
/api/... - 浏览器请求的是
http://dev-host:5173/api/... - 最终转发到真实后端的是 Vite
2. 但开发环境里的 WS 并不完全走代理
这个项目有一个很真实的细节:
- 自研 HTTP 开发态走
/api+ Vite 代理 - 首页监控/告警 WS 当前开发态走完整
ws://...地址直连 - topic hub WS 才有默认同源
/ws的逻辑
也就是说,开发环境下 HTTP 和 WS 的接入方式并不完全一致。
3. 开发环境请求流向
React 组件
业务 Service
/api/... 或 /third-party-api/...
Vite Dev Server
自研后端
第三方服务
三、生产环境为什么不能继续依赖 Vite 代理
因为生产环境根本没有 Vite dev server。
开发时 server.proxy 会生效,是因为页面和代理都跑在 Vite 开发服务器里;但打包后你部署的是静态资源,浏览器面对的是 Nginx、静态服务器或者容器,不再有 Vite 帮你转发。
所以到了生产环境,前端必须明确选择两种模式之一:
1. 同源路径模式
前端继续请求:
text
/api
/ws
/third-party-api
然后由 Nginx / 网关 / Ingress 去转发。
2. 前端直接拼绝对地址
前端在运行时自己拼出:
text
http://当前主机名:<BACKEND_PORT>/api
ws://当前主机名:<BACKEND_PORT>/...
然后浏览器直连目标主机端口。
当前这个项目,自研后端的最新改造,走的就是第二种思路。
3. 生产环境请求流向
构建后的前端代码
运行时地址解析
自研 HTTP -> 当前主机名 + 固定端口 + /api
自研 WS -> 当前主机名 + 固定端口 + 实时路径
第三方 HTTP -> /third-party-api 或绝对地址
浏览器直连自研后端
浏览器直连自研 WS
反向代理或第三方服务
四、这次改造到底改了什么
这次改造最核心的不是换 env 值,而是新增了一层"运行时地址解析器"。
改造前:
- 自研 HTTP 只有"固定地址"或
/api - 首页固定 WS 直接依赖固定
ws://host:port/... - topic hub 才有默认同源
/ws
改造后:
- 自研 HTTP 统一走
resolveHttpBaseUrl - 自研固定 WS 和 topic hub 统一走
resolveWsUrl - HTTP / WS 地址策略终于开始统一
改造前后对比
改造后
统一运行时地址解析层
绝对地址模式
动态主机 + 固定端口模式
同源路径模式
改造前
HTTP
固定地址或 /api
固定 WS
固定 ws://host:port/path
topic hub
默认 /ws
1. 动态拼接用到了哪些浏览器 API
这次改造里主要用到的是:
window.location.protocolwindow.location.hostnamewindow.location.host
注意这里没有用 location.origin。
2. 为什么这里用 hostname,不是 host
这是个很关键的实现细节。
如果要拼固定后端端口,就应该优先用:
ts
window.location.hostname
因为它不带当前页面端口。
而:
ts
window.location.host
会带当前页面端口,只适合"直接复用当前 host"的同源场景。
3. HTTP / WS 地址解析的核心思路
脱敏后的代码可以抽象成下面这样:
ts
function normalizePath(path: string) {
return path.startsWith("/") ? path : `/${path}`;
}
function stripColon(protocol: string) {
return protocol.endsWith(":") ? protocol.slice(0, -1) : protocol;
}
export function resolveHttpBaseUrl(rawValue?: string, fallbackPath = "/api") {
const value = rawValue?.trim() ?? "";
if (/^https?:\/\//i.test(value)) {
return value;
}
const path = normalizePath(value || fallbackPath);
const port = import.meta.env.VITE_PUBLIC_BACKEND_PORT?.trim();
if (!port) {
return path;
}
const protocol =
import.meta.env.VITE_PUBLIC_BACKEND_PROTOCOL?.trim() ||
stripColon(window.location.protocol);
return `${stripColon(protocol)}://${window.location.hostname}:${port}${path}`;
}
export function resolveWsUrl(rawValue?: string, fallbackPath = "/ws") {
const value = rawValue?.trim() ?? "";
if (/^wss?:\/\//i.test(value)) {
return value;
}
if (/^https?:\/\//i.test(value)) {
return value.replace(/^http/i, "ws");
}
const path = normalizePath(value || fallbackPath);
const port = import.meta.env.VITE_PUBLIC_BACKEND_PORT?.trim();
if (!port) {
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
return `${wsProtocol}://${window.location.host}${path}`;
}
const wsProtocol =
import.meta.env.VITE_PUBLIC_BACKEND_WS_PROTOCOL?.trim() ||
(window.location.protocol === "https:" ? "wss" : "ws");
return `${stripColon(wsProtocol)}://${window.location.hostname}:${port}${path}`;
}
4. 它实际上提供了 3 种模式
固定地址模式
如果 env 里写的是绝对地址,直接用。
动态主机 + 固定端口模式
如果 env 里给的是路径,同时配置了 VITE_PUBLIC_BACKEND_PORT,就拼成:
text
当前浏览器主机名 + 固定后端端口 + 路径
同源路径模式
如果没配固定端口:
- HTTP 保持
/api - WS 保持
ws(s)://当前host/...
五、这套设计怎么落到业务代码里
这次改造之后,业务层反而更简单了。
1. HTTP 统一封装只负责请求规则
ts
const DEFAULT_BASE_URL = resolveHttpBaseUrl(
import.meta.env.VITE_API_BASE_URL,
"/api",
);
class HttpClient {
constructor(
private readonly baseURL = DEFAULT_BASE_URL,
private readonly timeoutMs = 15000,
) {}
async request<T>(config: {
url: string;
method?: "GET" | "POST" | "PUT" | "DELETE";
body?: unknown;
}): Promise<T> {
const response = await fetch(this.normalizeUrl(config.url), {
method: config.method ?? "GET",
headers: {
"Content-Type": "application/json",
},
body: config.body ? JSON.stringify(config.body) : undefined,
});
const json = await response.json();
if (json && typeof json === "object" && "code" in json && "data" in json) {
if (json.code !== 0) {
throw new Error(json.message || "Request failed");
}
return json.data as T;
}
return json as T;
}
private normalizeUrl(url: string) {
if (/^https?:\/\//i.test(url)) return url;
if (url.startsWith("/")) return `${this.baseURL}${url.replace(/^\/api/, "")}`;
return `${this.baseURL}/${url}`;
}
}
业务层就只需要保留接口语义:
ts
export const deviceApi = {
getOverview(deviceId: string) {
return httpClient.request({
url: `/devices/example/overview?deviceId=${encodeURIComponent(deviceId)}`,
method: "POST",
});
},
};
2. WebSocket 统一封装只负责连接策略
ts
const MONITORING_WS_URL = resolveWsUrl(
import.meta.env.VITE_REALTIME_DEVICE_LIST_WS_URL,
"/api/devices/realtime/list",
);
const ALARM_WS_URL = resolveWsUrl(
import.meta.env.VITE_REALTIME_ALARM_LIST_WS_URL,
"/api/alarms/realtime/list",
);
function defaultTopicWsUrl() {
return resolveWsUrl(import.meta.env.VITE_WS_URL, "/ws");
}
这样之后,监控 WS、告警 WS、topic hub WS 都遵循同一套优先级:
text
绝对地址 > 路径 + 固定端口动态拼接 > 同源默认值
六、实战里最容易踩的坑
1. 开发环境和生产环境不是一套机制
开发环境能用 /api,是因为 Vite 在代理。
生产环境还能不能用 /api,取决于你的 Nginx / 网关是否继续转发。
2. 动态端口模式不是万能的
如果浏览器根本访问不到:
text
当前主机名:<BACKEND_PORT>
那即使前端拼得再对,也会失败。
这种部署方式下,更适合走"同源路径 + 网关转发"。
3. host / hostname / 协议映射很容易写错
要点很简单:
- 拼固定端口时优先用
hostname - 同源复用时再用
host https默认映射wsshttp默认映射ws
4. 第三方接口不要和自研后端混成一套
自研后端要解决的是代理、部署、同源和 WS 协同。
第三方服务要解决的是鉴权、安全和代调边界。
两者可以有统一规范,但最好不要强行共用一套所有策略。
5. 不要把高权限第三方 Key 直接打进前端产物
这是这次梳理里最需要警惕的一点。
更合理的做法通常是:
- 前端调你方后端
- 由你方后端代调第三方服务
- 或由后端下发短时票据
七、最后总结成 3 条经验
1. 把"开发代理"和"生产地址"分开理解
开发环境的 /api 很多时候只是联调入口,不是最终部署方案。
2. 把"地址解析"从业务代码里抽出去
不要让 HTTP、固定 WS、topic hub 各写一套地址拼接逻辑。
真正可维护的做法,是抽成统一运行时层。
3. 把"前端负责什么、网关负责什么"写进文档
一套长期可维护的接口治理方案,至少要讲清楚:
- 开发代理是谁做的
- 生产环境为什么不能依赖 Vite 代理
- 什么场景下前端自己拼地址
- 什么场景下由 Nginx / 网关转发
- HTTP / WS / 第三方接口是否同策略
小结
前端接口配置治理,真正难的不是写一个 baseURL,而是同时处理好:
- 开发和生产
- HTTP 和 WebSocket
- 自研后端和第三方服务
- 构建期和运行期
这个项目这次改造最值得借鉴的一点,就是把"自研后端地址如何确定"从固定主机,演进成了统一的运行时解析逻辑。
如果你也在维护 React + Vite + TypeScript 项目,我建议优先把接口配置拆成 3 层来看:
- 开发代理层
- 运行时地址解析层
- 部署协同层
只要这 3 层讲清楚,接口配置就不会总在上线后出问题。