文章

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 天的毫秒数。到这里,”会用”达成了。

但有两个现象这套流程解释不了:

  1. npm install express 之后,node_modules 里凭空多出几十个文件夹–你明明只点了一个菜;
  2. 每次装包,除了 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–宇宙可以随时重建,这是设计给你的底气

两个自测问题,能答上来说明真懂了:

  1. 为什么删掉 node_modules 重装能恢复原样,删掉 package.json 就真的没了?(答案在 L3 的两份文件。)
  2. require('cookie') 从没装过却能用,这埋着什么雷?(答案在 L2 的幽灵依赖。)

下一站:如果上一篇事件循环还没看,建议先回去补–那篇讲”代码怎么跑”,这篇讲”代码在哪个世界里跑”。装包的命令该怎么选(本地、全局、npx、pnpm),单独写了一篇:《全局装的 lodash,为什么项目里 require 不到》。都齐了,就装个 Express 写第一个 API 吧–你会发现同时用到这几篇的东西。

本文由作者按照 CC BY 4.0 进行授权