blog
blog copied to clipboard
基于 Docker 的 SPA 运行时变量方案
背景
SPA 与 Docker
一个 SPA 项目的产物通常来说是一份简单的 dist 文件夹,里面包括了诸如 index.html, js, css, images 等各种静态资源。然后丢到一个 static web server (e.g. nginx, apache) 上就能对外提供服务了。
后来,我们通过 Docker 来对这份 dist 做一个包装,用一个 nginx 的 base image 把它包裹起来,就变成通过 docker run 命令就能随时对外提供服务了。
FROM nginx:stable-alpine
COPY /dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
用这样一个简单的 Dockerfile 来打包一个前端镜像,并对外提供服务
docker run --rm -d -p 80:80 <image_name>
环境变量
无论是使用 webpack 还是 vite, 都会在对应的打包方案上看到环境变量,无论是使用 process.env.*
还是 import.env.*
, 其实都是在 npm build
的时候来做编译期的不同环境切换。
因此很多时候,前端打包会出现下面几种说法,打测试的包、打生产的包。也会产生不同的 npm scripts 和很多 .env
文件。
# 有很多不同的打包命令
npm run build:dev
npm run build:prod
Build once
在 12 Factors 里,描述了 Code, Build, Config, Deploy 之间的关系。具体到 SPA, 我们发现按照上面所说的编译期变量方案:
- 无法做到 build once, deploy anytime. 不同的 env 需要不同的 build
- Config 没有在 deploy 时使用,而是被固化到了 build 里,各种 .env 的变量其实都写到了具体的 js/css 中
因此我们希望有一个方案可以做到:
- Build once. SPA 的构建(具体而言就是 npm run build...)应该只有一次。当发布正式时,能够直接使用当前的这一份 Build.
- 运行时变量支持。可以在运行时改变 SPA 的行为。
- 结合 Docker, 通过环境变量来实现第 2 点的运行时变量(方便后续使用 K8s/Helm Chart 进行部署)
方案
搜索了一番,根据参考第一篇文章,基本思路如下:
- 用 npm run build 构建一次 SPA
- 用 Dockerfile nginx based image 构建一个 image, 入口调整为一个 shell 脚本
- 在运行时,执行 shell 脚本,对 container 内的 dist 静态文件做一些操作或直接生成一个 js 文件被 index.html 引用,然后再启动 nginx
- 代码中需要依赖运行时的逻辑,通过
window.__env
进行判断
构建与 Docker build
这部分不需要多解释。注意这里的 index.html
会默认在所有 bundled 的 js 前,引入一个 config.js
文件,里面定义了各种需要的运行时变量。
<div id="app"></div>
<script src="/config.js"></script>
<!-- built files will be auto injected -->
window.__env = {
PUBLIC_PATH: '/',
ABC: '111'
}
在本地开发的时候,新增一个 public/config.js
文件即可,内容可以是定义一个空对象。
window.__env = {}
这样就可以在代码里直接使用 window.__env.ABC
来进行逻辑判断了。
动态生成 config.js
使用 Dockerfile 构建镜像时,使用一个 shell 脚本作为启动命令
# 前面省略...
ENTRYPOINT ["/bin/sh", "/start-container.sh"]
然后在脚本里动态生成基于环境变量的 js 文件
#!/bin/bash
echo "window.__env = { PUBLIC_PATH: '${PUBLIC_PATH}' }" > /usr/share/nginx/html/config.js
nginx -g 'daemon off;'
像这样就能根据外部的环境变量把 container 里的文件覆盖了。
publicPath
如果使用 webpack 的话,有自带的 dynamic public path 功能,所有 async chunk 的 publicPath 都能通过 __webpack_public_path__
这个变量控制。(用过 qiankun 的应该都很熟悉了...)
所以只需要在你的 entry 最开始默认 import 一个 public-path.ts
, 然后在这个文件内部给这个变量赋值一个运行时变量(比如上面的 window.__env
)即可。
// main.ts
import 'public-path.ts'
// 后面省略...
// public-path.ts
// @ts-ignore
__webpack_public_path__ = window.__env.PUBLIC_PATH ?? '/'
这里有两个坑要注意。
第一,基于 css-loader/url-loader 的 publicPath 并不会运行时动态,需要给 url-loader 添加一个 postTransformPublicPath
选项。根据参考第 2 点的回答尝试,发现拼接出来的 publicPath 会多了一个 /, 要做一个去重处理才能 work. (也有可能是我的 publicPath 写成了 / 换成 ''
应该会没问题吧?没试过)
{
publicPath: '/',
postTransformPublicPath: (p) => `__webpack_public_path__ + (${p}.startsWith('/') ? ${p}.slice(1) : ${p})`,
}
第二,html entry 引用的 bundle 并不会动态 publicPath, 因为已经写死在 script src 里了,因此可以通过一个 sed 命令来直接把 index.html 的 publicPath 改了,注意要有一个 common 的 asset prefix
PARSED_PUBLIC_PATH=$(echo ${PUBLIC_PATH} | sed 's/\//\\\//g') # 这里要转义一下,不然 sed 会有问题
sed -i "s/\/static/${PARSED_PUBLIC_PATH}static/g" /usr/share/nginx/html/index.html
思考
本质上,这是一种对 bundled 产物的魔改,只是通过一些 shell 手段把这个 bundle time 从 compile delay 到了 runtime. 如果能清晰把握 bundled 的细节和结果,你也可以说这种 shell script 也是 bundle 的一种 extension 罢了(笑
另一种方案
除了通过在运行时把静态文件魔改掉之外,其实也可以将 static file 变成 dynamic 即可。比如上文提到的 index.html 可以通过一个 Node 服务器来 serve 动态的内容,这样也能做到相应的效果。