React

Effect Hooks - useEffect

useEffect用于在函数组件中执行副作用操作,如数据获取、订阅或手动更改 DOM
This entry is part 2 of the seriesenjoy-react-hooks

Side Effect

在编程中,Side Effect(副作用)是一种描述函数或操作对其环境产生影响的术语。在纯函数式编程中,函数不应该有副作用,即它们只应根据输入值计算输出,而不修改任何外部状态。但在实际应用中,特别是在UI框架如React中,副作用是不可避免的,因为我们经常需要与浏览器环境交互,比如:

  • 数据获取:从服务器获取数据。
  • 订阅:订阅某些事件源,如WebSocket连接、Redux store的变化等。
  • 手动修改DOM:直接操作DOM元素。
  • 日志记录:记录应用程序的运行信息。
  • 浏览器缓存:使用localStorage或sessionStorage进行数据存储。
  • 定时任务:设置setTimeout或setInterval。

useEffect语法

useEffect语法如下:

useEffect(() => {
// setup
// ❓ optional clean up
return () => {}
}, [dependencies])
  • setup: 处理 Effect 的函数
    • cleanup?: setup 函数选择性返回一个 清理(cleanup) 函数
  • dependencies?: 依赖项数组(可选)

useEffect用法

不传递依赖项数组

如果不传递依赖项数组,则每次Rnder后都会执行effect

不传递依赖项数组
import {useState, useEffect} from 'react';

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

  useEffect(() => {
    console.log("Effect run");
  });

  console.warn(count === 0 ? 'Mount' : 'Render')
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}
Preview
Console

传递空的依赖项数组

当传递一个空的依赖数组时,effect 将只在Mount阶段执行一次,后续Render不再执行。

传递空的依赖项数组
import {useState, useEffect} from 'react';

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

  useEffect(() => {
    console.log("Effect run...");
  }, []);

  console.warn(count === 0 ? 'Mount' : 'Render')
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}
Preview
Console

传递非空的依赖项数组

当传递一个非空的依赖数组时,仅在依赖项发生变动时执行effect

传递非空的依赖项数组
import {useState, useEffect} from 'react';

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

  useEffect(() => {
    console.log("Effect1 run...");
  }, []);

  useEffect(() => {
    console.log("Effect2 run...",'count: ', count);
  }, [count]);

  console.warn(count === 0 ? 'Mount' : 'Render')
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}
Preview
Console

带cleanup函数

在上述所有示例中,我们都没有使用可选的cleanup,但在某些情况下,我们可能需要使用清理函数

假设我们有个需求:

  1. 开关打开时,监听鼠标移动
  2. 开关关闭时,不监听鼠标移动

拿到需求后我开发了第一个版本,但是我发现我只实现了需求1,当开关关闭时,页面仍在监听鼠标移动

Code Playground
import {useState, useEffect} from 'react';

export default function Counter() {
  console.log('render')
  const [tracking, setTracking] = useState(true)
  const [mousePosition, setMousePosition] = useState({
    x: 0,
    y: 0,
  });

  useEffect(() => {
    console.log('effect')
    if(tracking) {
      function handleMouseMove({clientX: x, clientY: y}) {
        const newPosition = {x, y}
        setMousePosition(newPosition);
      }
      window.addEventListener('mousemove', handleMouseMove);
    }
  }, [tracking]);

  return (
    <div>
      <button onClick={() => setTracking(!tracking)}>Toggle Mouse Tracking: {tracking ? 'on' : 'off'}</button>
      <p>{mousePosition.x} / {mousePosition.y}</p>
    </div>
  )
}
Preview
Console

仔细分析上面代码,能发现两个问题

  1. 开关关闭(tracking为false)后,因为上一次开关打开时注册的事件仍然存在,导致页面仍在打印鼠标的座标信息
  2. 开关再次打开时会重复注册mousemove事件

而使用cleanup是能够解决上面两个问题的

Code Playground
import {useState, useEffect} from 'react';

export default function Counter() {
  console.log('render')
  const [tracking, setTracking] = useState(true)
  const [mousePosition, setMousePosition] = useState({
    x: 0,
    y: 0,
  });

  useEffect(() => {
    console.log('effect')
    if(tracking) {
      function handleMouseMove({clientX: x, clientY: y}) {
        const newPosition = {x, y}
        setMousePosition(newPosition);
      }
      window.addEventListener('mousemove', handleMouseMove);
    }

    // 👇 cleanup
    return () => {
      console.log('cleanup')
      window.removeEventListener('mousemove', handleMouseMove);
    }
  }, [tracking]);

  return (
    <div>
      <button onClick={() => setTracking(!tracking)}>Toggle Mouse Tracking: {tracking ? 'on' : 'off'}</button>
      <p>{mousePosition.x} / {mousePosition.y}</p>
    </div>
  )
}
Preview
Console

我们来分析下上面代码的执行逻辑

  1. 当组件挂载(第一次Render)时,effect执行,此时我们会注册事件监听,并返回一个cleanup
  2. 随着用户移动鼠标,mousePosition状态发生改变,重新触发Render,但mousePosition并不时effect的依赖项,所以effect在这次Render中不会执行
  3. 用户点击按钮tracking状态由true变为false,又重新触发Render 且tracking是effect的依赖项,所以effect会在这次Render中执行
    1. 但在执行effect之前,React会先执行上一次effect执行时返回的cleanup(移除事件监听)
    2. cleanup执行之后,才会执行本次effect
  4. 因为此时tracking为fasle, effect具体逻辑会被if忽略
  5. 每次点击开关都会触发上述操作

具体的操作顺序如下:

useEffect注意事项

非必要不用useEffect

当你需要基于propsstate计算某些值时,不要把计算逻辑放到useEffect

function Dashboard({metrics}) {
const [filteredMetrics, setFilteredMetrics] = useState([])
useEffect(() => {
const currentFilteredMetrics = filterMetrics(metrics)
setFilteredMetrics(currentFilteredMetrics)
}, [metrics])
}

相反你可以放到render阶段, 好处就是代码量少,不易引发bug,且速度更快。

function Dashboard({metrics}) {
const filteredMetrics = filterMetrics(metrics)
}

避免无限循环

下面代码会造成无线循环:

  1. 首次Render后执行Effect setViews 触发Re-Render
  2. user对象重新创建,且user是Effect的依赖项目,Effect再次执行, 又会执行setViews, 再次触发Re-Render
  3. 循环往复...
function App() {
const [views, setViews] = useState(0)
const user = {name: 'kevin', views: 5}
useEffect(() => {
setViews(prevState => prevState + user.views)
}, [user])
return <p>The count is: {views}</p>
}

解决方案是: 使用useMemo包裹user对象,这将会阻止React在Re-Render时创建新的对象引用

Code Playground
import {useState, useEffect, useMemo} from 'react';

export default function App() {
  const [views, setViews] = useState(0)
  const user = useMemo(() => ({
    name: 'kevin', 
    views: 5
  }), [])  

  useEffect(() => {
    setViews(prevState => prevState + user.views)
  }, [user])

  return <p>The count is: {views}</p>
}

另外注意,对于函数可以使用useCallback去处理,用法类似

避免竞争条件

下面代码可能会出现以下现象:

  1. 第一次渲染时 userId = 1
  2. 第二次渲染时 userId = 2
  3. 第一次 fetch('/user/1') 耗时可能远远高于第二次 fetch('/user/2')
  4. 导致最终显示的是 userId为1的用户数据
export default function User({userId}) {
const [userInfo, setUserInfo] = useState(null)
useEffect(() => {
const fetchUserInfo = async () => {
const newUserInfo = await fetch(`/user/${userId}`)
setUserInfo(newUserInfo)
}
fetchUserInfo()
}, [userId])
if(!userInfo) return null;
return <div>{userInfo.name}</div>
}

解决方案:使用AbortController来取消旧的请求

export default function User({userId}) {
const [userInfo, setUserInfo] = useState(null)
useEffect(() => {
const abortController = new AbortController()
const fetchUserInfo = async () => {
const newUserInfo = await fetch(`/user/${userId}`, {
signal: AbortController.signal
})
setUserInfo(newUserInfo)
}
fetchUserInfo()
return () => {
AbortController.abort()
}
}, [userId])
if(!userInfo) return null;
return <div>{userInfo.name}</div>
}

避免执行父组件传递的回调函数

比如:

const Dropdown = ({ onOpen, onClose }) => {
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
if(isOpen) onOpen()
onclose()
}, [isOpen])
return (
<button onClick={() => setIsOpen(currState => !currState)}>
Toggle dropdown
</button>
)
}

解决方案: 在event handler中去处理

const Dropdown = ({ onOpen, onClose }) => {
const [isOpen, setIsOpen] = useState(false)
const toggleView = () => {
const currIsOpen = !isOpen
setIsOpen(currIsOpen)
if(currIsOpen) onOpen()
onclose()
}
return (
<button onClick={toggleView}>
Toggle dropdown
</button>
)
}

自定义hook: useEffectAfterMount()

useEffect 会在首次渲染以及后续依赖发生变动时都会执行

我们自定义的useEffectAfterMount会跳过首次渲染,仅在依赖项发生变动时执行。

import { useRef, useEffect } from 'react';
export default function useEffectAfterMount(cb, deps) {
const hasMounted = useRef(false)
useEffect(() => {
if(hasMounted.current) {
return cb()
}
hasMounted.current = true
}, [...deps, cb])
}
不传递依赖项数组
import {useState, useEffect} from 'react';
import useEffectAfterMount from './useEffectAfterMount.js';

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

  useEffect(() => {
    console.log("Effect run");
  });

  useEffectAfterMount(() => console.log('useEffectAfterMount run'), [])

  console.warn(count === 0 ? 'Mount' : 'Render')
  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}
Preview
Console

Questions? Let's chat

discord logoOPEN DISCORD
6423
members online
previous article
State Hooks - useState
next article
Permormance Hooks - useMemo & useCallback

A TypeScript Full Stack Blog

Share articles about Typescript, React, Next.js, Node.js and Css from time to time.
No spam, unsubscribe at any time.