React 互動性:事件和狀態

在我們的元件計劃制定完成後,現在是時候開始將我們的應用程式從完全靜態的 UI 更新為一個允許我們互動和更改內容的 UI 了。在本文中,我們將完成此操作,在此過程中深入探討事件和狀態,並最終得到一個應用程式,在這個應用程式中,我們可以成功地新增和刪除任務,並將任務切換為已完成狀態。

先決條件

熟悉核心 HTMLCSSJavaScript 語言,瞭解 終端/命令列

目標 學習如何在 React 中處理事件和狀態,並使用它們開始使案例研究應用程式具有互動性。

處理事件

如果您之前只編寫過原生 JavaScript,您可能習慣於擁有一個單獨的 JavaScript 檔案,在其中查詢一些 DOM 節點並向其附加監聽器。例如,一個 HTML 檔案可能包含一個按鈕,如下所示

html
<button type="button">Say hi!</button>

而一個 JavaScript 檔案可能包含如下程式碼

js
const btn = document.querySelector("button");

btn.addEventListener("click", () => {
  alert("hi!");
});

在 JSX 中,描述 UI 的程式碼與我們的事件監聽器位於同一位置

jsx
<button type="button" onClick={() => alert("hi!")}>
  Say hi!
</button>

在這個例子中,我們向 <button> 元素添加了一個 onClick 屬性。該屬性的值是一個觸發簡單警報的函式。這似乎與關於不要在 HTML 中編寫事件監聽器的最佳實踐建議相違背,但請記住:JSX 不是 HTML。

onClick 屬性在這裡具有特殊含義:它告訴 React 在使用者點選按鈕時執行給定的函式。還有幾點需要注意

  • onClick駝峰式命名法 非常重要——JSX 無法識別 onclick(同樣,它已在 JavaScript 中用於特定目的,這與標準 onclick 處理程式屬性相關但不同)。
  • 所有瀏覽器事件在 JSX 中都遵循此格式 – on,後跟事件名稱。

讓我們將其應用到我們的應用程式中,從 Form.jsx 元件開始。

處理表單提交

Form() 元件函式的頂部(即,在 function Form() { 行的正下方),建立一個名為 handleSubmit() 的函式。此函式應 阻止 submit 事件的預設行為。之後,它應該觸發一個 alert(),可以顯示任何您想要的內容。它最終應該看起來像這樣

jsx
function handleSubmit(event) {
  event.preventDefault();
  alert("Hello, world!");
}

要使用此函式,請向 <form> 元素新增一個 onSubmit 屬性,並將其值設定為 handleSubmit 函式

jsx
<form onSubmit={handleSubmit}>

現在,如果您返回瀏覽器並點選“新增”按鈕,您的瀏覽器將顯示一個包含“Hello, world!”的警報對話方塊——或者您選擇寫入的內容。

回撥 props

在 React 應用程式中,互動性很少僅限於一個元件:在一個元件中發生的事件將影響應用程式的其他部分。當我們開始賦予自己建立新任務的能力時,<Form /> 元件中發生的事情將影響 <App /> 中呈現的列表。

我們希望我們的 handleSubmit() 函式最終幫助我們建立一個新任務,因此我們需要一種方法將資訊從 <Form /> 傳遞到 <App />。我們不能像使用標準 props 從父元件傳遞資料到子元件那樣,以相同的方式從子元件傳遞資料到父元件。相反,我們可以在 <App /> 中編寫一個函式,該函式將期望從我們的表單中獲取一些資料作為輸入,然後將該函式作為 prop 傳遞給 <Form />。此函式作為 prop 被稱為 **回撥 prop**。一旦我們有了回撥 prop,我們就可以在 <Form /> 內部呼叫它,以將正確的資料傳送到 <App />

透過回撥處理表單提交

App.jsx 中的 App() 函式內,建立一個名為 addTask() 的函式,它有一個名為 name 的引數

jsx
function addTask(name) {
  alert(name);
}

接下來,將 addTask() 作為 prop 傳遞到 <Form /> 中。此 prop 可以具有任何您想要的名稱,但選擇一個您以後可以理解的名稱。像 addTask 這樣的名稱可以,因為它與函式的名稱以及函式的作用相匹配。您的 <Form /> 元件呼叫應更新如下

jsx
<Form addTask={addTask} />

要使用此 prop,我們必須更改 Form.jsxForm() 函式的簽名,以便它接受 props 作為引數

jsx
function Form(props) {
  // ...
}

最後,我們可以在 <Form /> 元件中的 handleSubmit() 函式內部使用此 prop!更新如下

jsx
function handleSubmit(event) {
  event.preventDefault();
  props.addTask("Say hello!");
}

點選瀏覽器中的“新增”按鈕將證明 addTask() 回撥函式有效,但如果我們可以讓警報顯示我們在輸入欄位中鍵入的內容,那就更好了!這是我們接下來要做的。

旁註:關於命名約定的說明

我們將 addTask() 函式作為 prop addTask 傳遞到 <Form /> 元件中,以便 addTask() 函式addTask prop 之間的關係儘可能清晰。但是請記住,prop 名稱不 *需要* 是任何特定內容。我們可以將 addTask() 作為任何其他名稱傳遞到 <Form /> 中,例如

diff
- <Form addTask={addTask} />
+ <Form onSubmit={addTask} />

這將使 addTask() 函式作為 prop onSubmit 可用於 <Form /> 元件。此 prop 可以在 Form.jsx 中使用,如下所示

diff
function handleSubmit(event) {
  event.preventDefault();
- props.addTask("Say hello!");
+ props.onSubmit("Say hello!");
}

這裡,on 字首告訴我們 prop 是一個回撥函式;Submit 是我們提交事件將觸發此函式的線索。

雖然回撥 prop 通常與熟悉事件處理程式的名稱匹配,如 onSubmitonClick,但它們可以被命名為幾乎任何有助於闡明其含義的內容。假設的 <Menu /> 元件可能包含一個在選單開啟時執行的回撥函式,以及一個在選單關閉時執行的單獨回撥函式

jsx
<Menu onOpen={() => console.log("Hi!")} onClose={() => console.log("Bye!")} />

這種 on* 命名約定在 React 生態系統中非常常見,因此在您繼續學習時請牢記這一點。為了清楚起見,在本教程的其餘部分,我們將堅持使用 addTask 和類似的 prop 名稱。如果您在閱讀本節時更改了任何 prop 名稱,請務必在繼續之前將其更改回原樣!

使用狀態持久化和更改資料

到目前為止,我們已使用 props 透過元件傳遞資料,這為我們服務得很好。但是,現在我們正在處理互動性,我們需要能夠建立新資料,保留它並在以後更新它。Props 不適合此任務,因為它們是不可變的——元件無法更改或建立自己的 props。

這就是 **狀態** 的作用。如果我們將 props 視為元件之間通訊的一種方式,我們可以將狀態視為賦予元件“記憶”的一種方式——它們可以保留並根據需要更新的資訊。

React 提供了一個專門用於向元件引入狀態的函式,恰如其分地命名為 useState()

**注意:**useState() 屬於稱為 **鉤子** 的特殊類別函式,每個鉤子都可以用來向元件新增新功能。我們稍後將學習其他鉤子。

要使用 useState(),我們需要從 React 模組匯入它。在 Form.jsx 檔案的頂部,在 Form() 函式定義之上新增以下行

jsx
import { useState } from "react";

useState() 接受一個引數,該引數確定狀態的初始值。此引數可以是字串、數字、陣列、物件或任何其他 JavaScript 資料型別。useState() 返回一個包含兩個專案的陣列。第一個專案是狀態的當前值;第二個專案是一個可用於更新狀態的函式。

讓我們建立一個 name 狀態。在 Form() 內,在您的 handleSubmit() 函式上方編寫以下內容

jsx
const [name, setName] = useState("Learn React");

這一行程式碼中發生了幾件事

  • 我們正在使用值 "Learn React" 定義一個名為 name 的常量。
  • 我們正在定義一個函式,其作用是修改 name,稱為 setName()
  • useState() 在陣列中返回這兩樣東西,因此我們正在使用 陣列解構 將它們捕獲到單獨的變數中。

讀取狀態

您可以立即看到 name 狀態的實際效果。向表單的輸入新增一個 value 屬性,並將其值設定為 name。您的瀏覽器將在輸入中呈現“Learn React”。

jsx
<input
  type="text"
  id="new-todo-input"
  className="input input__lg"
  name="text"
  autoComplete="off"
  value={name}
/>

完成後,將“Learn React”更改為空字串;這是我們初始狀態所需的。

jsx
const [name, setName] = useState("");

讀取使用者輸入

在我們更改 name 的值之前,我們需要在使用者鍵入時捕獲其輸入。為此,我們可以監聽 onChange 事件。讓我們編寫一個 handleChange() 函式,並在 <input /> 元素上監聽它。

jsx
// near the top of the `Form` component
function handleChange() {
  console.log("Typing!");
}

...

// Down in the return statement
<input
  type="text"
  id="new-todo-input"
  className="input input__lg"
  name="text"
  autoComplete="off"
  value={name}
  onChange={handleChange}
/>;

目前,當您嘗試在其中輸入文字時,輸入的值不會更改,但您的瀏覽器會將“Typing!”記錄到 JavaScript 控制檯中,因此我們知道我們的事件監聽器已附加到輸入。

要讀取使用者的按鍵,我們必須訪問輸入的 value 屬性。我們可以透過讀取 handleChange() 被呼叫時接收到的 event 物件來做到這一點。event 反過來具有 一個 target 屬性,它表示觸發 change 事件的元素。這就是我們的輸入。因此,event.target.value 是輸入中的文字。

您可以 console.log() 此值以在瀏覽器的控制檯中檢視它。嘗試按如下所示更新 handleChange() 函式,並在輸入中鍵入以檢視控制檯中的結果

jsx
function handleChange(event) {
  console.log(event.target.value);
}

更新狀態

記錄是不夠的——我們希望實際儲存使用者鍵入的內容並在輸入中呈現它!將您的 console.log() 呼叫更改為 setName(),如下所示

jsx
function handleChange(event) {
  setName(event.target.value);
}

現在,當您在輸入中鍵入時,您的按鍵將填充輸入,正如您預期的那樣。

我們還有最後一步:我們需要更改我們的 handleSubmit() 函式,以便它使用 name 作為引數呼叫 props.addTask。還記得我們的回撥 prop 嗎?這將用於將任務傳送回 App 元件,以便我們稍後將其新增到任務列表中。作為良好實踐的一部分,您應該在提交表單後清除輸入,因此我們將再次使用空字串呼叫 setName() 來執行此操作

jsx
function handleSubmit(event) {
  event.preventDefault();
  props.addTask(name);
  setName("");
}

最後,您可以在瀏覽器中輸入欄位中鍵入內容並點選新增——您鍵入的內容將顯示在警報對話方塊中。

您的 Form.jsx 檔案現在應該如下所示

jsx
import { useState } from "react";

function Form(props) {
  const [name, setName] = useState("");

  function handleChange(event) {
    setName(event.target.value);
  }

  function handleSubmit(event) {
    event.preventDefault();
    props.addTask(name);
    setName("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <h2 className="label-wrapper">
        <label htmlFor="new-todo-input" className="label__lg">
          What needs to be done?
        </label>
      </h2>
      <input
        type="text"
        id="new-todo-input"
        className="input input__lg"
        name="text"
        autoComplete="off"
        value={name}
        onChange={handleChange}
      />
      <button type="submit" className="btn btn__primary btn__lg">
        Add
      </button>
    </form>
  );
}

export default Form;

注意:您會注意到,只需點選新增按鈕而不輸入任務名稱,就可以提交空任務。您能想到阻止這種情況的方法嗎?提示:您可能需要在handleSubmit()函式中新增某種檢查。

綜合應用:新增任務

現在我們已經練習了事件、回撥 props 和 hooks,我們準備編寫允許使用者從瀏覽器新增新任務的功能。

任務作為狀態

我們需要將useState匯入到App.jsx中,以便我們可以將任務儲存在狀態中。將以下內容新增到App.jsx檔案的頂部

jsx
import { useState } from "react";

我們希望將props.tasks傳遞到useState() hook 中——這將保留其初始狀態。將以下內容新增到App()函式定義的頂部

jsx
const [tasks, setTasks] = useState(props.tasks);

現在,我們可以更改我們的taskList對映,使其成為tasks而不是props.tasks的對映結果。您的taskList常量宣告現在應該如下所示

jsx
const taskList = tasks?.map((task) => (
  <Todo
    id={task.id}
    name={task.name}
    completed={task.completed}
    key={task.id}
  />
));

新增任務

我們現在有一個setTasks hook,我們可以在我們的addTask()函式中使用它來更新我們的任務列表。但是,有一個問題:我們不能只將addTask()name引數傳遞到setTasks中,因為tasks是一個物件陣列,而name是一個字串。如果我們嘗試這樣做,陣列將被替換為字串。

首先,我們需要將name放入一個與現有任務具有相同結構的物件中。在addTask()函式內部,我們將建立一個newTask物件新增到陣列中。

然後,我們需要使用這個新任務建立一個新陣列並將其新增到其中,然後將任務資料的狀態更新為這個新狀態。為此,我們可以使用展開語法來複製現有陣列,並在末尾新增我們的物件。然後我們將此陣列傳遞到setTasks()以更新狀態。

將所有這些放在一起,您的addTask()函式應該如下所示

jsx
function addTask(name) {
  const newTask = { id: "id", name, completed: false };
  setTasks([...tasks, newTask]);
}

現在您可以使用瀏覽器將任務新增到我們的資料中!在表單中鍵入任何內容並點選“新增”(或按Enter鍵),您將看到您的新待辦事項出現在 UI 中!

但是,我們還有另一個問題:我們的addTask()函式為每個任務賦予了相同的id。這對可訪問性不利,並且使 React 無法使用key prop 區分未來的任務。事實上,React 會在您的 DevTools 控制檯中向您發出警告——“警告:遇到兩個具有相同鍵的子節點……”

我們需要解決這個問題。生成唯一識別符號是一個難題——JavaScript 社群為此編寫了一些有用的庫。我們將使用nanoid,因為它很小而且有效。

確保您位於應用程式的根目錄中,並執行以下終端命令

bash
npm install nanoid

注意:如果您使用的是 yarn,則需要以下內容:yarn add nanoid

現在我們可以使用nanoid為我們的新任務建立唯一的 ID。首先,透過在App.jsx的頂部包含以下行來匯入它

jsx
import { nanoid } from "nanoid";

現在讓我們更新addTask(),以便每個任務 ID 成為字首todo-加上 nanoid 生成的唯一字串。將您的newTask常量宣告更新為此

jsx
const newTask = { id: `todo-${nanoid()}`, name, completed: false };

儲存所有內容,然後再次嘗試您的應用程式——現在您可以新增任務,而不會收到有關重複 ID 的警告。

岔路:統計任務

現在我們可以新增新任務了,您可能會注意到一個問題:無論我們有多少任務,我們的標題都顯示為“剩餘 3 個任務”!我們可以透過計算taskList的長度並相應地更改標題的文字來解決此問題。

在您的App()定義中新增此內容,在 return 語句之前

jsx
const headingText = `${taskList.length} tasks remaining`;

這幾乎是正確的,除了如果我們的列表曾經包含單個任務,標題仍將使用“tasks”一詞。我們也可以將其設為變數。按如下所示更新您剛剛新增的程式碼

jsx
const tasksNoun = taskList.length !== 1 ? "tasks" : "task";
const headingText = `${taskList.length} ${tasksNoun} remaining`;

現在您可以用headingText變數替換列表標題的文字內容。按如下所示更新您的<h2>

jsx
<h2 id="list-heading">{headingText}</h2>

儲存檔案,返回瀏覽器,然後嘗試新增一些任務:計數現在應該按預期更新。

完成任務

您可能會注意到,當您單擊複選框時,它會適當地選中和取消選中。作為 HTML 的一項功能,瀏覽器知道如何在沒有我們幫助的情況下記住哪些複選框輸入被選中或未選中。但是,此功能隱藏了一個問題:切換複選框不會更改我們 React 應用程式中的狀態。這意味著瀏覽器和我們的應用程式現在不同步了。我們必須編寫自己的程式碼以使瀏覽器與我們的應用程式同步。

證明 bug

在修復問題之前,讓我們觀察它發生的現象。

我們將從在我們的App()元件中編寫一個toggleTaskCompleted()函式開始。此函式將具有一個id引數,但我們現在還不會使用它。現在,我們將陣列中的第一個任務記錄到控制檯——我們將檢查在瀏覽器中選中或取消選中它時會發生什麼

將此內容新增到您的taskList常量宣告的正上方

jsx
function toggleTaskCompleted(id) {
  console.log(tasks[0]);
}

接下來,我們將toggleTaskCompleted新增到在我們的taskList內部渲染的每個<Todo />元件的 props 中;按如下所示更新它

jsx
const taskList = tasks.map((task) => (
  <Todo
    id={task.id}
    name={task.name}
    completed={task.completed}
    key={task.id}
    toggleTaskCompleted={toggleTaskCompleted}
  />
));

接下來,轉到您的Todo.jsx元件,並向您的<input />元素新增一個onChange處理程式,該處理程式應使用匿名函式來呼叫帶有一個props.id引數的props.toggleTaskCompleted()<input />現在應該如下所示

jsx
<input
  id={props.id}
  type="checkbox"
  defaultChecked={props.completed}
  onChange={() => props.toggleTaskCompleted(props.id)}
/>

儲存所有內容並返回瀏覽器,並注意我們的第一個任務“吃”已選中。開啟您的 JavaScript 控制檯,然後單擊“吃”旁邊的複選框。它會取消選中,正如我們預期的那樣。但是,您的 JavaScript 控制檯將記錄類似以下內容的內容

Object { id: "task-0", name: "Eat", completed: true }

複選框在瀏覽器中取消選中,但我們的控制檯告訴我們“吃”仍然已完成。我們接下來將修復它!

將瀏覽器與我們的資料同步

讓我們重新訪問App.jsx中的toggleTaskCompleted()函式。我們希望它僅更改已切換的任務的completed屬性,並保持所有其他屬性不變。為此,我們將遍歷任務列表,只更改我們已完成的任務。

將您的toggleTaskCompleted()函式更新為以下內容

jsx
function toggleTaskCompleted(id) {
  const updatedTasks = tasks.map((task) => {
    // if this task has the same ID as the edited task
    if (id === task.id) {
      // use object spread to make a new object
      // whose `completed` prop has been inverted
      return { ...task, completed: !task.completed };
    }
    return task;
  });
  setTasks(updatedTasks);
}

在這裡,我們定義了一個updatedTasks常量,它遍歷原始的tasks陣列。如果任務的id屬性與提供給函式的id匹配,我們將使用物件展開語法建立一個新物件,並在返回之前切換該物件的completed屬性。如果不匹配,我們將返回原始物件。

然後,我們使用這個新陣列呼叫setTasks()以更新我們的狀態。

刪除任務

刪除任務將遵循與切換其完成狀態類似的模式:我們需要定義一個用於更新狀態的函式,然後將該函式作為 prop 傳遞到<Todo />中,並在發生正確的事件時呼叫它。

deleteTask回撥 prop

在這裡,我們將從在您的App元件中編寫一個deleteTask()函式開始。與toggleTaskCompleted()類似,此函式將接受一個id引數,並且我們首先會將該id記錄到控制檯。將以下內容新增到toggleTaskCompleted()下方

jsx
function deleteTask(id) {
  console.log(id);
}

接下來,向我們的<Todo />元件陣列新增另一個回撥 prop

jsx
const taskList = tasks.map((task) => (
  <Todo
    id={task.id}
    name={task.name}
    completed={task.completed}
    key={task.id}
    toggleTaskCompleted={toggleTaskCompleted}
    deleteTask={deleteTask}
  />
));

Todo.jsx中,我們希望在按下“刪除”按鈕時呼叫props.deleteTask()deleteTask()需要知道呼叫它的任務的 ID,以便它可以從狀態中刪除正確的任務。

按如下所示更新Todo.jsx中的“刪除”按鈕

jsx
<button
  type="button"
  className="btn btn__danger"
  onClick={() => props.deleteTask(props.id)}>
  Delete <span className="visually-hidden">{props.name}</span>
</button>

現在,當您單擊應用程式中的任何“刪除”按鈕時,您的瀏覽器控制檯都應記錄相關任務的 ID。

此時,您的Todo.jsx檔案應如下所示

jsx
function Todo(props) {
  return (
    <li className="todo stack-small">
      <div className="c-cb">
        <input
          id={props.id}
          type="checkbox"
          defaultChecked={props.completed}
          onChange={() => props.toggleTaskCompleted(props.id)}
        />
        <label className="todo-label" htmlFor={props.id}>
          {props.name}
        </label>
      </div>
      <div className="btn-group">
        <button type="button" className="btn">
          Edit <span className="visually-hidden">{props.name}</span>
        </button>
        <button
          type="button"
          className="btn btn__danger"
          onClick={() => props.deleteTask(props.id)}>
          Delete <span className="visually-hidden">{props.name}</span>
        </button>
      </div>
    </li>
  );
}

export default Todo;

從狀態和 UI 中刪除任務

現在我們知道deleteTask()已正確呼叫,我們可以在deleteTask()中呼叫我們的setTasks() hook 以實際從應用程式的狀態以及應用程式 UI 中的視覺上刪除該任務。由於setTasks()期望一個數組作為引數,因此我們應該為它提供一個新的陣列,該陣列複製現有的任務,排除其 ID 與傳遞到deleteTask()中的 ID 匹配的任務。

這是一個使用Array.prototype.filter()的絕佳機會。我們可以測試每個任務,如果任務的id prop 與傳遞到deleteTask()中的id引數匹配,則從新陣列中排除該任務。

按如下所示更新App.jsx檔案中的deleteTask()函式

jsx
function deleteTask(id) {
  const remainingTasks = tasks.filter((task) => id !== task.id);
  setTasks(remainingTasks);
}

再次嘗試您的應用程式。現在您應該能夠從您的應用程式中刪除任務了!

此時,您的App.jsx檔案應如下所示

jsx
import { useState } from "react";
import { nanoid } from "nanoid";
import Todo from "./components/Todo";
import Form from "./components/Form";
import FilterButton from "./components/FilterButton";

function App(props) {
  function addTask(name) {
    const newTask = { id: `todo-${nanoid()}`, name, completed: false };
    setTasks([...tasks, newTask]);
  }

  function toggleTaskCompleted(id) {
    const updatedTasks = tasks.map((task) => {
      // if this task has the same ID as the edited task
      if (id === task.id) {
        // use object spread to make a new object
        // whose `completed` prop has been inverted
        return { ...task, completed: !task.completed };
      }
      return task;
    });
    setTasks(updatedTasks);
  }

  function deleteTask(id) {
    const remainingTasks = tasks.filter((task) => id !== task.id);
    setTasks(remainingTasks);
  }

  const [tasks, setTasks] = useState(props.tasks);
  const taskList = tasks?.map((task) => (
    <Todo
      id={task.id}
      name={task.name}
      completed={task.completed}
      key={task.id}
      toggleTaskCompleted={toggleTaskCompleted}
      deleteTask={deleteTask}
    />
  ));

  const tasksNoun = taskList.length !== 1 ? "tasks" : "task";
  const headingText = `${taskList.length} ${tasksNoun} remaining`;

  return (
    <div className="todoapp stack-large">
      <h1>TodoMatic</h1>
      <Form addTask={addTask} />
      <div className="filters btn-group stack-exception">
        <FilterButton />
        <FilterButton />
        <FilterButton />
      </div>
      <h2 id="list-heading">{headingText}</h2>
      <ul
        role="list"
        className="todo-list stack-large stack-exception"
        aria-labelledby="list-heading">
        {taskList}
      </ul>
    </div>
  );
}

export default App;

總結

一篇文章的內容足夠了。在這裡,我們向您介紹了 React 如何處理事件和處理狀態,並實現了新增任務、刪除任務和切換任務為已完成的功能。我們快完成了。在下一篇文章中,我們將實現編輯現有任務和在所有任務、已完成任務和未完成任務之間過濾任務列表的功能。在此過程中,我們將瞭解條件 UI 渲染。