使用 Refs 引用值

当你希望组件“记住”某些信息,但又不希望这些信息触发新的渲染时,你可以使用 ref

You will learn

  • 如何给组件添加 ref
  • 如何更新 ref 的值
  • ref 与 state 有何不同
  • 如何安全地使用 refs

给组件添加 ref

你可以通过从 React 导入 useRef Hook 来给组件添加 ref:

import { useRef } from 'react';

在组件内部,调用 useRef Hook,并将你想要引用的初始值作为唯一参数传入。例如,这里有一个指向值 0 的 ref:

const ref = useRef(0);

useRef 返回一个像这样的对象:

{
current: 0 // 你传给 useRef 的值
}
一个带有写着 'current' 的箭头,塞进一个写着 'ref' 的口袋里。

Illustrated by Rachel Lee Nabors

你可以通过 ref.current 属性访问这个 ref 的当前值。这个值被设计为可变的,这意味着你既可以读取它,也可以写入它。它就像你组件里的一个秘密口袋,React 不会跟踪它。(这就是它为何能成为逃离 React 单向数据流的“逃生口”——下面会详细说明!)

这里,一个按钮会在每次点击时递增 ref.current

import { useRef } from 'react';

export default function Counter() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert('You clicked ' + ref.current + ' times!');
  }

  return (
    <button onClick={handleClick}>
      Click me!
    </button>
  );
}

这个 ref 指向一个数字,但和state一样,你也可以让它指向任何东西:字符串、对象,甚至函数。与 state 不同,ref 只是一个普通的 JavaScript 对象,带有你可以读取和修改的 current 属性。

请注意,组件不会在每次递增时重新渲染。 和 state 一样,ref 会被 React 在多次重新渲染之间保留。然而,设置 state 会重新渲染组件。改变 ref 不会!

示例:构建一个秒表

你可以在同一个组件中同时结合 refs 和 state。例如,我们来做一个秒表,用户可以通过按按钮来开始或停止它。为了显示用户按下“Start”后已经过去了多长时间,你需要跟踪 Start 按钮何时被按下,以及当前时间是什么。这些信息用于渲染,所以你会把它保存在 state 中:

const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);

当用户按下“Start”时,你会使用 setInterval 每隔 10 毫秒更新时间:

import { useState } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);

  function handleStart() {
    // 开始计时。
    setStartTime(Date.now());
    setNow(Date.now());

    setInterval(() => {
      // 每 10 毫秒更新当前时间。
      setNow(Date.now());
    }, 10);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>已经过去的时间:{secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        开始
      </button>
    </>
  );
}

当按下“Stop”按钮时,你需要取消现有的 interval,以便它停止更新 now 状态变量。你可以通过调用 clearInterval 来做到这一点,但你需要传入之前在用户按下 Start 时由 setInterval 调用返回的 interval ID。你需要把这个 interval ID 保存在某个地方。由于 interval ID 不用于渲染,你可以把它保存在 ref 中:

import { useState, useRef } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());

    clearInterval(intervalRef.current);
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>已经过去的时间:{secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        开始
      </button>
      <button onClick={handleStop}>
        停止
      </button>
    </>
  );
}

当某些信息用于渲染时,把它保存在 state 中。当某些信息只被事件处理函数需要,并且修改它不需要重新渲染时,使用 ref 可能会更高效。

refs 和 state 的区别

也许你会觉得 ref 比 state “不那么严格”——例如,你可以直接修改它们,而不是总是必须使用 state 设置函数。但在大多数情况下,你会想使用 state。refs 是一种你不常需要的“逃生口”。下面是 state 和 ref 的对比:

refsstate
useRef(initialValue) 返回 { current: initialValue }useState(initialValue) 返回 state 变量的当前值和一个 state 设置函数([value, setValue]
当你修改它时,不会触发重新渲染。当你修改它时,会触发重新渲染。
可变——你可以在渲染过程之外修改和更新 current 的值。“不可变”——你必须使用 state 设置函数来修改 state 变量,以便排队触发重新渲染。
你不应该在渲染期间读取(或写入)current 的值。你可以随时读取 state。不过,每次渲染都有自己那一份不会改变的 快照

下面是一个使用 state 实现的计数按钮:

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      你点击了 {count}</button>
  );
}

因为 count 值会显示出来,所以把它作为 state 值是合理的。当通过 setCount() 设置计数器的值时,React 会重新渲染组件,屏幕也会更新以反映新的计数。

如果你尝试用 ref 来实现这个功能,React 就永远不会重新渲染组件,所以你永远看不到计数变化!看看点击这个按钮时不会更新其文本

import { useRef } from 'react';

export default function Counter() {
  let countRef = useRef(0);

  function handleClick() {
    // 这不会重新渲染组件!
    countRef.current = countRef.current + 1;
  }

  return (
    <button onClick={handleClick}>
      你点击了 {countRef.current}</button>
  );
}

这就是为什么在渲染期间读取 ref.current 会导致代码不可靠。如果你需要这样做,请改用 state。

Deep Dive

useRef 在内部是如何工作的?

虽然 useStateuseRef 都由 React 提供,但原则上 useRef 可以在 useState 的基础上实现。你可以把 React 内部的 useRef 想象成这样实现的:

// 在 React 内部
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}

在第一次渲染期间,useRef 返回 { current: initialValue }。这个对象会被 React 存储起来,因此在下一次渲染时会返回同一个对象。请注意在这个例子中 state setter 并未使用。它是不必要的,因为 useRef 总是需要返回同一个对象!

React 提供了内置版本的 useRef,因为它在实践中已经足够常见。但你可以把它理解为一个没有 setter 的普通 state 变量。如果你熟悉面向对象编程,ref 可能会让你联想到实例字段——只是你写的是 somethingRef.current,而不是 this.something

何时使用 refs

通常,当你的组件需要“跳出” React 并与外部 API 通信时,你会使用 ref——通常是不会影响组件外观的浏览器 API。以下是这些少见情况中的几个:

如果你的组件需要存储某个值,但它不会影响渲染逻辑,请选择 refs。

refs 的最佳实践

遵循这些原则会让你的组件更可预测:

  • 将 refs 视为一种逃生口。 当你与外部系统或浏览器 API 交互时,refs 非常有用。如果你的应用逻辑和数据流很大一部分都依赖 refs,你可能需要重新思考你的方案。
  • 不要在渲染期间读取或写入 ref.current 如果某些信息在渲染期间需要使用,请改用 state。由于 React 不知道 ref.current 何时变化,即使在渲染时读取它,也会让组件行为变得难以预测。(唯一的例外是像 if (!ref.current) ref.current = new Thing() 这样的代码,它只会在第一次渲染时设置一次 ref。)

React state 的限制不适用于 refs。例如,state 的行为就像每次渲染都有一个 快照,并且 不会同步更新。 但当你修改 ref 的当前值时,它会立即改变:

ref.current = 5;
console.log(ref.current); // 5

这是因为ref 本身就是一个普通的 JavaScript 对象, 因而它的表现也像一个普通对象。

当你使用 ref 时,也不需要担心避免突变。只要你修改的对象不用于渲染,React 并不关心你对 ref 或其内容做了什么。

Refs 和 DOM

你可以将 ref 指向任何值。不过,ref 最常见的用途是访问 DOM 元素。例如,如果你想以编程方式聚焦一个输入框,这就很方便。当你在 JSX 中将 ref 传递给 ref 属性时,例如 <div ref={myRef}>,React 会把对应的 DOM 元素放到 myRef.current 中。一旦该元素从 DOM 中移除,React 就会把 myRef.current 更新为 null。你可以在 使用 Refs 操作 DOM。 中了解更多内容。

Recap

  • Ref 是一种“逃生通道”,用于保存那些不参与渲染的值。你不会经常需要它们。
  • Ref 是一个普通的 JavaScript 对象,只有一个名为 current 的属性,你可以读取或设置它。
  • 你可以通过调用 useRef Hook 让 React 给你一个 ref。
  • 和 state 一样,ref 允许你在组件重新渲染之间保留信息。
  • 不同于 state,设置 ref 的 current 值不会触发重新渲染。
  • 不要在渲染期间读取或写入 ref.current。这会让你的组件难以预测。

Challenge 1 of 4:
修复一个损坏的聊天输入框

输入一条消息并点击 “Send”。你会注意到,在看到 “Sent!” 提示之前有 3 秒延迟。在这段延迟期间,你可以看到一个 “Undo” 按钮。点击它。这个 “Undo” 按钮本应该阻止 “Sent!” 消息出现。它通过调用 clearTimeout 来取消在 handleSend 中保存的定时器 ID 来实现这一点。然而,即使点击了 “Undo”,“Sent!” 消息仍然会出现。找出它为什么不起作用,并修复它。

import { useState } from 'react';

export default function Chat() {
  const [text, setText] = useState('');
  const [isSending, setIsSending] = useState(false);
  let timeoutID = null;

  function handleSend() {
    setIsSending(true);
    timeoutID = setTimeout(() => {
      alert('Sent!');
      setIsSending(false);
    }, 3000);
  }

  function handleUndo() {
    setIsSending(false);
    clearTimeout(timeoutID);
  }

  return (
    <>
      <input
        disabled={isSending}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button
        disabled={isSending}
        onClick={handleSend}>
        {isSending ? 'Sending...' : 'Send'}
      </button>
      {isSending &&
        <button onClick={handleUndo}>
          Undo
        </button>
      }
    </>
  );
}