npm 包管理器简介

npm(Node Package Manager)是 Node.js 标准的软件包管理器。起初用来下载和管理 Node.js 的包依赖,现在前端 JavaScript 工程同样离不开它。

2017 年 1 月时,npm 仓库里已有超过 35 万个软件包,当时已是世界上最大的单一语言代码仓库。到 2026 年,官方登记已超过 200 万 个包。覆盖面极广,不等于每个包都该用:要看维护状态、下载量和许可证。

安装及管理软件包

安装

现行 npm(5 起用 cacache,7 起用 Arborist 装树)大致按下面走:

  • 发出 npm install 命令;
  • 读取 package.json,有则对照 package-lock.json(或 npm-shrinkwrap.json)算出要装的依赖树;
  • 先查本地缓存。tarball 的 integrity(通常是 SHA-512)对得上,就从缓存解压,不再向 registry 下载;
  • 缓存未命中,才向 registry 查询压缩包地址并下载,写入缓存后再解压到当前项目的 node_modules

因此本地仍然是 两份:缓存里的内容寻址副本,以及 node_modules 里解压后的代码。这和 npm 5 之前「用户目录 .npm 里按包名存 package.tgz」是同一类分工,只是存储格式变了。

注意: npm 2-3 时代有个容易踩的点:npm install 只看 node_modules 里有没有这个包,不看 .npm 里已有的压缩包,缺装就会再下一遍。现行 npm 会按 integrity 复用 _cacache,缺的是 node_modules 里的目录时,优先从缓存解开,不必每次都打 registry。

npm install <packageName>              # 按 package.json 范围和 lockfile 安装;缺则下载,缓存命中则解压
npm install <packageName> --force      # 强制重装(忽略部分冲突与引擎检查)
npm install <packageName>@<version>    # 安装指定版本

npm 5 起,npm install <pkg> 默认写入 dependencies,不必再写 --save。开发依赖用 --save-dev-D)。

更新软件包

npm update <packageName>

package.json 里的 semver 范围(如 ^1.2.3)查 registry,把范围内的较新版本装上,并改 lockfile。不会擅自跨出范围去装最新大版本。本地完全没这个包时,效果接近一次安装。

协同开发、CI 要「和 lockfile 一字不差」时用 npm ci:它先清空 node_modules 再按 lockfile 装;package.json 与 lockfile 对不上就直接失败。

查询软件包

npm 模块仓库提供查询服务,叫做 registry。以 npmjs.org 为例:

https://registry.npmjs.org/webpack
https://registry.npmjs.org/webpack/4.44.2

前者是该包全部版本的 packument,后者是某一个版本的元数据。npm view webpacknpm view webpack@4.44.2 走的是同一套接口。npm installnpm update 也通过 registry 取包。

软件包安装位置

当使用 npm 安装软件包时,可以执行两种安装类型:

  • 本地安装:默认情况下,npm install <package-name> 把软件包装进当前项目的 node_modules
  • 全局安装:npm install -g <package-name> 装到全局前缀下,供命令行直接调用。

npm root -g 会打印全局 node_modules 的确切位置;npm prefix -g 打印全局前缀:

  • 在 macOS 或 Linux 上,未用版本管理器时常见为 /usr/local/lib/node_modules
  • 在 Windows 上,未用版本管理器时常见为 %AppData%\npm\node_modules

若使用 nvm 管理 Node.js 版本,路径会跟当前 Node 走,例如:

  • nvm-sh(macOS、Linux):/Users/joe/.nvm/versions/node/v22.0.0/lib/node_modules
  • nvm-windows:%NVM_HOME%\v22.0.0\node_modules

软件包缓存

npm installnpm update 从 registry 下载的数据,会放进本地缓存目录。

  • Linux、macOS 默认:~/.npm
  • Windows 现行默认:%LocalAppData%\npm-cache(旧教程里的 %AppData%\npm-cache 是更早的位置)
npm config get cache

npm 5 起,真正存包的是缓存目录下的 _cacache:按内容哈希存放 HTTP 响应和 tarball,写入和取出都会校验 integrity。损坏时会报错或自动重新拉取,一般不必为「装不上」去清缓存,清缓存主要是为了腾磁盘。

npm 5 之前,每个模块的每个版本有自己的子目录,里面是 package.tgzpackage/package.json,另外还有 {cache}/{hostname}/{path}/.cache.json。对 npm searchnpm view 这类不那么关键的操作,旧版会先看 .cache.json 里的最近更新时间,间隔可接受就不打远程。现行缓存不对外暴露这套按路径的 JSON 索引,也没有官方命令用来翻里面的单个 tarball。

校验、清空:

npm cache verify
npm cache clean --force

优先用缓存(旧参数 --cache-min)

npm 5 之前可以用 --cache-min 指定「多少分钟以内只信缓存」。--cache-min 9999999--cache-min Infinity 实际上就是尽量全部从缓存装。

该参数 已废弃。现行写法:

npm install --prefer-offline <package-name>   # 有缓存就用,缺的再向 registry 要
npm install --offline <package-name>          # 完全离线;缓存没有就失败

旧命令若仍能跑,大数值的 --cache-min 在部分版本里被当成 --prefer-offline 的别名,新脚本不要再写它。

package.json

package.json 用于记录项目中使用的 npm 包,以及名称、版本号、项目描述、许可证等元数据,便于开发组成员共享。在项目目录运行 npm install,会按其中的 dependenciesdevDependencies 以及 lockfile 装好依赖。字段细节见同系列的 package.json 专篇。

包运行器 npx

npx 从 npm 5.2(2017 年 7 月)开始随 npm 提供。Node.js 开发者过去常把可执行命令做成 全局包,以便立刻在 PATH 里调用。这很痛苦:同一条命令很难并存多个版本。

npx 先找当前项目的 node_modules/.bin 和 PATH。找到就直接跑;找不到再按包名安装后执行。

npm 7 起,独立的 npx 包已弃用,npx 变成 npm exec 的入口。未在本地依赖里找到时,会提示是否安装(CI 或非 TTY 下默认相当于 --yes),装到 npm 缓存里的临时目录并加入 PATH,而不是「用完从磁盘抹掉、下次必须重下」。早期 npx 确有用完即删的行为,现行会留在缓存里给下次复用。

npx 仍然有用,因为:

  • 不必先全局安装,也能跑一次性命令(如 npx cowsay hello);
  • 本地已有该命令时直接执行,不必再下一遍;
  • 可用 @version 跑同一命令的不同版本,例如 npx prettier@3 --check .
  • 命令名和包名不同时,用 --package 指定包。
npx --yes create-next-app@latest
npm exec -- --yes create-next-app@latest

「不必全局安装」不等于「完全不下载」:第一次跑仍会从 registry 取包。

软件包安装机制

安装 npm 模块时,物理目录会是 平级(扁平)嵌套

image-20201104203800542

假设:项目 APP 下有两个依赖模块 A 和 B;A 依赖 C v1.0;B 依赖 C v2.0。两个主版本的 C 不能占同一个 node_modules/c。若安装器只留其中一个版本,另一个会被盖掉,A 或 B 就会在运行期对不上 API。npm 2 用嵌套避免互盖;npm 3 起尽量扁平,冲突的那个版本再嵌回去。

npm 2 模块安装机制

npm 2.x 安装依赖比较直接:按依赖树递归下载,每个包都把依赖装进 自己的 node_modules

优点:

  • 层级结构明显;
  • 简单实现了多版本并存;
  • 安装和删除时,目录结构和依赖树一致。

缺点:

  • 相同模块大量冗余;
  • 目录嵌套过深(Windows 还容易碰到路径长度限制)。

npm 3 模块安装机制

npm 3.x 改为 扁平化 组织 node_modules。安装时按 package.json 里依赖的顺序解析:遇到新包就放到第一级;第一级已有同名包时,版本落在约定范围内就复用,否则按 npm 2 的方式嵌到依赖方自己的 node_modules 下。

  • 安装某个二级模块时,若第一层级 还没有相同名称的模块,便 把这第二层级的模块放在第一层级
  • 安装某个二级模块时,若第一层级 有相同名称、相同版本的模块,便 直接复用那个模块
  • 安装某个二级模块时,若第一层级 有相同名称、但版本不同的模块,便 只能嵌套在自身的父模块下方

在 npm 2 中,依赖树的逻辑结构和它的物理结构相同。

在 npm 3 中,依赖树的逻辑结构和它的物理结构不必相同。

npm 5 模块安装机制

从 npm 5.x 开始,组织 node_modules 仍和 npm 3.x 一样扁平,最大变化是增加了 package-lock.json

npm 为了让开发者在安全的前提下用上范围内的较新包,package.json 里常用 ^~ 锁定到某一大版本或小版本区间。没有 lockfile 时,每次 npm install 都会在该区间内拉当时最新的版本。协同开发时,两人安装间隔几天,锁文件又没有提交,node_modules 就会对不齐。

package-lock.json 精确描述当时 node_modules 里整棵树,每个包的版本号都是完全精确的。npm 5.1 之后,有 lockfile 时 npm install 以它为准复现依赖,而不是每次都按区间再选一遍最新。

npm 7 及之后

现行默认仍是扁平(hoisted),装树由 Arborist 计算,并叠加几件对日常影响更大的事:

  • lockfileVersion 2(npm 7)、3(npm 9 起):lockfile 里用 packages 映射完整树;v2 对旧客户端大致向后兼容,v3 去掉了重复的 dependencies 树。
  • peerDependencies 默认会装。对不上就安装失败。旧项目临时可加 --legacy-peer-deps,长期仍应修冲突。
  • workspaces:根 package.json 声明工作区后,一次 npm install 会装好子包并按策略链接。
  • 可用 install-strategyhoisted(默认扁平)、nestedshallowlinked 之间切换,默认行为仍接近 npm 3-5 那套扁平规则。

npm 模块引用机制

在 Node.js 中,用 require() 加载时常见三类模块:

  • Node.js 的 核心模块。如 fspathhttp(没有名为 database 的核心模块)。
  • 文件模块:开发者自行编写的,如 ./test.js
  • npm 包:也是文件模块的一种形态,装在 node_modules 里,例如 mysql

ESM(import)另有一套解析规则,会读 package.jsonexportsimports,不能把下面的 require 查找清单原样套上去。

路径分析

文件模块的标识已经指明位置(相对路径 ./../,或以 / 开头的绝对路径),路径分析很快,加载速度仅次于核心模块。

npm 包则从当前文件所在目录起,逐级向上找 node_modules,直到找到目标或走到盘符根。路径越深,查找越慢。CommonJS 还会在全局目录里再找一轮(可用 node -e "console.log(module.paths)" 查看本机列表),典型包括:

<当前文件目录>/node_modules
<上级目录>/node_modules
...
<项目根>/node_modules
$HOME/.node_modules
$HOME/.node_libraries
$PREFIX/lib/node

文件定位

模块标识可以不包含后缀名,Node.js 会依次补 .js.json.node 再定位;作为目录时还会看 package.jsonmain(ESM 则看 exports)以及目录下的 index.js。补后缀、读磁盘会发生同步 I/O。引入的若确定是 .json.node,写出后缀可以少试几次。

现行 Node 还会认 index.jsonindex.node,以及条件导出里的路径,不只是「三个后缀」。

编译执行

定位到具体文件后,Node.js 会创建一个模块对象,编译并执行。每一个编译成功的模块以其 文件路径 为索引缓存在 require.cache 上,二次 require 同一文件直接返回 exports

核心模块在 Node.js 源码编译时已打进二进制,启动后常驻内存,引入时跳过文件定位和编译,并且在路径分析里优先于文件模块,所以最快。

文件模块在执行时动态加载,路径分析、文件定位、编译执行都不能省,所以比核心模块慢。二次加载一律缓存优先;核心模块的缓存检查优先于文件模块。

npm 模块循环依赖

当我们在 A.js 中引用 B.js,在 B.js 中引用 A.js 时会发生什么?

CommonJS 官网上对这种循环的说明可以概括成:main.js 加载 a.jsa.js 再加载 b.js,此时 b.js 又要加载 a.js。为避免无限循环,返回给 b.js 的是 尚未完成a.jsexports 对象副本;等 b.js 加载完,再把它的 exports 交给 a.js

简单说:模块 第一次 进入加载就会先放进缓存,哪怕 module.exports 还没赋上最终值。再次 require 同一文件时从缓存取。因此循环依赖 不会死循环,但会拿到空对象或不完整导出。

下面是最简情形。

A.js

let b = require('./B');

console.log('A: before logging b');
console.log(b);
console.log('A: after logging b');

module.exports = {
    A: 'this is a Object'
};

B.js

let a = require('./A');

console.log('B: before logging a');
console.log(a);
console.log('B: after logging a');

module.exports = {
    B: 'this is b Object'
};

运行 A.js,输出:

B: before logging a
{}
B: after logging a
A: before logging b
{ B: 'this is b Object' }
A: after logging b

代码从上到下执行,打印顺序就是加载轨迹。A.jsrequireB.js,进入 B.js 第一行又 requireA.js。此时缓存里的 A.js 还是未完工的(an unfinished copy):尾部的 module.exports = { ... } 尚未执行,所以 B.js 里的 a{}B.js 跑完后回到 A.js,此时 b 已经是完整对象。

处理循环依赖,常用这几手(可并用):

  • 延迟 require:把对另一文件的 require 放到函数内部,等双方都导出完毕再取。
  • 先挂属性再整体替换:循环打断前用 exports.foo = ... 往同一个对象上挂方法,避免最后才 module.exports = {} 换掉缓存里那个对象。
  • 抽出第三模块:A、B 都依赖的逻辑放到 C.js,打断环。

ESM 的 import实时绑定(live binding),不会像上面那样拿到 {};但若在对方初始化完成前读取 letconst 导出,会碰到暂时性死区,报 ReferenceError。循环依赖在 ESM 里同样要靠拆模块或推迟访问来解。

参考资料

package.json 与 lockfile(npm 文档)

npm cache

npm exec / npx

Node.js require 解析

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-09-02 00:29:54

results matching ""

    No results matching ""