Diff 原理:只改需要改的地方
🎯 引言
前面我们一直在说"对比新旧两棵树,只更新差异",这个过程就是 Diff。这篇我们揭开它的面纱,理解 Snabbdom 对比虚拟节点树的核心策略。学完之后再回头看 Vue 的更新机制,你会有豁然开朗的感觉。
🧱 什么是 Diff
Diff 就是对比新旧两棵虚拟节点树,找出最小更新方案的过程。
一个朴素的想法是:新树的每个节点都和旧树的所有节点比一遍,复杂度高达 O(n³)——页面有 1000 个节点就要计算 10 亿次,显然不可用。
Snabbdom 通过几条"牺牲完美、换取高效"的规则,把复杂度降到了 O(n):
- 同层比较:只对比同一层级的节点,不跨层。
- sameVnode 判断:先判断两个节点是不是"同一个"。
- 分类处理:按新节点的内容走不同的更新逻辑。
⚖️ 规则一:同层比较
Diff 只做同层级的对比。如果旧树的 div 下有个 p,新树把这个 p 挪到了 section 下,Snabbdom 不会尝试"识别并移动"它,而是直接销毁旧的、创建新的。
为什么?因为跨层移动在真实页面中极少发生,为它做全局识别的成本太高。用极端情况下的性能,换日常场景的极致速度,这就是 Diff 的设计哲学。
⚖️ 规则二:sameVnode 判断
两个节点要不要"精细对比",先看它们是不是同一个节点。判断条件只有两条:
function sameVnode(a, b) {
return a.sel === b.sel && a.key === b.key;
}
- sel 相同:选择器(标签、id、class)一致。
- key 相同:都没设置 key(都是
undefined),或 key 相等。
这就是上一篇 key 如此重要的原因:key 直接参与"是不是同一个节点"的判断。
判断结果决定更新路径:
sameVnode为 true → 进入patchVnode,精细对比内容。sameVnode为 false → 直接销毁旧节点,创建新节点替换。
⚖️ 规则三:patchVnode 的更新规则
确认是"同一个节点"后,进入 patchVnode。这里有一个大前提必须先记住:能走到这一步,说明这个节点在新旧两棵树上都存在,元素本身是要保留的,要处理的只是它的"内容"。
Snabbdom 按新节点的内容分几种情况处理:
| 新节点情况 | 旧节点情况 | 操作 |
|---|---|---|
有 text | 无论之前是什么 | 直接设置 textContent 为新文本 |
有 children | 旧也有 children | 进入 updateChildren,双端对比 |
有 children | 旧有 text | 清空文本,创建所有子节点插入 |
| 无 text 无 children | 旧有 children | 逐个删除旧子节点 |
| 无 text 无 children | 旧有 text | 清空文本 |
表格最后两行可能让你困惑:新节点都是空的了,为什么不直接把这个节点删掉,还要费劲地删子节点、清文本?
原因就在上面的大前提里:"新节点无 text 无 children"的意思是——这个元素在新树里还在,只是变成空元素了。比如:
// 更新前:div 里有两个段落
h('div', [h('p', '甲'), h('p', '乙')]);
// 更新后:div 还在,只是内容空了
h('div');
正确的渲染结果是一个空的 <div></div>:div 必须留下,两个 p 被移除。如果把 div 整个删掉,页面上就平白少了一块,和新树描述的结构对不上了。
那什么时候才会真正删除节点?只有两种情况:
sameVnode为 false:这个节点在新树里已经"换人了",旧节点整个被替换。- 子节点对比结束后,旧子节点数组还有剩余:多出来的这些子节点在新树里不存在了,才会被删除。
另外重点看表格第一行:只要新节点是文本,就用 textContent 整体覆盖。这解释了上一篇实验一中"li 文字被改写"的现象——无 key 时新旧 li 被判定为 sameVnode,然后文本被直接覆盖。
🧠 updateChildren:双端指针对比
当新旧节点都有 children 时,进入最精彩的 updateChildren。纯文字描述很容易看晕,我们直接用一个例子走一遍:把 [苹果, 香蕉, 橙子] 更新成 [苹果, 橙子, 香蕉](三个 li 都带 key,key 就是水果名)。
指针是什么
"指针"就是一个位置标记。Snabbdom 在旧、新两个子节点数组上各放两个标记,分别指向当前还没处理完的第一个(头)和最后一个(尾):
旧: [苹果] [香蕉] [橙子]
↑旧头 ↑旧尾
新: [苹果] [橙子] [香蕉]
↑新头 ↑新尾
每一轮,Snabbdom 把四个指针指向的节点按固定顺序做四次比较,命中一次就处理、移动指针,然后进入下一轮:
| 顺序 | 比较 | 命中说明 | 处理方式 |
|---|---|---|---|
| ① | 旧头 vs 新头 | 开头没变 | 更新内容,两个头指针后移 |
| ② | 旧尾 vs 新尾 | 结尾没变 | 更新内容,两个尾指针前移 |
| ③ | 旧头 vs 新尾 | 旧的开头被移到了最后 | 更新内容,把它的真实元素移到末尾 |
| ④ | 旧尾 vs 新头 | 旧的结尾被移到了最前 | 更新内容,把它的真实元素移到开头 |
四次都不命中,就查旧子节点的 key 映射表:新头节点的 key 在旧数组里有,就复用并移过来;没有,就新建一个。
第一轮:命中 ①
旧: [苹果] [香蕉] [橙子]
↑旧头 ↑旧尾
新: [苹果] [橙子] [香蕉]
↑新头 ↑新尾
先比 ①:旧头"苹果"和新头"苹果"的 sel、key 都相同,命中!更新"苹果"的内容(本例没变化),然后两个头指针后移一位。
第二轮:命中 ③
旧: [香蕉] [橙子]
↑旧头 ↑旧尾
新: [橙子] [香蕉]
↑新头 ↑新尾
① 旧头"香蕉" vs 新头"橙子":不同。② 旧尾"橙子" vs 新尾"香蕉":不同。③ 旧头"香蕉" vs 新尾"香蕉":命中!
③ 命中说明"香蕉"被挪到了最后。于是更新它的内容,并把"香蕉"对应的真实 li 直接移动到页面末尾——注意,是移动原来的元素,不是新建。然后旧头指针后移、新尾指针前移。
第三轮:命中 ①,收工
旧: [橙子]
↑旧头(旧尾也是它)
新: [橙子]
↑新头(新尾也是它)
命中 ①,更新"橙子"。两个数组都处理完了,对比结束。
回顾整个过程
把 [苹果, 香蕉, 橙子] 变成 [苹果, 橙子, 香蕉],Snabbdom 总共只做了一次 DOM 移动(把"香蕉"的 li 移到末尾),没有改写任何文字、没有新建任何元素。
对比一下:如果这三个 li 没有 key,第二轮的 ① 会把"香蕉"和"橙子"误判为同一个节点(sel 都是 li、key 都是 undefined),结果就是按位置就地复用、两处文字都被改写。这就是双端指针 + key 带来的精准更新。
🧾 小节总结
- Diff 是对比新旧虚拟节点树、计算最小更新方案的过程,Snabbdom 将其复杂度降到 O(n)。
- 只同层比较,不处理跨层移动。
sameVnode要求sel和key都相同:相同才精细更新,否则整体替换。- 新节点有文本时直接用
textContent覆盖,是最高频的更新路径。 - 子节点对比采用双端指针:头头、尾尾、头尾、尾头四次比较,最后用 key 映射表兜底。
❓ 知识问答
Q1:Diff 和浏览器渲染是什么关系?
A:Diff 算出的差异会立即转成真实的 DOM 操作(增删改元素),浏览器再根据 DOM 变化重排重绘。Diff 的意义就是让 DOM 操作尽可能少。
Q2:为什么新节点有 text 就直接覆盖?
A:text 和 children 互斥。新节点是文本,就意味着旧内容(无论文本还是子元素)都没用了,整体覆盖是最快的做法。
Q3:双端指针每一轮都要比较四次吗?
A:每一轮最多按顺序尝试四种比较,命中一个就处理并移动指针,都不命中才查 key 映射表。比较虽多,但每一步都是 O(1)。
Q4:Vue3 的 Diff 和这里讲的一样吗?
A:思想同源,都有 sameVnode 判断和双端对比,但 Vue3 做了很多优化(如编译时标记动态节点、用最长递增子序列减少移动)。学好本篇再去看 Vue3,会轻松很多。
🧪 小练习
不查资料,用表格推演出下面这次更新的完整过程:旧子节点为 [A(key:a), B(key:b), C(key:c)],新子节点为 [B(key:b), C(key:c), A(key:a)],写出每一轮命中的是哪次比较、做了什么操作。
旧: A B C
新: B C A
# 请在这里写出推演过程(提示:第一轮命中的是"旧头 vs 新尾")
🎉 恭喜你已经理解了 Diff 的核心原理!下一篇是收官实战:亲手写一个迷你虚拟 DOM,把整门课程串起来。
