useEffect

一、useEffect使用过程中存在的问题

相信有不少小伙伴在使用useEffect过程中遇到过不少问题,找几个有bug的例子:

例子一

  1. // 弹框显示触发定时器
  2. useEffect(() => {
  3. timer = setInterval(() => {
  4. if (showModal) {
  5. requestFun()
  6. }
  7. }, 1000)
  8. }, [showModal])
  9. // 关闭弹框,清除定时器
  10. const closeModal = () => {
  11. clearInterval(timer)
  12. }

弹框显示定时器开始执行,当关闭弹框。
定时器居然没有清除,有bug

例子二

  1. useEffect(() => {
  2. let intervalId = setInterval(() => {
  3. fetchData();
  4. }, 1000 * 60);
  5. return () => {
  6. clearInterval(intervalId);
  7. intervalId = null;
  8. }
  9. }, [])
  10. const fetchData = () => {
  11. request({params}).then(ret => {
  12. if (ret.code === OK) {
  13. applyResult(ret.data);
  14. }
  15. })
  16. }

经过一番操作后
引用的值会调用上一次渲染的值,这是不对的

例子三

当在useEffect调用第三方库的实例,然后在其他函数清除这个实例,发现无法清除。

二、useEffect介绍

React16.8版本中描述了在 React 渲染阶段,改变 DOM、添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作是不被允许的,因为可能会产生莫名其妙的 bug 并破坏 UI 的一致性。
因此在使用useEffect完成副作用操作,赋值给useEffect的函数会在组件渲染到屏幕之后执行。useEffect一般是在每轮渲染结束后执行,当然也可以让它在只有某些值改变的时候才执行。
useEffect有个清除函数,官方demo如下:

  1. useEffect(() => {
  2. const subscription = props.source.subscribe();
  3. return () => {
  4. // 清除订阅
  5. subscription.unsubscribe();
  6. };
  7. });

一般在执行一些计时器或者订阅,会在组件卸载后,会清除这些内容。因此可以在清除函数里面做这些操作。
useEffect为防止内存泄漏,一般情况下如果组件多次渲染,在执行下一个effect 之前,上一个 effect 就已被清除。也就是说组件的每一次更新都会创建新的订阅。
useEffect 的函数会在浏览器完成布局与绘制之后,在一个延迟事件中被调用。
一旦 effect 的依赖发生变化,它就会被重新创建,例如:

  1. useEffect(
  2. () => {
  3. const subscription = props.source.subscribe();
  4. return () => {
  5. subscription.unsubscribe();
  6. };
  7. },
  8. [props.source],
  9. );

useEffect传递第二个参数,它是 effect 所依赖的值数组。只有当依赖改变后才会重新创建订阅。
温馨提示:在日常项目开发的时候,使用这个依赖的时候,很容易留下bug。比如:一个编辑弹框功能,如果useEffect依赖只写了个id,这个时候如果是对同一条数据进行编辑是不会再次执行useEffect的逻辑的。

三、useEffect原理

useEffect实际上是ReactCurrentDispatcher.current.useEffect(源码解析会讲到)
useEffect原理可以简单理解为:

  • 函数组件在挂载阶段会执行MountEffect,维护hook的链表,同时专门维护一个effect的链表。
  • 在组件更新阶段,会执行UpdateEffect,判断deps有没有更新,如果依赖项更新了,就执行useEffect里操作,没有就给这个effect标记一下NoHookEffect,跳过执行,去下一个useEffect

都知道useEffect 在依赖变化时,执行回调函数。这个变化是指本次 render 和上次render 时的依赖之间的比较。
默认情况下,effect 会在每轮组件渲染完成后执行,而且effect 触发后会把清除函数暂存起来,等下一次 effect 触发时执行,大概过程如下:
2021-05-29-13-57-16-040252.png
温馨提示:使用 hooks 要避免 if、for 等的嵌套使用

四、useEffrct源码解析

在react源码中,找到react.js中如下代码,这里进行了简化,方便阅读:

4.1 useEffect引入与导出

  1. import {
  2. ...
  3. useEffect,
  4. ...
  5. } from './ReactHooks';
  6. // ReactHooks.js
  7. export function useEffect(
  8. create: () => (() => void) | void,
  9. deps: Array<mixed> | void | null,
  10. ): void {
  11. const dispatcher = resolveDispatcher();
  12. return dispatcher.useEffect(create, deps);
  13. }
  14. function resolveDispatcher() {
  15. const dispatcher = ReactCurrentDispatcher.current;
  16. if (__DEV__) {
  17. if (dispatcher === null) {
  18. // React版本不对或者Hook使用有误什么的就报错...
  19. }
  20. }
  21. return ((dispatcher: any): Dispatcher);
  22. }

上面的代码就是引入与导出过程,不难看出useEffect实际上是ReactCurrentDispatcher.current.useEffect橙色的代码。

  1. import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';
  2. const ReactCurrentDispatcher = {
  3. current: (null: null | Dispatcher),
  4. };
  5. export default ReactCurrentDispatcher;

current的类型是null或者Dispatcher,不难看出接下来要找类型定义

  1. // ReactInternalTypes.js
  2. export type Dispatcher = {|
  3. useEffect(
  4. create: () => (() => void) | void,
  5. deps: Array<mixed> | void | null,
  6. ): void,
  7. |};

4.2 组件加载调用mountEffect

函数组件加载时,useEffect会调用mountEffect,接下来看看mountEffect

  1. // ReactFiberHooks.new.js
  2. function mountEffect(
  3. create: () => (() => void) | void,
  4. deps: Array<mixed> | void | null,
  5. ): void {
  6. return mountEffectImpl(
  7. PassiveEffect | PassiveStaticEffect,
  8. HookPassive,
  9. create,
  10. deps,
  11. );
  12. }

PassiveEffectPassiveStaticEffect是二进制常数,用位运算的方式操作,用来标记是什么类型的副作用的。mountEffect走了mountEffectImpl方法

  1. function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  2. const hook = mountWorkInProgressHook();
  3. const nextDeps = deps === undefined ? null : deps;
  4. currentlyRenderingFiber.flags |= fiberFlags;
  5. hook.memoizedState = pushEffect(
  6. HookHasEffect | hookFlags,
  7. create,
  8. undefined,
  9. nextDeps,
  10. );
  11. }

上面代码中,往hook链表里追加一个hook,把hook存到链表中以后还把pushEffect的返回值存了下来。

  1. function pushEffect(tag, create, destroy, deps) {
  2. const effect: Effect = {
  3. tag,
  4. create,
  5. destroy, // mountEffectImpl传过来的是undefined
  6. deps,
  7. next: (null: any),
  8. };
  9. // 一个全局变量,在renderWithHooks里初始化一下,存储全局最新的副作用
  10. let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  11. if (componentUpdateQueue === null) {
  12. componentUpdateQueue = createFunctionComponentUpdateQueue();
  13. currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
  14. componentUpdateQueue.lastEffect = effect.next = effect;
  15. } else {
  16. // 维护了一个副作用的链表,还是环形链表
  17. const lastEffect = componentUpdateQueue.lastEffect;
  18. if (lastEffect === null) {
  19. componentUpdateQueue.lastEffect = effect.next = effect;
  20. } else {
  21. // 最后一个副作用的next指针指向了自身
  22. const firstEffect = lastEffect.next;
  23. lastEffect.next = effect;
  24. effect.next = firstEffect;
  25. componentUpdateQueue.lastEffect = effect;
  26. }
  27. }
  28. return effect;
  29. }

最后返回了一个effect对象。

:::tips Tips: mountEffect就是把useEffect加入了hook链表中,并且单独维护了一个useEffect的链表。 :::

4.3 组件更新时调用updateEffect

函数组件加载时,useEffect会调用updateEffect,接下来看看updateEffect

  1. function updateEffect(
  2. create: () => (() => void) | void,
  3. deps: Array<mixed> | void | null,
  4. ): void {
  5. return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
  6. }
  7. function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  8. // 获取当前正在工作的hook
  9. const hook = updateWorkInProgressHook();
  10. // 最新的依赖项
  11. const nextDeps = deps === undefined ? null : deps;
  12. let destroy = undefined;
  13. if (currentHook !== null) {
  14. // 上一次的hook的effect
  15. const prevEffect = currentHook.memoizedState;
  16. destroy = prevEffect.destroy;
  17. if (nextDeps !== null) {
  18. const prevDeps = prevEffect.deps;
  19. // 比较依赖项是否发生变化
  20. if (areHookInputsEqual(nextDeps, prevDeps)) {
  21. // 如果两次依赖项相同,componentUpdateQueue增加一个tag为NoHookEffect = 0 的effect,
  22. hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
  23. return;
  24. }
  25. }
  26. }
  27. // 两次依赖项不同,componentUpdateQueue上增加一个effect,并且更新当前hook的memoizedState值
  28. currentlyRenderingFiber.flags |= fiberFlags;
  29. hook.memoizedState = pushEffect(
  30. HookHasEffect | hookFlags,
  31. create,
  32. destroy,
  33. nextDeps,
  34. );
  35. }

从上面代码中看到areHookInputsEqual用来比较依赖项是否发生变化。下面看看这个areHookInputsEqual函数

  1. function areHookInputsEqual(
  2. nextDeps: Array<mixed>,
  3. prevDeps: Array<mixed> | null,
  4. ) {
  5. if (prevDeps === null) {
  6. ...
  7. return false;
  8. }
  9. for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
  10. if (is(nextDeps[i], prevDeps[i])) {
  11. continue;
  12. }
  13. return false;
  14. }
  15. return true;
  16. }

其实就是遍历deps数组,对每一项执行Object.is()方法,判断两个值是否为同一个值。