React 源码揭秘 | bailout策略Memo
前面的文章介绍了React 更新 render + commit的完整流程,下面来说一下一些优化策略。
bailout 即 熔断策略,即在BeginWork阶段,如果递到某个节点的子树, 如果该节点的子树已经不需要更新了,即和current保持一致了,那么可以直接熔断(bailout)当前Beginwork过程,复用current的子树即可。
如何确定子树不用更新了?
这个取决于四要素,即current节点和新Fiber节点的
- Props相等
- type相等代码
- 当前Fiber节点不包含当然renderedLane更新
- 如果当前节点是Context节点,需要相等 (这个先不用管)
我们主要看前三条,如何判断这三条是否满足bailout条件
Props & type
props和bailout判断条件如下,代码在react-reonciler/beginwork.ts -> beginWork函数
didReceiveUpdate = false;
const current = wip.alternate;
if (current !== null) {
/** 更新模式下 才检查是否bailout */
/** 检查props和type */
const prevProps = current.memorizedProps;
const wipProps = wip.pendingProps;
/**
* 注意 bailout的props直接检查对象地址是否相等
* 如果父节点存在更新 那么子节点无法bailout 需要通过childReconcile创建
* 那么子节点的 props一定和current.props不一样 因为createElement中传入的对象也不是相同地址 比如
* current createElement('div',{a:100}) -父节点不同,导致reconcilechild-> createElement('div',{a:100})
* 注意 虽然都是{a:100} 但是两个对象来源于两次render 其对象地址不同,这也就导致如果父节点没能bailout 子节点也无法bailout 就必须使用memo来shallowEqual
*/
if (prevProps !== wipProps || current.type !== wip.type) {
// 检查不通过
didReceiveUpdate = true;
} else {
... ... ...
}
}
可以看到,Props和type的判等简单的通过 "!==" 完成
beginWork维护一个全局变量didReceiveUpdate: boolean 并且将其封装成一个函数导出
/** 是否收到更新 默认为false 即没有更新 开启bailout */
let didReceiveUpdate: boolean = false;
/** 标记当前wip存在更新 不能bailout
* 导出接口 方便其他模块 (hooks) 使用 */
export function markWipReceiveUpdate() {
didReceiveUpdate = true;
}
markWipReceiveUpdate可以方便的在其他模块内导入,并且修改didReceiveUpdate的值
判断Update
如果Props和Type都没有变化,接下来会判断当前节点是否存在当前renderLane对应的更新。
if (prevProps !== wipProps || current.type !== wip.type) {
// 检查不通过
didReceiveUpdate = true;
} else {
// 如果props和type都检查通过 检查state和context TODO
if (!checkUpdate(wip, renderLane)) {
// 进入bailout
didReceiveUpdate = false;
return bailoutOnAlreadyFinishedWork(wip, renderLane);
}
}
其中,checkUpdate就是判断当前节点的lanes是否包含renderedLane, 其实现如下:
/** 检查是否存在更新 即检查wip.lanes 是否包含当前renderLane */
function checkUpdate(wip: FiberNode, renderLane: Lane) {
// 注意 这里不要用wip.lanes直接检查,因为checkUpdate 也会在 wip.lanes = NoLane 之后调用,比如Memo中
// 此时wip.lanes可能为NoLane 所以需要使用在enqueueUpdate中同步的 current.lanes
const current = wip.alternate;
if (current !== null && includeSomeLanes(current.lanes, renderLane)) {
return true;
}
return false;
}
如果不存在当前renderLane对应的更新,那么就把didReceiveUpdate = false 并且开启当前节点的bailout流程
bailoutOnAlreadyFinishedWork用来进行bailout,其实现如下:
/** 进一步bailout
* 1. 如果childLanes也不包含renderLane 表示已经没有更新了 直接返回null 进入completework阶段
* 2. 如果childLanes还包含renderLane 表示还有更新 但是此wip节点可以直接复用子节点
*/
function bailoutOnAlreadyFinishedWork(wip: FiberNode, renderLane: Lane) {
/** 判断childLanes */
if (!includeSomeLanes(wip.childLanes, renderLane)) {
return null;
}
/** clone节点 */
cloneChildFibers(wip);
return wip.child;
}
其原理很简单,就是看当前需要bailout的子节点,是否还包含renderLane 如果没有,说明当前节点对应的子树都不存在当前renderLane对应的优先级的更新,那么后面的步骤都没必要进行了,直接return null 跳出beginWork流程,直接开始completeWork归的阶段。 这里注意,当前节点的Child此时指向的还是current节点的child。在createWorkInProgress中会复用current节点的child
如果下面的节点,还有当前renderLane对应的更新,那么就先把当前Fiber节点对应的子节点都克隆复用。
export function cloneChildFibers(wip: FiberNode) {
// 此时wip的child还是alternate的child (可能没有alternate)
if (wip.child === null) {
return;
}
let currentChild = wip.child;
let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
newChild.return = wip;
wip.child = newChild;
while (currentChild.sibling !== null) {
currentChild = currentChild.sibling;
// 找子节点
newChild = newChild.sibling = createWorkInProgress(
currentChild,
currentChild.pendingProps
);
newChild.return = wip;
}
}
cloneChildFibers 就是用createWorkInProgress 直接克隆当前Fiber节点所有的孩子节点即可。省去了reconcile的过程。
进一步判断更新结果
如果当前Fiber节点存在更新,那么就会转而去根据Fiber.tag 来调用对应的Update函数,此时还可以进一步优化,如果当前Fiber节点为函数组件,那么我们需要在在renderWithHook执行后检查,当前FIber节点更新后的值和更新之前的值书是否存在变化,如果没有变化,我们依旧可以将其当成没有更新,从而执行bailout策略
在updateState中,我们增加判断,如果本次更新结果 memorizedState和上次更新结果 lastRenderState 不相等,那么就调用markWipReceiveUpdate 来标记当前Fiber节点的更新存在State值的变化,此时就不能进一步bailout。updateState逻辑如下:
function updateState<T>(): [T, Dispatch<T>] {
const hook = updateWorkInProgressHook();
const { memorizedState } = hook.updateQueue.process(renderLane, (update) => {
currentRenderingFiber.lanes = mergeLane(
currentRenderingFiber.lanes,
update.lane
);
});
// 检查state是否变化
if (!Object.is(hook.updateQueue.lastRenderedState, memorizedState)) {
markWipReceiveUpdate();
}
hook.memorizedState = memorizedState;
hook.updateQueue.lastRenderedState = memorizedState;
return [memorizedState, hook.updateQueue.dispatch];
}
注意,判断状态使用的是Object.is()
在updateFunctionComponent函数中,我们判断一下在执行完当前组件函数之后,此时的didReceiveUpdate是否还是false,如果还是false,说明markWipReceiveUpdate没有被执行,此时State值不变
/** 处理函数节点的比较 */
function updateFunctionComponent(
wip: FiberNode,
Component: Function,
renderLane: Lane
): FiberNode {
// renderWithHooks 中检查,如果状态改变 则置didReceiveUpdate = true
const nextChildElement = renderWithHooks(wip, Component, renderLane);
if (wip.alternate !== null && !didReceiveUpdate) {
// bailout
// 重置hook
bailoutHook(wip, renderLane);
return bailoutOnAlreadyFinishedWork(wip, renderLane);
}
reconcileChildren(wip, nextChildElement);
return wip.child;
}
此时,我们需要先清空当前Fiber对象有关Hook的副作用,
并且调用bailoutOnAlreadyFinishedWork进行bailout逻辑
父子组件更新逻辑
当一个节点的父组件(函数组件)中存在状态/Props改变时,父节点对应的组件函数在BeginWork阶段一定会被重新执行,导致createElement函数被重新执行。 这导致传入的Props即便内部属性都一样,但是其地址也一定是不同的。
由于bailout对于Props的判断是简单的对比地址是否相同,所以对于父节点重新渲染的情况,其下面的所有子节点都不会bailout,也就是都会重新渲染。
不论其子组件的Props内容有无变化,也不论其state是否变动,都会直接重新渲染,如图:
很多时候,我们不希望子组件重新渲染,因为其state和props都没有变化,重新渲染会造成额外的性能开销,这个时候就要用的React提供的memo函数
React.memo - shallowEqual 对比Props
memo函数定义在 react/memo.ts下,其本质就是包裹一个memo Element对象,这个对象会在调用createElement被保存到type属性
/** memo函数 接收一个ReactElementType 组件 返回一个 REACT_MEMO_TYPE类型的ReactElement*/
export function memo(
/** 包裹的组件类型 */
type: ReactElementType,
compare?: (
oldProps: ReactElementProps,
newProps: ReactElementProps
) => boolean
): ReactElementType {
return {
$$typeof: REACT_MEMO_TYPE,
type,
compare,
} as any;
}
在createFiberFromElement中,会根据(element.type as any)?.$$typeof 来判断创建的Fiber对象属性。
同时,还保存了compare函数,以及其内部包裹的被memo组件type
在beginwork时,如果fiber.tag === MemoComponent 会调用updateMemoComponent
/** 更新MemoComponent */
function updateMemoComponent(wip: FiberNode, renderLane: Lane) {
// 需要检验四要素 type state(update) props context(TODO)
// 运行到此 type一定是相等的 需要判断state props context
const current = wip.alternate;
if (current !== null) {
// update阶段才bailout检查
const oldProps = current.pendingProps;
const newProps = wip.pendingProps;
// Props默认需要用ShallowEqual判断 可以传入compare函数替换
const compare = (wip.type as any).compare || shallowEqual;
if (compare(oldProps, newProps)) {
// 判断state context
if (!checkUpdate(wip, renderLane)) {
// 需要bailout
didReceiveUpdate = false;
// 重置props 注意 这里的oldProps newProps地址不一定一样
wip.pendingProps = oldProps;
// 重置当前lane
// 推出之后 需要恢复lanes
wip.lanes = current.lanes;
return bailoutOnAlreadyFinishedWork(wip, renderLane);
}
}
}
// 如果不能bailout 执行函数
const Component = (wip.type as any).type;
return updateFunctionComponent(wip, Component, renderLane);
}
updateMemoComponent函数的逻辑也很简单,如果传入了compare函数,就调用compare函数,如果没有则使用内部的shallowEqual函数,对比Props内部的属性是否真的相等,其逻辑就是逐一对比对象的属性值:
/** 对比两个对象中的属性是否浅比较相等
* shallowEqual在React.memo 对比curent.memorizedProps 和 wip.pendingProps中使用 区分hooks中的 areHookInputsEqual 后者判断的是数组
*/
export default function shallowEqual(obj1: any, obj2: any) {
// 先用Object.is 对比排除 基本类型 相同地址对象的情况
if (Object.is(obj1, obj2)) {
return true;
}
// 排除obj1 obj2 任意一个不是对象或者null的情况
if (
typeof obj1 !== "object" ||
obj1 === null ||
typeof obj2 !== "object" ||
obj2 === null
) {
return false;
}
// 运行到此 obj1 obj2 一定是对象 并且都不是null 开始判断其属性
// 属性数量判断 不一样一定属性不相等
if (Object.keys(obj1).length !== Object.keys(obj2).length) return false;
// 逐个判断属性
for (const key in obj1) {
if (
!Object.prototype.hasOwnProperty.call(obj2, key) ||
Object.is(obj1[key], obj2[key])
) {
// 判断 key在obj1内 但是为undefined 但是在obj2中不存在的情况 或者 都存在 但是值不等的情况
return false;
}
}
return true;
}
如果属性值相等,说明props没有变化,这个时候再检查有无更新,如果都没有,则设置didReceiverProps为false开启bailout流程
如果props有变化,或者存在当前renderLane对应的更新,则获取当前运行的函数组件 wip.type.type。并且调用updateFunctionComponent更新
所以可以看到,memo的作用就是补足简单判断props地址的bailout策略,如果你希望一个组件在父组件更新的时候,保持稳定,如果没有更新或者props变动不重新渲染,可以使用这个方法!
这个函数通常用来搭配 useMemo / useCallback 使用,把传入memo组件的属性/函数缓存,保证shallowEqual函数能判断两次更新的属性/函数相等