深入了解useEffect

455 阅读5分钟

文章前部分总结于Youtobe-Lama Dev老哥的视频,视频讲的不错,总结成文章学习一下,后续又参考官网及其他资料进行了扩展(顺便提一下,官网真的是常看常新,总能 get 到新的东西)。

📌 带着这些问题深入了解 useEffect

  1. 什么时候运行?
  2. 依赖关系如何运作?
  3. 原始依赖项和非原始依赖项有什么区别?
  4. 什么时候应该使用清理功能?

从一个最简单的 demo 开始(⚠️注意:初始的 useEffect 没有依赖项):

import React, { useState, useEffect } from 'react'

const App = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    console.log('useEffect----------runs');
    document.title = `点击了${count}次`;
  });
  
  console.count('component rendered!');
  
  return (
    <div>
      <span>you click {count} times</span>
      <button onClick={() => setCount(count + 1)}>click</button>
    </div>
  )
}

export default App; 

1. 运行时机

结论:useEffect是在组件渲染之后再执行的

在 React 官网中渲染和提交章节有说到:组件显示到屏幕之前,其必须被 React 渲染。所以 React 中组件的渲染的过程包含了 3 个主要因素:组件、React本身和浏览器

官网很形象的将 React 组件的渲染用点餐的形式来体现:

如果简单组件是上述点餐过程,那么包含 useEffect 的组件就是我们去点堂食的同时,又点了一份打包的场景:优先制作堂食,之后制作打包的内容。

渲染过程:

  1. 步骤 1:触发渲染

应用启动时,会进行组件的初次渲染,此时count 值为0。包含 useEffect 的组件除了告诉 React 要呈现组件内容外,还要告诉 React 渲染完组件后要执行 effect:

  1. 步骤 2:React 渲染组件

接着,React 会调用组件来确定要在屏幕上显示的内容,之后提交给浏览器进行DOM 渲染。

  1. 第三步,执行 effect

浏览器又获取到信息要执行 effect的指令,并将其显示在屏幕上:

2. 添加依赖

对代码进行改造,添加一个 state ,并通过输入框改变它:

import React, { useState, useEffect } from 'react'

const App = () => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  useEffect(() => {
    console.log('useEffect----------runs');
    document.title = `点击了${count}次`
  });
  
	console.count('component rendered!')

  return (
    <div>
      <div>you click {count} times</div>
      <button onClick={() => setCount(count + 1)}>click</button>
      <input onChange={e => setName(e.target.value)}/>
    </div>
  )
}

export default App;

添加一个输入框之后,每次输入内容 useEffect 里面的代码都会执行:

但这并不是我想要的,我只想在 count 改变的时候才去执行 useEffect 里面的代码,这个时候就需要给 useEffect 添加一个依赖:

// ...
useEffect(() => {
  console.log('useEffect----------runs');
  document.title = `点击了${count}次`
}, [count]);
//...

3. 处理非原始类型的依赖

在JavaScript中数据分为 原始类型非原始类型 ,原始类型包括: numberstringbooleannullundefinedsymbol,非原始类型 arrayobjectfunction我们知道在JS中非原始类型的数据即便值一样,也不相等:

在上述 useEffect 例子中使用的是原始类型的依赖,并没有什么问题,但是当设置为一个非原始类型的依赖时要注意:可能会导致一些不必要的渲染。这是因为:

  • React 使用 Object.is 来比较每个依赖项和它先前的值。
  • 在 JavaScript 中,每个新创建的对象和函数都被认为与其他所有对象和函数不同。即使他们的值相同:
// 第一次渲染
const options1 = { serverUrl: '<https://localhost:1234>', roomId: '音乐' };

// 下一次渲染
const options2 = { serverUrl: '<https://localhost:1234>', roomId: '音乐' };

// 这是 2 个不同的对象
console.log(Object.is(options1, options2)); // false
...

这就是为什么应该尽可能避免将对象和函数作为 useEffect 的依赖。当出现这种情况时,应该尝试将它们

  • 移到组件外部
  • 或 Effect 内部
  • 或从中提取原始值的方式

改造上述示例代码,将依赖改为原始值:

// 依赖项为对象:
useEffect(() => {
  console.log('The state is changed, useEffect runs!');
}, [state.name, state.selected]);

// 依赖项为数组:
useEffect(() => {
  console.log('The state is changed, useEffect runs!');
}, [JSON.stringify([arr])]);

其他官方示例:

  1. 将静态对象和函数移出组件
  2. 移除 Effect 依赖

4. 使用 cleanup 清理功能

4.1. 日常使用 cleanup 的场景是清除定时器:

useEffect(() => {
  console.log('useEffect runs');
    
  const interval = setInterval(() => {
    setCount(pre => pre + 1);
  }, 1000)

  return () => {
    console.log('useEffect clean up');
    clearInterval(interval);
  }
}, []);

4.2. ⚠️在 useEffect 中使用请求,应该使用 cleanup 来取消请求

这是日常开发中经常忽略的点,其实官方文档已经说明了:

useEffect(() => {
  let ignore = false;
  setBio(null);
  fetchBio(person).then(result => {
    if (!ignore) {
      setBio(result);
    }
  });
  return () => {
    ignore = true;
  };
}, [person]);

注意,ignore 变量被初始化为 false,并且在 cleanup 中被设置为 true。这样可以确保 你的代码不会受到“竞争条件”的影响:网络响应可能会与你的发送不同的顺序到达。

如下例是经常遇到的场景,快速切换路由,在网速正常的情况下,好像没什么问题,但是网速慢的情况下,切换至下个路由时,总会闪现一下上一个路由的数据信息:

这个时候就需要使用 cleanup 来取消 state 的更新

useEffect(() => {
  let ignore = false;
  axios.get(`https://console-mock.apipost.cn/mock/a0aff9f3-0fe2-4634-b8f7-c975e33e83d9/api/getUser?apipost_id=0a5f75&id=${id}`)
    .then(res => { 
      if (!ignore) {
        setUser(res.data.data)
      }
    })
    .catch(err => { console.log(err) })
  return () => { ignore = true }
}, [id]);

在快速切换路由,2➡️3时,没有再显示2的信息,直接展示了3的内容:

上述取消,只是取消了状态的更新,并未真正取消接口的请求,在实际开发中可以根据使用的请求插件,直接取消请求,例如示例中使用的是axios,就可以基于原生的 AbortController 对象来取消请求:

useEffect(() => {
  const controller = new AbortController();
  axios.get(`https://console-mock.apipost.cn/mock/a0aff9f3-0fe2-4634-b8f7-c975e33e83d9/api/getUser?apipost_id=0a5f75&id=${id}`, {
    signal: controller.signal
  })
    .then(res => { 
      setUser(res.data.data)
    })
    .catch(err => { console.log(err) })
  return () => { controller.abort() }
}, [id])

直接取消了2的请求,展示3请求的内容

5. ⚠️不要滥用useEffect

5.1. 使用自定义 Hook 复用逻辑

  1. 提取自定义 Hook 让数据流清晰,让进出 Effect 的数据流非常清晰。
  2. 你让组件专注于目标,而不是 Effect 的准确实现。
  3. 把你的 Effect 包裹进自定义 Hook,当这些解决方案可用时升级代码会更加容易。
  4. 当 React 增加新特性时,你可以在不修改任何组件的情况下移除这些 Effect。
  5. 这会让你的组件代码专注于目标,并且避免经常写原始 Effect。

5.2. 提取请求逻辑到自定义 hook 中

上述取消请求的方式应该是所有请求都具备的之外,我们常用的还有这种请求场景:某个请求依赖某个 state,当 state 变化时,会进行请求。这两种场景都推荐使用自定义 hook 的方式来实现,因为把 Effect 包裹进自定义 Hook 可以更准确表达你的目标以及数据在里面是如何流动的。

官网提供了一种开发中最为常见的场景为案例:

一个请求显示城市列表,另一个显示选中城市的区域列表,如下代码是常见的处理方式:

而更优的处理方式应该是把请求方式抽离为一个自定义 hook:

function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    if (url) {
      let ignore = false;
      fetch(url)
        .then(response => response.json())
        .then(json => {
          if (!ignore) {
            setData(json);
          }
        });
      return () => {
        ignore = true;
      };
    }
  }, [url]);
  return data;
}

在组件中复用:

5.3. 基于已有的 props 或 state 计算得出的值,不要把它作为一个 state

5.3.1. 简单计算得出的值,在渲染期间直接计算这个值

如果代码中某个 state 是依据 props 或其他 state 在 useEffect 中计算得出的值,那么可以考虑优化它,官网给出的一个极为简单却能很好说明问题的案例:

5.3.2. 数据庞大或复杂计算得出的值,使用useMemo进行缓存

上述例子是一个简单的计算,如果某个值的“计算”相当复杂或数据庞大,每次的渲染都会是很大的开销时,考虑使用 useMemo 来进行缓存,官网给的案例中,getFilteredTodos()的参数可能数据量很大,这时候使用useMemo对计算结果进行缓存,只有当todos或filter发生变化时,才会重新执行getFilteredTodos()

5.4. useEffect移除原则

5.4.1. 移除根据 props 或 state 来更新 state 的 useEffect,尽可能在渲染计算该值而不是作为一个 state。因为这样会导致不必要的渲染。

  1. 移除根据 props 变化时重置所有 state 的 useEffect,而是通过为组件添加一个 key ,来重置整个组件树的 state。
  2. 移除根据 props 变化时重置部分 state 的 useEffect,而是在渲染过程中计算该值:

->

  1. 可以使用useMemo缓存复杂的计算值。

5.4.2. 移除可以通过在事件处理函数中调整 state 的 useEffect,而是把逻辑放入事件处理函数中去。

  1. 如果某个逻辑是由某个特定的交互引起的,请将它保留在相应的事件处理函数中。如果是由用户在屏幕上 看到 组件时引起的,请将它保留在 Effect 中。

5.4.3. 移除链式计算,而是在渲染过程或事件处理函数中调整 state

常见于级联场景,某个值的变化会导致二级值变化,进而导致三级值变化:

->

⚠️ 无法 在事件处理函数中直接计算出下一个 state的场景除外。如一个具有多个下拉菜单的表单,如果下一个下拉菜单的选项取决于前一个下拉菜单选择的值。这时,Effect 链是合适的,因为你需要与网络进行同步。

5.4.4. 避免错误的数据流向,React中,数据流是从父组件流向子组件的。

当组件中出现子组件更新父组件状态时,要考虑是否可以“状态提升”,避免子组件更新父组件的状态。