浅谈React中的diff

5,037 阅读8分钟

简介

diff算法在React中处于主导地位,是React V-dom和渲染的性能保证,这也是React最有魅力、最吸引人的地方。
React一个很大一个的设计有点就是将diff和V-dom的完美结合,而高效的diff算法可以让用户更加自由的刷新页面,让开发者也能远离原生dom操作,更加开心的撸代码。
但总所周知,普适diff的复杂度对于大量dom对比会出现严重的性能问题,React团队对diff的优化可以让React能够在服务端渲染,到底React的diff做了什么优化呢?本文来简单探讨一下!

React的diff策略

  1. 策略一:忽略Web UI中DOM节点跨层级移动;
  2. 策略二:拥有相同类型的两个组件产生的DOM结构也是相似的,不同类型的两个组件产生的DOM结构则不近相同
  3. 策略三:对于同一层级的一组子节点,通过分配唯一唯一id进行区分(key值) 在Web UI的场景下,基于以上三个点,React对tree diff、component diff、element diff进行优化,将普适diff的复杂度降低到一个数量级,保证了整体UI界面的构建性能!

三个优化

tree diff

基于策略一,React的做法是把dom tree分层级,对于两个dom tree只比较同一层次的节点,忽略Dom中节点跨层级移动操作,只对同一个父节点下的所有的子节点进行比较。如果对比发现该父节点不存在则直接删除该节点下所有子节点,不会做进一步比较,这样只需要对dom tree进行一次遍历就完成了两个tree的比较。
==那么对于跨层级的dom操作是怎么进行处理的呢?==下面通过一个图例进行阐述

两个tree进行对比,右边的新tree发现A节点已经没有了,则会直接销毁A以及下面的子节点B、C;在D节点上面发现多了一个A节点,则会重新创建一个新的A节点以及相应的子节点。
具体的操作顺序:create A → create B → creact C → delete A。

优化建议

保证稳定dom结构有利于提升性能,不建议频繁真正的移除或者添加节点

component diff

React应用是基于组件构建的,对于组件的比较优化侧重于以下几点:
1. 同一类型组件遵从tree diff比较v-dom树 2. 不通类型组件,先将该组件归类为dirty component,替换下整个组件下的所有子节点 3. 同一类型组件Virtual Dom没有变化,React允许开发者使用shouldComponentUpdate()来判断该组件是否进行diff,运用得当可以节省diff计算时间,提升性能

如上图,当组件D → 组件G时,diff判断为不同类型的组件,虽然它们的结构相似甚至一样,diff仍然不会比较二者结构,会直接销毁D及其子节点,然后新建一个G相关的子tree,这显然会影响性能,官方虽然认定这种情况极少出现,但是开发中的这种现象造成的影响是非常大的。

优化建议

对于同一类型组件合理使用shouldComponentUpdate(),应该避免结构相同类型不同的组件

element diff

对于同一层级的element节点,diff提供了以下3种节点操作:
1. INSERT_MARKUP 插入节点:对全新节点执行节点插入操作 2. MOVE_EXISING 移动节点:组件新集合中有组件旧集合中的类型,且element可更新,即组件调用了receiveComponent,这时可以复用之前的dom,执行dom移动操作 3. REMOVE_NODE 移除节点:此时有两种情况:组件新集合中有组件旧集合中的类型,但对应的element不可更新、旧组建不在新集合里面,这两种情况需要执行节点删除操作

key值diff中重要性

一般diff在比较集合[A,B,C,D]和[B,A,D,C]的时候会进行全部对比,即按对应位置逐个比较,发现每个位置对应的元素都有所更新,则把旧集合全部移除,替换成新的集合,如上图,但是这样的操作在React中显然是复杂、低效、影响性能的操作,因为新集合中所有的元素都可以进行复用,无需删除重新创建,耗费性能和内存,只需要移动元素位置即可。 React对这一现象做出了一个高效的策略:允许开发者对同一层级的同组子节点添加唯一key值进行区分。意义就是代码上的一小步,性能上的一大步,甚至是翻天覆地的变化!

==重点来了,React通过key是如何进行element管理的呢?为何如此高效?==

算法改进:
React会先进行新集合遍历,for(name in nextChildren),通过key值判断两个对比集合中是否存在相同的节点,即if(prevChild === nextChild),如何为true则进行移动操作,在此之前,需要执行被移动节点在新旧(child._mountIndex)集合中的位置比较,if(child._mountIndex < lastIndex)为true时进行移动,否则不执行该操作,这实际上是一种顺序优化,lastIndex是不断更新的,表示访问过的节点在集合中的最右的位置。若当前访问节点在旧集合中的位置比lastIndex大,即靠右,说明它不会影响其他元素的位置,因此不用添加到差异队列中,不执行移动操作,反之则进行移动操作。

下图示例:

  • nextIndex = 0,lastIndex = 0,从新集合中获取B,在旧集合中发现相同节点B,旧集合中:B._mountIndex = 1,child._mountIndex < lastIndex ==> false,不执行移动操作,更新lastIndex = Math.max(prevChild._mountIndex, lastIndex), prevChild._mountIndex === B._mountIndex ==> true,更新B在新集合中的位置:prevChild._mountIndex = nextIndex,在新集合中:B._mountIndex = 0,nextIndex++,进行下一个节点判断。

  • nextIndex = 1,lastIndex = 1,从新集合中获取A,在旧集合中发现相同节点A,旧集合中:A._mountIndex = 0,child._mountIndex < lastIndex ==> true,对A进行移动操作enqueueMove(this, child._mountIndex, toIndex),toIndex是A要被移动到的位置,更新lastIndex = Math.max(prevChild._mountIndex, lastIndex),更新A在新集合中的位置prevChild._mountIndex = nextIndex,在新集合中:A._mountIndex = 1,nextIndex++,进行下一个节点判断。

  • nextIndex = 2,lastIndex = 1,从新集合中获取D,在旧集合中发现相同节点D,旧集合中:D._mountIndex = 3,child._mountIndex < lastIndex ==> false,不执行移动操作,更新lastIndex = Math.max(prevChild._mountIndex, lastIndex), prevChild._mountIndex === D._mountIndex ==> true,更新D在新集合中的位置:prevChild._mountIndex = nextIndex,在新集合中:D._mountIndex = 2,nextIndex++,进行下一个节点判断。

  • nextIndex = 3,lastIndex = 3,从新集合中获取C,在旧集合中发现相同节点C,旧集合中:C._mountIndex = 2,child._mountIndex < lastIndex ==> true,对C进行移动操作enqueueMove(this, child._mountIndex, toIndex),toIndex是C要被移动到的位置,更新lastIndex = Math.max(prevChild._mountIndex, lastIndex),更新C在新集合中的位置prevChild._mountIndex = nextIndex,在新集合中:A._mountIndex = 3,nextIndex++,进行下一个节点判断。

    • 由于是最后一个节点,diff操作完成

==那么,除了有可复用节点,新集合当有新插入节点,旧集合有需要删除的节点呢?==
下图示例:

对于这种情况,React则是执行以下步骤:

  • nextIndex = 0,lastIndex = 0,从新集合中获取B,在旧集合中发现相同节点B,旧集合中:B._mountIndex = 1,child._mountIndex < lastIndex ==> false,不执行移动操作,更新lastIndex = 1,更新B在新集合中的位置,nextIndex++,进行下一个节点判断。
  • nextIndex = 1,lastIndex = 1,从新集合中获取E,在旧集合中没有发现相同节点E,nextIndex++进入下一个节点判断。
  • nextIndex = 2,lastIndex = 1,从新集合中获取C,在旧集合中发现相同节点C,旧集合中:C._mountIndex = 2,child._mountIndex < lastIndex ==> false,不对C进行移动操作,更新lastIndex = 2,更新C在新集合的位置,nextIndex++,进行下一个节点判断。
  • nextIndex = 3,lastIndex = 2,从新集合中获取A,在旧集合中发现相同节点A,旧集合中:A._mountIndex = 0,child._mountIndex < lastIndex ==> true,对A进行移动操作,更新lastIndex = 2,更新A在新集合中的位置,nextIndex++进入下一个节点判断。
  • 当完成新集合所有节点中的差异对比后,对旧集合进行遍历,判读旧集合中是否存在新集合中不存在的节点,此时发现D节点符合判断,执行删除D节点的操作,diff操作完成。

优化后diff的不足

世上没有百分之百完美算法,React的diff也有自己的不足之处,比如新旧集合元素全部可以复用,只是新集合中将旧集合最后一个元素放到了第一个位置,短板就会出现 下图示例:

按照上述顺序优化,则旧集合中D的位置是最大的,最少的操作只是将D移动到第一位就可以了,实际上diff操作会移动D之前的三个节点到对应的位置,这种情况会影响渲染的性能。

优化建议

在开发过程中,同层级的节点添加唯一key值可以极大提升性能,尽量减少将最后一个节点移动到列表首部的操作,当节点达到一定的数量以后或者操作过于频繁,在一定程度上会影响React的渲染性能。比如大量节点拖拽排序的问题。

总之,React为我们提供优秀的diff算法,使我们能够在实际开发中happy的撸代码,但也不是说可以“随意”去构建我们的应用,根据diff的特点,在具体场景中取长补短,规避一些算法上面的短板也是有利于提升应用整体的性能。

参考资料:

  • 《深入React技术栈》陈屹 ——3.5章节