一、为什么需要差异/补丁
你有一个文档,协作用户各自改了一版。怎么合并?最粗暴的方式是把整个新文档发过去覆盖旧文档。但如果文档有 100MB,而你只改了一个字,传输整份文档就是浪费。
想象一个更具体的场景:两个人同时编辑同一份技术文档。Alice 在第 2 章加了一段话,Bob 在第 7 章改了一个术语。如果两人都用全量覆盖的方式提交修改,后提交的人就会覆盖掉先提交的人的工作。更糟糕的是,如果 Alice 和 Bob 各自基于版本 1 生成了版本 2a 和版本 2b,你无法简单地把一个覆盖到另一个上——两者的修改都有价值。这就是协作编辑中的冲突问题,而全量替换根本无法处理。
这里还有一层带宽问题。假设一个配置文件有 10 万行,某次部署只改了其中 3 行。全量推送是 10 万行,增量推送可能只有几十行。移动端同步日历、笔记类应用时,增量同步和全量同步的流量差距可能是几百 KB 和几十 MB。
同样的问题出现在前端框架里。React 每次状态更新都会生成新的虚拟 DOM 树,但浏览器操作 DOM 是昂贵的。React 的整个协调(Reconciliation)机制就是一个 diff 过程:对比前后两棵虚拟 DOM 树,找出最小变更集,只操作真正变化的 DOM 节点。没有 diff,React 的声明式 UI 编程模型就无从谈起。
Git 也面临同样的问题:每次提交之间只改了少量文件和行,存储完整快照太浪费。
Diff/Patch 模式解决的核心问题是:如何用最小的变更集描述两个序列之间的差异。Diff 计算出「哪些元素被添加、删除或修改」,Patch 将变更集应用到旧数据上还原出新数据。传输或存储的不是完整数据,而是变更集——体积小、可组合、可逆。
二、现实类比
Word 文档的修订模式。不是发送整个文档,而是只发红色标记的修改:「删除第 3 段,在第 5 段后插入这句话。」接收方把补丁应用到自己的副本上,得到最新版本。如果补丁冲突,就需要人工裁决。
这个类比还可以再推进一步:修订模式下,审阅者能看到哪些是新增(下划线标记)、哪些是删除(删除线标记),这与 diff 的 Insert 和 Delete 操作一一对应。而「接受修订」就是 patch——把变更应用到当前文档。多个审阅者的修订可能冲突,这和 Git 的合并冲突本质上是同一件事。
三、核心思想
给定旧列表和新列表,diff 算法确定哪些元素被添加、删除或移动。结果是一个补丁——一组最小变更操作。将补丁应用到旧列表即可还原出新列表。
3.1 Myers 差异算法
生产级 diff 的核心是 Myers 差异算法。它的关键思路是将 diff 问题映射到一个编辑图(Edit Graph)上:横轴是旧序列,纵轴是新序列,从左上角到右下角的最短路径就对应最小编辑脚本。
Myers 算法的精妙之处在于对角线优先搜索。在编辑图中,当旧序列和新序列的元素相同时,可以沿对角线方向免费前进(不需要任何编辑操作)。算法按编辑距离 d 逐层扩展,每一层优先沿对角线延伸尽可能远,只有无法继续时才选择向右(删除)或向下(插入)。这种策略让算法在序列相似时非常快——两个几乎相同的文件,大部分路径都在对角线上,接近 O(n) 的时间复杂度。最坏情况是 O(nd),其中 d 是编辑距离,n 是序列长度。
3.2 React 的 key-based diff 策略
React 面对的 diff 问题有其特殊性:虚拟 DOM 的子节点列表需要支持移动操作,而不仅仅是插入和删除。考虑这个场景:
// 旧列表[<li key="a">Alice</li>, <li key="b">Bob</li>, <li key="c">Charlie</li>]
// 新列表——只是把 Bob 移到了最后[<li key="a">Alice</li>, <li key="c">Charlie</li>, <li key="b">Bob</li>]如果没有 key,React 只能按位置逐个对比:位置 0 的 Alice 没变,位置 1 从 Bob 变成了 Charlie(更新),位置 2 从 Charlie 变成了 Bob(更新)。结果是需要两次 DOM 更新,而且 Charlie 和 Bob 的内部状态(比如输入框内容)会跟着错位。
有了 key 之后,React 可以识别「Bob 从位置 1 移动到了位置 2」和「Charlie 从位置 2 移动到了位置 1」,只需执行一次 DOM 移动操作,组件状态也正确保留。
key 对性能的影响是决定性的。没有 key 时,React 必须对每个旧节点在所有新节点中搜索匹配项,复杂度是 O(n²)。有了稳定的 key,React 可以先建立 key 到节点的映射表,O(1) 查找,整体复杂度降为 O(n)。这就是为什么 React 文档反复强调:key 必须稳定、唯一、可预测,绝对不要用数组索引作为 key——索引会在列表增删时变化,key 失去标识作用,diff 退化回逐位比较。
算法复杂度取决于实现方式:
| 属性 | 值 |
|---|---|
| Diff(Myers 算法) | O(nd),d = 编辑距离;相似时接近 O(n) |
| Diff(带 key 的列表) | O(n)——基于 key 匹配,避免二次搜索 |
| 应用补丁 | O(补丁大小)——每个操作 O(1) |
| 空间 | O(n)——用于编辑脚本/补丁 |
贪心 diff 的实现简单直观,但最坏情况是 O(n*m)。生产系统使用 Myers 差异算法保证最小编辑序列,React 使用基于 key 的方法专为 UI 列表协调优化。
四、变体与对比
| 模式 | 与 Diff/Patch 的关系 |
|---|---|
| 写时复制 | Diff/Patch 计算变更内容;CoW 将实际复制延迟到需要时 |
| Merkle 树 | Merkle 树识别哪些子树发生了变化,缩小 diff 比较范围 |
| 双缓冲 | React 将当前树与正在进行的双缓冲树进行差异比较 |
| 事件溯源 | 事件溯源记录每个操作;Diff 是从快照推导操作,方向相反 |
4.1 三路合并 vs 二路 diff
Git 的合并远不只是 diff。普通的 diff 是二路比较——只有旧版本和新版本。但合并时通常有三个版本:公共祖先(base)、你的修改(ours)、对方的修改(theirs)。三路合并(Three-way Merge)会分别对 base 和 ours、base 和 theirs 做 diff,然后尝试自动合并两组变更。如果两组修改了同一行,就产生冲突。
三路合并相比二路 diff 的优势在于能自动处理「一方改了、另一方没改」的情况。比如 Alice 修改了第 10 行而 Bob 没有,三路合并可以直接采用 Alice 的版本。二路 diff 做不到这一点——它只看到两个版本的第 10 行不同,无法判断该用哪个。
4.2 CRDT:实时协作的另一种思路
Diff/Patch 在处理并发编辑时有一个根本困难:如果两个人同时基于同一个版本修改,后到的补丁可能因为基础版本已经变化而无法直接应用。操作转换(OT)试图在服务端调整补丁顺序,但实现复杂。
CRDT(Conflict-free Replicated Data Type)走了另一条路:让每个操作本身满足数学性质(交换律、结合律、幂等性),无论操作以什么顺序到达,最终状态都一致。Google Docs 和 Figma 都使用 CRDT 实现实时协作。CRDT 和 Diff/Patch 不互斥——CRDT 解决并发合并,Diff/Patch 解决状态压缩,两者常常搭配使用。
4.3 Diff/Patch vs 全量替换
当超过 80% 的元素变化时,diff 的开销(计算 + 补丁大小)可能超过直接替换。这个 80% 不是精确阈值,而是经验判断:diff 需要额外的元数据(操作类型、位置信息),变更太多时元数据的开销抵消了节省的空间。当变更比例很低时,diff 的优势巨大——Git 每次提交只存差异,几十万次提交的仓库仍然紧凑。
五、多语言实现
5.1 Go 实现
type OpType int
const ( Keep OpType = iota Insert Delete)
type Op struct { Type OpType Value string}
// Diff 比较两个字符串切片,生成变更操作列表func Diff(old, new []string) []Op { var ops []Op oi, ni := 0, 0 for oi < len(old) && ni < len(new) { if old[oi] == new[ni] { ops = append(ops, Op{Keep, old[oi]}) oi++; ni++ } else if !contains(new[ni:], old[oi]) { // 旧元素在剩余新列表中不存在,标记删除 ops = append(ops, Op{Delete, old[oi]}) oi++ } else { // 新元素需要插入 ops = append(ops, Op{Insert, new[ni]}) ni++ } } // 处理剩余元素 for ; oi < len(old); oi++ { ops = append(ops, Op{Delete, old[oi]}) } for ; ni < len(new); ni++ { ops = append(ops, Op{Insert, new[ni]}) } return ops}
// Patch 将变更操作应用到旧列表,还原新列表func Patch(ops []Op) []string { var result []string for _, op := range ops { if op.Type != Delete { result = append(result, op.Value) } } return result}5.2 TypeScript 实现
type Op<T> = | { type: 'keep'; value: T } | { type: 'insert'; value: T } | { type: 'delete'; value: T };
// 比较两个列表,生成最小变更集function diff<T>( oldList: T[], newList: T[], eq: (a: T, b: T) => boolean = (a, b) => a === b,): Op<T>[] { const ops: Op<T>[] = []; let oi = 0, ni = 0;
while (oi < oldList.length && ni < newList.length) { if (eq(oldList[oi]!, newList[ni]!)) { ops.push({ type: 'keep', value: oldList[oi]! }); oi++; ni++; } else if (!newList.slice(ni).some(n => eq(n, oldList[oi]!))) { ops.push({ type: 'delete', value: oldList[oi]! }); oi++; } else { ops.push({ type: 'insert', value: newList[ni]! }); ni++; } } while (oi < oldList.length) ops.push({ type: 'delete', value: oldList[oi++]! }); while (ni < newList.length) ops.push({ type: 'insert', value: newList[ni++]! }); return ops;}
// 应用补丁重建新列表function patch<T>(oldList: T[], ops: Op<T>[]): T[] { const result: T[] = []; for (const op of ops) { if (op.type === 'keep' || op.type === 'insert') result.push(op.value); } return result;}5.3 贪心算法的局限性
这两种实现都使用贪心前向扫描策略:逐个比较元素,相同则保留,旧元素在新列表中找不到则删除,否则插入新元素。这个策略简单高效,但并不总是产生最优结果。
考虑这个例子:旧列表 [A, B, C],新列表 [C, B, A]。贪心算法会从位置 0 开始比较,发现 A != C,然后发现 A 在新列表后续位置存在,于是先插入 C,继续扫描……最终产生类似「插入 C、保留 A、删除 B、删除 C、插入 B、插入 A」的操作序列。而最优编辑脚本只需要「移动 C 到头部」——一个操作就够了。
贪心算法的问题在于它只看局部最优(当前位置怎么处理),不考虑全局最优(也许跳过当前元素、稍后再处理能产生更少的操作)。这就是为什么生产级实现(如 Git)使用 Myers 算法——它保证找到最小编辑序列,代价是实现更复杂。
5.4 基于 key 的优化
React 的做法提供了一种工程上的折中:不追求理论最优,而是利用领域知识(key 的存在)将问题简化。有了 key,不需要逐位比较,而是建立映射关系:
// 基于 key 的 diff——React 的核心思路(简化版)function keyDiff<T>( oldList: T[], newList: T[], getKey: (item: T) => string,): Op<T>[] { // 建立 key -> 旧索引 的映射 const oldKeyMap = new Map<string, { index: number; value: T }>(); oldList.forEach((item, i) => oldKeyMap.set(getKey(item), { index: i, value: item }));
const ops: Op<T>[] = []; for (const newItem of newList) { const key = getKey(newItem); if (oldKeyMap.has(key)) { const old = oldKeyMap.get(key)!; // key 相同但位置不同,标记为移动 ops.push({ type: 'keep', value: newItem }); oldKeyMap.delete(key); } else { ops.push({ type: 'insert', value: newItem }); } } // 旧列表中剩余的 key 对应删除 for (const { value } of oldKeyMap.values()) { ops.push({ type: 'delete', value }); } return ops;}这种基于 key 的方法时间复杂度是 O(n),因为映射表的查找是 O(1)。它不保证编辑脚本最短,但在 UI 更新场景下足够好——key 保证了语义正确性,O(n) 保证了性能可预测。
六、生产验证
6.1 React 的协调与 key 映射
React 的 reconcileChildrenArray(ReactChildFiber.js#L1169-L1340)是 Diff/Patch 在前端最经典的应用。它的流程大致是:
- 遍历新子节点,用 key 在旧 fiber 映射表中查找对应节点
- 找到则复用旧 fiber(可能标记为移动或更新),找不到则新建
- 遍历结束后,旧映射表中未被匹配的节点标记为删除
这个过程中,key 的作用至关重要。没有 key 时,React 只能按位置匹配,列表头部插入一个元素会导致所有后续节点被标记为更新——即使它们没有变化。有了稳定的 key,React 可以精确识别「哪个节点移动了」,只执行最少量的 DOM 操作。这也是为什么用数组索引作 key 是反模式:索引在列表变化时重新分配,key 的稳定性被破坏,diff 退化为按位置比较。
6.2 Git 的 packfile delta 压缩
Git 的 run_diff(diff.c#L5020-L5060)分派文件对比,内部使用优化版 Myers 算法(在 xdiff/ 中)。但 diff 在 Git 中的作用远不止显示变更——它是 Git 存储压缩的基础。
Git 在 git gc 或 git pack 时,会将松散的对象打包成 packfile。打包过程中,Git 会找到内容相似的 blob 对象,计算它们之间的 delta(即 diff 结果),只存储基础对象和 delta。一个修改了几个字的文件,delta 可能只有几十字节,而完整存储需要几千字节。大型仓库的 packfile delta 压缩通常能将体积缩小 70% 以上。
6.3 VS Code 的行内 diff
VS Code 的文本缓冲区差异比较用于行内 diff 显示和版本控制集成。编辑器需要在用户每次按键时实时计算差异并高亮变更行。VS Code 使用优化的行级 diff 算法,先按行比较缩小范围,再对变更区域做字符级 diff,在实时性和精确性之间取得平衡。
七、小结
何时使用:
- UI 协调——通过 diff 虚拟树最小化 DOM 变更,React 和 Vue 的核心机制
- 版本控制——计算提交之间的文件变更,从
git diff到三路合并,整个版本控制系统都建立在 diff 之上 - 协同编辑——通过操作转换或 CRDT 合并并发编辑,OT 需要 diff 识别冲突,CRDT 需要 diff 压缩状态
- 状态同步——通过网络只发送增量而非完整状态,移动端应用的增量同步几乎都依赖 diff
- 撤销/重做——用 diff 作为紧凑的撤销条目,编辑器的 undo 栈如果存完整快照,内存很快耗尽
何时不用:
- 完全不同的列表——变更超过 80% 时直接替换更高效。比如用户点击「清空购物车」,对空列表和 20 件商品的列表做 diff 没有意义,直接替换就好
- 无序集合——diff 假设顺序重要,对集合应使用交集/差集
- 实时流——逐个到达的项用增量方式比批量 diff 更自然
- 大列表无 key——没有稳定标识符时 diff 退化为 O(n²),10000 项的列表做 O(n²) 比较会让用户明显感到卡顿
八、参考资料
- An O(ND) Difference Algorithm and Its Variations - Myers 差异算法原始论文,Git 的核心算法
- React Reconciliation 文档 - React 协调算法中 key 的作用
- Git Internals - Packfiles - Git 如何用 diff/delta 压缩存储
- jsdiff 库 - JavaScript 文本差异比较库
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






