环境变量在前端项目中的正确使用与安全实践

夏侯诗晴 工具 阅读 871
赞 11 收藏
二维码
手机扫码查看
反馈

环境变量搞崩了,本地跑得好好的,一上测试服就404

昨天下午差点被一个环境变量问题整崩溃。项目在本地开发时一切正常,API 调用稳得很,结果一部署到测试环境,所有接口直接 404。我第一反应是:后端又改路由了?但查了一圈,人家说没动。那问题肯定在我这儿。

环境变量在前端项目中的正确使用与安全实践

我一开始以为是 Nginx 配置的问题,折腾了半小时 proxy_pass,结果发现根本不是。后来才意识到:前端代码里写死的 API 地址是 http://localhost:3001!这在本地当然没问题,但测试环境怎么可能访问 localhost?

试了三种方案,前两种都踩了坑

我赶紧去翻 Vite 的文档(我们项目用的是 Vite + React)。Vite 推荐用 .env 文件管理环境变量,于是我在项目根目录加了几个文件:

  • .env.development
  • .env.test
  • .env.production

比如 .env.development 里写:

VITE_API_URL=http://localhost:3001

然后代码里这么用:

const API_URL = import.meta.env.VITE_API_URL;
fetch(${API_URL}/users)

本地跑 npm run dev 没问题。但当我打测试包:vite build --mode test,结果打包后的代码里 VITE_API_URL 居然还是 undefined

这里我踩了个大坑:Vite 默认只加载以 VITE_ 开头的环境变量,而且 必须在构建时确定。更关键的是,--mode test 对应的文件名应该是 .env.test,但我一开始写成了 .env.testing,所以压根没加载。

改对文件名后,本地 build 出来的静态文件里确实有了正确的 URL。但新问题来了:测试环境有好几套(test、staging、uat),每次换环境都要重新 build 一次?这太反人类了。运维同事直接甩脸:“不能动态配吗?我们通过 Nginx 注入环境变量。”

真正的痛点:构建时注入 vs 运行时注入

这时候我才意识到问题本质:Vite(以及 Webpack)这类打包工具,环境变量是在 构建阶段 替换进去的,打包完就固化了。但很多公司(包括我们合作的客户)希望同一个构建产物能部署到多个环境,靠运行时读取配置——比如从 window.ENV 或一个单独的 config.js 文件。

我之前一直用构建时注入,因为简单。但现在需求变了,得支持运行时配置。怎么办?

我试了下在 index.html 里加一段内联脚本:

<script>
  window.APP_CONFIG = {
    API_URL: "https://jztheme.com/api"
  };
</script>

然后 JS 里直接读 window.APP_CONFIG.API_URL。这样确实能动态改,但有个恶心的问题:这个 config.js 得由运维在部署时生成,或者手动替换。而且如果忘了更新,就又回退到默认值(如果有设的话)。

后来我发现一个折中方案:既保留 Vite 的便利性,又支持运行时覆盖。

最终方案:双保险,优先运行时配置

我的思路是:构建时注入一个默认值,但允许在页面加载时被外部配置覆盖。

首先,保留 .env 文件作为兜底:

# .env.production
VITE_API_URL=https://jztheme.com/api

然后,在 main.js 或入口文件里,不直接用 import.meta.env.VITE_API_URL,而是封装一个 getConfig 函数:

// config.js
function getConfig() {
  // 优先使用运行时注入的 window.__APP_CONFIG__
  if (window.__APP_CONFIG__ && window.__APP_CONFIG__.API_URL) {
    return {
      apiUrl: window.__APP_CONFIG__.API_URL,
    };
  }

  // 兜底用构建时注入的变量
  return {
    apiUrl: import.meta.env.VITE_API_URL || 'https://jztheme.com/api',
  };
}

export const appConfig = getConfig();

接着,在 index.html 里预留一个位置让运维注入配置(通常放在 <head> 最顶部):

<!DOCTYPE html>
<html>
  <head>
    <script>
      // 这个会被部署脚本动态替换
      window.__APP_CONFIG__ = {
        API_URL: "https://test-api.jztheme.com"
      };
    </script>
    <!-- 其他 head 内容 -->
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

这样,如果运维在部署时替换了 window.__APP_CONFIG__,就用他们的;如果没有(比如本地开发),就自动回退到 .env 里的值。

亲测有效。现在我们打一次包,运维只需要改 HTML 里那一小段 JS,就能部署到任意环境。再也不用为不同环境分别 build 了。

踩坑提醒:这三点一定注意

整个过程折腾了快一天,总结几个血泪教训:

  • 变量名必须带 VITE_ 前缀:Vite 默认只暴露以 VITE_ 开头的变量到客户端,这是安全机制。别傻乎乎地写 API_URL=xxx,它不会被注入到 import.meta.env 里。
  • 不要在 HTML 里直接写敏感信息:虽然 window.__APP_CONFIG__ 方便,但所有人都能在浏览器里看到。所以这里只放公开的 API 地址,绝不能放密钥、token 之类的东西。
  • 本地开发时怎么模拟运行时配置? 我在 vite.config.js 里加了个插件,在 dev 模式下自动注入一个假的 __APP_CONFIG__,避免报错:
// vite.config.js
export default defineConfig({
  plugins: [
    // ...其他插件
    {
      name: 'mock-app-config',
      transformIndexHtml(html) {
        if (process.env.NODE_ENV === 'development') {
          return html.replace(
            '<head>',
            &lt;head&gt;&lt;script&gt;window.__APP_CONFIG__ = { API_URL: &quot;http://localhost:3001&quot; };&lt;/script&gt;
          );
        }
        return html;
      }
    }
  ]
});

这样本地开发时也不用改 HTML,自动生效。

还有个小瑕疵,但无大碍

目前这个方案有个小问题:如果运维忘了注入 __APP_CONFIG__,而 .env 里的地址又是生产环境的,那测试环境就会意外调到生产 API。虽然概率低,但理论上存在风险。

为了解决这个,我后来在 getConfig 里加了个校验:

function getConfig() {
  const runtimeUrl = window.__APP_CONFIG__?.API_URL;
  const buildTimeUrl = import.meta.env.VITE_API_URL;

  // 如果是测试/预发环境,但用了生产 URL,报警
  if (
    import.meta.env.MODE !== 'production' &&
    buildTimeUrl?.includes('jztheme.com') &&
    !runtimeUrl
  ) {
    console.warn('⚠️ 检测到可能误用生产 API,请确认是否注入了 __APP_CONFIG__');
  }

  return {
    apiUrl: runtimeUrl || buildTimeUrl || 'https://jztheme.com/api',
  };
}

至少能给开发者一个提示,不至于静默出错。

以上是我踩坑后的总结。这套方案虽然不是最优雅的(理想情况是后端提供统一网关,前端不用管环境),但在现有约束下算是平衡了灵活性和安全性。如果你有更好的方案,比如用 JSON 配置文件动态加载,或者结合 Docker 环境变量注入,欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论