从源码剖析React Hooks之useEffect、useLayoutEffect

lxf2023-04-08 12:36:01

前言

这一篇算是上一篇从源码剖析React Hooks之useReducer、useState的姊妹篇,因为useReduceruseState比较相似,useEffectuseLayoutEffect比较相似,所以就分别放在一篇文章里面对比讲解。

useEffect是React 16.8 新增的 Hook,它的作用是执行副作用操作(side effect),比如数据获取,订阅,手动更改DOM等等

useEffect就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMountcomponentDidUpdatecomponentWillUnmount具有相同的用途,只不过被合并成了一个 API。

本文基于React 18.2带大家进行深入的了解

基本使用

function FunctionComponent() {
  const [number, setNumber] = React.useState(0);

  React.useEffect(() => {
    console.log('useEffect1');
    return () => {
      console.log('destroy useEffect1');
    };
  }, []);
  React.useLayoutEffect(() => {
    console.log('useLayoutEffect2');
    return () => {
      console.log('destroy useLayoutEffect2');
    };
  });
  React.useEffect(() => {
    console.log('useEffect3');
    return () => {
      console.log('destroy useEffect3');
    };
  });
  React.useLayoutEffect(() => {
    console.log('useLayoutEffect4');
    return () => {
      console.log('destroy useLayoutEffect4');
    };
  });
  return <button onClick={() => setNumber(number + 1)}>{number}</button>;
}

useEffect 接收两个参数:

  • effect: 是一个函数,它就是需要执行的副作用操作;
  • deps: 是一个依赖数组,effect 函数执行依赖于 deps 数组中变量的变化。

useEffect首先会在组件 mount 后执行 effect 函数,然后它会在组件 update 后检查 deps 的变化,如果发生变化,则重新执行 effect 函数。

useEffect还会在组件 unmount 时返回 effect 函数的返回值(如果有的话),通常我们会在 effect 函数中 return 一个清除副作用的函数。

源码分析

React中的hooks基本都是在react-reconciler中实现的。hook的调用也是分为挂载阶段更新阶段

// src/react-reconciler/src/ReactFiberHooks.js
const HooksDispatcherOnMount = {
  useReducer: mountReducer,
  useState: mountState,
  useEffect: mountEffect,
  useLayoutEffect: mountLayoutEffect,
};
const HooksDispatcherOnUpdate = {
  useReducer: updateReducer,
  useState: updateState,
  useEffect: updateEffect,
  useLayoutEffect: updateLayoutEffect,
};

我们可以看到我们的函数useEffect分别是对应mountEffectupdateEffect两个函数,也就是我们在第一次渲染的时候调用的是mountEffect,当我们第二次渲染时(当调用useState等其它hook改变状态时),这时候调用的就是updateEffect这个函数。

// src/react-reconciler/src/ReactFiberHooks.js

function mountEffect(create, deps) {
  return mountEffectImpl(PassiveEffect, HookPassive, create, deps);
}
function updateEffect(create, deps) {
  return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
function mountLayoutEffect(create, deps) {
  return mountEffectImpl(UpdateEffect, HookLayout, create, deps);
}
function updateLayoutEffect(create, deps) {
  return updateEffectImpl(UpdateEffect, HookLayout, create, deps);
}

这里我们可以看到mountEffectmountLayoutEffect是通过mountEffectImpl这个方法实现的,

updateEffectupdateLayoutEffect是通过updateEffectImpl这个方法实现的,不同点就是参数的不同,下面我们介绍一下参数代表的含义:

  • PassiveEffect —— 作为一个Fiberflag,就是一个标志,大家就可以认为是为Fiber添加一个存在useEffect的标志,后续在提交进行副作用处理。
  • HookPassive —— 作为一个是effect的标志,useEffectuseLayoutEffect结构本身并没有区别(后面会讲到是什么结构),我们通过这个标志来区分。这个标志是useEffect的标志。
  • UpdateEffect —— 同PassiveEffect,是useLayoutEffect的标志。
  • HookLayout —— 同HookPassive,是useLayoutEffect的标志。
  • create —— 这个是在使用时传入的函数。
  • deps —— 这个是在使用时传入的依赖项。

下面我们来分别看一下mountEffectImplupdateEffectImpl方法的实现。

挂载阶段

mountEffectImpl

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
  const hook = {
    memoizedState: null, // hook的状态
    queue: null, // 存放本hook的更新队列,queue.pending = update 的循环链表
    next: null, // 指向下一把hook,一个函数里里面可能会有多个hook,它们会组成一个单向链表
  };
  // 允许不传入依赖项,如果不传就被默认成null
  const nextDeps = deps === undefined ? null : deps;
  // 给当前的函数组件fiber添加flags
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}

这里我们观察这个函数做了哪些事情:

  1. 创建了一个新的hook,包括memoizedState表示是hook的状态,queue是hook的更新链表,next是指向下一个hook的指针。
  2. 处理依赖项,把undefined重新赋值为null
  3. 给函数组件的fiber添加标志,表示使用了useEffect或者useLayoutEffect
  4. hook.memoizedState的添加一个值,这个值是pushEffect返回的值,看起来是一个入栈的操作。

要想彻底搞清楚发生了添加了什么内容,我们应该了解一个pushEffect这个函数。

pushEffect

/**
 * 添加effect链表
 * @param {*} tag effect的标签
 * @param {*} create 创建方法
 * @param {*} destroy 销毁方法
 * @param {*} deps 依赖数组
 */
function pushEffect(tag, create, destroy, deps) {
  const effect = {
    tag,
    create,
    destroy,
    deps,
    next: null,
  };

  let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
  if (componentUpdateQueue === null) {
    componentUpdateQueue = {
       lastEffect: null,
    };
    currentlyRenderingFiber.updateQueue = componentUpdateQueue;
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }

  return effect;
}

我们看一下这个像是入栈的函数做了哪些事情:

  1. 创建了一个effect对象,这里包括tag用来标志useEffect或者useLayoutEffectcreate是你传入的函数,destroy是create函数的返回值,可能是undefined,deps是依赖项,next是下一个effect。
  2. 在函数组件上添加了一个updateQueue的属性,这个用来指向effect链表。
  3. 操作这个effect链表,让他们组成一个首尾相连的环形链表
  4. 返回这个effect对象。

通过这两个函数我想我们应该对这个结构比较好奇,下面根据示意图表示一下。

从源码剖析React Hooks之useEffect、useLayoutEffect

如图所示,我们通过如上的操作就是得到了这样的结果。其中在函数组件的fiber上的memoizedState挂上我们hook链表,每个useEffectmemoizedState指向一个effect对象,所有effect对象组成一个环形链表。 函数组件fiber上的updateQueue.lastEffect指向最后一个effect对象。

思考一下:为什么我们已经拥有hook链表了,为什么还要维护一个effect链表?

是因为我们的hook链表不止包括useEffect还有其它hook,如果不引入这个链表的话,我们可能会多很多无用的遍历,故而再次又维护了一个effect对象的链表。

更新阶段

updateEffectImpl

点我们进入更新阶段,即第二次进入到useEffect或者useLayoutEffect,此时它们是通过updateEffectImpl实现的,下面我们看看这个函数做了哪些事情。

function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
  const hook = {
    memoizedState: currentHook.memoizedState,
    queue: currentHook.queue,
    next: null,
  };
  const nextDeps = deps === undefined ? null : deps;
  let destroy;
  //上一个老hook
  if (currentHook !== null) {
    //获取此useEffect这个Hook上老的effect对象 create deps destroy
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 用新数组和老数组进行对比,如果一样的话
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        //不管要不要重新执行,都需要把新的effect组成完整的循环链表放到fiber.updateQueue中
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }
  //如果要执行的话需要修改fiber的flags
  currentlyRenderingFiber.flags |= fiberFlags;
  //如果要执行的话 添加HookHasEffect flag
  //刚才有同学问 Passive还需HookHasEffect,因为不是每个Passive都会执行的
  hook.memoizedState = pushEffect(HookHasEffect | hookFlags, create, destroy, nextDeps);
}

仔细看代码,发现跟mountEffectImpl相似,这里我们可以对比着看一下:

  1. 创建了一个新的hook,包括memoizedState表示是hook的状态此时指向当前hook的memoizedStatequeue是hook的更新链表此时指向当前hook的queuenext是指向下一个hook的指针。
  2. 处理依赖项,把undefined重新赋值为null
  3. 创建destroy,就是create函数返回的函数。
  4. 通过areHookInputsEqual方法逐个对比依赖项,只有当依赖项变化时,继续往下走执行更新逻辑;否则就return,停止更新。
  5. 给函数组件的fiber添加标志,表示使用了useEffect或者useLayoutEffect
  6. hook.memoizedState的添加一个值,这个值是pushEffect的返回值,不同的是我们在第三个参数加入了destroy

我们着重指出了更新阶段的不同点,我们创造了destroy,同时在pushEffect时添加了该函数,相比挂载阶段,我们开始关注destroy,也就是说我们只有在更新时才会调用destroy

areHookInputsEqual

对比依赖项,只有依赖项变化时,我们才会继续我们更新逻辑,这里我们可以看看areHookInputsEqual如何实现的,

function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) {
    return null;
  }
  for (let i = 0; i < prevDeps, length && i < nextDeps.lenth; i++) {
    if (Object.is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }

  return true;
}

其实实现是比较简单的,主要就是通过Object.is()方法来逐项对比依赖项,来看看是否有变化,至于Object.is()的使用,大家可以点击查看。

提交阶段

commitRoot

当我们完成更新阶段,开始进入到提交阶段,来重新渲染我们视图,主要是在commitRoot函数中进行的,因为这里涉及的关于Fiber的知识比较多,也为了该篇文章尽量解耦,对一些关于Fiber操作进行了简化,这里主要是为了体现useEffectuseLayoutEffect的执行时机的不同。

// src/react-reconciler/src/ReactFiberWorkLoop.js
function commitRoot(root) {
  // 已经完成构建的fiber,上面会包括hook信息
  const { finishedWork } = root;

  // 如果存在useEffect或者useLayoutEffect
  if ((finishedWork.flags & Passive) !== NoFlags) {
    if (!rootDoesHavePassiveEffect) {
      rootDoesHavePassiveEffect = true;
      // 开启下一个宏任务
      requestIdleCallback(flushPassiveEffect);
    }
  }

  console.log('开始commit~~~~~~~~~~~~~~~~~~~~~~~');
  // 判断自己身上有没有副作用
  const rootHasEffect = (finishedWork.flags & MutationMask) !== NoFlags;
  // 如果自己的副作用或者子节点有副作用就进行DOM操作
  if (rootHasEffect) {
    // 当DOM执行变更之后
    console.log('DDOM执行变更commitMutationEffectsOnFiber~~~~~~~~~~~~~~');
    commitMutationEffectsOnFiber(finishedWork, root);

    // 执行layout Effect
    console.log(
      'DOM执行变更后commitLayoutEffects~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
    );
    commitLayoutEffects(finishedWork, root);
    if (rootDoesHavePassiveEffect) {
      rootDoesHavePassiveEffect = false;
      rootWithPendingPassiveEffects = root;
    }
  }
  // 等DOM变更之后,更改root中current的指向
  root.current = finishedWork;
}

我们在开始分析每一步做了哪些事情,我在代码中也给出了一个界限提示:

  1. 取出已经构建好的finishedWork,这是一个完成所有标记的完成态fiber,
  2. 根据flags来判断是否在代码中使用过useEffect,注意是这里只是判断了Passive,所以只是看有没有使用useEffect。如果存在就在requestIdleCallback开启下一个宏任务,这里要将要执行的就是我们useEffect中的逻辑。
  3. 检测是否有副作用操作

3.1 进行DOM变更操作commitMutationEffectsOnFiber

3.2 进行useLayoutEffect中的逻辑

3.3 清空rootDoesHavePassiveEffect

这里我们就可以看出来,对于useEffectuseLayoutEffect执行时机的不同,其中useLayoutEffect属于是同步操作,在DOM变更之前就会同步进行,同时他也会阻塞代码的进行,在UI变更前就会完成,而useEffect则是需要开启下一个宏任务需要在本次UI渲染之后进行,我们可以简单的理解为useLayoutEffectuseEffect之前进行。

flushPassiveEffect

这里我们在好好观察一下flushPassiveEffect,是怎么工作的,即useEffect的工作顺序。

function flushPassiveEffect() {
  console.log('下一个宏任务中flushPassiveEffect~~~~~~~~~~');
  if (rootWithPendingPassiveEffects !== null) {
    const root = rootWithPendingPassiveEffects;
    // 执行卸载副作用, destroy
    commitPassiveUnmountEffects(root.current);
    
    // 执行挂载副作用 create
    commitPassiveMountEffects(root, root.current);
  }
}

其实这个函数是比较简单的,这里我们关注点是commitPassiveUnmountEffects要比函数commitPassiveMountEffects先执行,即我们的destroy函数要在比create函数先执行,这里需要注意的是destroy是上一次create的返回值,即我们在执行本次的create函数之前要先执行上一次的destroy函数。

总结

本文从挂载阶段更新阶段提交阶段分别介绍了useEffectuseLayoutEffect的不同表现,其实对于useEffectuseLayoutEffect在使用上并无二致,只是在执行时机的不同,主要是以下几点:

  • useLayoutEffect要比useEffect先执行,相比而言前者是在UI变更调用的,而后者是在UI变更之后调用的,所以要慢一步。
  • 我们的destroy函数要在比create函数先执行,这里需要注意的是destroy是上一次create的返回值,即我们在执行本次的create函数之前要先执行上一次的destroy函数。