之前写过一篇关于React key的文章,文章里提到了reconcile阶段的Dom Diff过程,但没有细讲,今天就和大家一起探索下在React中,Dom diff到底做了哪些事。
名词解析
首先我们需要理清一些名词的具体含义
- virtual dom
- jsx
- react element
- fiber
virtual dom可能大家在很多的文章中都听到过,可以理解为用js对象来描述dom,但它不是真实的dom。在react中,其实是不存在virtual dom这种定义的,摘录一段Dan Abramov对于近几年不再提起“virtual DOM”的解释。
I wish we could retire the term “virtual DOM”. It made sense in 2013 because otherwise people assumed React creates DOM nodes on every render. But people rarely assume this today. “Virtual DOM” sounds like a workaround for some DOM issue. But that’s not what React is.
React is “value UI”. Its core principle is that UI is a value, just like a string or an array. You can keep it in a variable, pass it around, use JavaScript control flow with it, and so on. That expressiveness is the point — not some diffing to avoid applying changes to the DOM.
总结一下,virtual DOM在手动操作dom的年代是一种全新的尝试,所以取了这么个名字让大家意识到React带来的改变。但是,用来描述真实的dom的,其实是react element。它的结构大概是这样子👇🏻
{
$$typeof: Symbol(react.element),
key: null,
props: {},
ref: null,
type: ƒ AppFunc(),
_owner: null,
_store: {validated: false},
_self: null,
_source: null
}
可能大家又有疑问了,这种数据结构我都没怎么见过,它是如果生成的呢,那我再给一个例子,大家就会清晰了👇🏻
<AppFunc />
上图就是大家在写react时经常会写的内容,它就是我们经常提到的jsx语法,记住一点,这里的div并不完全是我们html里的div。在jsx里,我们可以写div、p、span等标签,也可以写我们自定义的函数,例如,当他们被转化为react element后,就会在type字段上有所区分。
jsx👇🏻:
<div>
<App>
<div />
<div />
<div />
<div />
<div />
<div />
</App>
</div>
React Element👇🏻
jsx👇🏻:
<Yrr />
React Element👇🏻
到此,我们纠正了vitrual dom的说法,也理清了jsx与react element之间的关系,接下来就改目光放到fiber上来。
正如大家听到过的一样,react16将原先的reconcile过程从栈结构改成了fiber架构。目的就是支持可中断、为未来更多的特性做好底层支撑。fiber有着区别与react element的结构,每一个react element,都会有一个对应的fiber节点,其结构大致如下👇🏻
我们在页面中看到的内容,是由具体的dom树呈现的,dom树又有对应的react element树,除此之外,还有一颗fiber树,它保存着组件的props、state、以及该被如何操作等信息。fiber中通过return及child保持父子节点之间的相连,同级节点之间通过sibling相连,这部分不做展开。 至此,我们介绍完了virtual dom、jsx、react element、fiber等含义,让我们把目光重新回到Dom Diff。
Dom Diff
关于Dom Diff,我们先问自己这几个问题?为什么要比?什么时候比?拿谁和谁比?比较完之后,我们希望得到的又是什么呢? 关于为什么要比,大家应该都清楚了,因为直接操作dom消耗太大,所以我们需要通过“省力”的对象比较来减少dom操作。
Diff时机
那就让我们来弄清楚Diff的时机:只有当发现一次新的渲染时,才有diff的必要。这个也好理解,如果是首次渲染,则是没有优化的空间的,每一步dom操作都需要执行。
Diff对象
当新渲染发生时,我们手里有哪些东西可供比较呢?其实是由老渲染的fiber节点,和新渲染得出的react element进行比较(后面会分析原因)。老渲染的fiber形成的树,代表的是已经在页面上看到的内容,而新渲染得出的react element形成的树,就是我们即将要绘制成的画面。我们做的就是这两颗树之间的比较,来得出一颗新的fiber树。 如果直接是树与树之间比较,复杂度是O(n^3),这显然不是浏览器能够承受的复杂度。结合常用的页面变化,react只做同层级的比较,且比较时遵循这两个前提:
- 不同类型的节点会产生不同的树;
- 多个节点的情况下,用key来做唯一性区分;
在这样的比较原则下,复杂度仅有O(n),且覆盖了前端绝大部分的页面变化,保证diff结果正确的同时,大大降低了所需时间。
Diff过程
在探索过程之前,我们先回答第四个问题,我们希望比了之后,得到什么? 回到引入dom diff这项技术的目的,我们是希望找出最小的变化,通过最少的dom操作,对页面进行更新。也就是说我们想要知道的是,哪些节点需要更新,哪些节点需要删除,哪些节点需要新增,而这些更新、删除、新增的操作,全部存放在fiber节点的flag字段中。所以,我们想要的结果是,一颗带着各自的操作标记的fiber树。 这也解释了第二步中,我们是拿老的fiber节点,和新渲染的react element进行比较。
对于给fiber打标记,我们简单举两个例子:
- 当我们发现新的react element的类型和原来的fiber中保存的类型不一致,说明组件类型发生了变化,我们就会新增一个全新的fiber节点,并为它的flag字段打上需要新建的标签;同时为老的fiber节点打上需要删除的标签
- 当发现类型一致时,我们回去判断新渲染中react element收到的props和fiber中存储的props是否一致,如果有变化,就为fiber节点打上需要更新的标签;
宏观上来看,想上述过程这样一级一级比下来,等到整颗树被遍历完,我们就得到了一颗新的fiber树,树中有些节点和旧渲染一模一样,有些节点被打上了需要更新、需要删除、需要新建的标签,这就是dom diff最终的产出。后续的渲染器,只需根据fiber上的标签,对dom节点进行调整,便可将结果渲染到页面上。
讲到这里,大家可能会有疑问,Diff的过程还是没讲清楚啊,而且react中这么有名的key,怎么还没有提到啊,不要急,我们接下来就开始分析,key在Dom Diff过程中到底扮演了怎样的作用。
key作为react内部使用到的一个属性,是专门用来做Dom Diff,且其优先级甚至比组件type更高。
通过分析源码,我们发现react在Diff时,会根据子节点数量进行区分处理:
- 新的渲染,如果是单个节点的话,按单个节点的方法进行处理,记为 reconcileSingleElement
- 新的渲染,如果是多个节点的话,按多个节点的方法进行处理,记为 reconcileChildrenArray
单节点Diff
我们先观察简单一些的单节点diff的情况,截取源码中的一部分进行分析👇🏻
我们可以发现,react的判断优先级是先key再组件type,所以key在Diff过程中优先级非常高
- 如果key相等才会继续进行type的判断,如果type再相等,则认为可以复用原有的fiber节点,后续我们不展开;
- 如果key不相等,会把旧渲染的fiber直接删除,并为新渲染生成一个新的fiber节点;
多节点Diff
react的多节点diff算法流程如下:(为方便记忆,我们将老节点记为oldChildren,新节点记为newChildren)
- 进行第一轮遍历,老节点的第0个与新节点的第0个进行比较,依次进行。如果发现key不同,直接退出此次循环
- 完成第一轮遍历后,分以下几种情况
- 新节点老节点都没了(这是最理想的情况,完成遍历,退出diff)
- 新节点遍历完了,老节点还有(部分老节点还需要处理,进入第二阶段)
- 老节点遍历完了,新节点还有(部分新节点还需要处理,进入第二阶段)
- 新节点老节点都还有(部分新老节点需要处理,准备进入第二轮阶段)
- 进入第二阶段,其步骤如下:
- 只剩下老节点:把老节点全部标记为需要删除,退出diff
- 只剩下新节点:为每个节点新建fiber,退出diff
- 新老节点都有剩余:
- 将还未配对的老节点存储起来,例如以key为键,fiber为值进行存储(react源码中用的是Map结构)
- 遍历剩余的新节点,如果在老节点中存在,又按以下流程遍历:
- 设置lastIndex变量,初始为0;
- 找到老节点的索引,记为oldIndex;新节点的索引我们记为newIndex
- 如果oldIndex<lastIndex,将节点标记为需要移动
- 将lastIndex设置为oldIndex、newIndex、lastIndex三者最大的那一个,继续下一个节点的遍历
- 如果新节点在老节点中不存在,则直接新建,且打上需要移动的标签,继续下一个节点的遍历
至此,多节点Diff之后,每个节点都知道自己该怎么处理。
Diff结果
从根节点开始,对每一层的层级,根据节点情况,进行单节点diff或多节点diff后,我们就得到了我们最终需要的fiber树,至此diff过程结束,我们就可以拿着fiber树对dom进行改动了。