在自己开发的包下执行 npm link之后,发生了什么?

268 阅读6分钟

在自己开发的包下执行 npm link之后,发生了什么?

概述

比如我有一个项目叫做my-cli

在项目的package.json里设置bin的值,作用是执行npm link之后,我们在任何终端地方执行my-cli命令都会执行cli.js这个文件。

{
  "name": "my-cli",
  "version": "1.0.0",
  "main": "index.js",
  "bin": {
    "my-cli": "./dist/cli.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "type": "module",
  "author": "",
}

而用什么环境(或者说程序)执行,则是通过在cli.js中头部加上的注释来判断

#!/usr/bin/env node

这句表示是用node来执行这个脚本文件

初探

可以观察到执行npm link之后,node安装目录下出现了几个文件,分别是:

  • my-cli
  • my-cli.cmd
  • my-cli.ps1

而node_modules下会出现my-cli这个文件夹,可以看到他是一个软链接,即真正的文件在原来执行npm link时的路径下,这里相当于只是一个快捷方式。

image.png

image-1.png

我是windows系统,所以我发现在终端执行my-cli命名时,调用的是my-cli.ps1这个文件

#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent

$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
  # Fix case when both the Windows and Linux builds of Node
  # are installed in the same directory
  $exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
  # Support pipeline input
  if ($MyInvocation.ExpectingInput) {
    $input | & "$basedir/node$exe"  "$basedir/node_modules/my-cli/dist/cli.js" $args
  } else {
    & "$basedir/node$exe"  "$basedir/node_modules/my-cli/dist/cli.js" $args
  }
  $ret=$LASTEXITCODE
} else {
  # Support pipeline input
  if ($MyInvocation.ExpectingInput) {
    $input | & "node$exe"  "$basedir/node_modules/my-cli/dist/cli.js" $args
  } else {
    & "node$exe"  "$basedir/node_modules/my-cli/dist/cli.js" $args
  }
  $ret=$LASTEXITCODE
}
exit $ret

我想看明白上面写了什么便问问Ai:

image-3.png

image-12.png

如果顺便想要学习一下powershell脚本的内容,则可以在vscode安装个插件进行断点调试

image-2.png

安装完可以创建launch.json

image-5.png

选择powerShell(刚刚安装插件后才有这个选项),之后选择launch script(你可以选别的)

image-6.png

image-7.png

在创建的launch.json中把script的值换成my-cli,之后在my-cli.ps1文件下打上断点后运行点击运行debug按钮就可以顺利调试了

image-11.png

image-10.png

调试npm源码,看看npm link怎么实现的

我们知道安装完node,他的路径会加入到操作系统的path环境变量里,所以我们才可以在任意位置访问到node,my-cli也同理,由于脚本文件被创建到了node下,所以也能在终端随时访问。

接下来我们去npm源码看一下具体npm link的实现方式。

首先到node安装路径下的node_modules目录里,找到npm包,并查看package.json文件内容,里面有bin字段,这个字段的值就是npm的入口文件

image-13.png 顺着入口打上断点,

image-16.png

再结合vscode的调试功能,很快就可以找到npm link的实现方式了。

在launch.json里,添加Launch via npm的配置。在runtimeArgs数组里添加link,然后点击运行调试按钮,就可以开始寻找npm link的实现方式了...

image-14.png

{
  "name": "测试npm link原理",
  "request": "launch",
  "runtimeArgs": [
    "link"
  ],
  "runtimeExecutable": "npm",
  "skipFiles": [
    "<node_internals>/**"
  ],
  "type": "node"
},

image-17.png 看到这一句,就知道了link命令的具体执行代码,就在commands文件夹下

return require(`./commands/${command}.js`)

从这一句可以看到npm link有没有加参数在功能上的区别。

image-18.png

定位到创建文件和软连接是通过@npmcli/arborist这个包的reify方法实现的

const Arborist = require('@npmcli/arborist')
const arb = new Arborist({
  ...this.npm.flatOptions,
  Arborist,
  path: globalTop,
  global: true,
})
await arb.reify({
  add,
})

找到创建软链接的地方

image-19.png

这里是创建脚本的地方,就前面提到的三个脚本 xx.ps1、xx.cmd、xx

image-20.png

这里可以看到之前为什么入口文件有需要指定运行环境的地方,他读取了入口文件的第一行

image-21.png

看到这些脚本语言,我们终于知道原来脚本是在这个函数里生成的。

image-22.png

总结

通过这次探索,我们学习了如何使用调试去观察代码的执行流程,并且也知道了命令行脚本的调试方法。还了解了如何借助AI帮我们辅助了解代码。在调试的过程中,我们可以根据一些现象去寻找源码的实现方式。所以前提条件需要我们对要调试的功能有一个初步的认识。 执行npm xx这种命令时,调用的是node_modules\npm\lib\commands\xx.js这种同名文件。 在调试的过程中,咱们发现了npm的核心文件是node_modules\npm\node_modules\@npmcli\arborist\lib\arborist\reify.js,因为不管是npm install或者是npm link等命令,最终都会调用该文件。

最后我们在reify.js中找到了他是调用node的symlink方法创建的软链接,并且在node_modules\npm\node_modules\cmd-shim\lib\index.js里创建了那三个脚本文件,以便于我们在任何终端中输入命令名都可以正常执行脚本。

额外

我在cursor编辑器中,用gpt4o帮我们分析了一下reify.js的功能,可供参考:

reify.js这个文件是一个 JavaScript 模块,定义了一个名为 Reifier 的类,该类继承自传入的 cls 类。Reifier 类的主要功能是处理 Node.js 项目的依赖管理,特别是处理依赖的安装、更新和锁定文件的生成。以下是对该文件的详细分析:

主要功能

  1. 依赖安装与更新

    • reify 方法是该类的核心方法,负责根据传入的选项安装或更新项目的依赖。
    • 该方法会根据不同的安装策略(如 linked)调整依赖树,并处理依赖的差异。
  2. 依赖树的管理

    • 该类通过 _loadTrees 方法加载当前项目的实际依赖树和理想依赖树。
    • 使用 _diffTrees 方法计算实际依赖树和理想依赖树之间的差异。
  3. 文件系统操作

    • 通过 _reifyPackages 方法,执行一系列文件系统操作以安装或更新依赖。
    • 包括创建目录、移动文件、创建符号链接等。
  4. 错误处理与回滚

    • 在安装过程中,如果发生错误,使用一系列回滚方法(如 _rollbackRetireShallowNodes)来恢复到之前的状态。
  5. 锁定文件的生成

    • 使用 _saveIdealTree 方法保存理想依赖树的元数据到 package-lock.jsonshrinkwrap 文件中。
  6. 审计与报告

    • 通过 _submitQuickAudit 方法进行快速审计,检查依赖的安全性问题。
  7. 脚本执行

    • 在安装或更新依赖后,执行相关的 npm 脚本(如 predependencies, dependencies, postdependencies)。

代码结构

  • 私有方法:以 _ 开头的方法是类的私有方法,用于处理内部逻辑,如 _reifyPackages_loadTrees_diffTrees 等。
  • 符号属性:使用 Symbol 定义了一些私有属性和方法,确保它们在类外部无法直接访问。
  • 异步操作:大量使用 async/await 进行异步操作,确保文件系统操作和网络请求的顺序执行。

总结

Reifier 类是一个复杂的依赖管理工具,主要用于处理 Node.js 项目的依赖安装、更新和锁定文件的生成。它通过一系列异步操作和文件系统操作,确保项目的依赖状态与预期一致,并在出现错误时提供回滚机制。