起因
React 的 key 相信大家都很了解,也不用我多说。但是平时对于key的使用可能没有那么严格,有可能不给 key ,有可能给 index ,其实一般来说不会出什么问题,顶多就是性能上会有一些损失,但是在某些特定的情况下使用不当也可能会导致 bug ,比如下面这种情况。
key导致的bug
在一个后台管理系统中,左侧是一个菜单可以选择不同的选项,右侧对应了不同的视频。但是左侧菜单切换的时候,右侧视频的封面图虽然重新加载了,点击播放后视频的内容并不是新的,还是上一个选项卡的视频,看起来就像没有重新渲染一样。而排查了代码之后发现我确实没有给 table 去加 key 值,而加上这个 key 值之后也确实是好了。我们来看看 video 那段代码。
<video controls={true} poster={poster}>
<source src={videoUrl} />
</video>
那么问题来了:
- 如果我不加 key,react 不是应该把各个list理解为完全不一样而重新渲染吗?
- video 里面的 src 确实已经变了,但是播放的视频还是老视频 一顿搜索之后,我先找到了第二个问题的答案: video 用这种 source 的写法时,如果只改变 src 的话,video 是不会重新去加载视频的,正确的做法应该是将 dom 卸载掉再重新加载,这样才能正确拉取到对应的视频。 虽然我感觉这很不符合常识,但结果就是这样的,在不加 key 的情况下,去掉 source 直接在 video 中使用 src 也能解决这个 bug。像下面这样
<video controls={true} poster={poster} src={videoUrl} />
那么我们回到第一个问题,我没有给key值,React居然不会把他们都认为是不一样的dom去重新渲染?
diff算法
key 值有什么用呢?在 react 的 diff 过程中,如果遇见 key 相同的两个结点,react 会认为这是两个相同的结点,在下一次渲染中会复用这个结点,减少渲染的内容,从而提升性能。那,react 的 diff 算法又是什么呢?
传统的diff算法
我没有仔细去研究过完全找出两颗树的改动之处最小的时间复杂度是多少,根据网上的信息来看,目前最小的时间复杂度也到了,而React目前的diff算法时间复杂度为,他们两个都不在一个量级上,所以 react 的 diff 势必是丢掉了一些东西的。
React的diff算法
为了降低算法复杂度,React 针对前端开发的习惯做了一些限制:
- React 只会对同级元素 diff,如果一个 dom 结点跨越了层级,那么它是永远不可能被复用的。
- 如果 dom 结点的类型发生了改变,这个结点以及其后代都会被销毁然后重新渲染
- react 可以通过 key 值来判断哪些结点属于同一个结点 所以通过这三条我们可以总结出要想让我们的页面少一些不必要的渲染,我们可以:
- 不要轻易改变 dom 层级
- 不要轻易改变 dom 类型
- 利用好 key
diff实现
说了这么多,那么我们就一起去看看 react 到底是怎么实现 diff 的,而 key 在这其中又发挥了怎样的作用
入口函数
diff的入口函数叫 ,在这之前经历了一系列的调度操作,最后来到了diff环节,那在中 react 又干了什么呢
// returnFiber是我们最后将要渲染的Fiber树
// currentFirstChild是当前的第一个子child
// newChild是将要挂载的所有的子元素
// lanes用于优先级判断
function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
// 首先判断newChild是否是Fragment
var isUnkeyedTopLevelFragment = typeof newChild === 'object' && newChild !== null && newChild.type === REACT_FRAGMENT_TYPE && newChild.key === null;
// 如果是Fragment则把里面的内容拿出来
if (isUnkeyedTopLevelFragment) {
newChild = newChild.props.children;
}
// 判断newChild是不是对象
var isObject = typeof newChild === 'object' && newChild !== null;
// 如果是对象则根据不同的类型去处理
if (isObject) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
case REACT_PORTAL_TYPE:
return placeSingleChild(reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes));
}
}
// 判断是否是文本类型
if (typeof newChild === 'string' || typeof newChild === 'number') {
return placeSingleChild(reconcileSingleTextNode(returnFiber, currentFirstChild, '' + newChild, lanes));
}
// 判断是否是数组
if (isArray$1(newChild)) {
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
// 判断是否可迭代
if (getIteratorFn(newChild)) {
return reconcileChildrenIterator(returnFiber, currentFirstChild, newChild, lanes);
}
// 后面都是一些错误处理
if (isObject) {
throwOnInvalidObjectType(returnFiber, newChild);
}
{
if (typeof newChild === 'function') {
warnOnFunctionType(returnFiber);
}
}
if (typeof newChild === 'undefined' && !isUnkeyedTopLevelFragment) {
// If the new child is undefined, and the return fiber is a composite
// component, throw an error. If Fiber return types are disabled,
// we already threw above.
switch (returnFiber.tag) {
case ClassComponent:
{
{
var instance = returnFiber.stateNode;
if (instance.render._isMockFunction) {
// We allow auto-mocks to proceed as if they're returning null.
break;
}
}
}
// Intentionally fall through to the next case, which handles both
// functions and classes
// eslint-disable-next-lined no-fallthrough
case Block:
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
{
{
{
throw Error((getComponentName(returnFiber.type) || 'Component') + "(...): Nothing was returned from render. This usually means a return statement is missing. Or, to render nothing, return null.");
}
}
}
}
} // Remaining cases are all treated as empty.
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
可以看到,这个入口函数相对于一个路由文件,将不同的类型分派到不同的子函数去处理。那么 React 会如何处理各种类型的结点了,我们接着看。
同级单个结点diff
如果同级只有一个结点,那么会进入 中去处理,我们来看看这里面做了什么操作。
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
var key = element.key;
var child = currentFirstChild;
// 首先会判断之前有没有这个结点
while (child !== null) {
// 有老结点的情况下判断key
if (child.key === key) {
// 根据tag类型来处理
switch (child.tag) {
case Fragment:
{
if (element.type === REACT_FRAGMENT_TYPE) {
deleteRemainingChildren(returnFiber, child.sibling);
var existing = useFiber(child, element.props.children);
existing.return = returnFiber;
{
existing._debugSource = element._source;
existing._debugOwner = element._owner;
}
return existing;
}
break;
}
case Block:
// We intentionally fallthrough here if enableBlocksAPI is not on.
// eslint-disable-next-lined no-fallthrough
default:
{
if (child.elementType === element.type || ( // Keep this check inline so it only runs on the false path:
isCompatibleFamilyForHotReloading(child, element))) {
deleteRemainingChildren(returnFiber, child.sibling);
// 复用当前结点
var _existing3 = useFiber(child, element.props);
_existing3.ref = coerceRef(returnFiber, child, element);
_existing3.return = returnFiber;
{
_existing3._debugSource = element._source;
_existing3._debugOwner = element._owner;
}
return _existing3;
}
break;
}
} // Didn't match.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 没有老结点只能创建新结点
if (element.type === REACT_FRAGMENT_TYPE) {
var created = createFiberFromFragment(element.props.children, returnFiber.mode, lanes, element.key);
created.return = returnFiber;
return created;
} else {
var _created4 = createFiberFromElement(element, returnFiber.mode, lanes);
_created4.ref = coerceRef(returnFiber, currentFirstChild, element);
_created4.return = returnFiber;
return _created4;
}
}
可以看到,首先会判断之前是否存在这个结点,如果之前都不存在,那复用更谈不上了,所以会直接新建一个插入。 而如果存在老结点,下面会判断 key 和 typ e是否相同。注意,在 key 相同之后,只有 type 也相同的情况下 react 才会去复用这个结点。而如果判断出 key 或者 type 不同,react 会删掉这个结点,去新建一个结点并返回。
同级多个结点diff
同级多个结点的diff就比单个结点要复杂多了,它会进入中去处理,我们慢慢来看。
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
{
// 判断key,如果这里出现相同的key会给一个warning
var knownKeys = null;
for (var i = 0; i < newChildren.length; i++) {
var child = newChildren[i];
knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
}
}
// diff后的结果
var resultingFirstChild = null;
// 新fiber树的上一个fiberNode
var previousNewFiber = null;
// 老fiber树的fiberNode,也就是未更新之前
var oldFiber = currentFirstChild;
// 当前可复用的结点的index
var lastPlacedIndex = 0;
// 新child的index
var newIdx = 0;
// 下一个老的fiberNode
var nextOldFiber = null;
// 这里用newIndex来遍历
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 没想到有什么情况会进第一个分支
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// sibling是fiberNode的兄弟结点,所以用nextOldFiber来存储下一个需要比较的fiberNode
nextOldFiber = oldFiber.sibling;
}
// 这里通过比较oldFiber和newFiber来拿到一个新的fiberNode对象
// 这个对象可能是复用的、新建的、也可能为null
var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
// 如果为null的情况下直接break,跳出循环
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
// 优化项,不用管
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
// We matched the slot, but we didn't reuse the existing fiber, so we
// need to delete the existing child.
deleteChild(returnFiber, oldFiber);
}
}
// 记录下当前可复用结点的index
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 是否是最开始的结点
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
// 设置兄弟结点为newFiber
previousNewFiber.sibling = newFiber;
}
// 链表移动,pre赋值为当前node
previousNewFiber = newFiber;
// 将old赋值为下一个需要比较的对象
oldFiber = nextOldFiber;
}
// 如果是newIdx为length跳出的循环,证明newChild已经遍历完了
// 那么此时剩下的oldFiber一定是被删除的结点,直接删除
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// 如果oldFiber为null了,那么证明老的fiber已经遍历完了
// 那么剩下的newChild都是新增的结点,直接插入
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (_newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber;
} else {
previousNewFiber.sibling = _newFiber;
}
previousNewFiber = _newFiber;
}
return resultingFirstChild;
}
// 如果是中途break,证明出现了结点变化
// 此时收集未遍历的oldFiber,以key作为键值,map结构存储
var existingChildren = mapRemainingChildren(returnFiber, oldFiber); // Keep scanning and use the map to restore deleted items as moves.
// 遍历newChild,用key去existingChildren中寻找对应的结点
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (_newFiber2 !== null) {
if (shouldTrackSideEffects) {
if (_newFiber2.alternate !== null) {
existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
}
}
// 找到了之后给effectTag赋值
lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber2;
} else {
previousNewFiber.sibling = _newFiber2;
}
previousNewFiber = _newFiber2;
}
}
if (shouldTrackSideEffects) {
// Any existing children that weren't consumed above were deleted. We need
// to add them to the deletion list.
existingChildren.forEach(function (child) {
return deleteChild(returnFiber, child);
});
}
// 返回最后的结果
return resultingFirstChild;
}
首先,一进来,react 就会检查一遍各个子结点上的 key 值。在中,用了 Set 来存储 key 值,如果发现了两个一样的值,就会抛出我们平常见的挺多的一个错误了:
error('Encountered two children with the same key, `%s`. ' + 'Keys should be unique so that components maintain their identity ' + 'across updates. Non-unique keys may cause children to be ' + 'duplicated and/or omitted — the behavior is unsupported and ' + 'could change in a future version.', key);
后面,就是真正的 diff 逻辑了,我先给大家解释一下react是如何diff的。
- 遍历新老 child 并进行对比,如果 key,type 全都能对上,那最好了,这些结点就能完全复用,diff结束
- 中途某个结点被改变了,导致遍历中断,这时候 react 会用 Map 以 key 为键去收集 oldChildren(即老结点)中没有被遍历到的结点,随后遍历剩余新结点。这个时候 key 就发挥了作用,遍历新结点的过程中,能不能复用老结点就是通过能不能找到 key 来判断的,如果在 map 中找到了,那么就复用,如果找不到,那么就新建。
- oldChild 被遍历完了,newChild 中还有结点,那么证明这些结点是被插入的,直接插入。
- newChild 被遍历完了,oldChild 还有结点,那么证明这些结点是被删除的,直接删除。 下面我们结合代码来看:
// 这里用newIndex来遍历
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 没想到有什么情况会进第一个分支
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// sibling是fiberNode的兄弟结点,所以用nextOldFiber来存储下一个需要比较的fiberNode
nextOldFiber = oldFiber.sibling;
}
// 这里通过比较oldFiber和newFiber来拿到一个新的fiberNode对象
// 这个对象可能是复用的、新建的、也可能为null
var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
// 如果为null的情况下直接break,跳出循环
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
// 优化项,不用管
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
// We matched the slot, but we didn't reuse the existing fiber, so we
// need to delete the existing child.
deleteChild(returnFiber, oldFiber);
}
}
// 记录下当前可复用结点的index
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 是否是最开始的结点
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
// 设置兄弟结点为newFiber
previousNewFiber.sibling = newFiber;
}
// 链表移动,pre赋值为当前node
previousNewFiber = newFiber;
// 将old赋值为下一个需要比较的对象
oldFiber = nextOldFiber;
}
这里是 newIndex++,所以是在 for 循环遍历 newChild,首先它会比较 oldFiber 的 index 和 newIndex 的大小,因为他们都是从头开始遍历的,所以正常情况下他们肯定是相等的,也就是会走到 else 分支,让 nextOldFiber 等于 oldFiber 的兄弟结点,也就是当前结点的相邻结点。然后 newFiber 是通过updateSlot 得到的,我们进去这个函数里面看看
function updateSlot(returnFiber, oldFiber, newChild, lanes) {
// 拿到key值,没有则为null
var key = oldFiber !== null ? oldFiber.key : null;
if (typeof newChild === 'string' || typeof newChild === 'number') {
if (key !== null) {
return null;
}
return updateTextNode(returnFiber, oldFiber, '' + newChild, lanes);
}
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
{
// 都没有给key都为null,则也是相等,可以复用
if (newChild.key === key) {
if (newChild.type === REACT_FRAGMENT_TYPE) {
return updateFragment(returnFiber, oldFiber, newChild.props.children, lanes, key);
}
// 得到更新后的Element
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
case REACT_PORTAL_TYPE:
{
if (newChild.key === key) {
return updatePortal(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
}
if (isArray$1(newChild) || getIteratorFn(newChild)) {
if (key !== null) {
return null;
}
return updateFragment(returnFiber, oldFiber, newChild, lanes, null);
}
throwOnInvalidObjectType(returnFiber, newChild);
}
{
if (typeof newChild === 'function') {
warnOnFunctionType(returnFiber);
}
}
return null;
}
从这里面可以看到,只有 key 相同的情况下,才会返回一个 fiber,否则会返回 null。我们先来看 key 相同的情况下,$$updateElement$$做了什么
function updateElement(returnFiber, current, element, lanes) {
// 判断是否有老结点
if (current !== null) {
// 只有type相同才能复用
if (current.elementType === element.type || (
isCompatibleFamilyForHotReloading(current, element))) {x
var existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
{
existing._debugSource = element._source;
existing._debugOwner = element._owner;
}
return existing;
}
}
// 没有老结点或者type不同直接创建一个新的结点
var created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
可以看到,如果有老结点,并且他们的 type 相同,会复用该结点,反之则会创建一个新结点返回。
回到我们之前的循环中,如果新老结点的 key 相同,那么才会返回一个 fiber 结点。而如果我们不给 key呢?如果两个都不给 key 的话,其实就是 null === null,最后的结果还是 true,所以也会复用。而如果 key 不一样,那么返回了 null,我们的 for 循环就会 break 了,进入下一个逻辑,这里我们后面再说。
在拿到新的 fiberNode 之后,此时会进入一个函数。他的作用主要是判断各个结点的更新类型并返回最后一个可复用结点的位置。
function placeChild(newFiber, lastPlacedIndex, newIndex) {
//lastPlacedIndex初始值为0
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {.
return lastPlacedIndex;
}
var current = newFiber.alternate;
// 判断当前有没有结点
if (current !== null) {
var oldIndex = current.index;
// 如果oldIndex小于最近可复用结点的index,那么它需要右移
if (oldIndex < lastPlacedIndex) {
// Placement为一个常量,react用于判断更改类型
newFiber.flags = Placement;
return lastPlacedIndex;
} else {
// 如果它比最近可复用结点大,那么不需要改动,同时把lastPlacedIndex设置为该值
return oldIndex;
}
} else {
//这个fiber是新建的
newFiber.flags = Placement;
return lastPlacedIndex;
}
}
这个地方有点绕,我们用个简单的例子来理解,现在有 abcd 四个数,我们要把它变成 dabc。如果我们肉眼来看,那很显然是把 d 移动到最前面,但是 react 不是这样干的,react 是这样干的。
- 遍历dabc,拿到第一个d,d在abcd中为第四个数,所以index=3,比lastPlacedIndex=0大,位置保持不变,并修改lastPlacedIndex为3
- 遍历dabc,拿到第二个a,a在abcd中为第一个数,所以index=0,小于lastPlacedIndex=3,为了保保证顺序,需要把a右移到d后面去
- 遍历dabc,拿到第三个b,b在abcd中为第二个数,所以index=1,小于lastPlacedIndex=3,为了保证顺序,需要把b右移到d后面去
- 遍历dabc,拿到第三个c,c在abcd中为第一个数,所以index=2,小于lastPlacedIndex=3,为了保证顺序,需要把c右移到d后面去 所以 react 其实是把 abc 移动到了 d 后面去,当然换一种方法也是可以的,但是对于react来说,不可能根据实际情况去判断如何移动,所以只会采取一种固定的方法来进行修改。
我们接着看后面的代码,如果newIndex等于newChild的长度了,那么证明我们已经把newChild遍历完了。但是此时oldChild还有剩余,那就证明我们这次操作删除了某些结点,所以直接把剩下的结点删掉。
而如果oldChild已经遍历完了,但是newChild还有,那么证明我们添加了结点,此时我们直接添加剩余结点即可。
但是如果我们中途退出了循环,则证明我们修改了中途的结点,此时会通过一个函数去收集没有遍历完的老结点,用Map以key作为键去存储,如果没有key则用index当key。
function mapRemainingChildren(returnFiber, currentFirstChild) {
// 新建一个map
var existingChildren = new Map();
// 拿到第一个fiber
var existingChild = currentFirstChild;
// 遍历
while (existingChild !== null) {
if (existingChild.key !== null) {
// 有key则用key
existingChildren.set(existingChild.key, existingChild);
} else {
// 没有则用index
existingChildren.set(existingChild.index, existingChild);
}
// 获取兄弟fiber
existingChild = existingChild.sibling;
}
return existingChildren;
}
随后再次循环剩下的newChild,去找其中对应的结点,并通过函数标记我们需要做的操作。
for (; newIdx < newChildren.length; newIdx++) {
// 这是根据之前的map找到的结点
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (_newFiber2 !== null) {
if (shouldTrackSideEffects) {
if (_newFiber2.alternate !== null) {
existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
}
}
// 标记结点修改类型
lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber2;
} else {
previousNewFiber.sibling = _newFiber2;
}
// 链表移动
previousNewFiber = _newFiber2;
}
}
这里有一个,这里主要就是从之前的map中利用key来匹配对应的结点,如果匹配到了则可以复用,匹配不到就创建一个新的结点。
总结
其实,最重要的几个点就是:
- key值不给的情况下,react会利用index来做判断,并不会粗暴的舍弃所有dom
- 只有key和type都相同的情况下,react才会去复用结点
- 改变list顺序时,react是通过从上往下移的顺序去改变的,所以我们尽可能少把后面的结点移动到前面,因为这其实会导致该结点前面的结点全部移动到后面,并不是单纯的把这一个结点提到前面。
最后,我们的整个diff过程就走完了。我们再次回到最初的问题,如果我不给key值,react不会把他们完全扔掉再构造吗?答案是不会。react会用index当作key值,而此时结点类型也没有改变,所以react会复用该结点,只是改变了他的props,也就导致了video没有取得正确的视频源。最后我们再来总结一般整个流程:
- 进入入口函数以后根据不同的类型去处理
- 如果是element那么直接就可以更新了,此时会根据key和type判断是否可以复用(都不传key,key也是相等)
- 如果是数组则进行特殊处理
- 首先循环一对一比较oldFiber和newChild,如果完全一样那么都可以复用,顺利结束
- 如果oldFiber遍历完了newChild还有,那么是新加了结点,直接把剩下的结点插入
- 如果newChild遍历完了oldFiber还有,那么是删除了结点,直接把剩下的结点删除
- 如果是中途退出了循环,那么证明有结点被改变了,此时收集剩下的oldFiber,用Map存储,以key为键值,如果没有key则用index
- 遍历剩下的newChild,在map中找对应的可复用结点,找到了之后比较type,相同则能复用。如果找不到或者type不同,则创建新的结点
- 最后返回diff后的fiber 这里我们只梳理了核心流程,你可以注意到源码中其实还有一些其他类型的比较和一些优化措施,这些就留给大家自己摸索了。