困境
目前的现代前端项目中,环境变量一般都是在打包阶段通过静态字符串替换的形式注入(写死)到前端代码中的。因此就导致了线上环境的配置一旦变更,就要重新执行整个前端流水线来拿到包含配置的 docker 镜像。
对于服务型的产品来说(部署到自己的服务器上,只会有几个固定的环境),这种流程没有问题。但对于交付型的产品来说(部署到客户的服务器上,产品卖了几次就会有几个环境甚至更多),越来越多的配置只会徒劳的消耗前端开发者的时间。
这个困境就是本文要解决的痛点。文末有 demo,有需要的可以自取。
目标
对于前端开发者来说:避免前端项目中出现与线上环境相关的配置或资源,把所有和环境相关的操作从开发阶段、打包阶段剥离出来,集中到部署阶段,开发时只会看到开发配置,很舒服。
对于运维部署者来说:拿到的前端镜像是纯净、通用的,且可以通过暴露出来的配置对前端服务进行自定义,避免因环境配置的变更导致需要重新打包跑流水线,也很舒服。
也就是说,在完成本文流程的配置之后,前端项目只需要一条流水线即可,项目里也只会有一份 env 配置。拿出来的包可以通过修改环境变量的形式部署在任意环境中。
基本思路
前端不再使用脚手架提供的 process.env
或者 import.meta.env
来访问环境变量。而是通过 nginx 获取环境变量,再注入到页面请求 header 中,从而使得前端代码可以访问到对应的配置。
在前端项目中则只需要一份 env 即可,用来配置开发环境中的默认参数。
其实前端项目中要必须通过这个方式配置的内容也只有两个,一个是前端部署的路径前缀,另一个是后端服务的请求地址。其他的东西都可以通过其他方式注入,例如:
- 不同环境的静态资源:例如 logo、图片,介绍视频等,可以通过 docker -v 把资源挂载到静态资源目录中
- 具体业务的配置、有安全性要求、需要保密的参数:在前端应用引导完后通过请求后端接口来拿
为什么后端请求地址必须要通过这种形式配置?
后端的请求地址这个很好理解,现在的前端镜像一般都是用 nginx 作为基础镜像,然后通过 conf location 的形式对后端接口进行反代。
而不同环境的请求地址肯定是不同的,所以需要通过这种配置的形式来告诉 nginx 后端的接口请求应该转发到哪里。
为什么前端部署路径必须要通过这种形式配置?
那么现在的问题就只剩下前端部署路径了,我们先来了解一下这个 "前端部署路径" 是什么意思。使用 history 路由模式的前端 SPA 项目的地址一般都是下面这样的:
bash
https://my-web-site.com/app1/pageA/detail
我们可以把他分为这三部分:
注意其中的 app1/
,这一节路径是部署时由运维人员设置的。所以我们把他叫做部署路径(或者二级目录、次级目镜)。只不过大多数前端项目中我们是直接把应用部署在跟路径下(/
),就不会有红色这一节了。
那么问题来了,像是 react-router、vue-router 这种路由库看到的路由是 /app1/pageA/detail
,如果我们不告诉他需要把 app1/
排除掉的话,它就会拿着这个完整的路由去匹配应用的路由树,这自然匹配不到。在实际开发中我们一般都是通过环境变量 process.env.BASE_URL
搭配路由库的 basename
属性来解决这个问题。
而现在我们需要把这些配置从打包阶段转移到部署阶段,那么自然不能用这种老方案了。
开始动手
OK,下面我们根据上面的思路开始动手,过程会包含一些引申知识点的讲解,大家也可以现在把文末的 demo 下下来跑一下。
1. 前端项目配置
首先我们来快速创建一个 demo 项目:
bash
npm create vite@latest hoho-frontent-env -- --template react-ts
后面的 npm install
、npm run dev
我就不提了,唯一需要注意的地方是打开 vite.config.js
,然后设置 base
路径:
js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
// 添加这个
base: './',
})
为什么要添加这个呢?因为如果我们不加的话,前端项目打包时是默认使用根路径 /
来请求静态资源的:
改成 ./
,打包后效果如下:
如果我们要搞部署路径配置的话,比如我们把服务部署到了 https://site.com/app1/
下面,这两种方案在请求静态资源时分别这样的:
- 🔴
https://site.com/assets/index-C4uVxZUa.js
- 🟢
https://site.com/app1/assets/index-C4uVxZUa.js
可以发现第一个链接(使用默认的绝对路径)是请求不到资源的,因为我们的 nginx 在 /app/
下面。
2. nginx 读取 docker 环境变量
在 docker nginx 1.19 之前,我们需要手写一段脚本来主动替换 nginx conf 中的变量,而 1.19 之后,官方通过 docker-entrypoint
的方式实现了自动读取环境变量(官方介绍见 Using environment variables in nginx configuration),所以说我们就可以直接用这个方案来实现:
首先找一个空文件夹作为项目目录,新建 server.conf.template
文件,然后填入如下内容:
nginx
server {
listen 80;
server_name localhost;
client_max_body_size 2048m;
include mime.types;
location / {
root /usr/share/nginx/html;
index index.html index.htm index.shtml;
try_files $uri $uri/ /index.html;
if ($request_filename ~* .*\.html$) {
add_header Cache-Control "no-cache, no-store";
add_header X-Hoho-Baseurl '${HOHO_BASE_URL}';
add_header X-Hoho-Myenv '${HOHO_MY_ENV}';
}
}
location ${HOHO_BASE_URL}/webapi/ {
proxy_pass ${HOHO_BACKEND_URL};
}
error_page 405 =200 $uri;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
文件里一共使用了三个变量:
HOHO_BASE_URL
:前端的部署路径前缀HOHO_BACKEND_URL
:后端的实际服务地址(不暴露给外部,前端代码通过webapi/
反代访问)HOHO_MY_ENV
:你自己项目需要的其他环境变量配置
注意其中的判断,如果请求的是 html 时就把配置放 header 传给前端,因为 index.html 是整个项目的入口,我们要第一时间拿到这个配置。
另外,你可以把环境变量的 HOHO 前缀改成自己的项目名,这是个好习惯。
然后再搞一个 Dockerfile
:
Dockerfile
FROM nginx:1.24.0
COPY ./dist /usr/share/nginx/html
COPY ./nginx/server.conf.template /etc/nginx/templates/server.conf.template
EXPOSE 80
很简单,把前端打包好的内容和刚才的 conf 复制进来,nginx 会自动把 templates/*
里的模板文件替换好环境变量然后塞进 /etc/nginx/conf.d
里。
注意,不要包含 CMD exec nginx -g 'daemon off;'
之类的启动指令,很多老项目都会这么搞来把 nginx 设置为前台运行。但是现在 nginx 镜像已经不需要这么做了,而且 nginx 镜像的环境变量加载是通过 entrypoint 脚本来实现的,你指定了 CMD 后就不会走这个脚本了。然后就会发现自己的配置完全没生效。
这样 build 完之后,我们就可以通过 docker run -e HOHO_BASE_URL=xxx
的形式把环境变量挂载进来了。
3. index.html 获取 header 参数
现在再回到前端,打开入口 index.html 文件,在 <header>
里添加如下脚本:
html
<script>
const req = new XMLHttpRequest();
req.open('GET', document.location.href, false);
req.send(null);
const respHeader = req.getAllResponseHeaders();
if (respHeader) {
const splitedHeader = respHeader.split('\r\n');
const headers = splitedHeader.reduce((config, cur) => {
const [key, value] = cur.split(': ');
config[key] = value;
return config;
}, {});
window.HOHO_CONFIG = {
PATH_BASENAME: headers['x-hoho-baseurl'] || '%VITE_PATH_BASENAME%',
HOHO_MY_ENV: headers['x-hoho-myenv'] || '%VITE_HOHO_MY_ENV%',
}
const baseUrl = window.HOHO_CONFIG.PATH_BASENAME;
if (baseUrl) {
const baseEle = document.createElement('base');
baseEle.setAttribute('href', baseUrl);
document.head.insertBefore(baseEle, document.head.firstChild);
}
}
</script>
有点长,我来解释一下,首先是第一部分:
js
const req = new XMLHttpRequest();
req.open('GET', document.location.href, false);
req.send(null);
const respHeader = req.getAllResponseHeaders();
这个没什么好说的,从当前 html 的请求头里获取响应 header,因为 nginx 设置的参数就在这里边。
然后是第二部分:
js
const splitedHeader = respHeader.split('\r\n');
const headers = splitedHeader.reduce((config, cur) => {
const [key, value] = cur.split(': ');
config[key] = value;
return config;
}, {});
window.HOHO_CONFIG = {
PATH_BASENAME: headers['x-hoho-baseurl'] || '%VITE_PATH_BASENAME%',
MY_ENV: headers['x-hoho-myenv'] || '%VITE_HOHO_MY_ENV%',
}
把 getAllResponseHeaders
返回的字符串切分成可以访问的形式,然后注入到全局变量里,注意后面的缺省值,因为我们开发环境里 header 时没有这些数据的,所以我们在开发环境需要手动把数据注入进来,因为我使用的是 vite,所以使用 %VITE_PATH_BASENAME%
的方法来实现,webpack 的话一般都是 <%= XXX %>
。
顺带一提,开发环境的 .env
应该是这样的:
env
VITE_PATH_BASENAME="/"
VITE_HOHO_MY_ENV="开发环境配置"
最后是第三部分:
js
const baseUrl = window.HOHO_CONFIG.PATH_BASENAME;
if (baseUrl) {
const baseEle = document.createElement('base');
baseEle.setAttribute('href', baseUrl);
document.head.insertBefore(baseEle, document.head.firstChild);
}
这一点代码的作用可能比较抽象,它是用于修复"相对路径请求资源"带来的问题的。<base />
标签可以自定义相对路径请求资源时使用的根路径,这样通过动态生成 base 标签来得到正确的资源获取链接。
相对路径请求资源会导致什么问题?
刚才我们提到了,出于动态配置前端访问路径的目的,我们把打包的路径前缀从 /
改成了 ./
,那么这个改动带来了什么问题呢?我们再看一下地址的组成:
如果我们请求的前端应用路由是 /pageA/detail/1
时,静态资源的访问链接会变成这个样子:
https://site.com/app1/pageA/detail/1/index.html
https://site.com/app1/pageA/detail/1/assets/index-C4uVxZUa.js
https://site.com/app1/pageA/detail/1/assets/index-DiwrgTda.css
nginx 的 try_files 配置可以保证 index.html 加载正确,但无法找到 js 和 css 的资源位置。原因在于:
- 🟢 正确的资源地址:
https://site.com/app1/assets/index-C4uVxZUa.js
- 🔴 实际的请求地址:
https://site.com/app1/assets/pageA/detail/1/index-C4uVxZUa.js
实际的问题表现就是:请求的所有静态资源内容都变成了 index.html 的内容(因为 nginx 无法找到资源,就 try_files 把默认的 index.html 内容返回了)
怎么修复相对路径的请求资源问题?
解决办法就是 base
标签,文档在这里:base 文档根 URL 元素 | MDN (mozilla.org)。
虽然这个标签平时接触可能比较少,但这并不代表它是个新标准,它的兼容性可是一等一的棒,可以放心使用:
简而言之,当需要请求一个相对路径的资源时,浏览器会先去找 dom 里有没有 base 标签,如果有的话,就把它的 href
当作前缀去拼接完整的资源路径,如果没有 base 标签的话,才会用地址栏里的路径当作前缀。
所以说,当我们把 base 标签设置上 nginx 发来的路径前缀 /app1
时,浏览器就不会再错误的使用地址栏来拼接实际请求地址了。
4. 前端代码使用配置
如果你在用 ts 的话,可以在 src/global.d.ts
里添加如下配置来获得类型支持:
typescript
declare const HOHO_CONFIG: {
/** 前端路由前缀 */
PATH_BASENAME: string;
/** 其他自定义配置 */
MY_ENV: string;
};
而具体的代码有两个是需要调整的,路由前缀和请求前缀,比如我们使用 react-router
路由库和 axios
请求库,就可以进行如下调整:
js
import { RouterProvider } from "react-router-dom";
const routeList = [ /** ... **/ ];
const router = createBrowserRouter(routeList, {
// 下面这个配置
basename: HOHO_CONFIG.PATH_BASENAME
});
js
/** 把多个路径拼接起来 **/
export const mergeUrl = (...path: string[]) => {
return path.reduce((pre, cur) => {
const endSlash = pre.endsWith('/');
const startSlash = cur.startsWith('/');
if (endSlash && startSlash) return pre + cur.substring(1);
if (!endSlash && !startSlash) return pre + '/' + cur;
return pre + cur;
});
};
export const axiosInstance = axios.create({
// 下面这个配置
baseURL: mergeUrl(location.origin, HOHO_CONFIG.PATH_BASENAME, '/webapi'),
});
路由的很好理解,要注意请求时要也要添加 HOHO_CONFIG.PATH_BASENAME
,因为我们的后端接口是通过 nginx 代理出来的,所以要配一下防止找不到 nginx。
5. docker 运行时添加配置
之后就是正常的镜像构建和运行了:
bash
docker build -t frontend-use-docker-env .
构建比较简单,这里就不细讲了。来看一下运行命令:
bash
docker run -p 8080:80 -e HOHO_BASE_URL= -e HOHO_MY_ENV=prod-env-value -e HOHO_BACKEND_URL=http://host.docker.internal:3001/ frontend-use-docker-env
所有的参数都通过 -e 参数传递进来,注意其中的 HOHO_BASE_URL
,这个是部署目录的配置,这里如果要部署到根目录下的话,直接置空就行,不能传 /
。因为我们 nginx conf 中有这么一段配置:
bash
location ${HOHO_BASE_URL}/webapi/ {
proxy_pass ${HOHO_BACKEND_URL};
}
如果设置为 /
的话,那后端接口的反代就成 //webapi
了,虽然能跑起来,但是会出现找后端接口报错的情况。
总结 & DEMO
至此,完整的配置就已经完成了。
demo 已上传至 github:HoPGoldy/frontend-use-docker-env: 前端使用 docker env 环境变量配置的 demo 示例项目 (github.com),有需要的可以 clone 下来跟着 README 自己跑一下。
其实前端项目使用外部的环境变量进行配置一直是一个老生常谈的问题。包括之前也用过把配置写在 config.js,然后 index.html 里阻塞加载这个 js 文件。但是这个方案会增加白屏时间,另外在 docker run 时也要通过文件替换的形式来配置,不够方便。
还有一种方案则是把参数注入到 cookie 中,效果和放在 header 里看似是一样的,但是会导致前后端发起的每一个请求都带着这些参数,白白浪费了很多带宽。
而是否要用这套配置也要视实际项目需求来定,很多 saas paas 的产品由于严格的上线流程,就是需要在打包中配置环境变量。那自然就不能用这种方案了。
另外,这个问题在面试中也有可能会出现,用来考察你对前端项目的打包、部署、托管的了解程度。毕竟现在的面试题越来越偏向实操而不是八股了。