你们是如何使用环境变量的?
在工作中,或多或少会用到环境变量以区分不同场景或执行不同的业务逻辑,比如process.env.NODE_ENV
或者自定义的变量process.env.ALEI
。我列举了本人见识过的几种用法,下面会从源码角度分析他们的区别。
举个例子, 业务代码如下, 根据process.env.ALEI
是否存在打印success或者failure
// example.js
const fn = () => {
const flag = process.env.ALEI
if (flag) {
console.log('success')
} else {
console.log('failure')
}
}
方式一: webpack.DefinePlugin
// webpack.config.js
{
...
plugins: [
new webpack.DefinePlugin({
'process.env.ALEI': JSON.stringify("888"),
'aaa': JSON.stringify('aba')
})
]
}
DefinePlugin
为webpack内置插件, 在webpack编译阶段, 如果用到DefinePlugin
定义的变量, 就会将变量替换成对应的值。比如上面的样例代码就会被转换成
// example.js
const fn = () => {
// process.env.ALEI => '888'
const flag = '888'
if (flag) {
console.log('success')
} else {
console.log('failure')
}
}
那么webpack是如何识别到哪些变量需要被替换的呢?下面先简单讲一下webpack的编译流程,方便后面的理解,也可先观看上篇文章后继续。
webpack可以看作是一个工厂,内部有很多车间负责不同的工序(Hook),对于原材料(Source)首先经过初始化处理(Resource => Module)后交付各车间加工, 同时工厂还对外招标只要按照工厂标准都可以参与材料加工(Plugin),经过各种工序处理后,工厂输出成品(Bundle)。
webpack是基于Tapable
实现编译流程的,在编译的不同时段会执行不同的订阅逻辑(Hook),所有的webpack插件(Plugin)都是基于这一特性来实现的。在编译普通js文件时,首先会创建该文件关联的NormalModule
对象,对象中包含文件路径、类型(javascript/auto
)、编译对象parser
、源代码(source
)、依赖的文件(Dep
)等, 这些信息都会在编译过程中用到,并且normalModule
对象以及parser
对象都可以订阅自己不同阶段的处理逻辑,即订阅不同的hooks。 normalModule
上的parser
主要用来对文件源代码进行编译,通过对文件ast
的转换以达到编译文件的目的;DefinePlugin
就是利用这个特性,实现了变量的替换。
回到上面的问题, 那么webpack是如何识别到哪些变量需要被替换的呢。 在DefinePlugin
内部,会将定义的变量逐个遍历注册对应Hook。 在parser
对代码ast
转换时, 会查询是否注册过该变量的hook, 如果有则执行hook的逻辑。下面看下DefinePlugin
的源码(为了方便阅读代码做了精简)
class DefinePlugin {
constructor(definitions) {
this.definitions = definitions;
}
apply(compiler) {
const definitions = this.definitions;
// 注册compiliation hook, webpack开始编译时执行
compiler.hooks.compilation.tap(
"DefinePlugin",
(compilation, { normalModuleFactory }) => {
const handler = parser => {
const applyDefine = (key, code) => {
// 注册parser解析Identifier时对应的处理逻辑
parser.hooks.evaluateIdentifier
.for(key)
.tap("DefinePlugin", expr => {
// 通过parser创建变量值对应的新的AST语句
const res = parser.evaluate(code);
res.setRange(expr.range);
return res;
})
}
const walkDefinitions = (definitions) => {
// 遍历DefinePlugin定义的变量注册对应hook
Object.keys(definitions).forEach(key => {
const code = definitions[key];
applyDefine(key, code);
})
}
walkDefinitions(definitions)
}
// 注册解析js文件时的hook
normalModuleFactory.hooks.parser
.for("javascript/auto")
.tap("DefinePlugin", handler);
})
)
}
}
normalModuleFactory.hooks.parser
hook在解析NormalModule时执行, 即解析js文件时执行, 这时会将DefinePlugin
定义的变量值,逐个注册parser编译逻辑; parser.hooks.evaluateIdentifier
在parser解析文件AST特定语句时执行。对于const flag = process.env.ALEI
语句,转换成AST
时,等号两边都会转成对应的Identifier
相关的语句(如下图)
在编译到process.env.ALEI
时, 会查找是否存在对应变量名的hook(DefinePlugin注册的
)
由于在DefinePlugin
内部已经注册了process.env.ALEI
变量的hook, 所以执行到DefinePlugin
中注册的parser.hooks.evaluateIdentifier
函数中,调用parser
方法创建变量值'888'
对应AST节点,在后续流程中用新的AST节点替换老的节点,达到替换变量的目的。
// DefinePlugin.js
parser.hooks.evaluateIdentifier
.for(key)
.tap("DefinePlugin", expr => {
// 通过parser创建变量值对应的新的AST语句
const res = parser.evaluate(code);
res.setRange(expr.range);
return res;
})
// JavascriptParser.js
evaluateExpression(expression) {
// 获得变量值对应的AST节点
const result = hook.call(expression);
if (result !== undefined && result !== null) {
// 替换节点
result.setExpression(expression);
return result;
}
}
此外,平时我们在webpack.config.js
文件中配置的mode: "development" | "production"
最后也是通过DefinePlugin
注册到process.env.NODE_ENV
变量上的。
方式二: webpack.ProviderPlugin
// webpack.config.js
{
plugins: [
new webpack.ProvidePlugin({ process: "process/browser" }),
]
}
ProviderPlugin
也是webpack官方提供的插件,主要作用是:在用到ProviderPlugin
定义的模块地方,自动引入该模块,不需要提前import
或require
。
在web项目中也见过如上的配置, 但是要安装process
对应的依赖包, 在业务代码中如果有用到process.env.XXX
的地方会自动引入import 'process/brower'
。 但是查看了process
包源码后, 在web模式下,该包只是提供了process.env = {}
这样一个全局变量, 以及部分api
, 并不能达到我们自定义变量的目的。process
部分源码如下图
个人认为仅仅通过上述配置,只能保证web模式下使用自定义的环境变量process.env.ALEI
时不会报错,还需配合其他插件给全局process.env
赋予不同的值才能达到最终目的。(不知道是不是我姿势不对, 如有其他见解,还望大佬指出)
借此也看一下ProviderPlugin
的源码, 看看如何自动引入模块的
class ProvidePlugin {
constructor(definitions) {
this.definitions = definitions;
}
apply(compiler) {
const definitions = this.definitions;
compiler.hooks.compilation.tap(
"ProvidePlugin",
(compilation, { normalModuleFactory }) => {
const handler = (parser, parserOptions) => {
// 遍历注册的变量
Object.keys(definitions).forEach(name => {
const request = [].concat(definitions[name]);
// 解析变量时如果用到比如'process.env.xxx'的语句则执行下面逻辑
parser.hooks.expression.for(name).tap("ProvidePlugin", expr => {
const nameIdentifier = name.includes(".")
? `__webpack_provided_${name.replace(/\./g, "_dot_")}`
: name;
// 创建模块依赖
const dep = new ProvidedDependency(request[0], nameIdentifier, request.slice(1), expr.range);
dep.loc = expr.loc;
// 将依赖加入到该模块下
parser.state.module.addDependency(dep);
return true;
});
});
normalModuleFactory.hooks.parser
.for("javascript/auto")
.tap("ProvidePlugin", handler);
}
)
}
}
和上一节DefinePlugin
源码很类似, 也是在parser
编译AST
语句时处理对应逻辑, 在遇到比如process.env.xxx
这种语句时, 会创建import 'process/browser'
语句的Dep
对象,通过parser.state.module.addDependency(dep)
将dep
对象添加到该模块依赖的字段中, 最后再经过webpack其他流程把该模块所有依赖的模块Deps
导出到bundle中。
方式三: DotEnv
// webpack.config.js
const DotEnv = require("dotenv-webpack");
{
plugins: [
new DotEnv(),
]
}
// .env
ALEI=666
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=12345
dotenv
并不是webpack官方提供的插件,因此使用时要先安装dotenv-webpack
。用法很简单, 创建一个.env
文件, 将需要用到的自定义变量写入.env
文件,在webpack.config.js
中引入dotenv
即可。dotenv
默认会读取运行目录下.env
文件, 也可指定具体路径,这样就可以在业务代码中使用自定义变量了。
看了dotenv
的源码, 其实内部也是用的webpack.DefinePlugin
插件, 将.env
中文件读取出来,做一些特定转换,统一经过DefinePlugin
注册变量hook。 (dotenv
部分源码如下)
class DoteEnv {
constructor (config = {}) {
this.config = Object.assign({}, {
path: './.env',
prefix: 'process.env.'
}, config)
}
apply (compiler) {
const variables = this.gatherVariables()
const target = compiler.options.target ?? 'web'
const version = (compiler.webpack && compiler.webpack.version) || '4'
const data = this.formatData({
variables,
target,
version
})
// 通过DefinePlugin注册变量hook
new DefinePlugin(data).apply(compiler)
}
getEnvs () {
const { path, silent, safe } = this.config
// 读取文件内容转换成对象
const env = dotenv.parse(this.loadFile({
file: path,
silent
}), this.getDefaults())
...
return {
env,
}
}
...
}
简而言之就是将文件内容转成变量,注册变量hooks。
方式四: cross-env
// package.json
{
"script": {
"dev": "cross-env ALEI=818 webpack --config webpack.config.js"
}
}
cross-env
与前面三种方式有本质区别, 前面三种方式都是通过webpack提供的插件机制在编译过程中干预某些流程实现变量的替换,cross-env
则是将变量注册到node的执行上下文中。
看下cross-env
源码
cross-env
会先解析命令行语句, 解析出变量以及command,然后通过spawn创建子进程执行命令, 并把解析出的变量注入到上下文中
我们发现, 虽然通过cross-env
注入了环境变量, 但是这个执行上下文是node的, 也就是说这个变量在webpack编译阶段可取,在业务代码中并没办法使用, 因此还是要配合webpack.DefinePlugin
才可
//webpack.config.js
{
plugins: [
new webpack.DefinePlugin({
BAL: process.env.ALEI
})
]
}
总结
通过对比我们发现,上面几种方式各有利弊。DefinePlugin
是打通业务代码与环境变量的基本方式;process/browser
提供了全局变量process.env
以及少量api, 特别是在webpack5之后, process已经不在作为polyfill提供,在升级webpack后如遇其他npm包没及时跟进适配报process
不存在的情况, 可作为解决方案;DotEnv
虽然本质也是利用DefinePlugin
, 但是提供了另外一种方式, 对于自定义变量较多的情况是种不错的选择, 或者对于多环境部署(比如开发、测试、预发、生产)的情况, 可以配置不同环境下的.env
文件也非常方便;cross-env
对于node应用可能更友好一点, 如果要在web或者其他环境下还是要合打包工具来使用才可。
前端轮子层出不穷, 个人水平也实属有限, 一定还存在使用环境变量的其他方式, 欢迎评论区补充。
转载自:https://juejin.cn/post/7159123105812578335