React 源码揭秘 | bailout策略&Memo

前面的文章介绍了React 更新 render + commit的完整流程,下面来说一下一些优化策略。

bailout 即 熔断策略,即在BeginWork阶段,如果递到某个节点的子树, 如果该节点的子树已经不需要更新了,即和current保持一致了,那么可以直接熔断(bailout)当前Beginwork过程,复用current的子树即可。

如何确定子树不用更新了?

这个取决于四要素,即current节点和新Fiber节点的

  1. Props相等
  2. type相等代码
  3. 当前Fiber节点不包含当然renderedLane更新
  4. 如果当前节点是Context节点,需要相等 (这个先不用管)

我们主要看前三条,如何判断这三条是否满足bailout条件

Props & type

props和bailout判断条件如下,代码在react-reonciler/beginwork.ts -> beginWork函数

TypeScript 复制代码
  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 并且将其封装成一个函数导出

TypeScript 复制代码
/** 是否收到更新 默认为false 即没有更新 开启bailout */
let didReceiveUpdate: boolean = false;

/** 标记当前wip存在更新 不能bailout
 * 导出接口 方便其他模块 (hooks) 使用 */
export function markWipReceiveUpdate() {
  didReceiveUpdate = true;
}

markWipReceiveUpdate可以方便的在其他模块内导入,并且修改didReceiveUpdate的值

判断Update

如果Props和Type都没有变化,接下来会判断当前节点是否存在当前renderLane对应的更新。

TypeScript 复制代码
    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, 其实现如下:

TypeScript 复制代码
/** 检查是否存在更新 即检查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,其实现如下:

TypeScript 复制代码
/** 进一步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节点对应的子节点都克隆复用。

TypeScript 复制代码
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逻辑如下:

TypeScript 复制代码
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值不变

TypeScript 复制代码
/** 处理函数节点的比较 */
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属性

TypeScript 复制代码
/** 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

TypeScript 复制代码
/** 更新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内部的属性是否真的相等,其逻辑就是逐一对比对象的属性值:

TypeScript 复制代码
/** 对比两个对象中的属性是否浅比较相等
 *  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函数能判断两次更新的属性/函数相等

相关推荐
betterangela3 分钟前
react工程化开发
前端·javascript·vue.js·react.js·前端框架
计算机科研狗@OUC12 分钟前
Firefox缩小标签页高度以及自定义调整
前端·firefox
lmy_loveF13 分钟前
安装 cnpm 出现 Unsupported URL Type “npm:“: npm:string-width@^4.2.0
前端·npm·node.js
伟笑43 分钟前
elementUI 表格隔行换色,修改table表头背景样式
前端·javascript·elementui
m0_748234521 小时前
SpringMVC 请求参数接收
前端·javascript·算法
@PHARAOH1 小时前
HOW - 在Windows浏览器中模拟MacOS的滚动条
前端·macos
叫我OldFe1 小时前
vue videojs使用canvas截取视频画面
前端·vue.js·音视频·js
七爷不在我这里1 小时前
charles 抓取https<仅web端>
前端·网络协议·https·charles
CsharpDev-奶豆哥1 小时前
ASP.NET实现上传图片生成缩略图的功能
服务器·前端·asp.net
timer_0171 小时前
Skyeye 云智能制造办公系统 VUE 版本 v3.15.11 发布
前端·vue.js·制造