编程 Vite SPA 打进 nginx 镜像后:启动时生成 env.js,一份镜像跑遍所有环境

2026-11-04 00:03:54

Vite SPA 打进 nginx 镜像后:启动时生成 env.js,一份镜像跑遍所有环境

现象很具体:镜像在 staging 构建、推到生产之后,浏览器 Network 里的接口请求还是打到 https://api.stg.example.com。查代码没有任何硬编码,import.meta.env.VITE_API_URL 写得好好的——问题出在 RUN npm run build 那一步,值已经被写进 dist/ 了。改一个 URL 就得重新构建一次镜像,registry 里堆着一批只差一个域名的近似镜像,build-once-promote-everywhere 直接失效。

import.meta.env 在 build 期就被静态替换了

浏览器里没有 process.env,Vite 也不在运行时读取环境变量。vite build 做的是一次静态文本替换:define 把源码里每一处 import.meta.env.VITE_API_URL 换成字符串字面量,再压缩、tree-shake:

// 源码
const api = import.meta.env.VITE_API_URL + "/orders"

// VITE_API_URL=https://api.stg.example.com 执行 vite build 之后
const api = "https://api.stg.example.com/orders"

// 死代码消除连分支都能删掉
if (import.meta.env.VITE_FEATURE_X === "true") { ... }
// 值为 "false" 时整段被移除

Docker 构建里发生替换的就是 RUN npm run build 那一层,构建阶段存在哪些 VITE_*,就被焊进 dist/。

几种做法对照:

方案镜像不可变成本
每个环境单独构建镜像否最简单,但 N 次构建、N 个镜像,发布慢,配置漂移风险高
占位符 token + 启动时 envsubst/sed是在压缩后的 JS 上做字符串替换,脆弱,token 可能误撞
容器启动时生成 env.js是一个小脚本,本文推荐的做法
nginx sub_filter 改写响应是可行,但配置耦合到 Web 服务器,每请求都有开销
应用启动时 fetch /config.json是干净,但多一个阻塞渲染的请求或加载门禁

运行时 env.js 的四个步骤

  1. 静态站点只构建一次,构建阶段不放任何真实密钥,产物进 nginx 镜像。
  2. 容器启动时,entrypoint 脚本读取真实环境变量,写出 /usr/share/nginx/html/env.js。
  3. index.html 在应用 bundle 之前加载 env.js,填充 window.__ENV__。
  4. 应用代码统一通过一个 accessor 读配置:优先 window.__ENV__,本地 vite dev 时回落到 import.meta.env。

1. 配置读取入口

// src/env.ts
type AppEnv = { API_URL: string; SENTRY_DSN: string; FEATURE_NEW_CHECKOUT: boolean }

const runtime = (typeof window !== "undefined" && (window as any).__ENV__) || {}

function read(key: string, devFallback: string | undefined): string {
const v = runtime[key] ?? devFallback
if (v === undefined || v === "") { console.warn("[env] missing config value: " + key); return "" }
return v
}

export const env: AppEnv = {
API_URL: read("API_URL", import.meta.env.VITE_API_URL),
SENTRY_DSN: read("SENTRY_DSN", import.meta.env.VITE_SENTRY_DSN),
FEATURE_NEW_CHECKOUT: read("FEATURE_NEW_CHECKOUT", import.meta.env.VITE_FEATURE_NEW_CHECKOUT) === "true",
}

其他地方一律 import { env } from "./env",不要再直接碰 import.meta.env 或 window.__ENV__。

2. index.html 先加载 env.js


提交一个占位文件 public/env.js,让 vite dev 和构建阶段不会 404:

// public/env.js:占位,容器运行时会被覆盖
window.__ENV__ = {}

3. 启动脚本

只输出显式白名单里的 key,容器环境里混进来的 AWS_SECRET_ACCESS_KEY 之类没有机会写进这个公开文件:

#!/bin/sh
set -eu
TARGET=/usr/share/nginx/html/env.js
KEYS="API_URL SENTRY_DSN FEATURE_NEW_CHECKOUT"
echo "window.__ENV__ = {" > "$TARGET"
for key in $KEYS; do
value=$(printenv "$key" || true)
printf '  "%s": "%s",\n' "$key" "$value" >> "$TARGET"
done
echo "};" >> "$TARGET"
cat "$TARGET"
exec "$@"

不受控的值要转义:值里可能出现双引号、反斜杠或换行,会产出非法 JavaScript,也等于开了一个注入口;用 jq 输出 JSON 更稳。

4. Dockerfile

# Stage 1: build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: serve
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh

nginx:alpine 在启动 nginx 之前会执行 /docker-entrypoint.d/*.sh,所以不需要自定义 ENTRYPOINT。换用其他基础镜像时,才需要显式接 ENTRYPOINT + CMD ["nginx","-g","daemon off;"]。

5. nginx:SPA fallback 与 env.js 不缓存

server {
listen 8080;
root /usr/share/nginx/html;

location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}

location = /env.js {
add_header Cache-Control "no-store, must-revalidate";
expires -1;
}

location / {
try_files $uri /index.html;
}
}

应用 bundle 的文件名带内容哈希,可以长期缓存;env.js 不行。

坑清单

  • envsubst 不带参数:会替换模板里所有 ${VAR} 形状的 token,包括碰巧以字面形式出现的那些。显式传入变量名列表可以彻底消除这个风险。
  • envsubst 不在镜像里:它来自 gettext 包,alpine 需要 apk add --no-cache gettext;chainguard 之类的硬化镜像则完全不带 envsubst(chainguard-images issue #93)。
  • /docker-entrypoint.d 的执行顺序:官方 nginx 镜像本身就会跑这里的脚本,包括 20-envsubst-on-templates.sh。文件名数字前缀决定执行次序,放成 40-env.sh 就排在 20 之后。在这里挂钩子,比替换镜像自带的 entrypoint 稳。
  • 覆盖 ENTRYPOINT 的代价:得自己处理 nginx -g "daemon off;"、SIGTERM 转发和模板解析,很容易把信号处理和配置流程搞坏。
  • env.js 被 CDN 或浏览器缓存:镜像提升到生产,应用却还在跟 staging 说话,直到缓存过期。env.js 上的 no-store 不是可选项,这一条几乎人人都踩过一次。
  • 值未转义:双引号、反斜杠、换行都能产出坏 JS,甚至构成简单的注入。
  • 绝不能放密钥:env.js 会发给每一个访客。
  • window.__ENV__ 里全是字符串:布尔和数字要在 accessor 里转换。
  • script 标签放到 bundle 之后或加 async:应用会在 window.__ENV__ 填充前初始化,静默回落到默认值,问题还很难复现。
  • 多一次 env.js 请求:能接受,仅仅因为 index.html 本来就不缓存;每个 key 一个字符串,代价是一次小的同步请求。

参考

复制全文 生成海报 SPA部署 nginx Vite Docker envsubst 运行时配置

推荐文章

程序员茄子在线接单