iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 27
4
Modern Web

從 Hooks 開始,讓你的網頁 React 起來系列 第 27

[Day 27 - 即時天氣] React 中的表單處理(Controlled vs Uncontrolled)以及 useRef 的使用

  • 分享至 

  • twitterImage
  •  

感謝 iT 邦幫忙與博碩文化,本系列文章已出版成書「從 Hooks 開始,讓你的網頁 React 起來」,首刷版稅將全額贊助 iT 邦幫忙鐵人賽,歡迎前往購書,鼓勵筆者撰寫更多優質文章。

keywords: useRef, controlled components, uncontrolled components, form

昨天已經建立好了天氣地區的設定頁面,今天要來說明在 React 中基本的表單處理以及 useRef 的使用。

不知道你是否還有印象,之前在「Day 10 - 換算起來吧 - 資料綁定與組件拆分」網速換算器的單元中,我們就曾經使用過表單元素 <input type="number" />,當時透過 onChange 搭配 setState 的方式來操作表單的資料,但在當時我們還沒有進一步說明 React 中表單處理的概念。

Controlled vs Uncontrolled Components

在 React 中表單元素的處理主要可以分成兩種 Controlled 和 Uncontrolled 這兩種,這裡關於 Controlled 和 Uncontrolled 指的是「資料受不受到 React 所控制」,也就是「受 React 所控制的資料(Controlled)」或「不受 React 所控制的資料(Uncontrolled)」

之所以在表單元素上會區分「受 React 控制的資料」和「不受 React 控制的資料」,主要是因為在瀏覽器中,像是 <input /> 這類的表單元素本身就可以保有自己的資料狀態,這也就是爲什麼當我們在 <input /> 中輸入文字後,可以直接透過 JavaScript 選到該 input 元素後,再取出該元素的值,因為使用者輸入的內容(資料)可以直接保存在 <input /> 元素內。

以下面程式碼舉例來說:

  • 我們可以透過 document.querySelector 選到該表單元素
  • 透過該元素的 value 屬性,就可以知道該 <input /> 欄位中填入的值為何
<input type="text" id="name"/>

<script>
  const inputName = document.querySelector("#name");
  inputName.addEventListener("input", e => console.log(e.target.value));
</script>

但到了 React 時,React 就可以幫我們處理資料狀態,我們可以把表單內使用者輸入的資料交給 React,在使用者輸入資料的同時驗證使用者輸入內容的有效性,並做瀏覽器畫面的更新(例如,輸入的內容有誤時跳出提示訊息)。

這種把表單資料交給 React 來處理的就稱作 Controlled Components,也就是受 React 控制的資料;相對地,如果不把表單資料交給 React,而是像過去一樣,選取到該表單元素後,才從該表單元素取出值的這種做法,就稱作 Uncontrolled Components,也就是不受 React 控制的資料

針對表單元素, React 會建議我們使用 Controlled Components,基本上使用 Controlled Components 和 Uncontrolled Components 都能達到一樣或類似的效果,但是當我們需要對資料有更多的控制或提示畫面的處理時,使用 Controlled Components 會來得容易的多。

⚠️ 提醒:多數的表單元素都可以交給 React 處理,除了上傳檔案用的 <input type="file" /> 例外,因為該元素有安全性的疑慮,JavaScript 只能取值而不能改值,也就是透過 JavaScript 可以知道使用者選擇要上傳的檔案為何(取值),但不能去改變使用者要上傳的檔案(改值)。因此對於檔案上傳用的 <input type="file" /> 只能透過 Uncontrolled Components 的方式處理

現在就來讓我們看看如何實作這兩者表單處理的方式。一樣可以先在 CodeSandbox 打開昨天完成的程式碼 Weather APP - Add Setting Page,複製一份來開始今天的練習。

今天我們會練習表單的處理,當使用者在天氣設定頁點選「儲存」的時候,可以在瀏覽器的開發者工具中看到使用者欲儲存的地區,而實際上資料的保存則會到明天再繼續說明。

Controlled Components

針對表單元素,React 會建議我們使用 Controlled Components,也就是把表單的資料儲存在該 React 組件內交給他來處理,這個做法就和先前在「Day 10 - 換算起來吧 - 資料綁定與組件拆分」網速換算器中使用的方式相同。

將資料交給 React

因為要將資料交給 React 處理,所以會先透過 useState 來建立保存資料狀態的地方,接著在表單元素上透過 onChange 事件來取得該表單元素當前的值,並且馬上更新到 React 組件的資料狀態內。

套用到天氣設定頁面就像這樣:

  1. 從 react 中載入 useState
  2. 透過 useState 取得 locationNamesetLocationName,將預設值先設為空
  3. 使用 onChange 事件來監聽使用者輸入的資料,並且當事件觸發時呼叫 handleChange
  4. 定義 handleChange 函式,當使用者輸入資料時,把資料內容透過 setLocation 更新 React 內部的資料狀態

提示:這裡不把資料狀態取名為 location 的原因在於瀏覽器本身就有一個 window.location 物件,因此當你直接在瀏覽器的 console 面版中輸入 location 是會得到內容的,為了避免可能的錯誤,取名為 locationName

// ./src/WeatherSetting.js

// STEP 1:從 react 中載入 useState
import React, { useState } from 'react';
// ...

const WeatherSetting = ({ setCurrentPage }) => {
  // STEP 2:定義 locationName,預設值先帶為空
  const [locationName, setLocationName] = useState('');

  // STEP 4:定義 handleChange 要做的事
  const handleChange = (e) => {
    console.log(e.target.value);

    // STEP 5:把使用者輸入的內容更新到 React 內的資料狀態
    setLocationName(e.target.value);
  };

  return (
    <WeatherSettingWrapper>
      {console.log('render')}
      <Title>設定</Title>
      <StyledLabel htmlFor="location">地區</StyledLabel>
      {/* STEP 3:使用 onChange 事件來監聽使用者輸入資料 */}
      <StyledInputList
        list="location-list"
        id="location"
        name="location"
        onChange={handleChange}
      />
      {/* ... */}
    </WeatherSettingWrapper>
  );
};

export default WeatherSetting;

這裡可以留意的是,每當我們在表單中輸入內容時,因為會呼叫到 setLocationName 方法,所以實際上畫面只要使用者輸入的內容有變動,畫面就會重新渲染,有興趣的話可以在 return 內透過 console.log('render') 來觀察一下:

Imgur

建立點擊儲存後的行為流程

先來建立使用者點擊儲存後的操作流程,我們希望當使用者輸入的是有效的(中央氣象局 API 有提供資料)地區時,才將資料保存下來,否則顯示錯誤提示:

  1. 定義 handleSave 方法,用來處理當使用者點擊儲存時要做的事
  2. 陣列中有列出的地區才是中央氣象局 API 有支援的地區,因此在 handleSave 中透過陣列的 includes 方法來判斷使用者輸入的資料有無包含在該陣列中
  3. 若使用者輸入的內容有包含在 API 支援的地區陣列中,將會把該資料保存下來(目前未做),並透過 setCurrentPage 導回到天氣資訊頁
  4. 若使用者輸入的地區, API 並不支援的話,則透過 alert 顯示錯誤訊息,並停在原頁面
  5. handleSave 函式綁定在儲存按鈕的點擊 onClick 事件上
// ./src/WeatherSetting.js

// ...
const locations = [
  '嘉義縣', '新北市', '嘉義市', '新竹縣', '新竹市',
  '臺北市', '臺南市', '宜蘭縣', '苗栗縣', '雲林縣',
  '花蓮縣', '臺中市', '臺東縣', '桃園市', '南投縣',
  '高雄市', '金門縣', '屏東縣', '基隆市', '澎湖縣',
  '彰化縣', '連江縣',
];

const WeatherSetting = ({ setCurrentPage }) => {
  const [locationName, setLocationName] = useState('臺北市');

  const handleChange = (e) => {
    setLocationName(e.target.value);
  };

  // STEP 1:定義 handleSave 方法
  const handleSave = () => {
    // STEP 2:判斷使用者填寫的地區是否包含在 locations 陣列內
    if (locations.includes(locationName)) {
      // TODO: 儲存地區資訊...
      console.log(`儲存的地區資訊為:${locationName}`);

      // STEP 3:透過 setCurrentPage 導回天氣資訊頁
      setCurrentPage('WeatherCard');
    } else {
      // STEP 4:若不包含在 locations 內,則顯示錯誤提示
      alert(`儲存失敗:您輸入的 ${locationName} 並非有效的地區`);
      return;
    }
  };

  return (
    <WeatherSettingWrapper>
      {/* ... */}

      <ButtonGroup>
        <Back onClick={() => setCurrentPage('WeatherCard')}>返回</Back>
        {/* STEP 5:將 handleSave 綁定在 onClick 事件 */}
        <Save onClick={handleSave}>儲存</Save>
      </ButtonGroup>
    </WeatherSettingWrapper>
  );
};

export default WeatherSetting;

現在的畫面就會像下面這樣,若輸入的是有效地區則會導回到天氣資訊頁,否則會顯示錯誤提示:

Imgur

現在因為還沒實際儲存地區資訊,所以回到天氣資訊頁時仍然會看到寫「臺北」,感覺怪怪的,我們會在明天接著來處理這個部分。

帶入資料預設值

假設使用者前一次已經有儲存過地區資料,當使用者在進到設定頁的時,地區欄位應該就會直接帶出上一次他填寫的資訊,在 Controlled Components 中可以怎麼做呢?

若要讓這個地區欄位的 <input> 在頁面呈現時就帶有預設值得話,我們可以直接在 <input> 中加上 value 屬性,React 會自動把這個 value 帶入的值當作該欄位的預設值呈現出來。

舉例來說,若想要讓使用者一進到設定頁時就看到「臺北市」已經被選擇的話,可以這麼做:

  1. useState 的地方把 locationName 的預設值設為「臺北市」
  2. 透過 value 屬性就可以把該 <input> 欄位帶入預設值,如果是 <select> 或這裡使用的 <datalist> 元素,React 會很聰明的直接和 <option> 欄位內的 value 做對應並呈現出來
// ./src/WeatherSetting.js
// ...

const WeatherSetting = ({ setCurrentPage }) => {
  // STEP 1:讓 locationName 的預設值為 '臺北市'
  const [locationName, setLocationName] = useState('臺北市');

  // ...
  return (
    <WeatherSettingWrapper>
      {/* ... */}

      {/* STEP 2:透過 value 帶入該欄位的預設值 */}
      <StyledInputList
        // ...
        value={locationName}
      />
      <datalist id="location-list">
        {locations.map((location) => (
          <option value={location} key={location} />
        ))}
      </datalist>

      {/* ... */}
    </WeatherSettingWrapper>
  );
};

export default WeatherSetting;

這時候當使用者一進到設定頁面時,地區就會帶有預設值。你也可以試著透過修改 value 內的值來嘗試改變表單預設呈現的資料:

Imgur

Uncontrolled Components - useRef 的使用

上面使用的是 Controlled Component 的作法,一般來說除非是 <input type="file"> 這種檔案上傳的欄位之外,多會使用 Controlled Component 來做。

但有些時候可能只是想要很簡單的去取得表單中某個欄位的值,或者是有一些情況下需要直接操作 DOM(例如,音樂播放器中有許多方法是直接綁在 <video> 元素上的),這時可以這麼做呢?

前面有提到 Uncontrolled Components 並不會把資料交給 React 管理,而是自己選到該 <input /> 元素後去從該 DOM 元素中把值取出來。在 React 中若想要選取到某一元素時,就可以使用 useRef 這個 React Hooks。

這裡我們就同樣以 WeatherSetting 這個表單為例,只是這次把它作為 Uncontrolled Components 搭配 useRef 來使用。

useRef 的基本用法

useRef 的基本用法如下:

  • useRef 內可以放進一個預設值(initialValue)
  • useRef 會回傳一個物件(refContainer),這個物件不會隨著每一次畫面重新渲染而指稱到不同的物件,而是可以一直指稱到同一個物件
  • 在回傳的物件中,透過 refContainer.current 屬性可以取得預設值或更動後的值
const refContainer = useRef(initialValue);

如果是要把 useRef 當成 document.querySelector 來選取到某一元素的話,可以在該 HTML 元素上使用 ref 屬性,並把 useRef 回傳的物件放進去即可,例如:

const InputElement = () => (
  <input ref={refContainer} />
)

這時候剛剛建立的 refContainer.current 就會指稱到這個 <input> 元素。

套用到設定頁面

讓我們實際套用到地區設定頁面來看看可以怎麼用:

  1. 先從 react 中取出 useRef 這個 Hook 來用
  2. 使用 useRef 來建立可以一直被參照到的物件,將這個回傳的物件取名為 inputLocationRef
  3. <input> 的地方,不需要在使用 onChange 事件隨時更新 React 的資料狀態,而是透過 ref={inputLocationRef}inputLocationRef.current 可以指稱到這個 input 欄位
  4. 透過 inputLocationRef.current 即可取得剛剛透過 ref 指稱的元素,並且透過 inputLocationRef.current.value 就可以取得該欄位的值
  5. 對於 Uncontrolled Components 若想要定義預設值,可以在 <input> 欄位中使用 defaultValue
// STEP 1:從 react 中載入 useRef
import React, { useRef } from 'react';
import styled from '@emotion/styled';
// ...

const WeatherSetting = ({ setCurrentPage }) => {
  // STEP 2:使用 useRef 建立一個 ref,取名為 inputLocationRef
  const inputLocationRef = useRef(null);

  const handleSave = () => {
    // STEP 4:
    // 透過 inputLocationRef.current 可以指稱到該 input 元素
    // 透過 inputLocationRef.current.value 即可取得該 input 元素的值
    const locationName = inputLocationRef.current.value;
    console.log(locationName);
    // ...
  };

  return (
    <WeatherSettingWrapper>
      {console.log('render')}
      <Title>設定</Title>
      <StyledLabel htmlFor="location">地區</StyledLabel>

      {/* STEP 3:將 useRef 回傳的物件,指稱為該 input 元素 */}
      {/* STEP 5:在 uncontrolled components 中可以使用 defaultValue 定義預設值 */}
      <StyledInputList
        // ...
        ref={inputLocationRef}
        defaultValue="臺南市"
      />

      <datalist id="location-list">
        {locations.map((location) => (
          <option value={location} key={location} />
        ))}
      </datalist>

      <ButtonGroup>
        <Back onClick={() => setCurrentPage('WeatherCard')}>返回</Back>
        <Save onClick={handleSave}>儲存</Save>
      </ButtonGroup>
    </WeatherSettingWrapper>
  );
};

export default WeatherSetting;

補充:當我們把 useRef 回傳的物件透過 rel 的方式放到 HTML 元素中時,就很像是用 document.querySelector 去選到該元素後,保存在 useRef 回傳物件的 current 屬性內。

現在操作的畫面就會如同先前使用 Controlled Components 的流程一樣,但要特別留意的是:「當 input 欄位內的資料有變動時,並不像 Controlled Component 一樣會促發畫面重新渲染」,因此,若有重新渲染畫面的需求,建議還是使用 Controlled Component 來處理:

Imgur

這部分完整的程式碼一樣可以到 CodeSandbox 上檢視 Weather APP - Setting Page with Uncontrolled Component by useRef

在 Functional Component 中建立不會導致畫面更新的變數 - useRef 的更多說明

實際上 useRef 做的事就是建立並回傳一個帶有 current 屬性的物件,因此和自己去定義一個 { current: '' } 是一樣的,但重要的差別在於,我們知道每一次 React 組件在重新呼叫並渲染時,該組件中的內容雖然看起來一模一樣,但實際上內部的函式和物件其實都是全新獨立的,而透過 useRef 就可以幫助開發者,即使在組建重新渲染後,仍可以去取得同一個物件,並取出內部的值來用

我們曾經在「Day 20 - 在 useEffect 中使用呼叫需被覆用的函式 - useCallback 的使用」中提到,Function Component 每次重新渲染時,函式內容雖然長得一模一樣,但實際上是完全獨立不同的執行環境,而這也是為什麼當時要使用 useCallback 的緣故。

具體來說,useRef 除了可以搭配 ref 指稱到某一 HTML 元素來使用之外,當我們在 React 組件中想要定義一些「變數」,但當這些變數改變時,又不需要像 state 一樣會重新導致畫面渲染的話,就很適合使用 useRef

例如說,有些時候想要看某個組件被重新渲染了幾次,就可以類似這樣寫:

// STEP 1:從 react 中載入 useRef
import React, { useRef } from 'react';

const RefExample = () => {
  // STEP 2:將 renderCount 的預設值設為 0
  const renderCount = useRef(0);

  return (
    <div>
      {/* STEP 3:每次畫面渲染時就將 renderCount.current + 1 */}
      {renderCount.current += 1}

      {/* STEP 4:顯示這是該組件第幾次重新渲染 */}
      {console.log('render', renderCount.current)}
      <h1> Hello, React </h1>
    </div>
  )
}

透過 useRef 便可以在 Functional Component 中定義不會導致畫面重新渲染的變數。

範例程式碼

今天學習到了在 React 中如何透過 Controlled 或 Uncontrolled 的方式進行基本的表單處理,並且了解 useRef 的使用。明天會再說明如何將地區資訊保存下來,並更新資料的拉取。

今天的程式碼一樣可以參考放置於 CodeSandbox 上的連結:

參考資源


上一篇
[Day 26 - 即時天氣] 切換顯示不同頁面 - 子層組件修改父層組件資料狀態的方式
下一篇
[Day 28 - 即時天氣] 保存與更新使用者設定的地區資訊 - localStorage 與 useEffect 的搭配使用
系列文
從 Hooks 開始,讓你的網頁 React 起來30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
37463538
iT邦新手 5 級 ‧ 2021-11-25 18:31:01

在練習 controlled component 設定預設值的部分是在 input tag 裡面使用 value,但是這樣除非先把預設刪掉才可以顯示下拉選單的值,建議改用 placeholder 就可以不用把預設刪掉顯示下拉選單。

我要留言

立即登入留言