前言
对于前端工程化而言,离不开npm
或者yarn
,pnpm
等管理工具。这些管理工具除了负责管理依赖和安装依赖以外,还能够通过npm scripts
串联起来各个职能部分。所以,深入了解npm
显得尤为重要。
npm
安装机制
npm
不同于Gem(ruby)
,pip(Python)
,它会优先安装依赖包到当前目录,使得不同应用项目的依赖各成体系,同时还能减轻包作者的API兼容压力。当然这样做的缺点也很明显:同一个依赖包会被多次安装。
通过一张图来详细说明npm
的安装机制:
简单来说:
- 执行
npm install
命令后,首先检查config
,获取npm
的配置,优先级如上图 - 检查项目中是否存在
package-lock.json
文件。如果有,则检查package.json
和package-lock.json
是否一致,具体检查如上图。如果没有,则根据package.json
构建依赖树。 - 检查缓存,如果有缓存,则直接从缓存内容解压到
node_modules
中。如果没有缓存,则先从npm
远程仓库下载包资源,检查包的完整性,并添加到缓存,同时解压到node_modules
中。 - 生成
package-lock.json
npm
缓存机制
一个依赖包的同一个版本在本地进行缓存,以避免再次去远程仓库下载,是管理工具的常见设计。
可以通过以下命令查看自己的npm
的缓存地址:
npm config get cache
通常情况下缓存都在以下目录:/Users/xxx/.npm
。进入这个目录,可以看到_cacache文件夹。缓存数据都是放在这个文件夹,如图:
可以看到,_cacache文件夹中有3个目录,作用如下:
- content-v2: 存放的是一些二进制文件,可以将这些文件的拓展名改为.tgz,然后解压,就得到了npm的包资源
- index-v5: 存放描述性的文件,这些文件就是
content-v2
的索引。 - tmp: 存放临时文件的目录。
那么这些缓存是如何存储并利用的呢?
其实在npm
下载包资源时,pacote
依赖npm-registry-fetch
来下载包资源,npm-registry-fetch
可以通过设置cache
属性在给定的路径下根据IETF RFC 7234
生成缓存数据。
然后,在每次安装资源时,根据package-lock.json
中存储的intergrity
、version
、name
信息生成一个唯一的key
,这个key
会映射index-v5
下的缓存记录。如果发现缓存资源,就会找到tar
包的hash
值,根据hash
值找到缓存的tar
包,并再次通过pacote
将对应的二进制文件解压到相应的node_modules
下,省去下载的时间。
npm
构建依赖树
在npm早期版本(npm v2),其设计非常简单,在安装依赖时候,都是将依赖安装在其“自己”的node_modules
下,然而这样的node_modules
虽然结构简单明了,但是对于大型项目非常不友好。其中可能存在很多重复的依赖包从而形成“依赖地狱”,导致浪费空间资源,并且安装过程过慢。
因此在npm v3之后,npm
构建依赖树采用了扁平化结构。
举例说明npm
是如何做到扁平化结构以及破解“依赖地狱”的。
参考资料:前端架构师<基础建设与架构设计思想>
假设:项目直接依赖模块A(v1.0),模块A(v1.0)依赖模块B(v1.0)。得到npm v3
安装结构图:
当项目中新增加模块C(v1.0),并且C(v1.0)依赖另一个版本的B(v2.0)时,因为B的版本不一致,所以B(v2.0)没办法安装在项目平铺的node_modules
中,此时,npm v3会将模块B(v2.0)安装在C(v1.0)的node_modules
中。
接下来,项目还需要安装一个模块D(v1.0),而D(v1.0)依赖模块B(v2.0),我们会得到如下安装结构图:
我们发现,B(v2.0)没有出现在顶层的node_modules
中,而是B(v1.0)出现在了顶层的node_modules
中,导致B(v2.0)被安装了两次,这是为什么呢?
其实这取决于模块A(v1.0)和模块C(v1.0)的安装顺序,因为模块A(v1.0)先安装,所以模块A(v1.0)依赖的模块B(v1.0)会被率先安装在顶层的node_modules
中,这就导致不同版本的模块B(v2.0)没有办法安装在顶层的node_modules
中。因此,模块的安装顺序可能影响到node_modules
下的文件结构。
这时,我们项目中又增加了一个模块E(v1.0),它依赖模块B(v1.0),我们会得到如下安装结构图:
在对应的package.json中,对应的依赖模块的顺序如下:
{
A: "1.0",
C: "1.0",
D: "1.0",
E: "1.0",
}
此时,我们想将模块A(v1.0)的版本更新为A(v2.0),并且A(v2.0)依赖模块B(v2.0),npm v3的处理流程如下:
- 删除模块A(v1.0)
- 安装模块A(v2.0)
- 留下模块B(v1.0),因为模块E(v1.0)还在依赖它
- 将模块B(v2.0)安装在模块A(v2.0)下
更新后的结构图如下:
通过上面的结构图发现,B(v2.0)被多次安装,这显然不是一个最完美的结构(由此可见安装顺序对于依赖树的影响很大),对于上述情况,更理想的结构应该如下:
言归正传,假设此时模块E(v2.0)发布了,并且它依赖模块B(v2.0),这个时候npm v3更新如下:
- 删除模块E(v1.0)
- 安装模块E(v2.0)
- 删除模块B(v1.0)
- 安装模块B(v2.0)到顶层
node_modules
中
安装结构如下:
可以看到,结构中出现了重复安装的模块B(v2.0)。
这个时候,我们可以删除node_modules
重新安装,利用npm
的依赖分析能力,得到一个更完美的结构。实际上,更优雅的方式是使用npm dedupe
命令,更新后的结构如图:
npm
小技巧
下面,介绍几个npm
常用的并且很方便的一些使用技巧。
自定义npm init
我们经常会使用npm init
或者npm init -y
来快速初始化package.json
文件,往往这个时候生成的package.json
如下:
{
"name": "package-name",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
这个是基础的一个init
格式,我们也可以自定义生成npm init
,来快速创建一个符合自己需求的自定义项目。
npm init
命令本身并不复杂,它的功能就是调用Shell
脚本来输出一个初始化的package.json
。所以,我们如果要自定义npm init
,就需要写一个Node.js
脚本,它的module.exports
就是package.json
的配置内容。
可以通过以下命令来查看目前npm init
调用的脚本文件及其位置:
npm config get init-module
下面我们自定义输出了一个.npm-init.js
文件如下:
module.exports = {
name: prompt('name?', process.cwd().split('/').pop()),
key: 'keywords',
version: prompt('version?', '0.0.1'),
description: prompt('description?', '项目描述'),
main: 'index.js',
scripts: {
},
keywords: [],
author: 'yuanwill',
"license": "ISC",
}
接着,我们执行以下命令来确保npm init
所执行的脚本是这个文件:
npm config set init-module ~\.npm-init.js
这个时候,运行npm init
就会发现,得到了根据我们脚本运行的package.json
:
{
"key": "keywords",
"name": "example",
"version": "0.0.1",
"description": "项目描述",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "yuanwill",
"license": "ISC"
}
我们也可以根据npm init
的默认字段来自定义npm init
的内容,如下:
npm config set init.author.name "yuanwill"
npm config set init.author.email "xxxx@mail.com"
npm config set init.author.url "yuanwill.cn"
npm config set init.license "MIT"
npm link
的使用
在我们开发一个公共组件库或者插件的时候,总希望能够在本地去验证这个组件库或者插件是否能够正常运行。但是我们应该怎么在某个项目中去验证这个组件库呢,我们总不能发布一个不安全的包版本去供这个项目使用。这个时候,npm link
帮我们解决了这个问题。
npm link
可以将模块链接到对应的业务项目中运行。简单的说,就是我们可以在本地项目中去引入本地开发好的公共组件库和模块。
演示npm link
演示npm link
,我们需要先开发一个简单的“插件”,创建一个package
文件夹,然后初始化一个package.json
如下:
{
"name": "add-package",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
开发这个“插件”,增加一个index.js
:
module.exports = {
add(a, b) {
console.log(a, b);
}
}
这样,就开发好了这个“插件”,现在就需要在本地验证这个“插件”了。在package
文件夹运行以下命令:
npm link
这个时候,项目通过npm link add-package
命令,就可以链接到这个本地包的可执行文件。
现在,我们创建一个项目来引入这个包,验证包的功能。创建example
文件夹,在里面增加一个验证包功能的文件index.js
:
const mod = require('add-package');
mod.add(1,3)
现在运行这个文件肯定会报错,因为找不到这个包。所以,我们在example
文件夹下运行以下命令:
npm link add-package
这个时候,在运行上面的index.js
,发现能正常运行并打印正确结果1,3
了。
如果想要解除这种软链接,那就在example
目录下运行:
npm unlink add-package
npx
的作用
npx
在npm v5.2
版本中被引入,主要是为了解决使用npm
时面临的快速开发、调试,以及在项目中使用全局模块的痛点。
一般
npm
都自带了npx
,如果没有,就手动安装一下npm i -g npx
举例说明:假设我们要使用create-react-app
来快速创建react
项目,一般我们会使用如下做法:
# 全局下载create-react-app
npm i -g create-react-app
# 使用create-react-app创建项目
create-react-app my-react-app
而如果使用npx
,则只需要一步:
npx create-react-app my-react-app
npx
首先会从当前目录的./node_modules/.bin
去查找是否有可执行的命令,没有找到的话再从全局里查找是否有,还没有的话就会自动下载对应的模块(create-react-app
),npx会将create-react-app
下载到一个临时目录(tmp
),用完即删,不会占用本地资源。
npm
的常用命令
最后,列举一些npm
经常使用到的命令:
# 查看HOME的地址
echo $HOME
# 查看node的安装地址
which node
# 查看npm的安装地址
which npm
# 查看全局安装时候的安装地址
npm root -g
# 查看全局安装过的包
npm list -g --depth 0