前言
自从去年年底发了一篇2021年年终总结后,有好长一段时间没发过文了,这其中的原因包括但不限于:
- 述职晋升(折腾了快一个月)
- 过年(回家待了半个月只想躺着)
- 搬砖,写bug,改bug,加班
- 懒(……
刚刚经历完周末连续加班的我,在今天猛然发现三月即将过半,而且我的OKR进度竟然还是0%……不行不行不能这样,我还有补救的空间……刚好这时候clone了一个项目下来,条件反射式地输入npm install
后,我忽然想到,写个npm install
不是正好吗:
于是这篇文章就诞生了。
寻找程序入口
首先明确我们的目标:就是要通过源码调试来看看npm install
的过程。
调试任何程序,我们的第一步都应该是去寻找程序的入口,所以我们需要先在本地安装npm
。npm
是Node.js
附带的包管理器,安装Node.js
就会自动安装npm
,直接官网下载安装即可:nodejs.org/en/ 。我选的是Node.js
的16.x版本。下载安装之后验证一下安装是否成功:
~
➜ node -v
v16.14.0
~
➜ npm -v
8.3.1
安装成功,接下来准备进入调试。
为了执行调试步骤,我们需要先找到npm install
命令的入口。我用的是mac,在终端任意路径下可以通过which npm
来查看可执行文件的地址:
➜ which npm
/usr/local/bin/npm
由此可知,npm
命令实际执行的是/usr/local/bin/npm
文件。
/usr/local/ bin是用户放置自己的可执行程序的地方。
用ll
命令查看文件的详细信息:
➜ ll /usr/local/bin/npm
lrwxr-xr-x 1 root wheel 38B 3 30 2021 /usr/local/bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js
可以发现/usr/local/bin/npm
是一个指向../lib/node_modules/npm/bin/npm-cli.js
的软链接,真正执行的是/usr/local/lib/node_modules/npm/bin/npm-cli.js
文件。
软连接是linux中一个常用命令,它的功能是为某一个文件在另外一个位置建立一个同不的链接。具体用法是:ln -s 源文件 目标文件。当 我们需要在不同的目录,用到相同的文件时,我们不需要在每一个需要的目录下都放一个必须相同的文件,我们只要在其它的 目录下用ln命令链接(link)就可以,不必重复的占用磁盘空间。 软链接类似于windows上的快捷方式。
调试准备
去npm
的git仓库clone代码:github.com/npm/cli ,这里用的lastest分支。
clone下来后开始进行调试准备,我们需要使用VSCode来调试。
第一种是常规调试配置:
首先点击左侧的debug图标,创建一个launch.json:
点击后VSCode会让我们选择调试运行的环境,这里我们选择Node.js:
选择后,VSCode会在项目根目录生成.vscode文件夹,其中就有launch.json配置文件。
默认生成的配置文件可能不符合我们的需求,因此这里需要手动修改一些配置。在之前的部分我们已经找到了
npm install
的入口文件,所以这里的命令我们修改为${workspaceFolder}/bin/npm-cli.js
,args
修改为install dayjs
(install什么都行,只要能够触发命令就行)即可。stopOnEntry
配置为true
,可以在进行debug时停留在入口:
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: <https://go.microsoft.com/fwlink/?linkid=830387>
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/bin/npm-cli.js",
"args": ["install", "dayjs"],
"stopOnEntry": true
}
]
}
配置完成之后,我们尝试点击运行和调试按钮:
调试程序成功启动了,并且停留在了
npm-cli.js
文件上。接下来就可以进一步调试了。
除了上面中规中矩的调试配置外,我们还可以走些捷径,因为我们要调试的是一个npm
包,所以可以使用npm
命令进行调试。
VSCode配置npm调试命令 详见官方文档
在package.json
中添加一个用于debug(名字叫什么都可以)的script:
"scripts": {
"debugger": "node ./bin/npm-cli.js install dayjs",
},
这相当于如下的配置:
{
"name": "Launch via npm",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run-script", "debugger"]
}
之后,在VScode中找到我们的package.json,直接点击调试按钮:
在命令选项中选择我们配置好的debugger命令:
ok,也同样进入了debug调试过程,并且走到了我在cli.js文件中打的断点。
Install过程解析
在前面的步骤中,我们找到了npm
命令的执行入口,是./bin/npm-cli.js
。文件中只有一行代码:
#!/usr/bin/env node
require('../lib/cli.js')(process)
很显然,下一步我们应该去看./lib/cli.js
文件。文件内容也不多:
// Separated out for easier unit testing
module.exports = async process => {
// set it here so that regardless of what happens later, we don't
// leak any private CLI configs to other programs
process.title = 'npm'
// We used to differentiate between known broken and unsupported
// versions of node and attempt to only log unsupported but still run.
// After we dropped node 10 support, we can use new features
// (like static, private, etc) which will only give vague syntax errors,
// so now both broken and unsupported use console, but only broken
// will process.exit. It is important to now perform *both* of these
// checks as early as possible so the user gets the error message.
const { checkForBrokenNode, checkForUnsupportedNode } = require('./utils/unsupported.js')
checkForBrokenNode()
checkForUnsupportedNode()
const exitHandler = require('./utils/exit-handler.js')
process.on('uncaughtException', exitHandler)
process.on('unhandledRejection', exitHandler)
const Npm = require('./npm.js')
const npm = new Npm()
exitHandler.setNpm(npm)
// if npm is called as "npmg" or "npm_g", then
// run in global mode.
if (process.argv[1][process.argv[1].length - 1] === 'g') {
process.argv.splice(1, 1, 'npm', '-g')
}
const log = require('./utils/log-shim.js')
const replaceInfo = require('./utils/replace-info.js')
log.verbose('cli', replaceInfo(process.argv))
log.info('using', 'npm@%s', npm.version)
log.info('using', 'node@%s', process.version)
const updateNotifier = require('./utils/update-notifier.js')
let cmd
// now actually fire up npm and run the command.
// this is how to use npm programmatically:
try {
await npm.load()
if (npm.config.get('version', 'cli')) {
npm.output(npm.version)
return exitHandler()
}
// npm --versions=cli
if (npm.config.get('versions', 'cli')) {
npm.argv = ['version']
npm.config.set('usage', false, 'cli')
}
updateNotifier(npm)
cmd = npm.argv.shift()
if (!cmd) {
npm.output(await npm.usage)
process.exitCode = 1
return exitHandler()
}
await npm.exec(cmd, npm.argv)
return exitHandler()
} catch (err) {
if (err.code === 'EUNKNOWNCOMMAND') {
const didYouMean = require('./utils/did-you-mean.js')
const suggestions = await didYouMean(npm, npm.localPrefix, cmd)
npm.output(`Unknown command: "${cmd}"${suggestions}\n`)
npm.output('To see a list of supported npm commands, run:\n npm help')
process.exitCode = 1
return exitHandler()
}
return exitHandler(err)
}
}
其实我们只要顺着代码一行行的读下来,就会发现,一直到try catch
前的代码都是一些检查、初始化的工作;到了try catch
代码块的部分,才是npm
的真正加载处理。
读源码的时候才能感受到,什么叫写得好的代码。即使不去逐行调试,光是通过方法、变量的命名,以及清晰的注释就可以将代码的工作了解的八九不离十,可见好的命名、好的注释是多么的重要……
try catch
内的代码,我们主要集中精力在下面这段就可以了:
cmd = npm.argv.shift()
if (!cmd) {
npm.output(await npm.usage)
process.exitCode = 1
return exitHandler()
}
await npm.exec(cmd, npm.argv)
return exitHandler()
在这里首先取出了npm
参数中的命令,此时cmd
为install
。此处若没有命令,则直接输出错误信息,跳出进程了。接下来执行了npm.exec(cmd, npm.argv)
方法,执行结束后return
了exitHandler()
进行退出进程的处理,显而易见npm.exec(cmd, npm.argv)
就是核心程序。
exec
方法在./lib/npm.js
中:
这里精简一下源码,只留下核心部分,看下exec方法:
// Call an npm command
async exec (cmd, args) {
// 初始化命令....
// 检查命令中的非法字符....
// 检查执行的工作空间...
if (filterByWorkspaces) {
// 在工作空间下执行命令...
} else {
return command.exec(args).finally(() => {
process.emit('timeEnd', `command:${cmd}`)
})
}
}
由于我们是在项目目录下执行的npm install
,因此在这里命中到最后的else分支,执行了command.exec()
方法。继续单步调试发现这个方法在./lib/commands/install.js
中:
哇哦,看到这里感觉好像快要到终点了呢!
来看看这个方法写了啥,精简下:
async exec (args) {
// the /path/to/node_modules/..
// 初始化一些变量...
// be very strict about engines when trying to update npm itself
// 升级npm,需要特殊处理...
// 全局安装的特殊处理
const opts = {
...this.npm.flatOptions,
auditLevel: null,
path: where,
add: args,
workspaces: this.workspaceNames,
}
const arb = new Arborist(opts)
await arb.reify(opts)
// 特殊安装命令的处理,例如preinstall...
await reifyFinish(this.npm, arb)
}
这里的重点,是reify
方法。该方法位于./workspaces/arborist/lib/arborist/reify.js
中。
到了这里,终于看到了曙光了……
await this[_validatePath]()
await this[_loadTrees](options)
await this[_diffTrees]()
await this[_reifyPackages]()
await this[_saveIdealTree](options)
await this[_copyIdealToActual]()
await this[_awaitQuickAudit]()
没错,这一排await方法,正是npm install
的核心!
恭喜你,读到这里,终于即将看到npm install
的真正核心内容,但是你以为我还会继续读下去吗?
No,我尝试着继续单步调试每一个方法,但是呢,内容实在是太多,没办法一次性弄懂node_modules
的所有构造机制……所以暂且把这个部分挖个坑,以后有时间再逐个击破。
调试源码谨记原则之一——聚焦问题,不要想着一次就把整个逻辑看清
总结
虽然这次的源码调试并没有完全的走完全流程,但是也收获颇多:
- 好的代码是真的可以让人像读自然语言一样,顺畅的读下来;
- 清晰的命名、注释和代码拆分都十分的重要(此处反思下平常写的代码,还得努力学习鸭);
- 源码调试,要带着具体的问题去看去分析;
没看完的部分,就当作是立个flag,下次一定.jpg