剖析React系列十一- useEffect的实现原理

589 阅读8分钟

本系列是讲述从0开始实现一个react18的基本版本。通过实现一个基本版本,让大家深入了解React内部机制。 由于React源码通过Mono-repo 管理仓库,我们也是用pnpm提供的workspaces来管理我们的代码仓库,打包我们使用rollup进行打包。

仓库地址

具体章节代码commit

系列文章:

  1. React实现系列一 - jsx
  2. 剖析React系列二-reconciler
  3. 剖析React系列三-打标记
  4. 剖析React系列四-commit
  5. 剖析React系列五-update流程
  6. 剖析React系列六-dispatch update流程
  7. 剖析React系列七-事件系统
  8. 剖析React系列八-同级节点diff
  9. 剖析React系列九-Fragment的部分实现逻辑
  10. 剖析React系列十- 调度<合并更新、优先级>

在上一节中,我们初步了解了微任务下面的合并更新以及优先级策略。本章我们来讲解useEffect的实现逻辑。

useEffect(() => {
    console.log('mount')
    return () => {
      console.log('unMount')
    }
}, [])

useEffect中,结构是要分3个部分:其中回调函数的执行是在异步调用

  1. create函数
  2. destroy函数
  3. 依赖项的保存deps

useEffect数据结构和保存位置

剖析React系列六-dispatch update流程中,我们了解到每一个fiber对应的fiberNode.memoizedState指向hook的链表。 每一个hook中的hook.memoizedState对应当前hook的结构。

例如对应useState中,例如有两个useState调用, 并且一定的条件下点击触发三次更新:

const [num, setNum] = useState(100)
const [count, setCount] = useState(1)
/ 点击触发三次
setNum(num + 1);
setNum(num + 2);
setNum((num) => num + 2);

useState回顾.png

effect数据结构

当我们设计useEffect的数据结构的时候需要注意几个部分:

  • 不同effect可以共用一个机制,所以需要tag区分不同的effect
    useEffect /  useLayoutEffect / useInsertionEffect
    
  • 需要保存依赖,用于对比依赖是否变化
  • 需要保存create回调
  • 需要保存destroy回调
  • 需要能够区分是否需要触发create回调
    1. mount
    2. 依赖变化时
const effect = {
    tag,
    create,
    destroy,
    deps,
    next
}

effect对应的flag

在之前的章节中,我们添加过Placement 以及 Update等flags, 用于标记对应的操作。本节新增几个对应flag

对于fiberNode 节点

PassiveEffect : 表示当前fiber有effect的副作用

PassiveMask: 表示存在需要触发effect。 PassiveEffect | ChildDeletion

对于effect hook

PassiveuseEffect对应的effect

HookHasEffect:当前effect存在副作用

effect-flags.png 例如:上图右边所示,当useEffecttag标记为Passive | HookHasEffect的时候,表示当前的hook存在副作用需要执行,所以hook对应的fiberNode就需要标记为HookHasEffect

effect自成环状

在之前的章节中,我们已经了解到hook之前会通过next指针来连接,这样在更新的过程中,可以很快的得到一个hook列表。

effect的hook中,为了方便单独的effect的使用,所以effecthook.memoizedState中有一个next属性

在上一节的合并更新中,我们晓得updateQueue.shared.pending中存放的useSateaction集合自成环装,方便我们遍历去执行相应的操作。

effect链表.png

其中主要逻辑都存在pushEffect中, 它主要是根据tag新建effect,并fiber.updateQueue指向环状列表。fiber.updateQueue.lastEffect 指向最后一个effect方便之后收集回到的时候只遍历effect


hook.memoizedState = pushEffect(
  Passive | HookHasEffect,
  create,
  undefined,
  nextDeps
);

// ......
function pushEffect(
  hookFlags: Flags,
  create: EffectCallback | void,
  destroy: EffectCallback | void,
  deps: EffectDeps
) {
  const effect: Effect = {
    tag: hookFlags,
    create,
    destroy,
    deps,
    next: null,
  };
  const fiber = currentlyRenderingFiber as FiberNode;
  const updateQueue = fiber.updateQueue as FCUpdateQueue<any>;
  if (updateQueue === null) {
    const updateQueue = createFCUpdateQueue();
    fiber.updateQueue = updateQueue;
    effect.next = effect;
    updateQueue.lastEffect = effect;
  } else {
    // 插入effect
    const lastEffect = updateQueue.lastEffect;
    if (lastEffect === null) {
      effect.next = effect;
      updateQueue.lastEffect = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      updateQueue.lastEffect = effect;
    }
  }
  return effect;
}

Effect的工作流程

上面我们主要介绍了effect的数据格式,接下来我们讲解如何从现有的工作流中接入effect

分三个阶段:

  1. render阶段 判断是否存在effect副作用
  2. commit阶段,异步调度副作用、收集回调
  3. 主流程和微任务合并更新执行完后,开始执行回调(类似setTimeout)

effect执行流程.png

调度副作用

commit阶段进入的时候,判断当前节点是否存在副作用,然后通过scheduleCallback包进行调度流程。

function commitRoot(root: FiberRootNode) {
    // xxx
    // 当前Fiber树中存在函数组件需要执行useEffect的回调
    if (
      (finishedWork.flags & PassiveMask) !== NoFlags ||
      (finishedWork.subtreeFlags & PassiveMask) !== NoFlags
    ) {
      // 防止多次调用
      if (!rootDoesHasPassiveEffects) {
        rootDoesHasPassiveEffects = true;
        // 调度副作用
        scheduleCallback(NormalPriority, () => {
          // 执行副作用
          flushPassiveEffects(root.pendingPassiveEffects);
          return;
        });
      }
    }
    // ....commit阶段
    commitMutationEffects(finishedWork, root);
}

这里的scheduleCallback来自于react官方提供的调度器,这里我们可以把它简单的理解为内部回调函数相等于setTimeout中执行。

useEffect的逻辑

分为mountupdate二种情况,他们的区别:

  • mount时:一定标记PassiveEffect
  • update时:deps变化时标记PassiveEffect

mount阶段

mount阶段,我们晓得useEffectcreate函数一定会执行,所以我们需要在mount的时候收集create执行的回调。

function mountEffect(create: EffectCallback | void, deps: EffectDeps | void) {
  // 新建hook
  const hook = mountWorkInProgressHook();

  const nextDeps = deps === undefined ? null : deps;

  (currentlyRenderingFiber as FiberNode).flags |= PassiveEffect;

  hook.memoizedState = pushEffect(
    Passive | HookHasEffect,
    create,
    undefined,
    nextDeps
  );
}

主要逻辑是在pushEffect中,生成effect特有的数据格式,此时由于effect是存在副作用的,所以传入的tagPassive | HookHasEffect。表示是useEffect并且还需要执行回调函数。初始化阶段不存在destroy的执行,所以传入undifined

同时将对应的fiberNode标记为PassiveEffect,表示有副作用要执行。

pushEffect的代码查看上面effect自成环状。

update阶段

update阶段。我们实际执行的useEffect对应updateEffect。这个阶段

function updateEffect(create: EffectCallback | void, deps: EffectDeps | void) {
  // 对应去mount的时候的每一个effect
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy: EffectCallback | void;

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState as Effect;
    destroy = prevEffect.destroy;

    if (nextDeps !== null) {
      // 浅比较依赖
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(Passive, create, destroy, nextDeps);
        return;
      }
    }
    // 浅比较后不相等
    (currentlyRenderingFiber as FiberNode).flags |= PassiveEffect;
    hook.memoizedState = pushEffect(
      Passive | HookHasEffect,
      create,
      destroy,
      nextDeps
    );
  }
}

update阶段主要是对比前后依赖项,如果发生了变化的话,传入create以及destroy的同时,需要传入标记Passive | HookHasEffect,如果没有变动的话,就是传入Passive

这里获取更新前的hook数据是通过currentHook.memoizedState

收集回调

如果存在副作用,在之后执行flushPassiveEffects之前,我们肯定要先去收集那些effect存在副作用,以及存在那些副作用(create / destroy)。

了解effect的使用的话,我们就应该晓得主要是分为2种回调收集:

  • unmout时执行的destroy回调
  • update / mount时执行的create回调

我们将需要收集的回调存放在fiberRootNodependingPassiveEffects属性中。

export interface PendingPassiveEffects {
  unmount: Effect[];
  update: Effect[];
}

useEffect的初始化和更新执行的时候,如果存在副作用的话,就会标记对应的fiber.flagPassiveEffect。 所以我们可以在comit阶段通过当前的fiber是否存在PassiveEffect的标记判断是否需要收集回调。

收集update回调

commitMutationEffects的过程中,当进行向上遍历的时候,我们会执行commitMutationEffectsOnFibers

由于我们是从下到上的遍历, 所以在之后的回调执行的时候,也会先执行子元素,之后才是父元素。正好对应React源码。

const commitMutationEffectsOnFibers = (
  finishedWork: FiberNode,
  root: FiberRootNode
) => {
  const flags = finishedWork.flags;

  // flags childDeletion
  if ((flags & ChildDeletion) !== NoFlags) {
    // 删除
  }

  if ((flags & PassiveEffect) !== NoFlags) {
    // 收集回调
    commitPassiveEffect(finishedWork, root, "update");
    finishedWork.flags &= ~PassiveEffect;
  }
}

判断当前的fiber.flag是否存在PassiveEffect,然后执行收集的函数commitPassiveEffect

commitPassiveEffect主要的功能就是根据传入进来的type,将当前的函数fiber对应的hook的环状列表放入root.pendingPassiveEffects中。

function commitPassiveEffect(
  fiber: FiberNode,
  root: FiberRootNode,
  type: keyof PendingPassiveEffects
) {
  //update unmount
  if (
    fiber.tag !== FunctionComponent ||
    (type === "update" && (fiber.flags & PassiveEffect) === NoFlags)
  ) {
    return;
  }
  const updateQueue = fiber.updateQueue as FCUpdateQueue<any>;
  if (updateQueue !== null) {
    if (updateQueue.lastEffect === null && __DEV__) {
      console.error("当FC存在PassiveEffect flags时,不应该不存在effect");
    }
    root.pendingPassiveEffects[type].push(updateQueue.lastEffect as Effect);
  }
}

因此,在标记了PassiveEffect的fiber的中, 我们收集了的root.pendingPassiveEffects[update],其中包含create以及未销毁的fiberdestroy的回调函数,

收集destroy回调

在之前的章节中,我们删除节点阶段的执行是在commitDeletion中,对应的函数组件处理的时候,我们通过commitPassiveEffect将塞入对应的unmount的类型回调函数。

/**
 * 删除对应的子fiberNode
 * @param {FiberNode} childToDelete
 */
function commitDeletion(childToDelete: FiberNode, root: FiberRootNode) {
  const rootChildrenToDelete: FiberNode[] = [];
  // 递归子树
  commitNestedComponent(childToDelete, (unmountFiber) => {
    switch (unmountFiber.tag) {
      case HostComponent:
        // ......
      case HostText:
        // ......
      case FunctionComponent:
        commitPassiveEffect(unmountFiber, root, "unmount");
        return;
      default:
        // ......
    }
  });
  // .......
}

commitDeletion中,我们主要是收集root.pendingPassiveEffects[unmount], 都是销毁的节点的destroy回调函数执行。

至此,我们就收集到了update以及destroy的回调函数。

接下来主流程执行完成后,开始执行对应的回调。

执行回调

在主流程和微任务合并更新执行完后,开始执行刚刚收集的回调函数。 但是回调函数的执行是有顺序的。

本次更新的任何create回调都必须在所有上一次更新的destroy回调执行完后再执行。

  1. 遍历effect
  2. 首先触发所有unmount effect,且对于某个fiber,如果触发了unmount destroy,本次更新不会再触发update create
  3. 触发所有上次更新的destroy
  4. 触发所有这次更新的create

主要逻辑都存在flushPassiveEffects中。由于flushPassiveEffects的执行是在新开的一个宏任务中,所以我们常说useEffect是一个异步操作。

function flushPassiveEffects(pendingPassiveEffects: PendingPassiveEffects) {
  // unmount effect
  pendingPassiveEffects.unmount.forEach((effect) => {
    commitHookEffectListUnmount(Passive, effect);
  });
  pendingPassiveEffects.unmount = [];

  pendingPassiveEffects.update.forEach((effect) => {
    commitHookEffectListDestroy(Passive | HookHasEffect, effect);
  });

  pendingPassiveEffects.update.forEach((effect) => {
    commitHookEffectListCreate(Passive | HookHasEffect, effect);
  });

  pendingPassiveEffects.update = [];
  flushSyncCallbacks();
}

对于commitHookEffectListUnmountcommitHookEffectListDestroy的区别是,commitHookEffectListUnmount需要去掉HookHasEffect,卸载后其他函数不要执行。

commitHookEffectListCreate中将新创建的destroy复制到effect.destroy中,方便下次调用。

export function commitHookEffectListUnmount(flags: Flags, lastEffect: Effect) {
  commitHookEffectList(flags, lastEffect, (effect) => {
    const destroy = effect.destroy;
    if (typeof destroy === "function") {
      destroy();
    }
    effect.tag &= ~HookHasEffect;
  });
}
export function commitHookEffectListDestroy(flags: Flags, lastEffect: Effect) {
  commitHookEffectList(flags, lastEffect, (effect) => {
    const destroy = effect.destroy;
    if (typeof destroy === "function") {
      destroy();
    }
  });
}

export function commitHookEffectListCreate(flags: Flags, lastEffect: Effect) {
  commitHookEffectList(flags, lastEffect, (effect) => {
    const create = effect.create;
    if (typeof create === "function") {
      effect.destroy = create();
    }
  });
}

例子

在下面例子中, 我们创建了App组件,然后有一个<Child />

function App() {
  const [num, updateNum] = useState(0);
  useEffect(() => {
    console.log("App mount");
  }, []);

  useEffect(() => {
    console.log("num change create", num);
    return () => {
      console.log("num change destroy", num);
    };
  }, [num]);

  return (
    <div onClick={() => updateNum(num + 1)}>
      {num === 0 ? <Child /> : "noop"}
    </div>
  );
}

function Child() {
  useEffect(() => {
    console.log("Child mount");
    return () => {
      console.log("Child unmount");
    };
  }, []);
  return "i am child";
}

在初始化的时候会依次执行:

  1. Child mount
  2. App mount
  3. "num change create", 0

点击后,Child组件开始卸载,由于首先执行unmount回调,顺序依次是:

  1. Child unmount
  2. num change destroy 0
  3. num change create 1

简单的结构图如下:从Child-fiberNode开始向上遍历。 结构.png