likes
comments
collection
share

从源码学 API 系列之 React.memo()

作者站长头像
站长
· 阅读数 13

Note: 本文是基于 react@18.2.0 源码进行研究的成果。

阅读本文之前,请建议阅读 《770 行代码还原 react fiber 初始链表构建过程

API 签名

const MemoizedComponent = memo(SomeComponent, arePropsEqual?)

API 功能

memo lets you skip re-rendering a component when its props are unchanged.

React.memo() 函数能让你的组件在 props 没有发生变化的情况下跳过重渲染(re-render)。react 新的官方文档如是说道

众所周知,react 的 re-render 模型是这样的:当某个组件因为自己的内部状态发生变化而重渲染的时候,以它自己为根节点的组件子树上的所有子孙组件默认都会被迫进行重渲染。即使,该子孙组件的 propsstate 没有发生变化的变化。这种重渲染,我们是称之为「被迫的,不必要的重渲染」。

React.memo() 函数就是要来避免这种「被迫的,不必要的重渲染」。在新的官方文档中,关于这个 API 的理解和使用,也给出不少善意的提醒。总结有下面这几点:

  • React.memo() 常常用于 function component,其实它能用于所有类型的 react 组件;

  • React.memo() 函数只是避免因为父组件重渲染而导致的「被迫的,不必要的重渲染」。它并不会阻碍主动的重渲染:

    • 组件自身状态的变化而导致的重渲染
    • 组件所消费的 context 值发生变化而导致的重渲染
  • React.memo() 函数在比较 props 是否发生变化时采用的是「浅比较算法」。而浅比较算法的核心部分 - 值的相等性比较所采用的算法是Object.is()

  • React.memo() 一般需要搭配 useCallback() 或者 useMemo()使用才能实现真正「避免不必要的重渲染」;

  • 我们可以通过自定义 arePropsEqual 函数来定制判断新旧 props 是否发生变化的算法。该函数返回true,即代表着我们认为新旧 props 是没有发生变化的;反之,则认为发生了变化。

原理关注点

React.memo() 的返回值是什么?

function component 就是一个普通的 js 函数,这个 js 函数放在 JSX 标签里面就会被转译为 react element。这应该是人尽皆知的基础知识了吧。

React.memo() 的返回的什么?我们从它的源码来一探究竟:

function memo(type, compare) {
    const elementType = {
      $$typeof: REACT_MEMO_TYPE,
      type,
      compare: compare === undefined ? null : compare,
    };

    return elementType;
  }

$$typeof 属性是 react element 这种数据结构的标志。所以说,React.memo() 的返回的也是 react element。

在 react 中,有很多种类型的 react element,在文件react@18.2.0/packages/shared/ReactSymbols.js 中我们可以查看所有的这些类型:

// The Symbol used to tag the ReactElement-like types.
export const REACT_ELEMENT_TYPE = Symbol.for('react.element');
export const REACT_PORTAL_TYPE = Symbol.for('react.portal');
export const REACT_FRAGMENT_TYPE = Symbol.for('react.fragment');
export const REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode');
export const REACT_PROFILER_TYPE = Symbol.for('react.profiler');
export const REACT_PROVIDER_TYPE = Symbol.for('react.provider');
export const REACT_CONTEXT_TYPE = Symbol.for('react.context');
export const REACT_SERVER_CONTEXT_TYPE = Symbol.for('react.server_context');
export const REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref');
export const REACT_SUSPENSE_TYPE = Symbol.for('react.suspense');
export const REACT_SUSPENSE_LIST_TYPE = Symbol.for('react.suspense_list');
export const REACT_MEMO_TYPE = Symbol.for('react.memo');
export const REACT_LAZY_TYPE = Symbol.for('react.lazy');
export const REACT_SCOPE_TYPE = Symbol.for('react.scope');
export const REACT_DEBUG_TRACING_MODE_TYPE = Symbol.for(
  'react.debug_trace_mode',
);
export const REACT_OFFSCREEN_TYPE = Symbol.for('react.offscreen');
export const REACT_LEGACY_HIDDEN_TYPE = Symbol.for('react.legacy_hidden');
export const REACT_CACHE_TYPE = Symbol.for('react.cache');
export const REACT_TRACING_MARKER_TYPE = Symbol.for('react.tracing_marker');

从这个文件中,我们认识到一个关于 react element 类型的基本概念模式 - 要么是以内置组件的名称要么是以对基本组件进行包裹的这个 API 的名称来 react element 类型进行命名的。

虽然,React.memo() 的返回值 react element,但是它跟最普通,最常见的 react element 是不一样的。最普通,最常见的 react element 的数据结构是这样的:

{
    $$typeof: Symbol(react.element),
    key: null,
    props: {},
    ref: null,
    type: "div" // 或者是代表组件的 js 函数(普通 function 或者 ES6 class)
}

关于这个差异点,我们需要格外的注意。

<MemoizedComponent /> 组件的本质?

我们都知道,普通的 host component, function component 或者 class component 一旦放在 JSX 标签里面,都会被转译为最普通的 react element。它们自己会成为 react elment type 属性的值。

那你有没有想过,被 memoized 过的普通组件,它放在 JSX 标签里面,它会被转译成什么样子呢?假如,我们现在有一个以下代码:

代码片段1

const MemoizedCounter = memo(
  function Counter(){
    const [count, setCount] = useState(0);

    const increment = useCallback(() => {
      setCount(count + 1);
    }, [count]);

    const decrement = useCallback(() => {
      setCount(count - 1);
    }, [count]);

    return (
      <div class="counter">
        <h1>Counter: {count}</h1>
        <button onClick={increment}>
          +
        </button>
        <button onClick={decrement}>-</button>
      </div>
    );
  });

请问 JSX 标签 <MemoizedCounter /> 会被编译成什么样子呢?其实,答案还是遵循 JSX 编译普通组件的规则 - 直接把 MemoizedCounter 这个变量赋值给普通 react element 的 type 属性。结果如下:

{
    $$typeof: Symbol(react.element),
    key: null,
    props: {},
    ref: null,
    type: MemoizedCounter
}

// 展开 `MemoizedCounter`, 结果如下
{
    $$typeof: Symbol(react.element),
    key: null,
    props: {},
    ref: null,
    type: {
      $$typeof: REACT_MEMO_TYPE,
      type: Counter, 
      compare: null,
    }
}

如果没有被 React.memo() 所 memoized 过,我们的 JSX 标签 <Counter /> 会被编译成普通的 react element,它是这样的:

{
    $$typeof: Symbol(react.element),
    key: null,
    props: {},
    ref: null,
    type: Counter
}

注意看,一个 function component 一旦被被 React.memo() 所 memoized 过, 我们的 Counter 函数已经被嵌套到第二层了。后面,我们且看在 react 源码中,它是怎么一层层解开,然后调用我们的 Counter 函数的。

<MemoizedComponent /> 是如何参与到 fiber 树的构建?

要想弄清楚终极问题的答案之前,我们需要一个前置知识 - 「<MemoizedComponent /> 是如何参与到 fiber 树的构建中来的」。

770 行代码还原 react fiber 初始链表构建过程 一文中,我们讲解了 react 应用 mount 阶段初始 fiber 树的构建过程。react 是通过对当前的 workInProgress(fiber 节点)进行 begin-work 来层层向下去创建子 fiber 节点的。在 beginWork()这个大函数里面,react 是使用枚举法,枚举了 react 内部所有类型的 fiber 节点, case-by-case 地去把不同类型的 workInProgress (记住,workInProgress 是一个 fiber 节点)交给不同的 helper 函数来处理的。

因为当前,我们是在探索「<MemoizedComponent /> 是如何参与到 fiber 树的构建?」,所以,我们只关注 begin-work 流程即可。而这个流程可以用下面的架构图来表示:

从源码学 API 系列之 React.memo()

从上面的架构图,我们可以看出,不管你是那种类型的 fiber 节点,最终都会进入以 reconcileChildren() 函数为入口的 reconciliation 流程。在 react 应用的 mount 阶段(更严谨来说,是 react 组件的 mount 阶段),reconciliation 流程的主要任务是从零开始以 react element 为原料来创建一个全新的 fiber 节点。创建全新 fiber 节点流程的入口函数为 createChild()。从该入口函数到最底部函数的调用栈如下:

createChild()
createFiberFromElement()
createFiberFromTypeAndProps()
createFiber()
new FiberNode()

因为并不是所有 react 组件所对应的 react element 都是平铺直叙的一层。这里的「平铺直叙的一层」指的是值为 string 或者 function(代表的就是用户直接定义的组件) 的 type 属性所在字面量对象中的层次。比如上面指出过:

注意看,一个 function component 一旦被被 React.memo() 所 memoized 过, 我们用户定义的 Counter 函数已经被嵌套到第二层了。

<MemoizedCounter /> 所对应的 react element 的数据结构就不是只有一层结构。正因为如此,上面所提到的那一段创建全新 fiber 节点流程的调用栈中,最为核心的是 createFiberFromTypeAndProps() 函数。因为该函数包含了 react 如何去剥解那些非一层结构的 react element 的逻辑。

该函数名中的TypeProps指的就是 react element 的 type 属性值和 props 属性值。如果要给它换一个更具体和冗长一点的名字,那应该是 createFiberFromReactElementTypeAndReactElementProps()

下面用一个实例进行说明。我们沿用上面的 <MemoizedCounter /> 组件,外加下面的 <App /> 组件

代码片段3

function App(){
    return (
        <div className="App" >
           <MemoizedCounter />
        </div>
      );
}

结合上面的给出的实例代码,我们可以将本主题拆分为三个小主题:

  • react 会为 <MemoizedCounter /> 创建单独的 fiber 节点吗?
  • 如果会,react 是会为 <MemoizedCounter /> 创建怎样的 fiber 节点呢?
  • 我们用户定义的组件函数 Counter 它最终挂在怎样的 fiber 节点上?

react 会为 <MemoizedCounter /> 创建相应的的 fiber 节点吗?

答案是:”会“。

对 begin-work 流程熟悉(不熟悉的人可以看我的770 行代码还原 react fiber 初始链表构建过程 )的人都知道,假如,react 会为 <MemoizedCounter /> 创建相应的的 fiber 节点,那么这个动作就应该发生在 react 对 div.App 所对应的 fiber 节点进行 begin-work 的时候。届时,<MemoizedCounter /> 对应的 react element 会成为 nextChildren 一路透传到 reconcileChildFibers() 函数里面。该函数的代码架构如下:

function reconcileChildFibers(
      returnFiber,
      currentFirstChild,
      newChild,
      lanes
    ){
    if (typeof newChild === "object" && newChild !== null) {
    
         // newChild 是单个element 走这里
         switch (newChild.$$typeof) {
           case REACT_ELEMENT_TYPE:
            return placeSingleChild(
              reconcileSingleElement(
                returnFiber,
                currentFirstChild,
                newChild,
                lanes
              )
            );
            // ...... other case
         }
         
        // newChild 是多个 element 走这里
        if (isArray(newChild)) {
        // ......
        }
    
    }
    
  if (
    (typeof newChild === "string" && newChild !== "") ||
    typeof newChild === "number"
  ) {
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        "" + newChild,
        lanes
      )
    );
}
}

此时 newChild 就是 <MemoizedCounter /> 所对应的 react element。还记得它长什么样子吗?复习一下:

{
    $$typeof: Symbol(react.element), // REACT_ELEMENT_TYPE 常量就是指向这个 symbol。
    key: null,
    props: {},
    ref: null,
    type: {
      $$typeof: REACT_MEMO_TYPE,
      type: Counter, 
      compare: null,
    }
}
  1. typeof newChild === "object" && newChild !== null 这个条件能通过
  2. 因为 REACT_ELEMENT_TYPE 常量指向就是 Symbol(react.element) 这个 symbol。<MemoizedCounter /> 所对应的 react element 的 type 就是 Symbol(react.element)

所以,很明显,我们最终进入了 reconcileSingleElement()。在该函数内部,react 会最终调用 createFiberFromElement() 函数来创建全新的 fiber 节点,并返回这个 fiber 节点。

看来,毫无意外,react 是会为 <MemoizedCounter /> 创建相应的 fiber 节点。

如果会,react 是会为 <MemoizedCounter /> 创建怎样的 fiber 节点呢?

关于这个问题的答案,是不固定的。它取决于用户对原始 function component 进行 memoize 的时候是否传递 arePropsEqual 函数!!

如果传递了,那么 react 就会为 <MemoizedCounter /> 创建 MemoComponent 类型(fiber 的 tag 属性为 MemoComponent )的 fiber 节点;否则,就创建 SimpleMemoComponent 类型的 fiber 节点。

承接回上一小节。我们会最终进入 createFiberFromTypeAndProps() 函数来创建 <MemoizedCounter /> 所对应的 fiber 节点。createFiberFromTypeAndProps() 函数的代码量虽然很大,但是,其实它只是实现了一个简单的功能: 那就是计算出将要创建的 fibe 节点的 tag 值。

就这么一个简单的功能,为什么需要这么大的代码量呢?这一切归咎于一个事实:在 react 中,存在着大量结构不一 的 react element。

当代码执行到了 createFiberFromTypeAndProps() 函数内部,此时传递给它的第一个实参 type 就是 <MemoizedCounter /> element 的 type 属性值,即:

代码片段2

{
  $$typeof: REACT_MEMO_TYPE,
  type: Counter, 
  compare: null,
}

熟悉 react 原理的人就知道了,一般而言,react element 的 type 属性值不是字符串类型就是 function 类型。到这里,事情就变得复杂了。因为,type 属性值在这里是 object 类型。这也是我第一次看到的 object 类型的 react element。

通读一遍 createFiberFromTypeAndProps() 函数的源码,我们发现 react element 的 type 属性值的数据类型有四大类:

  1. 字符串类型
  2. function 类型
  3. symbol 类型
  4. object 类型

不同的类型,react 计算 tag 值的方式的方式不一样。很明显,当下,我们的 <MemoizedCounter /> element 的 type 值属于第四类。对于第四类,react 会将该字面量对象再剥开一层,让它的 $$typeof 属性来决定 tag 的值。上面的这段逻辑对应下面的源码:

function createFiberFromTypeAndProps(
    type, // React$ElementType
    key,
    pendingProps,
    owner,
    mode,
    lanes){
   // ......
   default: {
          if (typeof type === "object" && type !== null) {
            // 在这里 `type` 的值是长这样:
            // {
            //   $$typeof: REACT_MEMO_TYPE,
            //   type: Counter, 
            //   compare: null,
            // }
            switch (type.$$typeof) { // 在这里再剥一层
              // ......
              case REACT_MEMO_TYPE:
                fiberTag = MemoComponent;
                break getTag;
             // ......
            }
          }

          let info = "";

          throw Error(
            formatProdErrorMessage(130, type == null ? type : typeof type, info)
          );
        }
        // ......
}

到这里,该主题的答案似乎已经出来了:「react 是会为 <MemoizedCounter /> 创建 MemoComponent 类型 fiber 节点」。这是真的吗?显然不是。因为在上面我早就指出:

关于这个问题的答案,是不固定的。它取决于用户对原始 function component 进行 memoized 的时候是否传递 arePropsEqual 函数!!

事情的转机发生在下一轮的 begin-work 中。一般而言,下一轮的 begin-work 的任务就是基于当前的 workInProgress(<MemoizedCounter /> 所对应的 fiber 节点) 和 nextChildren ,通过 reconciliation 流程来计算出它的子 fiber。

现在,我们开始对 <MemoizedCounter /> 所对应的 MemoComponent 类型 fiber 节点进行 begin-work。所以,理所当然,我们会进入 updateMemoComponent() 函数里面。因为当前是 react 应用的 mount 阶段(所有的 workInProgress fiber 所对应的 current fiber 都是 null ),因此,我们需要关注 current === null 的条件分支语句:

  function updateMemoComponent(
    current,
    workInProgress,
    Component,
    nextProps,
    renderLanes
  ) {
    if (current === null) {
      const type = Component.type;

      if (
        isSimpleFunctionComponent(type) &&
        Component.compare === null && // SimpleMemoComponent codepath doesn't resolve outer props either.
        Component.defaultProps === undefined
      ) {
        let resolvedType = type;
        // and with only the default shallow comparison, we upgrade it
        // to a SimpleMemoComponent to allow fast path updates.

        workInProgress.tag = SimpleMemoComponent;
        workInProgress.type = resolvedType;

        return updateSimpleMemoComponent(
          current,
          workInProgress,
          resolvedType,
          nextProps,
          renderLanes
        );
      }

      const child = createFiberFromTypeAndProps(
        Component.type,
        null,
        nextProps,
        workInProgress,
        workInProgress.mode,
        renderLanes
      );
      child.ref = workInProgress.ref;
      child.return = workInProgress;
      workInProgress.child = child;
      return child;
    }
}

到了这里,变量 component 的值就是如「代码片段2」那样。而 component.type 就是我们关注的,我们自己定义的 Counter 函数。上面的代码的意思就是:

  • 情况1 - 如果当前组件函数能够满足以下的三个条件:

    • 是真正的 function component(而不是 ES6 的 class component,因为使用 typeof 操作符对两者进行判断,结果都是 function );
    • 用户进行 memoize 的时候,没有传递用于自定义逻辑的 arePropsEqual函数;
    • 组件函数上面没有定义 defaultProps

    那么,我们就直接把当前的 workInProgress 的类型修正为 SimpleMemoComponent 类型,同时 workInProgress.type 指向组件函数本身(从这里,我们可以看出, workInProgress.type 的语义是 resolvedType,而 workInProgress.elementType 的语义是当前 fiber 所对应的 react element 的 element type)。最后,从修正后的 workInProgress 重新开始,对它进行begin-work - 即调用 updateSimpleMemoComponent() helper 函数。而 updateSimpleMemoComponent() 的与源码架构如下:

     function updateSimpleMemoComponent(
        current,
        workInProgress,
        Component,
        nextProps,
        renderLanes
      ) {
      if (current !== null) {
          // ......
      }
    
      return updateFunctionComponent(
          current,
          workInProgress,
          Component,
          nextProps,
          renderLanes
        );
      }
    

    因为当前是 react 应用的 mount 阶段,所以,最终会走到 updateFunctionComponent() 这个 helper 函数里面来。

    兜兜转转,我们还是没有进入 reconciliation 流程,而是回到 updateFunctionComponent() 这个 helper 函数中来。也就是说,如果没有传递用于自定义逻辑的 arePropsEqual函数的话,那么之前的对 MemoComponent 的 begin-work 就等同于下面的两件事:

    • 首先,将当前 workInProgress 修改为 SimpleMemoComponent 类型;
    • 然后,走 updateFunctionComponent() helper 函数来创建处于下一层的 child fiber 节点。

    最后,我们得到的 fier 树的层级(从应用的根 react element 开始)是这样的:

    div.App
    SimpleMemoComponent
    div.counter
    h1
    ......
  • 情况2 - 如果当前组件函数不能同时不满足上面的三个条件,那么 react 就跳过后续的常规 helper 函数都会走的 reconciliation 流程,直接以组件函数为 element type,为 workInProgress 创建一个相应类型的子 fiber 节点。然后进入下一轮的 begin-work 流程。

    上面提到过,React.memo() 可以作用于所有类型的 react 组件。当前,我们只讨论对 function component 进行 memo 的话,那么,最后我们得到的 fiber 树的层级(从应用的根 react element 开始)是这样的:

div.App
MemoComponent
FunctionComponent
div.counter
h1
......

从上面给出的两个 fiber 树示意图中,我们可以看出这两种情况下,react 创建的 fiber 树结构是不一样的。情况2相比情况1会多了一层 (FunctionComponent 节点那一层)。与此同时,同一层相比,fiber 节点的类型也是不一样的。情况1的是 SimpleMemoComponent 类型 fiber,而情况2是 MemoComponent 类型的 fiber。

小结

从上面的探索,我们得知,要想成为 react 口中的 SimpleMemoComponent,则要同时满足上面提到三个条件:

  1. 是真正的 function component(而不是 ES6 的 class component,因为使用 typeof 操作符对两者进行判断,结果都是 function );
  2. 用户进行 memoize 的时候,没有传递用于自定义逻辑的 arePropsEqual函数;
  3. 组件函数上面没有定义 defaultProps

如果只考虑对 function component 进行 memo 的用例(条件1肯定满足,同时,我们也不会在组件函数身上定义 defaultProps属性),那么 memoized 之后的组件是否能成为 SimpleMemoComponent 的先决条件无疑是剩下一个:「用户进行 memoized 的时候,没有传递用于自定义逻辑的 arePropsEqual函数」:

  • 如果用户传递了该函数,则 react 会为 <MemoizedComponent /> 创建一个 MemoComponent 类型的 fiber 节点。而该 fiber 节点的子节点将会是原始组件类型(本文指讨论 function component,则这里是指 FunctionComponent)的 fiber 节点;
  • 如果用户没有传递的话,那么 react 就会为 <MemoizedComponent /> 创建一个 SimpleMemoComponent 类型的 fiber 节点。它的下一层的子组件将会直接是由原始组件返回的 react element 所创建的 fiber 节点。也就是说,相比于上面一种情况,fiber 树将会少了一层结构。就是 react 在源码注释中所提到的「fast path」的意思。

被 memoize 之前的组件它最终挂在怎样的 fiber 节点上?

通过上面的讨论,如果一个 <MemoizedComponent /> 被 react 定性 SimpleMemoComponent 的话,那么被 memoized 之前的组件将会被挂载到 SimpleMemoComponent 类型 fiber 节点的 type 属性上;否则的,话,<MemoizedComponent /> 被 react 定性 MemoComponent。届时,被 memoized 之前的组件就会被挂载到它的下一层的子 fiber 上。该子 fiber 的类型就是由被 memoized 之前的组件类型所决定(比如,function component 就是对应 FunctionComponent,class component 对应的就是 ClassComponent等等)。

<MemoizedComponent /> 是如何实现跳过重渲染的?

如果你有认真阅读,到了这里,你已经具备了理解这个问题的前置知识。不难理解,react 只会在 react 应用的 update 阶段(或者说组件的 update 阶段)才会去考虑是否要跳过重渲染。为什么?因为在 mount 阶段,prevProps 还不存在呢,那怎么进行新旧 props 比较呢?另外一点,update 阶段,react 在 render 阶段依然会对需要更新的子树进行 begin-work,继而可能会进入 reconciliation 流程,也可能不进入 reconciliation 流程,而是调用 bailoutOnAlreadyFinishedWork() 函数,跳过 <MemoizedComponent /> 的重渲染。

所以,在 react 应用的 update 阶段,我们涉及的还是那几个 helper 函数:

  • updateMemoComponent()
  • updateSimpleMemoComponent()
  • updateFunctionComponent()

在这几个 helper 函数里面,都分了两种 current fiber 为 null 和不为 null 代码分支。本主题涉及的是 update 阶段,所以,这些 helper 函数里面,我们只需要看 「current fiber 不为 null」的分支代码即可。

上面一小节也提到过,如果当前只讨论对 function component 进行 memo 的场景,那么<MemoizedComponent /> 会根据我们用户是否传递 arePropsEqual 函数来分化为两条路径:

  • 一条是以 MemoComponent 类型 fiber 为首的路径,我们称之为 「slow path」
  • 另外一条是以 SimpleMemoComponent 类型 fiber 为首的路径,我们称之为 「fast path」

下面,我们分别针对这两条路径进行探索,看看 react 是如何实现跳过重渲染的。

fast path

fast path 我们会进入 updateSimpleMemoComponent() 这个 helper 函数。进入函数体,只看 current fiber 不为 null 的代码,react 跳过重渲染的逻辑代码能够看得一清二楚:

function updateSimpleMemoComponent(
    current,
    workInProgress,
    Component,
    nextProps,
    renderLanes
  ) {
    if (current !== null) {
      const prevProps = current.memoizedProps;

      if (
        shallowEqual(prevProps, nextProps) &&
        current.ref === workInProgress.ref
      ) {
        didReceiveUpdate = false; 
        workInProgress.pendingProps = nextProps = prevProps;

        if (!checkScheduledUpdateOrContext(current, renderLanes)) { 
          workInProgress.lanes = current.lanes;
          return bailoutOnAlreadyFinishedWork(
            current,
            workInProgress,
            renderLanes
          );
        } else if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
          // This is a special case that only exists for legacy mode.
          // See https://github.com/facebook/react/pull/19216.
          didReceiveUpdate = true;
        }
      }
    }
    
    // ......
}

显而易见,react 设置了两个需要同时满足的条件:

  1. shallowEqual(prevProps, nextProps) && current.ref === workInProgress.ref - 用「浅比较算法」来检查新旧 props 的相等性,两者必须相等,并且 ref 值前后没有变化,指向同一个对象实例;
  2. !checkScheduledUpdateOrContext(current, renderLanes) - 以原始组件为根节点的组件树中的所有子孙组件没有主动发起更新或者它们所消费的 context 对象的值没有发生改变(如果是不是这样情况的话,原始组件肯定是需要进入 reconciliation 流程的)。

一般情况下,我们会通过第二个条件。同时,我们也不会在 update 阶段去传递一个新的 ref 对象给同一个组件。所以,决定是否要跳过重渲染的条件就剩下一个,那就是 - 「新旧 props 用浅比较的算法去检查相等性」。在这种比较算法下,如果新旧算法是相等的,那么 react 就会跳过重渲染。

到这里,我们也算呼应了 react 新官文文档里面给出的信息 - 「如果用户不传递 arePropsEqual 函数进来的话,那么 react 就会采用浅比较算法来判断新旧 props 是否相等 」

slow path

slow path 我们会进入 updateMemoComponent() 这个 helper 函数。进入函数体,只看 current fiber 不为 null 的代码,react 跳过重渲染的逻辑代码同样能够看得一清二楚:

  function updateMemoComponent(
    current,
    workInProgress,
    Component,
    nextProps,
    renderLanes
  ) {
    // ......
    const currentChild = current.child; // This is always exactly one child

    const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
      current,
      renderLanes
    );

    if (!hasScheduledUpdateOrContext) {
      // This will be the props with resolved defaultProps,
      // unlike current.memoizedProps which will be the unresolved ones.
      const prevProps = currentChild.memoizedProps; // Default to shallow comparison

      let compare = Component.compare;
      compare = compare !== null ? compare : shallowEqual;

      if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
        return bailoutOnAlreadyFinishedWork(
          current,
          workInProgress,
          renderLanes
        );
      }
    } 
}

可以看得出,跟 fast path 一样,react 也是设置了两个需要同时满足的条件。这两个条件在语义上是跟 fast path 是完全一样的,这里就不再赘述了。唯一不同的是,比较新旧 props 所采用的算法不一样。这一次,比较算法完全由用户自己决定 - Component.compare 指代的就是用户传递进来的 arePropsEqual 函数。

关于 bailoutOnAlreadyFinishedWork() 函数

上面的两个路径,最终都会调用 bailoutOnAlreadyFinishedWork() 函数。

因为「组件渲染」的确切含义就是「调用组件的渲染函数」。对于,function component 而言,这个渲染函数就是组件函数本身,而对于 class component 而言,就是组件实例上的 render 方法。所以,如果 bailoutOnAlreadyFinishedWork() 函数最终并没有调用原始组件的渲染函数的话,我们就可以说「实现了真正的跳过重渲染」。

所以,我们得到 bailoutOnAlreadyFinishedWork() 函数中去一看究竟:

  function bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) {
    // ......

    if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
      // The children don't have any work either. We can skip them.
      // TODO: Once we add back resuming, we should check if the children are
      // a work-in-progress set. If so, we need to transfer their effects.
      {
        return null;
      }
    } 
     
    // This fiber doesn't have work, but its subtree does. Clone the child
    // fibers and continue.
    cloneChildFibers(current, workInProgress);
    return workInProgress.child;
  }

从源码注释,我们可以看到,!includesSomeLane(renderLanes, workInProgress.childLanes) 其实就是表示子树中并没有任何的需要在 commit 阶段执行的 work。这个时候,react 直接返回 null。这个 null 会一层层地返回上去,最终作为对 <MemoizedComponent /> 进行 beginWork() 调用的结果。

好的,在这个条件分支的代码中,我们确实没有看到我们原始组件的渲染函数被调用,符合「跳过重渲染」的定义。

那么,在另外一个条件分支的代码中呢?答案藏在 cloneChildFibers() 函数体中:

  function cloneChildFibers(current, workInProgress) {
    if (current !== null && workInProgress.child !== current.child) {
      throw Error(formatProdErrorMessage(153));
    }

    if (workInProgress.child === null) {
      return;
    }

    let currentChild = workInProgress.child;
    let newChild = createWorkInProgress(
      currentChild,
      currentChild.pendingProps
    );
    workInProgress.child = newChild;
    newChild.return = workInProgress;

    while (currentChild.sibling !== null) {
      currentChild = currentChild.sibling;
      newChild = newChild.sibling = createWorkInProgress(
        currentChild,
        currentChild.pendingProps
      );
      newChild.return = workInProgress;
    }

    newChild.sibling = null;
  }

肉眼可见,在cloneChildFibers() 函数体中,都是克隆或者新建 fiber 节点的直接操作。这些操作都不需要涉及组件渲染函数的调用。

看来,bailoutOnAlreadyFinishedWork() 函数确实是没有去调用我们的组件渲染函数。所以,它是实现了真正的「跳过重渲染」的。

小结

一个 <MemoizedComponent /> 组件是否会跳过重渲染,主要是取决于新旧 props 的相等性比较结果。如果新旧 props 是相等的,则 react 就会跳过 reconciliation 流程」,因而跳过组件的重渲染。而采用什么样的相等性比较算法取决于用户自己。

如果用户有传递 arePropsEqual 函数进来,则使用arePropsEqual 函数来充当相等性比较算法。arePropsEqual() 函数会被传入新旧 props (从源码 compare(prevProps, nextProps)...,我们可以得知,第一个实参是旧 props,第二实参是新 props),调用结果为 true,则是在告诉 react,我们认为新旧 props 是相等的,没有发生变化。

如果没有传递 arePropsEqual 函数给到 React.memo(),react 则默认会采用自己的「浅比较算法」。关于 react 对浅比较算法的实现,我们可以接在下一节去讨论。

react 浅比较算法的实现

二话不说,我们直接上它的源码:

  function shallowEqual(objA, objB) {
    if (objectIs(objA, objB)) {
      return true;
    }

    if (
      typeof objA !== "object" ||
      objA === null ||
      typeof objB !== "object" ||
      objB === null
    ) {
      return false;
    }

    const keysA = Object.keys(objA);
    const keysB = Object.keys(objB);

    if (keysA.length !== keysB.length) {
      return false;
    } // Test for A's keys different from B.

    for (let i = 0; i < keysA.length; i++) {
      const currentKey = keysA[i];

      if (
        !hasOwnProperty.call(objB, currentKey) ||
        !objectIs(objA[currentKey], objB[currentKey])
      ) {
        return false;
      }
    }

    return true;
  }

上面用到的 objectIs() 函数已经是一个包含 polyfill 的兼容写法,它的源码是这样的:

  function is(x, y) {
    return (
      (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
    );
  }

  const objectIs = typeof Object.is === "function" ? Object.is : is;

不管怎样,其实在比较两个 object key 的值是否相等时,react 所采用的算法就是 ECMAScript 里面的 Object.is() 算法。而关于这个算法的实现和细节,我已经在触摸 react 的命门 - 值的相等性比较(上篇)这篇文章里面介绍了,感兴趣的可以去看看。

综上所述,理解 react 浅比较算法剩下需要做的就是:理解 react 是如何认为两个对象字面量是相等的

shallowEqual() 的源码中,我们不难得出这样的总结:

react 浅比较算法认为两个字面量对象(objAobjB)是否相等的判断逻辑是这样的:

  • 如果 objAobjB 指向的是同一个引用的话,react 就直接认为它们是相等的;
  • 如果 objAobjB 指向的不是同一个引用的话,但它满足下面其中的一个条件,那么这两者就是不相等的:
    1. objAobjB 其中一个不是 object 类型;
    2. objAobjB 的自身属性数量不相等;
    3. 虽然 objAobjB 的自身属性数量相等,但是某个同名的 key 所对应的属性值用 Object.is() 算法去判断是不相等的;
  • 如果 objAobjB 指向的不是同一个引用的话同时它们没有命中上面的三种情况,那么 react 认为两者是相等的。

case 1

下面问题来了。两个空的字面量对象进行比较(shallowEqual({},{}))的结果是什么?

结果是:「相等的」。为什么?因为相比于当前的实现,react 并没有这么写:

  function shallowEqual(objA, objB) {
    if (!objectIs(objA, objB)) {
      return false;
    }
    
    // ......
  }

react 的浅比较算法觉得,即使你不是同一个引用,但是只要你不命中下面的三个条件:

  1. objAobjB 其中一个不是 object 类型;
  2. objAobjB 的自身属性数量不相等;
  3. 虽然 objAobjB 的自身属性数量相等,但是某个同名的 key 所对应的属性值用 Object.is() 算法去判断是不相等的;

那么,我还是认为你们是相等的。而两个空的字面量对象的比较刚好符合这种情况。从语义上看,「认为两个空的字面量对象是相等的」也挺符合人类对空值数据的语义化理解的。关于这一点,我们在触摸 react 的命门 - 值的相等性比较(上篇)这篇文章中也提到过。

讨论这种 case 有实际意义吗?有。因为,如果一个 <MemoizedComponent /> 如果没有任何 props 的话(包括它没有子组件),那么这种情况下,它被编译为 js 之后,react element 上的 props 实际上就是空字面量对象 {}。而此前如果没有传递 arePropsEqual 函数的话,那么最终 react 就会因为命中两个空字面量对象的浅比较结果是 true 而跳过不必要的重渲染。

case 2

熟悉 react JSX 标签编译原理的人都知道,JSX 会被编译为 React.createElement() 函数的调用。而每一次 React.createElement() 函数的调用之后返回的 react element 的 props 都是一个全新的字面量对象。所以,在进入 react 的浅比较算法之后,那它肯定不会命中引用相等的情况。

在引用不相等之下,我们又分为三种情况:

  1. objAobjB 其中一个不是 object 类型;
  2. objAobjB 的自身属性数量不相等;
  3. 虽然 objAobjB 的自身属性数量相等,但是某个同名的 key 所对应的属性值用 Object.is() 算法去判断是不相等的;

第一种情况在 react elment props 这种语境下,基本上都不会发生;第二种情况,多一个 prop 属性,在数据相等性语义上,我们能理所当然认为新旧 props 是不相等的。

唯独第三种情况我们需要格外注意。为什么?因为我们虽然用了 React.memo(),但是稍不注意就功亏一篑。举个例子,假设我们片段3的代码现在接受一个 onChange prop:

function App(){
    
    return (
        <div className="App" >
           <MemoizedCounter onChange={(count)=>{ console.log(count)}}/>
        </div>
      );
}

那请问,我们能如愿以偿地避免不必要的重渲染吗?

如果你有认真思考的话,那你就知道,我们实际上是「功亏一篑」的。原因就在于其实 <MemoizedCounter /> 在新旧渲染周期里面,onChange prop 持有的是不同的对象引用。而 react 在浅比较算法中,我们命中的是上面提到的第三种情况。在这种情况中,react 遍历所有的 prop 属性值,只有新旧 props 在所有的 prop 值是严格相等的(准确来说,是基于 Object.is() 算法来比较是否相等的),react 才会认为新旧 props 是相等。

这就导致了虽然在语义上,新旧 props 是相等的,但是实际上却因为不同的引用问题而导致了 memo 失败。有时候,一个组件所接受的 props 中有不少引用类型的 prop。但凡有一个 prop 在引用上不相等,那么都会导致 memo 失败。这就是我所说的「功亏一篑」 的确切含义。

解决方案

那解决方案是什么?

rule 1. 在不同的渲染周期中让同一个变量始终保持为同一个引用

理论上,凡是能够在不同的渲染周期中让同一个变量始终保持为同一个引用的技术都能使用上。比如:

  • useCallback()
  • useMemo()
  • useRef()
  • 将引用保存在组件函数作用域之上的上层作用域的某个变量中

如果这个引用是函数的话,并且该函数内部不去访问外部变量的(如果是这样的话,就会遇上「闭包过期」问题),那么上面所说的:

  • useRef()
  • 将引用保存在组件函数作用域之上的上层作用域的某个变量中

这两种方法都是行之有效的。只不过,因为 useMemo()useCallback() 等 API 帮我们处理了「闭包过期」问题,用起来也更方便和安心。

那什么时候用useMemo() ,什么时候用 useCallback 呢?一般是遵循下面两条规则:

  • 如果 prop 值是一个字面量对象,则使用 useMemo() 进行包裹;
  • 如果 prop 值是一个方法/函数,则使用 useCallback() 进行包裹。
rule 2. 对所有的引用类型的 prop 都要应用 rule 1,切勿有漏网之鱼

应用之痛

如果要考虑优化 react 组件的渲染性能,则我们需要双管齐下:

  • 一方面,需要使用 React.memo() 对原始组件进行包裹;
  • 另外一方面,需要使用 useMemo() 或者 useCallback()对所有引用类型的 props 进行包裹(当前不考虑传递 arePropsEqual() 函数的情况);

不得不说,手动完成上面的这两种操作,真的十分劳心劳力。这是社区开发者一直在吐槽 react 在性能优化方面的拉胯之处。

而对于社区的抱怨,react 官方的口吻是:js 执行得足够快,你不需要担心过多的组件重渲染。除非某个组件重渲染真的出现了很严重的性能问题或者间接影响了你产品的收入,你才值得考虑对它进行性能优化。

对于这个说法,我个人也是赞同的。因为我自我洗脑了:「要相信 V8 的能力,js 执行得足够快」,哈哈。

不过,我不认同官方的另外一个说法:React.memo() 只能用于性能优化。

You should only rely on memo as a performance optimization......

如果某个组件完全没有 props(包括子组件),那么通过 React.memo(MyComponentWithoutProps, ()=> true) 来避免组件被动的,不必要的重渲染也是没有问题的。

总结

通过深入源码,我们掌握了 React.memo(Component, arePropsEqual) 跳过组件重渲染的原理:

  • 如果用户传递了 arePropsEqual 函数,则在构建 fiber 树的时候走的是 slow path。最后,使用的比较算法就是由 arePropsEqual 函数决定;
  • 如果用户没有传递 arePropsEqual 函数,则在构建 fiber 树的时候走的是 fast path。最后,使用的 react 内部的「浅比较算法」来比较新旧 props 的相等性。

如果一番比较下来,react 得到的结果是「新旧 props 相等」,则跳过组件的重渲染;否则,接着进入常规的 reconciliation 流程。

个人认同 react 官方的这个说法:js 执行得足够快,你不需要担心过多的组件重渲染。除非某个组件重渲染真的出现了很严重的性能问题或者间接影响了你产品的收入,你才值得考虑对它进行性能优化

但不是不认同「React.memo() 只能用于性能优化」。在满足特定条件下,个人觉得它还能用于一般意义上的「去除不必要的组件重渲染」之目的(将arePropsEqual 函数实现为 ()=> true )。