【React Hooks原理 - useEffect、useLayoutEffect】

介绍

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

前提了解

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

javascript 复制代码
// 挂载时
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

javascript 复制代码
export function useEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | 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环境下的代码)

javascript 复制代码
// react-reconciler/src/ReactFiberHooks.js
function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  mountEffectImpl(
    PassiveEffect | PassiveStaticEffect,  // 定义的常量用于标记常规的副作用
    HookPassive, // 表示是被动类型的Hook常量,不需要用户主动调用
    create,
    deps,
  );
}

function mountEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | 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中
javascript 复制代码
function pushEffect(
  tag: HookFlags,
  create: () => (() => void) | void,
  inst: EffectInstance, // 组件实例
  deps: Array<mixed> | 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来进行更新处理。

javascript 复制代码
function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}

function updateEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | 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中返回一个函数,该函数会在每次组件更新前以及组件卸载前会执行,该函数称为清除函数。

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

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

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

javascript 复制代码
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 负责在组件更新或卸载时清理副作用:

javascript 复制代码
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大致一致,所以这里放在一起,重复代码则不再冗余贴上了。

javascript 复制代码
// mount
function mountLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect;
  return mountEffectImpl(fiberFlags, HookLayout, create, deps);
}

// update
function updateLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(UpdateEffect, HookLayout, create, deps);
}

区别就是在调用mountEffectImplupdateEffectImpl时传入的Flags不一样

javascript 复制代码
// 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 的新副作用在绘制完成后异步执行。
相关推荐
寒雒1 分钟前
【Python】实战:实现GUI登录界面
开发语言·前端·python
独上归州8 分钟前
Vue与React的Suspense组件对比
前端·vue.js·react.js·suspense
Komorebi⁼15 分钟前
Vue核心特性解析(内含实践项目:设置购物车)
前端·javascript·vue.js·html·html5
明月清风徐徐15 分钟前
Vue实训---0-完成Vue开发环境的搭建
前端·javascript·vue.js
daopuyun19 分钟前
LoadRunner小贴士|开发Web-HTTP/HTML协议HTML5相关视频应用测试脚本的方法
前端·http·html
李先静22 分钟前
AWTK-WEB 快速入门(1) - C 语言应用程序
c语言·开发语言·前端
MR·Feng31 分钟前
使用Electron将vue2项目打包为桌面exe安装包
前端·javascript·electron
萧大侠jdeps43 分钟前
图片生成视频-右进
前端·javascript·音视频
Domain-zhuo1 小时前
JS对于数组去重都有哪些方法?
开发语言·前端·javascript
明月清风徐徐2 小时前
Vue实训---2-路由搭建
前端·javascript·vue.js