可恶的 node_modules
作为前端工程师应该都知道这个东东,在我的电脑里存有几年积攒下来的各种工程约一百G,现在换了新电脑,导资料真麻烦,因为这个东东太大且文件太多,复制很慢。
node_modules 目录结构
npm install 执行完毕后,我们可以在 nodemodules 中看到所有依赖的包。虽然使用者无需关注这个目录里的文件夹结构细节,只管在业务代码中引用依赖包即可,但了解 nodemodules 的内容可以帮我们更好理解 npm 如何工作,了解从 npm 2 到 npm 5 有哪些变化和改进。
为简单起见,我们假设应用目录为 app, 用两个流行的包 webpack, nconf 作为依赖包做示例说明。并且为了正常安装,使用了“上古” npm 2 时期的版本 webpack@1.15.0, nconf@0.8.5.
npm 2 - 树状结构
npm 2 在安装依赖包时,采用简单的递归安装方法。执行 npm install 后,npm 2 依次递归安装 webpack和 nconf 两个包到 nodemodules 中。执行完毕后,我们会看到 ./nodemodules 这层目录只含有这两个子目录。
node_modules/
├── nconf/
└── webpack/
进入更深一层 nconf 或 webpack 目录,将看到这两个包各自的 nodemodules 中,已经由 npm 递归地安装好自身的依赖包。包括 ./node_modules/webpack/node_modules/webpack-core , ./node_modules/conf/node_modules/async 等等。而每一个包都有自己的依赖包,每个包自己的依赖都安装在了自己的 nodemodules 中。依赖关系层层递进,构成了一整个依赖树,这个依赖树与文件系统中的文件结构树刚好层层对应。
最方便的查看依赖树的方式是直接在 app 目录下执行 npm ls 命令。
app@0.1.0
├─┬ nconf@0.8.5
│ ├── async@1.5.2
│ ├── ini@1.3.5
│ ├── secure-keys@1.0.0
│ └── yargs@3.32.0
└─┬ webpack@1.15.0
├── acorn@3.3.0
├── async@1.5.2
├── clone@1.0.3
├── ...
├── optimist@0.6.1
├── supports-color@3.2.3
├── tapable@0.1.10
├── uglify-js@2.7.5
├── watchpack@0.2.9
└─┬ webpack-core@0.6.9
├── source-list-map@0.1.8
└── source-map@0.4.4
这样的目录结构优点在于层级结构明显,便于进行傻瓜式的管理:
- 例如新装一个依赖包,可以立即在第一层 node_modules 中看到子目录
- 在已知所需包名和版本号时,甚至可以从别的文件夹手动拷贝需要的包到 node_modules 文件夹中,再手动修改 package.json 中的依赖配置
- 要删除这个包,也可以简单地手动删除这个包的子目录,并删除 package.json 文件中相应的一行即可
实际上,很多人在 npm 2 时代也的确都这么实践过,的确也都可以安装和删除成功,并不会导致什么差错。
但这样的文件结构也有很明显的问题:
- 对复杂的工程, node_modules 内目录结构可能会太深,导致深层的文件路径过长而触发 windows 文件系统中,文件路径不能超过 260 个字符长的错误
- 部分被多个包所依赖的包,很可能在应用 node_modules 目录中的很多地方被重复安装。随着工程规模越来越大,依赖树越来越复杂,这样的包情况会越来越多,造成大量的冗余。
——在我们的示例中就有这个问题, webpack 和 nconf 都依赖 async 这个包,所以在文件系统中,webpack 和 nconf 的 node_modules 子目录中都安装了相同的 async 包,并且是相同的版本。
npm 3 - 扁平结构
主要为了解决以上问题,npm 3 的 node_modules 目录改成了更加扁平状的层级结构。文件系统中 webpack, nconf, async 的层级关系变成了平级关系,处于同一级目录中。
虽然这样一来 webpack/nodemodules 和 nconf/nodemodules 中都不再有 async 文件夹,但得益于 node 的模块加载机制,他们都可以在上一级 node_modules 目录中找到 async 库。所以 webpack 和 nconf 的库代码中 require('async') 语句的执行都不会有任何问题。
这只是最简单的例子,实际的工程项目中,依赖树不可避免地会有很多层级,很多依赖包,其中会有很多同名但版本不同的包存在于不同的依赖层级,对这些复杂的情况, npm 3 都会在安装时遍历整个依赖树,计算出最合理的文件夹安装方式,使得所有被重复依赖的包都可以去重安装。
npm 文档提供了更直观的例子解释这种情况:
假如 package{dep} 写法代表包和包的依赖,那么 A{B,C}, B{C}, C{D} 的依赖结构在安装之后的 node_modules 是这样的结构:
A
+-- B
+-- C
+-- D
这里之所以 D 也安装到了与 B C 同一级目录,是因为 npm 会默认会在无冲突的前提下,尽可能将包安装到较高的层级。
如果是 A{B,C}, B{C,D@1}, C{D@2} 的依赖关系,得到的安装后结构是:
A
+-- B
+-- C
`-- D@2
+-- D@1
这里是因为,对于 npm 来说同名但不同版本的包是两个独立的包,而同层不能有两个同名子目录,所以其中的 D@2 放到了 C 的子目录而另一个 D@1 被放到了再上一层目录。
很明显在 npm 3 之后 npm 的依赖树结构不再与文件夹层级一一对应了。想要查看 app 的直接依赖项,要通过 npm ls 命令指定 --depth 参数来查看:
npm ls --depth 1
PS: 与本地依赖包不同,如果我们通过 npm install--global 全局安装包到全局目录时,得到的目录依然是“传统的”目录结构。而如果使用 npm 3 想要得到“传统”形式的本地 node_modules 目录,使用 npm install--global-style 命令即可。
npm 5 - package-lock 文件
npm 5 发布于 2017 年也是目前最新的 npm 版本,这一版本依然沿用 npm 3 之后扁平化的依赖包安装方式,此外最大的变化是增加了 package-lock.json 文件。
package-lock.json 的作用是锁定依赖安装结构,如果查看这个 json 的结构,会发现与 node_modules 目录的文件层级结构是一一对应的。
以依赖关系为: app{webpack} 的 'app' 项目为例, 其 package-lock 文件包含了这样的片段。
"express": {
"version": "4.15.4",
"resolved": "https://registry.npmjs.org/express/-/express-4.15.4.tgz",
"integrity": "sha1-Ay4iU0ic+PzgJma+yj0R7XotrtE=",
"requires": {
"accepts": "1.3.3",
"array-flatten": "1.1.1",
"content-disposition": "0.5.2",
"content-type": "1.0.2",
"cookie": "0.3.1",
"cookie-signature": "1.0.6",
"debug": "2.6.8",
"depd": "1.1.1",
"encodeurl": "1.0.1",
"escape-html": "1.0.3",
"etag": "1.8.0",
"finalhandler": "1.0.4",
"fresh": "0.5.0",
"merge-descriptors": "1.0.1",
"methods": "1.1.2",
"on-finished": "2.3.0",
"parseurl": "1.3.1",
"path-to-regexp": "0.1.7",
"proxy-addr": "1.1.5",
"qs": "6.5.0",
"range-parser": "1.2.0",
"send": "0.15.4",
"serve-static": "1.12.4",
"setprototypeof": "1.0.3",
"statuses": "1.3.1",
"type-is": "1.6.15",
"utils-merge": "1.0.0",
"vary": "1.1.1"
}
},
看懂 package-lock 文件并不难,其结构是同样类型的几个字段嵌套起来的,主要是 version, resolved, integrity, requires, dependencies 这几个字段而已。
- version, resolved, integrity 用来记录包的准确版本号、内容hash、安装源的,决定了要安装的包的准确“身份”信息
- 假设盖住其他字段,只关注文件中的 dependencies:{} 我们会发现,整个文件的 JSON 配置里的 dependencies 层次结构与文件系统中 node_modules 的文件夹层次结构是完全对照的
- 只关注 requires:{} 字段又会发现,除最外层的 requires 属性为 true 以外, 其他层的 requires 属性都对应着这个包的 package.json 里记录的自己的依赖项
因为这个文件记录了 nodemodules 里所有包的结构、层级和版本号甚至安装源,它也就事实上提供了 “保存” nodemodules 状态的能力。只要有这样一个 lock 文件,不管在那一台机器上执行 npm install 都会得到完全相同的 node_modules 结果。
这就是 package-lock 文件致力于优化的场景:在从前仅仅用 package.json 记录依赖,由于 semver range 的机制;一个月前由 A 生成的 package.json 文件,B 在一个月后根据它执行 npm install 所得到的 node_modules 结果很可能许多包都存在不同的差异,虽然 semver 机制的限制使得同一份 package.json 不会得到大版本不同的依赖包,但同一份代码在不同环境安装出不同的依赖包,依然是可能导致意外的潜在因素。
相同作用的文件在 npm 5 之前就有,称为 npm shrinkwrap 文件,二者作用完全相同,不同的是后者需要手动生成,而 npm 5 默认会在执行 npm install 后就生成 package-lock 文件,并且建议你提交到 git/svn 代码库中。
package-lock.json 文件在最初 npm 5.0 默认引入时也引起了相当大的争议。在 npm 5.0 中,如果已有 package-lock 文件存在,若手动在 package.json 文件新增一条依赖,再执行 npm install, 新增的依赖并不会被安装到 node_modules 中, package-lock.json 也不会做相应的更新。这样的表现与使用者的自然期望表现不符。在 npm 5.1 的首个 Release 版本中这个问题得以修复。这个事情告诉我们,要升级,不要使用 5.0。
——但依然有反对的声音认为 package-lock 太复杂,对此 npm 也提供了禁用配置:
npm config set package-lock false
清除所有
了解了node_modules目录结构后,知道这东东都是一些重复的依赖包,干脆全部删除便于复制工程项目,手动删除好像不太现实,太多了,还要一个个找,通过命令可以一键完成。
find . -name "node_modules" -print | xargs rm -rf
命令前部分是通过find
递归找到当前目录下所有的node_modules
目录并打印出来,后部分是通过管道将前部分查找的结果当作参数并强制删除。