rwson

rwson

一个前端开发

npm之npx源码阅读

npmnodejs的模块管理器,功能及其强大。甚至随着前端模块化的兴起,在平时的工作中,也必不可少的会接触npm。在npm 5.2.0以及后面的版本, 新增了npx命令,该条命令主要有以下几个特点:

  • 临时安装可执行依赖包,无需全局安装,不用担心长期的污染。(比如我们某个项目里用到nodemon这个nodejs启动器, 但是全局的node_modules中又没安装这个模块,然后通过npx nodemon xxx来安装并且执行。再比如我们项目里依赖的webpack 4.x,而系统全局安装的确是webkack 1.x,又不想把全局的替换掉,这时候我们可以通过npx webpack@4.x xxx来用我们项目里的版本编译相关代码)

  • 可以执行依赖包中的命令,安装完成自动运行。

  • 自动加载node_modules中依赖包,如果没找到,再去找全局,如果依然没找到,则会先安装,再执行。

  • 可以指定node版本、包的版本,解决了不同项目使用不同版本的包的问题。

了解了这些特点,我们来分析下npx的具体实现。首先找到入口文件bin/index.js

#!/usr/bin/env node

const path = require('path')
const npx = require('libnpx')


const NPM_PATH = path.join(__dirname, 'node_modules', 'npm', 'bin', 'npm-cli.js')

//    解析参数
const parsed = npx.parseArgs(process.argv, NPM_PATH)

parsed.npxPkg = path.join(__dirname, 'package.json')
npx(parsed)

入口文件中最后执行的是libnpx这个包,所以我们跟到libnpx/index.js这个文件,先看下刚才入口文件里面调用的npx.parseArgs

npx.parseArgs

//    parse-args.js

'use strict'

let npa
const path = require('path')

module.exports = parseArgs

/**
 * 
 * @param {String[]} argv     由[node路径, npx路径, npx xxx后面的xxx, .etc]组成的数组
 * @param {String} defaultNpm 默认的npm路径, 从当前项目的node_modules中找
 */
function parseArgs (argv, defaultNpm) {
  argv = argv || process.argv

  //  npx xxx而不是npx -xxx这种
  //  比如npx webpack
  if (argv.length > 2 && argv[2][0] !== '-') {
    return fastPathArgs(argv, defaultNpm)
  }

  //  npx -xxx yyy这种
  npa = require('npm-package-arg')

  const parser = yargsParser(argv, defaultNpm)

  const opts = parser.getOptions()
  const bools = new Set(opts.boolean)

  //  存储cmd所在的下面, 便于后面解析这条cmd时直接通过下标获取
  let cmdIndex
  let hasDashDash


  for (let i = 2; i < argv.length; i++) {
    const opt = argv[i]

    //  遇到'--', 标记hashDashDash为true, 跳出循环
    if (opt === '--') {
      hasDashDash = true
      break
    } 
    //  传递给nodejs的参数, 比如npx --node-arg=--inspect cowsay
    //  使用 `--inspect` 选项执行node二进制文件
    else if (opt === '--node-arg' || opt === '-n') {
      argv[i] = `${opt}=${argv[i + 1]}`
      argv.splice(i + 1, 1)

      //  如果元素以'-'开头
    } else if (opt[0] === '-') {
      //  npx --no-install webpack
      if (opt !== '--no-install' && !bools.has(opt.replace(/^--?(no-)?/i, '')) && opt.indexOf('=') === -1) {
        i++
      }
    } else {
      cmdIndex = i
      break
    }
  }

  if (cmdIndex) {

    /**
     * 解析npx中的参数而不是需要执行的包的参数
     * 比如npx --no-install webpack --watch, parsed应为:
     * {
     *    _: [ NODE_PATH, process.cwd(), .etc ],
     *    'no-install': true,
     *    其他npx默认参数
     * }
     */
    const parsed = parser.parse(argv.slice(0, cmdIndex))

    const parsedCmd = npa(argv[cmdIndex])

    parsed.command = parsed.package && parsedCmd.type !== 'directory'
      ? argv[cmdIndex]
      : guessCmdName(parsedCmd)

    parsed.isLocal = parsedCmd.type === 'directory'

    parsed.cmdOpts = argv.slice(cmdIndex + 1)
    if (typeof parsed.package === 'string') {
      parsed.package = [parsed.package]
    }
    parsed.packageRequested = !!parsed.package
    parsed.cmdHadVersion = parsed.package || parsedCmd.type === 'directory'
      ? false
      : parsedCmd.name !== parsedCmd.raw
    const pkg = parsed.package || [argv[cmdIndex]]
    parsed.p = parsed.package = pkg.map(p => npa(p).toString())
    return parsed
  } else {
    const parsed = parser.parse(argv)

    if (typeof parsed.package === 'string') {
      parsed.package = [parsed.package]
    }

    // 指定了-c并且-p之后, -p命令用来安装npm包(仅安装, 不执行相关二进制文件), -c命令用来执行bash
    //  比如: npx -p nodemon -c 'echo $PATH'
    //  会先按照nodemon这个包, 然后打印出系统的PATH
    if (parsed.call && parsed.package) {
      parsed.packageRequested = !!parsed.package
      parsed.cmdHadVersion = false
      const pkg = parsed.package
      parsed.p = parsed.package = pkg.map(p => npa(p).toString())
    } else if (parsed.call && !parsed.package) {
      parsed.packageRequested = false
      parsed.cmdHadVersion = false
      parsed.p = parsed.package = []
    } else if (hasDashDash) {

      //  存在'--'的情况, '--'会被忽略, 重新截取参数进行解析
      const splitCmd = parsed._.slice(2)
      const parsedCmd = npa(splitCmd[0])
      parsed.command = parsed.package
        ? splitCmd[0]
        : guessCmdName(parsedCmd)
      parsed.cmdOpts = splitCmd.slice(1)
      parsed.packageRequested = !!parsed.package
      parsed.cmdHadVersion = parsed.package
        ? false
        : parsedCmd.name !== parsedCmd.raw
      const pkg = parsed.package || [splitCmd[0]]
      parsed.p = parsed.package = pkg.map(p => npa(p).toString())
    }
    return parsed
  }
}

function fastPathArgs (argv, defaultNpm) {
  let parsedCmd
  let pkg

  //  npx xxx, xxx中只包含大小写字母/数字/中划线/下划线
  if (argv[2].match(/^[a-z0-9_-]+$/i)) {
    parsedCmd = { registry: true, name: argv[2], raw: argv[2] }

    // 使用该包的最新版本
    pkg = [`${argv[2]}@latest`]
  } else {
    npa = require('npm-package-arg')
    parsedCmd = npa(argv[2])

    //  npx ./xxx或者npx /xxx/yyy时, 认为走本地宝, 不需要安装模块
    if (parsedCmd.type === 'directory') {
      pkg = []
    } else {
      pkg = [parsedCmd.toString()]
    }
  }

  return {
    command: guessCmdName(parsedCmd),
    //  把argv的第4项以及后面的元素作为执行该条命令的参数, 如npx webpack --watch, 则cmdOpts为['--watch']
    cmdOpts: argv.slice(3),
    packageRequested: false,
    //  是否执行本地的包
    isLocal: parsedCmd.type === 'directory',
    //  遇到类似npx webpack@4时, cmdHadVersion为true, 后续自动安装的时候会按照这个版本来装并执行
    cmdHadVersion: (
      parsedCmd.name !== parsedCmd.raw &&
      parsedCmd.type !== 'directory'
    ),
    package: pkg,
    p: pkg,
    shell: false,
    noYargs: true,
    npm: defaultNpm || 'npm'
  }
}

parseArgs.showHelp = () => require('yargs').showHelp()

module.exports._guessCmdName = guessCmdName
/**
 * 根据spec返回不同命令
 * @param {String|Object} spec 
 */
function guessCmdName (spec) {
  //  略
  //  https://github.com/npm/npx/blob/latest/parse-args.js#L132
}

/**
 * npx内置参数解析
 * @param {String[]} argv     由[node路径, npx路径, npx xxx后面的xxx, .etc]组成的数组
 * @param {String} defaultNpm 默认的npm路径, 从当前项目的node_modules中找
 */
function yargsParser (argv, defaultNpm) {
  //  略
  //  https://github.com/npm/npx/blob/latest/parse-args.js#L160
}

var _y
function Y () {
  if (!_y) { _y = require('./y.js') }
  return _y
}

参数解析大致实现就是上面的内容,从入口文件中我们可以看到,参数解析完成后就是执行npx函数:

/**
 * 
 * @param {*} argv  npx.parseArgs返回的参数对象
 */
function npx (argv) {
  //  命令行自动回滚
  const shell = argv['shell-auto-fallback']
  if (shell || shell === '') {
    const fallback = require('./auto-fallback.js')(
      shell, process.env.SHELL, argv
    )
    if (fallback) {
      return console.log(fallback)
    } else {
      process.exitCode = 1
      return
    }
  }

  //  -c, -p, 包名称都没有指定
  if (!argv.call && (!argv.command || !argv.package)) {
    !argv.q && console.error(Y()`\nERROR: You must supply a command.\n`)
    !argv.q && parseArgs.showHelp()
    process.exitCode = 1
    return
  }

  const startTime = Date.now()

  return localBinPath(process.cwd()).then(local => {

    if (local) {
      //  将当前项目路径与系统中的$PATH路径进行拼接
      process.env.PATH = `${local}${path.delimiter}${process.env.PATH}`
    }

    return Promise.all([
      //  如果命令存在, 则先找出当前命令所在路径
      argv.command && getExistingPath(argv.command, argv),

      //  如果是npx -c 'echo $PATH'这种形式, 并且当前项目的.bin目录已经存在, 获取系统中所有的变量(npm run env --parseable)
      argv.call && local && getEnv(argv)
    ]).then(args => {
      const existing = args[0]
      const newEnv = args[1]

      if (newEnv) {
        // NOTE - we don't need to manipulate PATH further here, because
        //        npm has already done so. And even added the node-gyp path!
        Object.assign(process.env, newEnv)
      }

      //  在安装模块之前, 首先检查npx版本, 如果有更新则打印更新信息
      if ((!existing && !argv.call) || argv.packageRequested) {
        if (argv.npxPkg) {
          try {
            require('update-notifier')({
              pkg: require(argv.npxPkg)
            }).notify()
          } catch (e) {}
        }

        // 确保这个npm包存在于当前项目
        return ensurePackages(argv.package, argv).then(results => {
          if (results && results.added && results.updated && !argv.q) {
            console.error(Y()`npx: installed ${
              results.added.length + results.updated.length
            } in ${(Date.now() - startTime) / 1000}s`)
          }

          //    略
          //    https://github.com/npm/npx/blob/latest/index.js#L79
        })
      } else {
        // We can skip any extra installation, 'cause everything exists.
        return existing
      }
    }).then(existing => {
      /**
       * 开始执行真正的命令
       * npx webpack --watch -> wabpack --watch
       */
      return execCommand(existing, argv)
    }).catch(err => {
      !argv.q && console.error(err.message)
      process.exitCode = err.exitCode || 1
    })
  })
}

npx这个函数中, 首先调用了localBinPath这个函数,该函数的作用就是找到当前项目中node_modules/.bin的绝对路径,这边不做分析,可以从这里找到源码,下面我们看下getExistingPath这个函数:

/**
 * 找出要执行的命令的路径是否存在
 * @param {String} command 命令: webpack/nodemon这些
 * @param {*} opts         从npx xxx中解析的出参数
 */
function getExistingPath (command, opts) {
  if (opts.isLocal) {
    return Promise.resolve(command)
    //  如果cmd中指定了版本, 或者需要安装, 或者忽略已经存在的包
  } else if (opts.cmdHadVersion || opts.packageRequested || opts.ignoreExisting) {
    return Promise.resolve(false)
  } else {
    //  使用which命令来查找, 类似linux平台下的which xxx
    return which(command).catch(err => {
      if (err.code === 'ENOENT') {
        if (opts.install === false) {
          err.exitCode = 127
          throw err
        }
      } else {
        throw err
      }
    })
  }
}

npx中的Promise.all中同时调用了getEnv,在上面已经有注释,也不做分析了,可以从[这里]([https://github.com/npm/npx/blob/latest/index.js#L120](https://github.com/npm/npx/blob/latest/index.js#L127找到源码,下面我们看下ensurePackages这个函数,这个函数相当重要:

/**
 * 作用:
 * 安装模块
 * 在程序退出后,删除安装的模块
 * 修改本次运行时的PATH,优先级: 安装缓存 > 当前项目路径 > 系统PATH
 */
function ensurePackages (specs, opts) {
  return (
    opts.cache ? Promise.resolve(opts.cache) : getNpmCache(opts)
  ).then(cache => {
    //  将模块安装在全局的NPM_CHCHE/_npx下面
    const prefix = path.join(cache, '_npx', process.pid.toString())
    const bins = process.platform === 'win32'
      ? prefix
      : path.join(prefix, 'bin')
    const rimraf = require('rimraf')

    //  订阅退出事件, 退出后同步删除NPM_CHCHE/_npx里面安装的内容
    process.on('exit', () => rimraf.sync(prefix))

    //  先删除之前的bin目录, 然后安装新包
    return promisify(rimraf)(bins).then(() => {
      return installPackages(specs, prefix, opts)
    }).then(info => {
      //  把本次的缓存路径也加到PATH前面
      process.env.PATH = `${bins}${path.delimiter}${process.env.PATH}`

      //  返回安装目录信息, 给后续查找使用
      if (!info) { info = {} }
      info.prefix = prefix
      info.bin = bins
      return info
    })
  })
}

ensurePackages中调用了installPackages这个函数,由它负责安装具体的模块到具体路径,这里也省略分析, 可以从这里找到源码,现在我们回到npx函数,继续往下分析,下面就是执行真正的命令了,在execCommand这个函数,在execCommand中还有一个相对重要的函数,我们先分析它,在之前被省略的内容中,也有多次调用了,下面我们先看下findNodeScript 这个函数:

/**
 * 找出具体可执行文件的路径
 * @param {*} existing 
 * @param {*} opts 
 */
function findNodeScript (existing, opts) {
  if (!existing) {
    return Promise.resolve(false)
  } else {
    return promisify(fs.stat)(existing).then(stat => {
      //  如果是执行本地文件, 并且文件格式为js, 直接返回
      //  类似npx ./xxx.js [--xxx] [--yyy]这种形式
      if (opts && opts.isLocal && path.extname(existing) === '.js') {
        return existing
      } 
      //  如果执行的是本地目录
      else if (opts && opts.isLocal && stat.isDirectory()) {
        // npx will execute the directory itself
        try {
          //  从package.json中的bin或者main指向找目标文件, 否则默认就是目录下的index.js
          const pkg = require(path.resolve(existing, 'package.json'))
          const target = path.resolve(existing, pkg.bin || pkg.main || 'index.js')

          //  再判断下, 其实是走的上面的(opts && opts.isLocal && path.extname(existing) === '.js')分支
          return findNodeScript(target, opts).then(script => {
            if (script) {
              return script
            } else {
              throw new Error(Y()`command not found: ${target}`)
            }
          })
        } catch (e) {
          throw new Error(Y()`command not found: ${existing}`)
        }
      } else if (process.platform !== 'win32') {
        const bytecount = 400
        const buf = Buffer.alloc(bytecount)
        return promisify(fs.open)(existing, 'r').then(fd => {
          return promisify(fs.read)(fd, buf, 0, bytecount, 0).then(() => {
            return promisify(fs.close)(fd)
          }, err => {
            return promisify(fs.close)(fd).then(() => { throw err })
          })
        }).then(() => {
          //  读取需要执行的文件内容, 并且判断文件内容里是否包含 #!/usr/bin/env node、/usr/local/bin/node、/usr/bin/node这里面的其中一个
          const re = /#!\s*(?:\/usr\/bin\/env\s*node|\/usr\/local\/bin\/node|\/usr\/bin\/node)\s*\r?\n/i

          //  如果包含上面三个其中一个, 就返回具体的路径, 否则返回null
          return buf.toString('utf8').match(re) && existing
        })
      } else if (process.platform === 'win32') {
        const buf = Buffer.alloc(1000)
        return promisify(fs.open)(existing, 'r').then(fd => {
          return promisify(fs.read)(fd, buf, 0, 1000, 0).then(() => {
            return promisify(fs.close)(fd)
          }, err => {
            return promisify(fs.close)(fd).then(() => { throw err })
          })
        }).then(() => {
          return buf.toString('utf8').trim()
        }).then(str => {
          const cmd = /"%~dp0\\node\.exe"\s+"%~dp0\\(.*)"\s+%\*/
          const mingw = /"\$basedir\/node"\s+"\$basedir\/(.*)"\s+"\$@"/i
          return str.match(cmd) || str.match(mingw)
        }).then(match => {
          return match && path.join(path.dirname(existing), match[1])
        })
      }
    })
  }
}

有了上面的findNodeScript,下面就是真正的调用了execCommand

/**
 * 执行真正需要执行的命令
 * @param {*} _existing 
 * @param {*} argv 
 */
function execCommand (_existing, argv) {
  return findNodeScript(_existing, argv).then(existing => {
    const argvCmdOpts = argv.cmdOpts || []

    //  npx ./xxx.js或者npx ./xxxx会进这个分支
    if (existing && !argv.alwaysSpawn && !argv.nodeArg && !argv.shell && existing !== process.argv[1]) {
      const Module = require('module')
      if (!argv.noYargs) {
        require('yargs').reset()
      }
      process.argv = [
        process.argv[0],
        existing
      ].concat(argvCmdOpts)

      //  执行本地js, 类似node ./xxx.js [-xxx] [-yyy]或者node ./xxx [-xxx] [-yyy]
      Module.runMain()
    } else if (!existing && argv.nodeArg && argv.nodeArg.length) {
      throw new Error(Y()`ERROR: --node-arg/-n can only be used on packages with node scripts.`)
    } else {
      let cmd = existing
      let cmdOpts = argvCmdOpts
      if (existing) {
        cmd = process.argv[0]
        if (process.platform === 'win32') {
          cmd = child.escapeArg(cmd, true)
        }
        cmdOpts = argv.nodeArg

        //  拼接命令中的参数
        if (cmdOpts) {
          cmdOpts = Array.isArray(cmdOpts) ? cmdOpts : [cmdOpts]
        } else {
          cmdOpts = []
        }

        cmdOpts = cmdOpts.reduce((acc, arg) => {
          return acc.concat(arg.split(/\s+/))
        }, [])
        cmdOpts = cmdOpts.concat(existing, argvCmdOpts)
      }
      const opts = Object.assign({}, argv, { cmdOpts })

      //  runCommand在./child.js中封装, 大致实现逻辑用child_process.spawn来执行相关命令
      //  https://github.com/npm/npx/blob/latest/child.js
      return child.runCommand(cmd, opts).catch(err => {
        if (err.isOperational && err.exitCode) {
          process.exitCode = err.exitCode
        } else {
          throw err
        }
      })
    }
  })
}

这大概就是我个人认为npx中比较重要的相关代码的分析,踉踉跄跄,网上关于npx的源码基本上找不到,所以也基本上没有参考,如果有分析的不到位的,还望指正。

总结

我们在命令行中输入了npx xxxx [--yyy]之后,npx内部大概实现了下面几件事情:

  • 分析xxx [--yyy]这段内容,最后得出是否是执行本地文件,还是npm包,判断是否需要安装新版本等

  • 然后先找出当前命令所在真实路径,并且修改本次执行时process中的信息

  • 确保模块存在当前项目,如果不存在,安装模块到$NPM_CACHE_$/_npx/xxx下面

  • 根据路径找到真正的可执行二进制文件或者js的绝对路径,用child_process.spawn 或者类似node ./[xxx/]yyy.js的形式来实现启动

  • 程序退出,删除之前安装的临时模块