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 webpack、npm view webpack@4.44.2 走的是同一套接口。npm install 和 npm 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 install 或 npm 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.tgz 和 package/package.json,另外还有 {cache}/{hostname}/{path}/.cache.json。对 npm search、npm 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,会按其中的 dependencies、devDependencies 以及 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 模块时,物理目录会是 平级(扁平) 或 嵌套。

假设:项目 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-strategy在hoisted(默认扁平)、nested、shallow、linked之间切换,默认行为仍接近 npm 3-5 那套扁平规则。
npm 模块引用机制
在 Node.js 中,用 require() 加载时常见三类模块:
- Node.js 的 核心模块。如
fs、path、http(没有名为database的核心模块)。 - 文件模块:开发者自行编写的,如
./test.js。 - npm 包:也是文件模块的一种形态,装在
node_modules里,例如mysql。
ESM(import)另有一套解析规则,会读 package.json 的 exports、imports,不能把下面的 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.json 的 main(ESM 则看 exports)以及目录下的 index.js。补后缀、读磁盘会发生同步 I/O。引入的若确定是 .json 或 .node,写出后缀可以少试几次。
现行 Node 还会认 index.json、index.node,以及条件导出里的路径,不只是「三个后缀」。
编译执行
定位到具体文件后,Node.js 会创建一个模块对象,编译并执行。每一个编译成功的模块以其 文件路径 为索引缓存在 require.cache 上,二次 require 同一文件直接返回 exports。
核心模块在 Node.js 源码编译时已打进二进制,启动后常驻内存,引入时跳过文件定位和编译,并且在路径分析里优先于文件模块,所以最快。
文件模块在执行时动态加载,路径分析、文件定位、编译执行都不能省,所以比核心模块慢。二次加载一律缓存优先;核心模块的缓存检查优先于文件模块。
npm 模块循环依赖
当我们在 A.js 中引用 B.js,在 B.js 中引用 A.js 时会发生什么?
CommonJS 官网上对这种循环的说明可以概括成:main.js 加载 a.js,a.js 再加载 b.js,此时 b.js 又要加载 a.js。为避免无限循环,返回给 b.js 的是 尚未完成 的 a.js 的 exports 对象副本;等 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.js 先 require 了 B.js,进入 B.js 第一行又 require 了 A.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),不会像上面那样拿到 {};但若在对方初始化完成前读取 let、const 导出,会碰到暂时性死区,报 ReferenceError。循环依赖在 ESM 里同样要靠拆模块或推迟访问来解。