一、为什么要关心版本漂移

在真实的开发里,最让人头疼的不是写不出代码,而是“昨天还能跑,今天突然挂了”。如果你用的是Lerna来管理多包仓库(也就是Monorepo),那这种惊悚时刻会更多。原因不复杂:每个包都有自己依赖的第三方库,而这些第三方库的版本号往往带了一个“^”或“~”,表示可以浮动。只要浮动,安装时间、安装环境不同,拿到的实际版本就可能不一样。

打个比方,你家厨房有两口锅,每口锅用同一个牌子的酱油,但是一瓶是今年的,一瓶是去年的。做出来的菜味道也许还能接受,但你要是把其中一口锅换成另一个牌子呢?味道就彻底乱了。代码里,第三方库内部行为发生变化,哪怕是小版本,也可能让你的功能表现不一样,这就是版本漂移。

这问题在Monorepo里格外严重。因为你可能有十个包,每个包里包含自己的依赖组合,它们之间还会互相引用。一旦某个基础依赖漂移了,影响不是一个小模块,而是整棵依赖树。尤其在一些长期维护的项目里,依赖的依赖还可能互相牵制,最终没人说清楚线上跑的是哪个版本。

应用场景覆盖很广:多团队并行开发、CI持续集成、发布后回滚,甚至是把项目从自己电脑搬到同事电脑,都可能因为版本漂移踩坑。所以我们需要在配置层面想办法,让同一套代码在任何时间、任何机器上安装出来的依赖树完全一致。

二、Lerna是怎么管理依赖的

2.1 两种经典模式

Lerna有两种工作模式:固定模式和独立模式。固定模式就像全班统一穿校服,所有包都是同一个版本号,发布时一起升版本。独立模式就像大家各穿各的,一个包一个版本号,互不干扰。实际项目中,独立模式更常用,因为你不希望改一个组件导致一堆包都升版本。但独立模式对依赖一致性要求更高,因为版本独立让依赖关系更复杂。

2.2 lerna.json的基础配置

不管哪种模式,你都要在项目根目录放一个lerna.json。下面这个配置是最常见的样子。本文所有示例均基于Node.js生态,包管理器使用npm。

{
  "version": "independent",
  "packages": ["packages/*"],
  "npmClient": "npm"
}

version字段是“independent”,说明是独立模式;packages告诉Lerna去packages目录下寻找子包;npmClient指定使用npm作为包管理工具。如果你对npm不熟,只要知道npm负责下载安装依赖,Lerna负责协调多包关系,两者配合干活就行。配置好之后,我们才能继续谈“锁版本”。

三、版本漂移是怎么发生的

先看看版本漂移是怎么发生的。假设我们有这样一个Monorepo,里面两个包,都依赖同一个第三方库lodash。注意,下面这些配置只是为了演示问题,现实中你可以换成任何库。

pkg-a的package.json:

{
  "name": "pkg-a",
  "version": "1.0.0",
  "dependencies": {
    "lodash": "^4.17.20"
  }
}

pkg-b的package.json:

{
  "name": "pkg-b",
  "version": "1.0.0",
  "dependencies": {
    "lodash": "^4.17.21"
  }
}

这里两个包都用了“^”,它表示允许安装同主版本下更高的次版本或补丁版本。比如^4.17.20可以升级到4.17.21甚至4.17.22,但不能跳到5.0.0。问题在于,如果你没有锁定安装结果,那么每次执行npm install时,npm都会重新解析这些范围,然后去找当前能满足范围的最新版本。今天安装可能解析出4.17.21,过了两周lodash发布了4.17.22,你再安装就会拿到4.17.22。你的代码没有改,但行为可能悄悄变了。

怎么知道当前到底装了哪个版本?在项目根目录执行:

# 查看整个依赖树中 lodash 的实际版本
npm ls lodash

你会看到类似这样的输出:

my-monorepo@1.0.0 /path/to/my-monorepo
└─┬ pkg-a@1.0.0
  └── lodash@4.17.21

如果pkg-b也安装成功,输出里会并列显示。假如你发现两个包分别装在各自目录中,并且版本还不一样,那问题就更明显了。没有锁定的依赖管理,就像没有线路图的地铁站,你永远不知道下一趟会停在哪一站。

四、几个锁定依赖的好帮手

要解决版本漂移,不是靠意志力,而是靠配置。下面介绍几个非常实用的技巧,你可以根据项目情况选择使用。

4.1 让Lerna使用精确版本号

Lerna有一个专门的配置项,叫exact。我们可以直接在lerna.json里打开它:

{
  "version": "independent",
  "packages": ["packages/*"],
  "npmClient": "npm",
  "exact": true
}

这个开关的作用是:当你用lerna add给某个包添加本地包依赖时,写入package.json的版本号不会带“^”或“~”,而是写死比如“1.2.3”。这能保证各包在声明依赖本地包时,不会出现“一会儿想用1.2.4,一会儿想用1.2.3”的混乱情况。

4.2 把package-lock.json当成保险单

默认情况下,npm会自动生成一个package-lock.json文件。这个文件把整个依赖树的所有版本号都记录得清清楚楚,包括哪个包的哪个依赖用的是哪一个小版本。只要提交这个文件,后续所有人安装时都会按它安装,不再重新解析。

很多新手会把package-lock.json加进.gitignore,这其实就是把保险单撕了。务必把它提交到代码仓库,并且定期审查。这个文件是对抗版本漂移的第一道防线。

4.3 用npm ci代替npm install

npm install会根据package.json重新解析依赖范围,如果没有lock文件或者lock文件过期,就可能产生变动。但npm ci会严格读取package-lock.json,按照里面的内容安装,并且会先删除node_modules,保证安装结果和锁文件完全一致。

在CI流水线、部署脚本里,请尽量使用npm ci,而不是npm install。这样每次构建的环境都一样,不会出现“本地能跑,线上就挂”的尴尬。

# 在CI或部署环境中,使用这个命令安装全部依赖
npm ci

4.4 用overrides强制统一某一个依赖

有时候你会发现,两个包依赖同一个库的两个不同大版本,比如一个依赖lodash 3,一个依赖lodash 4。这时即使有lock文件,也会同时装两个大版本,体积变大,还可能产生冲突。npm从8.3版本开始支持overrides字段,你可以在根package.json里强制指定某个依赖的版本。

比如我们想在所有包中统一使用lodash 4.17.21,可以在根package.json添加以下内容:

{
  "overrides": {
    "lodash": "4.17.21"
  }
}

注意,这是“强制覆盖”,可能会让某些原本依赖旧版本的代码出现兼容问题,所以一定要充分测试再使用。overrides适合在依赖冲突已经出现、并且你确认统一版本不会有问题时使用。

五、完整实操:配置一个稳定的Lerna工程

下面我们从头演示一遍。技术栈依然基于Node.js和npm。

首先创建一个空目录,并在里面初始化项目:

mkdir lerna-demo
cd lerna-demo
npm init -y

然后安装Lerna:

npm install lerna --save-dev

创建lerna.json,内容如下:

{
  "version": "independent",
  "packages": ["packages/*"],
  "npmClient": "npm",
  "exact": true
}

接着创建两个子包目录:

mkdir -p packages/pkg-a packages/pkg-b

在packages/pkg-a/package.json填入:

{
  "name": "pkg-a",
  "version": "1.0.0",
  "dependencies": {
    "lodash": "^4.17.20"
  }
}

在packages/pkg-b/package.json填入:

{
  "name": "pkg-b",
  "version": "1.0.0",
  "dependencies": {
    "lodash": "^4.17.21"
  }
}

现在执行安装:

# 使用 Lerna 的依赖管理功能安装所有依赖
npx lerna bootstrap

安装之后,查看lock文件是否生成:

# 列出根目录下所有文件,确认 package-lock.json 存在
ls package-lock.json

确认存在后,把它提交到Git仓库。以后每次安装,都使用:

npm ci

这样团队里每个人、每台服务器,拿到的依赖树都一模一样,版本漂移就不会再伤害你了。

如果你想给某个包添加一个本地包依赖,并且希望版本号写死,可以执行:

# 给 pkg-a 添加一个对 pkg-b 的依赖
# 因为 lerna.json 里设置了 exact: true,所以版本号会是 "1.0.0"
npx lerna add pkg-b --scope=pkg-a

执行后打开pkg-a的package.json,你会看到:

{
  "name": "pkg-a",
  "version": "1.0.0",
  "dependencies": {
    "lodash": "^4.17.20",
    "pkg-b": "1.0.0"
  }
}

注意,pkg-b的版本号没有“^”,这正是exact配置起的作用。

六、优点、缺点和需要小心的地方

6.1 优点

最大的优点是稳定。lockfile和npm ci组合,让“可复现构建”成为现实。你不用再为“为什么测试环境没事,生产环境就挂”这种事熬夜。其次,exact模式让本地包之间的版本依赖关系更透明,打开package.json就能看到精确版本,不用猜。

6.2 缺点

这组配置并非没有代价。过度精确会让升级变得繁琐。比如某个安全漏洞发布后,你只能手动改lockfile或者执行npm update,然后再跑一遍测试。另外,overrides强行统一版本,虽然能消除重复依赖,但也可能让某个包使用了它还没有测试过的依赖版本,反而埋下隐患。

6.3 注意事项

有几个重要的点需要留意。

第一,package-lock.json绝对不能忽略,也不要手动编辑,除非你真的清楚自己在做什么。

第二,在写package.json时,尽量保留“^”,不要因为害怕漂移而把依赖全部写成精确版本。因为lockfile已经替你解决了漂移问题,保留“^”反而有利于将来安全升级。

第三,添加新依赖或升级依赖时,要运行完整的测试用例,并重新生成lockfile。

第四,如果你的项目使用npm ci,请确保lockfile已提交,并且和package.json保持一致,否则npm ci会直接报错。

第五,overrides是最后的底牌。能用lockfile解决就不要轻易用overrides,因为它是“不讲道理”的强制覆盖。

七、总结

在Monorepo中,版本漂移就像一座看不见的火山,平时不爆发,一旦喷发就让人焦头烂额。通过使用Lerna的exact配置、规范化lockfile提交、使用npm ci安装,以及必要时用overrides统一版本,我们可以在很大程度上把“随机”变成“固定”。稳定的依赖树是项目长期健康的基石,花十分钟配置,省下的是无数个排查问题的深夜。希望这篇文章能帮你在Lerna的世界里少踩坑,多睡觉。