前言
在React开发中不可避免 我们总要用到setState 或者 useState相关以此来更新界面,但是在渲染中究竟发生了什么呢?让我们带着疑问一步一步看看。为了让读者看的明白清楚 我内容一分为三。
在看这篇文章时,可结合 React 渲染流程可视化进行服用。
- Fiber到底是什么
- 组件到底是怎么render的
- commit 阶段react做了哪些事情
render
render 是我们React中最关键的一步,它和页面的渲染息息相关,那到底他做了什么呢?
上面我们提到了在渲染的时候 在缓存中会存在一个(首次1个后续两个)VDOM,render经过对比前后两次的DOM的区别,并通过打标记的方式对发生的改变的地方进行增删改。简单说,render 过程就是 React 「对比旧 Fiber 树和新的 element」 然后「为新的 element 生成新 Fiber 树」的一个过程。让我们更详细一些。
根据源码我们得知 从源码中看,React 的整个核心流程开始于 performSyncWorkOnRoot 函数,在这个函数中,会根据root的mode来决定是同步模式还是异步模式,然后会根据root的current指针来找到当前的Fiber,然后会根据current指针的tag来进行不同的处理。
function performSyncWorkOnRoot(root) {
.....
var exitStatus = renderRootSync(root, lanes);
var finishedWork = root.current.alternate;
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
commitRoot(root, workInProgressRootRecoverableErrors, workInProgressTransitions);
.....
}
上面就是整个核心流程起始地,我们发现,在 performSyncWorkOnRoot中我们主要执行了 renderRootSync 函数和 commitRoot 函数,一个是 对比旧 Fiber 树和新的 element,另外一个是用于执行 Fiber 树中的更新操作并将更新应用到真实的 DOM 上(执行副作用操作)。当然这里看不懂没有问题,我们会详细讲解一下这两个方法。
回到正题,我们继续 render 话题。整个 render 过程的重点在 workLoopSync ,代码如下。
从 workLoopSync 函数里我们可以看到,这里用了一个循环来不断调用 performUnitOfWork 方法,直到 workInProgress 为 null。所以这里的重点就是performUnitOfWork。而在performUnitOfWork 主要使用了 beginWork 和 completeUnitOfWork 来分别模拟这个“递”和“归”。而这两个方法使得递归过程可以中断。
render 过程是深度优先的遍历,beginWork 函数会为遍历到的每个 Fiber 节点生成他的所有 子Fiber 并返回第一个子Fiber ,这个 子Fiber 将赋值给 workInProgress,在下一轮循环继续处理,直到遍历到叶子节点,这时候就需要“归”了。
completeUnitOfWork 就会为叶子节点做一些处理,然后把叶子节点的兄弟节点赋值给 workInProgress 继续“递”操作,如果连兄弟节点也没有的话,就会往上处理父节点。流程可以查看上图。
beginWork
目的
更新当前节点workInProgress,获取新的 children。
生成他们对应的 Fiber,并最终返回第一个子节点。
React 通常会同时存在两个 Fiber 树,一个是当前视图对应的,一个则是根据最新状态正在构建中的。这两棵树的节点一一对应,通过对比这两个数进行更新。当然,当首次渲染的时候,当前视图对应的Fiber 树 必然为 null。
如果首次渲染,我们会根据正在构建的节点的组件类型做不同的处理。生成一棵 Fiber 树。这个不是我们关注的重点。这部分逻辑并没有太大的争议。我们将关注点放在 非首次渲染 上。
react 使用了哪些手段来优化更新效率呢?
当前节点对应组件的 props 和 context 没有发生变化 并且当前节点的更新优先级不够 ,如果这两个条件均满足的话可以直接copy 当前视图对应 的子节点并返回。如果不满足则同首次渲染走一样的逻辑(当然这些都是一些浅比较)。说完上面这些话题我们不得不拿出我们经典的话题。
function Son() {
console.log('child render!');
return <div>Son</div>;
}
function Parent(props) {
const [count, setCount] = React.useState(0);
return (
<div onClick={() => {setCount(count + 1)}}>
count:{count}
<Son />
</div>
);
}
function App() {
return (
<Parent/>
);
}
上面的例子 很明显 Son 组件不依赖任意于 props 和 context,但是当我们点击父组件的时候 会发现 Son 组件也会重新渲染。不是说 节点对应组件的 props 和 context 没有发生变化 就不会渲染了吗? 我们先说一下解决方法,这个原因我们留一个悬疑待会再讲解
function Son() {
console.log('child render!');
return <div>Son</div>;
}
function Parent(props) {
const [count, setCount] = React.useState(0);
return (
<div onClick={() => {setCount(count + 1)}}>
count:{count}
{props.children}
</div>
);
}
function App() {
return (
<Parent>
<Son/>
</Parent>
);
}
上面的代码 会发现 Son 组件不会重新渲染。是因为 Son 组件是在 App 组件中生成并作为 props 传入 Parent 的,因为不管 Parent 组件状态怎么变化都不会影响到 App 组件,因此 App 和 Son 组件就只会在首次渲染时会执行一遍,也就是说 Parent 获取到的 props.children 的引用一直都是指向同一个对象,这样一来 Son 组件的 props 也就不会变化了。但是如果我将 Son 组件 放在 Parent 组件中 当 Parent 发生了setState 时
<Son/>
React.createElement(Son, null)
相当于重新创建了 Son,由于props的引用改变,oldProps !== newProps。于是重新渲染了。
如果不理解可以查看卡颂老师的文章。
生成子节点
我们通过上面得到 workInProgress 的 children 之后,接下来需要为这些 children 生成 Fiber ,这就是 reconcileChildFibers 做的事情,这也是我们经常提到的 diff 的过程。
当然更新也分两种情况 更新的是单节点 ( 例如<Son/> ) 调用reconcileSingleElement , 或者是数组(例如map((item,index)=><Son key = {index} />) )调用reconcileChildrenArray。
单节点
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
var key = element.key;
var child = currentFirstChild;
while (child !== null) {
// 首先比较 key 是否相同
if (child.key === key) {
var elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
deleteRemainingChildren(returnFiber, child.sibling);
var existing = useFiber(child, element.props.children);
existing.return = returnFiber;
{
existing._debugSource = element._source;
existing._debugOwner = element._owner;
}
return existing;
}
} else {
// 然后比较 elementType 是否相同
if (child.elementType === elementType || ( // Keep this check inline so it only runs on the false path:
isCompatibleFamilyForHotReloading(child, element) ) || // Lazy types should reconcile their resolved type.
// We need to do this after the Hot Reloading check above,
// because hot reloading has different semantics than prod because
// it doesn't resuspend. So we can't let the call below suspend.
typeof elementType === 'object' && elementType !== null && elementType.$$typeof === REACT_LAZY_TYPE && resolveLazy(elementType) === child.type) {
deleteRemainingChildren(returnFiber, child.sibling);
var _existing = useFiber(child, element.props);
_existing.ref = coerceRef(returnFiber, child, element);
_existing.return = returnFiber;
{
_existing._debugSource = element._source;
_existing._debugOwner = element._owner;
}
return _existing;
}
} // Didn't match.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
// 遍历兄弟节点,看能不能找到 key 相同的节点
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;
}
}
上面代码看起来可能生涩一些 接下来我用一些白话,给大家讲解一下。 按照react 的渲染规则---尽可能复用旧节点的原则 会遍历旧节点,对每个遍历到的节点会做下面两个判断
- key 是否相同
- key 相同的情况下,elementType 是否相同.
我们按照这两个判断在丰富一下
-
如果 key 不相同,则直接调用
deleteChild将这个 child标记为删除,注意这里是标记,可能只是我们还没有找到那个对的节点,所以要继续执行child = child.sibling;遍历兄弟节点,直到找到那个对的节点。 -
如果 key 相同,elementType 相同,那就是最理想的情况,找到了可以复用的节点,直接调用
deleteRemainingChildren把剩余的兄弟节点标记删除,然后直接复用 child 返回。 -
如果 key 相同,但 elementType 不同,这是最悲情的情况,我们找到了那个节点,可惜的是这个节点的 elementType 已经变了,那我们也不需要再找了,把 child 及其所有兄弟节点标记删除,跳出循环。直接创建一个新的节点。
注意:
-
deleteRemainingChildren把 child 及其所有兄弟节点标记删除 -
deleteChild只删除当前节点。 -
复用使用的是
useFiber -
重新生成新的 Fiber 使用的
createFiberFromXXX
多节点(数组)
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
...
var resultingFirstChild = null;
var previousNewFiber = null;
var oldFiber = currentFirstChild;
var lastPlacedIndex = 0;
var newIdx = 0;
var nextOldFiber = null;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
// 跳出循环
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = newFiber;
} else {
// TODO: Defer siblings if we're not at the right index for this slot.
// I.e. if we had null values before, then we want to defer this
// for each null value. However, we also don't want to call updateSlot
// with the previous one.
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
if (newIdx === newChildren.length) {
// We've reached the end of the new children. We can delete the rest.
deleteRemainingChildren(returnFiber, oldFiber);
if (getIsHydrating()) {
var numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
if (oldFiber === null) {
// If we don't have any more existing children we can choose a fast path
// since the rest will all be insertions.
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (_newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = _newFiber;
} else {
previousNewFiber.sibling = _newFiber;
}
previousNewFiber = _newFiber;
}
if (getIsHydrating()) {
var _numberOfForks = newIdx;
pushTreeFork(returnFiber, _numberOfForks);
}
return resultingFirstChild;
} // Add all children to a key map for quick lookups.
......
}
function updateSlot(returnFiber, oldFiber, newChild, lanes) {
var key = oldFiber !== null ? oldFiber.key : null;
if (typeof newChild === 'string' && newChild !== '' || 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:
{
if (newChild.key === key) {
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
...
}
...
}
return null;
}
这个看起来就更复杂了,我们还是老样子拆解一下。
从上面我们可以看到,方法内有两个循环。
第一轮循环中逻辑如下:
同时遍历 oldFiber 和 newChildren,判断 oldFiber 和 newChild 的 key 是否相同。
如果 key 相同。
如果elementType相同
复用 oldFiber 返回。
如果不同
新建 Fiber 返回。
如果 key 不同则
直接跳出循环。
可以看出,第一轮循环只要新旧的 key 不一样,就会跳出循环
跳出循环后,要先执行两个判断
newChildren 已经遍历完了:这种情况说明新的 children 全都已经处理完了,只要把 oldFiber 和他所有剩余的兄弟节点删除然后返回头部的 Fiber 即可。
已经没有 oldFiber :这种情况说明 children 有新增的节点,给这些新增的节点逐一构建 Fiber 并链接上,然后返回头部的 Fiber 即可。
如果以上两种情况都不是,则进入第二轮循环。第二轮循环比第一轮更复杂一些,给大家拆解一下。
var existingChildren = mapRemainingChildren(returnFiber, oldFiber); // Keep scanning and use the map to restore deleted items as moves.
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (_newFiber2 !== null) {
if (shouldTrackSideEffects) {
// 副本
if (_newFiber2.alternate !== null) {
// The new fiber is a work in progress, but if there exists a
// current, that means that we reused the fiber. We need to delete
// it from the child list so that we don't add it to the deletion
// list.
existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
}
}
lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber2;
} else {
previousNewFiber.sibling = _newFiber2;
}
previousNewFiber = _newFiber2;
}
}
在执行第二轮循环之前,先把剩下的旧节点和他们对应的 key 或者 index 做成映射(Map),方便查找。
第二轮循环沿用了第一轮循环的 newIdx,很容易看出第二轮的循环是在第一轮的基础上对newChildren进行处理。
在代码中我们可以发现 第二轮循环先调用了updateFromMap 来处理节点,参数为existingChildren, returnFiber, newIdx等
function updateFromMap(existingChildren, returnFiber, newIdx, newChild, lanes) {
...
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
{
var _matchedFiber = existingChildren.get(newChild.key === null ? newIdx : newChild.key) || null;
return updateElement(returnFiber, _matchedFiber, newChild, lanes);
}
.....
}
}
return null;
}
也就是说我们需要用 newChild 的 key 去 existingChildren 中找对应的 Fiber。上面我们也用到了 updateElement 这个函数,我们可以得知这个函数主要的目的
能找到 key 相同的,(这个节点只是位置变了),可以复用的
找不到 key 相同的,则说明这个节点应该是新增的。
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
...
var current = newFiber.alternate;
if (current !== null) {
var oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This is a move.
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// This item can stay in place.
return oldIndex;
}
} else {
// This is an insertion.
newFiber.flags |= Placement;
return lastPlacedIndex;
}
}
举一个例子
旧 1,2,3,4,5,
新 1,2,5,3,4
<!-- 第一次循环 -->
1,2
<!-- 第二次循环 -->
<!-- 找到5 -->
1,2,5
<!-- 找到3 -->
1,2,5,3
<!-- 找到4 -->
1,2,5,3,4
按照上面的说法我们应该如上面操作 但是很明显可以看出遍历完1、2,以后如果直接移动 5 到最后就不用这么多步骤了,或者移动3、4 到5的前面也比现在这个方案好。
那问题的根本就是
到底哪个节点才是移动了的?
这就需要一个参照点,我们要保证在参照点左边都是排好序了。而这个参照点就是 lastPlacedIndex。有了它,我们在遍历 newChildren 的时候可能会出现下面两种情况
Fiber(新建或者复用) 对应的老 index < lastPlacedIndex,这就说明这个 Fiber 的位置不对,因为 lastPlacedIndex 左边的应该全是已经遍历过的 newChild 生成的 Fiber。因此这个 Fiber 是需要被移动的,打上 'flag'。
如果 Fiber 对应的老 index >= lastPlacedIndex,那就说明这个 Fiber 的相对位置是 ok 的,可以不用移动,但是我们需要更新一下参照点,把参照点更新成这个 Fiber 对应的老 index。
这么说可能有点生涩,我们举一个例子来说明一下吧。
旧 a,b,c,d
新 c,a,b,d,e
lastPlacedIndex = 0
<!-- 开始操作 -->
<!-- 操作C -->
newC.index = 0 oldC.index = 2
oldC.index > lastPlacedIndex
lastPlacedIndex = 2
<!-- 操作A -->
newA.index = 1 oldA.index = 0
oldA.index < lastPlacedIndex
oldA.flag = Placement
<!-- 操作B -->
newB.index = 2 oldB.index = 1
oldB.index < lastPlacedIndex
oldB.flag = Placement
<!-- 操作D -->
newD.index = 3 oldD.index = 3
oldD.index > lastPlacedIndex
lastPlacedIndex = 3
<!-- 操作E -->
newE.index = 3 oldE.index = 0
oldE.index < lastPlacedIndex
oldE.flag = Placement
从上面我们可以看出 a b e 发生了移动,但是更要以最高效方法应该是 移动 c e。 从这里我们也得到一个注意事项
尽量避免把节点从后面提到前面
completeUnitOfWork
目的
- 调用completeWork:当一个Fiber节点被处理完毕(即没有更多的子节点),completeUnitOfWork会将渲染的结果提交到实际的DOM节点。
- 回溯到父节点:如果没有更多的兄弟节点需要处理,completeUnitOfWork会通过子->父链表回溯到父节点,并继续上述过程,直到回溯到根节点。
处理当前节点
这里主要调用方法为 completeWork 主要两种情况:
首次渲染和更新节点
首次渲染
创建真实 DOM。
如果有子节点的话将子节点的真实 DOM 插入到刚刚创建的 DOM 中。
处理真实 DOM 的 props 等。
这里我们以HostComponent为例子讲解下:
var type = workInProgress.type;
// 为fiber创建对应DOM节点
var instance = createInstance(type, newProps, rootContainerInstance, currentHostContext, workInProgress);
// 将子孙DOM节点插入刚生成的DOM节点中
appendAllChildren(instance, workInProgress, false, false);
workInProgress.stateNode = instance;
// 处理prop
if (finalizeInitialChildren(instance, type, newProps, rootContainerInstance)) {
markUpdate(workInProgress);
}
非首次渲染
当 update 时,Fiber 节点已经存在对应 DOM 节点,所以不需要生成 DOM 节点。需要做的主要是处理DOM 节点的 props,这里主要就是一些真实 DOM 的 onClick、onChange等回调函数的注册,style 等,这些处理完之后的 props 也会记录到 workInProgress.updateQueue 中,并在 commit 阶段更新到 DOM 节点上。
回溯
开始我们提到是 beginWork 是深度优先的更新,也就意味着进入 completeUnitOfWork 后,一定还需要回到 beginWork 中继续处理其他的节点。
var siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
workInProgress = siblingFiber;
return;
} // Otherwise, return to the parent
completedWork = returnFiber; // Update the next thing we're working on in case something throws.
workInProgress = completedWork;
可以看到,当处理完当前节点之后,React 会判断当前节点是否具有兄弟节点,如果有的话则将兄弟节点设置为当前的 workInProgress 回到主流程继续 beginWork。
而如果没有兄弟节点的话,就意味着同父节点下的所有子节点都已经处理完毕,则接下来就会处理他们的父节点。
大致流程就是:beginWork 执行到当前节点没有 child 的时候,进入 completeUnitOfWork 处理当前节点,处理完后如果当前节点有兄弟节点则回到 beginWork 继续处理兄弟节点,如果没有兄弟节点则继续在 completeUnitOfWork 处理当前节点的父节点,直到回溯到根结点上。