使用 unified diff 为 pnpm 依赖编写可复现补丁

使用 unified diff 为 pnpm 依赖编写可复现补丁

第三方依赖出了问题,最容易做的事是打开 node_modules 改一行。下一次 pnpm install 之后,改动通常会消失。开源协作里更麻烦:同事、CI、新克隆的仓库都看不到你那次本地修改。

pnpm 的 patchedDependencies 解决的就是这件事:把对某个精确版本的修改,收成仓库里的一份 .patch。安装时自动重放。.patch 本身不是配置文件,而是一份 unified diff------Unix 世界里用了几十年的变更描述格式。

本文分三层写:

  1. 为什么要补丁,以及它在开源协作里处在什么位置。
  2. unified diff 每一行在说什么。
  3. 如何「先改文件,再用 diff -u 生成文本」,并接到 pnpm。

生产环境优先用官方的 pnpm patch / pnpm patch-commit。学会 diff -u,是为了能读懂、能审查、能在必要时手写最小补丁。

前言:补丁是权宜之计,不是日常开发方式

给依赖打补丁,在开源社区通常排在后面:

  1. 升级到已修复版本。
  2. 向上游提 Issue / PR,让修复进入主线。
  3. 短期无法升级时,再在自己的仓库里打一枚 最小、可复现、绑死版本 的补丁。

补丁的价值是可审查、可分发、可回滚。成本是:上游一升级,补丁可能失效,必须重做。仓库里如果已经有 pnpm.patchedDependencies,升级其中任何一个包,都不能只改版本号。

三种处理路径

路径 适用 代价
升级或换依赖 上游已修复 最低,优先选
fork 后改发包 改动大、要长期维护 要跟上游同步
仓库内 .patch 改动小、等上游、要立刻修好 升级时可能失效

.patch 适合「只改三五行、希望下次安装自动长出同样结果」的场景。不适合在补丁里改依赖的 package.json 来换传递依赖------那种需求应走 pnpm overrides 或 package hook。

.patch 和 patchedDependencies 如何配合

可以把 .patch 理解成一份 安装后重放的操作指南:

text 复制代码
原始包(registry 里的精确版本)
        +
   patches/foo@1.2.3.patch
        ↓
  pnpm install 时应用
        ↓
  node_modules 里出现修复后的文件

根目录 package.json 里类似这样登记:

json 复制代码
{
  "pnpm": {
    "patchedDependencies": {
      "@expo/cli@55.0.16": "patches/@expo__cli@55.0.16.patch"
    }
  }
}

含义很具体:

  • 键是包名加精确版本,pnpm 只对这个版本套补丁。
  • 值 是补丁文件的相对路径,一般放在仓库根的 patches/。
  • 别人或 CI 执行 pnpm install 时,不需要再手动改 node_modules。

这就是「指南」的准确意思:补丁描述的是文件差异,不是运行时 hook。包版本对不上,或上下文对不上,应用就会失败。

生产环境:优先用官方命令

pnpm 官方流程(Yarn 也有类似命令):

bash 复制代码
pnpm patch @expo/cli@55.0.16

命令会抽出一份可编辑的临时目录。改完后:

bash 复制代码
pnpm patch-commit <刚才的临时目录>

它会:

  1. 对比「官方包」和「你改过的副本」。
  2. 在 patches/ 写下 .patch。
  3. 自动写入 pnpm.patchedDependencies。

日常修依赖,走这条路径最稳,路径头、文件名、清单字段都不用手填。下面之所以讲 diff -u,是因为你终究要读这些文件,偶尔也要自己生成它们。

为什么还要会 diff -u

常见理由有四个:

  • 审查 :PR 里出现 .patch,需要判断改动是否最小、是否误伤。
  • 理解失败信息 :patch does not apply 时,要能看 @@ 上下文对不对。
  • 最小手工补丁 :改动只有一行时,手写或用 diff -u 往往比走一遍交互命令更快。
  • 给上游提 PR:GitHub / GitLab 的 diff 本质也是这类格式。

+ 和 - 不是 你在编辑器里一个个敲进去的。它们是 diff 对比「旧文件 / 新文件」后打印出来的。

unified diff 逐行在说什么

下面是一份刻意写小的示例,路径已经改成 相对包根目录,这才是 pnpm 能吃的形态:

diff 复制代码
diff --git a/out/index.js b/out/index.js
--- a/out/index.js
+++ b/out/index.js
@@ -10,7 +10,7 @@
         async.read(path, getSettings(), optionsOrSettingsOrCallback);
         return;
     }
-    async.read(path, getSettings(optionsOrSettingsOrCallback), callback);
+    async.read(path, getSettings(optionsOrSettingsOrCallback));
 }
 exports.stat = stat;

逐行:

行 含义
diff --git a/... b/... Git 风格文件头。a/ 是旧文件,b/ 是新文件。pnpm 按包内相对路径定位。
--- a/out/index.js 旧文件(减号侧)。
+++ b/out/index.js 新文件(加号侧)。
@@ -10,7 +10,7 @@ hunk 头。旧文件从第 10 行起连续 7 行,新文件同样从第 10 行起 7 行。
行首空格 上下文,必须和原文件一致,patch 靠它们「卡位」。
行首 - 删掉的旧行。
行首 + 新增的新行。

几个容易误判的点:

  • 行首的 + / - / 空格是 格式的一部分,后面才是源代码。
  • 上下文对不上(上游已经改过这段),整段 hunk 都会失败。
  • 绝对路径写进文件头,例如 /tmp/foo.js.orig 或 node_modules/@scope/pkg/out/index.js,pnpm 对不上包内文件。
  • 默认上下文是 3 行。上下文太少容易误打到别处,太多则更容易在升级后失效。两者需要权衡。

@@ 可以读成:

text 复制代码
@@ -<旧文件起始行>,<旧文件行数> +<新文件起始行>,<新文件行数> @@

替换一行、行数不变时,常见就是 -10,7 +10,7。插入或删除时,两侧行数会不同。

工作流:先改文件,再生成文本

不要打开空白 .patch 手敲 + 和 -。正确顺序是:

  1. 备份原文件。
  2. 直接改「新文件」。
  3. 用 diff -u 对比备份和新文件。
  4. 把文件头改成包内相对路径。
  5. 登记到 patchedDependencies,再安装验证。

1. 备份时先建目录

cp 不会帮你创建中间目录。下面这种写法会失败:

bash 复制代码
cp node_modules/@nodelib/fs.stat/out/index.js /tmp/fs.stat/out/index.js.orig
# cp: /tmp/fs.stat/out/index.js.orig: No such file or directory

因为 /tmp/fs.stat/out/ 还不存在。应先:

bash 复制代码
mkdir -p /tmp/fs.stat/out
cp node_modules/@nodelib/fs.stat/out/index.js /tmp/fs.stat/out/index.js.orig

diff 的两个参数也必须是真实存在的路径。备份在 /tmp/fs.stat/out/index.js.orig,就不要去 diff /tmp/index.js.orig。

2. 改新文件

打开 node_modules/<pkg>/... 里真正要修的文件,改到行为正确为止。这一步和写业务代码一样,用编辑器即可。

为了演示命令,下面假设包是 @nodelib/fs.stat,相对路径是 out/index.js。真实项目里请换成你的包名和文件。

3. 生成 unified diff,并改写文件头

macOS / BSD 的 diff 可以用 -L 指定文件头标签,避免把 /tmp/... 写进补丁:

bash 复制代码
PKG="node_modules/@nodelib/fs.stat"
REL="out/index.js"
ORIG="/tmp/fs.stat/out/index.js.orig"
OUT="patches/@nodelib__fs.stat@2.1.5.patch"

mkdir -p patches
diff -u -L "a/${REL}" -L "b/${REL}" "${ORIG}" "${PKG}/${REL}" > "${OUT}"

若输出为空,说明新旧文件相同,没有可提交的变更。

打开生成的文件,确认头部类似:

diff 复制代码
--- a/out/index.js
+++ b/out/index.js

建议再补一行 Git 风格头(与 pnpm / patch-package 生态更一致):

diff 复制代码
diff --git a/out/index.js b/out/index.js
--- a/out/index.js
+++ b/out/index.js

GNU diff 若支持 --label,等价写法是:

bash 复制代码
diff -u --label a/out/index.js --label b/out/index.js "$ORIG" "$PKG/$REL"

一次改多个文件时,对每个文件生成一段 hunk,按顺序拼进同一个 .patch,或直接对两个目录做:

bash 复制代码
diff -ruN old_pkg_dir new_pkg_dir

此时更要把目录前缀裁成包内相对路径。这一步开始变烦,正是官方 pnpm patch-commit 存在的理由。

4. 完整可复制示例

把包名、版本、相对路径换成你的即可:

bash 复制代码
# 0. 变量
PKG_NAME="@nodelib/fs.stat"
PKG_VERSION="2.1.5"
REL="out/index.js"
PKG="node_modules/${PKG_NAME}"
ORIG_DIR="/tmp/manual-patch/${PKG_NAME}"
ORIG="${ORIG_DIR}/${REL}"
PATCH="patches/@nodelib__fs.stat@${PKG_VERSION}.patch"

# 1. 备份
mkdir -p "$(dirname "$ORIG")"
cp "${PKG}/${REL}" "${ORIG}"

# 2. 用编辑器修改 ${PKG}/${REL}
#    改完先跑一下相关测试或复现步骤

# 3. 生成补丁
mkdir -p patches
{
  echo "diff --git a/${REL} b/${REL}"
  diff -u -L "a/${REL}" -L "b/${REL}" "${ORIG}" "${PKG}/${REL}"
} > "${PATCH}"

# 4. 看一眼
cat "${PATCH}"

diff 在「有差异」时退出码是 1,对 shell 来说像失败。上面用 { echo; diff; } 时,如果开了 set -e,需要允许这个退出码,例如 diff ... || true,但要先确认不是「文件不存在」那种真失败。

接到 pnpm,并验证

在根 package.json 的 pnpm.patchedDependencies 里加上:

json 复制代码
"@nodelib/fs.stat@2.1.5": "patches/@nodelib__fs.stat@2.1.5.patch"

文件名没有强制格式,社区常见两种:

  • scoped 包:@scope__name@version.patch
  • 或 @scope+name@version.patch

然后重新安装,确认补丁被应用:

bash 复制代码
pnpm install

验证建议做三件事:

  1. 看安装日志里没有 patch failed。
  2. 打开 node_modules 里对应文件,确认改动还在。
  3. 再跑一遍当初失败的命令或测试。

补丁只对登记的版本生效。升到 2.1.6 后,旧补丁不会自动跟过去,需要基于新版本重新生成。

一个真实例子:Expo CLI 启错模拟器

Expo CLI 55 在「同时启动了多台 iOS Simulator」时,可能把 App 装到目标机,却到另一台设备上找 development build。根因是启动参数传了整个 manager 对象,而不是设备本身。

最小修复只有一行,包内路径是 build/src/run/ios/launchApp.js:

diff 复制代码
diff --git a/build/src/run/ios/launchApp.js b/build/src/run/ios/launchApp.js
--- a/build/src/run/ios/launchApp.js
+++ b/build/src/run/ios/launchApp.js
@@ -115,7 +115,7 @@ async function launchAppAsync(binaryPath, manager, props, appId) {
     await manager.getDefaultDevServer().openCustomRuntimeAsync('simulator', {
         applicationId: appId
     }, {
-        device
+        device: device.device
     });
 }

对应登记:

json 复制代码
"@expo/cli@55.0.16": "patches/@expo__cli@55.0.16.patch"

这类补丁符合开源协作里对「仓库内 patch」的预期:

  • 改动面积极小。
  • 绑定精确版本。
  • 能用 diff 在 PR 里直接看懂。
  • 上游一旦合并同类修复,就可以删补丁、升版本。

什么情况下不要打补丁

  • 能升级就升级。 补丁是债务。
  • 不要补传递依赖的版本。 改依赖自己的 package.json 去换包,应使用 pnpm.overrides。
  • 不要补构建产物里你看不懂的压缩代码,除非没有源码可改,且你能验证行为。
  • 二进制、图片、锁文件 不适合当文本 diff 来维护。
  • 改动超过几个文件、逻辑分叉很多 时,fork 或等上游更合适。
  • 补丁里夹带格式化、无关重构 会让下一次升级几乎必然冲突。保持最小 diff。

向上游贡献时,补丁往往只是本地证据。正式修复仍然应该是:复现步骤、失败日志、针对源码仓库的 PR,而不是让所有下游都去维护同一份 .patch。

常见失败

安装时报 sandbox / patch 失败

上下文对不上,或文件头不是包内相对路径。用 pnpm patch 重做通常比手工改 hunk 行号更省事。

补丁在你机器上有效,CI 无效

确认补丁文件已提交,patchedDependencies 已提交,包版本与键一致。pnpm 版本也尽量与 packageManager 字段一致。

升版本后静默回到旧行为

只改了 package.json 依赖版本,没重新生成补丁。patchedDependencies 的键对不上新版本,等于没打补丁。

小结

  • node_modules 里的修改默认不可复现。
  • .patch 是 unified diff,描述「相对某个精确版本改了哪些行」。
  • patchedDependencies 把这份 diff 登记进仓库,安装时重放。
  • 不要手敲 + -;先备份,再改文件,再用 diff -u 生成。
  • 文件头必须是包内相对路径,不要带 /tmp 或 node_modules/...。
  • 日常优先 pnpm patch + pnpm patch-commit;diff -u 用来理解、审查和兜底。
  • 补丁要最小,要绑版本,要准备在升级时重做;能回馈上游就回馈。

参考

如果你也在用 pnpm 维护一组依赖补丁,欢迎在评论里分享:你们是强制精确版本,还是用过范围匹配?升级依赖时,补丁失效是怎么纳入检查清单的。

相关推荐
Gizzap_Tech1 小时前
多语言官网如何兼顾统一与本地化?从页面生成到语言切换
前端
IT_陈寒1 小时前
Java缓存踩坑记:明明设置了过期时间,怎么还是读到脏数据?
前端·人工智能·后端
Initialize-le1 小时前
systemd 服务起不来?五步排查法
前端·chrome
OpenTiny社区1 小时前
多会话 + 流式输出怎么落地?TinyRobot Kit 对话数据层深度解析
前端·vue.js
派小心.2 小时前
网站统计采样数据的误差核对
大数据·服务器·前端·数据库·人工智能·小程序·数据分析
变与不变8063 小时前
防抖节流详解
开发语言·前端·javascript
用户72526496629693 小时前
浏览器为什么加载不了超大图片
前端·javascript
酷炫码神3 小时前
Day 2|CSS 盒模型:把昨天的骨架,变成能看的页面
前端·css
Amos_Web3 小时前
我做了一个 Chrome 插件,专治“这篇文章我明明看过,但重点去哪了?”
前端·浏览器