React useEffect
React 中用于同步外部系统的副作用 Hook 及其常见误区。
[!info] related notes
- 所属 MOC: React MOC
- 上位主题: react, react-hooks
- 相关概念: javascript, react-use-ref, React中的事件处理与状态更新
React useEffect
useEffect 的职责不是“渲染后执行任意逻辑”,而是把组件和 React 外部系统同步起来。
一句话定义
当组件需要和网络请求、订阅、定时器、DOM API 或第三方库建立连接时,useEffect 是声明这段同步关系的地方。
什么叫“外部系统”
- 网络请求
- 事件订阅
- 定时器
- 浏览器 DOM API
- 第三方可变对象或库实例
标准心智模型
不要把 useEffect 理解成“生命周期替代品”,更准确的理解是:
当依赖是这组值时,我需要把组件和某个外部系统同步到这个状态。
useEffect(() => {
const connection = createConnection(serverUrl, roomId)
connection.connect()
return () => {
connection.disconnect()
}
}, [serverUrl, roomId])
可以把它读成:
- 首次提交后,建立连接
- 依赖变化后,先清理旧连接,再建立新连接
- 组件卸载时,最后清理一次
什么情况通常不需要 useEffect
- 只是根据
props或state计算一个值 - 点击按钮后直接在事件函数里处理即可
- 本质只是渲染逻辑的一部分
这些场景更适合直接计算、提取函数,或使用 useMemo,而不是引入副作用。
依赖数组怎么理解
依赖数组不是“手动控制执行时机的开关”,而是告诉 React:这段副作用读取了哪些响应式值。
当这些值变化时,就要重新同步。
三种依赖写法的区别
不传依赖数组
useEffect(() => {
console.log('every render')
})
每次提交后都运行。
传空数组
useEffect(() => {
console.log('mount-like')
}, [])
只在首次挂载后运行一次,卸载时执行 cleanup。
但这里捕获的是首次渲染时的闭包值。
传具体依赖
useEffect(() => {
console.log(count)
}, [count])
首次执行一次,之后依赖变化时再执行。
依赖数组的常见坑
少写依赖
useEffect(() => {
fetchUser(userId)
}, [])
如果 userId 会变化,这通常就是 bug。
对象和函数依赖反复变化
const options = { roomId }
useEffect(() => {
connect(options)
}, [options])
这里的 options 每次渲染都会是新对象,effect 就会反复重跑。
更稳的写法通常是:
useEffect(() => {
const options = { roomId }
connect(options)
}, [roomId])
为什么 cleanup 特别重要
Effect 不是“一次性执行代码”,而是“开启一段同步过程”。
所以你开始了什么,就要结束什么:
addEventListener对应removeEventListenersetInterval对应clearIntervalconnect对应disconnectobserve对应unobserve
如果 cleanup 没写对,就容易出现:
- 重复订阅
- 定时器叠加
- 内存泄漏
- 过期异步结果回写
为什么开发环境里会执行两遍
在严格模式下,开发环境会额外做一次 setup -> cleanup -> setup 的压力测试。
这通常不是 bug,而是在帮你检查:
- cleanup 是否完整
- 副作用是否可恢复
- 代码是否偷偷依赖“只执行一次”
闭包旧值为什么在 Effect 里这么常见
function Demo() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count)
}, 1000)
return () => clearInterval(id)
}, [])
return <button onClick={() => setCount(c => c + 1)}>+</button>
}
这里会一直打印旧值,因为定时器回调拿到的是创建它那次渲染里的闭包。
常见解法有三种:
- 把依赖写全,让 effect 随值变化重建
- 用
useRef保存最新值 - 如果只是更新状态,优先用函数式更新
最容易踩的坑
- 把本该放在事件函数里的逻辑塞进
useEffect - 为了“让它只执行一次”而故意漏写依赖
- 在 effect 中更新依赖项本身,形成无限循环
- 忘记清理订阅、定时器和外部连接
- 用 effect 保存完全可以直接算出来的派生状态
最短判断题
如果去掉这段代码后,组件只是“少了一段外部同步行为”,那它可能属于 useEffect;如果去掉后连界面本身的计算都说不清了,那往往说明它本来就不该写进 useEffect。
面试要点
来自 react-use-effect-vs-use-layout-effect-interview-question 的面试视角整理。
一句话回答
useEffect 适合大多数普通副作用,会在浏览器绘制后执行;useLayoutEffect 会在浏览器绘制前同步执行,适合需要立即读取布局或避免闪动的场景。
最稳的回答主线
useEffect
- 默认优先选择它
- 不阻塞浏览器绘制
- 适合请求、订阅、普通副作用同步
useLayoutEffect
- 在绘制前同步执行
- 适合测量 DOM、同步布局、避免视觉闪动
- 用重了可能影响渲染性能
一个更完整的面试表达
可以这样答:
两者都属于副作用 Hook,区别主要在执行时机。
useEffect更适合大多数普通副作用,它通常不会阻塞浏览器绘制;useLayoutEffect会在浏览器绘制前同步执行,所以更适合布局测量、位置修正、避免闪动这类视觉敏感场景。
不是说useLayoutEffect更高级,而是它更重,只有明确依赖布局时机时才值得用。
一个典型例子
useLayoutEffect(() => {
const rect = tooltipRef.current?.getBoundingClientRect()
setPosition(rect)
}, [])
如果这里改成普通 useEffect,用户可能先看到错误位置,再看到修正后的结果。
一句很加分的话
如果不是明确依赖布局测量或绘制前同步,通常优先用 useEffect。
最短记忆方式
普通副作用用 useEffect,布局敏感副作用才考虑 useLayoutEffect。