blog icon indicating copy to clipboard operation
blog copied to clipboard

基于 Docker 的 SPA 运行时变量方案

Open ryancui92 opened this issue 2 years ago • 0 comments

背景

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, 我们发现按照上面所说的编译期变量方案:

  1. 无法做到 build once, deploy anytime. 不同的 env 需要不同的 build
  2. Config 没有在 deploy 时使用,而是被固化到了 build 里,各种 .env 的变量其实都写到了具体的 js/css 中

因此我们希望有一个方案可以做到:

  1. Build once. SPA 的构建(具体而言就是 npm run build...)应该只有一次。当发布正式时,能够直接使用当前的这一份 Build.
  2. 运行时变量支持。可以在运行时改变 SPA 的行为。
  3. 结合 Docker, 通过环境变量来实现第 2 点的运行时变量(方便后续使用 K8s/Helm Chart 进行部署)

方案

搜索了一番,根据参考第一篇文章,基本思路如下:

  1. 用 npm run build 构建一次 SPA
  2. 用 Dockerfile nginx based image 构建一个 image, 入口调整为一个 shell 脚本
  3. 在运行时,执行 shell 脚本,对 container 内的 dist 静态文件做一些操作或直接生成一个 js 文件被 index.html 引用,然后再启动 nginx
  4. 代码中需要依赖运行时的逻辑,通过 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 动态的内容,这样也能做到相应的效果。

参考

  1. 为 Single Page App 提供运行时环境变量
  2. Webpack url-loader dynamic public path

ryancui92 avatar Jan 04 '23 04:01 ryancui92