Web 项目的开发/生产环境请求接口配置治理实战

摘要

在真实前端项目里,接口配置最容易出问题的地方,不是 baseURL 怎么写,而是开发环境和生产环境的行为根本不是一回事。

我最近梳理了一个 React + TypeScript 项目,里面同时存在:

  • 自研后端 HTTP
  • 自研后端 WebSocket
  • 兼容保留的 topic hub WebSocket
  • 第三方工具 HTTP

项目这次做的一个关键改造,是把"线上自研后端地址"从固定主机,改成了"前端根据浏览器当前主机自动拼接"。本文就基于这次真实代码实现,讲清楚 4 件事:

  1. 开发环境到底是谁在代理 /api
  2. 生产环境为什么不能继续依赖 Vite 代理
  3. 前端如何在运行时拼出 HTTP / WS 地址
  4. 自研接口和第三方接口应该怎么分层管理

说明:文中的项目名、域名、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 切换

业务层再通过 deviceApiportBindingApi 发起请求。

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.protocol
  • window.location.hostname
  • window.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 默认映射 wss
  • http 默认映射 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 层来看:

  1. 开发代理层
  2. 运行时地址解析层
  3. 部署协同层

只要这 3 层讲清楚,接口配置就不会总在上线后出问题。

相关推荐
Можно2 小时前
深入理解 UniApp 生命周期钩子:从页面到组件的全流程掌控
前端·javascript·vue.js
橙色日落2 小时前
Vue2 + LogicFlow 实现可视化流程图编辑功能+常用属性大全
前端·vue·流程图·logicflow
NaMM CHIN2 小时前
Spring boot整合quartz方法
java·前端·spring boot
西洼工作室2 小时前
react 地图找房模块
前端·react.js·前端框架
低保和光头哪个先来2 小时前
Axios 近期安全版本
开发语言·前端·javascript·前端框架
han_2 小时前
JavaScript设计模式(八):命令模式实现与应用
前端·javascript·设计模式
AlunYegeer2 小时前
黑马头条踩坑总结:频道状态筛选前端联调失效问题
java·前端
蜡台2 小时前
浙政钉(浙里办小程序) H5 二次回退问题修复方案
前端·小程序·浙政钉·浙里办
踩着两条虫2 小时前
揭秘VTJ.PRO前端架构:一套代码,多端运行的低代码引擎
前端·vue.js·低代码