使用 unified diff 为 pnpm 依赖编写可复现补丁
第三方依赖出了问题,最容易做的事是打开 node_modules 改一行。下一次 pnpm install 之后,改动通常会消失。开源协作里更麻烦:同事、CI、新克隆的仓库都看不到你那次本地修改。
pnpm 的 patchedDependencies 解决的就是这件事:把对某个精确版本的修改,收成仓库里的一份 .patch。安装时自动重放。.patch 本身不是配置文件,而是一份 unified diff------Unix 世界里用了几十年的变更描述格式。
本文分三层写:
- 为什么要补丁,以及它在开源协作里处在什么位置。
- unified diff 每一行在说什么。
- 如何「先改文件,再用
diff -u生成文本」,并接到 pnpm。
生产环境优先用官方的 pnpm patch / pnpm patch-commit。学会 diff -u,是为了能读懂、能审查、能在必要时手写最小补丁。
前言:补丁是权宜之计,不是日常开发方式
给依赖打补丁,在开源社区通常排在后面:
- 升级到已修复版本。
- 向上游提 Issue / PR,让修复进入主线。
- 短期无法升级时,再在自己的仓库里打一枚 最小、可复现、绑死版本 的补丁。
补丁的价值是可审查、可分发、可回滚。成本是:上游一升级,补丁可能失效,必须重做。仓库里如果已经有 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 <刚才的临时目录>
它会:
- 对比「官方包」和「你改过的副本」。
- 在
patches/写下.patch。 - 自动写入
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 手敲 + 和 -。正确顺序是:
- 备份原文件。
- 直接改「新文件」。
- 用
diff -u对比备份和新文件。 - 把文件头改成包内相对路径。
- 登记到
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
验证建议做三件事:
- 看安装日志里没有
patch failed。 - 打开
node_modules里对应文件,确认改动还在。 - 再跑一遍当初失败的命令或测试。
补丁只对登记的版本生效。升到 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 维护一组依赖补丁,欢迎在评论里分享:你们是强制精确版本,还是用过范围匹配?升级依赖时,补丁失效是怎么纳入检查清单的。