《深入理解react》之memo、forwardRef原理

1,087 阅读4分钟

一、前面的话

我们在使用react的过程中,会经常使用一些API用于把我们的组件包裹起来,从而达到一些特定的效果,例如:

  1. 我们可以通过使用memo来包裹一个组件,memo 允许我们的组件在 props 没有改变的情况下跳过重新渲染,换句话说它可以让组件跳过执行render阶段,从而提高性能,我们也可以自定义是否渲染的函数,非常灵活

  2. 再比如我们可以通过forwardRef来包裹一个组件,从而给这个组件注入一个ref的引用,组件就可以使用 ref 将 DOM 节点或者一些自定义信息暴露给父组件

  3. 我们也可以使用 createPortal 将 JSX 作为 children 渲染至 DOM 的不同部分

之所以把这些内容串在一起是因为它们的原理其实是相似的,而文本尝试从源码的角度剖析一下它是如何实现的,但是大家不用担心,文中所有的内容都会通俗易懂,我会过滤掉不重要的部分,排除干扰只讲核心的实现,废话不多说我们开始吧!

二、memo

当我们用memo包裹一个组件时,实际上会创建一个特殊Memo类型的ReactElement节点,我们可以来看一下它的创建过程:

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

它会将被包裹的组件的引用保存在type这个属性上面,以供后面调和过程使用,并且会有一个自定义控制渲染的函数保存在compare这个属性上,ReactElement会在render过程中转化为fiber类型的节点,而REACT_MEMO_TYPEReactElement会转化为MemoComponent类型的fiber类型,在调和MemoComponent类型节点的时候是下面这个样子:

function updateMemoComponent(
    current,
    workInProgress,
    Component,
    nextProps,
    renderLanes
){
  
  // 更新时
  var prevProps = currentChild.memoizedProps; // 之前的props
  var compare = Component.compare;
  compare = compare !== null ? compare : shallowEqual; // 如果没有自定义策略就采取默认策略

  if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
    // 如果返回true就直接返回已有的fiber节点,而不用重新执行子树的render过程
    return bailoutOnAlreadyFinishedWork(
      current,
      workInProgress,
      renderLanes
    );
  }
  //
  var newChild = createWorkInProgress(currentChild, nextProps);
  workInProgress.child = newChild;
}

我只贴上了更新部分的render代码,因为初始化的时候它一定会进行render,在初始化之前没有任何fiber树可以复用的,在更新部分如果没有指定自定义的策略的话就会使用默认的shallowEqual策略,它会对props的每一个属性进行浅比较,判断如下:

function shallowEqual(objA, objB) {
    if (objectIs(objA, objB)) { // objectIs 实际上就可以理解为 === ,判断的是两个值的内存地址是否一样
      return true;
    }

    if (
      typeof objA !== "object" ||
      objA === null ||
      typeof objB !== "object" ||
      objB === null
    ) { // 两个值必须是对象
      return false;
    }

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

    if (keysA.length !== keysB.length) {
      return false; // 长度不一致,直接否决掉
    } 
    
    for (var i = 0; i < keysA.length; i++) {
      var currentKey = keysA[i];

      if ( // 只对比每个属性的引用是否一样即可
        !hasOwnProperty.call(objB, currentKey) ||
        !objectIs(objA[currentKey], objB[currentKey])
      ) {
        return false;
      }
    }

    return true;
 }

整体的逻辑还是比较简单的,如果前后两次的props的任何一个属性发生了引用地址的变化都会导致shallowEqual的判定为false,从而导致子树进行重新的render

小结: 以上就是memo这个API的核心逻辑,主要就是创建了一个特殊的Memo节点,根据用户的指定来决定子组件是否会重新渲染

三、forwardRef

之所以把这几个API放在一起分析,主要是因为它们的原理是类似的,都是通过创建一个特殊的ReactElement节点,然后在render阶段配合着做一些不同的操作即可,forwardRef也是如此,不信我们来看一下:

function forwardRef(render) {

   // render 必须是一个函数组件,且不能是memo类型的节点
   ...
    var elementType = {
      $$typeof: REACT_FORWARD_REF_TYPE,
      render: render, // 这个就是那个传入的组件
    };

    ...
    return elementType;
}

此时创建的节点,最终是要交给render才有意义,我们直接看看render的处理流程,REACT_FORWARD_REF_TYPE类型的ReactElement会被转化为ForwardRef类型的fiber节点

forwardRef的核心作用就是转发ref,因此在使用的过程中我们需要给它指定一个ref的引用,他会保存在fiber节点的ref属性身上,因此在它调和子组件的时候会取出这个ref交给下一层来使用:

function updateForwardRef(
    current,
    workInProgress,
    Component,
    nextProps,
    renderLanes
  ) {
    var render = Component.render; // 这就是被用户包裹的那个函数组件
    var ref = workInProgress.ref; // 用户指定给forwardRef的引用对象 { current: null }

    var nextChildren;
    {
      ReactCurrentOwner$1.current = workInProgress;
      nextChildren = renderWithHooks(
        current,
        workInProgress,
        render,
        nextProps,
        ref, // 此时它是有值的
        renderLanes
      );
    }

    reconcileChildren(current, workInProgress, nextChildren, renderLanes); // 调和子树
    return workInProgress.child;
  }

可以看到这一步我们前面文章中已经分析过了,ref就是在renderWithHook的时候被当作第二个参数交给这个函数组件去使用的

// renderWithHooks
var children = Component(props, secondArg); // 这个secondArg就是那个ref

所以其实任何一个函数式组件都可以接收到第二个参数的,只不过在不使用forwardRef的时候,收到的是null而已,forwardRef的作用其实完全可以使用一个refprops去替代,例如下面这个样子:

// 使用
function MyForwardComponent(props){
   const { ref } = props;
   return UI with ref;
}
//
const ForwardComponent = forwardRef(function (props , ref){
   // 消费ref
   return UI with ref;
})

function App(){
   const ref1 = useRef()
   const ref2 = useRef()
   return (
     <>
       <MyForwardComponent ref={ref1}/>
       <ForwardComponent ref={ref2}/>
     </>
   )
}

当我们对各种API的实现足够了解的时候,会解锁更多效果其实完全一致的用户,他们都是往下注入ref

四、createPortal

createPortal的作用我再次强调一下,其实就是可以往一个用户指定的容器中,而非渲染在父节点所在的容器中,非常有趣,而且这个API并未和上面提到的其他API一样暴露在react这个包下,createPortal是暴露在react-dom这个包里面的,这里大家用的时候注意下就好,这里可以看到它的介绍,我们来看一下它是如何实现的

首先第一步是创建一个特殊的ReactElement节点

function createPortal(
    children, // jsx
    containerInfo, // 必须是一个真实的DOM节点;
    implementation // key
) {
    var key =
      arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;

    return {
      $$typeof: REACT_PORTAL_TYPE,
      key: key == null ? null : "" + key,
      children: children,
      containerInfo: containerInfo,
      implementation: implementation,
    };
}

这个过程很简单,关键是要看在render阶段他是如何处理的,REACT_PORTAL_TYPE类型的ReactElement节点会转换为HostPortal类型的fiber节点

function createFiberFromPortal(portal, mode, lanes) {
    var pendingProps = portal.children !== null ? portal.children : []; // 传进来的jsx
    var fiber = createFiber(HostPortal, pendingProps, portal.key, mode); // HostPortal 类型
    fiber.lanes = lanes;
    fiber.stateNode = {
      containerInfo: portal.containerInfo,
      pendingChildren: null,
      implementation: portal.implementation,
    };
    return fiber;
}

并且会将containerInfokey等信息做一个封装保存在fiber.stateNode身上,在之前的文章中我们有详细分析过初始化的流程,在初始化过程中每一个原生DOM节点会在completeWork的时候向上构建真实的一棵离屏的DOM树,它的过程就是每一个原生DOM节点在向上归并的时候都将自己的子fiber节点的真实DOM节点挂载到自己身上,如下所示:

 // completeWork 处理HostComponente时
 var instance = createInstance( // 创建DOM节点 
  type, // div..
  newProps, 
  rootContainerInstance, // #root
  currentHostContext,
  workInProgress
);
// 把自己的children节点的真实DOM节点通过appendChild()加载自己身上
appendAllChildren(instance, workInProgress, false, false);

而当遇到我们使用了createPortal创建的节点的时候,情况会变的不一样,假设我们有这样的例子:

const FunctionComponent = () => {
    const [count, setCount] = React.useState(1);

    const onClick = () => {
      setCount(count + 1);
    };
    return (
      <div>
        <button onClick={onClick}>{count}</button>
      </div>
    );
  };

  const Modal = ReactDOM.createPortal(<FunctionComponent />, document.body);

  const App = () => <div id="app">{Modal}</div>;
  
  const root = ReactDOM.createRoot(document.getElementById("root"));
  root.render(<App />);

complateWork来到我们的#app-div这个原生节点的时候,也会执行appendAllChildren,在往下找自己的子节点的时候,会跳过Portal类型的节点,因此这棵离屏的DOM树不会包含FunctionComponent这个组件,而此时FunctionComponent实际上会有一棵单独的真实DOM树已经形成只是没有挂载#app-dev之下,等到commit阶段再挂载到document.body上,我们来看一下:

// commitMutationEeffect

case HostPortal: {
    commitReconciliationEffects(finishedWork);
    return;
}

commitmutation阶段会找到HostPortal类型的节点将它的子真实DOM树挂载到containerInfo身上,也就是用户自定义的那个真实DOM树上,自此便实现了createPortal对应的功能。

五、最后的话

本篇文章比较简单,主要是分析了reactmemoforwardRefcreatePortal这三个API的原理,纵观整个react,确实为我们提供了非常多的有趣的API,你还对那些API感兴趣呢?欢迎在评论区留言,我们一起学习

后面的文章我们会依然会深入剖析react的源码,学习react的设计思想,如果你也对react相关技术感兴趣请订阅我的《深入理解react》专栏,笔者争取至少月更一篇,我们一起进步,有帮助的话希望朋友点个赞支持下,多谢多谢!