疑问
- 什么是依赖地狱、依赖提升又是什么
- 什么是影子依赖(Phantom dependency), 什么机制照成影子依赖问题。
- package.lock 与 yarn.lock 作用是什么, 解决了什么问题
- 什么是**语义化版本(Semantic Versioning), **版本号前 ^0.0.1 与 ~0.0.1 与 *0.0.1 区别和含义, 会造成什么问题
常见的包管理工具
- npm
- yarn
- pnpm
npm 泪奔的历史
npm v1/v2 依赖嵌套
npm最早的版本中使用了很简单的嵌套模式进行依赖管理。比如我们在项目中依赖了A模块和C模块,而A模块和C模块依赖了不同版本的B模块,此时生成的node_modules目录如下:
依赖地狱(Dependency Hell)
可以看到这种是嵌套的node_modules结构,每个模块的依赖下面还会存在一个 node_modules 目录来存放模块依赖的依赖。这种方式虽然简单明了,但存在一些比较大的问题。如果我们在项目中增加一个同样依赖2.0版本B的模块D,此时生成的node_modules目录便会如下所示。虽然模块A、D依赖同一个版本B,但B却重复下载安装了两遍,造成了重复的空间浪费。这便是依赖地狱问题。
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
├── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── D@1.0.0
└── node_modules
└── B@1.0.0
一些著名的梗图:
npm v3 扁平化
npm v3完成重写了依赖安装程序,npm3通过扁平化的方式将子依赖项安装在主依赖项所在的目录中(hoisting提升),以减少依赖嵌套导致的深层树和冗余。此时生成的node_modules目录如下:
为了确保模块的正确加载,npm也规定了额外的依赖查找算法,核心是递归向上查找node_modules。在安装新的包时,会不停往上级node_modules中查找。如果找到相同版本的包就不会重新安装,在遇到版本冲突时才会在模块下的 node_modules 目录下存放该模块子依赖,解决了大量包重复安装的问题,依赖的层级也不会太深。 扁平化的模式解决了依赖地狱的问题,但也带来了额外的新问题。
幽灵依赖(Phantom dependency)
幽灵依赖主要发生某个包未在package.json中定义,但项目中依然可以引用到的情况下。考虑之前的案例,它的package.json 如下图所示。
在index.js中我们可以直接require A,因为在package.json声明了该依赖,但是,我们require B也是可以正常工作的。
var A = require('A');
var B = require('B'); // ???
因为B是A的依赖项,在安装过程中,npm会将依赖B平铺到node_modules下,因此require函数可以查找到它。但这可能会导致意想不到的问题:
- **依赖不兼容:**my-library库中并没有声明依赖B的版本,因此B的major更新对于SemVer体系是完全合法的,这就导致其他用户安装时可能会下载到与当前依赖不兼容的版本。
- **依赖缺失:**我们也可以直接引用项目中devDepdency的子依赖,但其他用户安装时并不会devDepdency,这就可能导致运行时会立刻报错。
多重依赖(doppelgangers)
考虑在项目中继续引入的依赖2.0版本B的模块D与而1.0版本B的模块E,此时无论是把B 2.0还是1.0提升放在顶层,都会导致另一个版本存在重复的问题,比如这里重复的2.0。此时就会存在以下问题:
- 破坏单例模式:模块C、D中引入了模块B中导出的一个单例对象,即使代码里看起来加载的是同一模块的同一版本,但实际解析加载的是不同的module,引入的也是不同的对象。如果同时对该对象进行副作用操作,就会产生问题。
- types冲突:虽然各个package的代码不会相互污染,但是他们的types仍然可以相互影响,因此版本重复可能会导致全局的types命名冲突。
不确定性(Non-Determinism)
在前端包管理的背景下,确定性指在给定package.json下,无论在何种环境下执行npm install命令都能得到相同的node_modules目录结构。然而npm v3是不确定性的,它node_modules目录以及依赖树结构取决于用户安装的顺序。
考虑项目拥有以下依赖树结构,其npm install产生的node_modules目录结构如右图所示。
假设当用户使用npm手动升级了模块A到2.0版本,导致其依赖的模块B升级到了2.0版本,此时的依赖树结构如下。
此时完成开发,将项目部署至服务器,重新执行npm install,此时提升的子依赖B版本发生了变化,产生的node_modules目录结构将会与用户本地开发产生的结构不同,如下图所示。如果需要node_modules目录结构一致,就需要在package.json修改时删除node_modules结构并重新执行npm install。
npm v5 扁平化+lock
在npm v5中新增了package-lock.json。当项目有package.json文件并首次执行npm install安装后,会自动生成一个package-lock.json文件,该文件里面记录了package.json依赖的模块,以及模块的子依赖。并且给每个依赖标明了版本、获取地址和验证模块完整性哈希值。通过package-lock.json,保障了依赖包安装的确定性与兼容性,使得每次安装都会出现相同的结果。
一致性
语义化版本(Semantic Versioning) 依赖版本兼容性就不得不提到npm使用的SemVer版本规范,版本格式如下:
- 主版本号:不兼容的 API 修改
- 次版本号:向下兼容的功能性新增
- 修订号:向下兼容的问题修正
在使用第三方依赖时,我们通常会在package.json中指定依赖的版本范围,语义化版本范围规定:
- ~:只升级修订号
- ^:升级次版本号和修订号
- *:升级到最新版本
语义化版本规则定义了一种理想的版本号更新规则,希望所有的依赖更新都能遵循这个规则,但是往往会有许多依赖不是严格遵循这些规定的。 因此一些依赖模块子依赖不经意的升级,可能就会导致不兼容的问题产生。因此package-lock.json给每个模块子依赖标明了确定的版本,避免不兼容问题的产生。
yarn 的出现
Yarn 是在2016年开源的,yarn 的出现是为了解决 npm v3 中的存在的一些问题,那时 npm v5 还没发布。Yarn 被定义为快速、安全、可靠的依赖管理。
Yarn v1 lockfile
Yarn 生成的 node_modules 目录结构和 npm v5 是相同的,同时默认生成一个 yarn.lock 文件。对于上文例子,生成的yarn.lock文件如下:
A@^1.0.0:
version "1.0.0"
resolved "uri"
dependencies:
B "^1.0.0"
B@^1.0.0:
version "1.0.0"
resolved "uri"
B@^2.0.0:
version "2.0.0"
resolved "uri"
C@^2.0.0:
version "2.0.0"
resolved "uri"
dependencies:
B "^2.0.0"
D@^2.0.0:
version "2.0.0"
resolved "uri"
dependencies:
B "^2.0.0"
E@^1.0.0:
version "1.0.0"
resolved "uri"
dependencies:
B "^1.0.0"
可以看到yarn.lock使用自定义格式而不是JSON,并将所有依赖都放在顶层,给出的理由是便于阅读和审查,减少合并冲突。
Yarn lock vs. npm lock
- 文件格式不同,npm v5 使用的是 json 格式,yarn 使用的是自定义格式
- package-lock.json 文件里记录的依赖的版本都是确定的,不会出现语义化版本范围符号(~ ^ *),而 yarn.lock 文件里仍然会出现语义化版本范围符号
- package-lock.json 文件内容更丰富,实现了更密集的锁文件,包括子依赖的提升信息
npm v5 只需要 package.lock 文件就可以确定 node_modules 目录结构 yarn.lock 无法确定顶层依赖,需要 package.json 和 yarn.lock 两个文件才能确定 node_modules 目录结构。node_modules 目录中 package 的位置是在 yarn 的内部计算出来的,在使用不同版本的 yarn 时可能会引起不确定性。
pnpm出现
pnpm1.0于2017年正式发布,pnpm具有安装速度快、节约磁盘空间、安全性好等优点,它的出现也是为了解决npm和yarn存在的问题。
因为在基于npm或yarn的扁平化node_modules的结构下,虽然解决了依赖地狱、一致性与兼容性的问题,但多重依赖和幽灵依赖并没有好的解决方式。因为在不考虑循环依赖的情况下,实际的依赖结构图为有向无环图(DAG),但是npm和yarn通过文件目录和node resolve算法模拟的实际上是有向无环图的一个超集(多出了很多错误祖先节点和兄弟节点之间的链接),这导致了很多的问题。pnpm也是通过硬链接与符号链接结合的方式,更加精确的模拟DAG来解决yarn和npm的问题。
非扁平化的node_modules
硬链接(hard link)节约磁盘空间
硬链接可以理解为源文件的副本,使得用户可以通过不同的路径引用方式去找到某个文件,他和源文件一样的大小但是事实上却不占任何空间。pnpm 会在全局 store 目录里存储项目 node_modules 文件的硬链接。硬链接可以使得不同的项目可以从全局 store 寻找到同一个依赖,大大节省了磁盘空间。
符号链接(symbolic link)创建嵌套结构
软链接可以理解为快捷方式,pnpm在引用依赖时通过符号链接去找到对应磁盘目录(.pnpm)下的依赖地址。考虑在项目中安装依赖于foo模块的bar模块,生成的node_modules目录如下所示。
可以看到node_modules下的bar目录下并没有node_modules,这是一个符号链接,实际真正的文件位于.pnpm目录中对应的 @version/node_modules/目录并硬链接到全局store中。而bar的依赖存在于.pnpm目录下@version/node_modules目录下,而这也是软链接到@version/node_modules/目录并硬链接到全局store中。
而这种嵌套node_modules结构的好处在于只有真正在依赖项中的包才能访问,避免了使用扁平化结构时所有被提升的包都可以访问,很好地解决了幽灵依赖的问题。此外,因为依赖始终都是存在store目录下的硬链接,相同的依赖始终只会被安装一次,多重依赖的问题也得到了解决。
官网上的这张图清晰地解释了pnpm的依赖管理机制
局限性
看起来pnpm似乎很好地解决了问题,但也存在一些局限。
- 忽略了 package-lock.json。npm 的锁文件旨在反映平铺的 node_modules 布局,但是 pnpm 默认创建隔离布局,无法由 npm 的锁文件格式反映出来,而是使用自身的锁文件pnpm-lock.yaml。
- 符号链接兼容性。存在符号链接不能适用的一些场景,比如 Electron 应用、部署在 lambda 上的应用无法使用 pnpm。
- 子依赖提升到同级的目录结构,虽然由于 Node.js 的父目录上溯寻址逻辑,可以实现兼容。但对于类似 Egg、Webpack 的插件加载逻辑,在用到相对路径的地方,需要去适配。
- 不同应用的依赖是硬链接到同一份文件,如果在调试时修改了文件,有可能会无意中影响到其他项目。
实际体验pnpm
rm -rf node_modules time npm install > /dev/null 2>&1 && time du -d 0 -h node_modules
rm -rf node_modules time pnpm install > /dev/null 2>&1 && time du -d 0 -h node_modules
实际公司项目中调试
codemao-activity
关键步骤1
# 删除先前 node_modules
rm -rf node_modules
# 改用pnpm 安装
pnpm install
# 开始构建
npm run build
插件node_module 目录结构
我们会发现node_modules 下包文件少了很多, 我们对比pacakage.json 文件才发现, 基本上包含pacakage.json, 下声明一些依赖包, 但也与其他多余依赖, 应该是类似post_install script 所为[待验证]
这种pnpm install 模式, 所有的依赖都不会提升。
构建流程
几个疑问
- 为什么babel-loader 无法被resolve
tips:
- 查看node_modules
- 查看 mlz/pack 的package.json
关键步骤2
# 能否从帮助找到一些答案出来
pnpm install --help
# 删除模块从新安装
rm -rf node_modules
# 采用扁平的方式安装依赖
pnpm install --shamefully-hoist
# 执行构建操作
npm run build
tips: 扁平的安装方式, 其实跟yarn 和npm 安装方式保持一致, 这里注意: 因为pnpm 没有相关lock文件, 对就有项目, 可能会存在依赖版本的问题, 跟先前锁定的版本结构不一致照成的。
这时可以对比node_module目录下结构
执行构建
这时先前的错误已经没有了, 说明之前问题分析是对的
这里webpack 执行构建的流程, 大部分构建流程走完了,
babel-runtime 作用及来源分析
babel-runtime 作用, 就是就是我们配置babel-loader preset-env 预设 对es6 语法进行垫片操作
// 比如如下代码 对对象进行解构时,
// 其实这里转码时会多出 var obj = require('babel-runtime/core-js/object/xxxx') 东西
// 然后 object({name: 1})
{ ...{name: 1} }
为什么解析不到babel-runtime
基本分析, 跟软链和webpack 莫快递查找有很大关系
关键步骤3(调试)
1. 找到报错位置, 查看调用栈
从调用栈中基本上可以看到一些信息, 但不够全面
- 只看和webpack 相关源码信息
- 只看和模块相关源码信息
2. 找webpack 源码中几个关键点, 抓住模块相关代码
- webpack tapable 机制
- webpack hooks
- 模块解析相关模块
大致流程, 读取文件, Parse.js 进行解析, 生成AST语法树, 对语法树进行依赖分析, 形成Dependencies Tree,例如CommonRequireDepenDency, ModuleDependency 等等很多, 从Dependency 到 resovleContext 到 NormalModule
- NormalModuleFactory.js
- Parse.js
- dependencies 相关
3. NormalModuleFactory 中hooks
- 条件断点
这里出现了路径解析问题, 按照软链位置, webpack 是无法reslove 模块, 只有按照真实模块解析路径才能解析出来
关键步骤4
第一个版本Pnpm-resolver-plguin
const fs = require('fs');
class PnpmResolverPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('PnpmResolverPlugin', (compilation, options) => {
const hander = (module) => {
if (!module.resource) return;
if (module.resource.match(/\.(ejs)$/)) return;
try {
const resource = fs.realpathSync(module.resource);
const context = getContext(resource);
if (module.request === module.resource && module.userRequest === module.resource) {
module.request = resource;
module.userRequest = resource;
}
module.context = context;
module.resource = resource;
} catch(err) {
// debugger;
}
};
compilation.hooks.buildModule.tap('PnpmResolverPlugin', hander);
});
}
}
function splitQuery(req) {
var i = req.indexOf('?');
if(i < 0) return [req, ''];
return [req.substr(0, i), req.substr(i)];
}
function dirname(path) {
if(path === '/') return '/';
var i = path.lastIndexOf('/');
var j = path.lastIndexOf('\\');
var i2 = path.indexOf('/');
var j2 = path.indexOf('\\');
var idx = i > j ? i : j;
var idx2 = i > j ? i2 : j2;
if(idx < 0) return path;
if(idx === idx2) return path.substr(0, idx + 1);
return path.substr(0, idx);
}
function getContext(resource) {
var splitted = splitQuery(resource);
return dirname(splitted[0]);
};
module.exports = PnpmResolverPlugin;
这个版本出现两个问题, 一个真实路径没有解析完全, 照成react-router-dom 在webpack中当成两份代码,NormalMoule实例, 最后照成结果, ract-router-dom 内部context 与 react-router 配合不起来。
第二个问题就是build 时运行不起来
由于源码经过压缩混淆, 所以看不出来, 采用development 进行打包看看
这似乎可以看出来问题了
这里调试过程也比较艰辛, 主要通过全局脚本中注入debuger 代码方式, 发现依赖库竟然执行初始化执行两次, 因此说明, 同一份代码被当成两个不同的NormalModule 进行build, 所以有了第二个版本。
第二个版本
const fs = require('fs');
class PnpmResolverPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('PnpmResolverPlugin', (compilation, {normalModuleFactory}) => {
normalModuleFactory.hooks.afterResolve.tap('PnpmResolverPlugin', (result) => {
try {
const resource = fs.realpathSync(result.resource);
const context = getContext(resource);
if (result.userRequest === result.resource) {
result.userRequest = resource;
}
if (result.request === result.resource) {
result.request = resource;
}
result.resource = resource;
result.context = context;
result.resourceResolveData.descriptionFilePath = fs.realpathSync(result.resourceResolveData.descriptionFilePath);
result.resourceResolveData.descriptionFileRoot = fs.realpathSync(result.resourceResolveData.descriptionFileRoot);
result.resourceResolveData.path = fs.realpathSync(result.resourceResolveData.path);
} catch(err) {
// debugger;
}
})
});
}
}
function splitQuery(req) {
var i = req.indexOf('?');
if(i < 0) return [req, ''];
return [req.substr(0, i), req.substr(i)];
}
function dirname(path) {
if(path === '/') return '/';
var i = path.lastIndexOf('/');
var j = path.lastIndexOf('\\');
var i2 = path.indexOf('/');
var j2 = path.indexOf('\\');
var idx = i > j ? i : j;
var idx2 = i > j ? i2 : j2;
if(idx < 0) return path;
if(idx === idx2) return path.substr(0, idx + 1);
return path.substr(0, idx);
}
function getContext(resource) {
var splitted = splitQuery(resource);
return dirname(splitted[0]);
};
module.exports = PnpmResolverPlugin;
页面被成功打包成功
第三个版本
尝试采用 pnpm install 非提升模式, 解决babel-loder css-loader 等loader 解析问题
const fs = require('fs');
class PnpmResolverPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('PnpmResolverPlugin', (compilation, {normalModuleFactory}) => {
const loaderContext = `${compiler.context}/node_modules/.pnpm`
const isPnpmModule = fs.existsSync(loaderContext);
if (!isPnpmModule) return;
// 这里主要处理 css-loader babel-loader 内置到公司内部脚手架问题
normalModuleFactory.context = loaderContext;
// 这里主要处理模块真实路径问题
normalModuleFactory.hooks.afterResolve.tap('PnpmResolverPlugin', (result) => {
try {
const resource = fs.realpathSync(result.resource);
const context = getContext(resource);
if (result.userRequest === result.resource) {
result.userRequest = resource;
}
if (result.request === result.resource) {
result.request = resource;
}
result.resource = resource;
result.context = context;
result.resourceResolveData.descriptionFilePath = fs.realpathSync(result.resourceResolveData.descriptionFilePath);
result.resourceResolveData.descriptionFileRoot = fs.realpathSync(result.resourceResolveData.descriptionFileRoot);
result.resourceResolveData.path = fs.realpathSync(result.resourceResolveData.path);
} catch(err) {
// debugger;
}
})
});
}
}
function splitQuery(req) {
var i = req.indexOf('?');
if(i < 0) return [req, ''];
return [req.substr(0, i), req.substr(i)];
}
function dirname(path) {
if(path === '/') return '/';
var i = path.lastIndexOf('/');
var j = path.lastIndexOf('\\');
var i2 = path.indexOf('/');
var j2 = path.indexOf('\\');
var idx = i > j ? i : j;
var idx2 = i > j ? i2 : j2;
if(idx < 0) return path;
if(idx === idx2) return path.substr(0, idx + 1);
return path.substr(0, idx);
}
function getContext(resource) {
var splitted = splitQuery(resource);
return dirname(splitted[0]);
};
module.exports = PnpmResolverPlugin;
第四个版本
解决webpack5 中问题, 在webpack 中afterResolve 中result 数据结构发生根本性变化, 所以, 不能通过这种修改数据结构hack方式去修改, 所以用到webpack 配置项resolve.symlinks, 省去大量解析代码'
compiler.options.resolve.symlinks = true;
const fs = require('fs');
class PnpmResolverPlugin {
apply(compiler) {
const loaderContext = `${compiler.context}/node_modules/.pnpm`;
const isPnpmModule = fs.existsSync(loaderContext);
if (!isPnpmModule) return;
compiler.options.resolve.modules.push(`${loaderContext}/node_modules`);
compiler.options.resolve.symlinks = true;
compiler.hooks.compilation.tap('PnpmResolverPlugin', (compilation, {normalModuleFactory}) => {
// 这里主要处理 css-loader babel-loader 内置到公司内部脚手架的问题
normalModuleFactory.context = loaderContext;
// compiler.options.resolve.symlinks = true; 解决实际路径问题
// 这里主要处理模块真实路径问题
// normalModuleFactory.hooks.afterResolve.tap('PnpmResolverPlugin', (result) => {
// try {
// const resource = fs.realpathSync(result.resource);
// const context = getContext(resource);
// if (result.userRequest === result.resource) {
// result.userRequest = resource;
// }
// if (result.request === result.resource) {
// result.request = resource;
// }
// result.resource = resource;
// result.context = context;
// result.resourceResolveData.descriptionFilePath = fs.realpathSync(result.resourceResolveData.descriptionFilePath);
// result.resourceResolveData.descriptionFileRoot = fs.realpathSync(result.resourceResolveData.descriptionFileRoot);
// result.resourceResolveData.path = fs.realpathSync(result.resourceResolveData.path);
// } catch(err) {
// // debugger;
// }
// })
});
}
}
function splitQuery(req) {
var i = req.indexOf('?');
if(i < 0) return [req, ''];
return [req.substr(0, i), req.substr(i)];
}
function dirname(path) {
if(path === '/') return '/';
var i = path.lastIndexOf('/');
var j = path.lastIndexOf('\\');
var i2 = path.indexOf('/');
var j2 = path.indexOf('\\');
var idx = i > j ? i : j;
var idx2 = i > j ? i2 : j2;
if(idx < 0) return path;
if(idx === idx2) return path.substr(0, idx + 1);
return path.substr(0, idx);
}
function getContext(resource) {
var splitted = splitQuery(resource);
return dirname(splitted[0]);
};
module.exports = PnpmResolverPlugin;
终极解决方案
问题
- 非提升模式 脚手架CI内置loader、babel问题引用不到
- 非提升模式 影子依赖问题
- webpack.resolve.symlinks = true 解析模块真实路径
方案
文件 npmrc
# 把脚手架中loader提升到公共(项目)node_modules下
public-hoist-pattern[]=*loader*
# 把babel相关库提升到公共(项目)node_modules下
public-hoist-pattern[]=*babel*
webpack配置
// webpack解析软链位置
options.resolve.symlinks = true
实际项目落地终极方案
最后
pnpm-resolver-plugin 点个Start