npm7.0源码分析(一)之npm启动

3,430 阅读6分钟

前言

nodejs系列文章传送门

之前分析了nodejs的模块机制,如果大家对nodejs模块机制还不了解,可以再去对应的文章看一下。之前也说了,要写一系列关于nodejs的文章,那npm作为nodejs的包管理工具,就必须要深入学习一下,好好了解它的实现原理,正所谓工欲善其事必先利其器,今天我们就好好分析一下npm的启动逻辑,为后续其他npm命令打下基础。我们知道npm用于在nodejs技术栈对CommonJS模块进行增删改查,然而npm其本身同样也是一个CommonJS模块,也可以通过npm命令对其进行增删改查,它同样遵守模块的规范,以npm@7.0为例(下面所有的分析都是基于7.0版本,该版本较之前的版本从整体执行逻辑上做了较大的重构,代码逻辑更清晰,更易维护和扩展),我们直接来看下它的package.json,会发现在bin字段里,npm作为可执行命令,其逻辑入口是基于bin/npm-cli.js文件,尽然找到入口,话不多说,我们直接从这个入口触发。

核心启动原理

为了更好的分析整个npm启动逻辑,直接vscode debug走起。我为了不在全局npm包下做调试,因为可能需要改动一下npm包里的代码来更好的调试,所以就本地安装了npm包,直接利用全局安装的npm包也是可以的。直接index.js里require('npm/bin/npm-cli'),这里以npm i nopt --no-package-lock为例子贯串全文。创建launch.json,打上断点,F5调试开始走起。

大家在看本篇文章的时候也可以边debug边看,这样能有更好的代入感。

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "runtimeExecutable": "/usr/local/bin/node",
      "request": "launch",
      "name": "Launch Program",
      "skipFiles": [
        "<node_internals>/**"
      ],
      "program": "index.js",
      "args": ["i", "nopt", "--no-package-lock"]
    }
  ]
}

npm逻辑入口

既然npm命令行的逻辑入口是bin/npm-cli.js,那我们直接锁定该文件,发现它的源码非常简单,就是在内部引用了lib/cli.js

#!/usr/bin/env node
require('../lib/cli.js')(process)

那我们马不停蹄,直接来看下cli.js做了什么,先看一下核心代码逻辑:

checkForBrokenNode()
...
checkForUnsupportedNode()
...
const npm = require('../lib/npm.js')
...
npm.load(async er => {

  ...
  const cmd = npm.argv.shift()
  const impl = npm.commands[cmd]
  if (impl)
    impl(npm.argv, errorHandler)
  else {
    npm.config.set('usage', false)
    npm.argv.unshift(cmd)
    npm.commands.help(npm.argv, errorHandler)
  }
})

总结起来主要做了以下三件重要的事情:

  1. 检查nodejs版本,做一些兼容性提示
  2. 加载核心模块lib/npm.js获取npm实例
  3. 进行npm.load,load之后根据process.argv中解析得到具体cmd执行对应逻辑

那么关键来了,既然为了获取npm实例而引入lib/npm.js,而且大部分核心处理逻辑都在npm实例上,我们必须分析一下npm实例化的整个过程。

npm实例化

从源码中我们会发现,npm实例是继承于EventEmitter的。直接看它的构造器,我省略了一些不重要的代码,

const npm = module.exports = new class extends EventEmitter {
  constructor () {
    super()
    ...
    this.command = null
    this.commands = proxyCmds(this) // 重点1:代理所有的cmd
        ...
    this.version = require('../package.json').version
    this.config = new Config({
      npmPath: dirname(__dirname),
      types,
      defaults,
      shorthands,
    }) // 重点2:获取执行过程所需的配置信息config
    this[_title] = process.title
    this.updateNotification = null
  }

不难看出,里面主要做了两件重要的事:

  1. 通过proxyCmds加载并代理所有定义过的可执行cmd
  2. 获取所需的配置信息config,查看详细npmrc配置,这些配置信息都维护在Config实例的config.data这个Map里

config.data里的这些信息会以['default','builtin','global','user','project','env','cli']逐级融合,后一项以前一项为原型,后面会详细说明,这里先有个概念:

  • default:包含所有

  • 包含所有默认命令行options配置

  • builtin:内建的npmrc下的运行时配置

  • global:全局npmrc下的运行时配置

  • user:用户配置的npmrc下的运行时配置

  • project:当前项目npmrc下的运行时配置

  • env:npm环境变量npm_config_开头的所有字段信息

  • cli:当前正在执行的cli命令的所有options(如--save等等),会依据所有命令行options的types类型配置及shorthands简写配置(如--save的简写-S之类)由nopt解析得到,得到的结果是一个以命令行option为key的对象,如{save:true,'save-dev':false}

逐个来看其实现逻辑,先来看proxyCmds

const proxyCmds = (npm) => {
  const cmds = {}
  return new Proxy(cmds, {
    get: (prop, cmd) => {
      if (hasOwnProperty(cmds, cmd))
        return cmds[cmd]

      const actual = deref(cmd)
      if (!actual) {
        cmds[cmd] = undefined
        return cmds[cmd]
      }
      if (cmds[actual]) {
        cmds[cmd] = cmds[actual]
        return cmds[cmd]
      }
      cmds[actual] = makeCmd(actual)
      cmds[cmd] = cmds[actual] // 同时将真实名字对应的命令实现赋值给别名
      return cmds[cmd]
    },
  })
}

const makeCmd = cmd => {
  const impl = require(`./${cmd}.js`)
  const fn = (args, cb) => npm[_runCmd](cmd, impl, args, cb)
  Object.assign(fn, impl)
  return fn
}

proxyCmds返回一个Proxy实例,

  • 将所有在npm中有定义的cmd(如install)都维护到了cmds这个对象中,并以cmds为target生成一个Proxy实例
  • 对cmd名称做derefderef最主要的作用是先将cmd(比如npm i对应了install)从camelCase转化成kebab-case,再从别名-真实名称的映射中寻找最终的真实名称(i对应的真实名称是install),根据这个真实名称来引用对应的模块文件(这里就是lib/install.js)
  • makeCmd中,根据kebab-case形式的真实名称引入对应cmd名称的模块文件,同时将这些引入的命令利用**npm[_runCmd]**实例方法统一封装。
  • 创建完对应cmd命令实现之后,在cmds中将真实名字对应的命令实现赋值给别名,这也是npm命令可以用很多别名的原因,从下图中我们就能直观的看到install这个命令的别名i同样存在于cmds中。

再来看一下配置信息是如何获取的:

先看一下Config构造器,这里的types、shorthands、defaults配置可以参考源码,是所有命令行options的配置。

constructor ({
    types,
    shorthands,
    defaults,
    npmPath,
    ...
  }) {
        ...
    this.data = new Map()
    let parent = null
    for (const where of wheres) { // 这里的wheres就是['default','builtin','global','user','project','env','cli']
      this.data.set(where, parent = new ConfigData(parent)) 
    }

  }

class ConfigData {
  constructor (parent) {
    this[_data] = Object.create(parent && parent.data)
    this[_source] = null
    this[_loadError] = null
    this[_raw] = null
    this[_valid] = true
  }
  
  get data () {
    return this[_data]
  }

结合ConfigData构造器,我们可以看到按照['default','builtin','global','user','project','env','cli']这个顺序,后一个ConfigData实例的data是以上一个ConfigData实例的data为原型,从而得到最终融合Config.data,所以当某一hasOwnProperty为fasle的字段被修改时正好会覆盖parent上对应字段,所以通过如config.data.get(key, where = 'cli')就能获取到对应key的配置项,如果找不到,则会从原型上逐级向上找。而这些ConfigData的赋值过程在npm.load中伴随npm.config.load进行。上面的图片展示了config还未load时data的样子,所有的ConfigData.data都是空的。

至此,npm实例化已经完成,接下来就是执行npm.loadnpm.load内部核心逻辑是在[_load]方法里。首先通过which找到process.argv[0](也就是/usr/local/bin/node)对应的可执行文件,这里拿到的是node命令,因为npm命令实际上是通过node来跑npm-cli.js。紧接着就是config.load

async [_load] () {
  const node = await which(process.argv[0]).catch(er => null)
  if (node && node.toUpperCase() !== process.execPath.toUpperCase()) {
    log.verbose('node symlink', node)
    process.execPath = node
    this.config.execPath = node
  }

  await this.config.load()
  this.argv = this.config.parsedArgv.remain
  ...
}

在这里,会将通过nopt解析得到的所有parsedArgv.remain赋值到npm.argv上,这里的remain字段上包含了nopt解析遗留下来的命令行参数,此时remain就是:

config.load

 async load () {
    if (this.loaded)
      throw new Error('attempting to load npm config multiple times')

    this.loadDefaults()
    await this.loadBuiltinConfig()
    this.loadCLI()
    this.loadEnv()
    await this.loadProjectConfig()
    await this.loadUserConfig()
    await this.loadGlobalConfig()
    
    ...
    this.validate()
   
    this[_loaded] = true

    this.globalPrefix = this.get('prefix')   
      ...
  }

['default','builtin','global','user','project','env','cli']里的所有都进行加载,加载完成的config.data如下图所示。这里的default就是源码中的默认命令行options配置项

npm.load结束之后触发回调,这是在cli.js中执行npm.load时传递的。

npm.load(async er => {
    if (er)
      return errorHandler(er)
    if (npm.config.get('version', 'cli')) {
      console.log(npm.version)
      return errorHandler.exit(0)
    }

    if (npm.config.get('versions', 'cli')) {
      npm.argv = ['version']
      npm.config.set('usage', false, 'cli')
    }

    npm.updateNotification = await updateNotifier(npm)

    const cmd = npm.argv.shift()
    const impl = npm.commands[cmd]
    if (impl)
      impl(npm.argv, errorHandler)
    else {
      npm.config.set('usage', false)
      npm.argv.unshift(cmd)
      npm.commands.help(npm.argv, errorHandler)
    }
  })

回调中,通过npm.argv.shift(),我们就拿到了当前npm命令行的执行命令名称,此例中的**i**,剩下的npm.argv就是['nopt']。正如上面介绍的,npm.commands里代理了所有定义过的cmd执行逻辑。通过执行impl(npm.argv, errorHandler)就进入到了具体的cmd执行逻辑,这里也就是lib/install.js。前面在makeCmd时也提到过,真正的cmd执行入口其实都已经收敛到npm实例的npm[_runCmd](cmd, impl, args, cb)。来看一下[_runCmd]方法的源码,其核心逻辑其实非常简单:

 [_runCmd] (cmd, impl, args, cb) {
    ...
    
    if (this.config.get('usage')) {
      console.log(impl.usage)
      cb()
    } else {
      impl(args, er => {
        process.emit('timeEnd', `command:${cmd}`)
        cb(er)
      })
    }
  }

如果当前为npm install --usage,此时拿到了usage字段为tue,则只是打印impl.usage,这里我顺便贴一下install.usage,大家发现没,其实install命令用途非常广,不知道的同学可以拓展一下,尤其它可以接收的模块目的地址可以有很多形式,除了模块名称,文件夹路径、git地址及tarball地址都可以,而且它的命令行options也比较丰富,可以满足我们不同的模块安装设置所需

const usage = usageUtil(
  'install',
  'npm install (with no args, in package dir)' +
  '\nnpm install [<@scope>/]<pkg>' +
  '\nnpm install [<@scope>/]<pkg>@<tag>' +
  '\nnpm install [<@scope>/]<pkg>@<version>' +
  '\nnpm install [<@scope>/]<pkg>@<version range>' +
  '\nnpm install <alias>@npm:<name>' +
  '\nnpm install <folder>' +
  '\nnpm install <tarball file>' +
  '\nnpm install <tarball url>' +
  '\nnpm install <git:// url>' +
  '\nnpm install <github username>/<github project>',
  '[--save-prod|--save-dev|--save-optional|--save-peer] [--save-exact] [--no-save]'
)

如果不是--usage,则会进入到实际的install执行逻辑,也就是lib/install.js模块导出的执行方法。这里顺便提一句,因为正如上面proxyCmds里所说,当npm实例化时,会根据真实命令名称引入对应文件模块,所以npm源码里所有被定义的cmd文件模块导出形式都是如下形式:

这也是7.0版本较之前版本重构较大的地方,而且这样的代码组织形式非常易于维护和扩展,增加新命令就非常方便。

const cmd = (args, cb) => install(args).then(() => cb()).catch(cb)

Object.assign(cmd, { completion, usage })

总结

至此,伴随npm实例化,在运行时,我们就可以拿到所有命令执行所需的所有配置项信息,接下来要做的就是根据这些丰富的配置项信息来处理特定的cmd执行逻辑。后续会逐一分析各个具体npm命令的执行逻辑,大家看到这里,我相信也可以很轻松的进入每一个npm命令里面看个究竟了,如果大家看完之后有什么想讨论的,非常欢迎大家留言评论,一起进步。