React useEffect 的闭包陷阱与竞态条件:你以为的 cleanup 真的在正确时机执行了吗

项目里有个搜索框,用户快速输入时页面偶尔会闪一下"旧结果"。排查了半天,发现是上一次请求比最新的请求晚返回------经典的竞态条件。你加了个 cleanup 函数,心想这下稳了。

结果,cleanup 里拿到的变量是旧的。

这不是你写错了,是 useEffect 的闭包机制和你的直觉产生了错位。这篇文章就是来把这个错位掰清楚的。


一个真实的 bug:搜索结果错乱

先看一个你大概率写过的代码:

tsx 复制代码
function SearchPage() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  useEffect(() => {
    // ❌ 没有任何竞态保护,快速输入时旧请求可能覆盖新结果
    fetchResults(query).then(data => {
      setResults(data) // 🐛 用户输入 "react" 但显示的是 "rea" 的结果
    })
  }, [query])

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

用户依次输入 r → re → rea → reac → react,触发了 5 次请求。网络不保证顺序,rea 的请求可能比 react 的晚 200ms 回来,直接把正确结果覆盖了。

你可能会说:"加个 cleanup 不就行了?"

行,但你得加对。


闭包:useEffect 的记忆是"快照"

聊 cleanup 之前,得先搞清楚 useEffect 里的闭包到底是什么。

很多人把 useEffect 当成 Vue 的 watch------值变了,回调里自动拿到最新值。不是的。React 的心智模型完全不同:每次渲染都是一张快照,effect 捕获的是那张快照里的变量。

tsx 复制代码
function Counter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const timer = setTimeout(() => {
      console.log(count) // 打印的是 effect 创建时的 count,不是"最新的"
    }, 3000)
    return () => clearTimeout(timer)
  }, [count])

  return <button onClick={() => setCount(c => c + 1)}>+1</button>
}

快速点 3 次按钮,3 秒后控制台输出 0, 1, 2------不是 2, 2, 2

这不是 bug,这是设计。每个 effect 都活在自己的"平行宇宙"里,拿到的是当时那一帧的 count。

js 复制代码
// React 每次渲染做的事(极度简化)
function render(component) {
  const snapshot = component()       // 执行组件,拿到当前状态的快照
  const effect = snapshot.effect      // effect 闭包捕获了这次的变量

  // 下次渲染时:
  // 1. 先执行上次 effect 的 cleanup(cleanup 闭包的是上次的变量)
  // 2. 再执行这次的 effect
}

关键洞察:cleanup 函数闭包的是上一次渲染的变量,不是当前渲染的。

很多人栽跟头就栽在这儿。


竞态条件:网络请求不排队

回到搜索的例子。标准的 cleanup 写法:

tsx 复制代码
useEffect(() => {
  let cancelled = false // 这个 flag 属于"这一次" effect

  fetchResults(query).then(data => {
    if (!cancelled) {
      setResults(data) // ✅ 只有没被取消的请求才更新
    }
  })

  return () => {
    cancelled = true // 下次 effect 执行前,把"这一次"标记为取消
  }
}, [query])

这段代码能跑,而且是 React 官方推荐的模式。但你有没有想过它为什么能跑?

关键在于 cancelled 这个变量------不是 state,不是 ref,就是个普通的局部变量。但因为闭包,每次 effect 执行都会创建一个新的 cancelled,而 cleanup 和 .then 回调捕获的是同一个 cancelled

画个时间线就清楚了:

ini 复制代码
输入 "rea":
  → effect1 执行,cancelled1 = false,发请求 A
  
输入 "react":
  → effect1 的 cleanup 执行,cancelled1 = true  ← 请求 A 的回调再执行时,发现被取消了
  → effect2 执行,cancelled2 = false,发请求 B

请求 A 回来(晚了):
  → if (!cancelled1) → cancelled1 是 true → 跳过 ✅

请求 B 回来:
  → if (!cancelled2) → cancelled2 是 false → 更新结果 ✅

每个 effect 有自己的 cancelled,互不干扰。这就是闭包在这里的正面作用------隔离


真正的陷阱:当你想在 cleanup 里用"最新值"

如果 cleanup 不是简单设个 flag,而是需要用到某些变量呢?

tsx 复制代码
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([])

  useEffect(() => {
    const conn = connectToRoom(roomId)

    conn.on('message', msg => {
      setMessages(prev => [...prev, msg])
    })

    return () => {
      // ❌ messages 是 effect 创建时的值,不是最新的!
      sendReadReceipt(roomId, messages.length) // 🐛 收了 50 条消息,这里可能是 0
      conn.disconnect()
    }
  }, [roomId])
}

cleanup 里的 messages.length 永远是 effect 创建时的值。用户在房间里收了 50 条消息,切换房间时发送的已读数量可能是 0。

典型的闭包陷阱:cleanup 需要访问最新状态,但它只能看到过去。


解法一:useRef 做"逃生通道"

tsx 复制代码
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([])
  const messagesRef = useRef(messages)

  useEffect(() => {
    messagesRef.current = messages // 每次 messages 变化,同步到 ref
  }, [messages])

  useEffect(() => {
    const conn = connectToRoom(roomId)
    conn.on('message', msg => setMessages(prev => [...prev, msg]))

    return () => {
      sendReadReceipt(roomId, messagesRef.current.length) // ✅ 通过 ref 拿到最新值
      conn.disconnect()
    }
  }, [roomId])
}

useRef 是一个可变容器,不参与 React 的渲染快照机制。它就像挂在组件上的"全局变量",cleanup 通过它逃出闭包的束缚。

但写到这里你可能也觉得别扭------为了拿一个最新值,得同时维护 state 和 ref?

是的。这就是 React 闭包模型的代价。


解法二:AbortController 处理异步竞态

对于网络请求,比 cancelled flag 更干净的方案是 AbortController:

tsx 复制代码
useEffect(() => {
  const controller = new AbortController()

  async function fetchData() {
    try {
      const res = await fetch(`/api/search?q=${query}`, {
        signal: controller.signal // 把取消信号传给 fetch
      })
      const data = await res.json()
      setResults(data)
    } catch (err) {
      if (err.name === 'AbortError') return // 被取消的请求,静默忽略
      throw err
    }
  }

  fetchData()

  return () => controller.abort() // ✅ cleanup 时真正取消请求,不只是忽略结果
}, [query])

AbortController 比 boolean flag 好在哪?它真的取消了请求,而不是等请求回来再丢掉。弱网环境下,这意味着节省带宽和连接数。


一个更隐蔽的坑:effect 里的 async 函数

tsx 复制代码
// ❌ useEffect 不能直接传 async 函数
useEffect(async () => {
  const data = await fetchData()
  setResults(data)
}, [query])

这段代码不会报错(某些版本会有 warning),但 cleanup 机制彻底失效了------async 函数返回的是 Promise,而 React 期望 effect 返回 cleanup 函数或 undefined。

tsx 复制代码
// ✅ 在 effect 内部定义 async 函数
useEffect(() => {
  let cancelled = false

  async function load() {
    const data = await fetchData(query)
    if (!cancelled) setResults(data) // 只在未取消时更新
  }

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

这个坑特别阴,因为代码"看起来能跑",只有在快速切换、竞态出现时才暴露。Code Review 时重点盯这个。


设计权衡:React 为什么选闭包快照模型?

你可能想问:Vue 的 watchEffect 就不存在这个问题,拿到的永远是最新值。React 干嘛用这种"反直觉"的模型?

因为一致性。

React 的核心理念是 UI = f(state)。每次渲染是一次纯函数调用,effect 看到的变量和那次渲染的 JSX 看到的变量完全一致。不会出现"界面显示 A,但 effect 里读到 B"这种中间态。

这带来一个巨大的好处:可预测性。你永远不需要担心 effect 里的变量在执行过程中突然变了。

代价?就是你刚才看到的------想要最新值得手动用 ref 去"逃逸"。

维度 React(闭包快照) Vue(响应式代理)
一致性 强,effect 和渲染永远对齐 弱,effect 里可能读到下一帧的值
心智负担 高,需要理解闭包、ref 逃逸 低,符合直觉
竞态处理 需要手动处理 同样需要手动处理
调试难度 闭包导致"变量值过期"问题 代理导致"意外触发"问题

没有完美方案,只有取舍。


当 effect 变复杂了怎么办

小项目里手动管理竞态还行。项目一大,问题就来了------十几个页面都有搜索,每个都写一遍 cancelled flag?WebSocket 连接的 cleanup 越写越长?

抽成 Hook

tsx 复制代码
function useAsyncEffect<T>(
  asyncFn: (signal: AbortSignal) => Promise<T>,
  deps: DependencyList
) {
  useEffect(() => {
    const controller = new AbortController()
    asyncFn(controller.signal).catch(err => {
      if (err.name !== 'AbortError') throw err // 非取消错误才抛出
    })
    return () => controller.abort()
  }, deps) // eslint-disable-line react-hooks/exhaustive-deps
}

// 使用:一行搞定竞态处理
useAsyncEffect(async (signal) => {
  const res = await fetch(`/api/search?q=${query}`, { signal })
  const json = await res.json()
  setResults(json)
}, [query])

或者更进一步,直接用 React Query / SWR。它们本质上就是帮你把竞态处理、缓存、重试这些脏活包了一层。

别觉得引入一个库是"过度设计"------当你第三次复制粘贴 cancelled flag 的时候,就该考虑抽象了。


边界 & 高危场景

Strict Mode 下 effect 执行两次

React 18 的 Strict Mode 会在开发环境下故意执行两次 effect,帮你发现 cleanup 写得不对。如果你的 effect 不是幂等的(比如注册了事件监听但 cleanup 没移除),Strict Mode 会让 bug 提前暴露。

别用 useRef 去"跳过"第二次执行,那是在掩盖问题。

定时器 + 闭包 = 定时炸弹

tsx 复制代码
// ❌ count 永远是 0,因为闭包捕获的是初始值
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1) // count 永远是 0
  }, 1000)
  return () => clearInterval(id)
}, []) // 空依赖 → effect 只执行一次 → 闭包只捕获一次

// ✅ 用函数式更新,不依赖闭包里的 count
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1) // prev 是 React 给的最新值
  }, 1000)
  return () => clearInterval(id)
}, [])

setCount(prev => prev + 1) 是 React 提供的"官方逃生通道"之一。能用函数式更新的场景,优先用它,别去碰 ref。

cleanup 的执行时机

最后一个容易搞混的点:cleanup 不是在组件卸载时才执行。每次 effect 重新执行前,都会先跑上一次的 cleanup。

复制代码
渲染1 → effect1 执行
渲染2 → effect1 的 cleanup 执行 → effect2 执行
渲染3 → effect2 的 cleanup 执行 → effect3 执行
卸载   → effect3 的 cleanup 执行

如果你把 cleanup 理解成"组件销毁时的钩子",那你会漏掉中间那些 cleanup 的执行------竞态条件恰恰就藏在那里。


总结成一个模型

useEffect = 快照闭包 + 声明式同步 + 手动竞态管理

  1. 快照闭包:effect 和 cleanup 看到的变量是创建时的快照,不是最新值。需要最新值就用 ref 逃逸,或用函数式更新。

  2. 声明式同步:effect 不是"生命周期钩子",而是"把组件状态同步到外部系统"的声明。依赖数组告诉 React"什么变了需要重新同步"。

  3. 手动竞态管理 :React 不帮你处理异步竞态。要么用 cancelled flag,要么用 AbortController,要么用 React Query 这类库。

下次遇到 effect 的 bug,先问自己三个问题:

  • 我闭包里的变量是哪一帧的?
  • cleanup 执行时拿到的值对吗?
  • 多次触发时,旧的异步操作处理了吗?

这三个问题答清楚,90% 的 effect 问题都能解决。剩下 10%------大概率是依赖数组少写了东西,装个 eslint-plugin-react-hooks,别跟 lint 对着干。

相关推荐
进击的尘埃2 小时前
TypeScript 类型体操进阶:用 Template Literal Types 实现编译期路由参数校验
javascript
滕青山2 小时前
文本字符数统计 在线工具核心JS实现
前端·javascript·vue.js
十二7402 小时前
前端缓存踩坑实录:从版本号管理到自动化构建
前端·javascript·nginx
进击的尘埃2 小时前
前端大文件上传全方案:切片、秒传、断点续传与 Worker 并行 Hash 计算实践
javascript
西梯卧客2 小时前
[1-2] 数据类型检测 · typeof、instanceof、toString.call 等方式对比
javascript
wuhen_n2 小时前
响应式探秘:ref vs reactive,我该选谁?
前端·javascript·vue.js
wuhen_n2 小时前
setup 的艺术:如何组织我们的组合式函数?
前端·javascript·vue.js
明月_清风3 小时前
性能级目录同步:IntersectionObserver 实战
前端·javascript
明月_清风3 小时前
告别暴力轮询:深度解锁浏览器“观察者家族”
前端·javascript