深入掌握Environments环境配置与实战技巧
项目初期的技术选型
上个月接手了个老项目重构,前端这块原本是用一堆全局变量加 jQuery 撸出来的,环境配置全写在 HTML 里,不同环境靠手动改 script 标签引入的 config.js。每次上线前运维都得盯着我,生怕我发错了测试配置到生产。
实在受不了了,就想着干脆把环境管理这块重做一下。本来想直接上 Webpack DefinePlugin 硬编码,但后来一想,这项目以后可能要支持多租户部署,配置项会越来越多,硬编码肯定不行。最后决定整一个基于 JSON 配置 + 运行时加载的 environment 管理方案。
核心思路:运行时动态加载环境配置
我的想法很简单:页面打开时先异步拉取一个 env.json,根据域名自动判断当前环境,然后把所有配置挂载到 window.APP_CONFIG 上。这样打包只需要一份代码,部署时只要换掉 env.json 就行。
实现起来也不复杂,入口 index.html 里加了个小脚本:
<script>
// 先定义个默认配置兜底
window.APP_CONFIG = {
API_URL: 'https://jztheme.com/api',
ENV: 'development',
DEBUG: true
};
// 动态加载环境配置
fetch('/env.json')
.then(res => res.json())
.then(config => {
window.APP_CONFIG = { ...window.APP_CONFIG, ...config };
// 加载完再启动 Vue 应用
startApp();
})
.catch(err => {
console.warn('加载 env.json 失败,使用默认配置', err);
startApp(); // 出错也得启动,不能卡死
});
function startApp() {
// 这里才是真正的应用入口
import('./main.js');
}
</script>
最大的坑:首屏请求全挂了
理想很丰满,现实很骨感。改完上线第一天,监控报警炸了——大量接口 404。查了半天才发现,因为 fetch env.json 是异步的,而有些组件在 startApp 前就发请求了,结果读到的是默认配置里的测试地址,根本不对。
开始没想到这个问题,以为 import(‘./main.js’) 能完全延迟执行。但实际上,webpack 的 dynamic import 只是“告诉”它什么时候下载模块,模块内部的副作用(比如 api.js 里直接使用的 window.APP_CONFIG.API_URL)会在模块加载时立刻执行。
也就是说,我还没来得及替换 window.APP_CONFIG,人家就已经拿着旧配置去发请求了。
折腾了半天发现,这种运行时动态配置的核心矛盾在于:你必须确保所有依赖配置的代码都在配置加载完成后才执行。
最终的解决方案:搞个配置等待队列
后来参考了以前做过的一个 SSR 项目,加了个简单的 Promise 锁机制。所有需要配置的地方,先等一等。
// config-loader.js
let resolveReady;
let rejectReady;
const readyPromise = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
export const Config = {
current: null,
async load() {
try {
const res = await fetch('/env.json');
const config = await res.json();
this.current = {
API_URL: 'https://jztheme.com/api',
ENV: 'development',
DEBUG: false,
...config
};
resolveReady(this.current);
} catch (err) {
console.warn('加载配置失败,使用默认值');
this.current = window.APP_CONFIG; // 回退到初始默认
resolveReady(this.current);
}
},
// 所有需要配置的地方都要 await Config.ready()
ready() {
return readyPromise;
}
};
// 自动触发加载
Config.load();
然后在 api.js 里这么用:
import { Config } from './config-loader';
let API;
// 不再直接使用配置,而是包装一层初始化函数
export async function initAPI() {
const config = await Config.ready();
API = {
fetchUsers: () => fetch(${config.API_URL}/users),
createUser: (data) => fetch(${config.API_URL}/users, {
method: 'POST',
body: JSON.stringify(data)
})
};
return API;
}
// 导出一个带锁的调用方式
export async function getAPI() {
if (!API) await initAPI();
return API;
}
组件里就得改成:
import { getAPI } from './api';
async mounted() {
const API = await getAPI();
const res = await API.fetchUsers();
this.users = await res.json();
}
这里注意我踩过好几次坑
- 一开始想偷懒,在 main.js 顶部 await Config.ready(),结果 webpack 把所有 import 提前执行了,照样出事。
- 有人提议用 Event Loop 微任务 hack,比如 setTimeout(() => startApp(), 0),但这不靠谱,没法保证顺序。
- 最后这个 Promise 锁虽然多写了点代码,但逻辑清晰,至少没再出问题。
还有些小问题没完美解决
现在每次调用 getAPI() 都要 await,写起来有点烦。理论上可以做个单例缓存,但怕异步竞争,暂时就这样了。反正影响不大,最多就是多几个 await。
另外 env.json 缓存问题也没处理。正常情况下 Nginx 配了 no-cache,但如果用户网络差或者离线访问,还是会用浏览器缓存的老配置。不过我们内部系统,刷新一下就行,懒得加版本号或时间戳了。
回顾与反思
这套方案上线两周了,目前稳定。最大的好处是部署真的变简单了,CI/CD 流程里不再需要根据不同环境打包,只传一个 env.json 就行。
缺点也很明显:首屏慢了一丢丢,因为要等配置加载完才能启动应用。实测平均多耗时 120ms 左右,在可接受范围。
如果下次再做类似需求,可能会考虑预埋多套配置在 JS 里,通过域名自动切换,牺牲一点包体积换来更稳定的启动流程。但现在这套也能跑,就不动了。
以上是我的项目经验,希望对你有帮助
这种 environment 管理看似简单,真动手就会踩各种异步陷阱。特别是老项目改造,更要小心副作用执行时机。我这个方案不是最优的,但够用、可控、容易理解。
如果你有更好的做法,比如用 Module Federation 或者其他 loader 机制,欢迎评论区交流。这个坑我不想再踩第二次了。

暂无评论