前言
在后端项目中,很多时候会带有一些启动参数,这些我们称它为配置参数,如 mysql-user
mysql-password
等等。
这可以使后端应用可以通过不同的参数即可运行在不同的环境下,达到软件复用。
前端呢?
前端也有,一般我们项目中都会使用 dotenv
在项目中使用,传入一些开发环境的启动参数。
以达到不同的开发运行时。
前端运行时
dotenv
做的事情,其实只是把 config
里面对应 path
读取到的 .env
文件里面的键值对给加载到了 process.env
这个对象中。
但是 process
是一个 Node
运行时的 global
变量。
换言之,一个 Node
运行时,是怎么在我们的浏览器环境中访问到的呢?
答案是 Webpack.DefinePlugin
, 无论是 nuxt
还是 vue-cli
的项目,底层都是通过调用 webpack.definePlugin
进行运行时的 process.env
变量设置的。
说设置并不太准确,准确来说应该是字符串替换。
比如 process.env.API_SERVER
经过编译后,就会直接替换成我们环境中的变量值。
load env
处理环境变量的方式, vue-cli
跟 nuxt
略有不同。
虽然两者的源码中都包含了 LoadEnv
这个方法。
in nuxt
在 nuxt
中,我们写的明明是定义的变量,比如 API_SERVER
最终会被它自己解析成 process.env.{key}
。
源码中是这么定义的~
加载 nuxt context env
的时候,会对我们定义的 env
里的变量进行处理,帮我们加上了前缀,同时如果我们定义的是字符串,会帮我们使用 JSON.stringify
包裹一下,这是为什么呢?这里留个悬念。
env () {
const env = {
'process.env.NODE_ENV': JSON.stringify(this.mode),
'process.mode': JSON.stringify(this.mode),
'process.dev': this.dev,
'process.static': this.target === TARGETS.static,
'process.target': JSON.stringify(this.target)
}
if (this.buildContext.buildOptions.aggressiveCodeRemoval) {
env['typeof process'] = JSON.stringify(this.isServer ? 'object' : 'undefined')
env['typeof window'] = JSON.stringify(!this.isServer ? 'object' : 'undefined')
env['typeof document'] = JSON.stringify(!this.isServer ? 'object' : 'undefined')
}
Object.entries(this.buildContext.options.env).forEach(([key, value]) => {
env['process.env.' + key] =
['boolean', 'number'].includes(typeof value)
? value
: JSON.stringify(value)
})
return env
}
所以我们如果想要在 nuxt
中注入环境变量,需要先使用 dotenv
,在 nuxt.config.js
中的顶部加上。
require('dotenv').config()
这样就会默认的帮我们把项目根目录的 .env
里面定义的简直对给加载到 Node
运行时了。
这个插件的源码也挺简单的, 核心方法就是一个 config
,一个 parse
。
config
就是读取文件,交给 parse
解析对象,再合并读取的内容到 process.env
。
说了那么多,上面的都是把环境变量加载到 server
运行时而已。
那么怎么才能把变量共享到 client
端呢?
The env property defines environment variables that should be available on the client side. They can be assigned using server side environment variables, the dotenv module ones or similar.
上面是官方文档的介绍,大意是,你在 nuxt.config.js
定义的属性,可以从 client
获取到。
这个设计我想应该是,假如你的 server
运行时变量非常多,那么不可能一股脑给你加载到 client
端去,所以这里手动声明也情有可原。
但是为了简便起见,我们可以这样定义。
export default {
srcDir: 'src/',
mode: 'spa',
env: ['API_SERVER', 'PUBLIC_PATH', 'CONSOLE_URL']
// 这里没有进行 JSON.stringify TODO
.reduce((acc, key) => (acc[key] = process.env[key], acc), {}),
// env: {
// API_SERVER: process.env.API_SERVER,
// PUBLIC_PATH: process.env.API_SERVER,
// CONSOLE_URL: process.env.CONSOLE_URL
// },
}
然后我们来看看 nuxt
源码是如何加载 env
的吧~
核心加载源码在 packages/config/src/load.js
下
// 读取并处理 env 对象
const env = loadEnv(envConfig, rootDir)
// Fill process.env so it is accessible in nuxt.config
for (const key in env) {
// !key.startsWith('_') 应该是私有变量,所以不读取
// 当前 env 对象中不存在的值,就从读取的变量中赋值过去
if (!key.startsWith('_') && envConfig.env[key] === undefined) {
envConfig.env[key] = env[key]
}
}
最后会在 nuxt/webpack/src/client.js
中的 plugins
就会注入 env
了
plugins.push(
new HTMLPlugin({
filename: '../server/index.spa.html',
template: appTemplatePath,
minify: buildOptions.html.minify,
inject: true
}),
new VueSSRClientPlugin({
filename: `../server/${this.name}.manifest.json`
}),
new webpack.DefinePlugin(this.env())
)
当底层还是调用的 webpack.definePlugin
进行前端注入的
in vue-cli
在 vue-cli
中使用规范更多,它规定了只有前缀为 VUE_APP
开头的变量才会被注入到客户端。
只有以
VUE_APP_
开头的变量会被webpack.DefinePlugin
静态嵌入到客户端侧的包中。你可以在应用的代码中这样访问它们
它的加载过程其实也比较容易看懂。
我们运行 Vue
项目的时候,入口其实是 packages/@vue/cli-service
这个包。
包的入口是
"bin": {
"vue-cli-service": "bin/vue-cli-service.js"
},
然后里面是执行了 packages/@vue/cli-service/lib/Service.js
实例化之后,再使用 Service
实例的 run
方法解析我们输入的参数。
const Service = require('../lib/Service')
const service = new Service(process.env.VUE_CLI_CONTEXT || process.cwd())
// 这里为什么要 slice(2) 呢?
// 因为前面 2 位都是固定参数
// 第一位是 执行的 node 的为止
// 第二位是 执行脚本的为止
const rawArgv = process.argv.slice(2)
// 解析用户输入的命令,具体规则 https://github.com/substack/minimist
const args = require('minimist')(rawArgv, {
boolean: [
// build
'modern',
'report',
'report-json',
'inline-vue',
'watch',
// serve
'open',
'copy',
'https',
// inspect
'verbose'
]
})
// 这里拿到了我们的启动参数, 比如 vue-cli-service serve 拿到的就是 serve
const command = args._[0]
service.run(command, args, rawArgv).catch(err => {
error(err)
process.exit(1)
})
run
会执行 init
方法,里面是的功能注释已经说明白了。
// load env variables, load user config, apply plugins
this.init(mode)
但我们这里要看的是 env
相关的操作, env
对象中的 key
会被 resolveClientEnv.js
过滤掉不是 VUE_APP
前缀的变量,再写入 webpack.definePlugin
插件里。
最后去执行 packages/@vue/cli-service/``lib/commands/server.js
里面暴露出来的方法,这里就是初始化我们的开发环境服务器的地方了。
更细节的大家自行源码阅读。
vue-cli
的 env
使用规则有点不太方便的就是,如果本身我们的变量的 key
就定义的很长,再加上 VUE_APP
的前缀,会显得有点冗长。
到这里为止,文章已经把 vue
中 2 个框架是如何加载环境变量的方式分析完了。
下面我们来聊聊,怎么做到只需构建一次,然后在多个环境下部署运行(环境变量不一样的情况下)。
build once deploy anywhere
传统的前端部署来说,一般是构建是把构建好的成品文件,放在 nginx
或者是其他的静态文件托管服务中,配上域名或者访问地址,就能展示给用户了。
这里的问题是,前端构建的时候,其实已经我们在代码中定义的 process.env.xx
给替换成完毕了,部署的又是静态资源文件,如何做到构建运行时呢?
1. 全局变量
在项目中创建配置一个 js
文件, 定义各种变量,最后这个 js
文件可以在项目中用内联的方式读取进去。
配置文件例如
window.$deepexi = {
config: {
API_SERVER: 'http://deepexi.com',
// ... other env
}
}
这样做可以做到项目运行时的一些环境变量的读取,但是对于模板中的含有 publicPath
的链接,就显得有点鸡肋了。
所以如果考虑不使用动态上下文的话,还是可以尝试抽离使用的
2. 镜像打包
2.1 整个项目打包 - 启动时构建
最简单的方法,就是把我们的前端直接打包成一个镜像,里面包含 node_modules
,每次运行在别的地方,先使用 docker run -e
把运行时参数传递进去,再进行构建。
这样的缺点也非常明显,就是我们的镜像会随着依赖的增多而变大(无上限)。
2.2 打包构建产物 - 对变量进行字符串替换
这里还有另外一个思路,就是我们打包的时候,使用占位符的形式,启动镜像的时候,再替换成真实的运行时的环境变量。
比如我的环境变量有一个为 API_SERVER
那么我构建的时候它的赋值就应该为 _PLACEHOLDER__API_SERVER
这样的话,我们就能在启动镜像的时候,搜索这个 _PLACEHOLDER__API_SERVER
直接替换即可。
总结
本篇文章为读者重在为读者理清了环境变量的加载机制,以及一些实施思路,为此我们还专门去阅读了相关的源代码!
下篇文章将会以实战的方式为读者带来如何构建多环境运行镜像。
敬请期待~
最后
最后这里解答一下悬念吧~
主要是因为,如果我们传入的是字符串的话,字符串的 **值 **会原样的插入代码中执行。
举个栗子。
new webpack.definePlugin({
"process.env.VERSION": "1.0.0"
})
// before
const VERSION = process.env.VERSION
// after
const VERSION = 1.0.0 // 错误🙅的执行代码 1.0.0 不是一个变量
所以正确做法应该是 JSON.stringify(${value})
。
更详细的解答请查看。