浅谈 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」 + 「合理的开发流程」来保障。
最后,如果看完本文有收获,欢迎一键三连(点赞、收藏、分享)🍻 ~