DOM diff 做为工程问题,须要具备必定算法思惟,所以常常出如今面试场景中,毕竟这是可贵出如今工程领域的算法问题。前端
不管出于面试目的,仍是深刻学习目的,都有必要将这个问题搞懂,所以前端精读咱们就专门用一个章节说清楚此问题。git
Dom diff 是全部如今框架必须作的事情,这背后的缘由是,由 Jquery 时代的面向操做过程转变为数据驱动视图致使的。github
为何 Jquery 时代不须要 Dom diff?由于 Dom diff 交给业务处理了,咱们调用 .append
或者 .move
之类 Dom 操做函数,就是显式申明了如何作 Dom diff,这种方案是最高效的,由于怎么移动 Dom 只有业务最清楚。面试
但这样的问题也很明显,就是业务心智负担过重,对于复杂系统,须要作 Dom diff 的地方太多,不只写起来繁琐,当状态存在交错时,面向过程的手动 Dom diff 容易出现状态遗漏,致使边界错误,就算你没有写出 bug,代码的可维护性也绝对算不上好。算法
解决方案就是数据驱动,咱们只须要关注数据如何映射到 UI,这样不管业务逻辑再复杂,咱们永远只须要解决局部状态的映射,这极大下降了复杂系统的维护复杂度,之前须要一个老手写的逻辑,如今新手就能作了,这是很是了不得的变化。typescript
但有利也有弊,这背后 Dom diff 就要交给框架来作了,因此是否能高效的作 Dom diff,是一个数据驱动框架可否应用于生产环境的重要指标,接下来,咱们来看看 Dom diff 是如何作的吧。数组
如图所示,理想的 Dom diff 天然是滴水不漏的复用全部能复用的,实在遇到新增或删除时,才执行插入或删除。这样的操做最贴近 Jquery 时代咱们手写的 Dom diff 性能。微信
惋惜程序没法猜到你的想法,想要精确复用就必须付出高昂的代价:时间复杂度 O(n³) 的 diff 算法,这显然是没法接受的,所以理想的 Dom diff 算法没法被使用。app
关于 O(n³) 的由来。因为左树中任意节点均可能出如今右树,因此必须在对左树深度遍历的同时,对右树进行深度遍历,找到每一个节点的对应关系,这里的时间复杂度是 O(n²),以后须要对树的各节点进行增删移的操做,这个过程简单能够理解为加了一层遍历循环,所以再乘一个 n。
如图所示,只按层比较,就能够将时间复杂度下降为 O(n)。按层比较也不是广度遍历,其实就是判断某个节点的子元素间 diff,跨父节点的兄弟节点也没必要比较。框架
这样作确实很是高效,但代价就是,判断的有点傻,好比 ac 明明是一个移动操做,却被误识别为删除 + 新增。
好在跨 DOM 复用在实际业务场景中不多出现,所以这种笨拙出现的频率实际上很是低,这时候咱们就不要太追求学术思惟上的严谨了,毕竟框架是给实际项目用的,实际项目中不多出现的场景,算法是能够不考虑的。
下面是同层 diff 可能出现的三种状况,很是简单,看图便可:
那么同层比较是怎么达到 O(n) 时间复杂度的呢?咱们来看具体框架的思路。
Vue 的 Dom diff 一共 5 步,咱们结合下图先看前三步:
如图所示,第一和第二步分别从首尾两头向中间逼近,尽量跳过首位相同的元素,由于咱们的目的是 尽可能保证不要发生 dom 位移。
这种算法通常采用双指针。若是前两步作完后,发现旧树指针重合了,新树还未重合,说明什么?说明新树剩下来的都是要新增的节点,批量插入便可。很简单吧?那若是反过来呢?以下图所示:
第一和第二步完成后,发现新树指针重合了,但旧树还未重合,说明什么?说明旧树剩下来的在新树都不存在了,批量删除便可。
固然,若是 一、二、三、4 步走完以后,指针还未处理完,那么就进入一个小小算法时间了,咱们须要在 O(n) 时间复杂度内把剩下节点处理完。熟悉算法的同窗应该很快能反映出,一个数组作一些检测操做,还得把时间复杂度控制在 O(n),得用一个 Map 空间换一下时间,实际上也是如此,咱们看下图具体作法:
如图所示,一、二、三、4 步走完后,Old 和 New 都有剩余,所以走到第五步,第五步分为三小步:
e:4 d:3 c:2 h:0
这样一个数组,下标 0 是新增,非 0 就是移过来的,批量转化为插入操做便可。最后一步的优化也很关键,咱们不要看见不一样就随便移动,为了性能最优,要保证移动次数尽量的少,那么怎么才能尽量的少移动呢?假设咱们随意移动,以下图所示:
但其实最优的移动方式是下面这样:
为何呢?由于移动的时候,其余元素的位置也在相对变化,可能作了 A 效果同时,也把 B 效果给知足了,也就是说,找到那些相对位置有序的元素保持不变,让那些位置明显错误的元素挪动便是最优的。
什么是相对有序?a c e
这三个字母在 Old 原始顺序 a b c d e
中是相对有序的,咱们只要把 b d
移走,这三个字母的位置天然就正确了。所以咱们只须要找到 New 数组中的 最长连续子串。具体的找法能够看成一个小算法题了,因为知道每一个元素的实际下标,好比这个例子中,下标是这样的:
[b:1, d:3, a:0, c:2, e:4]
肉眼看上去,连续自增的子串有 b d
和 a c e
,因为 a c e
更长,因此选择后者。
换成程序去作,能够采用动态规划,设 dp(i) 为以第 i 个字符串结尾的最长连续子串长度,一次 O(n) 循环便可。
// dp(i) = num[i] > num[i - 1] ? dp(i - 1) + 1 : 1
假设这么一种状况,咱们将 a 移到了 c 后,那么框架从最终状态倒推,如何最快的找到这个动机呢?React 采用了 仅右移策略,即对元素发生的位置变化,只会将其移动到右边,那么右边移完了,其余位置也就有序了。
咱们看图说明:
遍历 Old 存储 Map 和 Vue 是同样的,而后就到了第二步遍历 New,b
下标从原来的 1
变成了 0
,须要左移才行,但咱们不左移,咱们只右移,由于全部右移作完后,左移就等于自动作掉了(前面的元素右移后,本身天然被顶到前面去了,实现了左移的效果)。
同理,c 下标从 2
变成了 1
,须要左移才行,但咱们继续不动。
a 的下标从 0
变成 2
,终于能够右移了!
后面的 d、e 下标没变,就不用动。咱们纵观总体能够发现,b 和 c 由于前面的 a 被抽走了,天然发生了左移。这就是用一个右移代替两个左移的高效操做。
同时咱们发现,这也确实找到了咱们开始提到的最佳位移策略。
那这个算法真的有这么聪明吗?显然不是,这个算法只是歪打误撞碰对了而已,有用右移替代左移的算法,就有用左移替代右移的算法,既然选择了右移替代左移,那么必定丢失了左移代替右移的效率。
何时用左移代替右移效率最高?就是把数组最后一位移到第一位的场景:
显然左移只要一步,那么右移就是 n-1 步,在这个例子就是 4 步,咱们看右移算法图解:
首先找到 e,位置从 4
变成了 0
,但咱们不能左移!因此只能保持不动,悲剧今后开始。
虽然算法已经不是最优了,但该作的仍是要作,其实以前有一个 lastIndex 概念没有说,由于 e 已经在 4
的位置了,因此再把 a 从 0
挪到 1
已经不够了,此时 a 应该从 0
挪到 5
。
方法就是记录 lastIndex = max(oldIndex, newIndex)
=> lastIndex = max(4, 0)
,下一次移动到 lastIndex + 1
也就是 5
:
发现 a 从 0
变成了 5
(注意,此时考虑到 lastIndex 因素),因此右移。
同理,b、c、d 也同样。咱们最后发现,发生了 4 次右移,e 也由于天然左移了 4 次到达了首位,符合预期。
因此这是一个有利有弊的算法。新增和删除比较简单,和 Vue 差很少。
PS:最新版 React Dom diff 算法若有更新,欢迎在评论区指出,由于这种算法看来不如 Vue 的高效。
Dom diff 总结有这么几点考虑:
讨论地址是: 精读《DOM diff 原理详解》· Issue #308 · dt-fe/weekly
若是你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
版权声明:自由转载-非商用-非衍生-保持署名( 创意共享 3.0 许可证)