细烤 useEffect

2,899 阅读16分钟

前言

前段时间烧烤哥一直深陷业务的泥潭不能自拔,终于在过年之前忙完,有时间写写文章总结一下知识了。看了一下,距离上一篇文章已经一个季度了,真的就是季刊了😂。烧烤哥后面会好好写文章的,尽量伪装自己成高产博主。

还记得在之前的《烤透 React Hook》一文中,我们曾深入探究过 React Hook 的源码,其中有一部分专门探讨了 useEffect 这个 Hook 的底层原理。但是由于篇幅有限,那时候并没有列举更多关于 useEffect 应用场景。事实上,useEffect 作为最重要的 Hook 之一(另外一个肯定是 useState 啦),是许多自定义 Hook 的基本核心组成部分,这很大程度上得益于它灵活巧妙的使用方式和运行机制。

今天就让烧烤哥来为各位老铁开慢火细烤 useEffect,总结一下在实际应用场景中的更多细节吧。

函数组件其实只是一个返回了 React 元素的 JavaScript 函数

正如其名,React 的 函数组件 实际上就是一个 JavaScript 函数,只不过这个函数的返回的是 JSX,而 JSX 只是一个语法糖,JSX 实质上代表的是一个 JS 对象,这个对象在 React 中被叫做 React 元素React Element)。

function myComponent() {
    // JSX 是用来描述 React 元素对象的语法糖
    return (
        <div>
            <button className="red">
        </div>
    );
    // 上面的代码大致相当于如下 React 元素(下面对象中省略了一些对此解析不重要的属性):
    // return {
    //    type: 'div',
    //    props: {
    //        children: [{
    //            type: 'button',
    //            props: {
    //                className: 'red',
    //            },
    //       }],
    //    },
    // };
}

(上面例子中返回的 React 元素省略了许多对本文阐述不相关的属性,关于 React 元素,烧烤哥之前也写过一些总结:《轻烤 React 核心机制:React Fiber 与 Reconciliation》)

所以,这里要明确的是:React 的 函数组件 本质上是一个返回了 React 元素 的 JavaScript 函数。组件的每一次渲染,其实就是重新调用执行了一遍这个函数而已。

组件的每一次渲染都是相互独立的

组件的每一次渲染都是相互独立的,每次渲染都有固定不变的 props、state、事件处理函数以及 effects。组件内的每一个函数(包括事件处理函数,effects,定时器或者 API 调用等等)会"捕获"定义它们的那次渲染中的 props 和 state 。

组件的每次渲染都固定的 state

假如组件中用到了 useState 这个 Hook,在组件渲染时,useState 会返回此次渲染的 state 和修改 state 的方法(关于 useState 是如何获取需要渲染的 state,可以康康这里)。所以在组件的每一次渲染中,state 其实也是固定的不变,相当于一个 常量

例如有一个 Father 组件:

function Father() {

    const [notify, setNotify] = useState(0);

    function onClickButton() {
        setNotify(notify + 1);
    }

    return (
        <div>
            <button onClick={onClickButton}>开始计数</button>
            <div>notify 的值为:{notify}</div>
        </div>
    );
}

组件的每次渲染,都有固定不变的 props

组件的每次渲染,都有固定的 props。关于这一点其实挺好理解的。前面说过,函数组件本质上就是一个 JS 函数,当父组件给子组件传送 props 时,这个 props 就相当于给子组件这个函数传入函数的参数而已。

例如现在有一对父子组件:

// 父组件
function Father() {

    const [notify, setNotify] = useState(0);

    function onClickButton() {
        setNotify(notify + 1);
    }

    return (
        <div>
            <button onClick={onClickButton}>开始计数</button>
            <Child counterNotify={notify} />
        </div>
    );
}

// 子组件
function Child(props)  {
    const { counterNotify } = props;
    
    return (
        <div>counterNotify 的值是:{counterNotify}</div>
    );
}

当父组件点击按钮改变 notify 的值时,由于 notify 作为 <Child> 组件的 props,因此会引发子组件 <Child> 的重新渲染,<Child> 的每一次重新渲染,实际上可以近似看作重新调用函数 Child(props)。在重新执行 Child 函数时,其中的 props.counterNotify 的值是固定的,相当于是一个 常量

组件的每次渲染,都有固定的事件处理函数

前面我们讨论到,组件的每次渲染,都有固定不变的 props 和 state。在组件的每一次渲染(执行函数组件)中,首先在开头就确定了本次渲染的 props 和 state。接下来假如在本次渲染中触发了事件处理函数(无论是同步还是异步),事件处理函数中所使用的 props 和 state 的值就是本次渲染的 props 和 state 的值。

我们来看一个经典的例子:有两个按钮,一个叫【Click Me】,一个叫【Show Alert】。我们先连续点击两下【Click Me】,然后点一下【Show Alert】,然后在点一下【Click Me】,接下来等 3 秒过后,猜猜 alert 的结果是什么呢?

function Counter() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click Me
      </button>
      <button onClick={handleAlertClick}>
        Show Alert
      </button>
    </div>
  );
}

组件的每次渲染,都有固定的 effect

正如前面所述,在组件的每一次渲染(执行函数组件)中,首先在开头就确定了本次渲染的 props 和 state。接下来在本次渲染中,effect 里面所使用的 props 和 state 的值就是本次渲染的 props 和 state 的值。

先来一个简单的例子:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

再来一个复杂点的加了定时器的例子:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useEffect 依赖项的作用和原理

我们知道,useEffect 的调用写法是:useEffect(effect, [dep]),第一个参数是要执行的 effect,而第二个参数是依赖项。依赖项是选填的,如果不传依赖项的话,那么每一次重新渲染组件时,都会默认给这个 effect 打上【需要执行】的 tag,然后在 UI 渲染完成后执行 effect。假如传入依赖项,那么每一次重新渲染组件时,React 会去判断传入的依赖项有没有发生改变,假如有任何一个依赖项发生了改变,则给这个 effect 打上【需要执行】的 tag,等到组件的 UI 渲染完成后,再来执行 effect;如果所有依赖项都没有发生改变,则给这个 effect 打上【不需要执行】的 tag,本次组件的重新渲染不会去执行 effect。(具体打 tag 的细节可以康康这里

因此 useEffect 依赖项的作用是:可以决定本次渲染是否执行 effect,避免 effect 不必要的重复调用

那 React 到底是怎么判断依赖项是否发生改变的呢?关于这里点我们就要稍微翻一翻源码了。

在 React.js v17.0.1 的源码中的 /packages/react-reconciler/src/ReactFiberHooks.new.js 文件,我们可以看到对比依赖时,会调用 areHookInputsEqual 这个方法:

(/packages/react-reconciler/src/ReactFiberHooks.new.js)

顺藤摸瓜,看到 areHookInputsEqual 的函数定义,发现其中调用了 is 这个方法:

(/packages/react-reconciler/src/ReactFiberHooks.new.js)

从头部的导入可以发现,is 定义在 shared/objectIs 中:

(/packages/shared/objectIs.js)

终于真相大白了!React 在对比依赖项是否发生改变时,实际上是用 JavaScript 原生的 API Object.is() 方法来对比的。

Object.is() 方法主要用来判断两个值是否为同一个值。其中要注意的一点是:Object.is 在对比引用类型(数组、对象、函数)的数据时,实际上对比的是它们的引用(内存地址)。(点 这里 跳转到 MDN 文档查看其用法和 polyfill)

了解到依赖对比的原理(Object.is())后,我们可以清楚地知道 React 是怎判断依赖是否发生改变了:

  • 对于基本类型的依赖(数字、字符串),直接判断依赖的值,如果值发生了改变,则判断依赖发生了改变
  • 对于引用类型的依赖(数组、对象、函数),判断的是依赖的引用,如果引用发生了改变,则判断依赖发生了改变

effect 的清除函数

useEffect 中 effect 清除函数指的是在 effect 中 return 的函数,它不是必要的。不过,假如你在 effect 中设置了一些定时器(setTimeout、setInterval)或者事件监听(addEventListener),那么记得要在清除函数中清除它们(clearTimeout、clearInterval、removeEventListener),不然有可能会随着组件的重复渲染而重复累积许多的定时器或事件监听,最终导致内存溢出。

还有一个问题是:effect 的清除函数在什么时候执行呢?答案是:在本次组件渲染中的 effect 清除函数 (effect 中 return 的内容)不会 在本次组件渲染中执行,而是默认在下一次组件重新渲染时,在 UI 渲染(DOM 更新完毕)完成后再执行。假如依赖是 [],则会等到组件销毁之前才会执行清除函数。

useEffect 执行流程

一个使用了 useEffect Hook 的函数组件,在程序运行的时候运行的流程如下:

组件初次渲染:

  1. 执行 useEffect 时,将 useEffect Hook 添加到 Hook 链表中,然后创建 fiberNode 的 updateQueue,并把本次 effect 添加到 updateQueue 中;(关于 “Hook 链表”、“fiberNode”、“updateQueue” 等概念同样在《烤透 React Hook》 中有做一些总结)
  2. 渲染组件的 UI;
  3. 完成 UI 渲染后(DOM 更新完毕),执行本次 effect;

组件重新渲染:

  1. 执行 useEffect 时,将 useEffect Hook 添加到 Hook 链表中,判断依赖:
    • 假如没有传入依赖(useEffect 没有传入第二个参数),那么直接给这个 effect 打上 “需要执行” 的 tag(HookHasEffect);
    • 假如有传入依赖 deps 并且当前依赖和上次渲染时的依赖对比有发生改变,那么就给这个 effect 打上 “需要执行” 的 tag(HookHasEffect);
    • 假如有传入依赖 deps,但是依赖没有发生改变,则 不会 给这个 effect “需要执行” 的 tag;
    • 假如有传入依赖 deps,但是传入的是一个空数组 [],那么也 不会 给这个 effect “需要执行” 的 tag;
  2. 渲染组件的 UI;
  3. DOM 更新完毕,假如有清除函数(effect 中的 return 内容),则执行 上一次 渲染的清除函数;如果依赖是 [],则先不用执行清除函数,而是等到组件销毁时才执行;
  4. 判断本次 effect 是否有“需要执行” 的 tag(HookHasEffect),如果有,就执行本次 effect;如果没有,就直接跳过,不执行 本次 effect;

组件销毁时:

  1. 在组件销毁之前,先执行完组件上次渲染时的清除函数

补充:

这里补充一下,为什么不是确定了依赖发生改变后就立即执行 effect,而是要是先打 tag 做一下标记,然后等 UI 渲染完了再根据 tag 来决定要不要执行 effect 呢?答案是:React 这样做是为了保证每次运行 effect 的时候,DOM 都已经更新完毕,这样就避免了 effect 的执行阻塞 UI 渲染(DOM 更新完毕)的问题,让页面看起来响应更快。这属于 React 作出的性能优化之一。

依赖项不要对 React 撒谎

凡是在 effect 中有用到函数组件的任何 state 或 props,都需要将用到的那些 state 或 props 写到 useEffect 的依赖中。如果依赖项中包含了素有 effect 中使用到的值,React 就能知道何时需要运行 effect,何时不需要运行 effect。如果依赖项中有遗漏,那就属于对 React 撒谎 了。

对 React 撒谎的坏处

依赖项对 React 撒谎了,即 effect 中使用到的值没有包含在依赖项中,那么 React 就不能明确知道 effect 需不需要执行,这样有可能会影响性能、交互逻辑或者出现一些莫名其妙的 bug。

我们来看看一些依赖项对 React 撒谎的例子:

useEffect(() => {
    document.title = 'Hello, ' + name;
});

这里例子中,effect 中用到了组件中 name 这个 state,但是我们并没有传入依赖(相当于对 React 撒谎说:这个 effect 没有依赖)。于是,在每次组件重新渲染时,无论 name 是否有改变,都会执行 effect 的内容,导致 effect 重复无用的执行,假如 effect 中的逻辑很复杂且耗时,那么就有可能造成性能问题。

下面还有一个例子:

useEffect(() => {
    document.title = 'Hello, ' + name;
}, []);

这里例子中,effect 中用到了组件中 name 这个 state,但是并没有写到依赖中。于是,除了组件初次渲染时会执行一次 effect,后面每次组件重新渲染时,无论 name 是否有改变,都不会执行 effect,影响到交互的效果(标签的 title 没有随着 name 的变化而变化)。

// 上面两个例子的正确的写法是:
useEffect(() => {
    document.title = 'Hello, ' + name;
}, [name]);

我们再来看一个定时器例子:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(intervalId);
  }, []); // 这是错误的!依赖项对 React 撒谎了

  return <h1>{count}</h1>;
}

例子中,在 effect 中用到了 count 这个 state,但是依赖项却写的是 [],这样导致的结果是,effect 从组件初始渲染到组件销毁的过程中只会执行一次,而定时器的回调中使用到的 count 值一直会是创建定时器时的那一次组件渲染的 state,即是 0(前面说过,每一次组件的渲染都有固定的 state、effects),因此每间隔一秒后触发的回调里边,执行的总是 setCount(0 + 1)。从页面上看到的效果就是,从 0 变到 1 后,就一直是 1,不会再改变。

上面例子的一个解决方法是将 count 添加到依赖数组中:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(intervalId);
  }, [count]); // 将 count 添加到依赖中。依赖项没有对 React 撒谎了,但是这样写并不是最完美的方案

  return <h1>{count}</h1>;
}

将 count 添加到依赖中后,依赖项没有对 React 撒谎了,但是这样写并不是最完美的方案。不完美的原因是:定时器在每一次组件重新渲染时都会先被清除掉,然后再重新创建新的定时器

关于定时器一个比较好的写法可以是这样:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(n => n + 1); // 让 setCount 自己去内部获取 count 的值,这样就不用显式地写 count 了
    }, 1000);
    return () => clearInterval(intervalId);
  }, []); // 这里不算是对 React 撒谎,因为在本 effect 中没有显式地引用到 count,所以不需要将 count 写到依赖中。

  return <h1>{count}</h1>;
}

从上面的图中可以看到,定时器始终只有一个(id 为 ‘aaa’ 的定时器),并且在组件销毁前及时清除掉了。

将函数作为依赖项

当我们在 effect 中调用其他函数(假设调用了函数 A)的时候,如果这个函数 A 没有使用本函数组件中的任何 state 或 props,那没关系,随便怎么调用都可以。但是如果如果函数 A 使用了本函数组件中的 state 或者 props,那么实际上也是在 effect 中使用了 state 或者 props。

例子:

const [name, setName] = useState('');

function changeName() {
    console.log(name);
}

useEffect(() => {
    changeName();
}, []);

/*
 相当于:
 useEffect(() => {
    console.log(name);
 }, []); // <--- 这个 effect 中用到了 name,但是依赖项中没有写上,所以相当于这里的依赖项 “欺骗”了 React 
*/

要解决上述例子中的“撒谎欺骗行为”的方法有:

方法一:把整个函数写到 useEffect 中,然后改为把函数中使用到的 state 或 props 作为 useEffect 的依赖。这样做的好处是,我们不再需要去考虑这些“间接依赖”。我们的依赖数组也不再撒谎:在我们的 effect 中确实没有再使用组件范围内的任何东西。单我们后面修改 changeName 函数去使用别的组件状态时,我们更可能会意识到我们正在 effect 的依赖项里面编辑它。但是,这种方法的缺点是函数不能复用,如果有两个 useEffect 都使用这个函数,那只能分别复制粘贴到两个 useEffect 中。

const [name, setName] = useState('');

useEffect(() => {
    function changeName() { // <--- 将函数直接写在 effect 中
        console.log(name);
    }

    changeName();
}, [name]);

方法二:把函数中不直接使用 state,改为将使用到的 state 通过【传参】的形式传进去给函数。

const [name, setName] = useState('');

function changeName(argName) {
    console.log(argName);
}

useEffect(() => {
    changeName(name); // <--- 将 name 通过函数传参的形式传给函数
}, [name]); // <--- effect 中使用了 name,所以要添加到依赖中

方法三:将函数作为依赖

一个典型的误解是认为函数不应该成为依赖。但是如果这个函数内使用了某些 state 或者 props,我们有可能会忘记去更新使用这些函数的 effects 的依赖(对 React “撒谎”),这样我们的 effects 就不会同步 props 和 state 带来的变更。

但是,如果直接将普通的函数写到依赖项中,那么会造成一个问题就是:每一次判断依赖是否有改变时,结果都是判断为“依赖有改变”。相当于传了依赖和没传依赖的效果都一样:每次组件重新渲染都会执行 effect。原因是,由于函数的定义写在最外层,当组件重新渲染的时候,都会重新创建一遍这个函数,导致函数索引变更。前面我们也提到,useEffect 底层用的是 Object.is() 去比较依赖,而函数属于引用类型Object.is() 在比较引用类型的数据时实际上比较的是他们的索引。因此,每一次比较的结果都是“依赖有改变”。

const [name, setName] = useState('');

function changeName() {
    console.log(name);
}

useEffect(() => {
    changeName();
}, [changeName]);  // <--- 将 changeName 作为依赖,相当于每次组件渲染都会执行 effect,因为每次组件渲染都会创建新的函数,changeName 会被赋予新的索引

/*
    相当于:
    useEffect(() => {
        changeName();
    });
*/

解决上述问题的方法是:使用 useCallback 包装一下函数,将函数中使用到的组件 state 或 props 作为 useCallback 的依赖,然后将 useCallback 的返回值(函数的索引) 作为 useEffect 的依赖。useCallback 本质上是给函数添加了一层依赖检查。当 useCallback 的依赖发生改变时,才会返回新的函数索引(创建新的函数),如果 useCallback 依赖没有改变,那么就使用旧的函数索引(使用缓存的函数)。

const [name, setName] = useState('');

const changeName = useCallback(  // <--- 使用 useCallback 封装
    () => { 
        console.log(name);
    }, 
    [name]
);

useEffect(() => {
    changeName();
}, [changeName]); // <--- 将 changeName 作为依赖

将数组或对象作为依赖项

平时我们创建 state 不都只是字符串或数字等基本类型数据,反而往往更多的是数组或者对象。因为数组、对象都属于引用类型,所以将数组、对象作为 useEffect 的依赖项时,和函数一样,判断的是它们的索引是否有发生变化,而不是具体的值。因此,如果 state 的值是引用类型,那么在调用 setState() 时都会重新创建一个新的值并返回新的索引,最终导致 useEffect 每次判断依赖都是有改变的。

const [user, setUser] = useState({ name: 'Tom', age: 18});

useEffect(() => {
    console.log('user 发生了改变', user);
}, [user]); // 虽然 name 和 age 的值都没变,但是这里还是判断 user 有改变,因此还是会执行 effect

return (
    <button onClick={() => setUser({ name: 'Tom', age: 18 })}>
        设置 name: Tom, age: 18
    </button>
);

另外要注意的一点是,如果将对象的某个属性作为依赖,并且这个属性的值属于基本类型(或者以数组的某个元素作为依赖,并且这个元素的值属于基本类型),那么进行的就是值比较,而不是引用比较了。

const [obj, setObj] = useState({ a: 1 });

useEffect(() => {
    console.log('执行 effect!');
}, [obj.a]); // <--- obj.a 属于基本类型(数字),因此对比依赖时是值比较

return (
    <button onClick={() => setObj({ a: 1 })}>
        点这个按钮不会触发 effect 执行
    </button>
    
    <button onClick={() => setObj({ a: 2 })}>
        点这个按钮会触发 effect 执行,因为 obj.a 的值变为了 2
    </button>
);

小结

  • 函数组件其实只是一个返回了 React 元素的 JavaScript 函数;
  • 组件的每一次渲染中,都有固定不变的 props、start、时间处理函数以及 effects;
  • 对比 useEffect 依赖项是否有改变的底层实现是 Object.is();
  • useEffect 依赖项的作用是决定 effect 是否要执行。这样可以避免重复调用 effect,提升程序执行效率;
  • effect 清除函数即时 effect 中 return 的内容。在组件本次渲染中的清除函数会在下一次渲染时才执行;
  • useEffect 的执行流程:组件初次渲染时,在 UI 完成渲染后(DOM 更新完毕)会执行一次 effect;在组件重新渲染时,会对比依赖项在本次渲染和上一次渲染的值/引用是否有发生改变,根据对比结果给 effect 打上相应的 tag,然后等在 UI 渲染完成后,先执行上一次渲染的 effect 清除函数,然后再根据本次 effect 的 tag 来决定是否要执行本次 effect;在组件销毁之前,会执行上一次渲染的 effect 清除函数;
  • 上一次渲染时的 effect 清除函数和本次 effect 放在 UI 渲染完成后才执行的原因是避免了 effect 的执行阻塞 UI 渲染(DOM 更新);
  • useEffect 的依赖项不要对 React 撒谎,如果撒谎了,React 就不清楚何时执行 effect 了;
  • 如果将函数作为 useEffect 的依赖,可以通过将函数定义写到 effect 中、通过传参的方式将 state 和 props 传入函数中而不是在函数中直接调用、以及 useCallback 的方式来避免种种问题;
  • 将函数、数组或对象作为 useEffect 的依赖,在对比时,Object.is() 进行的是引用比较

以上就是全部内容了。本文是烧烤哥通过阅读官网和一些博客文章再加上平常的实践经验归纳而成。在总结过程中难免会有错误或者解析不到位的地方,希望各位老铁吃完烧烤之后在评论区指出,大家一起交流探讨,期待通过和老铁们的交流来加深对前端知识的理解。

参考文献

关注「前端烧烤摊」 掘金 or 微信公众号, 第一时间获取烧烤哥前的总结与发现。