介绍
在实际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<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环境下的代码)
// 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 listcurrentlyRenderingFiber.memoizedState = workInProgressHook = hook;} else {// Append to the end of the listworkInProgressHook = 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<mixed> | null,
): Effect {const effect: Effect = {tag,create,inst,deps,// Circularnext: (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<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中返回一个函数,该函数会在每次组件更新前以及组件卸载前会执行,该函数称为清除函数。
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<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);
}
区别就是在调用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 的新副作用在绘制完成后异步执行。