React源码解读(5)——hooks之useEffect

809 阅读3分钟

前言

建议没有看过前面系列文章从前面看起,这是本系列的第五篇文章,本篇文章主要介绍的是reacthooksuseEffect, 这应该是除了useState以外最常用的hook了,话不多说,一起开始今天的学习!

准备工作

接下来我们看一道关于useEffect的例子

const WatchCount = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setInterval(function log() {
      console.log(`Count: ${count}`);
    }, 2000);
  }, []);
  
  const handleClick = () => setCount(count => count + 1);
  
  return (
    <>
      <button onClick={handleClick}>+</button>
      <div>Count: {count}</div>
    </>
  );
}

在这个例子中,useEffect每隔两秒就会打印Count,但是当我们点击button,将count变为1的时候,那打印的会是2么,但是结果可能要让我们失望了,打印的依然是0。

让我们来分析一下,在第一次打印的时候应该没有什么问题,打印的是0,但是我们通过点击事件将count增加到1的时候,setInterval仍然调用的是从初次渲染中捕获的count为0的旧的log闭包,解决方法就是每次count变化,我们就重置定时器。

const WatchCount = () => {
  const [count, setCount] = useState(0);
  
  useEffect(function() {
    const id = setInterval(function log() {
      console.log(`Count: ${count}`);
    }, 2000);
    return () => clearInterval(id);
  }, [count]);
  
  const handleClick = () => setCount(count => count + 1);
  
  return (
    <>
      <button onClick={handleClick}>+</button>
      <div>Count: {count}</div>
    </>
  );
}

这样,当状态变量count发生变化时,就会更新闭包。为了防止闭包捕获到旧值,就要确保在提供给hook的回调中使用的prop或者state都被指定为依赖性。

初始化mount

mountEffectImpl

  • fiberFlags:有副作用的更新标记,用来标记hook所在的fiber
  • hookFlags:副作用标记;
  • create:使用者传入的回调函数;
  • deps:使用者传入的数组依赖;
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 1. 创建hook
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  // 2. 设置workInProgress的副作用标记
  currentlyRenderingFiber.flags |= fiberFlags; // fiberFlags 被标记到workInProgress
  // 2. 创建Effect, 挂载到hook.memoizedState上
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags, // hookFlags用于创建effect
    create,
    undefined,
    nextDeps,
  );
}

pushEffect

function pushEffect(tag, create, destroy, deps) {
  // 1. 创建effect对象
  const effect: Effect = {
    tag,
    create, // 回调函数
    destroy, // 回调函数里的return(mount时是undefined)
    deps, // 依赖数组
    next: (null: any),
  };
  // 2. 把effect对象添加到环形链表末尾
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    // 新建 workInProgress.updateQueue 用于挂载effect对象
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    // updateQueue.lastEffect是一个环形链表
    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;
    }
  }
  // 3. 返回effect
  return effect;
}

上面这段代码除了初始化副作用的结构代码外,都是我们前面讲过的操作闭环链表,向链表末尾添加新的effect,该effect.next指向fisrtEffect,并且链表当前的指针指向最新添加的effect

useEffect的初始化就这么简单,简单总结一下:给hook所在的fiber打上副作用更新标记,并且fiber.memoizedState.hook.memoizedStatefiber.updateQueue存储了相关的副作用,这些副作用通过闭环链表的结构存储。

更新update

updateEffectImpl

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 1. 获取当前hook
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;
  // 2. 分析依赖
  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    // 继续使用先前effect.destroy
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 比较依赖是否变化
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 2.1 如果依赖不变, 新建effect(tag不含HookHasEffect)
        pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }
  // 2.2 如果依赖改变, 更改fiber.flag, 新建effect
  currentlyRenderingFiber.flags |= fiberFlags;


  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

我们仔细看会发现在上面出现两次pushEffect,但我们发现只有一个pushEffect进行了赋值,原因在于这个areHookInputsEqual,我们来看下这个函数做了什么

areHookInputsEqual

function areHookInputsEqual(nextDeps, prevDeps) {
  // 没有传deps的情况返回false
  if (prevDeps === null) {
    return false;
  }
  // deps不是[],且其中的值有变动才会返回false
  for (var i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (objectIs(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  // deps = [],或者deps里面的值没有变化会返回true
  return true;
}

它会判断两次依赖数组中的值是否有变化以及deps是否是空数组来决定返回truefalse,返回true表明这次不需要调用回调函数。

执行副作用

上面我们讲了在mountupdate时的如何创建副作用,那么这些副作用会在什么时候执行呢? 在React源码解读(3):commit阶段也已经讲过了,在commit阶段有一系列处理副作用的操作

image.png 大概流程如上大图显示,首先在mutation之前阶段,基于副作用创建任务并放到taskQueue中,同时会执行requestHostCallback,这个方法就涉及到了异步了,它首先考虑使用MessageChannel实现异步,其次会考虑使用setTimeout实现。使用MessageChannel时,requestHostCallback会马上执行port.postMessage(null);,这样就可以在异步的第一时间执行workLoopworkLoop会遍历taskQueue,执行任务,如果是useEffecteffect任务,会调用flusnPassiveEffects

总结

useEffectuseLayoutEffect的区别是执行时机不同,前者是异步执行,后者是在layout后台同步执行,会阻塞渲染。这一部分和以前文章commit阶段紧密相连,建议先复习一遍commit阶段,在来读此篇文章会更加明白些。

React源码系列