一文彻底搞懂CLI全局命令、npm link、npx、npm srcipts、node_modules/.bin

1,805 阅读4分钟

当我们在开发npm包或者应用程序的时候,经常会用到CLI全局命令、npm link、npx、npm srcipts、node_modules/.bin这些概念,本文将一次性讲解清楚这些概念背后的原理。

CLI全局命令

当我们全局安装了一个npm包之后,我们就可以在电脑的任意终端使用这个npm包提供的指令。比如当我们安装了vue cli,npm i -g @vue/cli,我们就可以在任意地方直接使用vue这个指令创建项目。vue creat hello-world,这是怎么做到的呢?在了解其背后的原理之前,我们还需要了解一些概念。

环境变量($PATH)

我们常说的环境变量就是$PATH,Windows和Mac都有这个东西。以我自己电脑为例(Mac),将环境变量打出来看下:

% echo $PATH

/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

上面打出来的就是环境变量的值,环境变量是一堆路径的集合,在Mac中以冒号分隔,当我们在终端(Shell)执行某个指令的时候,会在这些路径下面找是否有对应的可执行文件,如果有就执行,没有就会报错。环境变量里的路径:

// 环境变量中包含的路径
[
'/usr/local/bin',
'/usr/bin',
'/bin',
'/usr/sbin',
'/sbin'
]

所以如果我们要直接执行一个指令,那么需要在环境变量的路径下有一个同名的可执行文件。

npm安装全局指令流程

我们在执行npm i -g @vue/cli后就可以使用vue的全局指令了,这是因为npm帮我们在环境变量的路径下创建了一个名为vue的文件,那么我们就可以在全局使用vue这个指令执行vue这个文件。在Mac电脑中一般都是放在/usr/local/bin目录下,打开目录如下所示:

image.png

可以看到vue文件在目录下,并且左下角还有一个箭头,这表明vue文件是一个软链接,它指向真正要执行的文件,我们可以通过ls -al查看/usr/local/bin目录下文件,如下图所示,可以看到vue -> ../lib/node_modules/@vue/cli/bin/vue.js这样一行内容,这个表明vue文件是一个软链接且实际指向的是 -> 后面的文件,我们在终端执行vue指令实际执行的是/usr/local/lib/node_modules/@vue/cli/bin/vue.js这个文件(全局安装的模块一般都安装在/usr/local/lib/node_modules目录下):

image.png

那么npm为什么会帮我们创建一个名字叫vue的软链文件,我们安装的明明是@vue/cli,且这个软链是怎么找到正确的要执行的文件的,这些是哪里设置的呢?

package.json里的bin字段

要回答上面的问题,我们需要来看下package.json里的bin字段,通过这个字段我们可以设置指令的名称以及对应指令要执行的文件。

image.png

如果我们的bin字段里的内容是这样的:

bin: {
    "vue": "bin/vue.js"
}

那么我们在执行全局安装的时候,npm会在环境变量的路径下生成一个软链文件,叫vue,且指向bin里设定的真实的可执行文件,所以当我们在终端输入vue,实际执行的是bin/vue.js

注:软链是什么?你可以理解每个文件或文件夹都是一个对象,软链就是对象的引用,比如 let a = {}; let b = {};,这时需要给a生成一个b的软链,a.b = b,这时候访问a.b就是相当于访问b。可以参考这边文章加深对软链的理解。

npm link

上面说的是全局安装包的模式,那如果是还在本地开发的包呢,我们不能每改一点东西就发到线上,然后安装下来再验证吧,这个时候就需要用到npm link了。

我们使用npm link的时候主要分为两种功能,一种是这个npm包需要被其他的包引用,一种是这个npm包作为命令行工具可以直接在终端用指令执行,或者同时具备以上两种特性。

作为npm包被其它包引用

假设我们有两个包,分别是a和b,b是我们本地在开发调试的包,a是我们的项目,a需要引用到b。

在开发调试的包执行npm link

这个时候我们会在b的目录下执行npm link,这个时候会发生什么呢?npm会帮我们生成一个软链的文件夹放到/usr/local/lib/node_modules目录下,且它的名字为package.json里name字段的值。

image.png

为什么放到这个目录下呢,可以理解为这是npm自己的一个约定,就像npm安装的全局包都是放在这个目录下,本地的npm包软链也都会放在这个目录下,为了方便后续的处理。

在项目包执行npm link

当我们在b包执行了npm link后已经在对应目录生成了一个b包的软链,这时候来到我们开发项目的a包,我们在a包下执行npm link b,这条指令的作用相当于在a下安装了b,效果如下:

image.png

可以看到b会被安装到a的node_modules下,如果没有node_modules目录,则该目录会被创建。值得注意的是a/node_modules里的b文件夹后面有一个箭头,说明b文件夹是一个软链(文件和文件夹都可以是软链),所有当我们在a模块里使用require('b')的时候,会通过模块查找机制找到node_modules下的b,而这个b又会软链到实际的b,这个时候我们在真正的b里面做的更改都会实时反应到a的b里面。

还有一个问题,为什么我们在a里面执行npm link b,在a/node_modules里生成的b软链能正确的找到真正的b文件夹的位置,理论上这个b文件夹可能放在任何地方,那它是怎么软链到的?

为了解释这个问题,需要回到上面看下在b目录下执行npm link的时候发生了什么,上文已经讲过,在b里面执行npm link也会生成软链,这个生成的的软链会统一放到/usr/local/lib/node_modules目录下,然后我们在a目录下执行npm link b的时候,是去/usr/local/lib/node_modules下找b(这里可以理解为是npm自己设定的约定),所以这里有两条软链,其关系是: a/node_modules/b -> /usr/local/lib/node_modules/b -> 真正的b文件,通过这样两层软链的结构,我们在a里面执行npm link b的时候就不需要关注b真正的文件夹的位置了。

同时我们还可以使用相对路径的形式来进行link,比如在a目录下执行npm link ../b(b和a为同级目录的情况),这种方式和上面的效果是一样的,相当于在b下执行了npm link,然后再到a下执行npm link b。

作为命令行工具需要直接在终端调用

还有一种是我们开发的npm包是需要作为命令行工具使用的,这个时候又该如何开发调试呢?比如说我们开发的b不是用于给a使用的,而是作为命令行工具,提供了一个say666的指令,可以在任意的地方直接say666,那么我们该如何实现呢?

其实操作方法和上面的一样,也是在b目录下执行npm link就可以了。唯一的不同点是作为命令行开发工具需要在package.json里添加bin字段,如下图所示,bin字段里的key会作为指令,key对应的值是该指令执行的文件。当我们执行在目录下执行npm link之后,就可以直接在命令行使用say666指令了。

image.png

原理是在执行npm link的时候,除了会在/usr/local/lib/node_modules/生成b文件夹的软链,还会检查package.json是否有bin字段,如果有bin字段,那么会给bin字段里的key也生成软链,这个软链会放在/usr/local/bin下,和安装全局包的时候软链放置的目录相同,因为这个目录在环境变量的路径里面,所以可以在命令行直接执行。

image.png

还有一个地方需要留意的是say.js文件开头有一行 #!/usr/bin/env node,这一行的专业术语叫做shebang,是用来告诉shell执行这个文件的时候使用node环境。这样设置后文件就可以直接执行,比如在b目录下直接输入指令./say.js而不需要使用node ./say.js

shebang的写法是固定的,这里不用写我们电脑node真正的路径,#!/usr/bin/env node会告诉系统,到环境变量($PATH)所在的路径里寻在node,最终会在/usr/local/bin 里找到node。如上图。

node_modules/.bin

当我们使用npm install安装模块的时候,如果被安装的模块的package.json里有bin字段,那么会将bin对象里的字段生成软链放到node_modules/.bin目录下。

举个例子,假设我们上文的b模块已经被发布到npm仓库了,然后我们在a目录下使用npm install b来局部安装b包,将b安装到a的node_modules目录下之后,会检查b的package.json里的bin字段,然后将bin字段里的键都生成软链统一放到a/node_modules/.bin目录下,如果a模块下还安装了c、d、e模块,也会做相同的检查,在a/node_modules/.bin里生成软链。

这个软链有什么用呢,后面会讲到。

image.png

npm scripts

在我们开发项目的过程中,经常可以使用类似npm run devnpm run serve,这样的指令来启动命令。这个背后发生了什么呢?

package.json里有一个scripts字段,在这里面有我们可以通过npm run执行的命令。比如如下图所示,我们在刚才创建的a/package.json里添加了一个scripts字段,里面的内容是serve: 'node index.js',那么我们便可以执行npm run serve,这句指令实际会执行node index.js,然后控制台会打出hello world,背后的原理是当我们执行npm run serve的时候,npm会通过fs.readFilcSync读取到当前目录下的package.json文件,然后通过我们run的是serve这个指令,找到scripts里的serve字段,取出里面的值。再新开一个shell,在新的shell里执行serve字段里的内容即node index.js

image.png

scripts的作用之一是将我们需要用到的指令都统一收拢到这个地方。scrips里的指令,还有一个很重要的功能就是我们可以直接执行项目node_modules目录下npm包bin里的指令。

如果我们局部安装了一个b包,这个b包里面有一个指令say666,如果我们想要执行这个指令,我们不能在控制台直接输入say666来执行,因为不是全局安装的。我们需要在控制台使用(a目录下)./node_modules/.bin/say666来执行。可是我们在scripts里是可以直接使用say666这个指令的,如下图所示,我们在终端可以直接执行npm run say,然后会执行scripts.say里的指令,在这个指令里可以直接使用say666,而不需要使用./node_modules/.bin/say666,原理是当执行npm run的时候,npm会将当前目录下的./node_modules/.bin这个路径加到环境变量$PATH里面,这个时候相当于./bin目录下的指令变成了全局指令,所以我们可以直接使用say666。当执行完npm run之后,又会把$PATH还原,即在环境变量里去除./node_modules/.bin这个路径。后续的流程就是通过软链找到真正的文件然后执行。

image.png

npx

上面说了如果我们安装了一个局部包b,我们想要使用b里的指令,那么需要写在a/package.json的scripts里,然后调用npm run say才行。每次想用个指令都要往scripts里添加也挺麻烦的,有没有更简单的方法呢。npx就是用来解决这个问题的。

如上文的例子,我们可以在控制台使用./node_modules/.bin/say666或者npm run say来调用say666指令,可是不能直接在控制台使用say666来调用这个指令。这个时候在前面加上npx就可以了。即我们可以使用npx say666来达到相同的目的。

原理是什么呢?

当我们执行npx的时候,npx会帮我们去./node_modules/.bin里去查找是否有对应的指令文件,如果有就会执行这个文件,即找到对应的文件后,npx帮我们执行了./node_modules/.bin/say666这个指令。如果没找到对应的指令,则会到$PATH下的路径里去查找,如果还查不到那么会帮我们去npm仓库下载对应的包保存到一个临时目录然后执行,如果连npm包也找不到就会报错。

npx和scripts实现的功能是一样的,只是实现的方式有点差异。scripts是将./bin目录添加到环境变量里,npx是去.bin目录查找对应的指令然后执行它。

npx更详细的功能可以看下阮一峰的这边文章

你学废了了吗