前言
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)
}
})
总结起来主要做了以下三件重要的事情:
- 检查nodejs版本,做一些兼容性提示
- 加载核心模块lib/npm.js获取npm实例
- 进行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
}
不难看出,里面主要做了两件重要的事:
- 通过
proxyCmds
加载并代理所有定义过的可执行cmd - 获取所需的配置信息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名称做
deref
,deref
最主要的作用是先将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.load
,npm.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命令里面看个究竟了,如果大家看完之后有什么想讨论的,非常欢迎大家留言评论,一起进步。