项目里有个搜索框,用户快速输入时页面偶尔会闪一下"旧结果"。排查了半天,发现是上一次请求比最新的请求晚返回------经典的竞态条件。你加了个 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 = 快照闭包 + 声明式同步 + 手动竞态管理
-
快照闭包:effect 和 cleanup 看到的变量是创建时的快照,不是最新值。需要最新值就用 ref 逃逸,或用函数式更新。
-
声明式同步:effect 不是"生命周期钩子",而是"把组件状态同步到外部系统"的声明。依赖数组告诉 React"什么变了需要重新同步"。
-
手动竞态管理 :React 不帮你处理异步竞态。要么用
cancelledflag,要么用AbortController,要么用 React Query 这类库。
下次遇到 effect 的 bug,先问自己三个问题:
- 我闭包里的变量是哪一帧的?
- cleanup 执行时拿到的值对吗?
- 多次触发时,旧的异步操作处理了吗?
这三个问题答清楚,90% 的 effect 问题都能解决。剩下 10%------大概率是依赖数组少写了东西,装个 eslint-plugin-react-hooks,别跟 lint 对着干。