如果说useState可以类比class组件中的setState,那么useEffect就显得非常特殊。在class组件中存在这各种生命周期,例如render、componentDidMount(组件挂载后)、componentDidUpdate(更新后)、componentWillUnmount(卸载销毁前)等,使用React Hook就要忘掉这些生命周期,对于习惯了Vue/React等生命周期机制来说,React Hook无疑是颠覆性的方式,在实际使用React Hook的函数组件中,经常使用useEffect来控制到对应的生命周期时机来执行相关代码。
但是如果不是了解React Hook背后的机制,使用React Hook可能会造成不必要的渲染,本文旨在理解useEffect的执行机制,明确useEffect Hook的使用时机以及相关方式的背后逻辑。
useEffect可以让你在函数中执行副作用,所谓的副作用是函数式编程的概念,与之对应的是纯函数。ajax请求数据、全局变量更改、更改DOM等都属于副作用,useEffect的使用有如下几种方式:
// 最基本的使用
useEffect(() => {})
// 存在函数返回值
useEffect(() => {
return () => {}
})
// 存在依赖项
useEffect(() => {}, [dep])
// 空数组依赖项
useEffect(() => {}, [])
上面几种方式实际上涉及到useEffect的特点,可以将useEffect看成是React class的生命周期componentDidMount、componentDidUpdate、componentWillUnmount的组合,如果返回函数,此函数会在组件卸载销毁前调用。接下来就通过来查看useeffect的整个运行逻辑,具体如下:
function Hello() {
useEffect(() => {
console.log('he')
})
}
如同useState一样,useEffect在初始化时对应的dispatcher是相同的,具体逻辑如下:
HooksDispatcherOnMountInDEV = {
useEffect: function (create, deps) {
currentHookNameInDev = 'useEffect';
mountHookTypesDev();
checkDepsAreArrayDev(deps);
return mountEffect(create, deps);
},
}
useEffect支持两个参数,第二个是其依赖项,其中checkDepsAreArrayDev是检查依赖项类型是否合法(是否是null、undefined、数组三种类型),而mountEffect函数内部会调用mountEffectImpl,该函数的会接受4个参数:
// update = 4 Passive = 512 Passive$1 = 4
mountEffectImpl(Update | Passive, Passive$1, create, deps)
而该函数的具体逻辑如下:
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps) {
var hook = mountWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber$1.effectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(HasEffect | hookEffectTag, create, undefined, nextDeps);
}
这里先不关心fiberEffectTag和hookEffectTag,该函数会创建一个hook对象,然后执行pushEffect函数,而pushEffect函数的返回值这是其hook对象的初始值。
该函数的具体处理逻辑如下:
function pushEffect(tag, create, destroy, deps) {
var effect = {
tag: tag,
create: create,
destroy: destroy,
deps: deps,
// Circular
next: null
};
var componentUpdateQueue = currentlyRenderingFiber$1.updateQueue;
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber$1.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
var lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
var firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
该函数逻辑实际上就是两点:
其中effect对象有如下几个字段:
var effect = {
tag: tag,
// 保存useEffect的第一个参数
create: create,
// 保存useEffect的返回函数
destroy: destroy,
// useEffect的第二个参数
deps: deps,
// Circular,链式结构
next: null
};
和useState一样,初始化做的工作简单清晰,到这里就完成了useEffect的整个初始化处理,会发现useEffect传递的函数并没有执行即在函数组件渲染时(可看成render)传入的函数并没有被执行。
实际上通过Debug发现,useEffect传递的函数的执行是在commit阶段结束后的末尾期间调用的,其调用的触发入口是在commit阶段的commitBeforeMutationEffects时。
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
var effectTag = nextEffect.effectTag;
...
// 注意effectTag的值,useEffect中对其有设置
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalPriority, function () {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
其中重要的逻辑有两点:
scheduleCallback背后是调用Scheduler_scheduleCallback,这个在之前的setState文章中就有提及,只不过这里推入到heap堆栈中的task的回调函数不同,主要是执行flushPassiveEffects函数。
那么问题来了,heap堆栈中的task是在哪里执行的呢?之前挂载和更新阶段都没有提及到,之前的文章是把Scheduler_scheduleCallback的处理逻辑就简单说了这里存在一个异步逻辑。实际上该函数创建task会根据其执行时间点判断将其存入timeQueue或taskQueue中,timeQueue相当于延迟执行,而taskQueue的执行是在workLoop函数中,而workLoop函数是通过flushWork函数调用的,而该函数的一个触发就是通过Scheduler_scheduleCallback的异步逻辑来处理的。
通过Debug发现在初始化阶段useEffect的处理是在commit结束后页面都已经更新了才会执行,即:
Scheduler_scheduleCallback -> 创建task -> 推入到taskQueue中 -> 触发异步逻辑使用MessageChannel来监听 -> 当异步满足条件后会执行performWorkUntilDeadline -> 该函数就会执行flushWork -> workLoop -> 会处理taskQueue,执行task对应的callback -> flushPassiveEffects
上面是初始化阶段主要的处理流程,当执行flushPassiveEffects时爱远远没有开始执行useEffect的函数,在该函数中继续调用flushPassiveEffectsImpl函数,而在该函数中会调用一个commitPassiveHookEffects函数,该函数才是useEffect传递函数执行的直接函数。该函数的处理逻辑如下:
function commitPassiveHookEffects(finishedWork) {
// effectTag
if ((finishedWork.effectTag & Passive) !== NoEffect) {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block:
{
commitHookEffectListUnmount(Passive$1 | HasEffect, finishedWork);
commitHookEffectListMount(Passive$1 | HasEffect, finishedWork);
break;
}
}
}
}
对于指定类型的FiberNode对象,会执行Unmount、Mount相关的HookEffect,没错这里的Unmount函数实际上就是执行effect对象的destory属性的值,在前面梳理useEffect时的逻辑就会创建一个effect对象,该对象存在destory和create属性对应着useEffect的返回值和传入函数。commitHookEffectListMount就是执行create的,所以知道了没有任何依赖的useEffect初始化时其函数参数执行的时机是在DOM挂载到页面之后了。
那么存在依赖的useEffect的时机呢?答案是一样,在初始化阶段是存在依赖还是无依赖调用时机都是一样的。
通过之前的文章可知更新阶段是通过syncQueue中的 performSyncWorkOnRoot callback来实现组件更新渲染从而视图渲染,这会再次调用函数就会再次执行useEffect。之前的useState在挂载和更新阶段是通过切换不同的dispatcher来实现执行不同的逻辑,useEffect也是如此。在更新阶段useEffect会对应执行updateEffect方法,该方法的具体逻辑如下:
function updateEffect(create, deps) {
return updateEffectImpl(Update | Passive, Passive$1, create, deps);
}
updateEffectImpl函数的具体执行逻辑如下:
function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps) {
var hook = updateWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
var destroy = undefined;
if (currentHook !== null) {
var prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
var prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(hookEffectTag, create, destroy, nextDeps);
return;
}
}
}
currentlyRenderingFiber$1.effectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(HasEffect | hookEffectTag, create, destroy, nextDeps);
}
上面有三个注意点:
areHookInputsEqual的比较结果相同的话,就不会更新hook的memoizedState值即effect对象不会更新。需要额外注意的是更新阶段useEffect传入的函数是新创建的函数,这跟之后的一些Hook存在区别的点之一。更新阶段useEffect依赖更新和无依赖的useEffect的执行时机跟初始化阶段时机相同,并没有什么区别。
假设依赖没有更新呢?按照上面的逻辑hooks.memoizedState还是之前的旧值,此时只会执行pushEffect函数。在初始化阶段知道useEffect的执行的关键在于commit阶段调用scheduleCallback向taskQueue中添加了带有callback值的task,当存在依赖被认为没有变更时这个逻辑就不会触发,为什么没有触发关键在于effectTag标记的问题。空数组和依赖没有更新是相同的道理,都是在更新时调用useEffect内部对依赖值比较时被认为是没有变化导致后面commit阶段后不会执行其传递的函数。
实际上从整个逻辑可以知道,只要依赖没有变化就不会执行相关函数。那么使用全局变量或props传递的值作为依赖会触发useEffect运行吗?实际上是否运行的关键逻辑就是依赖项的值是否变动了,无论你的依赖项是state、props、全局变量或者局部变量,只要保证每次函数组件执行useEffect依赖值发生了变化就会触发运行。useEffect的运行时机总是在渲染完成后,通过第二个参数是用来优化渲染的,也可以用来实现类似Vue Watch的监听效果,注意结合开发场景来具体使用。
在React官网的介绍中useLayoutEffect与useEffect是相同的,之前useLayoutEffect会同步调用,在上面对useEffect的逻辑梳理中,会发现useEffect调用是通过异步来触发的。那么看看useLayoutEffect的处理过程是怎样的,依旧分开初始化阶段和更新阶段来具体看。
逻辑基本类似,调用对应dispatcher的useLayoutEffect,而其方法最后会调用mountLayoutEffect来处理,而该函数又会调用mountEffectImpl函数来处理,需要注意的是相关effectTag的不同:
mountEffectImpl(Update, Layout, create, deps);
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps) {
var hook = mountWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber$1.effectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(HasEffect | hookEffectTag, create, undefined, nextDeps);
}
是跟useEffect的effectTag不同,那么必然不会满足useEffect在commit阶段的加入taskQueue的条件了。后面的pushEffect的逻辑与useEffect并没有什么区别。
通过Debug发现,useLayoutEffect会在commit阶段DOM挂载到页面后同步执行,具体是在commit阶段的commitLayoutEffects函数中执行:
function commitLayoutEffects(root, committedExpirationTime) {
while (nextEffect !== null) {
...
var effectTag = nextEffect.effectTag;
// 会满足该条件
if (effectTag & (Update | Callback)) {
recordEffect();
var current = nextEffect.alternate;
commitLifeCycles(root, current, nextEffect);
}
...
nextEffect = nextEffect.nextEffect;
}
}
commitLifeCycles函数中就会对于函数组件就会调用commitHookEffectListMount执行effect的create函数,即useLayoutEffect传入的函数。
实际上更新阶段的执行逻辑与useEffect并没有什么区别,而且最后都是调用updateEffectImpl函数,基本的处理逻辑都是相同的,只是effectTag的不同决定了其在不同阶段的处理,useEffect是异步调用,useLayoutEffect是同步调用,但是两者的执行时机都是在DOM挂载到页面之后。
上面梳理了初始化阶段和更新阶段的useEffect的主要逻辑,这里总结下:
使用React Hook如果要使用state就必须将函数定义在函数组件内部,可能useEffect会使用到相关函数是定义在useEffect函数内部还是其外部呢,在官网中实际上对这种情况有过阐述,是建议放在useEffect内部,避免state还是引用旧值的问题。实际上在整个源码中并没有了解这边的特殊处理,按照整个处理逻辑来讲并没有什么问题,放在外部和内部也没有任何的区别,唯一的区别就在于如果函数中存在 useEffect的依赖项会保证每次useEffect更新总是拿到最新的值,放在外部就要靠开发者自己思考了。相关逻辑函数放在内部还是外部并不是什么强制规定,只是存在引用了旧值问题。还有注意使用useEffect和useLayoutEffect一定要注意不要形成渲染循环,例如没有任何依赖的useEffect内部setState就会导致导致一直渲染。