你真的懂useEffect吗?useEffect源码解析

1,700 阅读11分钟

前言

React版本:16.8以上

如果你是一个react忠实用户,看到这个标题肯定会嗤之以鼻,useEffect还能不懂?这每天都要写好几遍的东西,早已如呼吸般自然~

别急,接下来将设置几道关卡,看一看你的useEffect是否如你想那样。

(看过你真的懂useState吗?useState源码解析这篇文章的应该会有股熟悉感~)

第一关:

function Son({ callback }) {
  // ...
  useEffect(() => {
    console.log('callback改变了');
  }, [callback]);
  // ...
}

function Father() {
  const [state, setState] = useState(0);
  const Click = () => {
    setState(state + 1);
  };
  const callback = () => {
    console.log('测试');
  };
  return (
    <div>
      <div onClick={Click}>点我</div>
      <Son callback={callback} />
    </div>
  );
}

每次点击触发Click后的打印是什么?⬆️

function Son({ callback }) {
  // ...
  useEffect(() => {
    console.log('callback改变了');
  }, [callback]);
  // ...
}
function Father() {
  const [state, setState] = useState(0);
  const Click = () => {
    setState(state + 1);
  };
  return (
    <div>
      <div onClick={Click}>点我</div>
      <Son callback={setState} />
    </div>
  );
}

每次点击触发Click后的打印是什么?⬆️

function Son({ callback }) {
  // ...
  useEffect(() => {
    console.log('callback改变了');
  }, [callback]);
  // ...
}

function Father() {
  const [state, setState] = useState(0);
  const Click = () => {
    setState(state + 1);
  };
  const callback = useCallback(() => {
    console.log('测试');
  }, []);
  return (
    <div>
      <div onClick={Click}>点我</div>
      <Son callback={callback} />
    </div>
  );
}

每次点击触发Click后的打印是什么?⬆️

答案揭晓:

  1. 打印'callback改变了'(触发setState后,Father组件重新render,callback被重新创建,导致与上次传入的引用地址不同,所以触发useEffect
  2. 不打印(setState方法是不会随着组件render而被重新创建的,若有疑问请回顾你真的懂useState吗?useState源码解析
  3. 不打印(被useCallback包裹后的方法会被缓存起来,不会随着组件render被重新创建

如果答对恭喜你已经通过了第一关,让我们继续~

第二关:

function Father() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
      console.log(count);
    }, 1000);
  }, []);
 // ...
}

每秒打印什么?⬆️

function Father() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      setCount(count => count + 1);
      console.log(count);
    }, 1000);
  }, []);
 // ...
}

每秒打印什么?⬆️

function Father() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setCount(count => count + 1);
    setCount(count => count + 1);
    setCount(count => count + 1);
    console.log(count);
  }, []);
 // ...
}

打印什么?⬆️

答案揭晓:

  1. 打印0(count为0的时候便被setInterval使用,形成闭包。)
  2. 打印0(count为0的时候便被setInterval使用,形成闭包。和setState方法无关。)
  3. 打印0(当前阶段中count依然为0,count的更新在useEffect之后)

如果答对恭喜你已经通过了第二关,让我们继续~

第三关:

function Father() {
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    console.log('render')
  });
  return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}

每次点击打印什么?⬆️

function Father() {
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    console.log('render')
  }, undefined);
  return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}

每次点击打印什么?⬆️

function Father() {
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    console.log('render')
  }, null);
  return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}

每次点击打印什么?⬆️

答案揭晓:

  1. 每次点击后都打印'render'(第二个参数不传入,每次render都将执行useEffect内容)
  2. 每次点击后都打印'render'(第二个参数传入undefined等同于不传入,每次render都将执行useEffect内容)
  3. 每次点击后都打印'render'(第二个参数传入null等同于不传入,每次render都将执行useEffect内容)

以上关卡答案有没有出乎大家的意料呢?如果不了解的话也别急,接下来让我们一起扒开useEffect的外衣,看看他里面究竟藏着什么玄机~

image.png

useEffect

useEffect的基本用法

useEffect(callback, deps);
  • callback:副作用函数,你在useEffect中记录的逻辑函数,在你的依赖项变化时,react会执行你的逻辑函数。callback有两种情况-void() => (() => void) =>有函数返回值没有函数返回值
  • deps:副作用函数的依赖项,依赖项列表必须有一个常数项,并且必须像[dep1, dep2, dep3]这样内联编写。传入[]将只在首次渲染执行,不传或传undefined或null则每次render都会执行。

callback的两种情况

  1. 无函数返回值
useEffect(() => {
  console.log('render');
}, []);

组件初次创建时:打印'render'

组件销毁时:无打印

  1. 有函数返回值
useEffect(() => {
  console.log('render');
  // 返回一个清除副作用的函数
  return () => console.log('end')
}, []);

组件初次创建时:打印'render'

组件销毁时:打印'end'

源码分析

我们都知道,react在不同阶段引用的hooks不是同一个函数,useEffect也不例外。首先我们先看一下react中对于useEffect在不同阶段的处理函数。

// ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  //...省略无关代码
  // 我们通过判断当前fiber中的memoizedState是否为空来判断当前的阶段
  ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  //...省略无关代码
}

image.png

image.png 可以看到hooks函数分为mount(初始化)和update(更新)两种状态。我们从一个简单的栗子来分析下useEffect的原理⬇️

function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log('mount');
  }, []);
  useEffect(() => {
    console.log('update');
  }, [count]);
  return <div onClick={() => setCount(count + 1)}>点我</div>;
}
  1. 初始化阶段-在首次创建App组件时,mountEffect方法被调用,打印'mount'和'update'。
  2. 更新阶段-在点击div触发setCount事件后,updateEffect方法被调用,打印'update'。

组件初始化(首次render)

首先我们根据源码定位到mountEffect函数⬇️

// ReactFiberHooks.js
function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  if (
    // 本地环境调用
    __DEV__ &&
    (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode
  ) {
    mountEffectImpl(
      MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect,
      HookPassive,
      create,
      deps,
    );
  } else {
    mountEffectImpl(
      PassiveEffect | PassiveStaticEffect,
      HookPassive,
      create,
      deps,
    );
  }
}

function mountEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  // 初始化hook
  const hook = mountWorkInProgressHook();
  // 创建一个nextDeps变量,用于对比原依赖项
  const nextDeps = deps === undefined ? null : deps;
  // 标记一个fiber需要重新渲染,或者是更新操作
  currentlyRenderingFiber.flags |= fiberFlags;
  // 管理副作用函数信息
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}

可以看到初始化阶段直接调用了mountEffectImpl函数,这个函数简要来说就是进行副作用管理的,函数总要分为4步,我们来细细解读这个函数:

  1. 函数入参。该函数接收4个参数。
    1. fiberFlags:fiberFlags表示当前fiber的标志位。
    2. hookFlags:hookFlags 表示当前hook的标志位。
    3. create:前文介绍的副作用函数,也就是useEffect中依赖变化对应的执行函数。
    4. deps:当前hook副作用函数的依赖项。
  1. 初始化hook。看过你真的懂useState吗?useState源码解析这篇文章的同学应该熟悉,可以直接跳过,没看过也没关系,我们再介绍一遍⬇️~

首先我们看一段hook节点初始化创建的代码:

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };

  if (workInProgressHook === null) {
    // 这是初始化第一个hook节点时
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 不是第一个节点直接放到节点后面
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

我们首先来了解下各个变量的含义:

  • memoizedState:Fiber上有一个记录组件当前状态的对象叫做memoizedState,有了memoizedState,我们就能在每次渲染时获取当前组件里的相关数据(state或者副作用函数信息)。hook是一个单项链表的结构。如果workInProgressHook为空,表示这是链表中的第一个hook,将当前hook对象设置为组件的memoizedState和workInProgressHook。否则,将当前hook对象添加到链表的末尾,并将workInProgressHook指向当前hook对象。最后返回当前hook对象。

image.png

  • currentlyRenderingFiber:当前组件渲染对应的fiber对象。
  • workInProgressHook:当前运行到的hook,如上图所示,组件内部可能会存在多个hook。
  1. 标记fiber需要被渲染或更新。将currentlyRenderingFiber.flags的某些位设置为1,表示当前fiber需要进行更新。具体来说,它将fiberFlags的值按位或上 currentlyRenderingFiber.flags 的值。这个操作会将currentlyRenderingFiber的flags属性的某些标志位设为1,表示这个fiber的状态发生了改变。
  2. 管理副作用函数信息。将副作用函数信息push到hook.memoizedState中,表示当前hook有一个副作用需要进行管理。同时,它还将create函数nextDeps数组保存在副作用信息中,以便在清除副作用时使用。

总的来说就是:标记fiber需要渲染,并且将需要对比的nextDeps依赖和副作用函数信息放入hook.memoizedState。也就是正在渲染的Fiber节点的update queue等待消费

大家可能对源码里放入hook.memoizedState这一步的pushEffect函数会有疑惑,这个函数内部做了什么呢?让我们一起来看一下:

tips:queue数据结构为一个单项环形链表

image.png

function pushEffect(
  // effect类型
  tag: HookFlags,
  // 副作用函数,会在组件第一次渲染时执行,用于创建effect,可以返回一个清除函数,用于在组件卸载时清除effect。如果不需要清除函数,可以返回空
  create: () => (() => void) | void,
  // 副作用函数的返回值,会在组件卸载时执行,用于清除effect。可以为空。
  destroy: (() => void) | void,
  // effect所依赖的变量
  deps: Array<mixed> | void | null,
): Effect {
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };
  // 获取当前正在渲染的Fiber节点的update queue
  let componentUpdateQueue: null | FunctionComponentUpdateQueue =
    (currentlyRenderingFiber.updateQueue: any);
  // 如果不存在则创建一个新的update queue
  if (componentUpdateQueue === null) {
    // 创建新的update queue,并将其赋值给Fiber节点的updateQueue属性
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    // 将创建的effect对象添加到update queue中,如果update queue中没有effect对象,则将effect对象作为第一个和最后一个effect对象(单项环形链表)
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      // 如果update queue中已经存在effect对象,则将新创建的effect对象添加到链表的末尾。
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

可以看出函数做了以下几步:

  1. 创建一个effect对象。对象包含了tag、create、destroy、deps、next。
  2. 不存在更新队列的操作。获取当前正在渲染的Fiber节点的update queue,如果不存在则创建一个新的update queue,并将其赋值给Fiber节点的updateQueue属性。
  3. 没有effect对象的操作。将创建的effect对象添加到update queue中,如果update queue中没有effect对象,则将effect对象作为第一个和最后一个effect对象。形成环形链表
  4. 有effect对象的操作。如果update queue中已经存在effect对象,则将新创建的effect对象添加到链表的末尾。然后指向头,形成环形链表

可以了解到pushEffect这个函数做的事就是把副作用信息放入当前fiber节点的update queue(更新队列)中消费。所以以上就是useEffect初始化的过程了。

组件更新

这一步就是useEffect依赖项发生改变后,执行副作用函数的阶段。也就是销毁旧的effect,执行新effect。让我们一起来看看代码具体是怎么实现的⬇️

function updateEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  // 前面提到hook为单向链表结构,这里为链表的更新
  const hook = updateWorkInProgressHook();
  // 获取最新的副作用依赖项
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    // 如果当前hook存在,则获取上一次的Effect和destroy函数
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 如果依赖没有发生变化,则复用上一次的Effect,直接返回。
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 管理副作用函数信息
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  // 在当前fiber标志位中添加需要更新的标志位
  currentlyRenderingFiber.flags |= fiberFlags;

  // 管理副作用函数信息
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

代码非常简单,总的来说每次更新都会进行以下几步:

  1. 判断当前hook是否存在。存在则获取上一次的effect和destroy,这里应该注意,const prevEffect = currentHook.memoizedState;这段代码中,因为取的是当前hook的memoizedState,所以无论在useEffect的副作用函数里去执行什么setState操作,都不会影响到当前的state。对useState不太清楚的同学可以去看一下你真的懂useState吗?useState源码解析这篇文章。
function Father() {
  const [count, setCount] = useState(0);
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    setCount(count + 1)
    setCount(count + 1)
    setCount(count + 1)
    console.log(count)
  }, [isRender]);
  return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}

image.png

  1. 判断nextDeps是否为空。如果为空则默认将副作用函数信息推送到update queue(更新队列) 。不为空则对比前后依赖,是则推送到update queue(更新队列)。

基本步骤大家应该都清楚了,但有同学可能会好奇,都觉得useEffect要比较前后依赖,那是怎么个比较法呢?那就让我们一起来看看他是怎么比较的⬇️

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
): boolean {
  // 省略无用代码
  if (prevDeps === null) {
    return false;
  }

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

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

是不是出乎意料的简单,就是遍历新旧依赖分别对比。比较规则为:

  • 如果x和y都是数字,且它们的值相等,则返回true。
  • 如果x和y都是布尔值,且它们的值相等,则返回true。
  • 如果x和y都是undefined,则返回true。
  • 如果x和y都是null,则返回true。
  • 如果x和y都是字符串,且它们的值相等,则返回true。
  • 如果x和y都是对象,并且它们的引用相等,则返回true。
  • 如果x和y都是NaN,则返回true。
  • 如果x和y都是+0或-0,则它们相等。
  • 如果x和y不相等,并且它们都不是NaN,则返回false。

总结

我们用一个简单的栗子来回顾下之前的知识点⬇️

function Father() {
  const [count, setCount] = useState(0);
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    setCount((count) => count + 1);
    setCount((count) => count + 1);
    setCount((count) => count + 1);
    console.log(count)
    return () => console.log('destroy')
  }, [isRender]);
  return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}

从组件创建到销毁总共会进行三步。

  1. 初始化
  • 在初次渲染时,React会调用mountEffectImpl函数创建一个新的Effect Hook状态,并将其存储到memoizedState中。加入更新队列。
  • 执行副作用函数。执行三次setCount((count) => count + 1),count此时为3,被放入了下一次的hook.memoizedState中,这个时候执行console.log(count),取的是当前hook的memoizedState,所以count为0, 打印0
  1. 更新
  • 当用户点击按钮时,会触发组件的更新。在更新过程中,React会调用updateEffectImpl函数更新 Effect Hook状态。这时,传入的 deps为 [!isRender]。React会比较 [isRender] 和 [!isRender] ,发现它们不相等,因此需要重新创建一个新的Effect Hook状态,并将其存储到memoizedState 中。加入更新队列。
  • 执行副作用函数。在这个例子中,执行三次setCount((count) => count + 1),count此时为6,被放入了下一次的hook.memoizedState中,这个时候执行console.log(count),取的是当前hook的memoizedState,所以count为3, 打印3
  1. 卸载
  • React会调用Effect Hook中存储的destroy函数,用于清理该组件产生的副作用。在这个例子中,destroy函数为console.log('destroy'),所以打印'destroy'。

现在大家再回过头去看看useEffect,是否真的已经“如呼吸般自然”~