【React Hooks原理 - useEffect、useLayoutEffect】

07-09 1094阅读

介绍

在实际React Hooks项目中,我们需要在项目的不同阶段进行一些处理,比如在页面渲染之前进行dom操作、数据获取、第三方加载等。在Class Component中存在很多生命周期能让我们完成这个操作,但是在React Hooks没有所谓的生命周期,但是它提供了useEffect、useLayoutEffect来让我们进行不同阶段处理,下面就从源码角度来聊聊这两个Hooks。【源码地址】

【React Hooks原理 - useEffect、useLayoutEffect】
(图片来源网络,侵删)

前提了解

同其他Hooks一样(useContext除外),在React18版本之后将其拆分为了mount、update两个函数,并由Dispatcher在不同阶段来执行不同函数。

// 挂载时
const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  use,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
};
// 更新时
const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,
  use,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
};

下面介绍主要涉及到两个文件中内容:

  • react/src/ReactHooks.js

    这个文件主要是定义暴露的给用户实际使用的Hooks,即我们在组件中通过import { useXXX } from 'react'引入的Hooks。

  • react-reconciler/src/ReactFiberHooks.js

    该文件主要是React内部真正执行的Hooks函数,内部将Hooks拆分为了mount、update两个函数,并通过Dispatcher在不同阶段进行分发如上所示

    useEffect

    由于React18对于Hooks进行了重新组织,将其拆分为了挂载时和更新时,所以我们也从这两方面入手介绍。

    mount挂载时

    源代码文件路径:react/src/ReactHooks.js

    export function useEffect(
      create: () => (() => void) | void,
      deps: Array | void | null,
    ): void {
      const dispatcher = resolveDispatcher();
      return dispatcher.useEffect(create, deps);
    }
    

    正如我们使用的那样,useEffect接受两个参数create、deps。然后通过dispatcher在不同阶段进行不同的处理即挂载时执行mountEffect,更新时执行updateEffect,通过上面的HooksDispatcherOnMount/HooksDispatcherOnUpdate映射。

    React内部实现的Hooks代码都在react-reconciler/src/ReactFiberHooks.js文件下。(下面代码皆省略了DEV环境下的代码)

    // react-reconciler/src/ReactFiberHooks.js
    function mountEffect(
      create: () => (() => void) | void,
      deps: Array | void | null,
    ): void {
      mountEffectImpl(
        PassiveEffect | PassiveStaticEffect,  // 定义的常量用于标记常规的副作用
        HookPassive, // 表示是被动类型的Hook常量,不需要用户主动调用
        create,
        deps,
      );
    }
    function mountEffectImpl(
      fiberFlags: Flags,
      hookFlags: HookFlags,
      create: () => (() => void) | void,
      deps: Array | void | null,
    ): void {
      const hook = mountWorkInProgressHook();
      const nextDeps = deps === undefined ? null : deps;
      currentlyRenderingFiber.flags |= fiberFlags;
      hook.memoizedState = pushEffect(
        HookHasEffect | hookFlags,
        create,
        createEffectInstance(),
        nextDeps,
      );
    }
    function mountWorkInProgressHook(): Hook {
      const hook: Hook = {
        memoizedState: null,
        baseState: null,
        baseQueue: null,
        queue: null,
        next: null,
      };
      if (workInProgressHook === null) {
        // This is the first hook in the list
        currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
      } else {
        // Append to the end of the list
        workInProgressHook = workInProgressHook.next = hook;
      }
      return workInProgressHook;
    }
    

    从代码能看出,当我们调用useEffect之后,如果是首次挂载,React会通过dispatcher触发mountEffect函数,在其中调用了mountEffectImpl并传递了四个参数来对创建当前节点的hook。

    • PassiveEffect | PassiveStaticEffect: 用于标记副作用的常量,用于区分特性和用途。PassiveEffect 是用于标记常规的副作用,例如 useEffect 中定义的副作用。它表示这个副作用是在组件更新阶段执行的,但是不会阻塞浏览器的渲染。PassiveStaticEffect 是用于标记静态的副作用。表示这个副作用是静态的,不会在组件的多次渲染中发生变化,通常与静态数据相关。
    • HookPassive:标记Hook的类型常量,在 React 内部,不同类型的 Hook 会根据不同的标记和调度器进行处理。HookPassive 表示这个 Hook 是一种被动的类型,适用于大多数常规的 Hook 使用情况。
    • create:组件内使用useEffect包裹的函数
    • deps:useEffect包裹函数所依赖的参数

      在mountEffect中将创建useEffect所需要的数据传递mountEffectImpl之后,就进行Hook的创建。在mountEffectImpl函数中主要做了这些操作:

      • 调用mountWorkInProgressHook函数,创建一个管理hooks的循环链表
      • 获取依赖nextDeps,以及设置该副作用的Flag
      • 通过pushEffect创建一个副作用链表,并保存在hook.memoizedState中
        function pushEffect(
          tag: HookFlags,
          create: () => (() => void) | void,
          inst: EffectInstance, // 组件实例
          deps: Array | null,
        ): Effect {
          const effect: Effect = {
            tag,
            create,
            inst,
            deps,
            // Circular
            next: (null: any),
          };
          let componentUpdateQueue: null | FunctionComponentUpdateQueue =
            (currentlyRenderingFiber.updateQueue: any);
          if (componentUpdateQueue === null) {
            componentUpdateQueue = createFunctionComponentUpdateQueue();
            currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
            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;
        }
        

        pushEffect主要是创建一个副作用循环链表,并将其挂载在当前渲染fiber节点的状态更新队列中。所以fiber.updateQueue.lastEffect 指向的就是pushEffect创建的副作用链表。

        因为effect list是环状链表,updateQueue.lastEffect指向的最后元素,是因为这样有利于遍历时从起点开始,以及更好的插入effect

        至此在挂载时,成功创建了hook链表和effect链表并挂载在当前渲染fiber节点的updateQueue中,后续通过在 Commit 阶段,React 会遍历 Effect list,执行相应的副作用操作。

        update更新时

        和挂载时类似,updateEffect会调用updateEffectImpl来进行更新处理。

        function updateEffect(
          create: () => (() => void) | void,
          deps: Array | void | null,
        ): void {
          updateEffectImpl(PassiveEffect, HookPassive, create, deps);
        }
        function updateEffectImpl(
          fiberFlags: Flags,
          hookFlags: HookFlags,
          create: () => (() => void) | void,
          deps: Array | void | null,
        ): void {
          const hook = updateWorkInProgressHook();
          const nextDeps = deps === undefined ? null : deps;
          const effect: Effect = hook.memoizedState;
          const inst = effect.inst;
          // currentHook is null on initial mount when rerendering after a render phase
          // state update or for strict mode.
          if (currentHook !== null) {
            if (nextDeps !== null) {
              const prevEffect: Effect = currentHook.memoizedState;
              const prevDeps = prevEffect.deps;
              if (areHookInputsEqual(nextDeps, prevDeps)) {
                hook.memoizedState = pushEffect(hookFlags, create, inst, nextDeps);
                return;
              }
            }
          }
          currentlyRenderingFiber.flags |= fiberFlags;
          hook.memoizedState = pushEffect(
            HookHasEffect | hookFlags,
            create,
            inst,
            nextDeps,
          );
        }
        

        由上面可以知道pushEffect主要就是创建一个effect然后将其添加到fiber的更新队列中。而在更新时,通过areHookInputsEqual对比了前后渲染的依赖是否改变,然后通过pushEffect创建新的effect然后添加到更新队列,区别是当依赖改变时会将当前创建的新的hook的flag设置为HookHasEffect,表示当前副作用需要重新执行。

        cleanup清除函数

        在useEffect中返回一个函数,该函数会在每次组件更新前以及组件卸载前会执行,该函数称为清除函数。

        useEffect(() => {
        	console.log('useEffect');
        	return () => {
        		consoe.log('清除函数')
        	}
        }, [deps])
        

        该函数会在commitHookEffectListMount函数中挂载到effect副作用上,并且在commitHookEffectListUnmount中执行,这两个函数都是在commit阶段进行的,文件路径为:packages/react-reconciler/src/ReactFiberCommitWork.js。

        commitHookEffectListMount 负责在副作用更新后重新执行副作用(即deps更新后会触发该函数执行):

        function commitHookEffectListMount(
          tag: HookFlags,
          finishedWork: Fiber,
        ) {
          const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
          let lastEffect: Effect | null = 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);
          }
        }
        

        从代码可以看出,在commitHookEffectListMount函数中,如果useEffect副作用中存在清除函数(即return的函数),则会挂载在副作用中,即 effect.destroy = create();

        commitHookEffectListUnmount 负责在组件更新或卸载时清理副作用:

        function commitHookEffectListUnmount(
          tag: HookFlags,
          finishedWork: Fiber,
        ) {
          const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
          let lastEffect: Effect | null = updateQueue !== null ? updateQueue.lastEffect : null;
          if (lastEffect !== null) {
            const firstEffect = lastEffect.next;
            let effect = firstEffect;
            do {
              if ((effect.tag & tag) === tag) {
                // 执行清理函数
                const destroy = effect.destroy;
                if (destroy !== undefined) {
                  destroy();
                }
              }
              effect = effect.next;
            } while (effect !== firstEffect);
          }
        }
        

        所以当组件卸载或者更新之前,会先执行清除函数然后在重新挂载新的清除函数。

        useEffect的执行时机

        上面说了在React Hooks中为了让我们能拥有类似Class Component生命周期一样对项目运行阶段进行监听并处理的功能,所以有了useEffect钩子,下面列举一下useEffect和Class Component生命周期的对应关系,帮助理解useEffect的执行时机。

        • 不写依赖数组: useEffect 会在每次渲染后执行,类似于 componentDidMount 和 componentDidUpdate 的结合。
        • 空依赖数组: useEffect 只在组件挂载和卸载时执行一次,类似于 componentDidMount 和 componentWillUnmount 的组合。
        • 带依赖数组: useEffect 只会在组件挂载时和依赖项发生变化时执行,类似于 componentDidUpdate 针对特定依赖项的变化。

          可能有的同学看到不写依赖数组,会在每次渲染以及更新时都会执行,那这样和不适应useEffect包裹,直接在组件内声明有什么区别呢?下面也简单列举一下:

          比较维度直接在函数内的代码useEffect 中的代码
          执行时机在 React 调用组件函数期间同步执行,这意味着它会在 React 准备和生成新的虚拟 DOM 树时执行在 React 完成更新后(渲染并提交真实 DOM 变更后)异步执行,适合处理副作用,如数据获取、订阅、DOM 操作等
          副作用管理不适合处理副作用,逻辑应该是纯函数的,不应引起副作用(例如,不直接操作 DOM)专为处理副作用设计,适合处理那些在组件渲染后需要进行的操作,如数据获取、DOM 更新或事件订阅
          清理机制没有自动的清理机制。提供了一个清理函数,允许在组件卸载或下一次副作用执行之前进行清理工作。
          性能优化每次渲染都会执行。如果不需要每次都执行,会造成不必要的性能开销。通过依赖数组控制执行频率,避免不必要的重新执行。

          由表可以看出,主要区别在于副作用处理,和性能优化的区别。需要根据场景来决定如何使用,除非需要实时更新执行,否则一般不推荐在组件内直接写函数。

          useLayoutEffect

          上面聊了useEffect,下面来谈谈它的同胞兄弟useLayoutEffect,毕竟我们经常看到说使用useLayoutEffect可以有效解决在useEffect中操作状态/dom导致的屏幕闪缩问题。

          同useEffect一样,useLayoutEffect也分为了mount、update。所以我们之间步如主题,从mountLayoutEffect开始。

          从代码来看,在mount时useLayoutEffect和useEffect两者在语法上是一样的,都接受一个create函数(可包含cleanup函数),一个deps依赖数组,并都通过mountWorkInProgressHook来创建hook,然后通过pushEffect添加effect到hook中。更新时候也和useEffect大致一致,所以这里放在一起,重复代码则不再冗余贴上了。

          // mount
          function mountLayoutEffect(
            create: () => (() => void) | void,
            deps: Array | void | null,
          ): void {
            let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect;
            return mountEffectImpl(fiberFlags, HookLayout, create, deps);
          }
          // update
          function updateLayoutEffect(
            create: () => (() => void) | void,
            deps: Array | void | null,
          ): void {
            return updateEffectImpl(UpdateEffect, HookLayout, create, deps);
          }
          

          区别就是在调用mountEffectImpl和updateEffectImpl时传入的Flags不一样

          // useEffect
          const PassiveEffect = /* */ 0b000000001000;
          // useLayoutEffect
          const UpdateEffect = /* */ 0b000000000100;
          
          • PassiveEffect 表示这是一个被动的副作用,它会在浏览器完成布局和绘制后执行。
          • UpdateEffect 表示这是一个同步的副作用,它会在所有 DOM 变更之后,浏览器绘制之前执行。

            由此能看出useEffect是浏览器完成布局和绘制后异步执行,不影响渲染。而useLayoutEffect在所有 DOM 变更之后,浏览器绘制之前同步执行。

            执行时机: DOM变更完成 -> useLayoutEffect(同步) -> 页面绘制 -> useEffect(异步)。 这也说明了为什么在useEffect中操作状态或者DOM时候,屏幕会闪缩(因为页面已经渲染,然后异步更新状态之后,会导致页面再次渲染时候存在时间差)。而useLayoutEffect能解决闪烁问题。(useLayoutEffect在页面还未绘制之前同步执行,修改状态之后再绘制到页面,对用户来说无感知,但是处理长任务时,会导致白屏问题)。

            总结一下。两种区别主要是执行时机不同:

            • useEffect 使用 PassiveEffect 标志,确保副作用在浏览器完成绘制后异步执行。
            • useLayoutEffect 使用 UpdateEffect 标志,确保副作用在 DOM 更新后,浏览器绘制前同步执行。

              总结

              useEffect和useLayoutEffect在语法和代码组织上,逻辑大致相同。在mount阶段通过mountWorkInProgressHook 创建hook,pushEffect创建effect list并绑定在渲染fiber上。在update阶段通过updateEffectImpl调用updateWorkInProgressHook更新hook 列表,并通过areHookInputsEqual判断依赖是否变化,然后设置不同的Flag交给pushEffect创建新的effect,在执行时会根据设置的Flag来判断是否需要重新执行。

              当状态更新时总的流程如下:

              • count 状态更新,组件重新渲染。
              • React 计算新的虚拟 DOM 并将其变更应用到实际 DOM。
              • useLayoutEffect 清除函数(如果存在)在 DOM 变更后立即同步执行。
              • useLayoutEffect 的新副作用在 DOM 变更后立即同步执行。
              • 浏览器绘制页面。
              • useEffect 清除函数(如果存在)在绘制完成后异步执行。
              • useEffect 的新副作用在绘制完成后异步执行。
VPS购买请点击我

文章版权声明:除非注明,否则均为主机测评原创文章,转载或复制请以超链接形式并注明出处。

目录[+]