作为一个负责视图渲染的框架,整体流程跑通重点有两部分,第一部分是创建,第二部分就是ui发生变更的时候。上一篇我们学习了React创建虚拟节点树提交渲染的流程,本篇我们来学习一下第二部分,了解一下React是如何发起刷新并处理ui的变化的。
触发状态变化
我们通过react hooks里的 useState
声明我们的ui和ui的改变:
const [state,setState] = useState('');
其中 useState 返回的就是当前状态以及触发状态更新的对象。在React里你会发现 useState
也会用多个定义,分别是 mountState
和 updateState
,updateState 是在组件更新阶段的时候处理state的,所以我们暂时关注一下 mountState 就可以了。这部分的代码定义在 ReactFiberHooks.js
里面:
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountStateImpl(initialState);
const queue = hook.queue;
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
null,currentlyRenderingFiber,queue
):any);
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];
}
所以我们调用 setState 的时候,实际上是在调用 dispatchSetState
, 主要就是调用 dispatchSetStateInternal
方法,这个方法核心逻辑如下:
function dispatchSetStateInternal<S,A>(
fiber: Fiber,
queue: UpdateQueue<S,A>,
action:A,
lane: Lane
): boolean {
const update: Update<S,A> = {
lane,
revertLane: NoLane,
action,
hasEagerState: false,
eagerState: null,
next: (null:any)
};
if (isRenderPhaseUpdate(fiber)) {
enqueueRenderPhaseUpdate(queue, update);
} else {
const alternate = fiber.alternate;
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
let prevDispatcher = null;
const currentState: S = (queue.lastRenderedState: any);
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false;
}
}
}
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
return true;
}
}
return false;
}
- 创建一个Update对象,表示一次更新。如果当前正在更新,就把Update对象加到更新队列。
- 如果上一次更新已经完成了,获取本次state(currentState)和上一次state(eagerState)。这里具体的获取逻辑涉及useState这个hooks的逻辑,这里不太影响我们理解刷新状态本身,所以先不关心。
- 对比新旧state,如果对比结果是一样的,调用 enqueueConcurrentHookUpdateAndEagerlyBailout。
- 新旧state发生改变,把Update加入更新队列,获取当前需要更新的fiber树的root。
- 调用scheduleUpdateOnFiber触发刷新,这个函数在上一篇我们遇到过,他会触发 workLoop 的流程,只不过这一次执行的都是Fiber节点刷新的逻辑。
新旧状态对比
React通过is函数进行新旧state的对比,这个函数定义在shared/objectIs.js文件:
function is(x: any, y: any) {
return (
(x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y)
);
}
可以看到当state是2个不同对象的时候,会进行严格对比,即使内部字段的值是一样的,仍然会触发刷新。
当对比通过不需要刷新的时候,会调 enqueueConcurrentHookUpdateAndEagerlyBailout,这里会把update塞入更新队列作为一个记录。
不过这里有一个细节我们可以关注下,这段比较逻辑执行的条件是需要满足:
fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes
但是如果一个如下的组件,会在第三次触发更新的时候,此条件才会满足,所以你会发现实际运行的时候,第三次点击才不会看到 render 的这条log打印。这个涉及到 fiber 和 fiber.alternate 也就是 workInProgress 节点的状态变化,具体可以参考这篇文章:juejin.cn/post/725771…
function App() {
const [count, setCount] = useState(0);
console.log('render', count);
return (
<button onClick={() => setCount(6)}>click me</button>
);
}
fiber节点更新
回到前一篇文章beginWork -> updateFunctionComponent -> reconcileChildren 的逻辑:
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
//ReactChildFiber.js
export const reconcileChildFibers: ChildReconciler = createChildReconciler(true);
export const mountChildFibers: ChildReconciler = createChildReconciler(false);
之前建树我们看的新创建节点的逻辑,也就是 mountChildFibers,节点状态更新的时候执行的是 reconileChildFibers,实际上他们都是调用的 createChildReconciler,只是更新的时候shouldTrackSideEffects为true。
再回顾一下 reconcileChiildFibersImpl 的逻辑:
// in reconcileChildFibersImpl
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
const firstChild = placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
return firstChild;
}
if (isArray(newChild)) {
const firstChild = reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
return firstChild;
}
}
这里单节点会走 reconcileSingleElement,如果是
- 这种jsx返回数组的,则会走reconcileChildrenArray。
单节点更新
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
//...
} else {
if (child.elementType === elementType) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
coerceRef(returnFiber, child, existing, element);
existing.return = returnFiber;
return existing;
}
}
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
}
这里能看到大概的节点复用/创建流程:
循环里面对应有fiber节点的diff逻辑,判断节点是否能复用:
- 如果当前child是null,也就是上次更新完没有对应的节点,没有节点可以复用了,创建新的。
- 对比key,如果key不一样,那就只能先把不能复用的子节点标记删除,然后去遍历其他的子节点看看能不能复用。
- 如果key一样了,那就对比一下elementType,如果是同类型的,说明fiber节点可以复用。复用的时候会把被复用节点的兄弟节点标记删除。
- 如果elementType也不一样,说明子节点匹配不是复用条件,把子节点标记为删除。
数组节点更新
reconcileChildrenArray的主要源码如下:
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<any>,
lanes: Lanes,
): Fiber | null {
let knownKeys: Set<string> | null = null;
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
// 开始循环
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
// 还没有遍历到oldFiber的index
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// 已经超过oldFiber的index了,只能尝试用oldFiber的兄弟节点
nextOldFiber = oldFiber.sibling;
}
const 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);
}
}
// 把newFiber添加到节点树
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// 循环结束
if (newIdx === newChildren.length) {
// newChildren遍历完成,oldFiber也遍历完成
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
if (oldFiber === null) {
// oldFiber遍历完成
for (; newIdx < newChildren.length; newIdx++) {
// 创建新的节点
const 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;
}
// 缓存已经存在的子节点到map里
const existingChildren = mapRemainingChildren(oldFiber);
// 再遍历一次,复用节点去map里面查找
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
代码比较长,我们把他整理成流程图:
这里会遍历newChildren和oldFiber 2个数组。新旧2个数组按下标对比,如果不能复用就会结束循环。这种比法会出现3种可能:
- newChildren遍历完成,也就是代码里的newIdx === newChildren.length,那说明整个数组都可以复用了。如果旧的fiber节点还没遍历完,那说明新ui里节点数量变少了,就把多出来的这部分节点标记删除。
- newChildren没有遍历完成,oldFiber遍历完成(为null),说明新的ui里节点变多了,所以需要创建新节点加在后面。
- newChildren和oldFiber都没有遍历完,那说明节点顺序变了,也就是2个数组是错位的,那就不能单靠下标去对应做比较了。这时候的处理比较复杂,会把所有已经存在的节点存在内存的一个map结构里面,然后再开启一轮newChildren的轮训,从map里面去查询是否有可复用的节点。从这里也可以看出,我们写代码的时候也应该尽可能的保持ui组件的顺序是没有错位变化的。
其中决定节点是否可以复用的函数是updateSlot。如果不能复用节点,这个函数会返回null。具体实现我这里就不分析了,感兴趣的话你可以自己翻阅一下ReactChildFiber.js 这个文件。
总结
通过本篇学习我们能整理几个结论
- useState刷新状态的时候会进行对象的严格对比,一些简单的组件状态可以考虑避免使用object来防止多次刷新。
- 即使新旧状态的值是一样的,也有可能不会执行到新旧状态对比的逻辑。要具体情况具体分析。
- 可以设置相同key来复用节点。
- 数组节点刷新的时候尽量让节点顺序处于只新增/只删减,减少错位移动,可以增加ui刷新的效率。