目录

useEffect的使用

react hooks中的useEffect是用的最频繁,也是需要'‘小心'‘的的hooks,因为一时不留神,页面就造成了死循环,又或者数据不更新,又或者数据请求结果不对,这篇文章会介绍一下基本概念以及我的一些使用心得。

1.useEffect的源码

useEffect的入口在 ReactFiberHooks.js中,这个文件,会在react内部调度的时候(渲染或者更新)先调用renderWithHooks,这个方法会根据当前是否有一个hooks链表来判断是创建新的hooks链表还是调用相应的update方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export function renderWithHooks(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime,
): any {
  renderExpirationTime = nextRenderExpirationTime;
  // workInProgress表示正在调度的Fiber任务
  currentlyRenderingFiber = workInProgress;
  // memoizedState为挂载的hooks链表
  firstCurrentHook = nextCurrentHook =
    current !== null ? current.memoizedState : null;
  
  // 判断是调用创建hooks链表方法还是更新hooks方法
  // 将方法赋予ReactCurrentDispatcher,由ReactCurrentDispatcher统一调用
  if (__DEV__) {
    ReactCurrentDispatcher.current =
      nextCurrentHook === null
        ? HooksDispatcherOnMountInDEV
        : HooksDispatcherOnUpdateInDEV;
  } else {
    ReactCurrentDispatcher.current =
      nextCurrentHook === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }
}

然后会看到HooksDispatcherOnMountInDEV定义中,初次渲染的时候useEffect调用的是mountEffect

1
2
3
4
5
6
7
8
9
...
useEffect(
  create: () => (() => void) | void,
  deps: Array < mixed > | void | null,
): void {
  currentHookNameInDev = 'useEffect';
  return mountEffect(create, deps);
},
...

然后更新的时候HooksDispatcherOnUpdateInDEV,调用的是updateEffect

1
2
3
4
5
6
7
8
9
...
useEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  currentHookNameInDev = 'useEffect';
  return updateEffect(create, deps);
},
...

1.1.mountEffect

首先是mountEffect

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return mountEffectImpl(
    UpdateEffect | PassiveEffect,
    UnmountPassive | MountPassive,
    create,
    deps,
  );
}

其中的mountEffectImpl方法

1
2
3
4
5
6
7
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  sideEffectTag |= fiberEffectTag;
  hook.memoizedState = pushEffect(hookEffectTag, create, undefined, nextDeps);
}

mountWorkInProgressHook 就是创建一个新的hook,而memoizedState则记录调用pushEffect后的返回值,这点先记下

1.2.updateEffect

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(
    UpdateEffect | PassiveEffect,
    UnmountPassive | MountPassive,
    create,
    deps,
  );
}

然后是其中的updateEffectImpl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        pushEffect(NoHookEffect, create, destroy, nextDeps);
        return;
      }
    }
  }

  sideEffectTag |= fiberEffectTag;
  hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
}

1.updateWorkInProgressHook是获取正在调度中的hook,当currentHook为空的时候,updateEffectImplmountEffectImpl的逻辑没什么区别

2.当currentHook不为空的时候,也就是说当前处理的hook有值,那么就会调用 currentHook.memoizedState,拿到prevEffect上的destroydeps,复制给方法内的destroy

3.然后对比nextDeps, prevDeps,如果相同,会调用 pushEffect(NoHookEffect, create, destroy, nextDeps),猜测一下,代表不执行这次的useEffect

如果不同,会走到最后调用pushEffect。其实就可以猜一下,这个pushEffect就是返回的一个effect对象,这个对象保留了useEffect上面的依赖(deps),return方法(destroy)等等,并将这个effect对象保留在当前hookmemoizedState对象上,以便后续update的时候再对比

1.3.pushEffect

下面是pushEffect的定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    next: (null: any),
  };
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    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;
}

// Effect的定义
type Effect = {
  tag: HookEffectTag, // 一个二进制数,它将决定 effect 的行为
  create: () => (() => void) | void, // 绘制后应该运行的回调
  destroy: (() => void) | void, // 用于确定是否应销毁和重新创建 effect
  deps: Array<mixed> | null, // 决定重绘制后是否执行的 deps
  next: Effect, // 函数组件中定义的下一个 effect 的引用
};

可以看到pushEffect里面的逻辑,componentUpdateQueue是一个存储当前页面effect的变量

1.当componentUpdateQueue === null,也就是mountEffect的时候,就创建一个链表,改一下next的指向,方便后续添加

2.当它不为空的时候,还会判断lastEffect的值,然后lastEffect 为空的时候,代表的是这次渲染阶段中遇到的第一个effect,和上一步处理逻辑一样;当lastEffect 不为空的时候,就将componentUpdateQueue链表添加新的effect,并更新next指向

3.最后也就是return 这个新的effect

4.也就是说这个pushEffect方法的作用如上说的一样,用来创建effect链表或更新effect链表,方法里面retrun effect会挂载到hook.memoizedState用于下次对这个effect的状态比较

5.还有一个需要主要的地方,这个componentUpdateQueue最后保存着effect链表,它会挂载到当前fiberNodeupdateQueue对象上,然后会依次去调用,碰到了NoHookEffect会跳过这个effect代表无须执行

1
2
3
4
5
6
7
fiber = {
  child // 子节点
  sibling // 兄弟节点
  memoizedState // 保存所有hooks链表
  updateQueue // effect链表
  return // 父节点
}

2.useEffect参数的意义

1
2
3
4
5
6
useEffect(() => {
  // code
  return () => {
    // code
  }
}, [deps])

里面return回调会在组件卸载的时候调用;[deps]则是数组里面的变量更新后useEffect会重新执行,其内部就像上面源码说的,会调用areHookInputsEqual先进行对比

3.使用的技巧以及坑

3.1.初次渲染useEffect只执行一次

有这样一个场景:一个父组件包含一个子组件,子组件的数据依赖父组件传入,父组件会在一开始useEffect进行异步请求,更改子组件依赖的数据,子组件再写一个useEffect去依赖父组件传入的数据再进行相应的操作,如下代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useEffect, useState } from "react";

const Test = ({ name }) => {
  useEffect(() => {
    console.log("useEffect");
  }, [name]);

  return <div>{name}</div>;
};

export default function App() {
  const [name, setName] = useState('')

  useEffect(() => {
    setTimeout(() => {
      setName('子组件')
    }, 300)
  }, [])

  return <Test name={name}></Test>;
}

这段代码的缺点在于子组件的useEffect会执行两次,因为useEffect不管依赖是否发生变化,在初次渲染调用pushEffect的时候是会调用一次的

在此段代码中,如果在子组件Test中的useEffect进行了接口请求,那么在一开始就会调用两次,这会造成网络资源的浪费,并且如果异步接口的响应时间相反了,后调用的先返回了,那这个页面的数据也就不正常了,这就是隐患

解决办法:

1.在Test组件中一开始对name属性进行一个空值的判断,用于过滤无效的请求,当然这是条件比较特别(初始化值为空)才会这么做

2.配合useRef使用,useRef不同于useState需要手动set值的改变,useRef不仅仅用于保存dom元素的应用,它的current属性还可以保留响应式值。那这里我创建一个属性isMounted代表是否是updateEffect阶段,用于过滤第一次调用的effcet

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const Test = ({ name }) => {
  // isMounted代表初次渲染是否完成
  const isMounted = useRef(false)

  useEffect(() => {
    if (!isMounted.current) {
      isMounted.current = true
      return;
    }
    console.log("useEffect");
  }, [name]);

  return <div>{name}</div>;
};

这样就能过滤掉第一次多余的调用,这个技巧在useEffect过多或者依赖项复杂的时候还是比较实用的;同样的,如果是多次调用接口的场景,也可以用这样一个变量来控制请求是否发起,从而保证异步接口的顺序

3.2.useEffect的依赖项

上面的代码改造一下,可以看到这里定义了一个名为obj的state,而当我点击按钮的时候,会setObj一个空对象,而这时useEffect却会执行console.log语句,从表面上看obj的属性并没有改变,按一般的期望,应该是obj改了才会执行才对

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function App() {
  const isMounted = useRef(false)
  const [obj, setObj] = useState({
    name: ""
  });

  useEffect(() => {
    if (!isMounted.current) {
      isMounted.current = true
      return;
    }
    console.log("useEffect");
  }, [obj]);

  const handleChangeObjValue = () => {
    setObj({
      name: ""
    });
  };

  return <button onClick={handleChangeObjValue}>按钮</button>;
}

造成这种现象的原因,还是可以从源码中看到,在updateEffectImpl中会调用areHookInputsEqual这个方法来遍历useEffect中的依赖,来对比每个依赖项是否相同,如果有一个不同,就返回false,返回false之后就会走hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps)也就是上面说的更新effect链表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// updateEffectImpl方法
...
if (areHookInputsEqual(nextDeps, prevDeps)) {
   pushEffect(NoHookEffect, create, destroy, nextDeps);
   return;
 }

sideEffectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
...

// areHookInputsEqual方法
function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
) {
  if (prevDeps === null) {
  	...
    return false;
  }

  ...
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

areHookInputsEqual里面的is是基于Object.is,也就是说上面的代码setObj设置新的对象的时候useEffect内部与旧的依赖对比的时候是两个不同的对象,所以会执行这个effect,因为{}语法本来就是创建一个新的对象,所以两者对比会返回false。所以我们在写的时候要写明白我们所需的依赖,因为这个时候会是一个基本类型的值(当然也有其它对象、数组等引用类型的值),而不是一股脑的直接扔一个对象到[]中就完事了

1
2
3
4
useEffect(() => {
  console.log("useEffect");
  // 写明依赖obj.name
}, [obj.name]);

3.3.闭包问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const Test = ({ playing }) => {
  let [count, setCount] = useState(0);
  useEffect(() => {
    let timer;
    if (playing) {
      timer = setInterval(() => {
        setCount(count + 1)
      }, 1000);
    }

    return () => clearInterval(timer);
  }, [playingm, count]);

  return (
    <div className="App">
      <h1>{count}</h1>
    </div>
  );
}

export default function App() {
  const [playing, setPlaying] = useState(false);
  return (
    <div className="App">
      <button onClick={() => setPlaying(!playing)}>
        {playing ? "播放" : "暂停"}
      </button>
      <Test playing={playing} />
    </div>
  );
}

这段代码想要实现的效果是,在点击播放的时候,count能每一秒都+1,并且渲染到页面上。而实际的效果是页面上的count并不会变,因为在useEffect定义的setInterval会产生一个闭包,一直引用着最初的count,导致每次setCount的值一直没变,所以页面也就没有刷新,解决的办法:

1.将count添加到useEffect的依赖项中,让setInterval每次能够引用到最新的count值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
useEffect(() => {
  let timer;
  if (playing) {
    timer = setInterval(() => {
      setCount(count + 1)
    }, 1000);
  }

  return () => clearInterval(timer);
}, [playing, count]);


这种方式确实能解决count未改变的问题,但是它每次都会创建一个新的定时器对象,这对于浏览器性能来说是是不友好的

2.我们需要一种不讲count放进useEffect依赖项的解决方法,也就是使用useState中的回调函数,每次拿到上一次更新的值,然后在进行这一次更新操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
useEffect(() => {
  let timer;
  if (playing) {
    timer = setInterval(() => {
      // 使用回调拿到上一次更新后的值,保证每次的值是最新的
      // 这个是react所提供的api
      setCount(v => v + 1)
    }, 1000);
  }

  return () => clearInterval(timer);
}, [playing]);

3.还有一种方式,在setInterval闭包中对当前引用的变量进行修改,这样闭包内所引用的变量的就会变化,在通过set操作,将外层的响应式变量更高,从而重新渲染页面

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
useEffect(() => {
  let timer;
  if (playing) {
    timer = setInterval(() => {
      // 对闭包内引用的count进行修改
      // setCount后render
      setCount(count++)
    }, 1000);
  }

  return () => clearInterval(timer);
}, [playing]);