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 钩子开始,然后通过 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,存放你作为初始值传入的东西。你总是要传一个值,null 是 DOM ref 的常规起点,因为此时还没有节点。

ref={inputRef} 就是 React 需要的指令。当 React 把 <input> 放到屏幕上时,它把 inputRef.current 设置为 DOM 节点。从那时起 inputRef.current 就是输入元素本身,拥有浏览器赋予它的所有方法和属性,所以点击处理器里的 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 只有在使用旧值的那次渲染完成后才会更新,下一次渲染读到的就是上一次的值。在第一次渲染时钩子返回 undefined。注意明确的 useRef(undefined)useRef 以初始值作为参数,React 19 的类型期望这个参数存在,所以当没有有意义的初始值时要明确传 undefined

还有一个日常场景:不受控的表单字段。当一个值只在提交时才重要,用户输入时屏幕上什么都不依赖它,ref 可以直接从输入框读它而不触发每次按键都重新渲染,Forms 章节有完整演示。

准确理解 ref.current 何时拿到节点能解释大多数意外之处。React 分为两个阶段工作。在渲染阶段,它调用你的组件函数来生成 UI 的描述,此时还没有 DOM。在提交阶段,它把改变应用到真实 DOM,ref 就在这里被附加:React 把 current 设为它刚创建或保留的节点,然后运行 useLayoutEffect,再让浏览器绘制,最后运行 useEffect。当 ref 被分离时,可能是因为元素离开屏幕或 ref 属性现在指向其他地方,React 在附加新的前先把 current 设回 null

所以顺序是:effects 和事件处理器在元素在屏幕上时看到填充好的 ref,但渲染函数体不会。在第一次渲染时,inputRef.current 仍然是你传给 useRefnull,在函数体运行的时候。在后续渲染时它拿到上一次提交的节点,那个节点描述的是即将被替换的屏幕。

最后一点说明了为什么在渲染时读 ref 是不安全的。React 期望渲染是纯的,它保留不提交结果就渲染一个组件的权利:Strict Mode 在开发中渲染两次,并发渲染可能在优先级更高的更新到来时被丢弃,暂停的树可能被重试。在渲染时做的测量可能描述一个不再匹配的布局,在渲染时对 ref.current 的写入可能发生两次或被完全丢弃。把读和写放在事件处理器、effects 或回调 ref 中,这些地方 React 已经提交过,时序是确定的。

回调 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。要把一个传给内部的元素需要用 forwardRef 包装组件。React 19 把 ref 作为普通 prop 传给函数组件,所以新代码中 forwardRef 不需要了,已经被弃用。再往前,class 组件React.createRef() 创建 ref,从 this 读取。useRef 钩子在函数组件中同时处理两种角色。

JunoRef 到达真实的 DOM 节点 调用 useRef(null),把结果放在元素上用 ref={inputRef},React 一旦把它放到屏幕上就会用真实的 DOM 节点填充 inputRef.current。然后你可以对它做事,比如 inputRef.current.focus()

当你需要让某个东西获得焦点、滚动或测量时才用 ref。用户在屏幕上读到的任何东西都属于状态。

JunoRef 到达真实的 DOM 节点useRef 给你一个有 current 属性的盒子,它能在每次渲染中保留,写入它永远不会调度重新渲染。这包括两个工作:到达 DOM 节点用于获得焦点、滚动或测量,以及存放 UI 永不显示的值,比如定时器 ID 或 prop 的前一个值。

如果值显示在屏幕上,用状态使屏幕真的更新。

JunoRef 到达真实的 DOM 节点 Ref 在提交时附加,在 useLayoutEffectuseEffect 之前,分离时回到 null,所以 effects 和处理器看到活的节点而渲染函数体看到上一次提交的。这就是为什么在渲染时读或写 ref.current 不安全:渲染必须保持纯,React 可以丢弃或重复一个。

React 19 中的回调 ref 可以返回清理函数,把设置和清理配对,虽然内联回调每次渲染都重新附加,除非你用 useCallback 稳定它。

接下来:Hooks,更大的家族,useStateuseEffectuseRef 都属于它。