从一个构建问题再谈依赖包加载机制
jerryjerry -问题描述
前段时间笔者和小伙伴们一起在对部门内的一些老项目做升级改造,其中有一个关键项是统一构建工程,即对原先散落在各个项目中的自定义 webpack 构建脚本进行收敛和抽象,定制标准构建器,然后各个项目统一采用这个标准构建器进行打包构建。
具体的工作过程如下:
pnpm i && pnpm build
(采用的 pnpm 而非 npm,这里是个伏笔~)。出人意料的是,项目启动构建后直接报错了:
“Module not found: Error: Can't resolve 'css-loader' in 'xxx/Documents/project/biz-pro/ops-lfs”
从报错信息已经很容易看出是 css-loader 这个模块找不到了,那么来看下我们针对 css-loader 这个模块做了什么变更:
首先,我们删除了项目中构建相关依赖包,不再直接依赖 css-loader。其次,我们安装了统一构建器,且在这个构建器包里安装了构建相关依赖,包括 css-loader 等 webpack loader 。
最终,css-loader 从项目的直接依赖变成了直接依赖的子依赖,从表面上来看,只是依赖的层级发生了变化而且依赖并没有丢失,那么为什么启动构建时会报 css-loader 模块找不到的错误呢?问题分析
通过上述描述,我们已经基本确定了 Can't resolve 'css-loader'
的报错问题和 css-loader 这个包在项目中的依赖层级由“直接依赖”变成了“二级依赖”有关。
那么有没有办法让 css-loader 在安装时进行依赖提升,即让二级依赖 css-loader 安装到项目 node_modules 的根目录下,从而解决这个问题?
通过网上查找,我们发现 pnpm 官网上已有关于 依赖提升设置 的说明,官方文档明确指出可以通过 .npmrc
文件配置的方式来实现 pnpm 下的依赖提升:
shamefully-hoist=true
相关 issue:Problems with pnpm and the way loaders are resolved #5087按照官方文档我们尝试了下这种方式,然后执行 pnpm install,果然 css-loader 被提升到了 node_modules
的根目录下:
此时,再运行 pnpm build,项目构建成功:
所以,通过 pnpm 的 依赖提升设置 是可以规避 css-loader 模块加载失败的问题。
事情到这里本该可以结束了,但是细心的读者可能已经发现了这种方式导致 node_modules 下的依赖包的数量陡增,即这种情况下使用 pnpm 和使用 npm 在安装效果上并无二致,让 pnpm 又走回了 npm 的老路,无法充分发挥 pnpm 非扁平 node_modules 结构(后文会介绍到)的优势。
那么有没有更优雅的方式,既能解决 loader 模块加载失败的问题,又能保持 pnpm 的优势功能不被阉割?
原理深究通过前面的介绍,可能聪明的读者已经轻易地洞察了这个问题的关键所在并有了初步的想法,但笔者当时到这里还是有点朦胧,似懂而非懂,故暗自决心要一探究竟。老话说的好,“工欲善其事必先利其器”,我们先沉下心来一起温故下“包管理”和 “loader 加载机制”的基础知识。
包管理机制作为前端开发的我们,几乎每天都在执行的 xxx install
命令。我们先来回忆一下,当命令被敲下后会发生什么:
xxx
从远端下载 package.json 中申明的依赖的压缩包到本地缓存;其次,xxx
将依赖的压缩包解压到当前项目的 node_modules
目录下;这其实就是“包管理”最基本的一个能力 —— 安装依赖,此外还有:升级、更新和删除依赖等。“包管理”实际帮我们解决了在项目开发过程中,引用到各种不同的库,各种库又依赖了其他不同的库,这些依赖如何进行管理的一系列问题。
随着前端模块化、工程化的发展,“包管理”已经深入人心并影响深远,正常的前端生产活动已经离不开“包管理”了,于是各种负责“包管理”的“包管理器”应运而生,上述命令行中的 xxx 其实就是各种“包管器”。目前社区主流的“包管理器”主要有:npm、yarn、pnpm 这三种。npm 先驱
说到“包管理器”,就不得不提到大名鼎鼎的 npm,这个早在 2010 年就诞生的“前端巨子”,如今已经被广泛使用。npm 同时又是其他包管理器的基石,前端所有的包管理器几乎都是基于 npm 演化而来的。
node_modules
嵌套结构早期的 npm,如 npm v2,采用的是嵌套 node_modules
结构,即项目 install 后各个不同的依赖包会平铺到 node_modules
目录下,各依赖包的子依赖又会继续嵌套在其直接依赖的 node_modules
目录下。举个例子,某个项目直接依赖了 A、B 和 C 三个包,其中 A 和 C 依赖了相同版本的 D@1.0.0,而 B 又依赖了不同版本的 D@2.0.0,项目 package.json 如下:
{
"dependencies": {
"A": "^1.0.0",
"B": "^1.0.0",
"C": "^1.0.0"
}
}
项目 install 后,node_modules 结构如下:
node_modules
├── A@1.0.0
│ └── node_modules
│ └── D@1.0.0
└── B@1.0.0
│ └── node_modules
│ └── D@2.0.0
└── C@1.0.0
└── node_modules
└── D@1.0.0
由此可见,npm v2 至少存在两个问题:冗余安装。相同版本的 D@1.0.0 被安装了两次。嵌套地狱。依赖包越多,冗余安装的包也越多,占用的磁盘空间就越大,且依赖的层级越深,嵌套深度也就越深。
npm v3 的救赎从 npm v3 开始实现了子依赖提升(hoist)的方案,采用扁平的 node_modules
结构,将嵌套的依赖在 node_modules
下打平,避免过深的依赖树和依赖包冗余安装。注:当前最新的 npm 版本是 npm v9,但 npm 确实是在 npm v2 到 npm v3 的版本变更中实现了 node_modules
目录打平,所以我们这里还是以 npm v3 作为讨论对象。继续上述例子, npm v3 下项目 install 后,node_modules
则变成如下结构:
node_modules
├── A@1.0.0
├── B@1.0.0
└── node_modules
└── D@2.0.0
└── C@1.0.0
└── D@1.0.0
由此可见,D@1.0.0 被提升到 node_modules
根目录,且仅被安装一次,这种设计在一定程度上能解决冗余安装的问题,同时依赖的层级也会随之减少。
那么子依赖提升是不是就已经是 npm 完美的解了?
回答这个问题之前,可能我们还得再了解下 npm v3 还有哪些“意难平”事件。
在上述例子中,实际依赖关系如下:
A,C:D@1.0.0B:D@2.0.0
细心的读者可能会问,在扁平化 node_module
s 结构中,同样是子依赖,而最终得到提升的为什么是 D@1.0.0 而不是 D2.0.0?这个问题,网上其实已经有很多解释,大意就是:“npm 会对依赖进行一次排序,字典序在前面的包的底层依赖会被优先提出来”。
这种依赖提升的“不确定性”意味着,如果项目的 package.json 发生了变更,项目 install 后也可能会得到不同的 node_modules
结构。所以,如果 package.json 发生了变更,一般情况下建议删除本地 node_modules
重新 install,确保能代码能正常运行。
继续上面的例子,假如这次提升的是 D@2.0.0,则 node_modules
结构如下:
node_modules
├── A@1.0.0
└── node_modules
└── D@1.0.0
├── B@1.0.0
└── C@1.0.0
└── node_modules
└── D@1.0.0
└── D@2.0.0
显而易见,这里 D@1.0.0 又会被安装了 2 次,这意味着 A 和 C 虽然依赖了相同的 D@1.0.0,但运行时却是不同的模块 D@1.0.0 的引用。所以,一般情况下建议模块导出之前尽量不要做一些副作用,使用者的项目可能因此而出错。
幽灵依赖幽灵依赖是指在 package.json 中未申明的依赖,但由于存在依赖提升,因此未申明而被提升的依赖在项目中依然可以正确地被引用到。比如上述例子中,package.json 并无 D@2.0.0 的显式申明:
{
"dependencies": {
"A": "^1.0.0",
"B": "^1.0.0",
"C": "^1.0.0"
}
}
但由于 D@2.0.0 被提升到了外层的 node_modules
中,故如下代码是可以被运行的:
import d from 'D';
咋一看好像没啥问题,但是如果某天 B 哪天修改了代码,移除了对 D@2.0.0 的依赖,那么项目代码很可能就奇妙地运行不起来了。我们两手一摊,发出经典免责申明:“我啥也没动啊~”。
知识点:该装的依赖还是要装,稳住,别浪!读到这里,是不是细思极恐 😓。综上,npm v3 确实带来了希望,但也留下来遗憾:依赖分身和幽灵依赖,而且最要紧的是 npm 依然很慢!
pnpm 后浪pnpm 项目的初衷就是:节约磁盘空间并提升安装速度,它基于硬链接(Hard link)的软链接(Symbolic link)创建非扁平化的 node_modules 文件夹。正如 pnpm 官网 宣传的,相比 npm 来说 pnpm 确实做到了减少冗余包安装和极快的包安装速度。
那么 pnpm 究竟是如何做到的呢?
我们结合官网的一张图来打开一下细节:
如果使用 pnpm,上面的示例项目 install 后,node_modules
结构如下:
// 注:<store>/xxx 开头的路径是硬链接,指向全局 store 中安装的依赖
// 其余的是软链接,指向依赖的快捷方式
node_modules
├── .pnpm
│ ├── A@1.0.0
│ │ └── node_modules
│ │ ├── A => <store>/A@1.0.0
│ │ └── D => ../../D@1.0.0
│ └── B@1.0.0
│ └── node_modules
│ ├── B => <store>/B@1.0.0
│ └── D => ../../D@2.0.0
│ ├── C@1.0.0
│ │ └── node_modules
│ │ ├── C => <store>/C@1.0.0
│ │ └── D => ../../D@1.0.0
│ ├── D@1.0.0
│ │ └── node_modules
│ │ └── D => <store>/D@1.0.0
│ ├── D@2.0.0
│ │ └── node_modules
│ │ └── D => <store>/D@2.0.0
│
├── A => .pnpm/A@1.0.0/node_modules/A
├── B => .pnpm/B@1.0.0/node_modules/B
└── C => .pnpm/C@1.0.0/node_modules/C
pnpm 通过“硬链接”、“软链接”这种巧妙设计,不仅节约了磁盘空间提升了包安装速度,而且捎带脚的有效地解决了 npm v3 的遗留问题:
幽灵依赖:由于 pnpm 没有依赖提升,只有直接依赖会平铺在 node_modules 下,所以不会有幽灵依赖。依赖分身:由于相同依赖只会在全局 store 中安装一次,项目中通过硬链接、软链接关联,所以不会出现依赖分身。webpack loader众所周知,webpack 只能理解 JavaScript 和 JSON 文件,loader 正是 webpack 用于对其他类型的文件进行源代码转换的工具。比如,我们可以使用 loader 告诉 webpack 加载 CSS 文件。为此,首先安装相对应的 loader:npm install --save-dev css-loader然后通过配置指示 webpack 对每个 .css 文件使用 css-loader。
loader 配置webpack 官方推荐在 webpack.config.js 中配置 module.rules 的方式来使用 loader,举个例子:
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{ loader: 'less-loader' },
],
},
],
},
};
另外,module.rules 允许我们配置多个 loader,这样能够清晰看出当前文件类型应用了哪些 loader,而当配置多个 loader 时支持“从右到左,从下至上”的链式调用。
loader 加载在上述例子中,我们看到在 webpack.config.js
中只需要配置一个 loader 的包名就可以正常使用 loader 模块的功能,那么 webpack 又是如何实现通过包名就能找到 loader 模块的呢?
一方面,loader 遵循标准模块解析规则,即从 resolve.modules
中指定的所有目录中检索模块中加载。 resolve.modules
的默认值为 "node_modules"
,所以默认情况下 webpack 将从项目根目录的 node_modules 中加载 loader 模块。
另一方面,loader 的解析规则也遵循特定的规范,用户可以通过 resolveLoader
配置项来为 loader 设置独立的解析规则。举个例子:
module.exports = {
resolveLoader: {
// 配置 resolveLoader.modules
modules: ['node_modules', path.resolve(__dirname, 'loaders']
},
module: {
rules: [
{
test: /\.js$/,
use: 'myLoader'
}
]
}
}
当 webpack 在默认目录下找不到指定 loader 时,会自动去用户指定的目录下查找,如例子中的 loaders
这个目录。
经过一番对底层原理的小小的深究,很多读者可能已经明白了这个问题的缘由,甚至有了清晰的解法思路。
时间不早了,这里也不卖关子了,笔者直接对这次问题进行一个小结。
首先,由于项目采用的包管理器是 pnpm,pnpm 是没有依赖提升机制的,只有项目直接依赖会被平铺在 node_modules 下,而作为二级依赖的 css-loader 则不会安装到 node_modules 根目录下。
其次,webpack 默认从项目 node_modules 下解析 loader 模块,故由于 css-loader不在 node_modules 根目录下而无法被加载。
最后,配置 shamefully-hoist=true
为什么看似能“解决问题”,是因为开启这个配置项会导致 pnpm 部分降级成 npm,即会依赖提升,此时会再次让 css-loader 安装到 node_modules
根目录下。
既然 css-loader 是二级依赖,那就可以让 webpack 从二级依赖的 node_modules 中去逐级查找模块,故解法很简单,就是将 loader 配置中的包名简单替换成 require.resolve
的方式:
修改构建器代码并升级发布,项目 install 后 node_modules
结构和引用关系如下:
由图可见,@sc-build/sls-ops-app
和 css-loader 从全局的 store 硬连接到当前项目 .pnpm
中,.pnpm
下各层级的 css-loader 通过软连接建立引用。所以,require.resolve
实际从 @sc-build+sls-ops-app@0.0.4_vlxnc5hnajjd47vzb5hjruzshe/node_modules 中加载到 css-loader 模块。
至此,我们在不破坏 pnpm 非扁平 node_modules
结构的情况下,终于解决了 loader 模块加载失败的问题。
欢迎关注笔者的语雀数字空间,工作之余一起探讨和学习。参考文档聊聊依赖管理深入浅出 npm & yarn & pnpm 包管理机制npm 依赖管理中被忽略的那些细节关于依赖管理的真相 — 前端包管理器探究