前端运行时动态环境变量方案(详细笔记)
目标读者:第一次接触「运行时注入环境变量」的前端/运维。
适用栈:任意 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.tsx是type="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))
});
三级回退的意义:
- 运行时(生产) :容器里
globalConfig.js有值 → 用它。这是动态能力的来源。 - 构建时(本地开发) :
globalConfig为空 → 回退import.meta.env,即读本地.env。开发体验不变。 - 默认值 :兜底,避免
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 字符串。 -
生成示例(容器内实际产物):
jswindow.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 步)
-
加读取工具
src/utils/env.ts:直接抄第 3.3 节整段(与项目无关,零改动)。 -
加占位文件
public/script/globalConfig.js:内容就一行window.globalConfig = {};(第 3.1 节)。public/下的文件 Vite 会原样拷进产物根。 -
改 index.html :在
<head>入口模块 之前 加html<script src="/script/globalConfig.js"></script>注意是普通
<script>(同步阻塞),不能加type="module",否则会变成 defer、晚于读取它的代码(第 3.2 节)。入口模块 React 项目是/src/main.tsx、Vue 项目是/src/main.ts,占位脚本写在它之前即可。 -
替换业务读取 :把所有
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.html 里 globalConfig.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() 自动回退到 .env 的 import.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 模板(反代)。换环境 = 改文本 + 重启容器,不重新打包。