前言
最近要实现一个功能, 让一套构建产物,可以部署多个环境。这个需求的来源是领导觉得前端生产版本回退不如后端那么快, 因为后端编译代码在运行时可以从外部宿主环境读取设置的环境变量, 编译产物与环境解耦,一套编译产物可以部署任何一个环境。 前端如何实现同样的功能,在Jenkins选择构建产物的镜像tag, 不必提交回退的代码到远程仓库, 再重装项目依赖,重新打包,进行版本回滚。

想了一下,可以根据每个线上不同环境的域名, 动态切换环境变量。初步想法有了,现在我们看看具体的实现方式。
具体实现方式
第一步 提取各个环境的变量配置
在项目根目录下新建一个src\utils\getEnv.ts
文件, 读取项目根目录下定义的各个环境的环境变量文件, 如下图所示

将环境变量整理js对象, 一个键名对应一个环境变量的变量定义
js
export const envMap = {
dev: {
// ...
VITE_PUBLIC_PATH: "/dev/",
},
test: {
// ...
VITE_PUBLIC_PATH: "/test/",
},
canary: {
// ...
VITE_PUBLIC_PATH: "/canary/",
},
prod: {
// ...
VITE_PUBLIC_PATH: "/",
},
};
编写环境变量切换逻辑, 根据当前页面的域名,决定使用哪一个环境的环境变量。
js
export const getEnv = () => {
const hostname = window.location.hostname;
if (hostname.includes("test")) {
return envMap.test;
} else if (hostname.includes("canary")) {
return envMap.canary;
} else if (hostname === "www.xxx.com") {
return envMap.prod;
} else {
return envMap.dev;
}
};
第二步 改造网页资源基础路径设置方式
首先移除vite.config.ts中编译时与环境变量有关的逻辑,如下的vite.config.ts配置, 要做两处修改:
- 将
base: isBuild ? viteEnv.VITE_PUBLIC_DIR : "./",
固定成base: "./",
- 移除define中process.env定义的环境变量
viteEnv
与其获取代码片段
ts
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import * as Path from "path";
import { wrapperEnv } from "./build/utils";
export default defineConfig(({ command, mode }) => {
const root = process.cwd();
const isBuild = command === "build";
const env = loadEnv(mode, root);
const viteEnv = wrapperEnv(env);
return {
root,
base: isBuild ? viteEnv.VITE_PUBLIC_DIR : "./",
define: {
"process.env": {
...viteEnv,
NODE_ENV: mode,
},
},
// ...
};
});
接着动态给打包编译之后的index.html中引入的资源文件设置资源加载基础路径,编写两个js脚本文件, 读取打包生成的dist目录中的index.html文件内容, 完成基础路径的设置和index.html中引入的静态资源路径基础路径的添加。
1. 动态确定资源的基础路径(base url)
通过 setBaseUrl
根据当前 window.location.hostname
判断:如果是生产站点 www.upaypal.com
→ 使用 /
, 其它环境使用 /xx/
,把结果写到 window.__vite__base__
,作为全局变量供后续加载资源时拼接路径。
js
export const setBaseUrl = `
(function () {
window.__vite__base__ = (function () {
const hostname = window.location.hostname;
if (hostname === "www.xxx.com") return "/";
else return "/xxx/";
})();
`
这部分每个项目不一样,所以抽取出来, 保存到set-base-url.mjs
文件中。现在看看重头戏post-build.mjs
文件的功能。post-build.mjs
的主要功能是 修改打包后的 index.html
,使得前端资源能根据运行环境动态设置基础路径,确保index.html直接和间接引入的资源, 都能正确加载。具体说来,逻辑分为两大部分:
第一部分 解析并重写 index.html
- 从打包后的
dist/index.html
读取原始内容。 - 去掉注释,避免误匹配到注释里的资源标签。
- 用正则解析原文件中的资源:
<title>
→ 保留页面标题。<div id="xxx"></div>
→ 找到挂载点(默认"app"
)。<link>
(样式表 / favicon)。<script src="...">
(区分 head 和 body)。- 内联
<script>...</script>
(非 src 的脚本,例如 viewport 自适应逻辑)。 <img src="...">
(图片资源)。
第二部分 生成新的 index.html
- 只保留原有的dist/index.html中的挂载点
<div id="挂载点">
。 - 通过动态脚本去还原资源:
- 设置
<meta name="viewport">
和<base>
。 - 根据
window.__vite__base__
拼接路径,创建<link>
、<script>
、<img>
等 DOM 节点,并插入到head
或body
。 - 内联脚本也会被重新插入并执行,避免丢失逻辑。
- 设置
js
import fs from "fs";
import path from "path";
import {setBaseUrl} from "./set-base-url.mjs";
const distDir = path.resolve("./dist");
const indexHtmlPath = path.join(distDir, "index.html");
// 1. 读取打包后的 index.html
const rawHtml = fs.readFileSync(indexHtmlPath, "utf-8");
// 2. 去掉注释(避免匹配到注释里的 script/link/img)
const cleanedHtml = rawHtml.replace(/<!--[\s\S]*?-->/g, "");
// 4. 使用正则提取资源
// 提取 <title>
const titleMatch = cleanedHtml.match(/<title>(.*?)<\/title>/i);
const title = titleMatch ? titleMatch[1] : "";
// 提取 <head> 中 link[rel=stylesheet|icon]
const headLinks = [];
const linkRegex = /<link\s+[^>]*href=["']([^"']+)["'][^>]*>/gi;
let match;
while ((match = linkRegex.exec(cleanedHtml)) !== null) {
headLinks.push(match[1]);
}
// 提取 <head> 和 <body> 中的 inline script(不含 src)
const inlineScripts = [];
const inlineScriptRegex = /<script(?![^>]*src=)[^>]*>([\s\S]*?)<\/script>/gi;
while ((match = inlineScriptRegex.exec(cleanedHtml)) !== null) {
const content = match[1].trim();
if (content) inlineScripts.push(content);
}
// 3. 提取挂载点(div 的 id)
const mountMatch = cleanedHtml.match(/<div\s+id=["']([^"']+)["']\s*><\/div>/i);
const mountId = mountMatch ? mountMatch[1] : "app"; // 默认 root
// 提取 <head> 中 script[src]
const headScripts = [];
const headScriptRegex =
/<head[^>]*>[\s\S]*?<script\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
while ((match = headScriptRegex.exec(cleanedHtml)) !== null) {
headScripts.push(match[1]);
}
// 提取 <body> 中 script[src]
const bodyScripts = [];
const bodyScriptRegex =
/<body[^>]*>[\s\S]*?<script\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
while ((match = bodyScriptRegex.exec(cleanedHtml)) !== null) {
bodyScripts.push(match[1]);
}
// 提取 <img src>
const imgSrcs = [];
const imgRegex = /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
while ((match = imgRegex.exec(cleanedHtml)) !== null) {
imgSrcs.push(match[1]);
}
// 5. 生成新的 index.html
const newHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>${title}</title>
</head>
<body>
<div id="${mountId}"></div>
<script>
${setBaseUrl}
var base = window.__vite__base__;
var head = document.head;
var body = document.body;
var meta = document.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width, initial-scale=1.0";
head.appendChild(meta);
// 设置 meta base 标签
const baseEl = document.createElement("base");
baseEl.setAttribute("href", window.__vite__base__);
document.head.appendChild(baseEl);
${headLinks
.map(
(href) => `
var link = document.createElement("link");
link.rel = "${href.endsWith(".css") ? "stylesheet" : "icon"}";
link.href = base + "${href.replace(/^\//, "")}";
head.appendChild(link);
`
)
.join("\n")}
${inlineScripts
.map(
(code) => `
var inlineScript = document.createElement("script");
inlineScript.type = "text/javascript";
inlineScript.text = ${JSON.stringify(code)};
head.appendChild(inlineScript);
`
)
.join("\n")}
${headScripts
.map(
(src) => `
var script = document.createElement("script");
script.type = "module";
script.crossOrigin = "";
script.src = base + "${src.replace(/^\//, "")}";
head.appendChild(script);
`
)
.join("\n")}
${bodyScripts
.map(
(src) => `
var script = document.createElement("script");
script.type = "module";
script.crossOrigin = "";
script.src = base + "${src.replace(/^\//, "")}";
body.appendChild(script);
`
)
.join("\n")}
${imgSrcs
.map(
(src) => `
var img = document.createElement("img");
img.src = base + "${src.replace(/^\//, "")}";
body.appendChild(img);
`
)
.join("\n")}
})();
</script>
</body>
</html>`;
// 写回 index.html
fs.writeFileSync(indexHtmlPath, newHtml, "utf-8");
console.log("✅ postBuild: index.html 已改造为动态加载原资源列表,挂载点使用 id =", mountId);
这个最终版本, 是修复了下面几个问题后,完善得到的。
- 读取的单页应用根元素id不是动态的,导致页面报错
- 内联script脚本丢失
- cheerio不兼容node18, 所以改用正则匹配html中的标签元素。
- 为了好维护,把非通用逻辑提取出去
第三步 修改文件中的环境变量的引用方式
这部分修改是体力活, 在项目src文件夹下全局搜索import.meta.env.xxx, 接着在搜索到的每个文件的开头部分, 添加如下两行代码, 定义env变量
js
import { getEnv } from "@/utils/getEnv";
const env = getEnv();
最后将import.meta.env.xxx替换成env.xxx, 除了vite内置的环境变量
变量名 | 类型 | 说明 |
---|---|---|
import.meta.env.MODE |
string |
当前运行的模式(默认为 "development" ,生产构建时为 "production" )。 |
import.meta.env.BASE_URL |
string |
部署应用时的基础路径,等同于 base 配置项(默认为 '/' )。 |
import.meta.env.PROD |
boolean |
是否为生产环境,等价于 import.meta.env.MODE === 'production' 。 |
import.meta.env.DEV |
boolean |
是否为开发环境,等价于 import.meta.env.MODE === 'development' 。 |
import.meta.env.SSR |
boolean |
是否在服务端渲染环境中(SSR)运行。 |
这部分工作也可以交给AI做, 由于项目中对env变量的写法比较多样化,AI的修改的效果差强人意,所以选择了手动改。
第四步 修改打包指令
本地开发启动命令不用做任何修改,主要是修改打包构建指令, 需要在原有构建指令之后, 添加node post-build.mjs
指令, 处理打包生成的dist/index.html文件, 将其改造成可以更具运行环境域名,添加不同的资源加载基础路径。 修改前:
js
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"build:master": "vite build --mode production",
"build:test": "vite build --mode test",
"build:canary": "vite build --mode canary"
},
修改后:
js
"scripts": {
"dev": "vite",
"build": "vite build && node post-build.mjs",
"build:dev": "vite build && node post-build.mjs",
"build:test": "vite build && node post-build.mjs",
"build:canary": "vite build && node post-build.mjs",
"build:master": "vite build && node post-build.mjs"
},
你可能好奇,修改之后的5个打包指令一模一样,为什么不写成一个。这是为了兼容Jenkinsfile中配置的打包指令。
最后
测试了一下本地启动,打包,以及部署到开发环境的运行情况, 均无问题。前端代码虽然不能像后端代码一样直接读取系统环境变量文件内容, 但变换一下思路,也能实现同样的效果。这个方案,目前在网上还没看到有类似的文章, 所以才感觉有分享的价值,本文的方案是基于vite编译工具的,不过思路具有普适性,换成umi,webpack或其它前端编译工具,也同样适用。如果你想实现类似的功能, 可以借鉴一下文中的方案。没事多逛逛掘金,开卷有益。