Skip to content

React 副作用(Side Effects)全面详解

一、什么是副作用?

在编程中,副作用(Side Effect) 是指函数或表达式在执行过程中,除了返回值之外,还对程序状态或外部环境产生了可观察到的变化。 简单概况就是组件和外部世界交互的那些动作

副作用的定义

简单来说,副作用就是函数与其外部世界发生的任何交互。如果一个函数:

  • 修改了外部变量
  • 进行了网络请求
  • 操作了DOM
  • 设置了定时器
  • 进行了日志记录
  • 修改了全局状态

那么这个函数就产生了副作用。

React中的副作用

在React组件中,副作用是指那些不属于组件渲染过程的一部分,但需要在组件生命周期中执行的操作。React组件的主要职责是根据props和state返回JSX,但实际应用中,组件往往需要:

  • 与浏览器API交互(DOM操作、事件监听)
  • 发起网络请求(API调用)
  • 订阅外部数据源
  • 设置定时器
  • 操作本地存储

这些都是典型的副作用。

二、React副作用的具体类型

1. 数据获取(Data Fetching)

最常见的副作用之一,包括:

  • 从API获取数据
  • 提交表单数据
  • 上传文件

2. 订阅(Subscriptions)

  • 订阅浏览器事件(resize、scroll、keydown等)
  • 订阅WebSocket连接
  • 订阅状态管理库的变化
  • 订阅第三方库的事件

3. 手动DOM操作

  • 直接修改DOM元素
  • 操作canvas
  • 集成第三方DOM库(如D3.js、jQuery插件)

4. 定时器(Timers)

  • setTimeout
  • setInterval
  • requestAnimationFrame

5. 本地存储操作

  • localStorage
  • sessionStorage
  • IndexedDB

6. 日志和性能监控

  • console.log
  • 发送性能指标
  • 错误追踪

7. 清理操作

  • 取消网络请求
  • 清除定时器
  • 移除事件监听器
  • 关闭WebSocket连接

三、useEffect Hook 详解

React提供了 useEffect Hook来处理副作用。它允许你在函数组件中执行副作用操作。

useEffect 的基本语法

jsx
useEffect(() => {
  // 副作用代码

  return () => {
    // 清理函数(可选)
  }
}, [dependencies]) // 依赖数组

useEffect 的执行时机

  1. 组件首次渲染后:effect会在DOM更新后执行
  2. 依赖项变化后:当依赖数组中的值发生变化时重新执行
  3. 组件卸载前:执行清理函数

依赖数组的三种形式

jsx
// 1. 无依赖数组 - 每次渲染后都执行
useEffect(() => {
  console.log('每次渲染后都执行')
})

// 2. 空依赖数组 - 仅在挂载时执行一次
useEffect(() => {
  console.log('仅在组件挂载时执行一次')
}, [])

// 3. 有依赖项 - 依赖变化时执行
useEffect(() => {
  console.log('count变化时执行')
}, [count])

四、具体使用案例

案例1:数据获取

jsx
import { useState, useEffect } from 'react'

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let ignore = false

    async function fetchUser() {
      setLoading(true)
      try {
        const response = await fetch(`/api/users/${userId}`)
        const data = await response.json()

        if (!ignore) {
          setUser(data)
          setError(null)
        }
      } catch (err) {
        if (!ignore) {
          setError(err.message)
        }
      } finally {
        if (!ignore) {
          setLoading(false)
        }
      }
    }

    fetchUser()

    return () => {
      ignore = true
    }
  }, [userId])

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

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

要点说明

  • 使用 ignore 标志避免竞态条件
  • userId 变化时重新获取数据
  • 清理函数防止组件卸载后更新状态

案例2:事件监听

jsx
import { useState, useEffect } from 'react'

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

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

    window.addEventListener('resize', handleResize)

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

  return (
    <div>
      <p>窗口宽度: {windowSize.width}px</p>
      <p>窗口高度: {windowSize.height}px</p>
    </div>
  )
}

要点说明

  • 空依赖数组表示仅在挂载时添加监听器
  • 清理函数在卸载时移除监听器,防止内存泄漏

案例3:定时器

jsx
import { useState, useEffect } from 'react'

function Timer() {
  const [seconds, setSeconds] = useState(0)
  const [isRunning, setIsRunning] = useState(false)

  useEffect(() => {
    if (!isRunning) return

    const intervalId = setInterval(() => {
      setSeconds(prev => prev + 1)
    }, 1000)

    return () => {
      clearInterval(intervalId)
    }
  }, [isRunning])

  return (
    <div>
      <p>已运行: {seconds} 秒</p>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? '暂停' : '开始'}
      </button>
      <button onClick={() => setSeconds(0)}>重置</button>
    </div>
  )
}

要点说明

  • isRunning 变化时重新设置定时器
  • 清理函数清除旧的定时器

案例4:订阅WebSocket

jsx
import { useState, useEffect } from 'react'

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([])
  const [connectionStatus, setConnectionStatus] = useState('disconnected')

  useEffect(() => {
    const ws = new WebSocket(`wss://example.com/chat/${roomId}`)

    ws.onopen = () => {
      setConnectionStatus('connected')
    }

    ws.onmessage = event => {
      const message = JSON.parse(event.data)
      setMessages(prev => [...prev, message])
    }

    ws.onclose = () => {
      setConnectionStatus('disconnected')
    }

    ws.onerror = () => {
      setConnectionStatus('error')
    }

    return () => {
      ws.close()
    }
  }, [roomId])

  return (
    <div>
      <p>状态: {connectionStatus}</p>
      <ul>
        {messages.map((msg, index) => (
          <li key={index}>{msg.text}</li>
        ))}
      </ul>
    </div>
  )
}

要点说明

  • 切换房间时关闭旧连接并建立新连接
  • 清理函数确保WebSocket正确关闭

案例5:操作本地存储

jsx
import { useState, useEffect } from 'react'

function ThemeToggle() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') || 'light'
  })

  useEffect(() => {
    localStorage.setItem('theme', theme)
    document.documentElement.setAttribute('data-theme', theme)
  }, [theme])

  const toggleTheme = () => {
    setTheme(prev => (prev === 'light' ? 'dark' : 'light'))
  }

  return <button onClick={toggleTheme}>当前主题: {theme}</button>
}

要点说明

  • 初始值从localStorage读取
  • 主题变化时同步到localStorage和DOM

案例6:集成第三方DOM库

jsx
import { useEffect, useRef } from 'react'
import * as d3 from 'd3'

function BarChart({ data }) {
  const svgRef = useRef(null)

  useEffect(() => {
    const svg = d3.select(svgRef.current)

    svg
      .selectAll('rect')
      .data(data)
      .join('rect')
      .attr('x', (d, i) => i * 30)
      .attr('y', d => 200 - d.value)
      .attr('width', 25)
      .attr('height', d => d.value)
      .attr('fill', 'steelblue')
  }, [data])

  return <svg ref={svgRef} width={400} height={200} />
}

要点说明

  • 使用ref获取DOM元素
  • 数据变化时重新渲染图表

案例7:鼠标位置追踪

jsx
import { useState, useEffect } from 'react'

function MouseTracker() {
  const [position, setPosition] = useState({ x: 0, y: 0 })

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

    document.addEventListener('mousemove', handleMouseMove)

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

  return (
    <div>
      <p>
        鼠标位置: X: {position.x}, Y: {position.y}
      </p>
    </div>
  )
}

案例8:页面标题更新

jsx
import { useEffect } from 'react'

function DocumentTitle({ title }) {
  useEffect(() => {
    const previousTitle = document.title
    document.title = title

    return () => {
      document.title = previousTitle
    }
  }, [title])

  return null
}

function App() {
  return (
    <div>
      <DocumentTitle title='我的应用' />
      <h1>欢迎</h1>
    </div>
  )
}

案例9:防抖搜索

jsx
import { useState, useEffect } from 'react'

function SearchInput() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [searching, setSearching] = useState(false)

  useEffect(() => {
    if (!query.trim()) {
      setResults([])
      return
    }

    setSearching(true)

    const timeoutId = setTimeout(async () => {
      try {
        const response = await fetch(`/api/search?q=${query}`)
        const data = await response.json()
        setResults(data)
      } catch (error) {
        console.error('搜索失败:', error)
      } finally {
        setSearching(false)
      }
    }, 500)

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

  return (
    <div>
      <input
        type='text'
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder='搜索...'
      />
      {searching && <p>搜索中...</p>}
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  )
}

要点说明

  • 使用setTimeout实现防抖
  • 清理函数取消未完成的请求

案例10:自定义Hook封装副作用

jsx
import { useState, useEffect } from 'react'

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch (error) {
      console.error(error)
      return initialValue
    }
  })

  const setValue = value => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value
      setStoredValue(valueToStore)
      localStorage.setItem(key, JSON.stringify(valueToStore))
    } catch (error) {
      console.error(error)
    }
  }

  return [storedValue, setValue]
}

function useFetch(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let ignore = false

    async function fetchData() {
      setLoading(true)
      try {
        const response = await fetch(url)
        if (!response.ok) throw new Error('请求失败')
        const json = await response.json()
        if (!ignore) {
          setData(json)
          setError(null)
        }
      } catch (err) {
        if (!ignore) {
          setError(err.message)
        }
      } finally {
        if (!ignore) {
          setLoading(false)
        }
      }
    }

    fetchData()

    return () => {
      ignore = true
    }
  }, [url])

  return { data, loading, error }
}

function App() {
  const [name, setName] = useLocalStorage('name', '')
  const { data, loading, error } = useFetch('/api/users')

  return (
    <div>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
        placeholder='输入名字'
      />
      {loading && <p>加载中...</p>}
      {error && <p>错误: {error}</p>}
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  )
}

五、其他副作用相关Hook

useLayoutEffect

useLayoutEffectuseEffect 类似,但执行时机不同:

jsx
import { useLayoutEffect, useRef } from 'react'

function Tooltip({ children, targetRect }) {
  const tooltipRef = useRef(null)

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

    const tooltipRect = tooltip.getBoundingClientRect()

    tooltip.style.left = `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`
    tooltip.style.top = `${targetRect.top - tooltipRect.height - 5}px`
  }, [targetRect])

  return (
    <div ref={tooltipRef} className='tooltip'>
      {children}
    </div>
  )
}

区别

  • useEffect:在DOM更新后异步执行,不阻塞浏览器绘制
  • useLayoutEffect:在DOM更新后同步执行,阻塞浏览器绘制

使用场景

  • 需要在浏览器绘制前读取或修改DOM布局
  • 防止视觉闪烁

useInsertionEffect

React 18新增,用于CSS-in-Js库注入样式:

jsx
import { useInsertionEffect } from 'react'

function useCSS(rule) {
  useInsertionEffect(() => {
    const style = document.createElement('style')
    style.textContent = rule
    document.head.appendChild(style)

    return () => {
      document.head.removeChild(style)
    }
  }, [rule])
}

六、副作用最佳实践

1. 正确设置依赖数组

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

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(c => c + 1)
    }, 1000)

    return () => clearInterval(timer)
  }, [])

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

2. 避免在effect中直接使用props或state

jsx
function BadExample({ userId }) {
  const [data, setData] = useState(null)

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setData)
  }, [])

  return <div>{data?.name}</div>
}

function GoodExample({ userId }) {
  const [data, setData] = useState(null)

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setData)
  }, [userId])

  return <div>{data?.name}</div>
}

3. 处理竞态条件

jsx
function SearchResults({ query }) {
  const [results, setResults] = useState([])

  useEffect(() => {
    let ignore = false

    async function search() {
      const response = await fetch(`/api/search?q=${query}`)
      const data = await response.json()

      if (!ignore) {
        setResults(data)
      }
    }

    if (query) {
      search()
    }

    return () => {
      ignore = true
    }
  }, [query])

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

4. 使用AbortController取消请求

jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)

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

    async function fetchUser() {
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal
        })
        const data = await response.json()
        setUser(data)
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('获取用户失败:', error)
        }
      }
    }

    fetchUser()

    return () => {
      controller.abort()
    }
  }, [userId])

  return <div>{user?.name}</div>
}

5. 将副作用逻辑提取到自定义Hook

jsx
function useWindowResize(callback) {
  useEffect(() => {
    window.addEventListener('resize', callback)
    return () => window.removeEventListener('resize', callback)
  }, [callback])
}

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

function App() {
  const [search, setSearch] = useState('')
  const debouncedSearch = useDebounce(search, 500)

  useEffect(() => {
    if (debouncedSearch) {
      console.log('搜索:', debouncedSearch)
    }
  }, [debouncedSearch])

  return <input value={search} onChange={e => setSearch(e.target.value)} />
}

6. 避免在循环或条件中调用useEffect

jsx
function BadExample({ items }) {
  return items.map((item, index) => {
    useEffect(() => {
      console.log(item)
    }, [item])

    return <div key={index}>{item}</div>
  })
}

function GoodExample({ items }) {
  useEffect(() => {
    items.forEach(item => console.log(item))
  }, [items])

  return items.map((item, index) => <div key={index}>{item}</div>)
}

七、常见错误和解决方案

错误1:忘记清理副作用

jsx
function BadExample() {
  useEffect(() => {
    setInterval(() => {
      console.log('tick')
    }, 1000)
  }, [])

  return <div>Timer</div>
}

function GoodExample() {
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('tick')
    }, 1000)

    return () => clearInterval(timer)
  }, [])

  return <div>Timer</div>
}

错误2:依赖数组遗漏

jsx
function BadExample({ count }) {
  useEffect(() => {
    console.log('count is', count)
  }, [])
}

function GoodExample({ count }) {
  useEffect(() => {
    console.log('count is', count)
  }, [count])
}

错误3:在effect中直接修改state导致无限循环

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

  useEffect(() => {
    setCount(count + 1)
  }, [count])

  return <div>{count}</div>
}

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

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(c => c + 1)
    }, 1000)

    return () => clearInterval(timer)
  }, [])

  return <div>{count}</div>
}

八、总结

副作用的核心要点

  1. 理解副作用本质:任何与组件外部世界的交互都是副作用
  2. 使用useEffect处理:React提供的标准方式来处理副作用
  3. 正确设置依赖:确保effect在正确的时机执行
  4. 及时清理:防止内存泄漏和不必要的操作
  5. 提取自定义Hook:复用副作用逻辑

何时使用副作用

  • ✅ 数据获取和提交
  • ✅ 订阅外部数据源
  • ✅ 操作浏览器API
  • ✅ 设置定时器
  • ✅ 操作本地存储

何时避免副作用

  • ❌ 在渲染过程中执行副作用
  • ❌ 在事件处理器中应该处理的逻辑
  • ❌ 可以通过纯计算得到的值

副作用处理流程

1. 确定副作用类型

2. 选择合适的Hook(useEffect/useLayoutEffect)

3. 编写副作用逻辑

4. 设置正确的依赖数组

5. 添加清理函数(如需要)

6. 测试和验证

通过正确理解和使用React副作用,可以构建出更加健壮、可维护的应用程序。

基于 VitePress 的本地知识库