记一次不完全的源码调试之npm install

前言

自从去年年底发了一篇2021年年终总结后,有好长一段时间没发过文了,这其中的原因包括但不限于:

  • 述职晋升(折腾了快一个月)
  • 过年(回家待了半个月只想躺着)
  • 搬砖,写bug,改bug,加班
  • 懒(……

摆烂.png

刚刚经历完周末连续加班的我,在今天猛然发现三月即将过半,而且我的OKR进度竟然还是0%……不行不行不能这样,我还有补救的空间……刚好这时候clone了一个项目下来,条件反射式地输入npm install后,我忽然想到,写个npm install不是正好吗:

前端高手.png

于是这篇文章就诞生了。

寻找程序入口

首先明确我们的目标:就是要通过源码调试来看看npm install的过程。

调试任何程序,我们的第一步都应该是去寻找程序的入口,所以我们需要先在本地安装npmnpmNode.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:

image.png

点击后VSCode会让我们选择调试运行的环境,这里我们选择Node.js:

image.png 选择后,VSCode会在项目根目录生成.vscode文件夹,其中就有launch.json配置文件。

image.png 默认生成的配置文件可能不符合我们的需求,因此这里需要手动修改一些配置。在之前的部分我们已经找到了npm install的入口文件,所以这里的命令我们修改为${workspaceFolder}/bin/npm-cli.jsargs修改为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
        }
    ]
}
复制代码

配置完成之后,我们尝试点击运行和调试按钮:

image.png 调试程序成功启动了,并且停留在了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,直接点击调试按钮:

image.png

在命令选项中选择我们配置好的debugger命令:

image.png

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参数中的命令,此时cmdinstall。此处若没有命令,则直接输出错误信息,跳出进程了。接下来执行了npm.exec(cmd, npm.argv)方法,执行结束后returnexitHandler()进行退出进程的处理,显而易见npm.exec(cmd, npm.argv)就是核心程序。

exec方法在./lib/npm.js中:

image.png 这里精简一下源码,只留下核心部分,看下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中:

image.png 哇哦,看到这里感觉好像快要到终点了呢!

怪不好意思的.jpg

来看看这个方法写了啥,精简下:

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中。

image.png

到了这里,终于看到了曙光了……

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

Guess you like

Origin juejin.im/post/7074934981625118750