React v16源码之useEffect与useLayoutEffect

前言

如果说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

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对象的初始值。

pushEffect

该函数的具体处理逻辑如下:

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对象并返回
  • 将effect对象方法对应FiberNode的updateQueue中,即对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;
     }
   }

其中重要的逻辑有两点:

  • effectTag:useEffect函数中对于当前FiberNode的effectTag的设置
  • scheduleCallback函数

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的时机呢?答案是一样,在初始化阶段是存在依赖还是无依赖调用时机都是一样的。

更新阶段的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);
   }

上面有三个注意点:

  • 更新阶段此时依赖已经是最新值
  • useEffect的返回值是undefined,初始化阶段也是
  • areHookInputsEqual会比较依赖项,比较是通过Object.js来实现,需要注意的是只是浅层比较

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的监听效果,注意结合开发场景来具体使用。

useLayoutEffect

在React官网的介绍中useLayoutEffect与useEffect是相同的,之前useLayoutEffect会同步调用,在上面对useEffect的逻辑梳理中,会发现useEffect调用是通过异步来触发的。那么看看useLayoutEffect的处理过程是怎样的,依旧分开初始化阶段和更新阶段来具体看。

初始化时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传入的函数。

更新阶段useLayoutEffect逻辑

实际上更新阶段的执行逻辑与useEffect并没有什么区别,而且最后都是调用updateEffectImpl函数,基本的处理逻辑都是相同的,只是effectTag的不同决定了其在不同阶段的处理,useEffect是异步调用,useLayoutEffect是同步调用,但是两者的执行时机都是在DOM挂载到页面之后。

总结

上面梳理了初始化阶段和更新阶段的useEffect的主要逻辑,这里总结下:

  • useEffect内部会创建effect对象和hook对象,effect对象会保存传入的函数参数以及内部返回值(函数才有意义),effect对象被hook的属性保存,继而与FiberNode建立联系
  • useEffect的返回值是undefined,即没有返回值
  • useEffect的执行时机总是在DOM挂载到页面之后,即commit阶段结束之后workLoop内部触发的,触发workLoop执行这个过程是异步的,workLoop是用来处理taskQueue的,而useEffect的触发函数被添加到taskQueue中是通过scheduleCallback实现的
  • useEffect的第二个参数作为依赖可以用来控制更新阶段其是否要执行,更新阶段useEffect是否执行的核心逻辑就是依赖值是否发生了变化,这个过程是通过Object.is()方法来比较,只有依赖值改变了在commit阶段结束之后就会执行。依赖值的比较的浅层比较,如果你传递一个对象,之后对象引用不变改变其内部任何值都不会触发useEffect执行。常使用空数组来控制useEffect只在初始化时执行,实际上数组中常量、全局const变量、常量等任何初始化之后不再改变的变量和常量都可以达到相同的效果,只不过空数组是简单的方式。
  • 如果useEffect依赖state,但是state是在useEffect后面会存在问题吗?不会,首先useEffect被触发是在commit阶段之后了,而commit阶段之前setState就已经执行完成了,其次useEffect执行的触发机制是异步的,通过MessageChannel或setTimeout来的,setState的执行是同步的
  • useEffect在初始化阶段和更新阶段的不同逻辑执行跟useState一样都是通过改变dispatcher来实现的
  • useEffect的执行的关键在于effectTag,只有effectTag满足相应的条件才会将触发useEffect执行的函数推入到taskQueue中(这个处理逻辑是在commit阶段的commitBeforeMutationEffects中),否则是不会处理,就导致了useEffect是否被执行的表面现象
  • 更新阶段useEffect的返回函数的执行实际上是跟useEffect的执行是相同的时机,只不过总是在useEffect执行前,即在useEffect执行前调用前一个effect的destory函数
  • useLayoutEffect与useEffect的处理基本相似,都是在DOM挂载到页面之后处理,只是useLayoutEffect是同步调用的,而useEffect是异步的要等到所有同步代码执行完后在workLoop中调用而已

使用React Hook如果要使用state就必须将函数定义在函数组件内部,可能useEffect会使用到相关函数是定义在useEffect函数内部还是其外部呢,在官网中实际上对这种情况有过阐述,是建议放在useEffect内部,避免state还是引用旧值的问题。实际上在整个源码中并没有了解这边的特殊处理,按照整个处理逻辑来讲并没有什么问题,放在外部和内部也没有任何的区别,唯一的区别就在于如果函数中存在 useEffect的依赖项会保证每次useEffect更新总是拿到最新的值,放在外部就要靠开发者自己思考了。相关逻辑函数放在内部还是外部并不是什么强制规定,只是存在引用了旧值问题。还有注意使用useEffect和useLayoutEffect一定要注意不要形成渲染循环,例如没有任何依赖的useEffect内部setState就会导致导致一直渲染。

你可能感兴趣的:(React相关,react.js,useEffect,源码)