闭包陷阱与 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,不是最新一次渲染的值。 这就是闭包陷阱。


如何解决:两种场景、两种方案

需要「在异步回调里用最新值」时,要区分调用发生的位置

场景解决方案说明
事件处理器里(如 onPressref 存最新值ref 在每次渲染后同步最新 state,回调里读 ref.current 即可。
useEffectuseEffectEvent不把该值写进依赖,又能读到最新值;且只能在 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 的渲染时序一致。

目录

  1. 组件 — 认识组件和元素、Props、State、单向数据流
  2. useState、useReducer、useContext
  3. useEffect、useEffectEvent
  4. 闭包陷阱、useRef
  5. useCallback、useMemo、React.memo
  6. Hook 规则、自定义 Hook
上次更新: