如何正确地解决 package-lock.json 合并冲突?

3,754 阅读6分钟

本文又名《浅谈 package-lock.json 合并冲突修复算法》

对于使用 npm 的前端项目,在分支合并时经常会遇到 package-lock.json 冲突。此时直接执行 npm install 命令,npm 会自动帮忙解决冲突。

但这是否存在什么问题?又该如何解决?文本将来探讨这个话题,预期收获有:

  • 了解 npm install 自动解决合并冲突的原理
  • 了解合并冲突修复算法存在的问题,以及如何解决

前置知识

  • Git 合并冲突原因:两个分支对一个文件同一区域做了修改
  • Git 合并冲突片段内容:包括 当前分支改动目标分支改动两个分支的最近公共祖先节点在该区域的内容 。需要注意的是,第三部分内容(base)需要配置合并冲突展示选项为 diff3 或者 zdiff3 才会存在,更多介绍可以看 开启 diff3,帮助解决 Git 合并冲突难题 这篇文章。

算法流程

package-lock.json 的冲突修复算法由 npm/parse-conflict-json 仓库维护,项目 README 简单描述了算法流程:

  1. 将冲突文件解析为 3 部分:ours (当前分支内容) , theirs (目标分支内容), 以及 parent (两个分支的公共祖先节点的内容)。
  2. 获取 parentours 间的差异(diff):对象 diff 对比,从 parent 变化到 ours 的步骤,包括变更路径(对象key路径)、变更行为(新增、删除、修改)、变更值。
  3. 将该差异的每个变更应用theirs 的变更中
    1. 如果是变更行为新增变更路径theirs 中已有值,则变更行为调整为修改
    2. 如果无法应用差异变更,(通常是变更路径无法在 theirs 中找到,见下方例子),则将 theirs 相应路径中的对象替换为 ours 路径中的对象。

一句话总结:基于 theirs,应用 ours 的变更。

代码流程

完整代码在:github.com/npm/parse-c…

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
}

实例讲解

{
      "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
}

第一步,解析文件得到 ourstheirsparent 的对象值

image.png

第二步,获取 parentours 的差异:

  • 增加 ms.version 字段,值为 2.1.2
  • 修改 c.x 字段,值为 bbbb

第三步,将差异逐个应用到 theirs

  • 修改(由于变更路径存在值) theirsms.version 的值为 2.1.2
  • 修改 theirsc.x 的值为 bbbb,但 c.x 这个路径无法在 theirs 中找到,于是将 theirsc 取值改成 oursc 取值
  • 全部应用完毕,得到如下对象
{
  ms: {
    version: "2.1.2",
    desc: "desc"
  },
  c: {
    x: "bbbb"
  }
}

问题分析

简单来说,冲突合并算法就是基于 theirs,并应用 ours 的变更。

如果 ourstheirs 更新了不同的路径,package-lock.json 最终都会保留。

但如果同时更新了同一路径,比如模块的版本号,则会以 ours 的版本为准,这在极少数情况下会出错。

这也符合直觉,处于主分支并 merge 开发分支,版本应该尽量以主分支的为准,更稳定

另外对于「开发分支 rebase 主分支」的情况,ourstheirs 的内容是相反的,即实际也是以主分支的为准

以两个分支更新同一模块的版本号为例: image.png

  1. 同时从主分支拉取了两个开发分支 feat1feat2
  2. 开发分支 feat1 安装了依赖 A^1.0.0 ,此时装的版本是 1.0.0feat1 先合入了主分支
  3. 开发分支 feat2 还在继续开发,也安装了依赖 A^1.0.0。但此时 A 发了一个有新 API 的 1.0.1 版本,正好 feat2 用到了,此时 feat2 装的版本是 1.0.1
  4. feat2 测试完毕 (仅测了 feat2 的功能) ,于是切换到主分支,并执行 git merge feat2 命令准备合入 feat2 的代码
  5. 此时发现 lock 文件冲突,于是执行 npm install 快速解决,lock 文件会选择了 ours (主分支)的版本,即 1.0.0
  6. 没有测试直接上线,结果发现项目中关于 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 文件的 specifierversion 部分)的版本变更,对于直接依赖引入的间接依赖,自动升级出错的概率较小(一旦出错影响的不只一个项目),且 review 成本太高,选择信任社区,也可选择「变更复测」来保障。

总结

本文系统分析了 npm i 解决 package-lock.json 冲突的算法策略,即基于 theirs 并应用 ours 的变更。

该策略在绝大多数情况下有效,但对于某些边缘场景,粗暴的选择某个依赖的版本会导致问题。

对于这类问题,目前没有(也很难有)一劳永逸的解决方案,要么信任社区并听天由命,要么通过文本提到的 「必要的 lockfile 人工 review」 + 「合理的开发流程」来保障。


最后,如果看完本文有收获,欢迎一键三连(点赞、收藏、分享)🍻 ~