发布于

React useEffect 钩子深度解析:副作用管理、依赖数组与性能优化

作者

React useEffect 钩子深度解析:副作用管理、依赖数组与性能优化

useEffect 是 React Hooks 中最重要也是最复杂的钩子之一。它统一了类组件中的多个生命周期方法,提供了处理副作用的强大能力。本文将深入探讨 useEffect 的工作原理和最佳实践。

useEffect 基础概念

副作用的定义和类型

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

// 基础 useEffect 使用
function BasicEffectExample() {
  const [count, setCount] = useState(0)
  const [name, setName] = useState('')

  // 1. 无依赖数组 - 每次渲染后都执行
  useEffect(() => {
    console.log('组件渲染完成')
    document.title = `计数: ${count}`
  })

  // 2. 空依赖数组 - 只在挂载后执行一次
  useEffect(() => {
    console.log('组件挂载完成')

    // 模拟 API 调用
    fetch('/api/user')
      .then((response) => response.json())
      .then((data) => setName(data.name))
      .catch((error) => console.error('获取用户信息失败:', error))
  }, []) // 空依赖数组

  // 3. 有依赖数组 - 只在依赖变化时执行
  useEffect(() => {
    console.log('count 发生变化:', count)

    // 保存到本地存储
    localStorage.setItem('count', count.toString())
  }, [count]) // 依赖 count

  // 4. 清理函数 - 处理副作用清理
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('定时器执行')
    }, 1000)

    // 返回清理函数
    return () => {
      console.log('清理定时器')
      clearInterval(timer)
    }
  }, [])

  return (
    <div>
      <h2>用户: {name}</h2>
      <p>计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>增加计数</button>
    </div>
  )
}

useEffect 执行时机详解

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

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

  console.log('1. 组件渲染开始')

  // useLayoutEffect - 同步执行,在 DOM 更新后立即执行
  useLayoutEffect(() => {
    console.log('3. useLayoutEffect 执行 - DOM 已更新但浏览器未绘制')
  })

  // useEffect - 异步执行,在浏览器绘制后执行
  useEffect(() => {
    console.log('4. useEffect 执行 - 浏览器已完成绘制')
  })

  console.log('2. 组件渲染结束,即将提交到 DOM')

  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>点击查看执行顺序</button>
    </div>
  )
}

// 执行顺序演示
function LifecycleComparison() {
  const [mounted, setMounted] = useState(false)

  useEffect(() => {
    console.log('useEffect: 组件挂载/更新后')

    return () => {
      console.log('useEffect cleanup: 组件卸载/依赖变化前')
    }
  })

  useEffect(() => {
    console.log('useEffect with empty deps: 仅在挂载后执行')

    return () => {
      console.log('useEffect cleanup with empty deps: 仅在卸载前执行')
    }
  }, [])

  if (!mounted) {
    return <button onClick={() => setMounted(true)}>挂载组件</button>
  }

  return (
    <div>
      <p>组件已挂载</p>
      <button onClick={() => setMounted(false)}>卸载组件</button>
    </div>
  )
}

依赖数组深度解析

依赖数组的工作原理

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

function DependencyArrayDemo() {
  const [count, setCount] = useState(0)
  const [name, setName] = useState('John')
  const [user, setUser] = useState({ id: 1, name: 'John' })

  // 1. 基本类型依赖
  useEffect(() => {
    console.log('count 变化:', count)
  }, [count])

  // 2. 对象依赖 - 浅比较问题
  useEffect(() => {
    console.log('user 对象变化:', user)
    // 注意:即使对象内容相同,如果是新对象引用,也会触发
  }, [user])

  // 3. 函数依赖 - 需要使用 useCallback
  const handleUserUpdate = useCallback((newName) => {
    setUser((prevUser) => ({ ...prevUser, name: newName }))
  }, []) // 空依赖,函数不会重新创建

  useEffect(() => {
    console.log('handleUserUpdate 函数变化')
  }, [handleUserUpdate])

  // 4. 计算值依赖 - 使用 useMemo
  const expensiveValue = useMemo(() => {
    console.log('计算昂贵的值')
    return count * 1000
  }, [count])

  useEffect(() => {
    console.log('expensiveValue 变化:', expensiveValue)
  }, [expensiveValue])

  // 5. 多个依赖
  useEffect(() => {
    console.log('count 或 name 变化:', { count, name })
  }, [count, name])

  return (
    <div>
      <p>计数: {count}</p>
      <p>姓名: {name}</p>
      <p>用户: {JSON.stringify(user)}</p>
      <p>昂贵计算值: {expensiveValue}</p>

      <button onClick={() => setCount(count + 1)}>增加计数</button>
      <button onClick={() => setName(name === 'John' ? 'Jane' : 'John')}>切换姓名</button>
      <button onClick={() => handleUserUpdate(name)}>更新用户</button>
    </div>
  )
}

// 依赖数组常见陷阱
function DependencyTraps() {
  const [count, setCount] = useState(0)

  // ❌ 错误:缺少依赖
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1) // count 被闭包捕获,始终是初始值 0
    }, 1000)

    return () => clearInterval(timer)
  }, []) // 缺少 count 依赖

  // ✅ 正确:使用函数式更新
  useEffect(() => {
    const timer = setInterval(() => {
      setCount((prevCount) => prevCount + 1) // 使用函数式更新
    }, 1000)

    return () => clearInterval(timer)
  }, []) // 不需要 count 依赖

  // ✅ 正确:包含所有依赖
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1)
    }, 1000)

    return () => clearInterval(timer)
  }, [count]) // 包含 count 依赖

  return <div>计数: {count}</div>
}

自定义依赖比较

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

// 自定义 useEffect,支持深度比较
function useDeepEffect(callback, dependencies) {
  const currentDepsRef = useRef()
  const callbackRef = useRef(callback)

  // 深度比较函数
  function deepEqual(a, b) {
    if (a === b) return true
    if (a == null || b == null) return false
    if (typeof a !== 'object' || typeof b !== 'object') return false

    const keysA = Object.keys(a)
    const keysB = Object.keys(b)

    if (keysA.length !== keysB.length) return false

    for (let key of keysA) {
      if (!keysB.includes(key)) return false
      if (!deepEqual(a[key], b[key])) return false
    }

    return true
  }

  // 更新回调引用
  callbackRef.current = callback

  useEffect(() => {
    // 深度比较依赖
    if (!deepEqual(currentDepsRef.current, dependencies)) {
      currentDepsRef.current = dependencies
      return callbackRef.current()
    }
  })
}

// 使用自定义 hook
function DeepComparisonDemo() {
  const [user, setUser] = useState({ name: 'John', age: 30 })
  const [settings, setSettings] = useState({ theme: 'light', lang: 'en' })

  // 使用深度比较的 effect
  useDeepEffect(() => {
    console.log('用户信息深度变化:', user)
  }, [user])

  useDeepEffect(() => {
    console.log('设置深度变化:', settings)
  }, [settings])

  const updateUserName = () => {
    // 即使创建新对象,但内容相同,不会触发 effect
    setUser({ name: 'John', age: 30 })
  }

  const updateUserAge = () => {
    setUser((prev) => ({ ...prev, age: prev.age + 1 }))
  }

  return (
    <div>
      <p>用户: {JSON.stringify(user)}</p>
      <p>设置: {JSON.stringify(settings)}</p>

      <button onClick={updateUserName}>更新用户名(相同值)</button>
      <button onClick={updateUserAge}>增加年龄</button>
    </div>
  )
}

// 使用 useRef 优化依赖
function OptimizedDependencies() {
  const [data, setData] = useState([])
  const [filter, setFilter] = useState('')

  // 使用 ref 存储稳定的函数引用
  const apiRef = useRef({
    fetchData: async (filterValue) => {
      const response = await fetch(`/api/data?filter=${filterValue}`)
      return response.json()
    },
  })

  useEffect(() => {
    let cancelled = false

    const loadData = async () => {
      try {
        const result = await apiRef.current.fetchData(filter)
        if (!cancelled) {
          setData(result)
        }
      } catch (error) {
        if (!cancelled) {
          console.error('加载数据失败:', error)
        }
      }
    }

    loadData()

    return () => {
      cancelled = true
    }
  }, [filter]) // 只依赖 filter,不依赖函数

  return (
    <div>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="过滤条件" />
      <ul>
        {data.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  )
}

实际应用场景

数据获取和 API 调用

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

// 自定义数据获取 hook
function useApi(url, options = {}) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  const fetchData = useCallback(async () => {
    try {
      setLoading(true)
      setError(null)

      const response = await fetch(url, options)
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const result = await response.json()
      setData(result)
    } catch (err) {
      setError(err.message)
    } finally {
      setLoading(false)
    }
  }, [url, JSON.stringify(options)])

  useEffect(() => {
    fetchData()
  }, [fetchData])

  const refetch = useCallback(() => {
    fetchData()
  }, [fetchData])

  return { data, loading, error, refetch }
}

// 使用数据获取 hook
function UserProfile({ userId }) {
  const { data: user, loading, error, refetch } = useApi(`/api/users/${userId}`)
  const [posts, setPosts] = useState([])

  // 获取用户文章
  useEffect(() => {
    if (!user) return

    let cancelled = false

    const fetchPosts = async () => {
      try {
        const response = await fetch(`/api/users/${user.id}/posts`)
        const userPosts = await response.json()

        if (!cancelled) {
          setPosts(userPosts)
        }
      } catch (error) {
        if (!cancelled) {
          console.error('获取文章失败:', error)
        }
      }
    }

    fetchPosts()

    return () => {
      cancelled = true
    }
  }, [user])

  if (loading) return <div>加载中...</div>
  if (error) return <div>错误: {error}</div>
  if (!user) return <div>用户不存在</div>

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>

      <h3>文章列表</h3>
      {posts.map((post) => (
        <div key={post.id}>
          <h4>{post.title}</h4>
          <p>{post.excerpt}</p>
        </div>
      ))}

      <button onClick={refetch}>刷新用户信息</button>
    </div>
  )
}

// 搜索功能实现
function SearchComponent() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  // 防抖搜索
  useEffect(() => {
    if (!query.trim()) {
      setResults([])
      return
    }

    const timeoutId = setTimeout(async () => {
      setLoading(true)

      try {
        const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
        const searchResults = await response.json()
        setResults(searchResults)
      } catch (error) {
        console.error('搜索失败:', error)
        setResults([])
      } finally {
        setLoading(false)
      }
    }, 500) // 500ms 防抖

    return () => {
      clearTimeout(timeoutId)
    }
  }, [query])

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
      />

      {loading && <div>搜索中...</div>}

      <ul>
        {results.map((result) => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  )
}

事件监听和清理

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

// 窗口尺寸监听
function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  })

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

    window.addEventListener('resize', handleResize)

    // 清理事件监听器
    return () => {
      window.removeEventListener('resize', handleResize)
    }
  }, [])

  return windowSize
}

// 键盘事件监听
function useKeyPress(targetKey) {
  const [keyPressed, setKeyPressed] = useState(false)

  useEffect(() => {
    const downHandler = (event) => {
      if (event.key === targetKey) {
        setKeyPressed(true)
      }
    }

    const upHandler = (event) => {
      if (event.key === targetKey) {
        setKeyPressed(false)
      }
    }

    window.addEventListener('keydown', downHandler)
    window.addEventListener('keyup', upHandler)

    return () => {
      window.removeEventListener('keydown', downHandler)
      window.removeEventListener('keyup', upHandler)
    }
  }, [targetKey])

  return keyPressed
}

// 鼠标位置跟踪
function useMousePosition() {
  const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })

  useEffect(() => {
    const handleMouseMove = (event) => {
      setMousePosition({
        x: event.clientX,
        y: event.clientY,
      })
    }

    document.addEventListener('mousemove', handleMouseMove)

    return () => {
      document.removeEventListener('mousemove', handleMouseMove)
    }
  }, [])

  return mousePosition
}

// 使用自定义 hooks
function InteractiveComponent() {
  const windowSize = useWindowSize()
  const escapePressed = useKeyPress('Escape')
  const mousePosition = useMousePosition()
  const [showModal, setShowModal] = useState(false)

  // ESC 键关闭模态框
  useEffect(() => {
    if (escapePressed && showModal) {
      setShowModal(false)
    }
  }, [escapePressed, showModal])

  // 在线状态监听
  const [isOnline, setIsOnline] = useState(navigator.onLine)

  useEffect(() => {
    const handleOnline = () => setIsOnline(true)
    const handleOffline = () => setIsOnline(false)

    window.addEventListener('online', handleOnline)
    window.addEventListener('offline', handleOffline)

    return () => {
      window.removeEventListener('online', handleOnline)
      window.removeEventListener('offline', handleOffline)
    }
  }, [])

  return (
    <div>
      <div>
        窗口尺寸: {windowSize.width} x {windowSize.height}
      </div>
      <div>
        鼠标位置: ({mousePosition.x}, {mousePosition.y})
      </div>
      <div>网络状态: {isOnline ? '在线' : '离线'}</div>
      <div>ESC 键状态: {escapePressed ? '按下' : '释放'}</div>

      <button onClick={() => setShowModal(true)}>打开模态框</button>

      {showModal && (
        <div
          style={{
            position: 'fixed',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            background: 'rgba(0,0,0,0.5)',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
          }}
        >
          <div
            style={{
              background: 'white',
              padding: '20px',
              borderRadius: '8px',
            }}
          >
            <p>ESC 键关闭模态框</p>
            <button onClick={() => setShowModal(false)}>关闭</button>
          </div>
        </div>
      )}
    </div>
  )
}

定时器和动画

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

// 倒计时 hook
function useCountdown(initialTime) {
  const [time, setTime] = useState(initialTime)
  const [isActive, setIsActive] = useState(false)

  useEffect(() => {
    let intervalId = null

    if (isActive && time > 0) {
      intervalId = setInterval(() => {
        setTime((prevTime) => prevTime - 1)
      }, 1000)
    } else if (time === 0) {
      setIsActive(false)
    }

    return () => {
      if (intervalId) {
        clearInterval(intervalId)
      }
    }
  }, [isActive, time])

  const start = () => setIsActive(true)
  const pause = () => setIsActive(false)
  const reset = () => {
    setTime(initialTime)
    setIsActive(false)
  }

  return { time, isActive, start, pause, reset }
}

// 动画 hook
function useAnimation(duration = 1000) {
  const [progress, setProgress] = useState(0)
  const [isAnimating, setIsAnimating] = useState(false)
  const startTimeRef = useRef(null)
  const requestRef = useRef(null)

  const animate = useCallback(
    (timestamp) => {
      if (!startTimeRef.current) {
        startTimeRef.current = timestamp
      }

      const elapsed = timestamp - startTimeRef.current
      const newProgress = Math.min(elapsed / duration, 1)

      setProgress(newProgress)

      if (newProgress < 1) {
        requestRef.current = requestAnimationFrame(animate)
      } else {
        setIsAnimating(false)
        startTimeRef.current = null
      }
    },
    [duration]
  )

  useEffect(() => {
    if (isAnimating) {
      requestRef.current = requestAnimationFrame(animate)
    }

    return () => {
      if (requestRef.current) {
        cancelAnimationFrame(requestRef.current)
      }
    }
  }, [isAnimating, animate])

  const startAnimation = () => {
    setProgress(0)
    setIsAnimating(true)
    startTimeRef.current = null
  }

  return { progress, isAnimating, startAnimation }
}

// 使用定时器和动画
function TimerAnimationDemo() {
  const { time, isActive, start, pause, reset } = useCountdown(60)
  const { progress, isAnimating, startAnimation } = useAnimation(2000)

  // 自动保存功能
  const [content, setContent] = useState('')
  const [lastSaved, setLastSaved] = useState(null)

  useEffect(() => {
    if (!content.trim()) return

    const saveTimeout = setTimeout(() => {
      // 模拟保存到服务器
      console.log('自动保存内容:', content)
      setLastSaved(new Date())
    }, 2000) // 2秒后自动保存

    return () => {
      clearTimeout(saveTimeout)
    }
  }, [content])

  return (
    <div>
      <div>
        <h3>倒计时器</h3>
        <div>剩余时间: {time}</div>
        <button onClick={start} disabled={isActive}>
          开始
        </button>
        <button onClick={pause} disabled={!isActive}>
          暂停
        </button>
        <button onClick={reset}>重置</button>
      </div>

      <div>
        <h3>动画进度</h3>
        <div
          style={{
            width: '200px',
            height: '20px',
            background: '#f0f0f0',
            borderRadius: '10px',
            overflow: 'hidden',
          }}
        >
          <div
            style={{
              width: `${progress * 100}%`,
              height: '100%',
              background: '#4caf50',
              transition: 'width 0.1s ease',
            }}
          />
        </div>
        <div>进度: {Math.round(progress * 100)}%</div>
        <button onClick={startAnimation} disabled={isAnimating}>
          开始动画
        </button>
      </div>

      <div>
        <h3>自动保存</h3>
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="输入内容,2秒后自动保存..."
          rows={4}
          cols={50}
        />
        {lastSaved && <div>最后保存时间: {lastSaved.toLocaleTimeString()}</div>}
      </div>
    </div>
  )
}

性能优化和最佳实践

避免不必要的重新渲染

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

// 性能优化示例
function OptimizedComponent() {
  const [count, setCount] = useState(0)
  const [users, setUsers] = useState([])
  const [filter, setFilter] = useState('')

  // ✅ 使用 useMemo 缓存计算结果
  const filteredUsers = useMemo(() => {
    console.log('过滤用户列表') // 只在 users 或 filter 变化时执行
    return users.filter((user) => user.name.toLowerCase().includes(filter.toLowerCase()))
  }, [users, filter])

  // ✅ 使用 useCallback 缓存函数
  const handleUserClick = useCallback((userId) => {
    console.log('用户点击:', userId)
    // 处理用户点击
  }, [])

  // ✅ 合并相关的 effect
  useEffect(() => {
    // 同时处理多个相关的副作用
    document.title = `计数: ${count}`
    localStorage.setItem('count', count.toString())

    // 发送分析数据
    if (count > 0) {
      analytics.track('count_changed', { count })
    }
  }, [count])

  // ✅ 条件性执行 effect
  useEffect(() => {
    if (filter.length < 2) return // 过滤条件太短时不执行

    const timeoutId = setTimeout(() => {
      // 执行搜索
      searchUsers(filter)
    }, 300)

    return () => clearTimeout(timeoutId)
  }, [filter])

  return (
    <div>
      <div>计数: {count}</div>
      <button onClick={() => setCount(count + 1)}>增加</button>

      <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="过滤用户" />

      <UserList users={filteredUsers} onUserClick={handleUserClick} />
    </div>
  )
}

// 错误处理和清理
function RobustEffectComponent() {
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)

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

    const fetchData = async () => {
      try {
        setError(null)

        const response = await fetch('/api/data', {
          signal: abortController.signal,
        })

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`)
        }

        const result = await response.json()

        if (!cancelled) {
          setData(result)
        }
      } catch (err) {
        if (!cancelled && err.name !== 'AbortError') {
          setError(err.message)
        }
      }
    }

    fetchData()

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

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

  return <div>数据: {JSON.stringify(data)}</div>
}

总结

React useEffect 钩子的核心要点和最佳实践:

🎯 核心概念

  1. 副作用管理:统一处理组件的副作用
  2. 执行时机:渲染后异步执行
  3. 依赖数组:控制 effect 的执行条件
  4. 清理函数:处理副作用的清理工作

✅ 最佳实践

  • 正确设置依赖数组
  • 使用清理函数防止内存泄漏
  • 合理使用 useCallback 和 useMemo
  • 处理异步操作的取消
  • 避免在 effect 中直接修改 DOM

⚠️ 常见陷阱

  • 缺少依赖导致的闭包问题
  • 无限循环的 effect 执行
  • 忘记清理定时器和事件监听器
  • 在 effect 中进行不必要的计算

🚀 性能优化

  • 使用条件判断避免不必要的执行
  • 合并相关的 effect
  • 使用 AbortController 取消请求
  • 正确使用依赖数组

掌握 useEffect,你就能优雅地处理 React 组件中的所有副作用!


useEffect 是 React Hooks 的核心,理解它的工作原理是掌握现代 React 开发的关键。