使用 Refs 參考值

當您希望元件「記住」某些資訊,但不希望該資訊觸發新的渲染時,您可以使用一個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 是一個具有 current 屬性的純 JavaScript 物件,您可以讀取和修改它。

請注意,元件不會隨著每次遞增而重新渲染。 與狀態一樣,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,因為它在實務中很常見。但您可以將它視為沒有設定器的普通狀態變數。如果您熟悉物件導向程式設計,ref 可能會讓您想起實例欄位—但您撰寫的是somethingRef.current,而不是this.something

何時使用 ref

通常情況下,當您的元件需要「跳出」React 並與外部 API 進行通訊時,您會使用 ref,這通常是指不會影響元件外觀的瀏覽器 API。以下列出一些罕見的情況

如果您的元件需要儲存某些值,但它不影響渲染邏輯,請選擇 ref。

ref 的最佳實務

遵循這些原則將使您的元件更具可預測性

  • 將 ref 視為逃生出口。 當您使用外部系統或瀏覽器 API 時,ref 很有用。如果您的應用程式邏輯和資料流程大多依賴 ref,您可能需要重新思考您的方法。
  • 不要在渲染期間讀取或寫入ref.current 如果在渲染期間需要某些資訊,請改用狀態。由於 React 不知道ref.current何時會更改,即使在渲染時讀取它也會讓您的元件行為難以預測。(唯一的例外是像if (!ref.current) ref.current = new Thing()這樣的程式碼,它只會在第一次渲染期間設定 ref 一次。)

React 狀態的限制不適用於 ref。例如,狀態的作用類似於每次渲染的快照,並且不會同步更新。但當您變更 ref 的目前值時,它會立即更改

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

這是因為ref 本身就是一個普通的 JavaScript 物件,所以它的行為就像一個物件一樣。

當您使用 ref 時,您也不必擔心避免變更。只要您正在變更的物件沒有用於渲染,React 就不會關心您如何處理 ref 或其內容。

Ref 與 DOM

您可以將 ref 指向任何值。然而,ref 最常見的用例是存取 DOM 元素。例如,如果您想以程式設計方式將焦點放在輸入上,這會很方便。當您在 JSX 中將 ref 傳遞給ref屬性時,例如<div ref={myRef}>,React 會將對應的 DOM 元素放入myRef.current中。一旦元素從 DOM 中移除,React 會將myRef.current更新為null。您可以在使用 Ref 操作 DOM中了解更多相關資訊。

重點回顧

  • Refs 是一個用於保存不需渲染的值的逃生艙口(escape hatch)。你不會經常用到它們。
  • 一個 ref 是一個普通的 JavaScript 物件,它只有一個名為 current 的屬性,你可以讀取或設定它。
  • 你可以透過呼叫 useRef Hook 來要求 React 提供一個 ref。
  • 與狀態一樣,refs 讓你可以在元件重新渲染之間保留資訊。
  • 與狀態不同的是,設定 ref 的 current 值不會觸發重新渲染。
  • 不要在渲染期間讀取或寫入 ref.current。這會讓你的元件難以預測。

挑戰 1 4:
修復損壞的聊天輸入框

輸入訊息並點擊「發送」。你會注意到在看到「已發送!」的提示訊息之前有三秒鐘的延遲。在此延遲期間,你可以看到一個「取消」按鈕。點擊它。這個「取消」按鈕應該要停止顯示「已發送!」訊息。它透過在 handleSend 期間儲存的逾時 ID 呼叫 clearTimeout 來做到這一點。然而,即使點擊了「取消」,「已發送!」訊息仍然會出現。找出它無法運作的原因,並修復它。

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