Node.js 环境搭建:装一个包,为什么多出几十个文件夹
装一个 Express,官方文档就一行命令:npm install express。三秒跑完,一切正常。直到有一天你随手 ls node_modules–里面躺着几十个你从没点名要过的文件夹。你点了一个菜,后厨端上来一桌。
另一幕更常见:周一你把 Node 升级到最新版,老项目当场罢工;切回旧版本,新教程的示例又跑不了。一台电脑只能装一个 Node?那同时维护三个项目的人怎么办?
这篇讲环境搭建,但不想给你一份”复制粘贴命令清单”–命令网上一搜一大把,值得讲的是命令背后那个设计:Node 的世界为什么长成这样。
L1:先让它跑起来–装 Node、建项目、装一个包
你在第一层,目标是十分钟后拥有一个能跑的环境:Node 本体、一个项目目录、一个装进来的第三方包。
装 Node。先别急着去官网下安装包–那是”一锤子买卖”:一台电脑一个版本,升级降级全靠重装。正确姿势是先装一个版本管理器:Linux/macOS 用 nvm,Windows 用 nvm-windows(同思路的独立实现):
1
2
3
4
5
# 安装命令以 nvm 官方 README 的最新版为准,大意是:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# 重开终端后:
nvm install --lts # 装最新的长期支持版(LTS:官方承诺维护 30 个月的稳定版)
node -v # 输出类似 v22.11.0
装 Node 自带 npm(包管理器,专门负责装和管理第三方包),不用单独装。
接着建项目、装包、跑起来,三步:
1
2
3
4
mkdir env-lab && cd env-lab
npm init -y # 生成 package.json(它是谁,下一层讲)
npm install ms # 装一个极小的包:把 "2 days" 这类描述解析成毫秒数
node -e "console.log(require('ms')('2 days'))"
最后一行输出 172800000–正好是 2 天的毫秒数。到这里,”会用”达成了。
但有两个现象这套流程解释不了:
npm install express之后,node_modules里凭空多出几十个文件夹–你明明只点了一个菜;- 每次装包,除了
node_modules,还悄悄更新一个package-lock.json,几万行,密密麻麻。
这两个文件,就是下一层的入口。
L2:懂原理–点菜单、上桌的菜、进货单
你在第二层,目标是搞清楚三样东西各是谁:package.json、node_modules、package-lock.json。
一个类比先立起来,把装包想象成在餐厅点菜:
package.json是点菜单:你亲手写(或 npm 替你记)的”我要什么”。npm init -y生成的是空菜单,npm install express会自动在上面添一行;node_modules是上桌的菜:实实在在占磁盘的东西,代码里require的就是它;package-lock.json是进货单:不但记了菜,还记了每样食材是哪家农场哪一批的(精确到版本号和下载地址)。
现在解释第一个现象。Express 不是一个”菜”,它是一张点菜单加一串托付:它自己也 npm install 过一堆东西(路由解析、cookie 处理、URL 转义……),每个包又可能有自己的托付。npm install express 做的事是:把整条托付链上所有人点的菜统统端上来。几十个文件夹不是 Express 臃肿,是它替你把后厨整个搬来了。不信可以数一数:
1
2
npm install express
ls node_modules | wc -l # 数一数到底端上来多少个包
第二个现象更值得亲手验证。先预测再看答案:你从未安装过 cookie 这个包,下面这行能不能跑通?
1
node -e "console.log(require('cookie').parse('a=1'))"
能跑通,输出 { a: '1' }。你没点过这道菜,它却上桌了。原因叫扁平化提升(hoisting):2015 年之前(npm@2 时代),每个包的依赖装进它自己的 node_modules 里,层层嵌套–同一个包在项目里被复制十几份,一个项目动辄几十万个文件,人称”嵌套地狱”。npm@3 之后改为尽量把所有包提升到顶层,目录瞬间清爽了。副作用就是刚才那行代码:只要依赖链上任何人点过这道菜,你也夹得到。
这在生态里有个正式名字:幽灵依赖(phantom dependency)。它今天是福利,明天是地雷–哪天 Express 升级时不再依赖 cookie,你的 require('cookie') 当场崩,报错还极其迷惑(”Cannot find module”–可我明明什么都没动过啊)。
那 package-lock.json 呢?它是”可复现”三个字的落地。点菜单上写的版本通常是 ^4.19.2 这种范围(^ 读作”兼容”:4.x 里不低于 4.19.2 的都行)。”都行”对人是自由,对构建是灾难:你今天装的 4.19.3 和新同事下周一装的 4.21.0 行为略有差异,一个”在我电脑上是好的”式的 bug 就诞生了。lock 文件把”都行”凝固成”就这个”:精确版本、下载地址、内容哈希全记下来。部署和 CI 用的 npm ci 就是严格按 lock 装,对不上直接报错而不是自作主张。
顺手把 L1 埋的另一个问题也收掉:nvm 凭什么能”切换版本”?跑一下 which node,你会看到类似 ~/.nvm/versions/node/v22.11.0/bin/node 的输出–nvm 给每个版本建了独立目录,切换版本只是把 PATH 指向其中之一。几副度数不同的眼镜换着戴:脸(你的项目)没变,看世界的度数变了。
到这里,”怎么工作”清楚了。但最根本的问题还没碰:为什么 Node 非要把依赖装进每个项目自己的文件夹?你装 Python 包是装进解释器旁的公共目录,全世界共享一份;Windows 装软件进 Program Files,也没人每个项目复制一份。Node 这个设计,磁盘动辄被 node_modules 吃掉几十 GB–是它傻吗?往下爬一层。
L3:想得透–为什么每个项目自带一个平行宇宙
你在第三层,最后一层。不谈命令也不谈机制,谈”为什么非这样不可”。
先看被否决的方案长什么样:全局共享。所有包装进一个公共目录,所有项目读同一份。省磁盘,看起来优雅。它的失败场景只需一句话:两个项目,一个要 ms 1.x,一个要 ms 2.x,公共目录里只能装一个–装了新的,老的当场崩;伺候了老的,新的起不来。这毛病有个古老的名字:依赖地狱(Windows 老用户对系统目录里的 DLL 冲突应该似曾相识)。
Node 的回答是朝反方向走到极端:不做共享,每个项目一个自己的 node_modules,整棵依赖树平铺在项目目录里。代价是磁盘爆炸、成千上万份重复拷贝;换来的是项目之间绝对隔离–你的项目是一个随身携带的平行宇宙,宇宙里的物理常数只由你定义。
2016 年 3 月,这个设计为什么值这个价,有过一次公开处刑式的演示。一位开发者因为包名纠纷,把自己发布的 left-pad 从 npm 服务器上撤下–一个总共 11 行代码的函数。当天,React、Babel 和数不清的项目的构建流水线集体崩溃:他们的依赖链深处躺着这 11 行,而”随时可以从服务器重新拿到它”这个默认假设碎了。事后 npm 收紧了撤包政策,整个生态学到的一课是:你依赖的世界不在你手里,除非你把它钉死在自己手里。lock 文件记下精确版本和内容哈希、依赖装进项目目录–本质上都是在干这一件事。
后来的故事你也猜得到:磁盘爆炸的代价有人不肯付。pnpm(名字就是 “performant npm”)用硬链接让所有项目共享磁盘上的同一份包文件,再用符号链接搭出每个项目专属的目录结构–既保住隔离,又消灭重复。也就是说,这笔权衡在今天的最优解已经不是 npm 的做法;但”项目级隔离”这个前提,再没有人挑战过。
本质段来了。去掉所有术语,”环境搭建”到底是什么?
把”我的代码在哪个世界里跑”从口头约定变成两份文件:package.json 写规则(我要什么),package-lock.json 写判决(就是这批)。换一台电脑、换一个同事、换一条 CI 流水线,只要带上这两份文件,就能原样召唤出同一个世界。
检验一下这个本质成不成立:你能不借用任何术语,向同事讲清”删掉 node_modules 不算事、删掉 package.json 才是天塌了”为什么成立吗?
一句话总结这层权衡:全局共享想用磁盘换一致性,没换到;项目隔离用磁盘买到了绝对可复现。几十 GB 就是这份确定性的标价–嫌贵,有 pnpm 打折。
收尾:一张地图
按情况对号入座:
| 你的情况 | 该做的事 |
|---|---|
| 第一次装 Node | 装 nvm(Windows 用 nvm-windows),再 nvm install --lts;别用官网安装包 |
| 多个项目、Node 版本各不相同 | 项目根目录放一个 .nvmrc(内容就一行版本号),进目录 nvm use |
| 装包太慢 | npm config set registry https://registry.npmmirror.com(国内镜像) |
| 部署 / CI 装依赖 | npm ci,严格按 lock 装,装出和同事一模一样的宇宙 |
| 磁盘被 node_modules 吃穿 | 换 pnpm(安装与迁移见下一篇) |
| node_modules 出了诡异问题 | rm -rf node_modules && npm ci–宇宙可以随时重建,这是设计给你的底气 |
两个自测问题,能答上来说明真懂了:
- 为什么删掉
node_modules重装能恢复原样,删掉package.json就真的没了?(答案在 L3 的两份文件。) require('cookie')从没装过却能用,这埋着什么雷?(答案在 L2 的幽灵依赖。)
下一站:如果上一篇事件循环还没看,建议先回去补–那篇讲”代码怎么跑”,这篇讲”代码在哪个世界里跑”。装包的命令该怎么选(本地、全局、npx、pnpm),单独写了一篇:《全局装的 lodash,为什么项目里 require 不到》。都齐了,就装个 Express 写第一个 API 吧–你会发现同时用到这几篇的东西。