本文仅在掘金发布
在现代前端开发中,我们通常不会从零开始构建系统,而是基于现有脚手架和模板仓库,利用各种第三方依赖库快速搭建项目骨架,专注于业务逻辑的开发。
这引出了“依赖治理”的概念。对于小型或短周期项目,依赖问题通常不明显;但随着项目规模扩大和时间推移,依赖管理会变得复杂。如果不及时管理,最终会导致稳定性、性能和安全等问题。
盗用网络流传的一张图:
2009 年 NodeJS 和 Npm 的出现,使前端开发不再只是简单的页面设计,社区逐渐壮大,工程化概念被引入,同时也带来了复杂的代码环境。
Npm 作为管理代码依赖的工具,虽然引入了复杂性,但我们仍然离不开它。毕竟,没有人愿意从零开始写新项目,优秀的第三方包能大大加速开发速度。
然而,第三方依赖也带来了困扰,它们的依赖关系逐渐变得不可控。
依赖冲突
顾名思义,就是你安装的包之间又依赖了同一个包,而这个包被指定的版本不一致的情况:
或者下面的情况:
Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.
上面的例子,有的库依赖react17,有的依赖react16,但是我本地直接装的是react18,在执行 yarn install 时就会有这些警告和错误。这种依赖冲突问题多数时候就是像上面这样,出现在次级依赖中,我们通常无法细粒度地管控好这些底层依赖。可以先使用 npm ls、 npm outdated 等查询依赖关系。
一般普通的冲突都是警告,不影响程序主体。致命的冲突直接在启动时就会报错
然后手动重构或者使用下面的方式:
- 打包的时候取别名:比如使用 webpack,给应用起个别名,比如给 react 18 换个名字叫 react-latest,这样是最简单的避免依赖名称冲突的方式。
resolve: {
alias: {
'lodash-v4': path.resolve(__dirname, 'node_modules/lodash@4.17.20'),
'lodash-v3': path.resolve(__dirname, 'node_modules/lodash@3.10.1'),
},
},
取别名的方法主要用于解决主应用直接依赖中的冲突,对于二级依赖(即间接依赖)的冲突,效果有限
- 使用对等依赖 peerDependencies: 在开发的 package 里面次级依赖的
react和react-dom的版本号应该和主系统中安装的react和react-dom的版本号保持一致,在你依赖的库中写入 peerDependencies 并约定版本号。他用于指定你的包依赖于另一个包的特定版本。当多个包依赖于同一个库时,通过peerDependencies可以确保这些包使用同一个版本的库,避免版本冲突。
{
"name": "my-plugin",
"version": "1.0.0",
"peerDependencies": {
"react": "^17.0.0",
"react-dom": "^17.0.0"
}
}
但这要求第三方依赖库作者来修改,有时候是不可控的。此时当你在项目里使用了这个依赖,但是本地有安装了一个别的版本的纯净依赖(比如 react18),就会出现文章开头的警告:
npm WARN my-plugin@1.0.0 requires a peer of react@^17.0.0 but none is installed. You must install peer dependencies yourself.
- 使用 package.json文件里的resolutions:将依赖库中对 React 17 或 React 16 的依赖强行解析到 React 18 上。(仅支持 yarn 工具,npm >= 5.1),但这样风险是有的,新版本下删除的旧版本的API的调用就会出问题(但一般版本差的不远,成熟的npm库都会向下兼容)。
{
"dependencies": {
"依赖react16的库": "^1.0.0",
"依赖react17的库": "^2.0.0"
},
"resolutions": {
"react": "^18.0.0"
}
}
如果使用的是 npm,可以使用 overrides,作用类似
- 升级或降级依赖,让相同的依赖版本号对应(无奈之举,有些年久失修的npm包,比如braft-editor,新版本react使用就会报各种问题)
互联网更新这么快真的好吗?学的东西几年就不值钱了,npm仓库几年不维护就不能用了,卷死个人了
循环依赖
这个也好理解,它是指是指两个或多个 Package 之间相互依赖,形成链式闭环的情况。
设想一个场景,存在这样的依赖链条:A -> B -> C -> D -> B。这会使得依赖图谱变得异常复杂,项目整体结构会变得非常脆弱。这种情况没有太好的处理办法,可以使用检测工具,比如:circular-dependency-plugin、dependency-cruiser、madge 等,检测后手动重构代码:
const madge = require('madge');
madge('path/to/app.js').then((res) => {
console.log(res.circularGraph());
});
或者也可以以动态引入或者依赖注入的形式来破坏 npm 依赖关系树:
依赖注入(适用于node项目):
// a.js
module.exports = function(b) {
console.log('Module A');
b();
};
// b.js
module.exports = function(a) {
console.log('Module B');
a();
};
// main.js
const a = require('./a');
const b = require('./b');
a(b);
b(a);
动态引入(适用于 ES6+ 组件模块化项目):
// a.js
export default async function() {
console.log('Module A');
const { default: b } = await import('./b');
b();
}
// b.js
export default async function() {
console.log('Module B');
const { default: a } = await import('./a');
a();
}
幽灵依赖
我们都知道,npm 项目依赖都会有自己的依赖,比如你安装 eslint,而他会自动安装其二级依赖 lodash;安装 js-export-excel时会自动安装依赖 xlsx 等等。
而现在的 高版本 npm / yarn 依赖文件管理方式,是将所有依赖、依赖的各级依赖都拍平放在 node_modules 里(操作系统文件树没办法做成图依赖结构,又为了避免 windows 中最长引用路径问题,所以要拍平),这就导致了依赖的各级依赖,你在项目里也能被引用了!
此时就有问题。我们看看下面的依赖关系:
如果你本地没有安装 lodash,但是安装了 eslint,你也能在项目中这样使用了:
const _ from 'lodash';
如果哪天 eslint 升级了版本,不再依赖 lodash 了,那项目就会出问题。更严重的是,我们往往在 devDependencies 中引入开发依赖才会使用 eslint,这样就导致了在开发环境没问题,而在生产环境就会出问题。
我们使用 yarn why 来看一下 lodash 都有谁用到了:
可以看到,不光是 eslint,依赖 react-scripts 也用到了😂。这个万一哪天项目迁移到 vite,岂不是直接报错。
引入 pnpm
pnpm 的原理,跟 cli 安装的原理有些相似,都是使用软连接(快捷方式) 来引用重复的依赖,我们看图:
由于文件树没办法模拟依赖的图状关系,所以,各个依赖如果有重复的依赖,仍然放在不同的文件夹,但是这个重复依赖是软连接,并不占磁盘空间,重复的依赖单独放在一个文件夹中:
这里有个疑问:这样就不会因为引用链条太长而导致依赖目录文件名路径过长的问题了吗?当然不会,pnpm 是原始 npm 和 yarn 的结合,.pnpm 文件夹里的真实依赖是采取拍平的姿势安装的。
项目中的依赖会安装在 node_modules/.pnpm 目录中,其他的文件都是软连接。lodash 的引用关系有些复杂,我们以 js-export-excel --> xslx 为例讲解,我们来看一下 js-export-excel 这个软连接:
找到他的原身看看:
可以看到,他引用了 xlsx 依赖,而 xlsx 又是个软连接,指向了 .pnpm 顶层安装的 xlsx,绕这么一圈,就避免了 xlsx 直接暴露在 node_modules 中被误使用了。
此时的依赖关系如下:
如果你没办法把项目迁移到 pnpm,可以使用 depcheck 检查幽灵依赖
下图中的 Missing dependencies 便是幽灵依赖:
依赖管理
版本控制
先抛出 npm 版本号说明,可能大家也都知道:
三段式版本号:major.minor.patch
三段式说明:
- MAJOR 版本:当你做了不兼容的修改。
- MINOR 版本:当你做了向下兼容的功能性新增。
- PATCH 版本:当你做了向下兼容的问题修正。
成熟的开源库,解决版本冲突一般控制 major 一致就可以,因为都会做向后兼容
版本号安装规则:
- 精确版本:"package-name": "1.2.3" - 只安装指定的版本 1.2.3。
- 插入符号(Caret):"package-name": "^1.2.3" - 允许更新到 1.x.x 版本,但不会引入 2.0.0。
- 波浪符号(Tilde):"package-name": "~1.2.3" - 允许更新到 1.2.x 版本,但不会引入 1.3.0。
- 大于等于(>=):"package-name": ">=1.2.3" - 允许安装 1.2.3 或更高版本。 范围:"package-name": ">=1.2.3 <2.0.0" - 允许安装 1.2.3 及以上但低于 2.0.0 的版本。
安装好后,都会自动生成 package-lock.json、 yarn.lock、 pnpm-lock.yaml 等文件,用于下次安装时依赖包一致,在 git 提交时,确保一定要将它提交上去。
在开发完项目后,一定要加上自己的 peerDependencies:
{
"peerDependencies": {
"react": "^17.0.0"
}
}
这样别人在使用你的仓库时,就知道你本地用的是 react17,他就会尽量和你的版本兼容。
检查依赖
检查是否需要更新:
npm outdated:
然后可以使用 npm update 更新依赖,也可以自己单独安装新的依赖。
第三方工具推荐使用 npm-check:
npm install -g npm-check
# 项目根目录下执行
npx npm-check
完!