使用 Ref 引用值

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

你将学到

  • 如何向组件添加 ref
  • 如何更新 ref 的值
  • Refs 与状态的区别
  • 如何安全地使用 refs

向组件添加 ref

你可以通过从 React 中导入 useRef Hook 向组件添加 ref

import { useRef } from 'react';

在组件内部,调用 useRef Hook 并将要引用的初始值作为唯一参数传递。例如,以下是一个指向值 0 的 ref

const ref = useRef(0);

useRef 返回一个如下所示的对象

{
current: 0 // The value you passed to useRef
}
An arrow with 'current' written on it stuffed into a pocket with 'ref' written on it.

插图作者 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 指向一个数字,但与 状态一样,你可以指向任何内容:字符串、对象,甚至是函数。与状态不同,ref 是一个普通的 JavaScript 对象,具有可以读取和修改的 current 属性。

请注意,组件不会随着每次递增而重新渲染。与状态一样,refs 在重新渲染之间由 React 保留。但是,设置状态会重新渲染组件。更改 ref 不会!

示例:构建秒表

你可以在一个组件中结合使用 refs 和状态。例如,让我们制作一个秒表,用户可以通过按下按钮来启动或停止它。为了显示自用户按下“开始”按钮以来经过了多少时间,你需要跟踪按下“开始”按钮的时间和当前时间。这些信息用于渲染,因此你需要将其保存在状态中:

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

当用户按下“开始”按钮时,你将使用 setInterval 来每 10 毫秒更新一次时间

import { useState } from 'react';

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

  function handleStart() {
    // Start counting.
    setStartTime(Date.now());
    setNow(Date.now());

    setInterval(() => {
      // Update the current time every 10ms.
      setNow(Date.now());
    }, 10);
  }

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

  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        Start
      </button>
    </>
  );
}

当按下“停止”按钮时,你需要取消现有的间隔,以便它停止更新 now 状态变量。你可以通过调用 clearInterval 来做到这一点,但你需要给它传递用户按下“开始”按钮时 setInterval 调用返回的间隔 ID。你需要将间隔 ID 保存在某个地方。由于间隔 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>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        Start
      </button>
      <button onClick={handleStop}>
        Stop
      </button>
    </>
  );
}

当一条信息用于渲染时,请将其保存在状态中。当一条信息仅由事件处理程序需要,并且更改它不需要重新渲染时,使用 ref 可能更有效。

Refs 与状态的区别

也许你认为 refs 似乎没有状态那么“严格”——例如,你可以对它们进行更改,而不必总是使用状态设置函数。但在大多数情况下,你需要使用状态。Refs 是一个你不需要经常使用的“应急出口”。以下是状态和 refs 的比较

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

这是一个使用状态实现的计数器按钮

import { useState } from 'react';

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

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

  return (
    <button onClick={handleClick}>
      You clicked {count} times
    </button>
  );
}

因为 count 值是显示的,所以使用状态值是有意义的。当使用 setCount() 设置计数器的值时,React 会重新渲染组件,屏幕会更新以反映新的计数。

如果你尝试使用 ref 来实现这一点,React 将永远不会重新渲染组件,所以你永远不会看到计数的变化!看看点击这个按钮如何不会更新它的文本

import { useRef } from 'react';

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

  function handleClick() {
    // This doesn't re-render the component!
    countRef.current = countRef.current + 1;
  }

  return (
    <button onClick={handleClick}>
      You clicked {countRef.current} times
    </button>
  );
}

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

深入探讨

useRef 在内部是如何工作的?

尽管 useStateuseRef 都是由 React 提供的,但原则上,useRef 可以在 useState 的基础上实现。你可以想象,在 React 内部,useRef 是这样实现的

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

在第一次渲染期间,useRef 返回 { current: initialValue }。这个对象由 React 存储,因此在下次渲染期间,将返回同一个对象。请注意,在本例中,状态设置器是如何未被使用的。这是不必要的,因为 useRef 始终需要返回同一个对象!

React 提供了 useRef 的内置版本,因为它在实践中足够常见。但你可以把它看作是一个没有设置器的常规状态变量。如果你熟悉面向对象编程,refs 可能会让你想起实例字段——但不是 this.something,而是写 somethingRef.current

何时使用 refs

通常,当你的组件需要“跳出”React 并与外部 API(通常是不会影响组件外观的浏览器 API)通信时,你将使用 ref。以下是一些此类罕见情况

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

refs 的最佳实践

遵循这些原则将使你的组件更具可预测性

  • 将 refs 视为一种紧急出口。当你使用外部系统或浏览器 API 时,Refs 很有用。如果你的大部分应用程序逻辑和数据流都依赖于 refs,你可能需要重新考虑你的方法。
  • 不要在渲染过程中读取或写入 ref.current如果在渲染过程中需要某些信息,请改用状态。由于 React 不知道 ref.current 何时更改,即使在渲染时读取它也会使你的组件行为难以预测。(唯一的例外是像 if (!ref.current) ref.current = new Thing() 这样的代码,它只在第一次渲染期间设置一次 ref。)

React 状态的限制不适用于 refs。例如,状态就像每个渲染的快照,并且不会同步更新。但是当你改变 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.currentnull。你可以在使用 Refs 操作 DOM。 中阅读有关此内容的更多信息

回顾

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

挑战 1 4:
修复损坏的聊天输入框

输入一条消息,然后点击“发送”。你会注意到在看到“已发送!”警报之前有三秒钟的延迟。在此延迟期间,你可以看到一个“撤消”按钮。点击它。此“撤消”按钮应该停止“已发送!”消息的出现。它通过调用 clearTimeout 来实现,该方法用于清除在 handleSend 期间保存的超时 ID。但是,即使在点击“撤消”之后,“已发送!”消息仍然出现。找出它不起作用的原因,并修复它。

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>
      }
    </>
  );
}