关于javascript:梳理useEffect和useLayoutEffect的原理与区别

38次阅读

共计 11298 个字符,预计需要花费 29 分钟才能阅读完成。

点击进入 React 源码调试仓库。

React 在构建用户界面整体遵循函数式的编程理念,即固定的输出有固定的输入,尤其是在推出函数式组件之后,更加强化了组件纯函数的理念。但理论业务中编写的组件未免要产生申请数据、订阅事件、手动操作 DOM 这些副作用(effect),这样不免让函数组件变得不那么纯,于是 React 提供 use(Layout)Effect 的 hook,给开发者提供专门治理副作用的形式。

上面咱们会从 effect 的数据结构动手,梳理 use(Layout)Effect 在 render 和 commit 阶段的整体流程。

Effect 的数据结构

对于 hook 链表构造的基本概念我曾经总结过一篇文章:React hooks 的根底概念:hooks 链表。对函数组件来说,其 fiber 上的 memorizedState 专门用来存储 hooks 链表,每一个 hook 对应链表中的每一个元素。use(Layout)Effect 产生的 hook 会放到 fiber.memorizedState 上,而它们调用后最终会生成一个 effect 对象,存储到它们对应 hook 的 memoizedState 中,与其余的 effect 连接成环形链表。

单个的 effect 对象包含以下几个属性:

  • create: 传入 use(Layout)Effect 函数的第一个参数,即回调函数
  • destroy: 回调函数 return 的函数,在该 effect 销毁的时候执行
  • deps: 依赖项
  • next: 指向下一个 effect
  • tag: effect 的类型,辨别是 useEffect 还是 useLayoutEffect

单纯看 effect 对象中的字段,很容易和平时的用法分割起来。create 函数即咱们传入 use(Layout)Effect 的回调函数,而通过 deps,能够管制 create 是否执行,如需革除 effect,则在 create 函数中 return 一个新函数(即 destroy)即可。

为了了解 effect 的数据结构,假如有如下组件:

const UseEffectExp = () => {const [ text, setText] = useState('hello')
    useEffect(() => {console.log('effect1')
        return () => {console.log('destory1');
        }
    })
    useLayoutEffect(() => {console.log('effect2')
        return () => {console.log('destory2');
        }
    })
    return <div>effect</div>
}

挂载到它 fiber 上 memoizedState 的 hooks 链表构造如下

例如 useEffect hook 上的 memoizedState 存储了 useEffect 的 effect 对象(effect1),next 指向 useLayoutEffect 的 effect 对象(effect2)。effect2 的 next 又指回 effect1. 在上面的 useLayoutEffect hook 中,也是如此的构造。

fiber.memoizedState ---> useState hook
                             |
                             |
                            next
                             |
                             ↓
                        useEffect hook
                        memoizedState: useEffect 的 effect 对象 ---> useLayoutEffect 的 effect 对象
                             |              ↑__________________________________|
                             |
                            next
                             |
                             ↓
                        useLayoutffect hook
                        memoizedState: useLayoutEffect 的 effect 对象 ---> useEffect 的 effect 对象
                                            ↑___________________________________|

effect 除了保留在 fiber.memoizedState 对应的 hook 中,还会保留在 fiber 的 updateQueue 中。

fiber.updateQueue ---> useLayoutEffect ----next----> useEffect
                             ↑                          |
                             |__________________________|

当初,咱们晓得,调用 use(Layout)Effect,最初会产生 effect 链表,这个链表保留在两个中央:

  • fiber.memoizedState 的 hooks 链表中,use(Layout)Effect 对应 hook 元素的 memoizedState 中。
  • fiber.updateQueue 中,本次更新的 updateQueue,它会在本次更新的 commit 阶段中被解决。

流程概述

基于下面的数据结构,对于 use(Layout)Effect 来说,React 做的事件就是

  • render 阶段:函数组件开始渲染的时候,创立出对应的 hook 链表挂载到 workInProgress 的 memoizedState 上,并创立 effect 链表,然而基于上次和本次依赖项的比拟后果,

创立的 effect 是有差别的。这一点暂且能够了解为:依赖项有变动,effect 能够被解决,否则不会被解决。

  • commit 阶段:异步调度 useEffect,layout 阶段同步解决 useLayoutEffect 的 effect。等到 commit 阶段实现,更新利用到页面上之后,开始解决 useEffect 产生的 effect。

第二点提到了一个重点,就是 useEffect 和 useLayoutEffect 的执行机会不一样,前者被异步调度,当页面渲染实现后再去执行,不会阻塞页面渲染。
后者是在 commit 阶段新的 DOM 筹备实现,但还未渲染到屏幕之前,同步执行。

实现细节

通过整体流程能够看出,effect 的整个过程波及到 render 阶段和 commit 阶段。render 阶段只创立 effect 链表,commit 阶段去解决这个链表。所有实现的细节都是在围绕 effect 链表。

render 阶段 - 创立 effect 链表

在理论的应用中,咱们调用的 use(Layout)Effect 函数,在挂载和更新的过程是不同的。

挂载时,调用的是mountEffectImpl,它会为 use(Layout)Effect 这类 hook 创立一个 hook 对象,将 workInProgressHook 指向它,而后在这个 fiber 节点的 flag 中退出副作用相干的 effectTag。最初,会构建 effect 链表挂载到 fiber 的 updateQueue,并且也会在 hook 上的 memorizedState 挂载 effect。

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 创立 hook 对象
  const hook = mountWorkInProgressHook();
  // 获取依赖
  const nextDeps = deps === undefined ? null : deps;

  // 为 fiber 打上副作用的 effectTag
  currentlyRenderingFiber.flags |= fiberFlags;

  // 创立 effect 链表,挂载到 hook 的 memoizedState 上和 fiber 的 updateQueue
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}

currentlyRenderingFiber 即 workInProgress 节点

更新时,调用updateEffectImpl,实现 effect 链表的构建。这个过程中会依据前后依赖项是否变动,从而创立不同的 effect 对象。具体体现在 effect 的 tag 上,如果前后依赖未变,则 effect 的 tag 就赋值为传入的 hookFlags,否则,在 tag 中退出 HookHasEffect 标记位。正是因为这样,在解决 effect 链表时才能够只解决依赖变动的 effect,use(Layout)Effect 能够依据它的依赖变动状况来决定是否执行回调。

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    // 从 currentHook 中获取上一次的 effect
    const prevEffect = currentHook.memoizedState;
    // 获取上一次 effect 的 destory 函数,也就是 useEffect 回调中 return 的函数
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 比拟前后依赖,push 一个不带 HookHasEffect 的 effect
      if (areHookInputsEqual(nextDeps, prevDeps)) {pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  currentlyRenderingFiber.flags |= fiberFlags;
  // 如果前后依赖有变,在 effect 的 tag 中退出 HookHasEffect
  // 并将新的 effect 更新到 hook.memoizedState 上
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

在组件挂载和更新时,有一个区别,就是挂载期间调用 pushEffect 创立 effect 对象的时候并没有传 destroy 函数,而更新期间传了,这是因为每次 effect 执行时,都是先执行前一次的销毁函数,再执行新 effect 的创立函数。而挂载期间,上一次的 effect 并不存在,执行创立函数前也就无需先销毁。

挂载和更新,都调用了 pushEffect,它的职责很单纯,就是创立 effect 对象,构建 effect 链表,挂到 WIP 节点的 updateQueue 上。

function pushEffect(tag, create, destroy, deps) {
  // 创立 effect 对象
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };

  // 从 workInProgress 节点上获取到 updateQueue,为构建链表做筹备
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    // 如果 updateQueue 为空,把 effect 放到链表中,和它本人造成闭环
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    // 将 updateQueue 赋值给 WIP 节点的 updateQueue,实现 effect 链表的挂载
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    // updateQueue 不为空,将 effect 接到链表的后边
    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;
}

函数组件和类组件的 updateQueue 都是环状链表

以上,就是 effect 链表的构建过程。咱们能够看到,effect 对象创立进去最终会以两种模式放到两个中央:单个的 effect,放到 hook.memorizedState 上;环状的 effect 链表,放到 fiber 节点的 updateQueue 中。两者各有用处,前者的 effect 会作为上次更新的 effect,为本次创立 effect 对象提供参照(比照依赖项数组),后者的 effect 链表会作为最终被执行的主体,带到 commit 阶段解决。

commit 阶段 -effect 如何被解决

useEffect 和 useLayoutEffect,对它们的解决最终都落在解决 fiber.updateQueue 上,对前者来说,循环 updateQueue 时只解决蕴含 useEffect 这类 tag 的 effect,对后者来说,只解决蕴含 useLayoutEffect 这类 tag 的 effect,它们的处理过程都是先执行前一次更新时 effect 的销毁函数(destroy),再执行新 effect 的创立函数(create)。

以上是它们的处理过程在宏观上的共性,宏观上的区别次要体现在执行机会上。useEffect 是在 beforeMutation 或 layout 阶段异步调度,而后在本次的更新利用到屏幕上之后再执行,而 useLayoutEffect 是在 layout 阶段同步执行的。上面先剖析 useEffect 的处理过程。

useEffect 的异步调度

与 componentDidMount、componentDidUpdate 不同的是,在浏览器实现布局与绘制之后,传给 useEffect 的函数会提早调用。
这使得它实用于许多常见的副作用场景,比方设置订阅和事件处理等状况,因而不应在函数中执行阻塞浏览器更新屏幕的操作。

基于 useEffect 回调 提早调用(实际上就是异步调用) 的需要,在实现上利用 scheduler 的异步调度函数:scheduleCallback,将执行 useEffect 的动作作为一个工作去调度,这个工作会异步调用。

commit 阶段和 useEffect 真正扯上关系的有三个中央:commit 阶段的开始、beforeMutation、layout,波及到异步调度的是前面两个。


function commitRootImpl(root, renderPriorityLevel) {
  // 进入 commit 阶段,先执行一次之前未执行的 useEffect
  do {flushPassiveEffects();
  } while (rootWithPendingPassiveEffects !== null);

  ...

  do {
    try {
      // beforeMutation 阶段的处理函数:commitBeforeMutationEffects 外部,// 异步调度 useEffect
      commitBeforeMutationEffects();} catch (error) {...}
  } while (nextEffect !== null);

  ...

  const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;

  if (rootDoesHavePassiveEffects) {
    // 重点,记录有副作用的 effect
    rootWithPendingPassiveEffects = root;
  }
}

这三个中央去执行或者调度 useEffect 有什么用意呢?咱们别离来看。

  • commit 开始,先执行一下 useEffect:这和 useEffect 异步调度的特点无关,它以个别的优先级被调度,这就意味着一旦有更高优先级的工作进入到 commit 阶段,上一次工作的 useEffect 还没失去执行。所以在本次更新开始前,须要先将之前的 useEffect 都执行掉,以保障本次调度的 useEffect 都是本次更新产生的。
  • beforeMutation 阶段异步调度 useEffect:这个是实打实地针对 effectList 上有副作用的节点,去异步调度 useEffect。
function commitBeforeMutationEffects() {while (nextEffect !== null) {

    ...

    if ((flags & Passive) !== NoFlags) {
      // 如果 fiber 节点上的 flags 存在 Passive 调度 useEffect
      if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        scheduleCallback(NormalSchedulerPriority, () => {flushPassiveEffects();
          return null;
        });
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

因为 rootDoesHavePassiveEffects 的限度,只会发动一次 useEffect 调度,相当于用一把锁锁住调度状态,防止发动屡次调度。

  • layout 阶段填充 effect 执行数组:真正 useEffect 执行的时候,实际上是先执行上一次 effect 的销毁,再执行本次 effect 的创立。React 用两个数组来别离存储销毁函数和

创立函数,这两个数组的填充就是在 layout 阶段,到时候循环开释执行两个数组中的函数即可。

function commitLifeCycles(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent:
    case Block: {

      ...

      // layout 阶段填充 effect 执行数组
      schedulePassiveEffects(finishedWork);
      return;
    }
}

在调用 schedulePassiveEffects 填充 effect 执行数组时,有一个重要的中央就是只在蕴含 HasEffect 的 effectTag 的时候,才将 effect 放到数组内,这一点保障了依赖项有变动再去解决 effect。也就是:如果前后依赖未变,则 effect 的 tag 就赋值为传入的 hookFlags,否则,在 tag 中退出 HookHasEffect 标记位。正是因为这样,在解决 effect 链表时才能够只解决依赖变动的 effect,use(Layout)Effect 才能够依据它的依赖变动状况来决定是否执行回调。

schedulePassiveEffects 的实现:

function schedulePassiveEffects(finishedWork: Fiber) {
  // 获取到函数组件的 updateQueue
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  // 获取 effect 链表
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    // 循环 effect 链表
    do {const {next, tag} = effect;
      if ((tag & HookPassive) !== NoHookEffect &&
        (tag & HookHasEffect) !== NoHookEffect
      ) {
        // 当 effect 的 tag 含有 HookPassive 和 HookHasEffect 时,向数组中 push effect
        enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
        enqueuePendingPassiveHookEffectMount(finishedWork, effect);
      }
      effect = next;
    } while (effect !== firstEffect);
  }
}

在调用 enqueuePendingPassiveHookEffectUnmountenqueuePendingPassiveHookEffectMount填充数组的时候,还会再异步调度一次 useEffect,但这与 beforeMutation 的调度是互斥的,一旦之前调度过,就不会再调度了,同样是 rootDoesHavePassiveEffects 起的作用。

执行 effect

此时咱们曾经晓得,effect 得以被解决是因为之前的调度以及 effect 数组的填充。当初到了最初的步骤,执行 effect 的 destroy 和 create。过程就是先循环待销毁的 effect 数组,再循环待创立的 effect 数组,这一过程产生在 flushPassiveEffectsImpl 函数中。循环的时候每个两项去 effect 是因为奇数项存储的是以后的 fiber。

function flushPassiveEffectsImpl() {
  // 先校验,如果 root 上没有 Passive efectTag 的节点,则间接 return
  if (rootWithPendingPassiveEffects === null) {return false;}

  ...

  // 执行 effect 的销毁
  const unmountEffects = pendingPassiveHookEffectsUnmount;
  pendingPassiveHookEffectsUnmount = [];
  for (let i = 0; i < unmountEffects.length; i += 2) {const effect = ((unmountEffects[i]: any): HookEffect);
    const fiber = ((unmountEffects[i + 1]: any): Fiber);
    const destroy = effect.destroy;
    effect.destroy = undefined;

    if (typeof destroy === 'function') {
      try {destroy();
      } catch (error) {captureCommitPhaseError(fiber, error);
      }
    }
  }

  // 再执行 effect 的创立
  const mountEffects = pendingPassiveHookEffectsMount;
  pendingPassiveHookEffectsMount = [];
  for (let i = 0; i < mountEffects.length; i += 2) {const effect = ((mountEffects[i]: any): HookEffect);
    const fiber = ((mountEffects[i + 1]: any): Fiber);
    try {
      const create = effect.create;
      effect.destroy = create();} catch (error) {captureCommitPhaseError(fiber, error);
    }
  }

  ...

  return true;
}

useLayoutEffect 的同步执行

useLayoutEffect 在执行的时候,也是先销毁,再创立。和 useEffect 不同的是这两者都是同步执行的,前者在 mutation 阶段执行,后者在 layout 阶段执行。
与 useEffect 不同的是,它不必数组去存储销毁和创立函数,而是间接操作 fiber.updateQueue。

卸载上一次的 effect,产生在 mutation 阶段


// 调用卸载 layout effect 的函数,传入 layout 无关的 effectTag 和阐明 effect 有变动的 effectTag:HookLayout | HookHasEffect
commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
  // 获取 updateQueue
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  // 循环 updateQueue 上的 effect 链表
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {if ((effect.tag & tag) === tag) {
        // 执行销毁
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {destroy();
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

执行本次的 effect 创立,产生在 layout 阶段

// 调用创立 layout effect 的函数
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListMount(tag: number, finishedWork: Fiber) {const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {if ((effect.tag & tag) === tag) {
        // 创立
        const create = effect.create;
        effect.destroy = create();}
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

总结

useEffect 和 useLayoutEffect 作为组件的副作用,实质上是一样的。共用一套构造来存储 effect 链表。整体流程上都是先在 render 阶段,生成 effect,并将它们拼接成链表,存到 fiber.updateQueue 上,最终带到 commit 阶段被解决。他们彼此的区别只是最终的执行机会不同,一个异步一个同步,这使得 useEffect 不会阻塞渲染,而 useLayoutEffect 会阻塞渲染。

欢送扫码关注公众号,发现更多技术文章

正文完
 0