使用 Refs 操作 DOM

React 會自動更新 DOM 以符合您的渲染輸出,因此您的元件通常不需要操作它。然而,有時您可能需要存取 React 管理的 DOM 元素,例如,要將焦點放在節點上、捲動到它,或測量它的大小和位置。React 中沒有內建的方法可以執行這些操作,因此您需要 DOM 節點的*ref*。

您將學習到

  • 如何使用 ref 屬性存取 React 管理的 DOM 節點
  • ref JSX 屬性與 useRef Hook 之間的關係
  • 如何存取其他元件的 DOM 節點
  • 在哪些情況下修改 React 管理的 DOM 是安全的

取得節點的 ref

要存取 React 管理的 DOM 節點,首先,匯入 useRef Hook

import { useRef } from 'react';

然後,使用它在您的元件內宣告一個 ref

const myRef = useRef(null);

最後,將您的 ref 作為 ref 屬性傳遞給您想要取得 DOM 節點的 JSX 標籤

<div ref={myRef}>

useRef Hook 會傳回一個具有單一屬性 current 的物件。最初,myRef.current 將為 null。當 React 為這個 <div> 建立 DOM 節點時,React 會將此節點的參考放入 myRef.current 中。然後,您可以從您的 事件處理器 存取此 DOM 節點,並使用在其上定義的內建 瀏覽器 API

// You can use any browser APIs, for example:
myRef.current.scrollIntoView();

範例:將焦點放在文字輸入框

在此範例中,點擊按鈕將會將焦點放在輸入框上

import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

要實現這個

  1. 使用 useRef Hook 宣告 inputRef
  2. 將它作為 <input ref={inputRef}> 傳遞。這會告訴 React 將這個 <input> **的 DOM 節點放入 inputRef.current 中。**
  3. handleClick 函式中,從 inputRef.current 讀取輸入 DOM 節點,並使用 inputRef.current.focus() 在其上呼叫 focus()
  4. 使用 onClickhandleClick 事件處理器傳遞給 <button>

雖然 DOM 操作是 refs 最常見的用例,但 useRef Hook 也可用於儲存 React 之外的其他東西,例如計時器 ID。與狀態類似,refs 在渲染之間會保留下來。Refs 就像狀態變數一樣,當您設定它們時不會觸發重新渲染。在 使用 Refs 引用值 中閱讀關於 refs 的資訊。

範例:捲動到元素

您可以在一個組件中擁有多個 ref。在此範例中,有一個包含三張圖片的輪播。每個按鈕都會透過在對應的 DOM 節點上呼叫瀏覽器的 scrollIntoView() 方法來將圖片置中。

import { useRef } from 'react';

export default function CatFriends() {
  const firstCatRef = useRef(null);
  const secondCatRef = useRef(null);
  const thirdCatRef = useRef(null);

  function handleScrollToFirstCat() {
    firstCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToSecondCat() {
    secondCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToThirdCat() {
    thirdCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  return (
    <>
      <nav>
        <button onClick={handleScrollToFirstCat}>
          Neo
        </button>
        <button onClick={handleScrollToSecondCat}>
          Millie
        </button>
        <button onClick={handleScrollToThirdCat}>
          Bella
        </button>
      </nav>
      <div>
        <ul>
          <li>
            <img
              src="https://placecats.com/neo/300/200"
              alt="Neo"
              ref={firstCatRef}
            />
          </li>
          <li>
            <img
              src="https://placecats.com/millie/200/200"
              alt="Millie"
              ref={secondCatRef}
            />
          </li>
          <li>
            <img
              src="https://placecats.com/bella/199/200"
              alt="Bella"
              ref={thirdCatRef}
            />
          </li>
        </ul>
      </div>
    </>
  );
}

深入探討

如何使用 ref 回呼函式來管理 ref 列表

在上述範例中,ref 的數量是預先定義的。然而,有時您可能需要列表中每個項目的 ref,而且您不知道會有多少個項目。像這樣的情況就**行不通**

<ul>
{items.map((item) => {
// Doesn't work!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>

這是因為**Hooks 必須只能在組件的頂層呼叫。** 您不能在迴圈、條件或 map() 呼叫內呼叫 useRef

一種可能的解決方法是取得其父元素的單一 ref,然後使用 DOM 操作方法,例如 querySelectorAll,從中「找到」個別的子節點。然而,這種方法很脆弱,如果您的 DOM 結構改變,它可能會失效。

另一個解決方案是**將函式傳遞給 ref 屬性。** 這稱為 ref 回呼函式。當需要設定 ref 時,React 會使用 DOM 節點呼叫您的 ref 回呼函式;當需要清除 ref 時,則會使用 null 呼叫它。這讓您可以維護自己的陣列或 Map,並透過索引或某種 ID 來存取任何 ref。

此範例顯示如何使用這種方法捲動到長列表中的任意節點

import { useRef, useState } from "react";

export default function CatFriends() {
  const itemsRef = useRef(null);
  const [catList, setCatList] = useState(setupCatList);

  function scrollToCat(cat) {
    const map = getMap();
    const node = map.get(cat);
    node.scrollIntoView({
      behavior: "smooth",
      block: "nearest",
      inline: "center",
    });
  }

  function getMap() {
    if (!itemsRef.current) {
      // Initialize the Map on first usage.
      itemsRef.current = new Map();
    }
    return itemsRef.current;
  }

  return (
    <>
      <nav>
        <button onClick={() => scrollToCat(catList[0])}>Neo</button>
        <button onClick={() => scrollToCat(catList[5])}>Millie</button>
        <button onClick={() => scrollToCat(catList[9])}>Bella</button>
      </nav>
      <div>
        <ul>
          {catList.map((cat) => (
            <li
              key={cat}
              ref={(node) => {
                const map = getMap();
                map.set(cat, node);

                return () => {
                  map.delete(cat);
                };
              }}
            >
              <img src={cat} />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

function setupCatList() {
  const catList = [];
  for (let i = 0; i < 10; i++) {
    catList.push("https://loremflickr.com/320/240/cat?lock=" + i);
  }

  return catList;
}

在此範例中,itemsRef 並未持有單個 DOM 節點。相反地,它持有一個從項目 ID 到 DOM 節點的 Map。(Refs 可以持有任何值!)每個列表項目的 ref 回呼函式 會負責更新 Map

<li
key={cat.id}
ref={node => {
const map = getMap();
// Add to the Map
map.set(cat, node);

return () => {
// Remove from the Map
map.delete(cat);
};
}}
>

這讓您稍後可以從 Map 中讀取個別的 DOM 節點。

注意事項

啟用嚴格模式時,ref 回呼函式在開發過程中會執行兩次。

閱讀更多關於 如何在開發過程中透過重新執行 ref 回呼函式來查找錯誤 的資訊。

存取其他組件的 DOM 節點

當您將 ref 放在輸出瀏覽器元素的內建組件上,例如 <input /> 時,React 會將該 ref 的 current 屬性設定為對應的 DOM 節點(例如瀏覽器中的實際 <input />)。

然而,如果您嘗試將 ref 放在**您自己的**組件上,例如 <MyInput />,預設情況下您將會得到 null。以下是一個示範的範例。請注意,點擊按鈕**並不會**讓輸入框聚焦

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

為了幫助您注意到這個問題,React 也會在主控台印出錯誤訊息

主控台
警告:函式組件不能被賦予 refs。嘗試存取此 ref 將會失敗。您是否 meant to use React.forwardRef()?

這是因為預設情況下,React 不允許組件存取其他組件的 DOM 節點。即使是它自己的子組件也不行!這是故意的。Refs 是一個應該謹慎使用的逃生出口。手動操作*其他*組件的 DOM 節點會讓您的程式碼更加脆弱。

相反地,*想要*公開其 DOM 節點的組件必須**選擇加入**此行為。組件可以指定它將其 ref「轉發」給其子組件之一。以下是如何讓 MyInput 使用 forwardRef API 的方法

const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});

它的運作方式如下

  1. <MyInput ref={inputRef} /> 告訴 React 將對應的 DOM 節點放入 inputRef.current 中。然而,是否要選擇加入此行為取決於 MyInput 組件——預設情況下,它不會選擇加入。
  2. MyInput 組件是使用 forwardRef 宣告的。**這讓它選擇接收來自上方 inputRef 作為第二個 ref 參數**,該參數在 props 之後宣告。
  3. MyInput 元件本身會將它接收到的 ref 傳遞給它內部的 <input> 元素。

現在點擊按鈕來聚焦輸入框可以正常運作了

import { forwardRef, useRef } from 'react';

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

在設計系統中,常見的模式是低階元件(如按鈕、輸入框等)將它們的 ref 轉發到它們的 DOM 節點。另一方面,高階元件(如表單、列表或頁面區塊)通常不會暴露它們的 DOM 節點,以避免意外地依賴 DOM 結構。

深入探討

使用指令式控制代碼暴露 API 的子集

在上面的例子中,MyInput 暴露了原始的 DOM 輸入元素。這讓父元件可以在其上調用 focus()。然而,這也讓父元件可以做其他事情——例如,更改它的 CSS 樣式。在不常見的情況下,您可能希望限制暴露的功能。您可以使用 useImperativeHandle 來做到這一點。

import {
  forwardRef, 
  useRef, 
  useImperativeHandle
} from 'react';

const MyInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    // Only expose focus and nothing else
    focus() {
      realInputRef.current.focus();
    },
  }));
  return <input {...props} ref={realInputRef} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

這裡,MyInput 內部的 realInputRef 持有實際的輸入 DOM 節點。然而,useImperativeHandle 指示 React 將您自己的特殊物件作為 ref 的值提供給父元件。因此,Form 元件內的 inputRef.current 將只具有 focus 方法。在這種情況下,ref 的「控制代碼」不是 DOM 節點,而是您在 useImperativeHandle 調用內創建的自定義物件。

當 React 附加 ref 時

在 React 中,每次更新都分為兩個階段

  • 在**渲染**階段,React 會調用您的元件來確定螢幕上應該顯示的內容。
  • 在**提交**階段,React 將更改應用到 DOM。

一般來說,您不希望在渲染期間訪問 ref。這也適用於持有 DOM 節點的 ref。在第一次渲染期間,DOM 節點尚未創建,因此 ref.current 將為 null。在更新的渲染期間,DOM 節點尚未更新。所以讀取它們還為時過早。

React 在提交階段設置 ref.current。在更新 DOM 之前,React 將受影響的 ref.current 值設置為 null。更新 DOM 後,React 立即將它們設置為對應的 DOM 節點。

**通常,您會從事件處理程序中訪問 ref。** 如果您想對 ref 執行某些操作,但沒有特定的事件可以執行此操作,則您可能需要一個 Effect。我們將在後續頁面中討論 Effect。

深入探討

使用 flushSync 同步刷新狀態更新

考慮以下程式碼,它會新增一個新的待辦事項,並將螢幕向下滾動到列表的最後一個子項。請注意,由於某種原因,它始終滾動到*緊接在*最後新增的待辦事項*之前*的待辦事項

import { useState, useRef } from 'react';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    setText('');
    setTodos([ ...todos, newTodo]);
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

問題出在這兩行

setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();

在 React 中,狀態更新會被排入佇列。 通常,這就是您想要的。然而,在這裡它會導致問題,因為 setTodos 不會立即更新 DOM。因此,當您將列表滾動到其最後一個元素時,待辦事項尚未新增。這就是為什麼滾動總是「落後」一個項目的原因。

要解決此問題,您可以強制 React 同步更新(「刷新」)DOM。為此,請從 react-dom 導入 flushSync,並將**狀態更新包裝**在 flushSync 調用中

flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();

這將指示 React 在 flushSync 包裝的程式碼執行後立即同步更新 DOM。因此,當您嘗試滾動到最後一個待辦事項時,它已位於 DOM 中

import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    flushSync(() => {
      setText('');
      setTodos([ ...todos, newTodo]);      
    });
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

使用 ref 操作 DOM 的最佳實務

Ref 是一個逃生艙口。您只應在必須「跳出 React」時才使用它們。常見的例子包括管理焦點、滾動位置或調用 React 未暴露的瀏覽器 API。

如果您堅持使用非破壞性操作(如聚焦和滾動),則不應遇到任何問題。但是,如果您嘗試手動**修改** DOM,則可能會與 React 正在進行的更改發生衝突。

為了說明此問題,此範例包含一條歡迎訊息和兩個按鈕。第一個按鈕使用條件渲染狀態來切換其存在,就像您通常在 React 中所做的那樣。第二個按鈕使用remove() DOM API 將其強制從 React 控制之外的 DOM 中移除。

嘗試按幾次「使用 setState 切換」。訊息應該消失然後再次出現。然後按「從 DOM 中移除」。這將強制移除它。最後,按「使用 setState 切換」

import { useState, useRef } from 'react';

export default function Counter() {
  const [show, setShow] = useState(true);
  const ref = useRef(null);

  return (
    <div>
      <button
        onClick={() => {
          setShow(!show);
        }}>
        Toggle with setState
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        Remove from the DOM
      </button>
      {show && <p ref={ref}>Hello world</p>}
    </div>
  );
}

手動移除 DOM 元素後,嘗試使用 setState 再次顯示它將導致崩潰。這是因為您更改了 DOM,而 React 不知道如何繼續正確管理它。

**避免更改 React 管理的 DOM 節點。** 修改、向其中添加子節點或從 React 管理的元素中移除子節點可能會導致視覺效果不一致或如上所述的崩潰。

但是,這並不意味著您根本無法這樣做。它需要小心。**您可以安全地修改 React *沒有理由* 更新的 DOM 部分。** 例如,如果某些 <div> 在 JSX 中始終為空,則 React 沒有理由觸及其子節點列表。因此,在此處手動添加或移除元素是安全的。

重點回顧

  • Ref 是一個通用的概念,但大多數情況下,您會使用它們來保存 DOM 元素。
  • 您可以通過傳遞 `<div ref={myRef}>,指示 React 將 DOM 節點放入 myRef.current 中。
  • 通常,您會將 ref 用於非破壞性操作,例如聚焦、滾動或測量 DOM 元素。
  • 組件預設不會公開其 DOM 節點。您可以選擇使用 forwardRef 並將第二個 ref 參數向下傳遞到特定節點來公開 DOM 節點。
  • 避免更改由 React 管理的 DOM 節點。
  • 如果您確實修改了由 React 管理的 DOM 節點,請修改 React 沒有理由更新的部分。

挑戰 1 4:
播放和暫停影片

在此範例中,按鈕會切換狀態變數以在播放和暫停狀態之間切換。但是,若要實際播放或暫停影片,僅切換狀態是不夠的。您還需要在 <video> 的 DOM 元素上呼叫 play()pause()。新增一個 ref 到它,並讓按鈕可以運作。

import { useState, useRef } from 'react';

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);

  function handleClick() {
    const nextIsPlaying = !isPlaying;
    setIsPlaying(nextIsPlaying);
  }

  return (
    <>
      <button onClick={handleClick}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <video width="250">
        <source
          src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
          type="video/mp4"
        />
      </video>
    </>
  )
}

額外的挑戰是,即使使用者在影片上按滑鼠右鍵並使用內建的瀏覽器媒體控制項播放影片,也要保持「播放」按鈕與影片是否正在播放同步。您可能需要監聽影片上的 onPlayonPause 事件來做到這一點。