前端运行时动态环境变量方案

前端运行时动态环境变量方案(详细笔记)

目标读者:第一次接触「运行时注入环境变量」的前端/运维。

适用栈:任意 Vite 框架 (React / Vue / Svelte / Solid 等)+ Nginx + Docker。本项目用 React,但本方案不依赖框架------只用到 Vite 通用约定(import.meta.env.VITE_*public/ 原样拷贝、index.html 脚本顺序),换 Vue 同样照用。K8s 同理可推广。


一、要解决什么问题

1.1 痛点

普通 Vite 项目里,import.meta.env.VITE_API_BASE_URL 这类变量是在 pnpm build 打包时 就被静态替换成字面量、写死进 JS 产物的。

举例,源码:

ts 复制代码
const api = import.meta.env.VITE_API_BASE_URL;

打包后产物里直接变成:

js 复制代码
const api = "http://192.168.1.198:6543";

后果 :一旦打包完成,想换后端地址(开发→测试→生产,或后端 IP 变更)就必须 重新 build。多环境部署时这非常笨重------同一份代码要为每个环境各打一次包。

1.2 目标

一次构建,多环境复用 :打出来的是同一份产物,部署到哪个环境,由那个环境的「运行时配置」决定它连哪个后端。改配置 = 改一个文本文件 + 重启容器,不重新打包前端


二、核心思路(一句话)

把环境变量从「构建时写死进代码」改成「容器启动那一刻,从环境变量生成一个 JS 文件,挂到 window 上,前端运行时去读」。

具体拆成两条注入链路:

链路 面向谁 变量 注入方式
A 浏览器/前端代码 VITE_* 启动脚本生成 globalConfig.js,挂 window.globalConfig
B Nginx 反向代理 API_TARGET nginx 官方 entrypoint 用 envsubst 渲染模板

链路 A 让前端知道「调哪个接口前缀」;链路 B 让 nginx 知道「把这个前缀转发到哪个真实后端」。两者配合实现「浏览器同源访问、nginx 背后转发」。

三、代码实现(链路 A:前端 VITE_* 变量)

整条链路涉及 4 处文件,按「数据流」顺序讲。

3.1 占位文件 public/script/globalConfig.js

放在 public/ 下,Vite 构建时会原样拷到产物根目录。初始内容是空对象

js 复制代码
// 运行时环境变量占位文件。
// 本地开发:保持空对象即可,代码会自动回退到 .env / import.meta.env。
// 容器部署:entrypoint 脚本会在启动时根据环境变量重新生成本文件内容,
//           因此修改 env 后重启容器即可生效,无需重新构建前端。
window.globalConfig = {};
  • 本地开发 :它是空的 → 前端读不到 → 自动回退到 .env(见 3.3)。
  • 容器部署 :启动脚本会用真实环境变量 覆盖重写 这个文件。

3.2 在 index.html 里先于入口模块加载

html 复制代码
<head>
  <meta charset="UTF-8" />
  <title>联核科技</title>
  <!-- 运行时环境变量:必须在入口模块之前加载,使 window.globalConfig 先就绪 -->
  <script src="/script/globalConfig.js"></script>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>

关键点(容易踩坑)

  • globalConfig.js普通 <script> (同步、阻塞),放在 <head>
  • 入口 main.tsxtype="module",模块脚本默认 defer,在 HTML 解析完后才执行。
  • 所以浏览器一定先执行 globalConfig.js、挂好 window.globalConfig之后 才执行 React 入口代码。读取顺序天然安全,无需额外等待逻辑。

3.3 统一读取工具 src/utils/env.ts

前端所有地方都通过 getEnv() 取变量,不再直接用 import.meta.env。读取优先级:运行时 > 构建时 > 默认值。

ts 复制代码
declare global {
  interface Window {
    globalConfig?: Record<string, string>;
  }
}

export function getEnv(name: string, defaultValue = ""): string {
  // 1. 运行时注入(window.globalConfig)优先
  if (
    typeof window !== "undefined" &&
    window.globalConfig &&
    window.globalConfig[name] != null &&
    window.globalConfig[name] !== ""
  ) {
    return String(window.globalConfig[name]);
  }

  // 2. 回退到构建时变量(Vite 仅暴露 VITE_ 前缀)
  const buildTimeValue = (import.meta.env as Record<string, unknown>)[name];
  if (buildTimeValue != null && buildTimeValue !== "") {
    return String(buildTimeValue);
  }

  // 3. 默认值
  return defaultValue;
}

// 便捷代理:env.VITE_API_BASE_URL 等价于 getEnv("VITE_API_BASE_URL")
export const env = new Proxy({} as Record<string, string>, {
  get: (_target, prop: string) => getEnv(String(prop))
});

三级回退的意义

  1. 运行时(生产) :容器里 globalConfig.js 有值 → 用它。这是动态能力的来源。
  2. 构建时(本地开发)globalConfig 为空 → 回退 import.meta.env,即读本地 .env。开发体验不变。
  3. 默认值 :兜底,避免 undefined

3.4 业务代码改为 getEnv()

把原本散落各处的 import.meta.env.VITE_XXX 全换成 getEnv("VITE_XXX", 默认值)

通用写法(任何项目都适用):

ts 复制代码
// 改前
const base = import.meta.env.VITE_API_BASE_URL;
// 改后
import { getEnv } from "@/utils/env";
const base = getEnv("VITE_API_BASE_URL", "/api");

下面是本项目三个实际消费点的真实代码(仅作示例,移植时换成你自己项目的变量)。

(a) src/services/http/client.ts ------ HTTP 客户端的 baseURL、鉴权头:

ts 复制代码
import { getEnv } from "@/utils/env";

// 扩展 axios 请求配置:skipAuth 用于登录前的 OAuth 接口,跳过 token 校验与 401 自动跳转
declare module "axios" {
  export interface AxiosRequestConfig {
    skipAuth?: boolean;
  }
}

const defaultApiBaseUrl = "/api";
const defaultAuthHeader = "Authorization";
const defaultTokenPrefix = "Bearer";

const authHeaderName = getEnv("VITE_AUTH_HEADER", defaultAuthHeader);
const authTokenPrefix = getEnv("VITE_AUTH_TOKEN_PREFIX", defaultTokenPrefix);
export const apiBaseUrl = getEnv("VITE_API_BASE_URL", defaultApiBaseUrl);

export const http = createHttpClient(apiBaseUrl);

// 请求拦截器:登录前的 OAuth 接口跳过 token 注入
//   if (config.skipAuth) return config;
// 响应拦截器:OAuth 接口的 401 不触发自动跳转登录
//   if (error.config?.skipAuth) return Promise.reject(error);

(b) src/utils/oauth.ts ------ OAuth 接口 baseURL:

ts 复制代码
import { getEnv } from "@/utils/env";

const API_BASE = getEnv("VITE_AUTH_API_BASE_URL", "/api");

// OAuth 走登录前流程,复用同一个 http 实例但用 baseURL 覆盖、并跳过鉴权
const authRequestConfig = { baseURL: API_BASE, skipAuth: true } as const;

© src/components/CopilotChatPanel/_utils/runtime-config.ts ------ CopilotKit 配置:

ts 复制代码
import { getEnv } from "@/utils/env";

export function getCopilotRuntimeConfig(): CopilotRuntimeConfig {
  const runtimeUrl = normalizeEnvValue(getEnv("VITE_COPILOTKIT_RUNTIME_URL"));
  const publicApiKey = normalizeEnvValue(getEnv("VITE_COPILOTKIT_PUBLIC_API_KEY"));
  const agent = normalizeEnvValue(getEnv("VITE_COPILOTKIT_AGENT")) ?? "main";

  return {
    runtimeUrl,
    publicApiKey,
    agent,
    enabled: Boolean(runtimeUrl || publicApiKey),
    showDevConsole: getEnv("VITE_COPILOTKIT_SHOW_DEV_CONSOLE") === "true"
  };
}

// 模块加载时计算一次(此时 window.globalConfig 已就绪,见 3.2)
export const copilotRuntimeConfig = getCopilotRuntimeConfig();

四、容器启动脚本(链路 A 的注入器)deploy/docker-entrypoint.sh

这是「动态」的发动机:容器每次启动,它扫描容器内所有 VITE_* 环境变量,重新生成 globalConfig.js,覆盖掉那个空占位文件。

bash 复制代码
#!/bin/bash
# 运行时配置脚本(由 nginx 官方 entrypoint 在启动前自动执行)
# 职责:把容器环境变量中所有 VITE_ 前缀的变量,写入 window.globalConfig,
#       生成 /usr/share/nginx/html/script/globalConfig.js。
set -e

CONFIG_FILE="/usr/share/nginx/html/script/globalConfig.js"

echo "==> [runtime-config] 生成运行时环境变量: $CONFIG_FILE"
mkdir -p "$(dirname "$CONFIG_FILE")"

# 收集所有 VITE_ 前缀的环境变量
ENV_BLOCK=""
while IFS='=' read -r name value; do
  case "$name" in
    VITE_*)
      # 转义反斜杠和双引号,避免破坏 JS 字符串
      esc=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
      ENV_BLOCK="${ENV_BLOCK}  \"${name}\": \"${esc}\",
"
      ;;
  esac
done < <(printenv)

if [ -z "$ENV_BLOCK" ]; then
  ENV_BLOCK="  // 未检测到 VITE_ 前缀环境变量"
fi

cat > "$CONFIG_FILE" <<EOF
// 本文件由容器启动脚本自动生成,请勿手动修改。
window.globalConfig = {
${ENV_BLOCK}
};
EOF

echo "==> [runtime-config] 生成完成:"
cat "$CONFIG_FILE"

要点

  • 放进镜像的 /docker-entrypoint.d/40-runtime-config.sh。nginx 官方镜像启动时会 按文件名顺序 执行 /docker-entrypoint.d/*.sh,所以编号 40- 决定执行时机。

  • 只挑 VITE_ 前缀变量写进前端,其它(如 API_TARGET)不会泄露到浏览器。

  • sed 转义反斜杠和双引号,防止变量值破坏 JS 字符串。

  • 生成示例(容器内实际产物):

    js 复制代码
    window.globalConfig = {
      "VITE_API_BASE_URL": "/api",
      "VITE_COPILOTKIT_RUNTIME_URL": "/api/copilotkit",
    };

五、Nginx 配置(链路 B:反代目标动态化)

链路 B 解决「nginx 把 /api 转发到哪个真实后端」。用 nginx 官方镜像自带的 envsubst 模板 机制:把配置写成 .template,启动时用环境变量渲染。

5.1 主配置 deploy/nginx.conf

nginx 复制代码
user  nginx;
worker_processes  auto;
pid        /var/run/nginx.pid;
error_log  /var/log/nginx/error.log warn;

events {
  worker_connections  1024;
}

http {
  include       /etc/nginx/mime.types;
  default_type  application/octet-stream;

  server_tokens off;
  sendfile      on;
  tcp_nopush    on;
  tcp_nodelay   on;
  keepalive_timeout  65;
  client_max_body_size 1024m;

  # gzip
  gzip on;
  gzip_vary on;
  gzip_proxied any;
  gzip_comp_level 6;
  gzip_min_length 1024;
  gzip_types
    text/plain text/css text/javascript
    application/javascript application/json application/xml
    image/svg+xml font/ttf font/otf;

  # WebSocket 升级支持(CopilotKit 流式 / SSE 可能需要)
  map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
  }

  access_log /var/log/nginx/access.log;

  # 站点配置由 templates/*.template 经 envsubst 渲染生成
  include /etc/nginx/conf.d/*.conf;
}

5.2 站点模板 deploy/default.conf.template

文件名带 .template,nginx 官方 entrypoint 的 20-envsubst-on-templates.sh 会把它里面的 ${VAR} 用环境变量渲染,输出到 /etc/nginx/conf.d/default.conf

nginx 复制代码
# 仅以下占位会被替换(它们是容器环境变量):
#   ${API_PREFIX} ${API_TARGET} ${COPILOTKIT_PREFIX} ${COPILOTKIT_TARGET}
# nginx 内置变量($host、$uri、$connection_upgrade 等)不是环境变量,保持原样。

server {
  listen       80;
  listen  [::]:80;
  server_name  _;
  root   /usr/share/nginx/html;
  index  index.html;

  # 运行时环境变量文件:禁止缓存(保证改 env 后浏览器拿到新配置)
  location = /script/globalConfig.js {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    add_header Pragma "no-cache";
    expires -1;
  }

  # CopilotKit Runtime 代理(更长前缀,必须写在前面优先匹配)
  location ${COPILOTKIT_PREFIX}/ {
    proxy_pass ${COPILOTKIT_TARGET};
    proxy_http_version 1.1;
    proxy_set_header Upgrade            $http_upgrade;
    proxy_set_header Connection         $connection_upgrade;
    proxy_set_header Host               $host;
    proxy_set_header X-Real-IP          $remote_addr;
    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto  $scheme;
    proxy_buffering off;            # SSE / 流式响应需要关闭缓冲
    proxy_read_timeout 300s;
    proxy_connect_timeout 300s;
  }

  # 业务后端 API 代理
  location ${API_PREFIX}/ {
    proxy_pass ${API_TARGET};
    proxy_http_version 1.1;
    proxy_set_header Upgrade            $http_upgrade;
    proxy_set_header Connection         $connection_upgrade;
    proxy_set_header Host               $host;
    proxy_set_header X-Real-IP          $remote_addr;
    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto  $scheme;
    proxy_read_timeout 300s;
    proxy_connect_timeout 300s;
  }

  # 静态资源:长期缓存
  location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|otf|eot|mp4|webm)$ {
    expires 30d;
    add_header Cache-Control "public, immutable";
    try_files $uri =404;
  }

  # SPA 路由回退
  location / {
    try_files $uri $uri/ /index.html;
    add_header Cache-Control "no-cache";
  }

  error_page 500 502 503 504 /50x.html;
  location = /50x.html { root /usr/share/nginx/html; }
}

坑位提醒

  • ${API_PREFIX} 等会被 envsubst 替换;$host/$uri/$connection_upgrade 是 nginx 内置变量,不是环境变量,envsubst 默认只替换「已定义的环境变量名」,所以它们安然无恙。
  • 更长的前缀(/api/copilotkit)的 location 必须写在 /api 之前 ,否则会被 /api 抢先匹配。
  • globalConfig.js 必须设 no-cache,否则改了 env 浏览器还吃旧缓存。

六、Dockerfile(copy-only 模式)apps/web/Dockerfile

本项目流水线已单独跑 build 产出 dist,所以镜像 不在内部构建,只把产物拷进 nginx。若你的项目想在镜像里构建,见 6.2。

6.1 本项目用的 copy-only

dockerfile 复制代码
# syntax=docker/dockerfile:1
# 前提:构建上下文为仓库根目录,且 apps/web/dist 已由流水线 build 生成。
FROM nginx:1.27-alpine

# bash 供 entrypoint 使用;envsubst 由 nginx 官方镜像自带(gettext)
RUN apk add --no-cache bash

# 前端产物(vite outDir 为 dist)
COPY apps/web/dist /usr/share/nginx/html

# nginx 配置:主配置 + 站点模板(启动时 envsubst 渲染)
COPY apps/web/deploy/nginx.conf /etc/nginx/nginx.conf
COPY apps/web/deploy/default.conf.template /etc/nginx/templates/default.conf.template

# 运行时入口脚本:放进 /docker-entrypoint.d,nginx 官方 entrypoint 会自动执行
COPY apps/web/deploy/docker-entrypoint.sh /docker-entrypoint.d/40-runtime-config.sh
RUN sed -i 's/\r$//' /docker-entrypoint.d/40-runtime-config.sh \
    && chmod +x /docker-entrypoint.d/40-runtime-config.sh

EXPOSE 80

# 官方 entrypoint 依次执行 /docker-entrypoint.d/*.sh:
#   20-envsubst-on-templates.sh 渲染 templates/*.template -> conf.d(链路 B)
#   40-runtime-config.sh 生成 window.globalConfig(链路 A)
CMD ["nginx", "-g", "daemon off;"]

关键点

  • templates/*.template 是官方约定目录,放进去就会被自动 envsubst。
  • entrypoint 脚本放 /docker-entrypoint.d/,编号 40- 排在 20-envsubst 之后。
  • sed -i 's/\r$//':去掉 Windows 换行符 \r,否则 Linux 下 bash 执行报错。
  • nginx:alpine,需 apk add bash(脚本是 bash 写的)。

6.2 替代:镜像内构建(多阶段,通用项目可用)

如果不想在 CI 单独构建,可用多阶段在镜像里 build:

dockerfile 复制代码
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY . .
RUN pnpm install --frozen-lockfile && pnpm --filter @apps/web build:ci

FROM nginx:1.27-alpine
RUN apk add --no-cache bash
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
COPY apps/web/deploy/nginx.conf /etc/nginx/nginx.conf
COPY apps/web/deploy/default.conf.template /etc/nginx/templates/default.conf.template
COPY apps/web/deploy/docker-entrypoint.sh /docker-entrypoint.d/40-runtime-config.sh
RUN sed -i 's/\r$//' /docker-entrypoint.d/40-runtime-config.sh \
    && chmod +x /docker-entrypoint.d/40-runtime-config.sh
CMD ["nginx", "-g", "daemon off;"]

七、docker-compose 编排与环境变量文件

docker-compose.yml 把镜像、端口、环境变量文件串起来,是「本地/手动部署」的入口。

7.1 deploy/docker-compose.yml

yaml 复制代码
services:
  app-web:                      # 服务名,移植时随意改
    build:
      context: ../../..         # 构建上下文 = 仓库根(本项目是 monorepo,故回退三级)
      dockerfile: apps/web/Dockerfile   # Dockerfile 不在上下文根时要显式指定
    image: app-web:latest
    container_name: app-web
    restart: unless-stopped
    ports:
      - "${WEB_PORT:-8080}:80"  # 宿主端口:容器端口;${WEB_PORT} 见下方说明
    env_file:
      - .env.runtime            # 把该文件里所有变量整体注入容器环境
    extra_hosts:
      - "host.docker.internal:host-gateway"   # 容器内访问宿主机服务用

两种变量注入,别混淆

机制 写法 何时被读取 作用对象
env_file env_file: [.env.runtime] 容器启动时 注入进容器环境 entrypoint 脚本(生成 globalConfig.js、envsubst 渲染 nginx)
${VAR} 插值 "${WEB_PORT:-8080}:80" compose 解析 yaml 时(在宿主机) compose 文件自身的字段

关键坑ports: 里的 ${WEB_PORT} 是 compose 解析阶段 的插值,发生在宿主机、早于容器启动,它读不到 env_file 里的值 。要让它生效,必须用 --env-file 显式指定同一个文件:

bash 复制代码
docker compose --env-file .env.runtime -f docker-compose.yml up -d

--env-file 同时喂给「compose 解析插值」和「容器 env_file」,两层都拿到值。只写 env_file: 而不加 --env-file,端口会回退到默认 8080(因为有 :-8080),其它容器内变量仍正常。

7.2 deploy/.env.deploy.example

模板文件,复制成 .env.runtime(已 gitignore,不会误提交)后按环境改。

ini 复制代码
# 宿主机对外端口
WEB_PORT=8080

# ===== 浏览器可见(写入 window.globalConfig)=====
# 前端经 nginx 同源代理访问后端,保持前缀即可,无需填后端真实地址
VITE_API_BASE_URL=/api
VITE_AUTH_API_BASE_URL=/api
VITE_COPILOTKIT_RUNTIME_URL=/api/copilotkit
VITE_COPILOTKIT_PUBLIC_API_KEY=
VITE_COPILOTKIT_AGENT=runtime-agent
VITE_COPILOTKIT_SHOW_DEV_CONSOLE=false

# ===== 仅 nginx 反代使用(不暴露给浏览器)=====
# 业务后端:浏览器请求 /api/* -> 转发到 API_TARGET(保留 /api 前缀)
API_PREFIX=/api
API_TARGET=http://192.168.1.198:6543/

# CopilotKit Runtime:浏览器请求 /api/copilotkit/* -> 转发到 COPILOTKIT_TARGET
COPILOTKIT_PREFIX=/api/copilotkit
COPILOTKIT_TARGET=http://192.168.1.198:3000/

上方 VITE_COPILOTKIT_* 是本项目的业务变量,移植时换成你自己的。规律:浏览器要读的填 VITE_ 前缀;只给 nginx 反代用的不加前缀(避免泄露真实后端地址到浏览器)。

八、使用方法(部署与运维)

8.1 首次部署,三步

bash 复制代码
# ① 构建前端产物(仓库根执行,只需一次)。本项目是 monorepo,故用 --filter;
#   单包项目直接 pnpm build:ci 即可。
pnpm --filter @apps/web build:ci

# ② 从示例复制出真正使用的 env 文件,再按本环境改
cp apps/web/deploy/.env.deploy.example apps/web/deploy/.env.runtime

# ③ 起容器(注意 --env-file,见 7.1)
docker compose --env-file apps/web/deploy/.env.runtime \
  -f apps/web/deploy/docker-compose.yml up -d --build

第 ② 步后编辑 .env.runtime,把后端地址改成本环境真实值:

ini 复制代码
WEB_PORT=8080                              # 浏览器访问端口 -> http://服务器IP:8080
API_TARGET=http://10.0.0.5:6543/           # 真实业务后端地址
COPILOTKIT_TARGET=http://10.0.0.5:3000/    # 真实 CopilotKit 后端地址
# VITE_* 一般保持 /api 前缀不动(前端经 nginx 同源代理访问后端)

打开 http://服务器IP:8080 即可访问。

8.2 以后改环境变量(重点:不重新打包前端)

后端 IP 变了,或要加一个 VITE_* 配置:

bash 复制代码
# 1. 编辑 .env.runtime,改/加变量
# 2. 重跑(注意没有 --build)
docker compose --env-file apps/web/deploy/.env.runtime \
  -f apps/web/deploy/docker-compose.yml up -d

容器重启 → 重新读 .env.runtime → entrypoint 重新生成 globalConfig.js + 重新 envsubst 渲染 nginx → 新值即时生效。几十秒完成,不碰前端代码、不重新 build

新增 VITE_* 只需写进 .env.runtime,无需改 docker-compose.yml(compose 用 env_file 整体注入容器,entrypoint 用 printenv 自动扫描所有 VITE_*)。

8.3 常用运维命令

bash 复制代码
# 看日志(含启动时生成的 globalConfig.js 内容)
docker compose -f apps/web/deploy/docker-compose.yml logs -f

# 进容器看实际生成的前端配置
docker exec app-web cat /usr/share/nginx/html/script/globalConfig.js

# 看 nginx 实际渲染出的站点配置(验证 envsubst 是否替换成功)
docker exec app-web cat /etc/nginx/conf.d/default.conf

# 停止 / 删除容器
docker compose -f apps/web/deploy/docker-compose.yml down

九、移植到其他项目(通用 checklist)

这套机制不依赖本项目,任何 Vite + 静态部署 + nginx 的项目都能用。下面按「前端代码」「部署文件」「项目差异调整」三块走。

9.1 前端代码(4 步)

  1. 加读取工具 src/utils/env.ts:直接抄第 3.3 节整段(与项目无关,零改动)。

  2. 加占位文件 public/script/globalConfig.js:内容就一行 window.globalConfig = {};(第 3.1 节)。public/ 下的文件 Vite 会原样拷进产物根。

  3. 改 index.html :在 <head> 入口模块 之前

    html 复制代码
    <script src="/script/globalConfig.js"></script>

    注意是普通 <script>(同步阻塞),不能加 type="module",否则会变成 defer、晚于读取它的代码(第 3.2 节)。入口模块 React 项目是 /src/main.tsx、Vue 项目是 /src/main.ts,占位脚本写在它之前即可。

  4. 替换业务读取 :把所有 import.meta.env.VITE_XXX 换成 getEnv("VITE_XXX", 默认值)(第 3.4 节)。搜一下全局 import.meta.env 逐个换即可。

做完这 4 步,本地开发体验不变(globalConfig 为空 → 自动回退 .env),但产物已具备「运行时可改」的能力。

9.2 部署文件(5 个,基本照抄)

放在项目里任意目录(本项目放 deploy/):

文件 改动量 说明
docker-entrypoint.sh 零改动 自动扫描所有 VITE_*,与项目无关(第四节)
nginx.conf 几乎零改动 通用主配置(第 5.1 节)
default.conf.template 按你的反代需求改 有几个后端就写几个 location(第 5.2 节)
Dockerfile 改路径 见 9.3
.env.deploy.example 按你的变量改 列出你项目的 VITE_* 和反代目标
docker-compose.yml 改路径/服务名 见 9.3

9.3 项目差异点(移植时必须改的地方)

本文档示例来自一个 pnpm monorepo,单包项目要把下面这些「本项目特化」的地方换掉:

本项目写法(monorepo) 单包项目改成 出现位置
pnpm --filter @apps/web build:ci pnpm build:ci(或 npm run build 构建命令
compose context: ../../.. context: .(Dockerfile 在根时) docker-compose.yml
COPY apps/web/dist ... COPY dist ... Dockerfile
dockerfilePath: apps/web/Dockerfile Dockerfile(在根时) CI 配置
产物目录 apps/web/dist dist 各处路径

反代 location 的通用规律 (改 default.conf.template 时):

  • 每个要代理的后端 = 一个 location ${PREFIX}/ { proxy_pass ${TARGET}; ... }
  • 更长的前缀写在前面 (如 /api/copilotkit 必须在 /api 之前),否则被短前缀抢匹配。
  • 流式/SSE/WebSocket 的 location 要 proxy_buffering off + 长 proxy_read_timeout
  • PREFIX/TARGET 这些占位会被 envsubst 替换;$host/$uri 等 nginx 内置变量不是环境变量,保持原样。

变量命名规律 (改 .env.deploy.example 时):

  • 浏览器要读的 → 加 VITE_ 前缀(会被写进 globalConfig.js,对前端可见)。
  • 只给 nginx 反代用的真实后端地址 → 不要VITE_ 前缀(避免泄露到浏览器)。

十、FAQ / 常见坑

Q1:改了 env、重启了容器,浏览器还是旧值?

缓存。globalConfig.js 必须在 nginx 里设 no-cache(第 5.2 节那段 location = /script/globalConfig.js),否则浏览器吃旧缓存。确认无误后强刷(Ctrl+Shift+R)。

Q2:window.globalConfig 是 undefined / 读不到?

检查 index.htmlglobalConfig.js<script> 是不是放在入口模块之前、且没误加 type="module"。模块脚本默认 defer,会晚于读取它的同步代码。

Q3:nginx 启动报错,${API_TARGET} 没被替换?

两种可能:① 模板文件没放进 /etc/nginx/templates/ 且文件名以 .template 结尾;② 启动时该环境变量没注入(检查 --env-file 是否带上、变量名是否拼对)。用 docker exec ... cat /etc/nginx/conf.d/default.conf 看渲染结果。

Q4:entrypoint 脚本 Linux 下报 bad interpreter 或语法错误?

Windows 换行符 \r。Dockerfile 里已 sed -i 's/\r$//' 处理;移植时别漏。另外 nginx:alpine 默认无 bash,脚本是 bash 写的需 apk add --no-cache bash

Q5:端口 ${WEB_PORT} 没生效,总是 8080?

ports:${WEB_PORT} 是 compose 解析阶段插值,env_file 喂不到它。必须用 --env-file 显式指定(第 7.1 节)。

Q6:VITE_ 之外的变量也想给前端读?

不建议------非 VITE_ 前缀不会写进 globalConfig.js,这是刻意的安全边界(防止真实后端地址等泄露到浏览器)。前端要读就老老实实加 VITE_ 前缀。

Q7:本地 pnpm dev 要不要配这套?

不用。本地 globalConfig 为空,getEnv() 自动回退到 .envimport.meta.env,开发体验和原来完全一样。这套机制只在容器部署时发挥作用。


附:完整文件清单

移植一份,对照下表确认齐全:

文件 角色
src/utils/env.ts 统一读取(运行时>构建时>默认) 3.3
public/script/globalConfig.js 空占位,运行时被覆盖 3.1
index.html 先于入口加载占位脚本 3.2
deploy/docker-entrypoint.sh 启动时扫 VITE_* 生成 globalConfig.js
deploy/nginx.conf nginx 主配置 5.1
deploy/default.conf.template 站点模板,envsubst 渲染反代 5.2
Dockerfile 打包产物 + 配置进 nginx 镜像
deploy/docker-compose.yml 编排,env_file 注入 7.1
deploy/.env.deploy.example 变量模板,复制成 .env.runtime 7.2

一句话总结:前端只构建一次;环境变量不写死进代码,而是容器启动那一刻从文本文件读进 window.globalConfig(前端)和 nginx 模板(反代)。换环境 = 改文本 + 重启容器,不重新打包。

相关推荐
Lee川1 小时前
Event Loop 面试通关:从原理到口述再到实战
前端·面试
kyriewen1 小时前
手写 call、apply、bind:从原理到实现,附 3 个最容易忽略的边界情况
前端·javascript·面试
用户2181697049301 小时前
swift (三) 枚举 结构体 类
前端
胡萝卜术2 小时前
从内存视角重新认识 JavaScript 数据类型:一份深度学习笔记
前端·javascript·面试
IVEN_2 小时前
记一次诡异的前端白屏故障:Nginx Proxy Cache 内存缓存"幽灵"事件
前端·nginx
如果超人不会飞2 小时前
TinyRobot SuggestionPills紧凑的建议按钮组组件
前端·vue.js
如果超人不会飞2 小时前
TinyRobot Container构建优雅的AI对话容器
前端·vue.js
幸运小圣2 小时前
全面解析 Web 核心性能指标:LCP、INP、CLS 是什么、怎么用、怎么看
前端
如果超人不会飞2 小时前
TinyRobot SuggestionPopover智能建议弹出框组件
前端·vue.js