iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
自我挑戰組

React 入門到實作與除錯|30 天哩ㄟ刻系列 第 21 篇

Day 21 : 聚光燈輪到你,用 ref 操作 DOM

  • 分享至 

  • xImage
  •  

網購也是這樣一步一步的聚焦操作讓我買一堆?

今天目標

  1. 認識 useRef
  2. 執行計數觀察
  3. 按鈕聚焦輸入框
  4. 執行觀察
  5. 清空並重新聚焦
  6. 錯誤修正練習

飲料訂單算是有個簡單的雛形了,現在我們想提供一個「填寫取餐姓名」按鈕按下後,鍵盤輸入的位置直接移到姓名欄位。

認識 ref

瀏覽器會用 DOM 表示網頁結構,輸入框的焦點在畫面上的 <input> 有對應的 DOM 節點,透過 ref 取得這個節點後,就能呼叫瀏覽器提供的 focus()。

React:Manipulating the DOM with Refs

current

current 是 ref 物件裡,用來存放資料的屬性,來看範例:

const countRef = useRef(0);

首先 useRef(0) 會取得一個 ref 物件,初始的 current 是 0,在同一個元件持續掛載期間,重新渲染會取得同一個 ref 物件,具有保留資料的特性。

React:useRef

跟 state 哪裡不同:

ref 在事件函式裡讀取或修改 countRef.current,賦值後就能讀到新值,但修改本身不會要求 React 重新渲染。

如果資料需要持續反映在畫面上,還是使用 state。

React:Referencing Values with Refs

來比較一下區域變數、state、ref:

方式 重新渲染後 修改方式
元件函式內的普通區域變數 函式重新執行時重新宣告、計算 直接賦值,不要求渲染
state React 保留狀態 呼叫 setter,React 安排更新;相同值可能略過
ref React 保留同一個 ref 物件 修改 current,不要求渲染

ref 和 state 都不是永久儲存,如果整頁重新載入,或元件移除後重新掛載,都會建立新的資料。

執行計數觀察

現在我們加入一個 ref 玩看看吧。

加入 useRef

將 drink-order.tsx 的 React 匯入替換為:

import {
  createContext,
  useContext,
  useReducer,
  useRef,
  useState,
  type SubmitEvent,
} from "react";

讓 ref 計數,讓 state 顯示讀取結果

在 export function DrinkOrder() 前新增以下完整元件,與 DrinkOrder 分別宣告在檔案最外層:

// 示意片段
function RefCounterProbe() {
  const countRef = useRef(0);
  const [reportedCount, setReportedCount] = useState(0);

  function handleIncrement() {
    countRef.current += 1;
    console.log("[ref 計數]", countRef.current);
  }

  function handleRead() {
    setReportedCount(countRef.current);
  }

  return (
    <section aria-labelledby="ref-probe-heading">
      <h2 id="ref-probe-heading">ref 計數觀察</h2>
      <p>上次讀取的計數:{reportedCount}</p>
      <button type="button" onClick={handleIncrement}>
        ref 加一(看 Console)
      </button>
      <button type="button" onClick={handleRead}>
        讀取 ref 到畫面
      </button>
    </section>
  );
}

在 DrinkOrder 的 </header> 後加入:

<RefCounterProbe />

上面的 countRef 負責累加,畫面顯示的是 reportedCount state,按「讀取 ref 到畫面」時,才將當下計數交給 setter,這樣能比較「值已經改變」與「要求畫面顯示新值」兩件事。

按鈕聚焦輸入框

除了保留資料之外,也能利用 ref 讓 current 保存 DOM 節點的參考。

建立 DOM ref

在 DrinkOrder 的 Hook 區塊、原本 customerName state 旁新增:

const customerNameRef = useRef<HTMLInputElement>(null);

上面 HTMLInputElement 表示這個 ref 要連接輸入框的 DOM 節點,初始為 null,React 將輸入框放進 DOM 後,才會設定 current,當節點移除時,React 會把它設回 null。

React:Getting a ref to the node

把 ref 接到原本的 input

找到 id="customer-name" 的 <input>,只在它的屬性中新增 ref={customerNameRef} :

<input
  ref={customerNameRef}
  id="customer-name"
  name="customerName"
  type="text"
  required
  value={customerName}
  onChange={(event) => setCustomerName(event.target.value)}
/>

在點擊事件呼叫 focus

在 DrinkOrder 裡、handleSelectOrder 前新增事件函式:

function handleFocusCustomerName() {
  if (isLocked) return;
  customerNameRef.current?.focus();
}

?. 是可選串連:current 是 null 時就跳過呼叫,避免操作不存在的節點。

在「取餐資料」的 <h2 id="pickup-heading"> 後面,姓名欄位的 <div> 前新增:

<button type="button" onClick={handleFocusCustomerName}>
  填寫取餐姓名
</button>

按鈕和姓名欄位都在既有的 fieldset disabled={isLocked} 裡,會一起停用,type="button" 會使點擊只處理焦點,焦點變化會由瀏覽器處理,不會觸發表單送出。

React 提交姓名 input 到 DOM
  -> 將節點放入 customerNameRef.current
  
使用者按「填寫取餐姓名」
  -> 事件讀取 current
  -> 呼叫瀏覽器的 focus()
  -> 鍵盤輸入位置移到姓名欄位

執行觀察

按三次 ref 加一

https://ithelp.ithome.com.tw/upload/images/20261005/20184332zMsm0KbcNu.png

可以看到計數維持 0,但 Console 裡面確實有計數。

按讀取 ref 到畫面

https://ithelp.ithome.com.tw/upload/images/20261005/20184332wEc80ymvRN.png

按下讀取 ref 到畫面後,畫面上的計數會更新成 3。

再按一次 ref 加一

https://ithelp.ithome.com.tw/upload/images/20261005/20184332LYmqaiB664.png

可以明顯的發現兩邊數字不同,但這是因為 ref 的更新不會重新渲染畫面的關係,實際上數字還是有更新。

重點是:ref 改變不會要求畫面更新,而且 state 更新造成重新渲染後,ref 還是可以從 3 繼續加到 4。

按下後直接輸入姓名

按「填寫取餐姓名」,姓名輸入框會聚焦。

https://ithelp.ithome.com.tw/upload/images/20261005/20184332jpQrB0CUcj.png

清空並重新聚焦

姓名重填,內容與焦點分別處理

現在如果需求改成:「保留訂單與備註,提供另一個按鈕,清空取餐姓名並讓使用者直接重填。」

首先在 handleFocusCustomerName 後新增:

function handleClearCustomerName() {
  if (isLocked) return;
  setCustomerName("");
  customerNameRef.current?.focus();
}

在「填寫取餐姓名」按鈕後新增:

<button type="button" onClick={handleClearCustomerName}>
  清空姓名並聚焦
</button>

setCustomerName("") 是更新受控欄位的資料,focus() 會操作已經存在的輸入框。注意這裡不用等待 state 更新完成才呼叫 focus(),因為清空文字不會移除或換掉這個節點。

如果操作是「把原本不存在的輸入框顯示出來,再立刻聚焦」,再另外考慮 DOM 的提交時機。

React:When React attaches the refs

避免繞過受控欄位的 state

直覺上使用 customerNameRef.current.value = "" 取代 setter 會比較快,但其實直接改 DOM 的值並不會更新 customerName,即時摘要與送出條件可能還是使用舊姓名,會造成資料和畫面不一致。

React:Best practices for DOM manipulation with refs

錯誤修正練習

假如有個情況是忘記連結 ref:

// 錯誤示意
<input
  id="customer-name"
  name="customerName"
  type="text"
  required
  value={customerName}
  onChange={(event) => setCustomerName(event.target.value)}
/>

在編輯狀態按聚焦按鈕,焦點不會移到姓名欄位,?.focus() 也沒有拋出例外。

這是因為 useRef(null) 沒有 JSX 上的 ref 連接,React 不知道這個 ref 要指向哪一個節點,而 ?. 只能避免空值呼叫,並不保證功能。

發生聚焦失敗的話,就記得加一下吧。


今天透過事件操作已經存在的 DOM。明天接著學 Effect,處理隨元件狀態或掛載情況與外部系統同步的工作。

參考資料

附錄修改片段

匯入 useRef

將 React 匯入替換為:

import {
  createContext,
  useContext,
  useReducer,
  useRef,
  useState,
  type SubmitEvent,
} from "react";

Hook 在元件最上層呼叫,不放進事件、條件或迴圈裡。

加入計數觀察元件

在 export function DrinkOrder() 前新增以下完整元件:

function RefCounterProbe() {
  const countRef = useRef(0);
  const [reportedCount, setReportedCount] = useState(0);

  function handleIncrement() {
    countRef.current += 1;
    console.log("[ref 計數]", countRef.current);
  }

  function handleRead() {
    setReportedCount(countRef.current);
  }

  return (
    <section aria-labelledby="ref-probe-heading">
      <h2 id="ref-probe-heading">ref 計數觀察</h2>
      <p>上次讀取的計數:{reportedCount}</p>
      <button type="button" onClick={handleIncrement}>
        ref 加一(看 Console)
      </button>
      <button type="button" onClick={handleRead}>
        讀取 ref 到畫面
      </button>
    </section>
  );
}

在 DrinkOrder 的 JSX 中,</header> 後、顯示設定的 <section> 前插入:

<RefCounterProbe />

建立姓名 ref 與聚焦事件

在 DrinkOrder 的 Hook 區塊、customerName state 下一行新增:

const customerNameRef = useRef<HTMLInputElement>(null);

在同一個元件的 handleSelectOrder 前新增:

function handleFocusCustomerName() {
  if (isLocked) return;
  customerNameRef.current?.focus();
}

連接原本輸入框與按鈕

找到 id="customer-name" 的 input,新增 ref 屬性:

<input
  ref={customerNameRef}
  id="customer-name"
  name="customerName"
  type="text"
  required
  value={customerName}
  onChange={(event) => setCustomerName(event.target.value)}
/>

保留原本 label,不要新增第二個姓名欄位。在 <h2 id="pickup-heading"> 後、姓名欄位的 <div> 前插入:

<button type="button" onClick={handleFocusCustomerName}>
  填寫取餐姓名
</button>

按鈕與 input 都保留在原本 fieldset disabled={isLocked} 內。

加入清空姓名的修改

在 handleFocusCustomerName 後新增:

function handleClearCustomerName() {
  if (isLocked) return;
  setCustomerName("");
  customerNameRef.current?.focus();
}

在「填寫取餐姓名」按鈕後新增:

<button type="button" onClick={handleClearCustomerName}>
  清空姓名並聚焦
</button>

上一篇
Day 20 : 注意還動,Context 跨層傳遞資料
下一篇
Day 22 : 查詢天氣,用 Effect 同步資料與清理
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言