Skip to content

如何解决项目依赖重复打包问题

2024年3月22日  28分钟

由于最近面试经常被问到这个问题(简历上写了),感觉答的时候不是很系统清晰,于是便有了这篇文章。

为啥对这个问题这么上心?

在上家公司最后一段时间是做前端工程基建相关的,不说是最有成长的一段时间,但绝对是最开心的一段时间。上来第一个任务是优化项目构建体积,项目之前是 webpack 写的,做技术升级之后迁移到了 vite,包管理器也从 yarn1 迁移到 pnpm,但是迁移后发现主入口 bundle 的体积增加了很多,后面也是采用了多个方法对体积进行优化:

等等,离职混日子快半年了,暂时只能想起这些。

回到主题,在我所有的优化策略中,去除重复依赖减小打包体积的效果是占第二位的。第一位是 importmap,最简单的减小体积策略就是不打包。关于 importmap,有机会单独写一篇文章。

重复依赖是如何产生的?

在讨论之前,先介绍一些后面会提到的术语,确保我们在同一个频道上。

下面是一个典型的 monorepo 前端项目:

./mono
├── apps // 应用级别的包
│   ├── mobile
│   └── web
├── packages // 共享模块
│   ├── pkg1
│   ├── pkg2
│   ├── ui
│   └── utils
└── tools // 工具包
    ├── eslint-config
    └── vite-config

apps, packages, tools 都叫 workspace, 里面 package 称之为 workspace packagemono 文件夹叫 root workspace

在包管理器的视角依赖可以分为两类:

按照用途分为两类:

lockfile 的副作用

lockfile 可以帮我们确保安装的依赖完全一致,但是它却也是导致我们项目依赖安装多个版本的主要原因之一。

场景还原:

  1. 我们在 pkg1 中的 dependencies 声明 "foo": "^1.0.1"”,此时 foo 最新版本是 1.0.1,运行 pnpm install, 这时 lockfile 中写入了 pkg1foo 解析到的版本是 1.0.1,实际也是安装 1.0.1
  2. 某一天我们需要在 pkg2 中开发需求,pkg2 也要用到 foo,于是运行 pnpm --filter pkg2 add foo,在这段时间 foo 发布了 1.0.2,此时 pkg2 安装的就会是 1.0.2。我们的 app package 依赖 pkg1pkg2,于是打包 app 的时候就会打包 [email protected][email protected]

你可能会说 pnpm 咋这么蠢,不会直接把 pkg1foo 也安装为 1.0.2 吗,1.0.2 是符合 ^1.0.1 的兼容性要求的呀。实际上按照我的理解,pnpm 之所以没这么做,是因为

  1. lockfile 里面已经声明了 pkg1foo 解析到的版本,所以会直接按照 lockfile 声明的版本来
  2. 因为 pkg 中 [email protected] 之前已经验证测试时可用的,要是它帮你更新到 1.0.2 可能会出现 bug,为了确保项目的稳定默认不会帮你升级。semver 只是一个规范, 1.0.11.0.2 到底有没有引没有引入 breaking change 那你得去看 changelog 和源代码才能确定。

不兼容版本

一个很典型的例子就是去年 axios 发布了 1.x,项目中同时存在 0.x1.x。草,axios 这么古老的项目直到去年才发 1.x 你敢信?esbuildreact native 加把油,希望我退休之前能发 1.0

即便依赖树上的包在 dependencies 中声明 axios 的时候都使用了兼容性前缀 ^,但是对于这种不符合兼容性前缀的多个版本,pnpm 就没法通过后文会详细介绍的删 lockfile 重装或者跑 pnpm dedupe 来解决了。

除此之外还有换包名这种特殊情况,例如 babel-runtime -> @babel/runtime

peerDependencies

我们知道,在 node_modules/.pnpm 里面存放的是缓存的硬链接,它的命名格式是 pkgName@pkgVersion_peer1Name@peer1Version_peer2Name@peer2Version,简单来说就是由本身的包名,版本号和它的所有 peerDependencies 包名,版本号组成,使用 _ 连接,在 lockfile 中是用括号,例如 [email protected]([email protected])

实际的项目中就可能在 .pnpm 下看到同一个包名和版本号,由于实际依赖的 peerDependencies 不同安装了多次。

# workspace package
# packages/pkg1/package.json
{
  "dependencies": {
    "vue": "2.6.14",
    "@yutengjing/foo": "1.0.3" // 对应硬链接 @[email protected][email protected]
  }
}

# workspace package
# packages/pkg2/package.json
{
  "dependencies": {
    "vue": "2.7.16",
    "@yutengjing/foo": "1.0.3" // 对应硬链接 @[email protected][email protected]
  }
}

# 间接依赖
# @yutengjing/foo
{
  "peerDependencies": {
    "vue": "^2.7.16"
  }
}

node_modules
  .pnpm
    @[email protected][email protected]
      node_modules
        @yutengjing/[email protected]
        [email protected]
    @[email protected][email protected]
      node_modules
        @yutengjing/[email protected]
        [email protected]

由于本地安装的 peerDependencies vue 的版本不同,且都不符合兼容性要求 ^2.7.16,实际上会存在两个硬链接依赖 @[email protected][email protected]@[email protected][email protected]

虽然都是 @[email protected], 但是由于是不同的硬链接,被视为不同的文件,就会打包多次。

重复打包

关于 peerDependencies 在 pnpm 中是怎么解析的,可以阅读:How peers are resolved

文章写到这我才发现后面要介绍的我写的分析重复依赖的插件有 bug,因为我没考虑同一个依赖同一个版本被打包多次的情况,马上修!

顺便提一下上面 pkg1 和 pkg2 依赖的 vue 如果都使用兼容性前缀(也就是 ^2.6.14^2.7.16),pnpm 最新版是可以自动去重的,硬链接到 node_modules/.pnpm 下的 @[email protected][email protected]

其它情况

之前有发现某些重复依赖总是干不掉,仔细查看依赖路径发现有个别不规范的 package 构建的时候把 dependencies 也打包到 dist 了。

如何发现重复依赖?

下面介绍下我使用过的方法和工具。

直接看依赖分布图

对于一个在 3 年前应届时期就是 webpack 老鸟的我,第一步当然是装上一些构建分析工具来看看项目里面打包了哪些妖魔鬼怪。于是找下 vite 的 bundle 可视化工具,没找到,于是搜了搜 rollup 相关的插件,被我找到 rollup-plugin-visualizer。要咱说为什么 vite 能这么成功,兼容 rollup 插件很关键啊。当时我对 vite 其实一点都不熟,也没那么多 vite 插件,很多插件都是用 rollup 的,就非常佩服 vite 兼容 rollup 插件的这个设计。要问 rspackturbopack 更看好谁?那肯定是 rspackrspack 兼容 webpack 生态呀,看看 bundeno 现在的流行趋势还不够说明问题吗?

我记得我当时分析我们公司那个项目的时候当时就发现打包了好几个 ant design vue,对于那些体积较大的依赖一眼就能看出来。然后可以试着过滤一些非常常用的第三方依赖,像 vue, vue-router, lodash 这些。

拿 github star 21k 的 vue-vben-admin 做测试,本来想用网红项目 elk 的,但是这个项目构建有点复杂,会构建好几次,我对 nuxt 也不熟,还是算了。

bundle visualizer

rollup-plugin-visualizer 这个插件建议用 5.8.3 版本,最新版过滤不太好用,参考 issue:new Include and Exclude is hard to understand and use

基于 lockfile 分析的 cli

早在 pnpm dedupe 出来之前,我一直用的是 pnpm-deduplicate,效果很直观:

Package "@babel/runtime" wants ^7.17.10 and could get 7.18.2, but got 7.18.0
Package "@babel/runtime" wants ^7.15.5 and could get 7.18.2, but got 7.18.0
Package "@babel/runtime" wants ^7.7.5 and could get 7.18.2, but got 7.18.0
Package "@babel/generator" wants ^7.7.2 and could get 7.18.2, but got 7.18.0
Package "@babel/generator" wants ^7.18.0 and could get 7.18.2, but got 7.18.0

整挺好,但是和 pnpm 一样只要是基于 lockfile 分析的工具都有一个通病,就是无法区分开发依赖和源码依赖。另外,在实际的 monorepo 项目中,你可能存在多个 app 需要构建,可能不同的 app 依赖了不同版本也会被扫描出来。

有时候为了确保某个重复依赖是否被干掉,你也可以直接去 pnpm-lock.yaml 里面搜索那个 package,还可以借助 pnpm why 去查看一个包是因为什么原因被安装的。

打包工具插件

最开始我搜到了 duplicate-package-checker-webpack-plugin,但是我们项目用的是 vite,于是熟悉 vite 插件开发的机会来了,当时 unplugin 挺火,可以让你用 rollup 插件系统的钩子写跨构建工具的插件,于是在借鉴了前面提到的 webpack 插件和 chatgpt 的帮助下很快就写出了 unplugin-detect-duplicated-deps

还是拿项目 vue-vben-admin 来做测试,如果使用 pnpm dedupe,结果是:

vben pnpm dedupe

如果使用我写的插件:

vben unplugin

vue-typespnpm dedupe 中检测不出来好理解,因为是跨大版本号。那 sortablejs 为啥没检查出来? axios 怎么又被检查出来了?

我们可以借助 pnpm why 来分析依赖是怎么被引入项目的:

 pnpm why -r axios
Legend: production dependency, optional only, dev only

[email protected] /Users/yutengjing/code/vue-vben-admin

dependencies:
axios 1.6.7

devDependencies:
unplugin-detect-duplicated-deps 1.1.1
└── axios 1.6.8

@vben/[email protected] /Users/yutengjing/code/vue-vben-admin/internal/vite-config

devDependencies:
vite-plugin-purge-icons 0.10.0
├─┬ @purge-icons/core 0.10.0
 └── axios 0.26.1
└─┬ rollup-plugin-purge-icons 0.10.0
  └─┬ @purge-icons/core 0.10.0
    └── axios 0.26.1

查看发现一个版本是源码依赖 /Users/yutengjing/code/vue-vben-admin/package.json 依赖了 [email protected],另外两个是开发依赖:unplugin-detect-duplicated-deps 依赖了 [email protected]@purge-icons/core 依赖了 [email protected]

 pnpm why sortablejs
[email protected] /Users/yutengjing/code/vue-vben-admin

dependencies:
sortablejs 1.15.2
vuedraggable 4.1.0
└── sortablejs 1.14.0

打开 vuedraggablepackage.json 看了一下,发现居然是使用固定版本号声明的 "sortablejs": "1.14.0",怪不得 pnpm dedupe 检测不出来。

原理

面试经常被问到 webpack 插件是怎么工作的,其实 webpack 和其他它很多构建工具的插件的开发方式都一样,插件本质上就是去订阅构建工具暴露的扩展事件(或者说钩子),在回调编写你的扩展逻辑。

核心逻辑:

  1. 再打包工具解析完模块 id 后获取其对应的文件绝对路径
  2. 从文件当前目录往上找 package.json,确定这个模块是哪个包,以及哪个版本引入的
  3. 构建结束后分析收集到的模块信息,对于那些同一个依赖出现了多个版本,则被视为有问题的依赖
  4. 当有依赖被重复打包则输出有问题的依赖信息

第一步可以使用 rollupresolveId 事件,第三步则可以使用 buildEnd 事件。

显示包的体积

只是列出了被重复打包的依赖,感觉对构建体积的影响不够直观,于是便借助 https://bundlephobia.com/ 的 api 来获取依赖的体积并展示,接口很简单:

https://bundlephobia.com/api/size?package=${name}@${version}

其实我一直不太理解为啥用 import cost vscode 插件的人那么多,这玩意因为获取一个包体积需要跑一次 webpack,所以又慢又吃资源,直接用 api 请求不香吗?

如何预防再次出现重复依赖

最初借助这个插件把一些重复依赖干掉后,过了一段时间,发现又多了一些新的重复依赖。对于开发效率有极致追求的我,受不了自己再处理一遍,谁拉的 💩 应该让他自己去处理。当时有在做 ci check 的优化,于是说能不能把重复依赖检查加到 ci 上去。这是我在上家公司贡献的最后一个 pr 了,也是技术分享会最后分享的内容了 😂。于是这个插件新增了一个选项 throwErrorWhenDuplicated,当设置为 true 时,出现重复依赖的时候构建进程便会退出。这个时候你可以有两种选择:

  1. 通过排查出现重复依赖的原因,消除掉这个重复依赖
  2. 通过 ignore 选项配置白名单
export default defineConfig({
  plugins: [
    UnpluginDetectDuplicatedDeps({
      throwErrorWhenDuplicated: true,
      ignore: {
        axios: ['0.27.2'],
        vue: ['*'],
      },
    }),
  ],
});

ci checker

如何修复重复依赖?

对 package 打包时不要打包 dependencies

这个推荐使用我维护的插件 unplugin-externalize-deps。如果你把 dependencies 也打包到 dist 里面了,tree-shaking 也救不了你啊。

举个例子,pkg1 和 pkg2 都依赖了 [email protected],结果你把 node_modules/axios 打包内联到 dist 里面,那依赖 pkg1 和 pkg2 的 app 肯定会存在两份 axios 代码呀,

声明版本号使用兼容性前缀

声明 vue 版本号有两种常见方式:

  1. 带兼容性前缀(或者说通配符) ^2.7.14
  2. 使用固定版本号 2.7.14,确实有人喜欢这么干:migrate tslint to eslint

问题场景:

项目中的 workspace packages 声明了 vue@^2.6.10vue@^2.7.14,使用了 pnpm-lock.yaml 锁定版本,项目中也确实安装了两个版本 vue2.6.10vue2.7.14

在使用兼容性前缀时修复方法:

删掉 lockfile, node_modules,重新 pnpm install,这样 pnpm 只会安装 [email protected]

注意我前面提到的要求:

  1. 删除 lockfile,因为 lockfile 的作用就是锁定版本
  2. 删除 node_modules,因为 pnpm 貌似在 node_modules 存在时,会直接复用当前 node_modules 安装的依赖版本作为解析的版本,具体看 issue:doesn’t resolve to latest satisfied version

虽说这样可以修复安装多个版本的问题,但是删除 lockfile 会导致依赖升级,往往可能会引入新的问题,项目越大越容易出问题。可能这也是为什么 pnpm 在 v8 的时候对于直接依赖采取 lowest 策略, 虽然后面有意见的人太多又改回去了。

小技巧:

# monorepo 项目删除所有 node_modules
alias rmpkgs="rm -rf node_modules && pnpm -r exec rm -rf node_modules"

关于通配符这里再插一嘴,不是所有版本都适合使用 ^ 前缀,像 typescript 建议使用 ~typescript 是一个典型的不遵守 semver 版本号的项目,minor 版本号变化也会引入破坏性变更。

使用 peerDependencies

之前参与团队会议的时候,听到同事把 peerDependencies 翻译为对等依赖,听的我稀里糊涂,问了下才知道说的是 peerDependencies,我觉得这翻译确实离谱。

当我们给我们的 package 添加一个依赖 dep 时,如果安装这个 package 的时候,大概率 dep 已经被 app 安装了,那么我们应该声明这个 dep 为 peerDependencies。这种依赖往往是某个生态的核心依赖,例如 vue, react, @babel/core, type-fest, vite 等,使用 peerDependencies 是时候也往往是我们在开发扩展,或者某个生态下的库。

如果我们声明这些依赖为 dependencies,那么由于不同的 package 声明的版本不一样,由于 lockfile 的锁定作用,有可能导致安装多个版本。

如果使用 peerDependencies,由于所有的 packages 依赖的都是 app 安装的 dep,就不会有重复依赖的问题。

但是实际情况往往更复杂,前面也说了,

pnpm update -r

如果重复安装的依赖是 workspace packages 的直接依赖导致的,通过升级依赖可以解决。

pnpm dedupe

pnpm update -r,可以解决直接依赖问题,但是不能解决依赖的依赖或者说间接依赖导致的问题。

pnpm dedupe 是 pnpm 自带的依赖去重命令,我在上家公司的时候这个功能还没出。

pnpm dedupe

如果只是想对重复依赖做检查而不是直接修改 lockfile,可以使用 --check 参数,甚至可考虑以把这一步加到 ci 的 pnpm install 之前。但是我感觉不太实用,因为这会检查到开发依赖,开发依赖重复其实一般是不太 care 的。

配置打包工具的 resolve

以 vite 为例,如果我们 vue 项目需要运行时编译模版,那么我们一般会这么配置:

export default defineConfig({
  resolve: {
    alias: {
      // 指向带模版编译器的版本
      vue: 'node_modules/vue/dist/vue.esm-bundler.js',
    },
  },
});

当 app 打包了一个依赖多个版本时,我们也可以通过这种方式让一个包名指向唯一的一个文件,这种方式还可以处理子路径:

export default defineConfig({
  resolve: {
    alias: [
      {
        // 当时项目从 vue2.6 迁移到 vue2.7
        find: '^vue$',
        replacement: 'node_modules/vue/dist/vue.esm-bundler.js',
      },
      {
        // 当时我们项目中存在 lodash 和 lodash-es 混用的情况
        find: /^lodash(\/.+)?/,
        replacement: 'lodash-es$1',
      },
    ],
  },
});

这种方式的缺点:

  1. 一个项目可以存在多个 app,你可能需要多个项目多次配置,当然我们都用 monorepo 了,一般会把构建配置封装到一个 npm 包中
  2. 对于一个重复依赖,你需要安装到 app 的 node_modules 中,才方便你在 alias 中指向它,比方说有个重复依赖 foo 是多个间接依赖导致的,app 的 package.json 没有直接依赖到 foo,为了配置 resolve.alias,你必须在 app 的 package.json 中声明。这可能会导致维护项目的新人觉得很困惑,明明用不到的依赖还要安装。如果是使用🔐版本的方式,那只需要在根 package.json 中配置一次。

锁版本

pnpm dedupe 可以在遵守兼容性前缀的前提下把多个版本升到一个最高的版本,但是对于像 1.x, 2.x 这种不符合兼容性要求的情况就无能为力了。

这两年 ESM 大行其道,很多包就因为迁移到 ESM,就发了大版本。由于我们项目使用 vite 进行构建,这种情况其实可以直接使用 2.x。

除了大版本这种情况,还有改包名的情况,一个典型的例子 babel-runtime@babel/runtime

我们可以通过配置去锁定整个项目中某个依赖的版本,不同的包管理器方法不太一样:

{
  "pnpm": {
    "overrides": {
      "vue": "2.7.14",
      "babel-runtime": "npm:`@babel/runtime7.24.3"
    }
  }
}

我还在团队的时候, pnpm.overrides 有个 bug:不能锁定 peerDependencies 的版本。当时是通过手写 .pnpmfile 钩子去解决这个问题的,不过现在已经修复了。

这里顺便分享个故事:我虽然是在编辑器团队,但是很少参与编辑器本身的功能迭代,我记得是 3 年前第一次跑编辑器项目的时候那次安装依赖贼耗时,开了代理也没用。原因是项目中依赖了 node-canvas, puppeteer 之类包需要下载很大的二进制文件,但是实际上项目根本用不到这几个包。三年后我再次参与这个项目的迭代,还是这么慢,我就直接截张图发群里开喷了。我的性格就是这样,之前面试的时候还被面试官说我有点强势… 喷归喷,我还是把这个问题给解决了。

首先这几个依赖我们项目里面依赖到了,但实际上是用不到的,但是我们当时没法推动别的团队去把那几个下载贼慢的依赖从 dependencies 里面干掉。当时查阅资料发现了:a way to ignore packages,原理就是把这几个不需要安装的包锁定到一个占位包:

{
  "resolutions": {
    "node-canvas": "https://registry.yarnpkg.com/@favware/skip-dependency/-/skip-dependency-1.0.2.tgz"
  }
}

后面了解到有 .pnpmfile.cjs 这个东西我就直接在那里面去 delete 掉那几个依赖了,但是 .pnpmfile.cjs 其实也有个挺无语的 bug:.pnpmfile change not trigger resolution。上个月才修复的。

要不是天天和 pnpm 做斗争,我也想不到这玩意有这么多 bug。pnpm 纵然有很多 bug,某些框架如 electron 和 vsce 对 pnpm 这种基于链接的包管理器兼容性不好。但我还是很喜欢 pnpm,安装快,省空间,node_modules 下结构清晰,提供了非常多实用的扩展选项,还可以编写 .pnpmfile.cjs 修改安装逻辑,等等。

在 importmap 中有个 scope 字段其实也可以起到锁定版本的作用:

{
  "imports": {
    "vue": "https://esm.dancf.com/npm:[email protected]/dist/vue.esm.js"
  },
  "scopes": {
    "https://esm.dancf.com/": {
      "axios": "https://esm.dancf.com/npm:[email protected]/dist/axios.min.js",
      "js-cookie": "https://esm.dancf.com/npm:[email protected]/dist/js.cookie.mjs",
      "uuid": "https://esm.dancf.com/npm:[email protected]/dist/esm-browser/index.js"
    }
  }
}

分享一些我在和 pnpm 做斗争时使用过的工具

参考资料

写作过程中主要参考了以下资料:

总结

项目中一个包安装多个版本几乎是不可避免的,合理使用版本兼容性前缀和 peerDependencies 可以一定程度缓解这个问题。

分析重复打包的源码依赖建议使用插件:unplugin-detect-duplicated-depsrsdoctor 也不错,不过它只支持 webpack 和 rspack。

修复重复打包依赖,一般情况可以通过 pnpm update + pnpm dedupe 来自动修复,对于跨大版本,换包名,或者其它很难解决的情况建议直接上 pnpm.overrides 锁定版本。

建议开启 unplugin-detect-duplicated-depsthrowErrorWhenDuplicated 选项将其作为 ci check 来预防再次出现依赖重复打包。