浅谈 package-lock.json 合并冲突修复算法
对于使用 npm 的前端项目,在分支合并时经常会遇到 package-lock.json 冲突。此时直接执行 npm install 命令,npm 会自动帮忙解决冲突。
但这是否存在什么问题?又该如何解决?文本将来探讨这个话题,预期收获有:
- 了解
npm install自动解决合并冲突的原理 - 了解合并冲突修复算法存在的问题,以及如何解决
# 前置知识
- Git 合并冲突原因:两个分支对一个文件的同一区域做了修改
- Git 合并冲突片段内容:包括
当前分支改动、目标分支改动、两个分支的最近公共祖先节点在该区域的内容。需要注意的是,第三部分内容(base)需要配置合并冲突展示选项为diff3或者zdiff3才会存在,更多介绍可以看 开启 diff3,帮助解决 Git 合并冲突难题 (opens new window) 这篇文章。

# 算法流程
package-lock.json 的冲突修复算法由 npm/parse-conflict-json (opens new window) 仓库维护,项目 README 简单描述了算法流程:
- 将冲突文件解析为 3 部分:
ours(当前分支内容) ,theirs(目标分支内容), 以及parent(两个分支的公共祖先节点的内容)。 - 获取
parent与ours间的差异(diff) (opens new window):对象 diff 对比,从parent变化到ours的步骤,包括变更路径(对象key路径)、变更行为(新增、删除、修改)、变更值。 - 将该差异的每个变更应用 (opens new window)到
theirs的变更中- 如果变更行为是
新增且变更路径在theirs中已有值,则变更行为调整为修改。 - 如果无法应用差异变更,(通常是变更路径无法在
theirs中找到,见下方例子),则将theirs相应路径中的对象替换为ours路径中的对象。
- 如果变更行为是
一句话总结:基于 theirs,应用 ours 的变更。
# 代码流程
完整代码在:https://github.com/npm/parse-conflict-json/blob/main/lib/index.js
const PARENT_RE = /|{7,}/g
const OURS_RE = /<{7,}/g
const THEIRS_RE = /={7,}/g
const END_RE = />{7,}/g
const isDiff = str =>
str.match(OURS_RE) && str.match(THEIRS_RE) && str.match(END_RE)
const parseConflictJSON = (str, reviver, prefer) => {
// 解析冲突内容
const pieces = str.split(/[\n\r]+/g).reduce((acc, line) => {
if (line.match(PARENT_RE)) {
acc.state = 'parent'
} else if (line.match(OURS_RE)) {
acc.state = 'ours'
} else if (line.match(THEIRS_RE)) {
acc.state = 'theirs'
} else if (line.match(END_RE)) {
acc.state = 'top'
} else {
if (acc.state === 'top' || acc.state === 'ours') {
acc.ours += line
}
if (acc.state === 'top' || acc.state === 'theirs') {
acc.theirs += line
}
if (acc.state === 'top' || acc.state === 'parent') {
acc.parent += line
}
}
return acc
}, {
state: 'top',
ours: '',
theirs: '',
parent: '',
})
// 转为对象结构
const parent = parseJSON(pieces.parent, reviver)
const ours = parseJSON(pieces.ours, reviver)
const theirs = parseJSON(pieces.theirs, reviver)
// 获取结果
return resolve(parent, ours, theirs)
}
const resolve = (parent, ours, theirs) => {
// 获取 parent 对象到 ours 对象的变更
const dours = diff(parent, ours)
// 将变更应用到 theirs
for (let i = 0; i < dours.length; i++) {
try {
diffApply(theirs, [dours[i]])
} catch (e) {
// 拷贝 ours 的变更路径至 theirs
copyPath(theirs, ours, dours[i].path, 0)
}
}
return theirs
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# 实例讲解
{
"ms": {
<<<<<<< HEAD
"version": "2.1.2"
||||||| merged common ancestors
=======
"version": "2.1.3",
"desc": "test"
>>>>>>> feat4
},
<<<<<<< HEAD
"c": {
"x": "bbbb"
}
||||||| merged common ancestors
"c": {
"x": "aaaa"
}
=======
"c": "xxxx"
>>>>>>> a
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
第一步,解析文件得到 ours、theirs、parent 的对象值

第二步,获取 parent 与 ours 的差异:
- 增加
ms.version字段,值为2.1.2 - 修改
c.x字段,值为bbbb
第三步,将差异逐个应用到 theirs
- 修改(由于变更路径存在值)
theirs中ms.version的值为2.1.2 - 修改
theirs中c.x的值为bbbb,但c.x这个路径无法在theirs中找到,于是将theirs的c取值改成ours的c取值 - 全部应用完毕,得到如下对象
{
ms: {
version: "2.1.2",
desc: "desc"
},
c: {
x: "bbbb"
}
}
2
3
4
5
6
7
8
9
# 问题分析
简单来说,冲突合并算法就是基于 theirs,并应用 ours 的变更。
如果 ours 或 theirs 更新了不同的路径,package-lock.json 最终都会保留。
但如果同时更新了同一路径,比如模块的版本号,则会以 ours 的版本为准,这在极少数情况下会出错。
这也符合直觉,处于主分支并 merge 开发分支,版本应该尽量以主分支的为准,更稳定
另外对于「开发分支 rebase 主分支」的情况,
ours和theirs的内容是相反的,即实际也是以主分支的为准
以两个分支更新同一模块的版本号为例:

- 同时从
主分支拉取了两个开发分支feat1和feat2。 - 开发分支
feat1安装了依赖A^1.0.0,此时装的版本是1.0.0,feat1先合入了主分支。 - 开发分支
feat2还在继续开发,也安装了依赖A^1.0.0。但此时 A 发了一个有新 API 的1.0.1版本,正好feat2用到了,此时feat2装的版本是1.0.1。 feat2测试完毕 (仅测了feat2的功能) ,于是切换到主分支,并执行git merge feat2命令准备合入feat2的代码- 此时发现 lock 文件冲突,于是执行
npm install快速解决,lock 文件会选择了ours(主分支)的版本,即1.0.0 - 没有测试直接上线,结果发现项目中关于
feat2的功能报错了
虽然很少见,但这是业务碰到过的活生生的例子 🩸。
并且这类问题,在 monorepo 流行后会变得更加常见 — 不同的子包安装了相同依赖的不同版本,且很难 review 到位。。
那对于这个场景,选择 A 的 1.0.1 版本可行么?实际上也不靠谱,如果 1.0.1 出现了 BREAKING CHANGE,那么 feat1 的功能将报错。。
# 解决方案
当出现依赖版本冲突时,没有一劳永逸且稳定的版本选择策略,但可以参考下面这个最佳实践,进行必要的 lockfile 人工 review ,并通过合理的开发流程来保障。
- 冲突解决操作: 依然选择
npm install解决冲突,如果后面有调整版本的需求再手动更改 - 版本调整原则: 版本默认以主分支为准,若要调整,关注以下两点:
- 当前需求是否用到默认版本所不拥有的新接口,如果是则尝试调整为当前需求的版本
- 调整至当前需求版本后,是否存在
BREAKING CHANGE。如果是,则不推荐使用这个依赖,或者此依赖分属 monorepo 的不同子包,则采用固定版本的写法。
- 合理的开发流程: 及时 rebase 、变更复测
- 开发阶段,及时 merge 或者 rebase 主分支的代码,有冲突提前解,而不是等到提测后要上线的时候再去处理。
- 提测后合码前,如果发现代码冲突,解决冲突并合码上线前最好再重新测试一下代码冲突相关的场景(如果项目重要和人力允许的话)。
- 必要的 lockfile 人工 review: 仅需关注直接依赖(比如
pnpm-lock.yaml文件的specifier和version部分)的版本变更,对于直接依赖引入的间接依赖,自动升级出错的概率较小(一旦出错影响的不只一个项目),且 review 成本太高,选择信任社区,也可选择「变更复测」来保障。
# 总结
本文系统分析了 npm i 解决 package-lock.json 冲突的算法策略,即基于 theirs 并应用 ours 的变更。
该策略在绝大多数情况下有效,但对于某些边缘场景,粗暴的选择某个依赖的版本会导致问题。
对于这类问题,目前没有(也很难有)一劳永逸的解决方案,要么信任社区并听天由命,要么通过文本提到的 「必要的 lockfile 人工 review」 + 「合理的开发流程」来保障。
最后,如果看完本文有收获,欢迎一键三连(点赞、收藏、分享)🍻 ~