likes
comments
collection
share

前端依赖报错不用愁,自救技巧大放送!

作者站长头像
站长
· 阅读数 12

前端依赖报错不用愁,自救技巧大放送!

背景

作为前端开发者而言,在日常工作中最讨厌的事情莫过于之前还能正常运行的项目,今天突然运行不起来了,各种红色刺眼的依赖报错让人应接不暇。依赖报错就像是前端开发者的"宿敌",让人感到头疼和无力,很多人对此退避三舍、束手无策又无可奈何。

这篇文章旨在让读者对于包管理器管理依赖建立起最基本的认识,并可以在日常问题中有一个基本排查思路。

推荐读者:掌握nodejs的使用、有一定的包管理器使用经验的开发者。

依赖的类型

每个前端项目中, package.json 文件内都声明了该项目所用到的所有依赖,一般会有三种类型,dependenciesdevDependenciespeerDependencies。网传的一些说法是:

对于 peerDependencies 下面再详细介绍~

  • dependencies:项目的生产依赖,生成环境下所依赖的包。
  • devDependencies:项目的开发依赖,只在开发环境下才会依赖的包。

更有甚者直接简化为:dependencies:生产依赖,devDependencies:开发依赖。

那么这里笔者提出一个问题:以一个最简单的react项目来说,如果把reactreact-dom等依赖全部移入devDependencies中,那么这个项目还可以跑得起来吗?

有兴趣的同学可以自行测试,修改后重新执行npm inpm run start即可。

测试过便知,项目的运行只能说一帆风顺、毫无波澜。这里甚至可以下一个结论:

在开发过程中,你甚至可以将dependenciesdevDependencies混为一谈,也丝毫不会对开发有任何的影响。 那么,网传的dependencies:生产依赖,devDependencies:开发依赖到底是怎么来的呢?其实,dependenciesdevDependencies他们的区别是在构建过程中体现出来的,devDependencies在包构建过程中并不会被打包进来。而上述问题主要是依赖下载过程,依赖的下载是包管理器的责任,在 install 过程中并不会对不同类型的依赖区别对待。

peerDependencies

peerDependencies顾名思义同步依赖,例如鱼之于水,草之于土地,以前端视角来看,那就是vuex之于vuereact component之于react

需要注意的是,如果你使用的是npm,不同版本对于peer Dependencies的下载行为有所差异:

  • 在npm4之前npm会自动帮你下载好peer Dependencies。(Prior to version 4, npm automatically included peer dependencies if they weren’t explicitly included. )
  • npm4-6: 不会自动安装,但是会报warning
  • npm7: 更新了新的包依赖解析算法,因此恢复了自动下载的机制

包依赖文件系统

扁平化依赖(hoist)

假设我们有一个项目,他依赖A和B两个包,而A和B又分别依赖着C,则下载完毕后node_modules呈现的结构是怎样的呢?

npm1npm2的时候呈现的是嵌套结构:

node_modules
|  A
|  └─ node_modules
|    └─ C
|        ├─ index.js
|        └─ package.json
└─ B
   └─ node_modules
     └─ C
         ├─ index.js
         └─ package.json

这样的嵌套结构会导致以下问题:

  • 嵌套结构过长在Windows系统中会产生问题。
  • 相同依赖重复下载、具有不同的多个实例。

而自从npm3过后,包括yarn,都采用了扁平化依赖的方式进行依赖管理,相比上述的嵌套结构,如今的node_modules看起来平滑多了,且C不会被重复安装:

node_modules
|  A
|  └─ node_modules
└─ B
|  └─ node_modules
└─ C
   ├─ index.js
   ├─ node_modules
   └─ package.json 

这里对于dependenciesdevDependencies来说就有着显著区别。假设有一个项目中的依赖关系是这样的:

前端依赖报错不用愁,自救技巧大放送!

那么最终安装完毕后的node_modules结构会变成:

// My Package
node_modules
|  A  // My Package 的 dependencies
|  └─ node_modules
└─ B  // My Package 的 devDependencies
|  └─ node_modules
└─ C  // A 的 dependencies
| 
└─ E  // B 的 dependencies

即对于app来说,dependenciesdevDependencies下载后会被全部扁平化,但是二级依赖中的devDependencies将不会被下载。

这也符合我们上面所说的,因为二级依赖已经属于构建后包,自然不会包含devDependencies

扁平化依赖的缺陷---幽灵依赖

扁平化依赖的实现并不是完美的,它也带来了一些新的问题:

  • 首先是扁平化算法的复杂度,导致了在工作中一些大项目的依赖安装需要耗时几分钟甚至更久。
  • 在我们的第一个例子中(假设我们有一个项目,他依赖A和B两个包,而A和B又分别依赖着C),如果A和B分别依赖的不同版本的C,则被扁平化上来的C究竟是哪个版本呢?

相同依赖的版本不确定性,就是package-lock.json文件的诞生原因。

  • 导致幽灵依赖的非法访问

什么是幽灵依赖呢?

让我们回想一下依赖包的寻路算法:从当前目录的node_modules开始,依次向上层目录的node_modules查询。既然扁平化算法将所有依赖都扁平化到了node_modules中,未包含在package.json中的依赖也自然放在了node_modules中,项目自然也可以访问到它们了。

这就是幽灵依赖的由来。这也是项目莫名其妙无法运行的罪魁祸首之一:试想一下,你依赖了幽灵依赖,当某一天你的正式依赖不再依赖该幽灵幽灵依赖,项目不就访问不到它了嘛?

pnpm

什么是pnpm?引用官方的一句介绍:Fast, disk space efficient package manager,跟npm、yarn一样,pnpm也是一个包管理器,但是它更快、利用磁盘空间更加高效。

pnpm解决幽灵依赖

与npm、yarn不同的是,pnpm解决了幽灵依赖的缺陷,保证了项目的稳定性。

举个例子,让我们安装一个vue,得到的依赖目录是这样的:

node_modules
|——vue
|__.pnpm
    |__ @vue+compiler-core..
    |__ @vue+.....

可见,node_modules下只包含了package.json中声明好的依赖,幽灵依赖并不会被hoist上来。但是可以注意到,目录中多了一个.pnpm/**的结构,而且里面的不正是vue的依赖包嘛?

pnpm默认不允许项目中使用幽灵依赖,但是对于二级依赖而言,是默认允许彼此访问幽灵依赖的。官网上也有具体说明。

前端依赖报错不用愁,自救技巧大放送!

然而,pnpm对于项目中的幽灵依赖实际上并没有完全禁止,而是给eslintprettier等插件开了后门。

前端依赖报错不用愁,自救技巧大放送!

正是由于npm三方包的鱼龙混杂,pnpm才不得已而为之。如果你是一位有着代码洁癖的前端工程师,不如坚决地将hoist设置为false,即使这会让其他小伙伴在第一次项目初始化时痛苦万分。

当然,你如果不追求这种严谨性,你也可以设置shamefully-hoistture,此时不管项目还是三方库中的所有依赖都会被hoist到node_modules中,正如npm、yarn中的行为。

symlink & hardlink

还记得我们之前讨论过的不确定性嘛?假设我们有一个项目,他依赖A和B两个包,而A和B又分别依赖着C),如果A和B分别依赖的不同版本的C。这里我们以expresskoa都依赖不同版本的http-errors举例子:

前端依赖报错不用愁,自救技巧大放送!

则最终的结构会变成(结构有删减):

├── node_modules
│   ├── .modules.yaml
│   ├── .pnpm
│   │   ├── express@4.18.2
│   │   │   └── node_modules
│   │   │       ├── express -> {store}
│   │   │       │   └── package.json
│   │   │       ├── http-errors -> ../../http-errors@2.0.0/node_modules/http-errors
│   │   │       ├── merge-descriptors -> ../../merge-descriptors@1.0.1/node_modules/merge-descriptors
│   │   │       ├── methods -> ../../methods@1.1.2/node_modules/methods
│   │   │       ├── ...
│   │   ├── http-errors@1.8.1
│   │   │   └── node_modules
│   │   │       │   └── package.json
│   │   ├── http-errors@2.0.0
│   │   │   └── node_modules
│   │   │       │   └── package.json
│   │   └── koa@2.14.2
│   │       └── node_modules
│   │           ├── http-errors -> ../../http-errors@1.8.1/node_modules/http-errors
│   │           ├── koa -> {store}
│   │           │   └── package.json
│   │           ├── koa-compose -> ../../koa-compose@4.1.0/node_modules/koa-compose
│   │           ├── koa-convert -> ../../koa-convert@2.0.0/node_modules/koa-convert
│   │           └── ...
│   ├── express -> .pnpm/express@4.18.2/node_modules/express
│   └── koa -> .pnpm/koa@2.14.2/node_modules/koa
├── package.json
└── pnpm-lock.yaml

这里可以注意到,node_modules下只有expresskoa,这也是上节提到的杜绝项目幽灵依赖的办法。更多的是,他们都是软连接,以express来说实际上连接到了.pnpm/express@4.18.2/node_modules/express中,而.pnpm/express@4.18.2/node_modules下也是其依赖包,也都是软连接到了.pnpm/中。不同版本的http-errors也放置在了.pnpm/http-errors@xxx中,这样就保证了包引用的正确性。

值得注意的是,这种结构中,.pnpm/xxx/node_modules/xxx {store}是一个指向全局的hardlink,这样就避免了依赖重复下载以及软连接嵌套的问题。

依赖报错怎么办?

这里以我实际工作中遇到的案例作为引子:

我使用到了一个react组件SDK,其package.json如下:

前端依赖报错不用愁,自救技巧大放送!

而我的主项目中react的版本是17.0.2,这就导致依赖安装后,存在不同版本的reactnode_modules/.pnpm):

前端依赖报错不用愁,自救技巧大放送!

这里导致SDK中使用的是17.0.1react-dom,与项目中的17.0.2react发生版本冲突。

前端依赖报错不用愁,自救技巧大放送!

遇到这种情况,我们应该想到有以下四种方式修改依赖:

  1. overrides

设置overrides可以让你强行制定依赖包的版本。参考文档:pnpm.io/package_jso…

需要注意的是,该字段应该被设置在根目录package.json下,但在monorepo下无法对单个package进行设置。

{
  "pnpm": {
    "overrides": {
      "foo": "^1.0.0",
      "quux": "npm:@myorg/quux@^1.0.0",
      "bar@^2.1.0": "3.0.0",
      "qar@1>zoo": "2"
    }
  }
}
  1. packageExtensions

你可以通过packageExtensions来修改三方依赖的依赖,举个例子,react-redux理当将react-dom添加进peerDependencies中,但它缺失了,那我们就可以给他添加上:

{
  "pnpm": {
    "packageExtensions": {
      "react-redux": {
        "peerDependencies": {
          "react-dom": "*"
        }
      }
    }
  }
}
  1. pnpm hooks

如果你想要更加精准地控制依赖,你可以使用pnpm hooks。Hooks 可以被定义在.pnpmfile.cjs中。.pnpmfile.cjs文件应该被放置在与lock文件相同的目录中。

参考资料:pnpm.io/pnpmfile

  • hooks.readPackage(pkg, context): pkg:在pnpm解析完package.json中的依赖名称清单后调用,用于改变package.json中的依赖。

  • hooks.afterAllResolved(lockfile, context): lockfile:允许你在序列化lockfile输出之前对其进行修改,用于改变pnpm-lock.yaml文件。

  1. npm alias

npm alias 可以让你以自定义包名来安装npm包。假设你的项目中依赖了lodash,但是其中有一个bug导致你的项目崩溃。而lodash官方并没有去修复,而你只能fork其仓库自行修复,并发布了一个lodash-custom-fixed包。那么,你可以使用lodash作为alias来去安装lodash-custom-fixed

pnpm add lodash@npm:lodash-custom-fixed

不需要更改代码,项目中 lodash 引用都被解析到了 lodash-custom-fixed

参考资料:pnpm.io/aliases

总结

在前端开发的日常工作中,依赖管理是一个关键的环节,但经常会遇到依赖安装报错的情况。本文主要梳理当前前端包依赖管理的现状,解析一些广泛传播的技术话题,以及为解决依赖报错问题提供的排查思路。无论你是新手还是资深前端开发者,都不可避免地会遇到这些问题。我们鼓励读者不仅要掌握临时解决问题的方法,更要巩固自己的基础知识,以便更好地独立排查和解决复杂的依赖安装问题。