【React Hooks原理 - useEffect、useLayoutEffect】
介绍
在实际React Hooks项目中,我们需要在项目的不同阶段进行一些处理,比如在页面渲染之前进行dom操作、数据获取、第三方加载等。在Class Component中存在很多生命周期能让我们完成这个操作,但是在React Hooks没有所谓的生命周期,但是它提供了useEffect、useLayoutEffect来让我们进行不同阶段处理,下面就从源码角度来聊聊这两个Hooks。【源码地址】
前提了解
同其他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 的新副作用在绘制完成后异步执行。