为什么保证前端依赖一致这么难?

6,288 阅读8分钟

本文又名「前端依赖一致性的问题分析与最佳实践」

背景

image.png

前端开发经常遇到项目依赖变更的问题,可以归结为时间、空间和人三个方向。

  • 时间:没有锁版本(lock),下次安装时部分依赖会更新到最新的小版本
  • 空间:系统的 Node.js 版本、包管理工具版本等与目标要求不一致,导致依赖变更
  • 人:使用了错误的包管理工具或者命令,导致依赖变更

这些问题不能单纯依靠文档规范来限制,需要有行之有效的工具来避免。

本文会先描述这些常见问题的原因和解决方案,并在最后给出可实施的最佳实践。

常见问题

1. 不锁版本(lock),依赖包会自动更新

这个应该都懂,需要把 lock 文件加入 git 版本控制之中。

但可能有些同学不知道为什么这么做,这里简单提下。

如果没有锁文件(比如 package-lock.json),Node 在安装依赖时会选择符合版本范围的最新版本,这个版本范围的设计叫做 semver 规范,其格式为 主版本号.次版本号.修订号 ,版本号递增规则如下:

  1. 主版本号:当你做了不兼容的 API 修改,
  2. 次版本号:当你做了向下兼容的功能性新增,
  3. 修订号:当你做了向下兼容的问题修正。

理想情况下大家都照这个要求设计,有不兼容变更就做主版本号升级,那当然没问题,大家也乐意自动更新来修复漏洞。

然而实际情况上社区的大部分包并不遵守这个约定,有不兼容变更却只是升级了 修订号 或者 次版本号。。如果选了自动升级,那么准备等待项目报错吧。

因此还是乖乖的 lock 文件加入 git 版本控制吧。

2. 锁了版本包也没动,还是更新了依赖

主要表现为没有改动包版本,也使用了 lock 锁定版本,但是后面重装依赖时还是更新了版本和 lock 文件。

如果出现这个原因,是 npm 5.x 时期的某些奇怪设计,更新 npm 包版本即可。

一些相关的讨论:

3. 使用了错误的包管理工具

接到一个新项目,我们会选择什么包管理工具安装依赖?

有经验的同学可能会先看项目说明文档(如果有的话),或者看看项目存在哪种 lock 文件(package-lock.jsonpnpm-lock.jsonyarn.lock),再决定使用对应的包管理工具。

但总有部分人,经验不足或基于习惯,使用了与项目要求不符的包管理工具。这样的问题在于,该包管理工具没有 lock 文件,依赖是最新的不可置信的。也不可能把这个错误的 lock 文件提交,那会导致管理混乱。

如何解决?把非目标的包管理工具 lock 文件加入 gitignore ,只能解决管理混乱。

可以尝试在安装依赖前(preinstall/prepare npm-script 钩子),检查依赖安装命令用的工具是否符合预期,如果不符合预期则中断安装并提示使用正确的命令。

社区也有现成的工具 only-allow,可以直接使用,具体可以看官方文档。

"preinstall": "npx only-allow pnpm"

4. 安装具体依赖不会触发 prepare 钩子

问题 3 中,我们提到了使用 prepare 钩子来检测依赖安装命令是否符合预期。

然而 prepare 钩子的触发条件是有限制的(preinstall 同),如果 install 命令存在其他参数,将无法触发这个钩子。

npm i lodash

官方文档也有说明:

由于 npm 针对单个依赖安装触发钩子的 RFC 还在讨论阶段,故目前还没有根本的解决方案,但我们可以通过一些手段来避免这个问题:

  • 如问题 3 所说,把错误的包管理工具 lock 文件加入 gitignore

  • 提交合并请求时,在 CI 阶段做一次 lock 文件完整性校验。如果 package.json 的依赖存在变更,但没有加入lock 文件,说明之前的安装方式不正确。

一些相关的讨论:

5. 自动使用正确的依赖安装命令

问题 3 中,我们提到了使用 prepare 钩子来检测依赖安装命令是否符合预期,如果不符合预期则中断安装并提示使用正确的命令。

那能否调整为:如果不符合预期,则使用正确的命令安装?

一个技巧是,在 prepare 钩子中执行正确的安装命令,在安装后中断脚本运行,避免走到默认的依赖安装。

示例脚本:

# install.sh
# 在 preinstall 中添加如下脚本
# "preinstall": "sh install.sh",

# 无论使用什么包管理工具,最后都改成 pnpm install

echo '已自动改成 pnpm install'
# 跳过 scripts ,避免循环执行
pnpm install --ignore-scripts
rm package-lock.json
rm yarn.lock
echo '安装完成,中断后续执行'
exit 1

以上为测试代码,请勿使用在生产项目中。如果有更好的方案,欢迎分享讨论~

6. 限制 Node 和包管理工具的版本

因为设计思路的变化,包管理工具的不同版本也会导致 lock 文件不同。

有一些项目只能运行在特定的 Node.js 版本,又或者使用特定版本的包管理工具安装依赖。

Node.js 好理解,不同版本的语法会有变化。而使用特定版本的包管理工具,通常是和幽灵依赖相关。某些依赖凑巧使用了另一个版本的依赖,一切凑巧使得项目能够正常运行。如果更新了包管理工具版本,依赖组织方式变了,那项目就不一定能运行了。

彻底的解决方案还是进行升级,但没有时间、收益太低,且这种改动影响面太大,通常需要整体测试。

因此,通常还是选择利用 package.jsonengines 字段限制 Node 和包管理工具的版本。

{
  "engines": { 
    "node": ">=14",
    "npm": "~8",
    "pnpm": "~8", 
    "yarn": "~1", 
  }
}

需要注意的是,engines.npm 默认并不生效(只警告不中断),需要在 .npmrc 文件中添加 engine-strict=true 才行。也有另一种解决方案是在 prepare 钩子中检测 npm 的版本,如果不符合版本要求就中断执行,相关代码可以看这篇文档

7. 快速切换版本

在问题 6 中,我们谈到了限制 Node 和包管理工具的版本。但有限制就会有快速切换的需求,一个电脑通常不只跑一个项目。

如何快速切换?比如 node 12 切换到 node 14,又或者 pnpm 6 切换到 pnpm 7

我们拆成两个问题来看:

  1. 快速切换 Node 版本
  2. 快速切换包管理工具版本

快速切换 Node 版本

切换 Node 版本简单,使用 nvm 来管理即可。

还可以在项目根目录创建 .nvmrc 文件并添加版本号,需要切换的时候直接 nvm use 即可,无需记忆版本。

// .nvmrc
v16.0.0

还能不能更快?比如我切换了项目目录,就自动帮忙切换版本。

在社区上找到了一款叫 avn 的工具,号称能做这样的事,但看起来很久没迭代了,这种监测应该会有额外的性能影响,没有实际体验过不做评价。

我个人的实践是将 node 版本检测和切换的逻辑放入 prepare 等钩子,在需要的时候再切。

快速切换包管理工具版本

通常包管理工具在全局只会存在一个版本,有以下 3 种解决方案:

  1. corepack: Node.js 16 推出,可以用来管理各种包版本工具,包括 npm/yarn/pnpm,参考文档:Corepackpnpm using corepack
# 以 pnpm 版本切换为例

# 启用 corepack
corepack enable

# 指定版本
corepack prepare pnpm@<version> --activate
# 在 Node.js v16.17 及以上版本,支持使用 latest tag 安装最新的版本
corepack prepare pnpm@latest --activate
  1. pnpm dlx: 使用 pnpm dlx 来指定 pnpm 运行命令使用 pnpm7 ,可以参考这篇文章,个人不推荐
pnpm dlx pnpm@7 install
pnpm dlx pnpm@7 run dev
  1. 项目内依赖:在公司内部有个实践,把 pnpm 等包管理工具作为依赖安装在项目中,再通过其他命令调用,把命令转发给这个项目内的 pnpm 。这样做的好处是可以实现包管理工具的版本控制,缺点就是需要改用其他命令。

最佳实践

对上面的问题做下整理,可以得到以下最佳实践。

  1. 使用 lock 来锁住版本,并使用较新版本的 npm 避免一些遗留问题
  2. 把非目标的包管理工具 lock 文件加入 gitignore
  3. 使用 prepare 钩子和 only-allow 工具
  4. 在 CI 阶段再次进行 lock 文件完整性校验
  5. 使用 package.jsonengines 字段限制 Node 和包管理工具的版本

展望

实际上还存在一些问题,比如问题 5 的「自动使用正确的依赖安装命令」,目前还缺少官方解决方案。以及问题 7 提到的 Corepack 工具,目前还处于实验阶段。

感叹下,前端的基建真挺稀烂的,一些不合理的设计,导致了数百万前端程序员接盘,不过这个情况也在慢慢变好了。


最后,👋🏻 Respect!欢迎一键三连 ~