React

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > React > React18 useEffect使用

React18中useEffect的使用与常见问题解析

作者:皮卡丘-杰尼龟

本文深入解析React 18中useEffect Hook的使用方法、行为变化和最佳实践,包括三种常见场景(组件挂载、依赖项变化、每次渲染)及其语法结构 React 18 行为变化,感兴趣的可以了解一下

引言

useEffect 是 React 中最常用的 Hook 之一,它用于处理组件的副作用(Side Effects),包括数据获取、事件监听、DOM 操作、订阅外部数据源等。在 React 18 中,useEffect 的行为发生了一些重要变化,特别是在并发渲染(Concurrent Rendering)模式下,清理函数的执行时序与 React 17 有显著不同。此外,React 18 还引入了新的 API(如 useSyncExternalStore)来更好地处理某些传统上由 useEffect 承担的场景。本文将详细介绍 useEffect 的基本用法、React 18 中的行为变化、实际项目中的应用模式以及常见问题的解决方案。

useEffect 基本用法

基本语法

useEffect(() => {
  // 副作用代码(在组件渲染到屏幕之后执行)

  return () => {
    // 清理代码(在组件卸载或依赖项变化导致重新执行前调用)
  }
}, [依赖项])

useEffect 接收两个参数:第一个是副作用函数(effect function),第二个是可选的依赖项数组(dependency array)。副作用函数可以返回一个清理函数(cleanup function),React 会在适当的时机调用它来防止内存泄漏和无效操作。

三种常见场景

1. 只在组件挂载时执行(模拟 componentDidMount)

useEffect(() => {
  console.log('组件挂载完成')

  return () => {
    console.log('组件即将卸载')
  }
}, [])  // 空依赖数组,只在挂载和卸载时执行

当依赖项数组为空数组 [] 时,useEffect 仅在组件首次挂载后执行一次,对应的清理函数在组件卸载时执行。这是最常见的用法之一,适合执行只需要运行一次的初始化操作,如读取本地存储、发起初始数据请求等。

2. 在依赖项变化时执行

useEffect(() => {
  console.log('count 变化了:', count)

  return () => {
    console.log('count 即将变化,准备清理')
  }
}, [count])  // 依赖 count,当 count 变化时重新执行

当依赖项数组中列出了具体的变量时,React 会在每次渲染后对比新旧依赖项(使用 Object.is 进行浅比较)。只有当依赖项发生变化时,才会重新执行副作用函数,并在执行前先调用上一次的清理函数。这种模式常用于响应 props 或 state 的变化来执行副作用。

3. 每次渲染都执行(模拟 componentDidMount + componentDidUpdate)

useEffect(() => {
  console.log('组件渲染完成(首次或更新)')
})  // 不传依赖项数组,每次渲染后都会执行

当完全不传依赖项数组时,useEffect 会在每次组件渲染完成后都执行。这种用法在实际开发中比较少见,因为绝大多数场景下我们只希望响应特定依赖的变化。如果发现自己在使用这种模式,通常意味着应该重新审视是否可以将某些逻辑移到事件处理函数或 useMemo 中。

React 18 中的行为变化

StrictMode 双调用机制

在 React 18 的开发环境下,<StrictMode> 会故意调用 useEffect 两次(先挂载 → 执行 → 清理 → 再次挂载 → 再执行),这是为了帮助开发者检测副作用代码中可能存在的问题。

// React 18 开发环境下,以下代码会执行两次
useEffect(() => {
  console.log('useEffect 执行')
}, [])
// 控制台输出:
// useEffect 执行
// useEffect 执行(StrictMode 模拟卸载后重新挂载)

原因:React 18 引入了并发特性(Concurrent Features),未来 React 可能会在不卸载组件的情况下保留 DOM 而重新执行副作用。StrictMode 通过模拟"卸载 → 重新挂载"的过程,提前暴露那些没有正确编写清理函数的代码,确保副作用在并发环境下能够安全地重复执行和清理。

影响范围:

注意:以上行为仅出现在开发环境中,生产构建不会出现双调用。

清理函数执行时序变化(重要)

React 18 中清理函数的执行时序发生了重要变化,这对实际开发有显著影响。

React 17 的行为(同步清理):

React 18 的行为(异步清理):

useEffect(() => {
  const timer = setInterval(() => {
    console.log('定时器运行中')
  }, 1000)

  return () => {
    // React 17: 在新渲染之前同步清除 → 用户感知不到延迟
    // React 18: 在新渲染之后异步清除 → 短暂间隙内定时器仍在运行
    clearInterval(timer)
  }
}, [someValue])

实际影响:如果你的清理函数中包含关闭模态框、重置滚动位置等影响视觉状态的操作,在 React 18 中可能会出现短暂的视觉闪烁。大多数情况下这不需要处理,但如果对视觉一致性要求较高,可以考虑使用 useLayoutEffect(其清理函数仍然是同步执行的)来替代。

解决 StrictMode 双调用的方法

如果副作用确实只需要执行一次(如建立 WebSocket 连接),可以使用 useRef 标记:

const hasInitialized = useRef(false)

useEffect(() => {
  if (hasInitialized.current) return
  hasInitialized.current = true

  // 只执行一次的初始化代码
  const ws = new WebSocket('wss://example.com')
  ws.onopen = () => console.log('WebSocket 已连接')

  return () => {
    ws.close()
    // 注意:StrictMode 下第一次会被清理,第二次才是"真正的"连接
    // 所以 hasInitialized 需要在 StrictMode 的两次挂载中都保持 false
    // 更好的做法是让副作用天然支持重复执行和清理
  }
}, [])

更推荐的做法:与其对抗双调用机制,不如确保副作用代码及其清理函数正确配对,使得"执行 → 清理 → 再执行"是安全的。这才是 React 团队的设计意图。

实际项目中的应用

数据获取(含请求取消)

在 useEffect 中进行数据获取时,必须处理组件卸载时的请求取消,否则会出现"状态更新已卸载组件"的警告以及潜在的内存泄漏。

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

function DataList() {
  const [data, setData] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const abortController = new AbortController()

    const fetchData = async () => {
      try {
        setLoading(true)
        setError(null)
        const response = await fetch('/api/data', {
          signal: abortController.signal  // 传入取消信号
        })
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }
        const result = await response.json()
        setData(result)
      } catch (err) {
        // 忽略因取消请求而产生的错误
        if (err instanceof DOMException && err.name === 'AbortError') {
          return
        }
        setError(err instanceof Error ? err.message : '未知错误')
      } finally {
        setLoading(false)
      }
    }

    fetchData()

    // 组件卸载或依赖变化时取消请求
    return () => {
      abortController.abort()
    }
  }, [])  // 只在挂载时获取数据

  if (loading) return <div>加载中...</div>
  if (error) return <div>错误: {error}</div>

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  )
}

关键点:使用 AbortController 的 signal 传入 fetch,并在清理函数中调用 abortController.abort()。这样当组件卸载或依赖项变化导致重新执行时,之前的请求会被自动取消,避免对已卸载组件进行状态更新。

事件监听

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

function WindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  })

  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      })
    }

    window.addEventListener('resize', handleResize)

    return () => {
      window.removeEventListener('resize', handleResize)
    }
  }, [])

  return (
    <div>
      <p>宽度: {size.width}</p>
      <p>高度: {size.height}</p>
    </div>
  )
}

为避免不必要的频繁触发(resize 事件在拖动窗口时每秒可触发数十次),通常还会配合节流(throttle)或防抖(debounce)使用:

import { useCallback, useEffect, useRef, useState } from 'react'

function useThrottledResize(delay: number = 200) {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  })
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  useEffect(() => {
    const handleResize = () => {
      if (timerRef.current) return
      timerRef.current = setTimeout(() => {
        timerRef.current = null
        setSize({
          width: window.innerWidth,
          height: window.innerHeight
        })
      }, delay)
    }

    window.addEventListener('resize', handleResize)

    return () => {
      window.removeEventListener('resize', handleResize)
      if (timerRef.current) clearTimeout(timerRef.current)
    }
  }, [delay])

  return size
}

订阅外部数据源

当需要订阅外部数据源(如 WebSocket、事件总线、状态管理库等)时,必须在清理函数中取消订阅,防止内存泄漏。

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

function useWebSocket(url: string) {
  const [messages, setMessages] = useState<string[]>([])
  const [connected, setConnected] = useState(false)

  useEffect(() => {
    const ws = new WebSocket(url)

    ws.onopen = () => setConnected(true)
    ws.onmessage = (event) => {
      setMessages(prev => [...prev, event.data])
    }
    ws.onclose = () => setConnected(false)
    ws.onerror = (error) => {
      console.error('WebSocket error:', error)
    }

    return () => {
      ws.close()  // 组件卸载时关闭连接
    }
  }, [url])

  return { messages, connected }
}

状态恢复

import React, { useEffect } from 'react'
import useUserStore from '@/store/userStore'

function App() {
  useEffect(() => {
    const savedState = sessionStorage.getItem('state')
    if (savedState) {
      try {
        const parsedState = JSON.parse(savedState)
        if (parsedState.user && parsedState.user.token) {
          useUserStore.getState().restoreState(parsedState.user)
        }
      } catch (error) {
        console.error('Failed to parse saved state:', error)
      }
    }
  }, [])

  return <div>应用内容</div>
}

常见问题

Q1: useEffect 无限循环

原因:依赖项在每次渲染时都创建了新的引用,导致每次渲染都判定为"依赖已变化",从而触发状态更新,状态更新又导致重新渲染,形成死循环。

// 错误示例:无限循环
const [count, setCount] = useState(0)
const obj = { value: count }  // 每次渲染都创建新对象

useEffect(() => {
  console.log('obj 变化了')
  setCount(c => c + 1)  // 触发重新渲染 → obj 变成新引用 → 再次触发
}, [obj])  // obj 每次渲染都是新的引用,永远不相等

解决方法:

  1. 使用 useMemo 缓存对象引用:
const obj = useMemo(() => ({ value: count }), [count])
  1. 直接使用原始值作为依赖(推荐,更简单直接):
useEffect(() => {
  console.log('count 变化了')
}, [count])  // count 是原始值,比较结果稳定
  1. 如果依赖的是函数,使用 useCallback 缓存:
const handleChange = useCallback(() => {
  console.log(count)
}, [count])

useEffect(() => {
  // ...
}, [handleChange])

Q2: useEffect 中的状态是旧值(闭包陷阱)

原因:useEffect 的副作用函数捕获了定义时那次渲染中的状态值(闭包),当 setInterval 或 setTimeout 的回调在后续执行时,它们读取的仍然是旧渲染周期中的 count 值。

const [count, setCount] = useState(0)

useEffect(() => {
  const timer = setInterval(() => {
    console.log('count:', count)  // 始终输出 0,因为闭包捕获了初始值
    setCount(c => c + 1)
  }, 1000)

  return () => clearInterval(timer)
}, [])  // 空依赖数组,effect 只在挂载时执行一次

解决方法:

  1. 使用函数式更新(推荐,最简洁):
// setCount 的函数参数始终能拿到最新值
setCount(c => c + 1)
  1. 使用 useRef 保存最新值:
const countRef = useRef(count)

// 每次 count 变化时同步到 ref
useEffect(() => {
  countRef.current = count
}, [count])

useEffect(() => {
  const timer = setInterval(() => {
    console.log('count:', countRef.current)  // 通过 ref 读取最新值
  }, 1000)

  return () => clearInterval(timer)
}, [])
  1. 将 count 加入依赖项数组(副作用会随 count 重新执行,但注意清理旧定时器):
useEffect(() => {
  const timer = setInterval(() => {
    console.log('count:', count)  // 每次都能拿到最新的 count
  }, 1000)

  return () => clearInterval(timer)
}, [count])  // count 变化时清除旧定时器并重新创建

Q3: React 18 开发环境下请求发送两次

原因:React 18 StrictMode 的双调用机制导致 useEffect 执行两次,因此网络请求也会被发送两次。

useEffect(() => {
  fetchData()  // 开发环境下会执行两次
}, [])

解决方法:

useEffect(() => {
  const abortController = new AbortController()

  fetch('/api/data', { signal: abortController.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err.message)
      }
    })

  return () => abortController.abort()
}, [])
  1. 生产环境不受影响:
npm run build  # 生产构建中 StrictMode 不会双调用

Q4: useEffect 清理函数不执行或执行时机不符合预期

原因:清理函数不执行通常有以下几种情况:

useEffect(() => {
  const timer = setTimeout(() => {
    console.log('1秒后执行')
  }, 1000)

  return () => {
    clearTimeout(timer)  // 确保在清理函数中清除所有副作用
  }
}, [])

排查清单:

  1. 确认是否在依赖项数组中遗漏了变量,导致 effect 没有按预期重新执行
  2. 确认清理函数是否有条件性 return(例如 if (condition) return)导致有时不返回清理函数
  3. 在开发环境中确认是否是 HMR 导致的,尝试完全刷新页面
  4. 检查是否在 React 18 并发模式下遇到了异步清理的时序问题

Q5: 依赖项太多导致频繁执行

原因:依赖项数组包含了太多频繁变化的值(如每次渲染都创建的新函数、新对象、新数组),导致 effect 几乎每次渲染都被触发。

// 问题代码
function Parent() {
  const [data, setData] = useState([])

  useEffect(() => {
    const filtered = data.filter(item => item.active)  // 每次都是新数组
    document.title = `有 ${filtered.length} 条活跃数据`
  }, [data])  // data 每次变化都重新执行

  const handleClick = () => {
    console.log('clicked')  // 每次渲染都是新函数
  }

  useEffect(() => {
    document.addEventListener('click', handleClick)
    return () => document.removeEventListener('click', handleClick)
  }, [handleClick])  // handleClick 每次渲染都是新引用
}

解决方法:

  1. 使用 useCallback 缓存函数引用:
const handleClick = useCallback(() => {
  console.log('clicked')
}, [])  // 空依赖,函数引用稳定

useEffect(() => {
  document.addEventListener('click', handleClick)
  return () => document.removeEventListener('click', handleClick)
}, [handleClick])  // handleClick 引用稳定,effect 不会频繁触发
  1. 使用 useMemo 缓存计算结果:
const activeCount = useMemo(() => {
  return data.filter(item => item.active).length
}, [data])

useEffect(() => {
  document.title = `有 ${activeCount} 条活跃数据`
}, [activeCount])  // 只有数量变化才触发
  1. 将计算逻辑移到渲染阶段(如果只是为了计算派生状态):
// 不需要 useEffect,直接在渲染中计算
const activeCount = data.filter(item => item.active).length

Q6: 自定义 Hook 中处理竞态条件

原因:当依赖项(如 url)快速变化时,多个请求可能会同时发出,而先发出的请求后返回结果,导致显示过时的数据。

// 有竞态问题
function useFetchData(url: string) {
  const [data, setData] = useState(null)

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(result => setData(result))  // 如果 url 已变化,这里会设置过时数据
  }, [url])

  return data
}

解决方法:使用 AbortController + 版本号/标志位:

function useFetchData(url: string) {
  const [data, setData] = useState<unknown>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const abortController = new AbortController()
    let cancelled = false

    const fetchData = async () => {
      try {
        setLoading(true)
        setError(null)
        const response = await fetch(url, {
          signal: abortController.signal
        })
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }
        const result = await response.json()
        if (!cancelled) {
          setData(result)
        }
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') {
          return  // 请求被取消,忽略
        }
        if (!cancelled) {
          setError(err instanceof Error ? err.message : '未知错误')
        }
      } finally {
        if (!cancelled) {
          setLoading(false)
        }
      }
    }

    fetchData()

    return () => {
      cancelled = true
      abortController.abort()
    }
  }, [url])

  return { data, loading, error }
}

最佳实践

1. 每个 useEffect 只处理一个逻辑

不要在一个 useEffect 中混合多个不相关的副作用,这会导致代码难以维护和调试。

// 好的做法:每个 effect 只做一件事
useEffect(() => {
  fetchData()  // 数据获取
}, [query])

useEffect(() => {
  document.title = `Count: ${count}`  // 页面标题更新
}, [count])

useEffect(() => {
  const handler = (e: KeyboardEvent) => {
    if (e.key === 'Escape') onClose()
  }
  window.addEventListener('keydown', handler)
  return () => window.removeEventListener('keydown', handler)
}, [onClose])

// 不好的做法:多个不相关逻辑混在一起
useEffect(() => {
  fetchData()
  document.title = `Count: ${count}`
  window.addEventListener('keydown', handler)
  // ...
}, [query, count, onClose])

2. 明确且完整地声明依赖项

不要省略依赖项数组,也不要依赖 ESLint 的自动修复而忽略其背后的原因。react-hooks/exhaustive-deps 规则的存在是为了帮助你避免遗漏,而不是让你盲目地添加依赖。

// 不好的做法:依赖项不完整
useEffect(() => {
  console.log(a, b)  // 使用了 a 和 b,但依赖数组中只有 a
}, [a])

// 好的做法:完整声明所有依赖
useEffect(() => {
  console.log(a, b)
}, [a, b])

// 如果不想因为 b 变化而重新执行,应使用 useRef 保存 b
const bRef = useRef(b)
bRef.current = b

useEffect(() => {
  console.log(a, bRef.current)  // 通过 ref 读取 b,不将其加入依赖
}, [a])

3. 使用 eslint-plugin-react-hooks

启用 ESLint 插件来自动检测依赖项问题和 Hook 规则违规:

// .eslintrc.js
module.exports = {
  plugins: ['react-hooks'],
  rules: {
    'react-hooks/rules-of-hooks': 'error',        // 确保 Hook 在顶层调用
    'react-hooks/exhaustive-deps': 'warn'          // 确保依赖项完整
  }
}

在 Next.js 项目中,如果使用 eslint-config-next,这些规则默认已经启用。

4. 使用自定义 Hook 封装可复用逻辑

将复杂的副作用逻辑封装到自定义 Hook 中,提高代码的可读性和可复用性。

function useFetchData<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const abortController = new AbortController()
    let cancelled = false

    const fetchData = async () => {
      try {
        setLoading(true)
        setError(null)
        const response = await fetch(url, {
          signal: abortController.signal
        })
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }
        const result: T = await response.json()
        if (!cancelled) {
          setData(result)
        }
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') {
          return
        }
        if (!cancelled) {
          setError(err instanceof Error ? err.message : '未知错误')
        }
      } finally {
        if (!cancelled) {
          setLoading(false)
        }
      }
    }

    fetchData()

    return () => {
      cancelled = true
      abortController.abort()
    }
  }, [url])

  return { data, loading, error }
}

// 使用示例
interface User {
  id: number
  name: string
  email: string
}

function UserList() {
  const { data, loading, error } = useFetchData<User[]>('/api/users')

  if (loading) return <div>加载中...</div>
  if (error) return <div>错误: {error}</div>
  if (!data) return null

  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>
          {user.name} - {user.email}
        </li>
      ))}
    </ul>
  )
}

5. 考虑使用 useSyncExternalStore 替代部分 useEffect 场景

React 18 新增的 useSyncExternalStore 专门用于订阅外部数据源(如浏览器 API、全局状态等),相比 useEffect,它能避免"闪烁"问题并自动处理服务端渲染的一致性。

import { useSyncExternalStore } from 'react'

// 订阅在线状态
function useOnlineStatus() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('online', callback)
      window.addEventListener('offline', callback)
      return () => {
        window.removeEventListener('online', callback)
        window.removeEventListener('offline', callback)
      }
    },
    () => navigator.onLine,           // 客户端快照
    () => true                         // 服务端快照
  )
}

// 使用
function StatusBar() {
  const isOnline = useOnlineStatus()
  return <div>{isOnline ? '在线' : '离线'}</div>
}

useSyncExternalStore 的优势在于:它保证在并发渲染中返回一致的值,不会出现 useEffect 可能导致的中间状态不一致问题。如果你的场景是"订阅一个外部数据源并在其变化时更新组件",优先考虑使用 useSyncExternalStore 而非 useEffect + useState。

useEffect vs useLayoutEffect

虽然本文主要讨论 useEffect,但在某些场景下 useLayoutEffect 是更合适的选择:

特性useEffectuseLayoutEffect
执行时机浏览器绘制之后(异步)浏览器绘制之前(同步)
适用场景数据获取、事件监听、订阅DOM 测量、同步修改 DOM 样式
阻塞绘制不阻塞阻塞
服务端渲染可以使用(但不会执行)会报警告,应使用 useIsomorphicLayoutEffect
// useLayoutEffect 典型场景:防止 DOM 闪烁
function Tooltip({ position }: { position: { x: number; y: number } }) {
  const tooltipRef = useRef<HTMLDivElement>(null)

  useLayoutEffect(() => {
    const tooltip = tooltipRef.current
    if (!tooltip) return

    // 测量并调整位置,确保在用户看到之前完成
    const rect = tooltip.getBoundingClientRect()
    if (rect.right > window.innerWidth) {
      tooltip.style.left = `${position.x - rect.width}px`
    }
  }, [position])

  return <div ref={tooltipRef} style={{ left: position.x, top: position.y }}>提示内容</div>
}

总结

useEffect 是 React 中处理副作用的核心 Hook,其设计理念是让组件与外部世界进行同步。在 React 18 中,需要特别注意以下变化:

  1. StrictMode 双调用:开发环境下 effect 会执行两次,这是为了帮助你写出更健壮的代码,确保清理函数与副作用正确配对。
  2. 清理函数异步执行:并发渲染下清理函数在浏览器绘制后异步执行,可能产生短暂的视觉中间状态。
  3. 请求取消是必需的:使用 AbortController 防止对已卸载组件更新状态和处理竞态条件。
  4. 新 API 替代方案:useSyncExternalStore 是订阅外部数据源的推荐方式,能避免一致性问题。

掌握 useEffect 的正确用法,理解 React 18 中的行为变化,并遵循最佳实践(单一职责、明确依赖、正确清理),是编写高质量 React 应用的重要基础。

到此这篇关于React18中useEffect 的使用与常见问题解析的文章就介绍到这了,更多相关React18 useEffect 使用内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文