每日一个npm包 —— crossenv

2,878 阅读4分钟

crossenv 介绍

💡 跨平台运行脚本,支持 设置 和 使用 环境变量

解决什么问题

大多数情况下,当我们在 package.jsonscripts 中使用类似于 NODE_ENV=production 的命令设置环境变量的时候,往往会提示报错。因为 Windows 和其他 Unix 系统 bash 的命令是不一样的。其设置环境变量的命令也不同:

  • Windows:"SET NODE_ENV=production && webpack"
  • Unix:"EXPORT NODE_ENV=production && webpack"

同样,在 Windows 和 Unix 命令行中使用环境变量的方式也有所不同:

  • Windows: %ENV_VAR%
  • Unix 上:$ENV_VAR

因此,我们可以使用 cross-env 将命令兼容于 Windows 和 Unix 。这样就可以用 Unix 方式设置环境变量,同时兼容 Windows 系统。即用一行命令,再在不同端执行:"cross-env NODE_ENV=production && webpack $NODE_ENV"

crossenv 源码分析

在深入源码前让我们先了解一下 npm scripts 原理

npm scripts

我们通常会在 package.jsonscripts 中写下类似的命令:

{
  "scripts": {
    "build": "cross-env NODE_ENV=production webpack --config build/webpack.config.js"
  }
}

我们通过 cross-env 设置环境变量 NODE_ENV=production,同时调用 cross-spawn 执行 webpack --config build/webpack.config.js。那当我们执行 npm run build 时,背后究竟发生了什么呢?

npm 脚本的原理非常简单。每当执行 npm run,就会自动新建一个 Shell,在这个 Shell 里面执行指定的脚本命令。因此,只要是 Shell(一般是 Bash)可以运行的命令,就可以写在 npm 脚本里面。

比较特别的是,npm run 新建的这个 Shell,会将当前目录的 node_modules/.bin 子目录加入 PATH 变量,执行结束后,再将 PATH 变量恢复原样。

这意味着,当前目录的 node_modules/.bin 子目录里面的所有脚本,都可以直接用脚本名调用,而不必加上路径。比如,当前项目的依赖里面有 cross-env,只要直接写 cross-env *** 就可以了。

我们可以在依赖 cross-env 的项目中找到 ./node_modules/.bin/cross-env 文件,其内容如下:

#!/usr/bin/env node

const crossEnv = require('..')

crossEnv(process.argv.slice(2))

由于 npm 脚本的唯一要求就是可以在 Shell 执行,因此它不一定是 Node 脚本,任何可执行文件都可以写在里面。所以第一行 #!/usr/bin/env node 的作用是指定这个脚本的解释程序是 node。

紧接着我们看到 crossEnv 函数的引入,并且把 process.argv 传入 crossEnv 进行调用,在上面的 build 示例中,crossEnv 的入参为 ['NODE_ENV=production', 'webpack', '--config', 'build/webpack.config.js']

crossEnv 逻辑

function crossEnv(args, options = {}) {
  // 1
  const [envSetters, command, commandArgs] = parseCommand(args)
  // 2
  const env = getEnvVars(envSetters)
  if (command) {
    // 3
    const proc = spawn(
      commandConvert(command, env, true),
      commandArgs.map(arg => commandConvert(arg, env)),
      {
        stdio: 'inherit',
        shell: options.shell,
        env,
      },
    )
    // 4
    process.on('SIGTERM', () => proc.kill('SIGTERM'))
    process.on('SIGINT', () => proc.kill('SIGINT'))
    process.on('SIGBREAK', () => proc.kill('SIGBREAK'))
    process.on('SIGHUP', () => proc.kill('SIGHUP'))
    proc.on('exit', (code, signal) => {
      let crossEnvExitCode = code
      if (crossEnvExitCode === null) {
        crossEnvExitCode = signal === 'SIGINT' ? 0 : 1
      }
      process.exit(crossEnvExitCode) 
    })
    return proc
  }
}

crossEnv 函数分四步:

  1. 将入参 args 分为 [envSetters, command, commandArgs] 三部分。接着上述例子:

    args = ['NODE_ENV=production', 'webpack', '--config', 'build/webpack.config.js']
    
    envSetters = { NODE_ENV: 'production' }
    command = 'webpack'
    commandArgs = ['--config', 'build/webpack.config.js']
    
  2. 处理 envSetters 并将其与 process.env 融合生成 env

  3. 如果 command 存在,则利用 spawn 开启子进程,跑 command 命令,并附带上 commandArgs,同时将子线程的 process.env 设置成 env

  4. 监听进程的信号事件,并将其传递给子/父进程。

接下来让我们来仔细看每一步是如何实现的。

parseCommand 逻辑

const envSetterRegex = /(\w+)=('(.*)'|"(.*)"|(.*))/

function parseCommand(args) {
  const envSetters = {}
  let command = null
  let commandArgs = []
  for (let i = 0; i < args.length; i++) {
    const match = envSetterRegex.exec(args[i])
    if (match) {
      let value

      if (typeof match[3] !== 'undefined') {
        value = match[3]
      } else if (typeof match[4] === 'undefined') {
        value = match[5]
      } else {
        value = match[4]
      }

      envSetters[match[1]] = value
    } else {
      let cStart = []
      cStart = args
        .slice(i)
        .map(a => {
          const re = /\\\\|(\\)?'|([\\])(?=[$"\\])/g
          return a.replace(re, m => {
            if (m === '\\\\') return '\\'
            if (m === "\\'") return "'"
            return ''
          })
        })
      command = cStart[0]
      commandArgs = cStart.slice(1)
      break
    }
  }

  return [envSetters, command, commandArgs]
}

可以看到 parseCommand 逻辑为遍历 args 数组,用正则表达式 /(\w+)=('(.*)'|"(.*)"|(.*))/ 来提取环境变量,如(NODE_ENV=production),其返回数组 match,表达式第 i 个括号包围表达式所匹配的字符串储存在 match[i] 中,match[0] 为整个表达式所匹配的字符串。所以 match[3] 的作用是删掉变量两边的 ''match[4] 同理。match[2] 未被使用,笔者觉得这应该是个冗余。

遍历 args 直到表达式未匹配成功,这时默认接下来第一个参数是 command,往后的参数都是 commandArgs,并且用正则表达式 /\\\\|(\\)?'|([\\])(?=[$"\\])/g 对它们进行处理。?= 为向前断言,感兴趣的读者可以看其具体介绍

getEnvVars 函数

getEnvVars 也主要是利用正则表达式来处理输入,具体细节读者可以自行看源码,这里不再赘述

spawn 函数

spawn 开启一个子进程,并在子进程里执行我们所期望的操作。

commandConvert 函数

const isWindows = () =>
  process.platform === 'win32' || /^(msys|cygwin)$/.test(process.env.OSTYPE)

function commandConvert(command, env, normalize = false) {
  if (!isWindows()) {
    return command
  }
  const envUnixRegex = /\$(\w+)|\${(\w+)}/g // $my_var or ${my_var}
  const convertedCmd = command.replace(envUnixRegex, (match, $1, $2) => {
    const varName = $1 || $2
    return env[varName] ? `%${varName}%` : ''
  })
  return normalize === true ? path.normalize(convertedCmd) : convertedCmd
}

commandCovert 将环境变量的使用转换成兼容当前操作系统的形式。判断是否为 Windows 系统主要依据 process.platformprocess.env.OSTYPE 的值。如果当前操作系统不是 Windows 则直接返回 command,否则进行处理。主要是将 $my_var${my_var} 变成 %my_var%

总结

cross-env 之所以能跨平台运行脚本同时支持设置和使用环境变量,是因为在跑实际脚本前加了一层,在这层内处理环境变量的兼容性。建议读者自己动手翻翻源码。可以对 regexp 的实际使用有更深入的了解,同时也能收获 npm scripts 和 node 子进程的知识。

引用资料