调和:指的是将虚拟DOM映射到真实DOM的过程。
Virtual DOM 是一种编程概念,在这个概念里,UI以一种理想化的,或者说“虚拟的”表现形式被保存于内存中,并通过如ReactDOM等类库使之与“真实的”DOM同步,这一过程叫作协调(调和) -- React 官网
调和是“使一致”的过程,而Diff是“找不同”的过程,它只是“使一致”过程中的一个环节。
React源码中Reconciler(调和器)所做的工作是一系列的,包括组件的挂载、卸载、更新过程,其中更新过程涉及对Diff算法的调用。
要想找出两个树结构之间的不同,传统的方法是通过循环递归进行树节点的一一比对,这个过程的算法复杂度是O(n^3)。
具体来说,若一个页面中有100个节点,100^3算下来就有10万次操作了,这还只是一次Diff的开销。然而OJ中相对理想的时间复杂度是O(1)或者O(n)。React团队从设计层面总结了两条规律为O(n^3) -> O(n)确立了前提:
- 若两个组件属于同一类型,那么他们将拥有相同的DOM树形结构;
- 处于同一层级的一组子节点,可通过设置key作为唯一标识,从而维持各个节点在不同渲染过程中的稳定性;
- DOM节点之间的跨层级操作不多,同层级操作是主流
- Diff算法性能突破关键在于“分层对比”;
- 类型一致的节点才有继续Diff的必要;
- key属性的设置,可以帮我们尽可能重用同一层级内的节点
1. 分层对比
React的Diff过程直接放弃了跨层级的节点比较,它只针对相同层级的节点作比较,如下图。这样一来,只需要从上到下的一次遍历,就可以完成对整棵树的对比。
需要注意的是:虽然栈调和将传统的树对比算法优化为了分层对比,但整个算法仍是以递归的形式运转,分层递归也是递归
如果真的发生了跨层级操作(比如将以B节点为根节点的子树从A节点下面移动到C节点下,如下图),在这种情况下React并不能够判断出“移动”这个行为,它只能机械的认为移出子树那一层的组件消失了,对应子树需要被销毁,而移入子树的那一层新增了一个组件,需要重新为其创建一棵子树。
销毁 + 创建的代价是昂贵的,因此React官方也建议开发者尽量不要做跨层级操作,尽量保持DOM结构的稳定性
2. 类型的一致性决定递归的必要性
只有同类型的组件,才有进一步对比的必要。若参与Diff的两个组件类型不同,那么直接放弃比较,原地替换掉旧的节点,如下图。只有确认组件类型相同后,React才会在保留组件对应DOM树(子树)的基础上,尝试更深层Diff。
3. key属性
key 是用来帮助 React 识别哪些内容被更改、添加或者删除。key 需要写在用数组渲染出来的元素内部,并且需要赋予其一个稳定的值。稳定在这里很重要,因为如果 key 值发生了变更,React 则会触发 UI 的重渲染。这是一个非常有用的特性。
试图解决同层级下节点重用问题,假如有如下图情况:
A组件在保持类型和其它属性不变的情况下,在两个子节点B和D之间插入新节点C,按照已知的Diff规则,两棵树之间的Diff过程应该是这样的:
- 首先对比位于第一层的节点,发现两棵树的节点类型一致,于是进一步Diff;
- 开始对比位于第二层的节点,第一个接受比较的就是B位置,也一致;
- 第二个接受比较的是D位置,对比D和C,前后类型不一致,直接删掉D,重建C;
- 第三个接受比较的是E节点位置,E和D类型不一致,也删掉E,重建D;
- 最后比较的是第二棵树的E位置,在第一棵树里没有,直接新建E节点
无法重用B、D、E三个节点,这时候就要借助key属性:
const todoItems = todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))
如果忘记写key,React并不会报错,当我们没有设置key时,Diff过程确实如上所述。但只要加上key,key可以帮助React记住某个节点,从而在后续的更新中实现对这个节点的追踪。比如上图的两棵树,我们给每个节点增加一个key,如下图:
这个key就充当了每个节点的唯一标识,有了这个标识后,当C被插入到B和D之间,React并不会再认为C、D、E这三个节点都需要重建--它会通过识别唯一标识,意识到E、E并没有发生改变(D的仍旧是1,E的仍旧是2),而只是被调整了顺序。这也就能轻松地重用“追踪”到的节点,将D、E移动到新的位置上,并完成对C的插入,使同层级下的元素操作成本降低。