useEffect处理副作用,诸如dom交互或者请求api等

基本使用

  1. useEffect(
  2. () => {
  3. doSomething(a, b);
  4. },
  5. [dependency],
  6. );

第一个参数是function ,称之为side-effect function
第二个参数是依赖,称之为dependency array
函数式组件是没有生命周期钩子的,通过useEffect来模拟各个生命周期,我们可以在useEffect中处理side-effect。

依赖数组在判断元素是否发生改变时使用了 Object.is 进行比较,因此当 deps 中某一元素为非原始类型时(例如函数、对象等),每次渲染都会发生改变,从而每次都会触发 Effect,失去了 deps 本身的意义。

渲染机制

v2-7aebc7d6cef11c3a9c73c22abf3a4d8e_b.gif
动画中有以下需要注意的点:

  • 每个 Effect 必然在渲染之后执行,因此不会阻塞渲染,提高了性能
  • 在运行每个 Effect 之前,运行前一次渲染的 Effect Cleanup 函数(如果有的话)
  • 当组件销毁时,运行最后一次 Effect 的 Cleanup 函数

提示
将 Effect 推迟到渲染完成之后执行是出于性能的考虑,如果你想在渲染之前执行某些逻辑(不惜牺牲渲染性能),那么可使用 useLayoutEffect 钩子,使用方法与 useEffect 完全一致,只是执行的时机不同。

再来看看 useEffect 的第二个参数:deps (依赖数组)。从上面的演示动画中可以看出,React 会在每次渲染后都运行 Effect。而依赖数组就是用来控制是否应该触发 Effect,从而能够减少不必要的计算,从而优化了性能。具体而言,只要依赖数组中的每一项与上一次渲染相比都没有改变,那么就跳过本次 Effect 的执行。
仔细一想,我们发现 useEffect 钩子与之前类组件的生命周期相比,有两个显著的特点:

  • 将初次渲染(componentDidMount)、重渲染(componentDidUpdate)和销毁(componentDidUnmount)三个阶段的逻辑用一个统一的 API 去解决
  • 把相关的逻辑都放到一个 Effect 里面(例如 setInterval 和 clearInterval),更突出逻辑的内聚性

特别的,如果在useEffect中请求 不要写成下面这种:

  1. useEffect(async () => {
  2. const response = await fetch('...');
  3. // ...
  4. }, []);

强烈建议你不要这样做。useEffect 约定 Effect 函数要么没有返回值,要么返回一个 Cleanup 函数。而这里 async 函数会隐式地返回一个 Promise,直接违反了这一约定,会造成不可预测的结果。

最佳实践

在 effect 内部 去声明它所需要的函数:
如果effect中的函数在effect外部定义,并且这个函数依赖了props或者state,effect很难记住这个函数的依赖,最好将函数定义在effect中

  1. // bad,不推荐
  2. function Example({ someProp }) {
  3. function doSomething() {
  4. console.log(someProp);
  5. }
  6. useEffect(() => {
  7. doSomething();
  8. }, []); // 🔴 这样不安全(它调用的 `doSomething` 函数使用了 `someProp`)
  9. }
  10. // good,推荐
  11. function Example({ someProp }) {
  12. useEffect(() => {
  13. function doSomething() {
  14. console.log(someProp);
  15. }
  16. doSomething();
  17. }, [someProp]); // ✅ 安全(我们的 effect 仅用到了 `someProp`)
  18. }

如果处于某些原因无法把一个函数移动到 effect 内部,还有一些其他办法:
可以尝试把那个函数移动到你的组件之外。那样一来,这个函数就肯定不会依赖任何 props 或 state,并且也不用出现在依赖列表中了;
万不得已的情况下,可以 把函数加入 effect 的依赖但 把它的定义包裹 进 useCallback Hook。这就确保了它不随渲染而改变,除非它自身的依赖发生了改变;

模拟生命周期

下面我们来通过示例展示useEffect的生命周期

  1. import * as React from 'react';
  2. const App = () => {
  3. const [toggle, setToggle] = React.useState(true);
  4. const handleToggle = () => {
  5. setToggle(!toggle);
  6. };
  7. return <Toggler toggle={toggle} onToggle={handleToggle} />;
  8. };
  9. const Toggler = ({ toggle, onToggle }) => {
  10. return (
  11. <div>
  12. <button type="button" onClick={onToggle}>
  13. Toggle
  14. </button>
  15. {toggle && <div>Hello React</div>}
  16. </div>
  17. );
  18. };
  19. export default App;

如上,通过一个简单的例子来探究函数式的生命周期,父组件管理状态,子组件通过回调函数来更新状态


  1. const Toggler = ({ toggle, onToggle }) => {
  2. React.useEffect(() => {
  3. console.log('I run on every render: mount + update.');
  4. });
  5. return (
  6. <div>
  7. <button type="button" onClick={onToggle}>
  8. Toggle
  9. </button>
  10. {toggle && <div>Hello React</div>}
  11. </div>
  12. );
  13. };

这是最基本最直接的effect用法,只专递给effect一个函数,这时,函数会在组件第一次render的时候和每一次re-render的时候被调用

it runs on the first render of the component (also called on mount or mounting of the component) and on every re-render of the component (also called on update or updating of the component).


挂载

如果你想仅仅在组件渲染的时候执行一次,可以传给useEffect第二参数空数组。

  1. const Toggler = ({ toggle, onToggle }) => {
  2. React.useEffect(() => {
  3. console.log('I run only on the first render: mount.');
  4. }, []);
  5. return (
  6. <div>
  7. <button type="button" onClick={onToggle}>
  8. Toggle
  9. </button>
  10. {toggle && <div>Hello React</div>}
  11. </div>
  12. );
  13. };

第二个参数是一个数组,我们称之为dependency array。
这里是一个空数组,如果dependency array是空数组,意味着side-effect function 仅仅在render的时候执行一次

组件更新(挂载+更新)


  1. const Toggler = ({ toggle, onToggle }) => {
  2. React.useEffect(() => {
  3. console.log('I run only if toggle changes (and on mount).');
  4. }, [toggle]);
  5. return (
  6. <div>
  7. <button type="button" onClick={onToggle}>
  8. Toggle
  9. </button>
  10. {toggle && <div>Hello React</div>}
  11. </div>
  12. );
  13. };

我们给依赖数组传了一个参数toggle,意味着,只有toggle发生变化的时候,side-effect function才会被调用。注意在组件mout的时候也调用了side-effect function

我们也可以给依赖传递多个参数

  1. const Toggler = ({ toggle, onToggle }) => {
  2. const [title, setTitle] = React.useState('Hello React');
  3. React.useEffect(() => {
  4. console.log('I run if toggle or title change (and on mount).');
  5. }, [toggle, title]);
  6. const handleChange = (event) => {
  7. setTitle(event.target.value);
  8. };
  9. return (
  10. <div>
  11. <input type="text" value={title} onChange={handleChange} />
  12. <button type="button" onClick={onToggle}>
  13. Toggle
  14. </button>
  15. {toggle && <div>{title}</div>}
  16. </div>
  17. );
  18. };

这时,title和toggle变化都会调用side-effect function

仅更新

上面的例子,添加依赖,但是mount挂载的时候也会触发,如何实现仅仅某个依赖更新的时候触发呢?
https://stackblitz.com/edit/react-f46vcr

  1. const Toggler = ({ toggle, onToggle }) => {
  2. const didMount = React.useRef(false);
  3. console.log(didMount);
  4. React.useEffect(() => {
  5. if (didMount.current) {
  6. console.log("I run only if toggle changes.");
  7. } else {
  8. didMount.current = true;
  9. }
  10. }, [toggle]);
  11. return (
  12. <div>
  13. <button type="button" onClick={onToggle}>
  14. Toggle
  15. </button>
  16. {toggle && <div>Hello React</div>}
  17. </div>
  18. );
  19. };

我们通过useRef来模拟,在mount的时候,calledOnce 为false,通过控制calledOnce来实现update的生命周期
如果对ref还不清楚的的同学,可以移步 你不知道的ref

仅一次更新

我们知道,可以通过给useEffect传递一个空数组可以实现组件在mount的时候执行一次,那么,如果我只想在某个值变化的时候执行一次,该怎么操作呢?上代码:
https://stackblitz.com/edit/react-xstfnw

  1. const Toggler = ({ toggle, onToggle }) => {
  2. const calledOnce = React.useRef(false);
  3. React.useEffect(() => {
  4. if (calledOnce.current) {
  5. return;
  6. }
  7. if (toggle === false) {
  8. console.log('I run only once if toggle is false.');
  9. calledOnce.current = true;
  10. }
  11. }, [toggle]);
  12. return (
  13. <div>
  14. <button type="button" onClick={onToggle}>
  15. Toggle
  16. </button>
  17. {toggle && <div>Hello React</div>}
  18. </div>
  19. );
  20. };

我们通过useRef来追踪非状态信息

卸载

  1. import * as React from 'react';
  2. const App = () => {
  3. const [timer, setTimer] = React.useState(0);
  4. React.useEffect(() => {
  5. const interval = setInterval(() => setTimer(timer + 1), 1000);
  6. return () => clearInterval(interval);
  7. }, [timer]);
  8. return <div>{timer}</div>;
  9. };
  10. export default App;

通过返回一个函数来实现清除定时器

同时也验证了unmount,清除定时器的触发是在每次组件消除或者重新渲染的时候。

useLayoutEffect

useLayoutEffect和useEffect大部分场景下用法是一样的

区别是:
useEffect:dom更新,浏览器绘制之后执行,不会阻塞渲染。
useLayoutEffect: dom更新之后,浏览器绘制之前执行,会阻塞浏览器渲染。

  1. import React, { useEffect, useLayoutEffect } from "react";
  2. import "./style.css";
  3. export default function App() {
  4. const [count, setCount] = React.useState(0);
  5. const ref = React.useRef();
  6. const moveTo = (dom, delay, postion) => {
  7. dom.style.transform = `translate(${postion.x}px)`;
  8. dom.style.transition = `left ${delay}ms`;
  9. };
  10. // useEffect(() => {
  11. // moveTo(ref.current, 600, { x: 200 });
  12. // }, []);
  13. useLayoutEffect(() => {
  14. moveTo(ref.current, 600, { x: 200 });
  15. }, []);
  16. console.log("RE-RENDER");
  17. return (
  18. <div ref={ref} style={{ width: 100, height: 100, backgroundColor: "red" }}>
  19. 方块
  20. </div>
  21. );
  22. }

在 useEffect 里面会让这个方块往后移动 600px 距离,可以看到这个方块在移动过程中会闪一下。但如果换成了 useLayoutEffect 呢?会发现方块不会再闪动,而是直接出现在了 600px 的位置。

原因:
useEffect 是在浏览器绘制之后执行的,所以方块一开始就在最左边,于是我们看到了方块移动的动画。useLayoutEffect 是在绘制之前执行的,会阻塞页面的绘制,页面会在 useLayoutEffect 里面的代码执行结束后才去继续绘制,于是方块就直接出现在了右边。

例2:

  1. import React, { useEffect, useLayoutEffect } from "react";
  2. import "./style.css";
  3. export default function App() {
  4. const [count, setCount] = React.useState(0);
  5. let ref = React.useRef(0);
  6. useEffect(() => {
  7. ref.current = "some value";
  8. });
  9. useEffect(() => {
  10. console.log("useEffect", ref.current);
  11. });
  12. // then, later in another hook or something
  13. useLayoutEffect(() => {
  14. console.log("useLayoutEffect", ref.current); // <-- this logs an old value because this runs first!
  15. });
  16. return <div>Hello,React</div>;
  17. }

image.png
从打印结果可以看到:
先执行了useLayoutEffect,拿到的是ref的旧值
后执行useEffect,拿到的是更新后的ref值

应用场景:

  • 如果改变了dom(获取元素的滚动位置或其他样式),立刻看到改变
  • 拿到ref的旧值

setInterval

https://raoenhui.github.io/react/2019/11/07/hooksSetinterval/

  1. const [count, setCount] = useState(0);
  2. const myRef = React.useRef(0);
  3. useEffect(() => {
  4. const id = setInterval(() => {
  5. myRef.current += 1;
  6. setCount(myRef.current); // 是为了更新页面
  7. console.log('监听', myRef.current);
  8. }, 1000);
  9. //当[] 不会走
  10. return () => {
  11. console.log('卸载', myRef.current);
  12. clearInterval(id);
  13. };
  14. }, []);
  1. const [count, setCount] = useState(0);
  2. const myRef = React.useRef(null);
  3. myRef.current = () => {
  4. setCount(count + 1);
  5. };
  6. useEffect(() => {
  7. const id = setInterval(() => {
  8. myRef.current();
  9. // console.log('监听', myRef.current);
  10. }, 1000);
  11. return () => {
  12. console.log('卸载');
  13. clearInterval(id);
  14. };
  15. }, []);
  1. const [count, setCount] = useState(0);
  2. function useInterval(fn) {
  3. const myRef = useRef(null);
  4. myRef.current = fn;
  5. useEffect(() => {
  6. const id = setInterval(() => {
  7. myRef.current();
  8. }, 1000);
  9. return () => clearInterval(id);
  10. }, []);
  11. }
  12. useInterval(() => setCount(count + 1));
  1. const [count, setCount] = useState(0);
  2. function useInterval(fn, delay) {
  3. const myRef = useRef(null);
  4. useEffect(() => {
  5. myRef.current = fn;
  6. }, [fn]);
  7. useEffect(() => {
  8. const id = setInterval(() => {
  9. myRef.current();
  10. }, delay);
  11. return () => clearInterval(id);
  12. }, [delay]);
  13. }
  14. useInterval(() => setCount(count + 1), 1000);

重点总结

  • 与 componentDidMount 或 componentDidUpdate 不同,使用 useEffect 调度的 effect 不会阻塞浏览器更新屏幕,这让你的应用看起来响应更快;(componentDidMount 或 componentDidUpdate 会阻塞浏览器更新屏幕)
  • useLayoutEffect 和平常写的 Class 组件的 componentDidMount 和 componentDidUpdate 同时执行;

References

https://segmentfault.com/a/1190000018224631