React diff 原理和部分源码解析

前言

起初我接触前端是因为在公司活干完太无聊了,刚好那时候 React-Native 出来了,就学习了一波,那时候最令我惊讶的时候可以直接setState去更新,只更新差异部分,而且效率那么高。

大家都说 React Vue 这些都是用了 Virtual DOM 来 diff 所以提升了效率,但是怎样提升呢?本次源码基于React15

diff 改进

传统 diff 去判断一个树形结构转换成另一个树形结构,每一个节点的对比循环,这个过程开销十分大,而 React 的做法是:

  • Web 中 DOM 跨层级移动特别少,放弃 DOM 跨层级移动判断,只判断同层级
  • 相同树形结构的组件特别少,也放弃对比组件中的树形结构
  • 同一层及的子 DOM,它们通过唯一 id 来提升效率

大概就是这三个改动来判断

跨层级改进

tree

他把层级区分,新旧树对比只对比同层级的 DOM,因为 UI 展示来讲跨层移动 DOM 操作特别少,如果发生了这样的情况,则会删除重新创建。

ps

所以说开发的时候,保持 DOM 结构稳定有助于性能提升,如需频繁的展示和隐藏也比较适合通过display来改善

组件改进

意思就是如果有两个组件,实际上内部结构是一样的,但是不同的情况下。React 会直接删除和创建。

diff 源码

下面看看源码怎么运作的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
_updateChildren: function(
nextNestedChildrenElements,
transaction,
context,
) {
var prevChildren = this._renderedChildren;
var removedNodes = {};
var mountImages = [];
var nextChildren = this._reconcilerUpdateChildren(
prevChildren,
nextNestedChildrenElements,
mountImages,
removedNodes,
transaction,
context,
);
if (!nextChildren && !prevChildren) {
return;
}
var updates = null;
var name;
// `nextIndex` will increment for each child in `nextChildren`, but
// `lastIndex` will be the last index visited in `prevChildren`.
var nextIndex = 0;
var lastIndex = 0;
// `nextMountIndex` will increment for each newly mounted child.
var nextMountIndex = 0;
var lastPlacedNode = null;
for (name in nextChildren) {
if (!nextChildren.hasOwnProperty(name)) {
continue;
}
var prevChild = prevChildren && prevChildren[name];
var nextChild = nextChildren[name];
if (prevChild === nextChild) {
updates = enqueue(
updates,
this.moveChild(prevChild, lastPlacedNode, nextIndex, lastIndex),
);
lastIndex = Math.max(prevChild._mountIndex, lastIndex);
prevChild._mountIndex = nextIndex;
} else {
if (prevChild) {
// Update `lastIndex` before `_mountIndex` gets unset by unmounting.
lastIndex = Math.max(prevChild._mountIndex, lastIndex);
// The `removedNodes` loop below will actually remove the child.
}
// The child must be instantiated before it's mounted.
updates = enqueue(
updates,
this._mountChildAtIndex(
nextChild,
mountImages[nextMountIndex],
lastPlacedNode,
nextIndex,
transaction,
context,
),
);
nextMountIndex++;
}
nextIndex++;
lastPlacedNode = ReactReconciler.getHostNode(nextChild);
}
// Remove children that are no longer present.
for (name in removedNodes) {
if (removedNodes.hasOwnProperty(name)) {
updates = enqueue(
updates,
this._unmountChild(prevChildren[name], removedNodes[name]),
);
}
}
if (updates) {
processQueue(this, updates);
}
this._renderedChildren = nextChildren;

if (__DEV__) {
setChildrenForInstrumentation.call(this, nextChildren);
}
}

nextChildren是新集合,prevChildren是老集合,for (name in nextChildren)开始循环对比,其中namekey,开始对比的时候通过key两个集合取出,然后对比,如果相同走向更新updates操作,这里走到moveChild,然后prevChild._mountIndexlastIndex判断对比大的一方更新lastIndex,然后更新prevChild._mountIndex位置

1
2
3
4
5
6
7
8
movefhild: function(child, afterNode, toIndex, lastIndex) {
// If the index of `child` is less than `lastIndex`, then it needs to
// be moved. Otherwise, we do not need to move it because a child will be
// inserted or moved before `child`.
if (child._mountIndex < lastIndex) {
return makeMove(child, afterNode, toIndex);
}
}

movefhild判断child的位置是否小于lastIndex,如果小于代表影响了新集合位置,举个 🌰
[a, b] -> [b, a]

  1. 首先是 b 进入,而此时lastIndex是 0,由于没有小于lastIndex,所以不记录进行移动,通过更新_mountIndex他到了 0 的位置
  2. 然后 a 进入,此时lastIndex是 1,由于a._mountIndex小于lastIndex,所以记录进行移动

另一个分支,如果不相同,则判断prevChild是否存在,存在则记录位置,之后执行_mountChildAtIndex创建。

等上面循环走完,再走一次去检查移除的项目,然后删除

最后判断updates是否有数据来执行一连串更新

其实还有一个问题,这个name如果不设置会怎样呢?其实他不设置的话就是一个index,也一样会执行这样的流程,但是indexchild之间是没有意义的,尽量使用key是比较好的方法。

ps

由于是lastIndex这种优化手段去遍历,开发过程中应该尽量减少尾部到头部的移动,只要出现一个,之后的操作都会变得复杂起来

总结

  • React diff 只对比同层级的,不对比跨层级,应该尽量保持稳定结构提升性能
  • 如果是同样类型的结构的组件,不对比内部结构
  • 同层级设置唯一key有助提升性能,同时减少从后到前的大幅度移动

由于 React16 Fiber 的出现,以上分析可能不适用目前版本。