likes
comments
collection
share

你们是如何使用环境变量的?

作者站长头像
站长
· 阅读数 47

在工作中,或多或少会用到环境变量以区分不同场景或执行不同的业务逻辑,比如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定义的模块地方,自动引入该模块,不需要提前importrequire

在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或者其他环境下还是要合打包工具来使用才可。

前端轮子层出不穷, 个人水平也实属有限, 一定还存在使用环境变量的其他方式, 欢迎评论区补充。