React useEffect

React 中用于同步外部系统的副作用 Hook 及其常见误区。

#tech / dev / frame #resource / react #type / concept #status / growing

[!info] related notes

React useEffect

useEffect 的职责不是“渲染后执行任意逻辑”,而是把组件和 React 外部系统同步起来。

一句话定义

当组件需要和网络请求、订阅、定时器、DOM API 或第三方库建立连接时,useEffect 是声明这段同步关系的地方。

什么叫“外部系统”

  • 网络请求
  • 事件订阅
  • 定时器
  • 浏览器 DOM API
  • 第三方可变对象或库实例

标准心智模型

不要把 useEffect 理解成“生命周期替代品”,更准确的理解是:

当依赖是这组值时,我需要把组件和某个外部系统同步到这个状态。

useEffect(() => {
  const connection = createConnection(serverUrl, roomId)
  connection.connect()

  return () => {
    connection.disconnect()
  }
}, [serverUrl, roomId])

可以把它读成:

  • 首次提交后,建立连接
  • 依赖变化后,先清理旧连接,再建立新连接
  • 组件卸载时,最后清理一次

什么情况通常不需要 useEffect

  • 只是根据 propsstate 计算一个值
  • 点击按钮后直接在事件函数里处理即可
  • 本质只是渲染逻辑的一部分

这些场景更适合直接计算、提取函数,或使用 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 对应 removeEventListener
  • setInterval 对应 clearInterval
  • connect 对应 disconnect
  • observe 对应 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>
}

这里会一直打印旧值,因为定时器回调拿到的是创建它那次渲染里的闭包。

常见解法有三种:

  1. 把依赖写全,让 effect 随值变化重建
  2. useRef 保存最新值
  3. 如果只是更新状态,优先用函数式更新

最容易踩的坑

  • 把本该放在事件函数里的逻辑塞进 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

创建于 2026/3/19 更新于 2026/5/27