useEffect 是 React Hooks 中最容易踩坑的 API。它看起来简单——"渲染后执行副作用",但依赖数组、闭包陷阱、重复执行等问题让无数开发者头疼。本文从 useEffect 的执行原理讲起,结合 useMemo/useCallback 与 React.memo,给你一套完整的 React 性能优化方案。
一、useEffect 的执行时机
每次组件渲染完成后,React 都会执行 useEffect 的回调。依赖数组决定了"哪些变化会触发重新执行"。
import { useEffect, useState } from 'react'
function Profile({ userId }) {
const [user, setUser] = useState(null)
// 依赖 userId:userId 变化才重新请求
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
// 返回的清理函数会在下次 effect 执行前、组件卸载时调用
return () => console.log('清理上一次请求', userId)
}, [userId])
return <div>{user?.name}</div>
}
依赖数组的三种形态:
不传:每次渲染后都执行(几乎不用)。[]:仅挂载后执行一次,相当于 componentDidMount。[dep1, dep2]:依赖变化时执行,最常用。
原则:用到什么外部变量就放进依赖数组。用 ESLint 的 react-hooks 插件帮你补全,不要靠"经验"忽略。
二、闭包陷阱:为什么读到旧值
useEffect 闭包了它执行那次渲染的 state/props。如果依赖数组漏写了某项,effect 里读到的就是"旧渲染"的值,这就是经典的闭包陷阱。
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
// count 始终是 0!因为闭包捕获了首次渲染的 count
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, []) // ← 漏写 count
return <div>{count}</div>
}
// 修复方式一:函数式更新,不依赖外部 count
setCount(c => c + 1)
// 修复方式二:把 count 加入依赖(但会让定时器每秒重建)
// useEffect(..., [count])
函数式更新 setCount(c => c + 1) 是最优雅的解法:它接收前一个状态,不依赖闭包里的旧值。
三、useMemo:缓存昂贵计算
每次渲染都会重新执行组件函数体。如果内部有昂贵的计算(如大列表过滤、复杂运算),用 useMemo 缓存,仅在依赖变化时重算。
import { useMemo } from 'react'
function ProductList({ items, keyword }) {
// 仅当 items 或 keyword 变化时才重新过滤
const filtered = useMemo(() => {
return items.filter(it => it.name.includes(keyword))
}, [items, keyword])
return (
<ul>
{filtered.map(it => <li key={it.id}>{it.name}</li>)}
</ul>
)
}
注意:useMemo 本身也有开销,简单计算(如 a + b)无需缓存,否则得不偿失。
四、useCallback:稳定函数引用
每次渲染函数都会被重新创建,引用变化。如果这个函数作为 prop 传给被 React.memo 包裹的子组件,会导致子组件无效重渲染。useCallback 用来稳定函数引用。
import { useCallback, useState } from 'react'
const ExpensiveItem = React.memo(({ onClick, label }) => {
console.log('ExpensiveItem render')
return <li onClick={onClick}>{label}</li>
})
function List() {
const [items, setItems] = useState([])
// 稳定 handleClick 引用,避免子组件无谓重渲染
const handleClick = useCallback((id) => {
setItems(prev => prev.filter(it => it.id !== id))
}, [])
return items.map(it => (
<ExpensiveItem key={it.id} onClick={handleClick} label={it.name} />
))
}
五、React.memo:组件级缓存
React.memo 对组件做浅比较,props 不变就跳过渲染。配合 useCallback/useMemo 让 props 引用稳定,是 React 性能优化的标准组合拳。
// 默认浅比较 props
const MemoCard = React.memo(Card)
// 自定义比较函数
const MemoCard2 = React.memo(Card, (prev, next) => {
return prev.id === next.id && prev.title === next.title
})
六、性能优化的判断原则
性能优化不是越多越好,过度优化反而增加复杂度。给出三条判断标准:
- 先测量再优化:用 React DevTools Profiler 找到真正慢的组件,别凭感觉加 useMemo。
- 关注子组件重渲染:列表项、纯展示组件适合 memo;高频更新的状态组件 memo 反而增加比较成本。
- 状态下沉:把高频变化的状态隔离到小组件,避免父组件重渲染拖累整棵树。
// 状态下沉:搜索框独立持有 keyword,不触发 List 重渲染
function Page({ items }) {
return (
<>
<SearchBar /> {/* keyword 状态在内部 */}
<List items={items} /> {/* 不受输入影响 */}
</>
)
}
React 的性能优化本质上是在回答两个问题:"这次渲染是否必要?"和"这次渲染是否够快?"理解了 useEffect 的依赖机制与渲染流程,你就能精准地回答它们,写出既正确又高效的组件。