闭包陷阱与 useRef
本文由 AI 协助更新
本文先说明 React 中常见的闭包陷阱,再给出两种场景下的解决方式(事件处理器里用 ref,Effect 里用 useEffectEvent),最后展开讲 useRef 的其它用途。
闭包陷阱
在讲 ref 之前,先看一个由 JavaScript 闭包带来的现象。
下面这段代码:点击「完成」后,界面上的标题会变成 "买菜 ✓",但 Alert 里弹出的仍是 "买菜"。
import { Alert } from 'react-native'
function App() {
const [todoTitle, setTodoTitle] = useState('买菜')
function handleComplete() {
setTodoTitle('买菜 ✓')
setTimeout(() => Alert.alert('提示', `完成了: ${todoTitle}`), 0)
}
return (
<View style={styles.container}>
<TodoItem title={todoTitle} />
<Button title="完成" onPress={handleComplete} />
</View>
)
}
原因:每次渲染都会重新执行 App,得到新的 todoTitle 和新的 handleComplete。用户点击时执行的是某一次渲染创建的那个 handleComplete,它闭包里的 todoTitle 是那一次的变量,所以是 "买菜"。随后 setTodoTitle('买菜 ✓') 触发了一次新的渲染,界面用的是新渲染的 todoTitle("买菜 ✓"),但 setTimeout 回调仍在旧渲染的闭包里,读到的还是 "买菜"。
一句话:异步回调(如 setTimeout)执行时,拿到的是「创建该回调那次渲染」的 state/props,不是最新一次渲染的值。 这就是闭包陷阱。
如何解决:两种场景、两种方案
需要「在异步回调里用最新值」时,要区分调用发生的位置:
| 场景 | 解决方案 | 说明 |
|---|---|---|
事件处理器里(如 onPress) | 用 ref 存最新值 | ref 在每次渲染后同步最新 state,回调里读 ref.current 即可。 |
| useEffect 里 | 用 useEffectEvent | 不把该值写进依赖,又能读到最新值;且只能在 Effect 内调用。 |
事件处理器里:用 ref
在事件处理器中触发的异步回调(如 setTimeout、请求回调)里若要拿到最新 state,可先用 ref 在每次渲染后同步该 state,再在回调里读 ref:
import { useRef, useEffect } from 'react'
import { Alert } from 'react-native'
function App() {
const [todoTitle, setTodoTitle] = useState('买菜')
const latestTodoTitleRef = useRef(todoTitle)
useEffect(() => {
latestTodoTitleRef.current = todoTitle
})
function handleComplete() {
setTodoTitle('买菜 ✓')
setTimeout(() => {
Alert.alert('提示', `完成了: ${latestTodoTitleRef.current}`)
}, 0)
}
return (
<View style={styles.container}>
<TodoItem title={todoTitle} />
<Button title="完成" onPress={handleComplete} />
</View>
)
}
每次渲染后,useEffect 把当前 todoTitle 写入 latestTodoTitleRef.current,所以 setTimeout 回调里读到的一定是最新值。事件处理器里不能使用 useEffectEvent,只能用这种 ref 方案。
useEffect 里:用 useEffectEvent
若问题出在 useEffect 里——例如需要在 Effect 中读到某个值的最新值,又不想把该值写进依赖数组(避免它变化时重新执行整个 Effect)——则应用 useEffectEvent,在 Effect 内部只调用由 useEffectEvent 包装的函数,该函数内部总能拿到最新 props/state。
用法与限制见 useEffect、useEffectEvent 一节。要点:useEffectEvent 只能在 Effect(或其它 Effect)内部调用,不能放在事件处理器里;事件处理器里的「异步回调要最新值」仍用上面的 ref 方案。
useRef 的其它用途
上面用 ref 存「最新 state」只是 ref 的一种用法。useRef 返回一个在组件整个生命周期内不变的 ref 对象,其 .current 可被任意读写,且修改不会触发重新渲染。可以概括为三类用途:
const refContainer = useRef(initialValue)
1. 连接 React 与原生/命令式 API
把 ref 挂到组件或 DOM 上,通过 ref.current 访问实例并调用命令式方法(如 focus()、scrollTo())。
示例:添加待办后让输入框重新获得焦点。
function App() {
const inputRef = useRef<TextInput>(null)
const [todos, setTodos] = useState<string[]>([])
const [inputText, setInputText] = useState('')
function handleAddTodo() {
if (inputText.trim()) {
setTodos((prev) => [...prev, inputText])
setInputText('')
inputRef.current?.focus()
}
}
return (
<View style={styles.container}>
<TextInput
ref={inputRef}
value={inputText}
onChangeText={setInputText}
placeholder="输入新的待办事项"
style={styles.input}
/>
<Button title="添加" onPress={handleAddTodo} />
</View>
)
}
React 会在挂载时把 TextInput 实例赋给 inputRef.current,卸载时置为 null。
2. 连接「过去」和「未来」:跨渲染的可变值
ref 可存任意可变值,且更新不触发渲染,适合「保存上一轮渲染的某个值」或「在多次渲染/Effect 之间共享一个可变引用」(如定时器 ID)。
追踪上一次的值:例如在待办数量变化时打日志,说明是增是减。
function App() {
const [todos, setTodos] = useState<string[]>([])
const [inputText, setInputText] = useState('')
const prevCountRef = useRef(0)
useEffect(() => {
const currentCount = todos.length
const prevCount = prevCountRef.current
if (currentCount > prevCount) {
console.log(`增加了 ${currentCount - prevCount} 个待办事项`)
} else if (currentCount < prevCount) {
console.log(`减少了 ${prevCount - currentCount} 个待办事项`)
}
prevCountRef.current = currentCount
}, [todos])
// ... 省略渲染
}
保存定时器 ID:在 Effect 里设 setTimeout/setInterval,在 cleanup 里清除,需要把 timer id 存到 ref,以便同一 Effect 的多次执行或卸载时能清掉上一次的定时器。
const saveTimerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => saveTodos(), 2000)
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
}
}, [todos])
3. 在函数组件里持有类实例(连接 OOP 与 FP)
复杂逻辑有时用类封装更清晰。用 ref 保存类实例,保证整个生命周期内是同一个实例,且不会因 state 更新而重建。
示例:用类封装撤销/重做历史,ref 持有一个 TodoHistory 实例。
class TodoHistory {
private history: string[][] = [[]]
private currentIndex: number = 0
constructor(initialTodos: string[]) {
this.history = [initialTodos]
}
push(todos: string[]) {
this.history = this.history.slice(0, this.currentIndex + 1)
this.history.push([...todos])
this.currentIndex = this.history.length - 1
}
undo(): string[] | null {
if (this.currentIndex > 0) {
this.currentIndex--
return [...this.history[this.currentIndex]]
}
return null
}
redo(): string[] | null {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++
return [...this.history[this.currentIndex]]
}
return null
}
canUndo(): boolean {
return this.currentIndex > 0
}
canRedo(): boolean {
return this.currentIndex < this.history.length - 1
}
}
function App() {
const [todos, setTodos] = useState<string[]>([])
const [inputText, setInputText] = useState('')
const historyRef = useRef<TodoHistory | null>(null)
if (historyRef.current === null) {
historyRef.current = new TodoHistory(todos)
}
function handleAddTodo() {
if (inputText.trim()) {
const newTodos = [...todos, inputText]
setTodos(newTodos)
historyRef.current?.push(newTodos)
setInputText('')
}
}
function handleUndo() {
const prevTodos = historyRef.current?.undo()
if (prevTodos) setTodos(prevTodos)
}
function handleRedo() {
const nextTodos = historyRef.current?.redo()
if (nextTodos) setTodos(nextTodos)
}
function handleRemoveTodo(index: number) {
const newTodos = todos.filter((_, i) => i !== index)
setTodos(newTodos)
historyRef.current?.push(newTodos)
}
const canUndo = historyRef.current?.canUndo() ?? false
const canRedo = historyRef.current?.canRedo() ?? false
return (
<View style={styles.container}>
<View style={styles.toolbar}>
<Button title="撤销" onPress={handleUndo} disabled={!canUndo} />
<Button title="重做" onPress={handleRedo} disabled={!canRedo} />
</View>
<TextInput
value={inputText}
onChangeText={setInputText}
placeholder="输入新的待办事项"
style={styles.input}
/>
<Button title="添加" onPress={handleAddTodo} />
{todos.map((todo, index) => (
<View key={index} style={styles.todoRow}>
<Text>{todo}</Text>
<Button title="删除" onPress={() => handleRemoveTodo(index)} />
</View>
))}
</View>
)
}
用 ref 持有类实例,在事件里调用 historyRef.current?.undo() / redo(),既保留类的封装,又和函数组件的声明式 UI 结合。
使用 ref 时注意
.current可随意读写;修改不会触发渲染。- ref 对象在组件生命周期内保持不变。
- 除初始化外,尽量在事件处理器或 Effect 里读写 ref,避免在渲染过程中读写,以保证与 React 的渲染时序一致。