npm 与包管理:敲下 npm install 之后,那些包是从哪来的
你敲的 npm install,是一台你雇来的跑腿:它读你写的那张采购清单(package.json),跑到一个登记着 442 万个包名 的公共仓库里,把清单上写的东西、以及清单上没写但它们自己又需要的那些东西,一并搬回你电脑上的一个文件夹(node_modules)。「包管理」管的就是这后半句——那些你没写、却必须一起搬回来的东西。读完这篇,你能看懂项目里那两个文件各自在管什么、知道 npm install 和 npm ci 该用哪条,并且在装任何一个包之前知道它会让别人的代码在你电脑上运行。
📅 更新于 2026-09-23 · 本文校对于 npm @ 12.1.0(2026-09-22 发布)· 官方源:registry.npmjs.org/npm
0030 秒速览:三句话,和你的下一个动作
读完这篇你能做到三件事:① 打开任何一个项目的 package.json,说出「它要哪些包、哪些只在开发时用」;② 知道 npm install 和 npm ci 的区别,以及那个长得像乱码的锁文件为什么必须提交到 git(管理代码版本的工具);③ 在装一个包之前,知道它会执行别人写的代码,以及现在该怎么应对。
如果只记三句话
① npm 不是一个程序,是三个东西共用一个名字:一个网站、一个命令行工具、一个公共仓库。你敲的是那个命令行工具,它去仓库里取东西。这三者混着叫,是后面一大半困惑的来源——「npm 上搜到」说的是网站,「npm 装不上」说的是工具,「npm 挂了」说的是仓库。
② package.json 只写「我想要什么范围」,package-lock.json 才记「上次实际装的是哪一个版本」。前者是给人看的意图,后者才是「换台机器装出一样结果」的保证。这两个文件的关系,是整篇里最值得记住的一处分工。
③ 装一个包,等于允许别人写的代码在你的电脑上运行。2025 年 9 月那场叫 Shai-Hulud 的蠕虫就是这么扩散的。npm 12(2026-07-08 起)因此改成了:依赖的安装脚本默认不跑,要跑得你逐个批准。
接下来 60 秒:你的第一个动作
打开一个你手上有过 node_modules 的项目(从 AI 那里拿来的半成品也算),在项目目录里依次敲这三条:
node -v # 你装的 Node 是哪个版本
npm -v # 顺带装上的 npm 是哪个版本
npm ls --depth=0 # 只看「你亲自要的」那几层
npm ls --all # 看整棵树——你会看到比上一条多几十倍的名字
这两条 ls 的对比,就是这篇要讲的全部内容。第一条列出来的,通常只有五六个到几十个;第二条列出来的,往往是几百上千行。多出来的那些,你一个字都没写过,但它们必须被装上——这就是「包管理」这四个字真正在管的东西。
① 一个装过依赖的项目(哪怕是从 AI 那里拿来的半成品)。不需要你懂代码,但需要一个真实的 package.json 和 node_modules,否则本文里的每条命令你都无处可敲。手上没有的话,先跟着本仓的 Next.js 从第一行代码到上线 建一个——那份教程会带你装 Node 并生成第一个项目。
② 一个能敲命令的地方(终端)。macOS 上是「终端」,Windows 上是「PowerShell」或「终端」。怎么算敲通了?node -v 能打印出一个版本号,比如 v24.21.0。
关键数字(先有个量级感)
不确定自己该看哪一节?
| 你现在的状态 | 直接跳 |
|---|---|
| 只会照着 AI 给的命令敲,完全不知道发生了什么 | §01 npm 是三个东西 → §04 install 全流程 |
被 package.json / package-lock.json 两个文件名搞晕 | §08 锁文件(先看 §07 的版本号那节更顺) |
| 想直接动手跑一遍 | §05 最小例子(全程可复制,10 分钟) |
磁盘被 node_modules 吃满了,想删 | §09 为什么这么大、能不能删 |
| 正在报错,急着找答案 | §11 报错对照表 |
| 看见 npm 安全新闻,担心自己中招 | §10 装一个包等于让别人的代码在你电脑上跑 |
| 在犹豫要不要换 pnpm / yarn | §12 该不该换 |
01npm 是三个东西共用一个名字
先纠正一个连很多老手都在传的错
你可能在无数教程里见过这句话:「npm 是 Node Package Manager 的缩写」。这句话是错的,而且官方明确反对。
npm 自己的品牌用词政策里写着:"npm" should never be used or explained as an acronym.(npm 不应被当作缩写来使用或解释。)我把 npm 官方文档仓库里的 745 个文档文件全量检索过一遍,Node Package Manager 这个词组一次都没有出现过;而 acronym(缩写)这个词只出现过一次,就是上面那句否认。(出处与复核方法见 §15。)
不只是较真。把 npm 理解成「Node 的包管理器」,会顺带带出两个更麻烦的误解:一是以为 npm 只能管 Node 的东西(Node 是 Node.js 的简称——让 JavaScript 这门网页编程语言能脱离浏览器、直接在你电脑上运行的那个程序;实际上任何 JavaScript 项目、很多桌面软件和命令行工具都用 npm);二是以为 npm 就是那个命令行程序(它同时还是个网站和一个仓库)。第 1 节剩下的部分都在拆第二个。
它的三个身份,各自是什么
npm 官方对这件事的原话是:npm 由三个不同的东西组成——the website、the Command Line Interface (CLI)、the registry。翻译成人话:
| 身份 | 它是什么 | 你什么时候会撞上它 | 别把它当成什么 |
|---|---|---|---|
| 那个网站 npmjs.com |
一个可以搜包、看文档、看这个包多久没更新的网页 | 你想查「这个包靠谱吗」「上次发版是什么时候」 | 别当成下载站——你几乎不会从网页上下包,都是命令行去取 |
| 那个命令行工具 npm CLI |
你敲 npm install 时被执行的那个程序,有版本号(现在是 12.1.0) |
每次你在终端里敲 npm … |
它不是「仓库本身」。npm -v 报的版本,与仓库里有多少包毫无关系 |
| 那个仓库 the registry |
一台由 GitHub(微软旗下)运营的服务器,存着全世界发布的包,官方说法是「世界上最大的软件仓库」 | 装包时它在联网;ENOTFOUND 报错时多半是它连不上 |
别当成一个静态网盘——它会因为一次攻击在几小时内下架 500 多个包(§10) |
你敲一条命令,三个身份是这样串起来的:
.tgz 压缩包)拉下来,先存进本地缓存——一台机器上所有项目共用的一个存放处没有它的年代,人是这么干活的
要讲清「它解决什么问题」,最省事的办法是想象它不存在。假设你要用别人写的一个图片压缩库:
- 你得先找到这个库的网站或代码托管页(当年是 Google 搜、翻博客);
- 把它的压缩包下下来,手动解压,放进项目的某个文件夹;
- 然后发现它自己也依赖别的库——再去把那几个下下来、解压、放进去;
- 那几个库又各自依赖更小的库,如此递归三到五层,全程手工;
- 两个库都要求同一个底层库,但要求的版本不一样——到这里基本就卡死了;
- 终于跑通了,把整个文件夹拷给同事,他那边报错,因为你的系统和你拷的库不匹配;
- 半年后你想升级其中一个库,得把上面这套再走一遍。
npm(以及所有包管理器)解决的正是这一串:把「我这个项目依赖哪些别人的代码、分别要哪个版本」写进一份机器能读的清单,然后自动递归地把它们取回来、摆到正确的位置、并在换一台机器时复现同样的结果。这句话里的每一段,后面都对应一节。
最容易混的一组:仓库是服务,npm 是客户端
日常说话里,这三个身份被混得最狠的是后两个。一个类比可以帮你固定住它们的关系:
package.json 是你写的采购清单(要面粉、要酵母,酵母写的是「干酵母,牌子随你」);registry 是那家超市(货架上什么都有,而且全世界共用这一家);npm 是你雇的跑腿(拿着清单去超市,把你的和他自己路上想到要买的一起拎回来)。
三条对应关系都对得上:清单在你自己手上、货架在超市、跑腿是每次现叫的。这个类比到这里就不准了——现实中的超市不会替你记住「上次你买的酵母是哪个牌子、批次号是多少」,而这个功能恰恰是 npm 最值钱的部分,也就是 §08 要讲的锁文件。类比负责让你分清三者,再往下就得靠正文了。
02包管理管的不是「包」,是「依赖的依赖」
先花一分钟把「依赖」这个词固定住
依赖(dependency)在这里的意思非常朴素:你这个项目要用别人已经写好的代码。你写十行代码去调用一个别人写好、经过几万人用过的库,那这个库就是你的一个依赖。
而「包」(package)是 npm 里承载依赖的单位:一个有名字、有版本号、并且一定包含一个 package.json 文件的文件夹(发布时被打成一个压缩包)。npm 官方文档对「包」的定义就是这么一句——必须有 package.json 才能被发布到仓库里。
你只写了 15 个,机器装出来 51 个
真实项目里,几乎没有哪个包是「自己一个人就能干活」的。它也要用别人写的东西,于是产生一层层的传递依赖(transitive dependency,意思是「不是你要的,是你要的东西要的」)。
这件事的量级值得亲手看一次。我写了一段脚本,去公共仓库把每个包自己声明的依赖递归走了一遍,把整棵树数了出来(复核方法在 §15):
| 包 | 它清单上写的直接依赖 | 实际装进来的包(唯一名字) | 解包后大小 |
|---|---|---|---|
express(一个后端框架) | 28 个 | 63 个 | 约 2.1 MiB |
eslint(一个代码检查工具) | 30 个 | 75 个 | 约 10.8 MiB |
vite(一个前端构建工具) | 6 个 | 13 个(必装)/40 个(含可选) | 4.2 MiB/356.8 MiB |
next(一个网站框架) | 15 个 | 12 个(必装)/51 个(含可选) | 179.5 MiB/1129.0 MiB |
这张表里有三个反直觉的地方,每个都值得停一下:
- express 的 28 变成了 63。你只说了「我要 express」,实际到手的是一个 63 个包的集合。这些多出来的包你一个都不用认识,但它们会跑在你的机器上(这一点在 §10 会变成一件严肃的事)。
- next 的「12 个」里有 179.5 MiB,其中 176.2 MiB 是 next 自己。包的数量少,不代表占的地方小。看体积得单独看。
- next 和 vite 的「含可选」列大得离谱(1129 MiB / 356.8 MiB)。这不是笔误:它们把 Windows、macOS、Linux 以及各种 CPU 架构的预编译二进制(意思是这些文件已经编译好、能直接在对应系统上运行)全列进来了,靠
os/cpu两个字段让你机器上只装匹配的那一个。这是「依赖树」与「你磁盘上实际有什么」之间最大的一处差异。
为什么这件事不能被「手动管」替代
有人会想:既然只是「下几个文件」,我为什么不自己下、自己放?因为真正的难点不在「下」,在对齐:63 个包各自声明了自己能接受的版本范围,要让它们同时成立,是一个约束求解问题。当两个人分别更新了不同的依赖,或者你半年后重装一次,这个解会变——
你升级了 A,A 要求 C 的 2.x;你的同事升级了 B,B 只兼容 C 的 1.x。两条改动各自都没问题,合到一起装不上了。而这个问题不会在你们写代码时暴露,只在「下一个人重装依赖」时暴露——通常是三个月后的 CI 上(CI 是持续集成:每次有人提交代码,就由一台机器自动装一遍依赖、跑一遍检查)。
求解只做一次,结果写进 package-lock.json:哪个包、哪个确切版本、从哪个地址下载、校验值是多少。后面所有人、所有机器都照着这份「上次的解」装,不再重新求解。这就是它和「一个下载脚本」的根本区别。
什么场景该用它
只要一个项目里出现了 JavaScript 代码、并且它要用到别人写的库,npm 就适用。它的覆盖面比「做网站」大得多:
网站、后台接口、微信小程序的后端——这是最主流的一类,Next.js / Express / Fastify 都走 npm。
用 JavaScript 写的桌面软件(如 VS Code 就是)、以及 npm i -g 装的那些命令行工具,也是 npm 装的。
Claude Code、Codex CLI、OpenCode 这类工具大多是通过 npm 分发的。你在本仓其他教程里看到的 npm install -g …,走的就是这套。
什么时候不该用它
| 你的情况 | 该用什么 | 为什么 |
|---|---|---|
| 纯静态页面(几个 HTML + CSS,没有构建步骤) | 什么都不用 | 没有依赖需要管理。不要因为教程都从 npm init 开始就跟着做——那会平白多出几百 MB 和一堆更新提醒 |
| Python / Go / Rust 项目 | pip、poetry、go mod、cargo | 每个语言有自己的包管理器与仓库。Python 那套见本仓的 虚拟环境 |
| 只想临时跑一个命令(格式化一个文件、起一个脚手架) | npx,不要 npm install -g |
npx 会把包放进缓存里用一次就完,不往你的全局环境里长期堆东西;-g 装的东西会一直留着(区别见 §04 末尾) |
| 公司项目已经规定了用 pnpm 或 yarn | 跟团队走 | 混用会在仓库里留下两份锁文件,是团队协作里的经典事故。先看项目里已有哪个锁文件,就用哪个 |
03装 npm:它其实是跟着 Node 一起来的
你不需要单独装 npm
npm 是随 Node.js 一起发下来的。Node.js 是让 JavaScript 能脱离浏览器、直接在你电脑上运行的那个程序——安装它的同时,npm 就被放在同一个目录里了。npm 官方文档的原话是:When you install Node.js, npm is automatically installed.(装了 Node.js,npm 就自动装好了。)
所以要做的只有一件事:确认你装过 Node,然后看它带来了哪个 npm。你已经在 §00 敲过 node -v 和 npm -v 了。
第一个反直觉的地方:Node 自带的 npm 不是最新的
截至 2026-09-23,各条 Node 线自带的 npm 版本是这样(数据来自 Node 官方发布清单,每个版本的元数据里就写着它捆绑的 npm 版本):
| Node 大版本 | 状态 | 最新补丁版自带的 npm |
|---|---|---|
| Node 26 | 当前版(2026-10-28 转长期支持) | npm 11.19.1 |
| Node 24「Krypton」 | 长期支持版(推荐用它) | npm 11.19.0 |
| Node 22「Jod」 | 维护期 | npm 10.9.8 |
| Node 20「Iron」 | 已于 2026-04-30 停止支持 | npm 10.8.2 |
| 而 npm 命令行工具自己的最新版本 | 12.1.0(2026-09-22 发布) | |
也就是说:截至今天,npm 12 还没有随任何一条 Node 线发布。你装最新 Node 拿到的仍然是 11.x。这不奇怪——npm 是独立发版的,Node 只会在某个时间点把当时的 npm 版本固定进去。这条事实也说明:「我的 npm 版本」是一个需要你自己确认的东西,不是一个跟着 Node 自动最新的东西。
要不要升级,怎么升
先明确一点:如果项目现在跑得动,不升级是完全正当的选择。npm 12 带来了一处会影响安装结果的默认行为改动(§10),升上去之前值得先知道那是什么。
要升级的话就一条命令:
npm install -g npm@latest # -g = global,装到全局,而不是当前项目
npm -v # 确认升上去了
npm 12 要求 Node 版本满足 ^22.22.2 || ^24.15.0 || >=26.0.0(意思分别是:22.22.2 及以上、24.15.0 及以上、或 26 及以上)。如果你的 Node 是 20 或更老,这条命令会失败或装上一个不能用的组合。正确顺序是先升 Node,再让 npm 跟着来。
国内网络下常见的一步:换一个镜像源
默认情况下,所有包都从官方仓库 https://registry.npmjs.org/ 取。国内直连它经常很慢,于是有一份同步过来的副本(业内叫「镜像」)可以换:
npm config get registry # 先看现在指向哪:默认会打印 https://registry.npmjs.org/
npm config set registry https://registry.npmmirror.com # 换成国内镜像
这条设置会被写进你的用户级配置文件 ~/.npmrc(一个纯文本的「配置清单」文件,npm 启动时读它)。npm 的配置有明确优先级:命令行参数 > 环境变量 > 项目里的 .npmrc > 用户级的 ~/.npmrc > 全局的。也就是说,项目目录里那个 .npmrc 会覆盖你个人的设置——团队项目里它常被用来统一源。
镜像不是官方仓库,它是第三方同步的副本:新发布的包可能滞后几小时到几天,个别包可能同步失败。所以遇到「明明刚发布的版本却装不到」时,先怀疑镜像。npm config set registry https://registry.npmjs.org 可以换回去。另一个方向也成立:公司内网通常有自己的私有源(存内部包、同时代理公共包),那时用哪个源不是你决定的,要看项目里的 .npmrc。
04敲下 npm install 之后,机器按什么顺序干活
六个步骤,按发生顺序
npm 从你当前所在目录开始,往上逐级找最近的 package.json 或 node_modules,找到的那个目录就叫「本地前缀」。所以你在项目的子文件夹里敲命令,它照样能找到项目根。npm prefix 可以单独打印出这个位置。
注意关键词是范围:这里写的多半是 ^4.18.0 这种「大致范围」,不是某一个确切版本(这些符号的规则全在 §07)。所以光凭这个文件,两次安装可能装出不同的东西。
这个文件里记着每个包的确切版本、下载地址、以及一个校验值(下载完重新算一遍,对不上就说明包的内容被换过)。它存在时的优先级高于上一步的范围。没有它,第 4 步就得重新算一遍整棵树。
npm 内部的这个求解器叫 arborist(arbor = 树)。它要处理的是冲突:A 要 C 的 1.x,B 要 C 的 2.x,两个都得装——于是它会把其中一个放进子目录里,这就是 node_modules 里出现层层嵌套文件夹的原因(§09 会展开)。
向仓库查询每个包的元数据,然后把包体(tarball:一个把整个包文件夹打包压缩后的文件,.tgz 结尾)下载到本地缓存里——默认在用户目录下的 .npm/_cacache。缓存是所有项目共用的。
把缓存里的压缩包解压、按第 4 步算出的结构摆好。然后跑「安装脚本」(一句话说:包作者写在 package.json 里、希望在安装时自动执行的一段命令)——这一步是整条流程里唯一会真的执行别人代码的地方,也是 §10 的主题。最后把这次的结果写回 package-lock.json。
为什么第一次慢、第二次快
除了第 5 步的下载,其余每一步都在本地。而下载这一步只在第一次或者缓存被清掉时才真的跑网络:第二次装同一个包,npm 直接从缓存解包。这就是「第一次装了三分钟、删掉 node_modules 再装只要二十秒」的原因。
知道了这一点,两条命令就有用了:
npm config get cache # 缓存放在哪(默认 ~/.npm)
npm ci --prefer-offline # 优先用缓存,缓存里没有再联网
npm ci --offline # 完全不联网:缓存里缺任何东西就直接失败
官方对 --offline 的说明是「强制离线模式:安装期间不做任何网络请求」。它能用是一个很强的信号——说明这个项目之前的依赖都是同一台机器上装的。反过来,在刚拿到的新机器上敲 --offline 一定失败。
那几行输出到底在说什么
added 63 packages, and audited 64 packages in 3s
found 0 vulnerabilities
| 这行字 | 它在说什么 | 你该关心什么 |
|---|---|---|
added 63 packages | 这次在 node_modules 里新增了 63 个包目录 | 和你清单上写的数量对不上是正常的(§02)。对不上太多时,用 npm ls --all 看是谁带进来的 |
audited 64 packages | 顺手拿这 64 个包的版本去问了一遍漏洞库(记录已知安全问题及其影响版本的数据库;比新增数多一个,因为含项目自己) | 这是联网行为,可以用 --no-audit 关掉 |
found 0 vulnerabilities | 漏洞库里没有这些版本的已知问题记录 | 「0 个漏洞」不等于「安全」,只等于「没有已知的、被收录进库里的问题」。一个新的恶意包在有人举报之前,这里也是 0 |
in 3s | 耗时 | 突然变慢往往说明缓存失效或换了镜像 |
顺带说清三个长得很像的命令
| 命令 | 它做什么 | 什么时候用 |
|---|---|---|
npm install 包名 | 装这个包,并把它写进 package.json(默认写进 dependencies) | 你真的想给项目加一个新依赖 |
npm install(不带包名) | 按清单把项目需要的都装上,必要时更新锁文件 | 日常开发 |
npm ci | 严格按锁文件装一遍,不碰两个 json 文件,装之前先清空 node_modules | 服务器、CI、以及任何需要「和上次一模一样」的场合(细节见 §08) |
npx 包名 | 把包临时取到缓存里,执行它一次,不写进你的项目 | 只想跑一次某个工具,不想污染项目 |
npx 和「全局安装」的区别值得单独记一句:npm install -g 包名 是把包装进一个全局位置并一直留着(所有项目都能用,但也会一直留着);npx 是把包放进缓存里执行一次。不确定要不要长期留着的工具,用 npx。(从 npm 7 起,npx 本身就是 npm exec 的一个更简短的入口。)
05最小例子:从空目录到「换台机器也一样」
这一节是一条可以完整照抄的路。它专门设计成能让你亲眼看到「删掉重装会变、按锁文件装不会变」——这是整篇里最值得花十分钟亲手做一次的实验。
第 0 步:建一个空目录,起一个项目
mkdir npm-demo
cd npm-demo
npm init -y # -y = 所有问题都用默认答案,生成一个 package.json
cat package.json # Windows PowerShell 上换成:type package.json
你会看到一个很短的文件,里面有 "name"、"version"、"scripts" 之类的字段。这就是你的采购清单,现在是空的。
第 1 步:装第一个依赖,看它带来了什么
npm install express
cat package.json # 多了一行 "dependencies"
npm ls --depth=0 # 只列你亲自要的:这里只有 express
npm ls --all # 整棵树:63 个名字
清单上只多了一行,磁盘上多了 63 个包目录。这一处反差,就是「包管理」三个字的重量所在。
第 2 步:做一个对照实验——删掉锁文件重装
先看看那个自动出现的锁文件长什么样:
ls # 除 node_modules 外,多了一个 package-lock.json
head -40 package-lock.json
里面最值得看的是 "lockfileVersion"(格式版本号)和下面每个包的三个字段:version(确切版本)、resolved(从哪个地址下载的)、integrity(校验值)。现在做这个实验:
rm -rf node_modules package-lock.json # Windows:rmdir /s /q node_modules && del package-lock.json
npm install # 没有锁文件,它要重新算一遍「范围」里最新的是什么
如果这中间有过新版本发布,这次装到的版本就和你上次装的不一样——而你什么都没改。这就是「不能只靠 package.json」的现场演示。
第 3 步:换成按锁文件装
rm -rf node_modules # 注意:这次不删 package-lock.json
npm ci # 严格照锁文件装,一个版本都不许协商
npm ci 的承诺是:只要锁文件没变、仓库里那些版本还在,装出来的东西就一定和上次一样。这条承诺是整个生态能协作的基础——服务器、同事的电脑、CI 机器,靠的都是它。
第 4 步:加一个「只在开发时用」的依赖,再加一条自己的命令
npm install --save-dev prettier # 写进 devDependencies 而不是 dependencies
npm pkg set scripts.check="prettier --check ." # 往 package.json 的 scripts 里加一条
npm run check # 跑这条脚本
--save-dev 的效果是把它记进 devDependencies 而不是 dependencies。这个区分的意义在 §06,但那句「npm run 名字 就是执行 scripts 里对应的那行命令」现在就可以记住——AI 给你的 npm run dev、npm run build,全都是这个机制,没有例外。
这个例子里每条命令各证明了什么
| 这条命令 | 你该看到什么 | 它证明了什么 |
|---|---|---|
npm init -y | 凭一个空目录生成了 package.json | 「一个项目」的标志就是这个文件,不是别的 |
npm install express | 清单多一行,磁盘多 63 个目录 | 传递依赖的存在(§02) |
npm ls --depth=0 vs npm ls --all | 一个列 1 行,一个列几十行 | 「你要的」与「实际装的」是两件事 |
删锁文件后 npm install | 可能装到不同版本 | 范围不等于确切版本(§07) |
npm ci | 每次都装出同一份 | 锁文件才是复现的保证(§08) |
npm run check | 执行了 package.json 里那行命令 | AI 说的 npm run xxx 是什么(§06) |
上面这套完全在一个新建的空目录里进行,不会影响你已有的项目。做完直接删掉整个 npm-demo 文件夹即可。反过来,别在你还想跑起来的项目上做「删锁文件重装」的实验——那正是把项目搞坏的最常见方式之一。
06package.json:哪个字段是给谁看的
这个文件里字段很多,但绝大多数你一辈子不用改。最有价值的一栏是「谁读它」——同一份文件里,有的字段是给 npm 自己看的,有的是给别的工具看的,有的是给人类看的。
| 字段 | 它写什么 | 谁读它 | 你什么时候会动它 |
|---|---|---|---|
name / version | 项目名与版本号 | npm(发布时必需) | 基本不动 |
private | true 表示「这个项目不许被发布到公共仓库」 | npm | 如果这是个你自己用、不打算发布出去的项目,加上它更安全——能防一次手滑 |
scripts | 一串「名字 → 命令」的对应关系 | npm,用 npm run 名字 触发 | 看 AI 给的命令就知道里面有啥 |
dependencies | 项目运行时需要的包 | npm 安装时 | 装包时自动写 |
devDependencies | 只在开发时需要的包(格式化、测试、构建工具) | npm 安装时 | 加开发工具时自动写 |
peerDependencies | 「我这个插件要配哪个版本的主程序」 | npm 安装时(会自动装,见下) | 写插件时 |
optionalDependencies | 装不上也允许失败的依赖 | npm | 一般不用手写,但你会在依赖树里遇到 |
overrides | 强制把依赖树里某个包换成指定版本 | npm(只在项目根生效) | 某个底层包有问题、上游还没修时 |
engines | 「我这个项目要求 Node 至少是哪个版本」 | npm(默认只警告,不拦) | 换 Node 版本报 EBADENGINE(版本不匹配)时 |
type | "module" 或 "commonjs",决定 JS 文件的加载方式 | Node 运行时(真正执行这套代码的程序) | 报 require is not defined 一类的错时 |
workspaces | 声明「这个仓库里还有哪几个子包」 | npm | 一个仓库放多个项目时(monorepo:mono = 单一,repo = 仓库,见下) |
packageManager | 「这个项目用哪个包管理器、哪个版本」 | Corepack 等工具 | 团队里有人用 npm、有人用 pnpm 时 |
files / main / exports / bin | 发布时要带上哪些文件、入口在哪、命令行命令叫什么 | npm 与 Node | 发布自己的包时 |
dependencies 与 devDependencies:最容易混的一对
这一对的区别只有一个问题:「这东西在项目真正跑起来的时候,还需要吗?」
它不是「次等的」或「可以省掉的」。一个构建工具(把源码变成能上线的代码)通常在 devDependencies 里,但你的项目离开它根本发不出去。它被分开的唯一原因是:一旦代码构建完成,线上服务器运行时不再需要它。
服务器上跑 npm ci --omit=dev 时,devDependencies 那些包不会被装到磁盘上——省时间、省空间、少一份被攻击的面。注意一个官方细节:即使被省略,它们仍然会被解析并写进锁文件,只是不落到 node_modules。NODE_ENV=production 环境变量也会触发同样的省略。
peerDependencies:ERESOLVE 报错的源头
这个字段的用途有点绕,但值得懂,因为新手遇到的第一大报错(ERESOLVE)几乎都从这里来。
它要表达的是:「我是一个插件,我需要宿主程序是某个版本,但我不把宿主程序打包进来。」典型例子是一个 React 组件库——它必须和你的 React 配合工作,但绝不能自带一份 React(否则你的页面里会有两个 React,直接崩)。所以它声明 "peerDependencies": {"react": "^19.0.0"}:「我要求你(宿主项目)提供这个版本的 React。」
历史上这里有个重要分界:npm 7 之前(3~6),peerDependencies 不会自动安装,只是不匹配时给个警告;从 npm 7 起(2020-10 发布,随 Node 15 分发),它会被自动安装。好处是少了一类「你还得自己再装一次」的坑;代价是两个插件要求不同版本的主程序时,npm 会直接报错而不是警告——这就是 ERESOLVE。
网上最常见的建议是加 --force 或 --legacy-peer-deps 把报错压下去。官方对 --legacy-peer-deps 的说明是「完全忽略 peerDependencies,就像 npm 3~6 那样」,并明确写着 not recommended(不推荐)——因为它等于放弃了插件与宿主之间那份版本契约,你得到的是一个没被验证过的组合。--force 更宽:官方描述它的原话是「解除针对各种副作用、常见错误、不必要的性能下降和恶意输入的多重保护」。先看报错里那个冲突的实际版本,再决定(§11 给了逐条判断法)。
optionalDependencies:那 1129 MiB 是怎么来的
回头看 §02 那张表里 next 的 1129 MiB。它是这么产生的:next 把「Windows 版二进制」「macOS 版二进制」「Linux 各架构版二进制」全都列为可选依赖,同时用另外两个字段限制它们各自只在对应平台上生效:
{
"os": ["darwin", "linux"], # 允许在这些操作系统上安装
"cpu": ["x64", "arm64"] # 允许在这些 CPU 架构上安装
}
于是:你的机器上只会装匹配的那一个(约 90~100 MiB),其余的在解析阶段就被排除了。这也是「别人发的 node_modules 拷给你用不了」的技术原因——里面装的二进制是给他那台机器编的。
workspaces:一个仓库里放多个项目
当你有「网站 + 共用组件库 + 后台服务」放在同一个仓库里(业内叫 monorepo,mono = 单一,repo = 仓库),用 workspaces 声明它们,npm 安装时会自动把子包互相链接起来,不需要你手工去建链接。官方对这个功能的描述是「自动化了链接过程,免去手工使用 npm link」。它从 npm 7 起提供。
顺带提一句 packageManager 字段:它写的是「这个项目应该用哪个包管理器、哪个版本」,配套的工具叫 Corepack。但要注意现状:Corepack 至今仍是实验性功能,而且从 Node 25 起已经不再随 Node 分发(Node 24 里还有)。所以它不是一个能指望的强制手段,团队约定仍然主要靠文档和 CI。
不想手改 JSON 的时候:npm pkg set
npm pkg set scripts.dev="next dev" # 加一条脚本
npm pkg set private=true # 防止被误发布
npm pkg get scripts # 读出来看看
这里的 package.json 是 JSON 格式(一种纯文本的数据格式:用大括号和引号描述结构,机器一眼能读,人也不难读)。它用点号表示嵌套层级,写回时会沿用你原来的缩进格式。对不熟 JSON 语法的人来说,这比手改文件安全——少打一个逗号或引号,整个项目就跑不起来了。
07^1.2.3 到底允许装哪个版本
这一节是全文最值得逐行读的一节。因为几乎所有「昨天还好好的,今天就装不上了」的事故,根源都在这几个符号上。
先看版本号本身:三段数字各是什么
4.18.2 这样的版本号,三段数字的含义由一套叫语义化版本(semantic versioning,简称 semver)的约定规定:
4.18.2 → 4 变 5,表示有破坏性改动:原来能用的写法可能用不了了。你的代码可能需要改。
4.18.2 → 18 变 19,表示加了新功能,但保持兼容:旧写法继续能用。
4.18.2 → 2 变 3,表示只修 bug(bug 指代码里的错误),不新增、不破坏。
这套约定是 2011 年由 Tom Preston-Werner 提出的,规范文本在 semver.org,npm 用的具体实现是 github.com/npm/node-semver。关键在于:它是一个「约定」,不是技术强制。包作者是否严格遵守,你无法验证——这解释了为什么下面那些符号只是「大概率安全」,而不是保证。
最要紧的一张表:写在 package.json 里的符号允许装什么
| 你写的 | 实际允许的范围 | 为什么 |
|---|---|---|
4.18.2 | 只有 4.18.2 | 不给任何余地,最保守 |
~4.18.2(波浪号) | >=4.18.2 <4.19.0 | 只允许修订号往上走(修 bug 可以,加功能不行) |
^4.18.2(尖号,npm 的默认) | >=4.18.2 <5.0.0 | 允许次版本和修订号往上走(加功能可以,破坏性改动不行) |
^0.2.3 | >=0.2.3 <0.3.0 | 0 开头的包有特殊规则,见下一节 |
^0.0.3 | 只有 0.0.3 | 0.0 开头的包,尖号等于什么都不许变 |
4.18.x | >=4.18.0 <4.19.0 | x 和 * 是「这一位随便」,越往左写越宽松 |
4.x | >=4.0.0 <5.0.0 | |
* | 任何版本 | |
>=4.18.2 <5.0.0 | 就是它字面意思 | 想精确控制时直接写区间 |
4.18.2 - 4.20.0 | >=4.18.2 <=4.20.0 | 连字符表示闭区间,两端都含 |
^4.18.2 || ^5.0.0 | 两个条件满足任意一个 | || 是「或」,用于同时兼容两条主版本线 |
0.x 的坑:写着 ^ 却几乎锁死
尖号的规则有一句精确表述:它允许变化的范围,是「最左边那个非零数字」往右。对 1.0.0 以上的版本,最左边非零的是主版本,所以 ^1.2.3 允许变到 1.x 的尽头。但对 0.2.3,最左边非零的是次版本(那个 2),于是:
"^0.2.3" # 实际只允许 0.2.x —— 从 0.2.3 到 0.3.0 之前
这件事对你有现实影响:很多年轻的工具(尤其是 AI 相关的新项目)都还在 0.x 阶段。你写 ^0.2.3 以为它会自动跟进新功能,实际上它停在 0.2.x。要跟进就得手动改。反过来这也是保护——0.x 阶段的作者按惯例会把次版本当破坏性版本用。
预发布版本不会自己进来
像 1.3.0-beta.1 这种带后缀的版本叫预发布版本(beta 是「测试版」的意思)。规则是:除非你明确写了带预发布后缀的范围,否则它不会被选中。
"^1.2.3" # 不会装到 1.3.0-beta.1,只会往 1.3.0 正式版走
这条规则的设计意图官方写得很清楚:预发布版本「更新频繁、常常包含破坏性改动」,而且一个包同时存在多组 alpha/beta 序列,谁也不知道你想要哪一组。所以默认排除,只有你显式写出来才算同意了风险。
那我到底该写哪种
| 你的处境 | 建议写法 | 理由 |
|---|---|---|
| 普通应用,跟着教程走 | 默认的 ^ 就好 | 能拿到 bug 修复,又不至于被破坏性改动打到 |
| 这是个要长期维护、必须稳定复现的项目 | 照旧用 ^,但把锁文件提交进 git 并用 npm ci | 锁文件已经解决了「复现」,不需要靠把范围写死来达到(§08) |
| 依赖出了一个诡异的 bug,你要排除版本漂移 | 写成精确版本,或 --save-exact | 缩小变量,先把 bug 定位下来再说 |
| 你在写一个给别人用的工具/插件 | 范围放宽,比如 ^1.0.0 甚至 >=1.0.0 <3 | 官方对 peerDependencies 的建议是「尽可能宽,不要锁到具体补丁版本」,否则用你的人天天撞冲突 |
官方有一个网页版计算器:semver.npmjs.com,输入一个范围和版本号,它直接告诉你满不满足。这个动作值得在动手改 package.json 之前做一次——尤其是碰到 0.x 的包和预发布版本时。
08package-lock.json:为什么它比 package.json 更重要
它记的是「上次的解」,不是「你的意图」
官方对这个文件的定义是:它由任何改动 node_modules 或 package.json 的操作自动生成,描述「实际生成出来的那棵树」,使得后续安装无论中间依赖有没有更新,都能生成一模一样的树。每个条目里最关键的三个字段:
^4.18.2 这样的范围,而是 4.18.2 这样的确切值。这才是「上次装的是哪个」的答案。sha512-…。它记的是「这个包体应该长什么样」。下载完一算,对不上就报 EINTEGRITY 并拒绝安装——这是整条链上唯一一处「内容被换过就一定会被发现」的检查。官方原话:这个文件应该被提交到 git
npm 文档写得很直接:This file is intended to be committed into source repositories.(这个文件应当被提交进源码仓库。)它列了四个用途,其中最关键的两个:让同事、部署环境、持续集成「保证装到完全相同的依赖」;以及允许你「时间旅行」回之前的状态,而不必把整个 node_modules 也提交进去。
顺带回答一个几乎人人都会问的问题:node_modules 该不该提交?不该。证据是 GitHub 官方维护的 .gitignore 模板里,Node 那一份明确把 node_modules/ 列在「Dependency directories(依赖目录)」下面。提交锁文件,不提交依赖目录——这一条是标准做法,不是风格偏好。
版本号格式的三代
锁文件里写的 lockfileVersion | 哪个 npm 写的 | 你需要知道什么 |
|---|---|---|
| (没有这个字段) | npm 5 之前的老文件 | 考古级别的项目才会见到 |
1 | npm 5 与 6 | 老项目里常见,升级 npm 后会被自动改写 |
2 | npm 7 与 8 | 同时容纳新旧两套结构——留着一份给 npm 6 读的兼容段落。这个「为什么」常被传错:它兼容的是 npm 自己的旧格式,不是 yarn |
3 | npm 9 及以上(现在) | 格式更干净,只被 npm 7 及以上读得懂 |
除了根目录那份,node_modules 里还会藏一份 node_modules/.package-lock.json,官方叫它「隐藏锁文件」。它是给 npm 加速用的:下次安装时先读它,就不必再去翻整个目录。它永远是 lockfileVersion: 3,因为它不需要照顾老版本。如果你手工改过 node_modules 里的文件,最省事的做法是把它删掉——否则它记录的树和你磁盘上的真实情况会对不上。
npm install 与 npm ci:五条官方差异
| # | npm install | npm ci |
|---|---|---|
| 1 | 没有锁文件也能跑:现算一个 | 必须有锁文件,没有直接报错 |
| 2 | 锁文件里写的东西和 package.json 对不上?那就更新锁文件 | 对不上就报错退出,绝不改写 |
| 3 | 可以只装一个包:npm install 某包 | 只能整项目装,不能单独加包 |
| 4 | 已有的 node_modules 会尽量复用 | 如果 node_modules 已存在,先整个删掉再装 |
| 5 | 会写 package.json 与锁文件 | 两个文件都不碰,安装结果是「冻结」的 |
所以选择很简单:日常你在改依赖,用 npm install;只要目标环境需要「和上次一模一样」(服务器、CI、别人拿到你的项目),用 npm ci。本仓的 CI/CD 怎么用 里那条流水线,装依赖那一步就该是 npm ci。
如果当初生成锁文件时用过某个特殊参数(比如 --legacy-peer-deps),那么跑 npm ci 时必须带上同样的参数,否则它会报错说两边对不上。官方给的做法是把这条设置写进项目里的 .npmrc 并提交,让所有人、所有机器都自动带上它:
npm config set legacy-peer-deps=true --location=project # 写进项目 .npmrc,然后提交它
锁文件冲突了怎么办
多人协作时,package-lock.json 是最容易起冲突的文件(几千行、每行都可能变)。处理顺序建议是:
它由机器生成,手改出来的结果几乎一定有内部矛盾。冲突时选一边版本,然后交给工具重新生成。
npm install 重新对账解决完 package.json 的冲突后,跑一次 npm install:它会以 package.json 为准,把锁文件重新算一遍并写回。这一步多半就够了。
npm ci 验一遍如果 npm ci 能跑通,说明两个文件是一致的——这是一条很好用的自检。
它确实能解决一些冲突,但代价是整个项目的依赖版本可能集体上浮一次——那可能引入新的坏掉的东西,而且这个改动会淹没在几千行 diff(两份文件的差异对比)里没人看得出来。做之前先备份这个文件。
npm shrinkwrap 这个命令在 npm 12 里已经被移除,根目录下的 npm-shrinkwrap.json 也不再被读取。它的历史用途和锁文件重叠。看到旧教程让你跑这个命令时,把它换成「提交 package-lock.json」即可。
09node_modules:为什么这么大、能不能删、要不要提交
先看它到底有多大
§02 那张表已经给过一次包数量:express 是 63 个包、解包后约 2.1 MiB;eslint 是 75 个包、约 10.8 MiB。这两个数说明「包多」不等于「占地方大」——60 多个小包加起来才 2 MB。真正让 node_modules 胀起来的,是另一类东西:给不同操作系统准备好的预编译文件。
| 装了谁 | 必装部分 | 把可选依赖也算上 | 相差的那部分是什么 |
|---|---|---|---|
vite | 13 个包 / 4.2 MiB | 40 个包 / 356.8 MiB | 各个操作系统与 CPU 架构的构建引擎文件(你的机器只用得上其中一个) |
next | 12 个包 / 179.5 MiB | 51 个包 / 1129.0 MiB | 其中 176.2 MiB 是 next 自己,其余是各平台的编译产物 |
所以「我的 node_modules 有 500 MB 正常吗」的答案是:对现代前端项目来说很正常。它主要由三类东西撑起来——框架自己的代码、各个平台的预编译二进制、以及几十上百个小工具包。
为什么清单上只有 28 个,磁盘上却有 63 个目录
因为 npm 会把依赖树尽量拍平(业内叫 hoisting,直译「往上提」)。官方文档用一个小例子讲清了它的算法:
# 假设依赖关系是 A 需要 B 和 C,B 需要 C,C 需要 D
A
├── B
├── C # B 要的 C 已经在顶层有了,直接共用,不再装一份
└── D
# 但如果 B 要 D 的 1 版、C 要 D 的 2 版,冲突了:
A
├── B
├── C
│ └── D@2 # 顶层放一个,另一个塞进需要它的包自己的目录里
└── D@1
这就是为什么 node_modules 里既有大量平铺的文件夹,又会在某些包下面再嵌套一层。官方对这个算法的评价是「确定性的」,但也留了一句重要的提醒:如果两个依赖的安装顺序不同,可能产出不同的树。
树被拍平之后,你能 require 到的东西比你声明的多:某个包恰好把它的依赖提到了顶层,你的代码就顺手用上了它——但你的 package.json 里根本没写这个包。这种叫幽灵依赖(phantom dependency,也叫未声明依赖)。它的可怕之处是不会立刻报错:等哪天上游调整了依赖关系,这个包不再被提到顶层,你的代码突然就找不到了。npm 官方自己给出的检测方法是:开发时用「链接式」安装布局,专门去暴露这类未声明的依赖:
npm install --install-strategy=linked # 官方推荐给「包作者」在开发时用来抓幽灵依赖
那它能不能删
能删,而且随时可以删——这正是「提交锁文件、不提交依赖目录」这套做法的前提。删了以后用一条命令重建:
rm -rf node_modules # Windows:rmdir /s /q node_modules
npm ci # 按锁文件重建,结果和删之前一样
① 你手上没有锁文件。这时删掉重装等于「重新解一次方程」,版本可能整体漂移。
② 你有没提交的本地改动在依赖里。有些人会直接改 node_modules 里的文件来试 bug——删之前先确认那些改动不需要了(正确的做法是用 overrides 钉住版本,或者去这个包的仓库提一个 issue——issue 就是它的问题跟踪页,用来报告 bug、提需求)。
③ 现在是线上出故障、你在救火。救火时删依赖目录可能让现场彻底不可复现,先复制一份再动。
四个「看树」的命令
| 命令 | 它回答什么 | 什么时候用 |
|---|---|---|
npm ls --depth=0 | 我亲自要了哪些包 | 快速确认项目的主要依赖 |
npm ls --all | 整棵树长什么样 | 想知道某个名字是怎么进来的(配合搜索) |
npm explain 某包 | 这个包是谁要求的、沿着哪条链进来的 | 看到一个陌生包,想知道该不该有它 |
npm outdated | 哪些包有新版本(会区分「范围内可升」和「需要跳主版本」) | 想升级但怕踩坑时 |
npm dedupe | 把能合并的重复版本尽量往上提 | 磁盘占用异常时 |
npm prune | 删掉「没人依赖、却躺在目录里」的多余包 | 手动折腾过依赖之后 |
其中 npm explain 是最被低估的一个。AI 给你一个报错、你看到一个从没听过的包名时,用它基本能在一分钟内搞清「它是谁带进来的」。
10装一个包,等于让别人的代码在你电脑上跑
这一节讲的是整个 npm 体系里最需要你真正理解的一件事。它不需要你会写代码,但需要你知道它存在。
机制:安装脚本
包作者可以在自己的 package.json 里写一段在安装时自动执行的命令,叫安装脚本(install script,包括 preinstall、install、postinstall、prepare 这几个名字)。
这个机制本身是必要的、也是善意的:很多包需要在你机器上现场编译(sharp、canvas 这类要处理图片的包,需要按你的系统编译一份二进制),或者下载配套的可执行文件。没有它,这些包根本装不上。
但它的另一面是:你在第 1 步敲的那条 npm install,会让几十个你从未听说过的包的作者,在你电脑上执行他们写的命令。而且这些命令是在你的用户权限下跑的——能读你的文件、能联网。
2025 年那场事故:Shai-Hulud 蠕虫
这不是理论风险。下面是按时间排的事件(出处见 §15):
2025-09-14 —— 一个颜色小工具被投毒
攻击者发布了一个被改过的 @ctrl/tinycolor(一个颜色处理小库,周下载量约两百万)。恶意代码藏在 postinstall 脚本里,只要有人 npm install,它立刻执行——不需要点击、不需要下载额外东西。
24 小时内 —— 从 1 个包到 200 个,几天内到 500 多个
恶意脚本干的事是:在你机器上翻找 npm 的发布凭证,找到之后用它自动去给这个作者名下其他包也注入同样的代码、重新发布。它还会从被感染的机器上偷其他类型的密钥。这就是「蠕虫」与「一次性投毒」的区别:每个新受害者都变成新的传染源。连 CrowdStrike(一家大型安全公司)维护的包也被卷进去了。
2025-09-22 / 09-23 —— 平台侧处置
GitHub 发布说明:从仓库里移除了 500 多个被投毒的包,并让 npm 拒绝上传含有该恶意代码特征的包,切断了自我复制。9 月 23 日,美国网络安全和基础设施安全局(CISA)发布了警报。
2025-11-24 —— 第二波
自称「Shai-Hulud 2.0」的第二波攻击。第三方安全公司的统计是:796 个包、1,092 个版本、波及超过 25,000 个代码仓库,两波合计影响的包,周下载量加总超过 20 亿次。(这几个数字来自安全厂商的统计,不是官方公告,见 §15 的证据边界。)
2026-05-22 —— 平台侧的第一道结构性防线
GitHub 宣布「暂存发布」正式可用:发布流程从「直接发出去」改成「先放进一个待批准队列,由一位真人用两步验证(除了密码之外还要一次动态码或硬件密钥)批准后才生效」。这一步专门防「CI 里的凭证被偷之后自动大批发布」。
2026-07-08 —— npm 12 改了默认行为
npm 12 的发布说明里有一句关键改动:依赖的安装脚本默认被阻断,除非根项目的 allowScripts 策略里放行。同一版本里,从 Git 地址、远程 URL 安装依赖也默认改成「不允许」。这是这个工具十几年来对默认行为最大的一次调整,直接原因就是上面那场事故。
npm 12 具体改了什么,你会遇到什么
行为变化用一句话概括:以前是「默认全跑」,现在是「默认不跑,你逐个批准」。落到操作上:
npm install # 装依赖,但依赖的安装脚本会被跳过
npm install-scripts ls # 看哪些包有脚本、哪些被跳过了
npm install-scripts approve sharp # 批准某个包
npm rebuild # 让刚批准的脚本真正跑一次
批准的结果会被记在项目的 package.json 里(一个叫 allowScripts 的策略),所以它可以跟代码一起提交、被团队和 CI 共用——这比每个人在自己机器上盲点「同意」要靠谱得多。
上面 npm install-scripts … 这一组要 npm 12 起才认识。如果你按 §03 那张表确认自己还在 11.x,敲它会报「不认识这个子命令」——那说明你的机器还停留在旧默认行为上:依赖的安装脚本会自动跑。这种情况下你有两个选择:升到 npm 12(先确认 Node 版本够新),或者用 --ignore-scripts 手动关掉(代价是那些需要现场编译的包会装不上,要自己权衡)。不要以为「我文档里没看到这个命令」就等于「这个风险不存在」——风险一直都在,变的是默认值。
| 你会遇到的现象 | 原因 | 怎么办 |
|---|---|---|
| 装完之后某个包报「找不到二进制」「无法加载原生模块」 | 它的安装脚本被跳过了(npm 12 的默认行为) | 先 npm install-scripts ls 确认它被跳过了,再 npm install-scripts approve 包名 + npm rebuild。批准之前花十秒想一下:你真的需要这个包吗 |
| 全局装一个命令行工具时脚本不跑 | 全局安装没有项目的 package.json 可写策略 |
安装时直接点名:npm install -g --allow-scripts=包名;要长期生效就 npm config set allow-scripts=包名 --location=user |
| 你想彻底不跑任何脚本(更保守) | — | npm install --ignore-scripts,或写进 .npmrc:ignore-scripts=true。它是优先级最高的开关 |
| 你想一次放行全部(最省事也最危险) | — | 有一个逃生口叫 --dangerously-allow-all-scripts。官方对它的评语是 strongly discouraged(强烈不建议)——名字里的 dangerously 是字面意思 |
作为「装包的人」,你现在能做的五件事
npm ci这是性价比最高的一条。锁文件里的 integrity 校验值意味着包内容被换过就会报错;而 npm ci 保证不偷偷把版本往上浮。
npm audit fix --forcenpm audit 会把你的依赖版本拿去和漏洞库比对并给出报告——但它报的是「已知问题」,且 --force 会跨越语义化版本范围去升主版本,可能直接搞坏项目。先看报告里具体是哪个包、哪条依赖链。
npm 有一个不太为人知但很实用的配置:before——只安装某个日期之前发布的版本。恶意版本被发现、下架通常就在发布后的一两天内,加几天冷静期能过滤掉相当一部分攻击:
npm install --before=2026-09-20 # 只装 2026-09-20 之前发布的版本
npm explain 包名。如果一个包来路不明,那就是该停下来查的时候了。
npm 12 把安装脚本默认关掉,是被一场真实事故换来的默认值。不要因为「加个参数就能跑」就一次性把全部脚本放行——那条参数的名字里写着 dangerously。
对「发布包的人」,同一个事故之后平台加了三道锁:① 可信发布——CI 里不再存放长期有效的令牌,而是用一次一换的短期凭证(基于 OIDC 这个身份交换标准:CI 向平台证明「我是哪个仓库的哪次运行」,换来一张几分钟后就过期的通行证;npm 要求命令行工具 ≥ 11.5.1、Node ≥ 22.14.0);② 暂存发布——发布的包先进入待批准队列,由真人用两步验证批准;③ 收紧长期令牌——那些能绕过两步验证的旧式令牌被逐步剥夺敏感操作权限,自动发布被引导去走前两条路。这些变化的方向是一致的:把「一个被偷的长期密钥」变成「一次需要真人确认的操作」。你装包时遇到的「这个包最近没更新」,有时正是作者在等批准队列。
11报错对照表:看到这行字,实际发生了什么
npm 的报错都以 npm error code XXX 的形式给出错误码。下面是你会撞到的几类,以及它们真正在说什么。
| 错误码 | 它在说什么 | 你该做什么 |
|---|---|---|
ERESOLVE |
「依赖树解不开」——最常见的原因是 peerDependencies 冲突:两个包要求同一个宿主程序的不同主版本 | 先读 npm 自动生成的那份解释(它会写一个 eresolve-report.txt,并在屏幕上打印冲突链)。看清是谁和谁冲突之后,再决定是升级其中一个、还是用 overrides 钉住版本。别一上来就 --force——那等于把「没验证过的组合」装进项目 |
ETARGET |
官方解释:In most cases you or one of your dependencies are requesting a package version that doesn't exist.(多半是你或你的某个依赖在要一个不存在的版本。) | 三种可能:版本号打错了;这个版本已被作者撤回;你用的镜像还没同步到(换成官方源试一次) |
ENOTFOUNDETIMEDOUT |
官方把这一组归为网络问题:This is a problem related to network connectivity. | 先确认能联网(npm ping),再检查是不是要挂代理、或者镜像源写错了域名 |
EACCESEPERM |
权限不够:要写的目录你没有写权限 | 不要用 sudo 解决(sudo 是以系统最高权限账户 root 的身份执行,那会让之后所有包都归 root 所有,制造下一堆更麻烦的权限问题)。正确做法是把 Node 装到用户可写的目录(用 nvm 这类 Node 版本管理器,它能在同一个用户下切换多个 Node 版本),或改 npm 的全局目录 |
EBADENGINE |
官方解释:Not compatible with your version of node/npm,并会打印「要求的版本」和「你的实际版本」两行 | 按它打印的那两行对比:要么升 Node,要么换这个包的版本。这条错误通常来自 engines 字段 |
EINTEGRITY |
下载下来的包,算出来的校验值和锁文件里记的对不上 | 这是一条真正的警报,不是简单的缓存坏了。可能是本地缓存损坏,也可能是包内容被换过。先删掉本地缓存里那一项重试;如果反复出现,去查这个包最近有没有异常发布 |
| 脚本跑失败 (退出码非 0:命令结束时会给系统一个数字,0 表示成功) |
安装脚本或 npm run 的那条命令自己失败了,npm 会把失败的脚本名和子进程的退出码打出来 |
看它打印的那条具体命令,然后直接手动跑那条命令——你会看到真正的报错,而不是 npm 转述的 |
--force 不是「修复」
官方对 --force 的描述是:它「解除针对各种副作用、常见错误、不必要的性能下降、以及恶意输入的多重保护」。换句话说,它让 npm 停止替你设防。它在极少数场合是唯一出路(比如依赖树里有历史遗留的死结),但正确姿势是:先弄清报错在说什么,再用它,并且在提交信息里写清为什么。
12该不该换成 pnpm / yarn / bun
你会在很多文章里看到「pnpm 更快、更省磁盘」之类的说法。它们大体上是真的,但换包管理器是一个团队级决定,不是性能优化。先看它们到底差在哪。
| 工具 | 它和 npm 的关键差别 | 锁文件叫什么 | 2026-09-23 的最新版本 |
|---|---|---|---|
| npm | 基准。把依赖拍平放进项目里的 node_modules,每个项目各存一份 |
package-lock.json |
12.1.0(2026-09-22) |
| pnpm | 所有项目的包存在一个全局仓库里,项目里只用链接指向它——所以多项目时省磁盘、装得快;同时它不做扁平化,天然不产生幽灵依赖 | pnpm-lock.yaml |
12.6.0(2026-09-22) |
| yarn | 分成两条线:经典线(1.x,2016 年出现,曾是 npm 的主要替代品)和 Berry 线(2.x 起,可与「零安装」等做法配合)。同一个名字两条线是它最容易被搞混的地方 | yarn.lock |
经典线 1.22.22(2024-03);Berry 线 4.18.0(2026-07-29) |
| bun | 不只是包管理器,还是一个 JS 运行时(可以理解成「Node 的另一种实现」),主打速度,把安装、运行、测试放在一个程序里 | bun.lock |
1.4.2(2026-09-05) |
在公共仓库里搜 yarn,它的最新版停在 1.22.22(2024-03 发布)——那是经典线。而 Berry 线(2.x 起)是以另一个包名发布的:@yarnpkg/cli,最新 4.18.0(2026-07-29)。所以「yarn 是不是已经好几年没更新了」这个判断,取决于你看的是哪条线——这也是本文 §15 想反复说的那件事:版本类事实必须跟到发布渠道,不能看一个页面就下结论。
什么时候值得换
- 你机器上同时有好几个项目,磁盘吃紧。pnpm 的全局仓库模型在这里收益最明显——同一个包在十个项目里只存一份。
- 你在维护一个多包仓库(monorepo)。pnpm 与 yarn 在这方面做得更早、更成熟。
- 你被幽灵依赖坑过一次,想从机制上避免它。
什么时候别换
团队/项目已经用 npm 跑通了(包括 CI 脚本、部署平台缓存配置、文档里的命令)——换一次要改的地方散布在整个流程里,而收益通常只是「装得快一点」。
你主要靠 AI 生成命令——AI 给的绝大多数命令是 npm 的,换过去之后你得自己翻译每一条。
你只是想解决一个具体报错——那不是换工具的理由,是查报错的理由(§11)。
看项目里已经存在哪个锁文件。有 package-lock.json 就是 npm,有 pnpm-lock.yaml 就是 pnpm,有 yarn.lock 就是 yarn。仓库里同时出现两个锁文件,是「有人在混用」的典型症状——这比选哪个更重要,因为混用会让「装上的是哪个版本」彻底失去确定性。
你可能会看到用 packageManager 字段 + Corepack 来「强制所有人用同一个包管理器版本」的做法。截至 2026-09:Corepack 仍是实验性功能,并且从 Node 25 起已经不再随 Node 分发(Node 24 里还在)。所以它现在的定位是「可选的辅助」,不要把它当成能自动约束团队的机制。
13一页纸:以后回来只翻这一节
命令速查
| 我想干什么 | 命令 | 备注 |
|---|---|---|
| 看版本 | node -v · npm -v | npm 独立发版,不跟着 Node 自动最新 |
| 看我要的依赖 | npm ls --depth=0 | 只有顶层 |
| 看整棵树 | npm ls --all | 会很长 |
| 查某个陌生包的来路 | npm explain 包名 | 最被低估的命令 |
| 装依赖(开发时) | npm install | 可能更新锁文件 |
| 装依赖(要一模一样) | npm ci | 需要锁文件;会先删 node_modules |
| 只装生产需要的 | npm ci --omit=dev | 跳过 devDependencies(仍会写进锁文件) |
| 断网也能装 | npm ci --offline | 缓存里缺东西就会失败 |
| 加一个依赖 | npm install 包名 | 写进 dependencies |
| 加一个开发工具 | npm install --save-dev 包名 | 写进 devDependencies |
| 临时跑一个工具 | npx 包名 | 不写进项目、不长期留着 |
| 跑项目里的脚本 | npm run 名字 | AI 说的 npm run dev 就是这个 |
| 看有哪些脚本 | npm run | 不带名字就列出来 |
| 看有没有新版本 | npm outdated | 留意「跨主版本」那类 |
| 查已知漏洞 | npm audit | 「0 个漏洞」≠ 安全 |
| 给安装加冷静期 | npm install --before=YYYY-MM-DD | 只装该日期前发布的版本 |
| 看有哪些安装脚本被跳过 | npm install-scripts ls | npm 12 的新默认行为 |
| 看源指向哪 | npm config get registry | 国内常改成 registry.npmmirror.com |
| 看缓存和全局目录 | npm config get cache · npm prefix -g | 排查磁盘占用时用 |
五条判断,不用记细节也能做对
① 「我该用 install 还是 ci?」——你在改依赖,用 install;你要复现上次的结果(服务器、CI、别人的项目),用 ci。
② 「这个包能不能删 / node_modules 能不能删?」——只要锁文件在并且提交过,随时能删、能重建。没有锁文件时不要删。
③ 「报错怎么办?」——先读报错里点名的那两个东西(哪个包、哪个版本),再看 §11 那张表。不要先加 --force。
④ 「版本号前面那个符号要不要改?」——默认的 ^ 是给普通项目用的;碰到 0.x 的包要知道它其实锁得很死(§07)。
⑤ 「装了一个包,会不会有风险?」——会。真实的判断不是「这个包有没有名」,而是「它有没有安装脚本、我要不要批准它跑」。
14名词速查:别把它们当成什么用
下面这些词在 AI 给的说明和报错里高频出现。第三列是这张表真正的价值——它告诉你这个词最容易被误当成什么。
| 术语 | 定义(一句话) | 为什么重要 / 何时用 · 别当什么用 |
|---|---|---|
| npm | 一个名字指三样东西:网站、命令行工具、公共仓库 | 分不清这三者,就会把「网站搜不到」当成「装不上」。另外:它不是 "Node Package Manager" 的缩写 |
| registry(仓库) | 存放所有已发布包的服务器,默认是 registry.npmjs.org | 别当成 npm 这个程序——它是被访问的服务。连不上时你看到的是 ENOTFOUND 一类网络错误 |
| package(包) | 一个有名字、有版本号、必含 package.json 的文件夹(发布时打成压缩包) | 别把「包」等同于「库」:一个包可以只是一个命令行工具,不含任何可被调用的代码 |
| dependency(依赖) | 你这个项目要用到的、别人写好的代码 | 你和它的关系是「你要用它」,方向不要搞反 |
| 传递依赖 | 「你要的东西要的东西」——你没写、但必须一起装的包 | 这正是「28 个变 63 个」的原因。别以为清单上没写就不会被安装 |
| semver(语义化版本) | 用主.次.修订三段数字表达「这次改动有多大」的一套约定 | 它是约定不是强制,包作者可能不遵守。所以版本号只是「大概率」,锁文件才是「确定」 |
| 锁文件(lockfile) | package-lock.json:记录上次实际装的确切版本、下载地址、校验值 | 别当成「自动生成的垃圾文件」——它是复现能力的全部来源,要提交到 git |
| integrity | 锁文件里那串 sha512-…,是包内容的「指纹」 | 它让「包内容被偷换」变成一次必然被发现的失败。别与版本号混为一谈:版本号可以不变而内容被换 |
| tarball | 把整个包文件夹压成的一个 .tgz 文件,也就是实际下载下来的东西 | 别以为 npm 是「拷贝一个文件夹」——它下载的是压缩包,然后在本地解开 |
| 缓存(cache) | 所有项目共用的本地仓库,默认在 ~/.npm/_cacache | 它解释了「第二次装为什么快」。别把缓存和 node_modules 混为一谈:前者是全局的、后者的每个项目一份 |
| node_modules | 项目里真正存放所有依赖的文件夹 | 别提交到 git,也别拷给别人(里面有给你这台机器编译的二进制) |
| 拍平 / hoisting | npm 把能共用的依赖尽量提到顶层,减少重复 | 它让目录变小的同时带来幽灵依赖。别以为目录结构等于依赖关系 |
| 幽灵依赖 | 你没声明、但因为被拍平而能意外用到的包 | 它今天能用、明天可能突然消失。官方推荐用 --install-strategy=linked 在开发时抓它 |
| devDependencies | 只在开发阶段需要的依赖 | 别理解成「不重要的依赖」——构建工具通常在这里,但项目离开它发不出去 |
| peerDependencies | 「我这个插件要求宿主项目提供某个版本的主程序」 | 它是 ERESOLVE 报错的主要来源。npm 7 起会被自动安装(之前只警告) |
| optionalDependencies | 装不上也允许失败的依赖 | 平台专用的预编译二进制走这条路(配合 os/cpu 字段),是依赖树体积虚高的主因 |
| 安装脚本(lifecycle script) | 包作者写在 package.json 里、在安装时自动执行的命令(postinstall 等) | 这是整条流程里唯一真的执行别人代码的地方。npm 12 起默认不跑,要你逐个批准 |
| 两步验证(2FA) | 除了密码,还需要一个动态码或硬件密钥才能登录/发布 | 它防的是「凭证被偷」——但那场蠕虫恰恰偷的就是能发布包的凭证,所以平台在把它从「可选」推向「必需」 |
| 可信发布(trusted publishing) | CI 里不再存长期令牌,改用一次一换的短期凭证(基于 OIDC 标准) | 你在装包时不用管它,但它决定了「作者能不能安全地发版」。缺了它,作者就只能依赖容易被偷的长期令牌 |
| monorepo / workspaces | 一个仓库里放多个项目,用 workspaces 声明并自动互相链接 | 别把 workspaces 当成「npm 的插件系统」——它管的是仓库内多个包的链接,不管发布 |
| Corepack | 用来按 packageManager 字段自动切换包管理器版本的工具 | 仍是实验性,且 Node 25 起不再随 Node 分发。别指望它替你强制统一团队环境 |
| pnpm / yarn / bun | npm 的替代品,机制不同(全局仓库 / 双产品线 / 自带运行时) | 换它们是团队决定,不是性能优化。先看项目里已有哪个锁文件,别混用 |
15来源与证据边界
这篇里凡涉及「现在是多少」「现在是哪个版本」「默认行为是什么」的判断,核对日期统一为 2026-09-23。下面是出处,按类别分组。
官方文档(一手)
- docs.npmjs.com — About npm — npm 由 website / CLI / registry 三个组件组成、「世界上最大的软件仓库」这两句的出处
- docs.npmjs.com — Logos and Usage(用词政策) — 「npm 不应被当作缩写使用或解释」这句原文的出处(§01)
- docs.npmjs.com — npm ci —
npm ci与npm install的五条差异、install-strategy四种布局(§08、§09) - docs.npmjs.com — npm install — 依赖树的拍平算法与其「确定性但可能因顺序而异」的说明(§09)
- docs.npmjs.com — package-lock.json — 「应当提交进源码仓库」的原话、
lockfileVersion1/2/3 的官方对应关系、隐藏锁文件(§08) - docs.npmjs.com — package.json 参考 — 各字段含义、
os/cpu/libc、peerDependencies 在 npm 7 起自动安装、workspaces(§06) - docs.npmjs.com — .npmrc 与 config 参考 — 配置优先级、
offline/omit/before/allow-scripts/legacy-peer-deps/force的官方描述(§03、§04、§06、§10) - docs.npmjs.com — npm install-scripts — npm 12 的脚本批准机制与四种子命令(§10)
- docs.npmjs.com — scripts — 生命周期脚本的执行顺序,含「
preinstall在依赖被取下来之前运行」「npm ci会先清空node_modules」(§04、§10) - docs.npmjs.com — npm audit · npm ls · npm explain · npm outdated · npm dedupe · npm prune — §09、§11、§13 那些命令语义的出处
- docs.npmjs.com — npx 与 npm prefix — npx 的行为、npm 向上查找项目根的依据(§04)
- docs.npmjs.com — Trusted publishing — OIDC 可信发布、版本要求(CLI ≥ 11.5.1、Node ≥ 22.14.0)、支持的 CI 平台(§10)
- semver.org 与 github.com/npm/node-semver(README——仓库首页那份说明文件——的 Ranges 一节)— §07 那张表的每一条规则,含 0.x 与预发布版本的特殊处理
- github/gitignore 的 Node 模板 —
node_modules/被列为「依赖目录」(§08)
发布说明与公告(版本与默认行为的依据)
- npm CLI v12.0.0 发布说明(2026-07-08) — 依赖安装脚本默认被阻断、
allow-git/allow-remote默认 none、npm shrinkwrap移除、Node 版本要求 - npm CLI v12.1.0 发布说明(2026-09-21/22) — 本文的版本锚点
- npm v7 changelog(2020-10-12) — peerDependencies 改为默认安装、workspaces 第一阶段(§06、§09)
- GitHub changelog(2026-05-22) — 暂存发布正式可用、四种安装来源白名单开关(§10)
- GitHub 安全博客(2025-09-22) — 「2025-09-14 收到 Shai-Hulud 通知」「移除 500 多个被投毒的包」、以及发布侧加固路线(2FA、七天有效的细粒度令牌、可信发布)
- GitHub 公告(2020-03-16) — GitHub 收购 npm,并留下了一个历史横截面数字:「130 万个包、每月 750 亿次下载」(§15 用来对照今天的量级)
- npm 官方博客(2014-09-11) — 「npm 由 Isaac Z. Schlueter 于 2009 年创建」「2014 年 1 月底成立 npm, Inc.」「2014-02-04 起由 npm, Inc. 托管 registry」
- Node.js 官方博客(2011-05-01) — npm 1.0 发布
- nodejs/node PR #61207 — Corepack 从 Node 25 起不再随 Node 分发的决定(§06、§12)。PR 是「合并请求」,也就是一次代码改动的提案与讨论记录
本文自己做的核对(不是转述)
正文里有几处数字是本文当天亲手从一手接口取的,你可以用同样的方式复现:
① 仓库里到底登记了多少个包名(442 万)
curl -s https://replicate.npmjs.com/ → 返回的 JSON 里 doc_count 就是。注意口径:这是仓库里的文档数,包含已占位但未真正发布的包名,所以不能读成「能用的包有 442 万个」。
② 全站一周下载量(1,599 亿)
curl -s https://api.npmjs.org/downloads/point/last-week(不指定包名就是全站聚合)。本文取的是 2026-09-15 ~ 09-21 那一周。单独查某个包:curl -s https://api.npmjs.org/downloads/point/last-week/typescript → 209,215,221(同一周)。
③ 依赖树的包数与体积(§02、§09 那张表)
方法:从 https://registry.npmjs.org/<包名>/latest 取每个包的 dependencies 与 optionalDependencies,递归走完整棵树(按包名去重),把每个包的 dist.unpackedSize 加起来。两点要说明:它按每个依赖的 latest 版本走,不等同于真实解析结果(真实安装可能因版本冲突装进同一个包的多个版本,节点数会更多);unpackedSize 是解包后大小,不等于磁盘占用。想自己验一个数,可以跑:curl -s https://registry.npmjs.org/next/latest | python3 -c "import sys,json;print(json.load(sys.stdin)['dist']['unpackedSize']//1048576,'MiB')"。
④ npm 自己的最新版本与发布日(12.1.0 / 2026-09-22)
curl -s https://registry.npmjs.org/npm | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['dist-tags']['latest'],d['time'][d['dist-tags']['latest']])"。各 Node 线自带哪个 npm,则看 https://nodejs.org/dist/index.json 里每个版本的 npm 字段。
已知的分歧、薄弱处与时效
① npm 的「首发日期」有两个说法,差一天。维基百科写 2010-01-12;而 GitHub 仓库里 v0.0.1 这个标签所指的那次 commit(一次代码提交,会记录精确时间)是 2010-01-12(太平洋时间)/2010-01-13(UTC,世界标准时间)。更早的是仓库创建时间 2009-09-29。所以严谨的说法是:代码 2009 年 9 月底就有了,第一个版本在 2010 年 1 月中。本文采用这个说法,不宣称精确到某一天。
② Shai-Hulud 第二波的具体数字(796 个包 / 1,092 个版本 / 25,000+ 仓库)来自安全厂商的统计,不是官方公告。官方公告我能核到的只有「第一波移除 500 多个包」这一处。这些数字可能随调查修正,本文用它来说明量级,不作为精确事实。
③ 令牌收紧的时间表(2026 年 8 月第一批、2027 年 1 月第二批)来自科技媒体的转述,我没能在官方页面上核到逐条日期。所以 §10 只讲了「方向」,没有写具体日期。
④ 关于「lockfileVersion 2 是为了兼容 yarn」的说法,我没有找到任何官方依据。官方文档给出的理由是与 npm 6 / v1 格式兼容。网上这个说法流传很广,但本文按官方说法写。
⑤ npm 12 里「批准脚本」的命令名。有媒体报道写作 npm approve-scripts --allow-scripts-pending,而官方文档的主命令是 npm install-scripts approve(approve-scripts 作为别名也存在)。以官方文档为准;如果两个都报错,用 npm install-scripts --help 看当前版本实际支持什么。这一类「文档与报道不同步」本身就是本文 §15 想传达的态度:涉及命令时,以你机器上那个版本的 --help 为最终依据。
时效提示:这篇属于「演进型」——机制部分(依赖树怎么解、锁文件为什么存在、为什么会有幽灵依赖)不容易过时,但版本号、默认行为、安全机制的状态天天在变。本文校对日期 2026-09-23;其中提到 npm 12 的默认行为改动就在两个多月前,是本文里最「新」也最容易过期的一段。动手前请以你机器上 npm --version 和官方文档为准。
99一句话总结
包管理做的事只有一件:把你写的「我要什么范围」变成一份确定的、别人也能复现的依赖清单,并把它取回来放好。package.json 是你的意图,package-lock.json 是已经解出来的答案,node_modules 是答案的落地结果——三者里只有第二个需要你提交到 git,也只有它能保证「换台机器装出一样的」。
对不写代码、靠 AI 推进项目的你,真正需要记住的是四件事:① npm install 与 npm ci 的选择取决于「你要不要复现上次的结果」;② 版本号前那个尖号在 0.x 的包上几乎等于锁死;③ 依赖的依赖会被一起装进来,所以「我只装了一个包」从来不是真的;④ 装包是让别人的代码在你机器上跑,npm 12 起默认不跑安装脚本,这个默认值值得保留。其余的一切——镜像、报错、要不要换 pnpm——本文都给了可以照着做的判断法。