npm核心原理和操作指南

378 阅读9分钟

前言

对于前端工程化而言,离不开npm或者yarn,pnpm等管理工具。这些管理工具除了负责管理依赖和安装依赖以外,还能够通过npm scripts串联起来各个职能部分。所以,深入了解npm显得尤为重要。

npm安装机制

npm不同于Gem(ruby)pip(Python),它会优先安装依赖包到当前目录,使得不同应用项目的依赖各成体系,同时还能减轻包作者的API兼容压力。当然这样做的缺点也很明显:同一个依赖包会被多次安装。

通过一张图来详细说明npm的安装机制:

npm install流程图.jpg

简单来说:

  1. 执行npm install命令后,首先检查config,获取npm的配置,优先级如上图
  2. 检查项目中是否存在package-lock.json文件。如果有,则检查package.jsonpackage-lock.json是否一致,具体检查如上图。如果没有,则根据package.json构建依赖树。
  3. 检查缓存,如果有缓存,则直接从缓存内容解压到node_modules中。如果没有缓存,则先从npm远程仓库下载包资源,检查包的完整性,并添加到缓存,同时解压到node_modules中。
  4. 生成package-lock.json

npm缓存机制

一个依赖包的同一个版本在本地进行缓存,以避免再次去远程仓库下载,是管理工具的常见设计。

可以通过以下命令查看自己的npm的缓存地址:

npm config get cache

通常情况下缓存都在以下目录:/Users/xxx/.npm。进入这个目录,可以看到_cacache文件夹。缓存数据都是放在这个文件夹,如图:

image.png

可以看到,_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中存储的intergrityversionname信息生成一个唯一的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安装结构图:

image.png

当项目中新增加模块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中。

image.png

接下来,项目还需要安装一个模块D(v1.0),而D(v1.0)依赖模块B(v2.0),我们会得到如下安装结构图:

image.png

我们发现,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),我们会得到如下安装结构图:

image.png

在对应的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的处理流程如下:

  1. 删除模块A(v1.0)
  2. 安装模块A(v2.0)
  3. 留下模块B(v1.0),因为模块E(v1.0)还在依赖它
  4. 将模块B(v2.0)安装在模块A(v2.0)下

更新后的结构图如下:

image.png

通过上面的结构图发现,B(v2.0)被多次安装,这显然不是一个最完美的结构(由此可见安装顺序对于依赖树的影响很大),对于上述情况,更理想的结构应该如下:

image.png

言归正传,假设此时模块E(v2.0)发布了,并且它依赖模块B(v2.0),这个时候npm v3更新如下:

  1. 删除模块E(v1.0)
  2. 安装模块E(v2.0)
  3. 删除模块B(v1.0)
  4. 安装模块B(v2.0)到顶层node_modules

安装结构如下:

image.png

可以看到,结构中出现了重复安装的模块B(v2.0)。

这个时候,我们可以删除node_modules重新安装,利用npm的依赖分析能力,得到一个更完美的结构。实际上,更优雅的方式是使用npm dedupe命令,更新后的结构如图:

image.png

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的作用

npxnpm 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