Skip to content
This page has been auto-translated and may contain errors.View in English

Ref 和 DOM

React 为你构建和更新 DOM。你描述屏幕应该长什么样,实际的元素由 React 负责。有少数任务仍然需要直接操作元素本身:点击按钮后给输入框聚焦、把消息滚动到视图、测量一个盒子的宽度、播放视频。这些都没办法用 JSX 表达,因为它们是作用在节点上,而不是描述节点。Ref 是解决这些问题的逃生舱。它让一个组件能够持有一个真实 DOM 元素的引用,并直接调用它的方法。

附加一个 ref

Ref 从 useRef Hook 开始,然后通过 ref 属性把结果传给一个元素:

jsx
import { useRef } from 'react'

function SearchBar() {
  const inputRef = useRef(null)

  function handleFocus() {
    inputRef.current.focus()
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleFocus}>Focus</button>
    </>
  )
}

useRef(null) 返回一个对象,它有一个属性 current,保存你作为初始值传入的任何东西。你总是要传一个初始值,对于 DOM ref 来说 null 是惯例起点,因为这时还没有节点。

ref={inputRef} 是 React 需要的指令。当 React 把那个 <input> 放到屏幕上时,它会把 inputRef.current 设为这个 DOM 节点。从那以后,inputRef.current 就是 input 元素本身,拥有浏览器赋予它的所有方法和属性,所以点击处理器里的 inputRef.current.focus() 会把光标移到那个字段。React 会为你保持这个赋值是最新的:如果元素从屏幕上移除了,React 会把 current 设回 null

滚动节点到视图

聚焦是一个用途。滚动是另一个你很快会碰到的,通常在聊天日志这样的东西里,要跳到最新的消息。这个任务在渲染之后运行,所以它搭配 useEffect

jsx
import { useRef, useEffect } from 'react'

function MessageList({ messages }) {
  const bottomRef = useRef(null)

  useEffect(() => {
    bottomRef.current.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  return (
    <div>
      {messages.map(message => (
        <p key={message.id}>{message.text}</p>
      ))}
      <div ref={bottomRef} />
    </div>
  )
}

底部的空 <div> 只是作为滚动目标存在的,这是一个常见的、完全合理的技巧。每当 messages 改变时 effect 都会运行,此时 React 已经把新消息放到屏幕上,并让 bottomRef.current 指向那个最后的 div。

改变一个 ref 不会触发重渲染

状态和 ref 都能在一次渲染到下一次渲染之间保存,相似之处就到这儿为止了。调用状态 setter 会让 React 用新值重新渲染这个组件。赋值给 ref.current 会静默地改变这个值,屏幕上保持原样。

这个静默就是重点所在。Ref 持有的是 UI 不显示的东西:一个 DOM 节点、一个定时器 ID、一个标志位你的渲染逻辑从不读取。当其中一个改变时重新渲染会让你付出一次根本改不了任何像素的重绘代价。

这还带来另一个值得记住的结果。因为什么都不需要调度,ref.current 里的值在你赋值后立即可读。一个状态变量在整个渲染过程中是固定的,只在下一次渲染时改变,就像 State 章节讲的那样。Ref 的表现像一个普通的可变盒子,你可以在任何时刻读写它。

什么时候用 ref 是错误的直觉

分界线在于那个值是否会最终出现在屏幕上。标题里的文本、徽章上的数字、一个面板是否打开:这些都应该在 state 里,因为当值改变时显示必须跟着改变。把它放在 ref 里,更新就会看不见:值改变了,但 UI 还在显示上次渲染的内容。

伸手到 DOM 去改变显示内容是同一种直觉再往前走一步。手动设置 node.textContent,或者直接切换一个 class,看起来是有效的,但下一次渲染就会撤销它,因为 React 会重新应用你的 JSX 描述的内容。Ref 涵盖两样东西:读取和命令一个节点,也就是聚焦它、滚动它、测量它、播放它,以及保存在渲染间存活但从不被显示的值。对于用户从屏幕上读取的任何东西,要用 JSX 描述它,并用状态驱动它。

Ref 的第二种用途与 DOM 无关。任何必须在渲染间存活而且不被显示的值都可以活在 ref.current 里,这让 ref 成为保存簿记工作(比如定时器 ID)的天然场所:

jsx
function Stopwatch() {
  const [seconds, setSeconds] = useState(0)
  const intervalRef = useRef(null)

  function start() {
    if (intervalRef.current !== null) return
    intervalRef.current = setInterval(() => setSeconds(s => s + 1), 1000)
  }

  function stop() {
    clearInterval(intervalRef.current)
    intervalRef.current = null
  }

  useEffect(() => () => clearInterval(intervalRef.current), [])

  return (
    <>
      <p>{seconds}</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </>
  )
}

setInterval 返回的 ID 是后来停止定时器的唯一方式,所以 stop 必须能找到它。一个本地变量下一次渲染就会消失,而状态会在定时器每次启动时触发一次无谓的重渲染。intervalRef.current 是这项工作的合适形式,它还兼作 start 里的"它已经在运行了吗?"检查。最后的 effect 是 Effects 里的常规清理规则:定时器在这里启动,所以如果组件在运行中离开屏幕,必须有东西停止它。

同样的模式让你可以获取一个 prop 或状态变量的前一个值,这里打包成一个 custom hook

jsx
function usePrevious(value) {
  const ref = useRef(undefined)

  useEffect(() => {
    ref.current = value
  })

  return ref.current
}

Effect 在每次渲染后运行,所以 ref 只在使用了旧值的那次渲染完成后更新,下一次渲染读的就是它之前的值。在最初渲染时这个 hook 返回 undefined。注意这里明确的 useRef(undefined)useRef 把初始值作为参数,React 19 的类型期望这个参数存在,所以当没有有意义的起始值时要明确传 undefined

还有一个日常场景:不受控的表单字段。当一个值只在提交时有关,而用户在输入时屏幕上没有什么依赖它,一个 ref 可以直接从 input 读它而不用每次按键都重渲染,Forms 章节会全面展示这个。

精确理解 ref.current 什么时候持有一个节点可以解释大多数惊喜。React 分两个阶段工作。在渲染阶段它调用你的组件函数来生成 UI 的描述,此时还没有 DOM。在提交阶段它把改变应用到真实 DOM,这就是 ref 被附加的地方:React 把 current 设为它刚刚创建或保留的节点,然后运行 useLayoutEffect,然后让浏览器绘制,然后运行 useEffect。当 ref 被分离时(要么因为元素离开屏幕,要么因为 ref 属性现在指向别的地方),React 在附加新的之前把 current 设回 null

所以顺序是:当元素在屏幕上时 effect 和事件处理器看到一个被填充的 ref,渲染体则看不到。在第一次渲染时,当函数体运行时 inputRef.current 仍然是你传给 useRefnull。在之后的渲染里它持有前一次提交的节点,那代表一个即将被替换的屏幕。

这最后一点是为什么在渲染中读 ref 不安全的原因。React 期望渲染是纯的,它保留不提交结果的情况下渲染一个组件的权利:Strict Mode 在开发中渲染两次,并发渲染在更高优先级的更新来临时可以被丢弃,一个被挂起的树可以重试。在渲染中进行的测量因此可能描述一个不再匹配的布局,在渲染中对 ref.current 的写可能发生两次或被完全丢弃。把读和写放在事件处理器、effect 或回调 ref 里,React 已经提交了而且时机是确定的。

另一种附加 ref 的方式是回调 ref。把一个函数传给 ref,React 会在附加时用节点调用它:

jsx
<div
  ref={node => {
    const observer = new ResizeObserver(() => {
      // 对元素尺寸改变作出反应
    })
    observer.observe(node)
    return () => observer.disconnect()
  }}
/>

在 React 19 中,一个回调 ref 可以返回一个清理函数,React 会在那个节点被分离时调用它。这取代了返回清理的任何回调的旧惯例,它让设置和清理像在 effect 里那样坐在一起。一个不返回任何东西的回调仍然会在分离时被调用第二次,传入 null,这是你在现有代码里会遇到的形式。一个尖锐的边缘:内联箭头函数在每次渲染时都是一个新函数,所以 React 每次都会分离和重新附加节点。当这很关键时,把回调包裹在 useCallback 里以保持它的身份稳定。

因为在 React 19 中 ref 作为一个普通 prop 到达,一个组件也可以接收一个并把它交给它里面应该接收它的元素:

jsx
function TextInput({ ref, ...props }) {
  return <input ref={ref} {...props} />
}

很少地暴露这个。把一个活的 DOM 节点交给父组件会让那个节点成为你公开 API 的一部分,底层标记的任何重构都可能破坏调用者。

版本说明

在 React 19 之前,函数组件根本不能接收 ref prop。把一个 prop 传给内部的元素需要用 forwardRef 包裹组件。React 19 把 ref 作为一个普通 prop 交给函数组件,所以新代码中 forwardRef 不必要了,已被弃用。更早以前,class 组件React.createRef() 创建 ref,从 this 读取它们。useRef hook 在函数组件中涵盖了两个角色。

JunoRef 到达真实的 DOM 节点 调用 useRef(null),把结果放在一个元素上用 ref={inputRef},React 就会在它到屏幕上时把 inputRef.current 填入实际的 DOM 节点。然后你可以对它做些事,比如 inputRef.current.focus()。当你需要聚焦、滚动或测量某样东西时才去用 ref。任何用户从屏幕上读到的东西都应该在状态里。
JunoRef 到达真实的 DOM 节点useRef 给你一个盒子,它有一个在每次渲染中都存活的 current 属性,写入它从不调度一次重渲染。这涵盖两项工作:到达一个 DOM 节点用于聚焦、滚动或测量,以及藏匿 UI 永不显示的值,比如定时器 ID 或一个 prop 的前一个值。如果值出现在屏幕上,就用状态好让屏幕真的更新。
JunoRef 到达真实的 DOM 节点 Ref 在提交时附加,在 useLayoutEffectuseEffect 之前,然后分离回 null,所以 effect 和处理器看到一个活的节点而渲染体看到前一次提交的。这就是为什么在渲染中读或写 ref.current 不安全:渲染必须保持纯,React 可以丢弃或重复一个。React 19 中的回调 ref 可以返回一个清理函数,它把设置和清理配对,虽然内联回调每次重渲染都会重新附加,除非你用 useCallback 稳定它。

下一步:HooksuseStateuseEffectuseRef 都属于的更大家族。