使用 State 響應輸入

React 提供了一種宣告式的方式來操作 UI。您无需直接操作 UI 的各个部分,而是描述组件可以处于的不同状态,并根据用户输入在它们之间切换。这类似于设计师思考 UI 的方式。

你將學到

  • 宣告式 UI 程式設計與指令式 UI 程式設計有何不同
  • 如何列舉元件可以處於的不同視覺狀態
  • 如何從程式碼觸發不同視覺狀態之間的變化

宣告式 UI 與指令式 UI 的比較

當您設計 UI 互動時,您可能會考慮 UI 如何響應使用者操作而**改變**。考慮一個允許使用者提交答案的表單

  • 當您在表單中輸入內容時,「提交」按鈕會**變成啟用狀態。**
  • 當您按下「提交」時,表單和按鈕都會**變成停用狀態,**並且會**出現**一個旋轉器。
  • 如果網路請求成功,表單會**被隱藏,**並且會**出現**「謝謝您」訊息。
  • 如果網路請求失敗,會**出現**錯誤訊息,並且表單會再次**變成啟用狀態**。

在**指令式程式設計**中,上述內容直接對應於您如何實作互動。您必須根據發生的情況編寫精確的指令來操作 UI。這裡有另一種思考方式:想像一下坐在車裡的人旁邊,並一步一步地告訴他們要去哪裡。

In a car driven by an anxious-looking person representing JavaScript, a passenger orders the driver to execute a sequence of complicated turn by turn navigations.

插圖作者 Rachel Lee Nabors

他們不知道您想去哪裡,他們只會遵循您的命令。(如果您指示錯誤,您最終會到達錯誤的地方!)它被稱為*指令式*,因為您必須「命令」每個元素,從旋轉器到按鈕,告訴電腦*如何*更新 UI。

在此指令式 UI 程式設計範例中,表單的建構*沒有*使用 React。它只使用瀏覽器 DOM

async function handleFormSubmit(e) {
  e.preventDefault();
  disable(textarea);
  disable(button);
  show(loadingMessage);
  hide(errorMessage);
  try {
    await submitForm(textarea.value);
    show(successMessage);
    hide(form);
  } catch (err) {
    show(errorMessage);
    errorMessage.textContent = err.message;
  } finally {
    hide(loadingMessage);
    enable(textarea);
    enable(button);
  }
}

function handleTextareaChange() {
  if (textarea.value.length === 0) {
    disable(button);
  } else {
    enable(button);
  }
}

function hide(el) {
  el.style.display = 'none';
}

function show(el) {
  el.style.display = '';
}

function enable(el) {
  el.disabled = false;
}

function disable(el) {
  el.disabled = true;
}

function submitForm(answer) {
  // Pretend it's hitting the network.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (answer.toLowerCase() === 'istanbul') {
        resolve();
      } else {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      }
    }, 1500);
  });
}

let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;

以指令式操作 UI 對於單獨的範例來說效果很好,但在更複雜的系統中管理起來會變得越來越困難。想像一下更新一個充滿各種表單的頁面,就像這個一樣。新增新的 UI 元素或新的互動需要仔細檢查所有現有程式碼,以確保您沒有引入錯誤(例如,忘記顯示或隱藏某些內容)。

React 的構建就是為了要解決這個問題。

在 React 中,您不會直接操作 UI,也就是說,您不會直接啟用、停用、顯示或隱藏元件。相反地,您**宣告您想要顯示的內容,**React 會找出如何更新 UI。想像一下搭計程車並告訴司機您想去哪裡,而不是告訴他們確切的轉彎位置。到達目的地是司機的工作,他們甚至可能知道一些您沒有考慮過的捷徑!

In a car driven by React, a passenger asks to be taken to a specific place on the map. React figures out how to do that.

插圖作者 Rachel Lee Nabors

以宣告式思考 UI

您已經瞭解如何在上面以指令式實作表單。為了更好地理解如何在 React 中思考,您將在下面逐步 reimplement 這個 UI 在 React 中

  1. **識別**您的元件的不同視覺狀態
  2. **判斷**觸發這些狀態變化的因素
  3. 使用 useState 在記憶體中**表示**狀態
  4. **移除**任何非必要的狀態變數
  5. **連接**事件處理程式以設定狀態

步驟 1:識別您的元件的不同視覺狀態

在電腦科學中,您可能會聽到「狀態機」處於多種「狀態」之一的說法。狀態機。如果您與設計師合作,您可能已經看過不同「視覺狀態」的模型。React 位於設計和電腦科學的交匯點,因此這兩個概念都是靈感的來源。

首先,您需要視覺化使用者可能看到的所有不同 UI「狀態」

  • 空白:表單具有停用的「提交」按鈕。
  • 輸入中:表單具有啟用的「提交」按鈕。
  • 提交中:表單已完全停用。顯示旋轉器。
  • 成功:顯示「謝謝」訊息而不是表單。
  • 錯誤:與輸入中狀態相同,但會顯示額外的錯誤訊息。

就像設計師一樣,在新增邏輯之前,您需要為不同的狀態「製作模型」或建立「模型」。例如,這裡只有一個表單視覺部分的模型。此模型由名為 status 的屬性控制,預設值為 'empty'

export default function Form({
  status = 'empty'
}) {
  if (status === 'success') {
    return <h1>That's right!</h1>
  }
  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form>
        <textarea />
        <br />
        <button>
          Submit
        </button>
      </form>
    </>
  )
}

您可以隨意命名該屬性,命名並不重要。試著將 status = 'empty' 修改為 status = 'success' 來查看成功訊息。製作模型讓您可以在連接任何邏輯之前快速迭代 UI。這是同一個組件的更完整原型,仍然由 status 屬性「控制」

export default function Form({
  // Try 'submitting', 'error', 'success':
  status = 'empty'
}) {
  if (status === 'success') {
    return <h1>That's right!</h1>
  }
  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form>
        <textarea disabled={
          status === 'submitting'
        } />
        <br />
        <button disabled={
          status === 'empty' ||
          status === 'submitting'
        }>
          Submit
        </button>
        {status === 'error' &&
          <p className="Error">
            Good guess but a wrong answer. Try again!
          </p>
        }
      </form>
      </>
  );
}

深入探討

一次顯示多個視覺狀態

如果一個組件有很多視覺狀態,將它們全部顯示在一個頁面上會很方便

import Form from './Form.js';

let statuses = [
  'empty',
  'typing',
  'submitting',
  'success',
  'error',
];

export default function App() {
  return (
    <>
      {statuses.map(status => (
        <section key={status}>
          <h4>Form ({status}):</h4>
          <Form status={status} />
        </section>
      ))}
    </>
  );
}

像這樣的頁面通常被稱為「活樣式指南」或「故事書」。

步驟 2:確定觸發這些狀態變化的因素

您可以觸發狀態更新以回應兩種輸入

  • 人為輸入,例如點擊按鈕、在欄位中輸入文字、瀏覽連結。
  • 電腦輸入,例如網路回應到達、逾時完成、圖片載入。
A finger.
人為輸入
Ones and zeroes.
電腦輸入

插圖作者 Rachel Lee Nabors

在兩種情況下,您都必須設定狀態變數來更新 UI。 對於您正在開發的表單,您需要根據一些不同的輸入來更改狀態

  • 更改文字輸入(人為)應將其從_空白_狀態切換到_輸入中_狀態,反之亦然,具體取決於文字方塊是否為空。
  • 點擊提交按鈕(人為)應將其切換到_提交中_狀態。
  • 成功的網路回應(電腦)應將其切換到_成功_狀態。
  • 失敗的網路回應(電腦)應將其切換到_錯誤_狀態並顯示相應的錯誤訊息。

備註

請注意,人為輸入通常需要事件處理器

為了幫助視覺化此流程,請嘗試在紙上將每個狀態繪製為帶標籤的圓圈,並將兩個狀態之間的每次變化繪製為箭頭。您可以用這種方式繪製許多流程,並在實作之前解決錯誤。

Flow chart moving left to right with 5 nodes. The first node labeled 'empty' has one edge labeled 'start typing' connected to a node labeled 'typing'. That node has one edge labeled 'press submit' connected to a node labeled 'submitting', which has two edges. The left edge is labeled 'network error' connecting to a node labeled 'error'. The right edge is labeled 'network success' connecting to a node labeled 'success'.
Flow chart moving left to right with 5 nodes. The first node labeled 'empty' has one edge labeled 'start typing' connected to a node labeled 'typing'. That node has one edge labeled 'press submit' connected to a node labeled 'submitting', which has two edges. The left edge is labeled 'network error' connecting to a node labeled 'error'. The right edge is labeled 'network success' connecting to a node labeled 'success'.

表單狀態

步驟 3:使用 useState 在記憶體中表示狀態

接下來,您需要使用 useState 在記憶體中表示組件的視覺狀態。簡潔是關鍵:每個狀態都是一個「活動部件」,而您希望盡可能減少「活動部件」的數量。 更複雜會導致更多錯誤!

從_絕對必須_存在的狀態開始。例如,您需要儲存輸入的 answer 以及儲存最後一個錯誤的 error(如果存在)

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);

然後,您需要一個狀態變數來表示您要顯示的視覺狀態。通常在記憶體中表示它有多種方法,因此您需要進行實驗。

如果您難以立即想到最佳方法,請先新增足夠的狀態,以便您_確定_涵蓋了所有可能的視覺狀態

const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

您的第一個想法可能不是最好的,但這沒關係——重構狀態是流程的一部分!

步驟 4:移除任何非必要的狀態變數

您想要避免狀態內容中的重複,因此只追蹤必要的內容。花一點時間重構您的狀態結構,將使您的組件更容易理解,減少重複,並避免非預期的含義。您的目標是防止記憶體中的狀態無法表示您希望使用者看到的任何有效 UI 的情況。(例如,您永遠不希望同時顯示錯誤訊息並停用輸入,否則使用者將無法更正錯誤!)

以下是一些您可以詢問關於狀態變數的問題

  • 這個狀態會導致矛盾嗎?例如,isTypingisSubmitting 不能同時為 true。矛盾通常意味著狀態的約束不足。兩個布林值有四種可能的組合,但只有三種對應到有效的狀態。要移除「不可能」的狀態,您可以將它們組合成一個 status,它必須是以下三個值之一:'typing''submitting''success'
  • 相同的資訊是否已存在於另一個狀態變數中?另一個矛盾:isEmptyisTyping 不能同時為 true。將它們設為單獨的狀態變數,可能會導致它們不同步並造成錯誤。幸運的是,您可以移除 isEmpty,改為檢查 answer.length === 0
  • 您可以從另一個狀態變數的反面獲得相同的資訊嗎?不需要 isError,因為您可以改為檢查 error !== null

經過這次清理,您只剩下 3 個(從 7 個減少到 3 個!)*必要*的狀態變數

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

您知道它們是必要的,因為移除任何一個都會破壞功能。

深入探討

使用 reducer 消除「不可能」的狀態 ...

這三個變數足以表示此表單的狀態。然而,仍然存在一些沒有完全意義的中間狀態。例如,當 status'success' 時,非 null 的 error 沒有意義。為了更精確地模擬狀態,您可以將其提取到 reducer 中。 Reducer 讓您可以將多個狀態變數統一到單個物件中,並整合所有相關的邏輯!

步驟 5:連接事件處理程式以設定狀態 ...

最後,建立更新狀態的事件處理程式。以下是最終的表單,所有事件處理程式都已連接

import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  if (status === 'success') {
    return <h1>That's right!</h1>
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  function handleTextareaChange(e) {
    setAnswer(e.target.value);
  }

  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form onSubmit={handleSubmit}>
        <textarea
          value={answer}
          onChange={handleTextareaChange}
          disabled={status === 'submitting'}
        />
        <br />
        <button disabled={
          answer.length === 0 ||
          status === 'submitting'
        }>
          Submit
        </button>
        {error !== null &&
          <p className="Error">
            {error.message}
          </p>
        }
      </form>
    </>
  );
}

function submitForm(answer) {
  // Pretend it's hitting the network.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      let shouldError = answer.toLowerCase() !== 'lima'
      if (shouldError) {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      } else {
        resolve();
      }
    }, 1500);
  });
}

雖然這段程式碼比原始的指令式範例更長,但它更不容易出錯。將所有互動表示為狀態變化,讓您之後可以引入新的視覺狀態,而不會破壞現有的狀態。它也允許您在不更改互動本身邏輯的情況下,更改每個狀態中應顯示的內容。

總結 ...

  • 宣告式程式設計意味著描述每個視覺狀態的 UI,而不是微觀管理 UI(指令式)。
  • 開發組件時
    1. 識別其所有視覺狀態。
    2. 確定狀態變化的觸發條件(人为和计算机)。
    3. 使用 useState 建立狀態模型。
    4. 移除不必要的狀態以避免錯誤和矛盾。
    5. 連接事件處理程式以設定狀態。

挑戰 1 3:
新增和移除 CSS 類別 ...

使其點擊圖片時從外部 <div> *移除* background--active CSS 類別,但將 picture--active 類別*新增* 到 <img>。再次點擊背景應恢復原始 CSS 類別。

在視覺上,您應該預期點擊圖片會移除紫色背景並突顯圖片邊框。點擊圖片外部會突顯背景,但會移除圖片邊框突顯。

export default function Picture() {
  return (
    <div className="background background--active">
      <img
        className="picture"
        alt="Rainbow houses in Kampung Pelangi, Indonesia"
        src="https://i.imgur.com/5qwVYb1.jpeg"
      />
    </div>
  );
}