在 State 中更新物件

State 可以儲存任何種類的 JavaScript 值,包括物件。但不應該直接更改在 React state 中儲存的物件。相反地,當你想更新物件時,你需要建立一個新的物件(或複製現有的物件),然後將 state 設定為使用該副本。

你將學到

  • 如何在 React state 中正確更新物件
  • 如何在不修改巢狀物件的情況下更新它
  • 什麼是不變性,以及如何不破壞它
  • 如何使用 Immer 減少物件複製的重複性

什麼是修改?

你可以在 state 中儲存任何種類的 JavaScript 值。

const [x, setX] = useState(0);

到目前為止,你一直在使用數字、字串和布林值。這種類型的 JavaScript 值是「不可變的」,表示不可更改或「唯讀」。你可以觸發重新渲染來*取代*一個值

setX(5);

x state 從 0 改為 5,但*數字 0 本身*並沒有改變。在 JavaScript 中,不可能對內建的原始值(如數字、字串和布林值)進行任何更改。

現在考慮 state 中的一個物件

const [position, setPosition] = useState({ x: 0, y: 0 });

從技術上講,可以更改*物件本身*的內容。 **這稱為修改:**

position.x = 5;

但是,儘管 React state 中的物件在技術上是可變的,但你應該將它們**視為**不可變的——就像數字、布林值和字串一樣。你不應該修改它們,而應該始終替換它們。

將 State 視為唯讀

換句話說,你應該**將放入 state 中的任何 JavaScript 物件視為唯讀。**

此範例在 state 中儲存一個物件來表示目前的指標位置。當你觸摸或將游標移到預覽區域上時,紅點應該會移動。但該點停留在初始位置

import { useState } from 'react';

export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        position.x = e.clientX;
        position.y = e.clientY;
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

問題出在這段程式碼。

onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}

這段程式碼修改了分配給 position 的物件,從先前的渲染。但是,如果不使用 state 設定函式,React 就不知道物件已更改。所以 React 沒有做出任何反應。這就像在你已經吃完飯之後試圖更改訂單一樣。雖然在某些情況下修改 state 可以正常運作,但我們不建議這樣做。你應該將在渲染中存取的 state 值視為唯讀。

要在此情況下實際觸發重新渲染,請**建立一個*新*物件並將其傳遞給 state 設定函式:**

onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}

使用 setPosition,你是在告訴 React

  • 使用這個新物件替換 position
  • 並再次渲染此元件

請注意,當你觸摸或將滑鼠懸停在預覽區域上時,紅點現在如何跟隨你的指標

import { useState } from 'react';

export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        setPosition({
          x: e.clientX,
          y: e.clientY
        });
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

深入探討

局部修改是可以的

像這樣的程式碼會產生問題,因為它修改了 state 中*現有的*物件

position.x = e.clientX;
position.y = e.clientY;

但像這樣的程式碼**絕對沒問題**,因為你修改的是你*剛剛建立*的全新物件

const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);

事實上,它與編寫以下程式碼完全等效

setPosition({
x: e.clientX,
y: e.clientY
});

只有當你更改 state 中已*存在的*物件時,修改才會成為問題。修改你剛剛建立的物件是可以的,因為*沒有其他程式碼引用它*。更改它不會意外影響依賴它的東西。這稱為「局部修改」。你甚至可以在渲染時進行局部修改。非常方便而且完全沒問題!

使用展開語法複製物件

在前面的例子中,position 物件總是根據目前的游標位置全新建立。但通常,您會希望將*現有*的資料包含在您正在建立的新物件中。例如,您可能只想更新表單中的*一個*欄位,但保留所有其他欄位的先前值。

這些輸入欄位無法正常運作,因為 onChange 事件處理函式會改變狀態

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    person.firstName = e.target.value;
  }

  function handleLastNameChange(e) {
    person.lastName = e.target.value;
  }

  function handleEmailChange(e) {
    person.email = e.target.value;
  }

  return (
    <>
      <label>
        First name:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

例如,這行程式碼會改變先前渲染的狀態

person.firstName = e.target.value;

要獲得您期望的行為,可靠的方法是建立一個新物件並將其傳遞給 setPerson。但在這裡,您也希望將現有資料複製到其中,因為只有一個欄位已更改

setPerson({
firstName: e.target.value, // New first name from the input
lastName: person.lastName,
email: person.email
});

您可以使用 ... 物件展開 語法,這樣您就不需要單獨複製每個屬性。

setPerson({
...person, // Copy the old fields
firstName: e.target.value // But override this one
});

現在表單可以正常運作了!

請注意,您沒有為每個輸入欄位宣告單獨的狀態變數。對於大型表單,將所有資料分組在一個物件中非常方便—只要您正確地更新它!

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    setPerson({
      ...person,
      firstName: e.target.value
    });
  }

  function handleLastNameChange(e) {
    setPerson({
      ...person,
      lastName: e.target.value
    });
  }

  function handleEmailChange(e) {
    setPerson({
      ...person,
      email: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

請注意,... 展開語法是「淺層」複製—它只複製一層深度。這使得它速度很快,但也意味著如果您想更新巢狀屬性,您將需要多次使用它。

深入探討

對多個欄位使用單一事件處理函式

您也可以在物件定義中使用 [] 大括號來指定具有動態名稱的屬性。以下是相同的範例,但使用單一事件處理函式而不是三個不同的事件處理函式

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleChange(e) {
    setPerson({
      ...person,
      [e.target.name]: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          name="firstName"
          value={person.firstName}
          onChange={handleChange}
        />
      </label>
      <label>
        Last name:
        <input
          name="lastName"
          value={person.lastName}
          onChange={handleChange}
        />
      </label>
      <label>
        Email:
        <input
          name="email"
          value={person.email}
          onChange={handleChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

在這裡,e.target.name 指的是賦予 <input> DOM 元素的 name 屬性。

更新巢狀物件

考慮像這樣的巢狀物件結構

const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});

如果您想更新 person.artwork.city,很清楚如何使用變動來做到這一點

person.artwork.city = 'New Delhi';

但在 React 中,您將狀態視為不可變的!為了更改 city,您首先需要產生新的 artwork 物件(預先填入先前資料),然後產生指向新 artwork 的新 person 物件

const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);

或者,寫成單一函式呼叫

setPerson({
...person, // Copy other fields
artwork: { // but replace the artwork
...person.artwork, // with the same one
city: 'New Delhi' // but in New Delhi!
}
});

這有點囉嗦,但在許多情況下都能正常運作

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    }
  });

  function handleNameChange(e) {
    setPerson({
      ...person,
      name: e.target.value
    });
  }

  function handleTitleChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        title: e.target.value
      }
    });
  }

  function handleCityChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        city: e.target.value
      }
    });
  }

  function handleImageChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        image: e.target.value
      }
    });
  }

  return (
    <>
      <label>
        Name:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        Title:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        City:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        Image:
        <input
          value={person.artwork.image}
          onChange={handleImageChange}
        />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' by '}
        {person.name}
        <br />
        (located in {person.artwork.city})
      </p>
      <img 
        src={person.artwork.image} 
        alt={person.artwork.title}
      />
    </>
  );
}

深入探討

物件並不是真正巢狀的

像這樣的物件在程式碼中看起來是「巢狀的」

let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};

然而,「巢狀」是一種不準確的思考物件行為的方式。當程式碼執行時,沒有「巢狀」物件這種東西。您實際上看到的是兩個不同的物件

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};

obj1 物件不在 obj2「內部」。例如,obj3 也可以「指向」obj1

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};

let obj3 = {
name: 'Copycat',
artwork: obj1
};

如果您要改變 obj3.artwork.city,它會同時影響 obj2.artwork.cityobj1.city。這是因為 obj3.artworkobj2.artworkobj1 是同一個物件。當您將物件視為「巢狀」時,這很難看出。相反,它們是透過屬性互相「指向」的獨立物件。

使用 Immer 編寫簡潔的更新邏輯

如果您的狀態深度巢狀,您可能需要考慮將其扁平化。但是,如果您不想更改狀態結構,您可能更喜歡巢狀展開的捷徑。Immer 是一個熱門的函式庫,可讓您使用方便但會改變狀態的語法編寫程式碼,並負責為您產生副本。使用 Immer,您編寫的程式碼看起來像是您在「破壞規則」並改變物件的狀態

updatePerson(draft => {
draft.artwork.city = 'Lagos';
});

但與常規的變動不同,它不會覆蓋過去的狀態!

深入探討

Immer 的運作原理為何?

Immer 提供的 draft 是一種特殊物件,稱為 Proxy,它會「記錄」您對它的操作。這就是為什麼您可以隨意修改它的原因!在底層,Immer 會找出 draft 中哪些部分已更改,並產生一個包含您編輯內容的全新物件。

試用 Immer

  1. 執行 npm install use-immer 將 Immer 作為依賴項新增
  2. 然後將 import { useState } from 'react' 替換為 import { useImmer } from 'use-immer'

以下是轉換為使用 Immer 的上述範例

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

請注意事件處理函式變得更簡潔。您可以在單個組件中根據需要混合搭配 useStateuseImmer。Immer 是一個讓更新處理函式保持簡潔的好方法,尤其是在您的狀態中存在巢狀結構,並且複製物件會導致程式碼重複的情況下。

深入探討

有幾個原因

  • 除錯:如果您使用 console.log 且不修改狀態,您過去的紀錄就不會被最近的狀態更改覆蓋。因此您可以清楚地看到狀態在渲染之間是如何變化的。
  • 優化:常見的 React 優化策略 依賴於如果先前的 props 或狀態與下一個相同,則跳過工作。如果您從不修改狀態,檢查是否有任何更改會非常快速。如果 prevObj === obj,您可以確定它內部沒有任何變化。
  • 新功能:我們正在構建的新 React 功能依賴於狀態被 視為快照。如果您正在修改過去版本的狀態,那可能會阻止您使用新功能。
  • 需求變更:某些應用程式功能,例如實作復原/重做、顯示變更歷史記錄或讓使用者將表單重設為較早的值,在不修改任何內容時更容易完成。這是因為您可以將過去的狀態副本保留在記憶體中,並在適當的時候重複使用它們。如果您一開始就使用修改的方法,那麼以後可能難以新增此類功能。
  • 更簡單的實作:由於 React 不依賴修改,因此它不需要對您的物件進行任何特殊處理。它不需要劫持其屬性、始終將其包裝到 Proxy 中,或像許多「反應式」解決方案那樣在初始化時執行其他工作。這也是為什麼 React 允許您將任何物件放入狀態(無論大小)而沒有額外的效能或正確性陷阱的原因。

在實務上,您經常可以在 React 中「僥倖逃脫」修改狀態,但我們強烈建議您不要這樣做,以便您可以使用以此方法開發的新 React 功能。未來的貢獻者,甚至未來的您都會感謝您!

摘要

  • 將 React 中的所有狀態視為不可變的。
  • 當您將物件儲存在狀態中時,修改它們不會觸發渲染,並且會更改先前渲染「快照」中的狀態。
  • 不要修改物件,而是建立它的 *新* 版本,並透過將狀態設定為它來觸發重新渲染。
  • 您可以使用 {...obj, something: 'newValue'} 物件展開語法來建立物件的副本。
  • 展開語法是淺層的:它只複製一層深度。
  • 要更新巢狀物件,您需要從您正在更新的位置一直向上建立副本。
  • 要減少重複的複製程式碼,請使用 Immer。

挑戰 1 3:
修正錯誤的狀態更新

此表單有一些錯誤。點擊幾次增加分數的按鈕。請注意,它沒有增加。然後編輯名字,並注意分數突然「趕上」了您的更改。最後,編輯姓氏,並注意分數已完全消失。

您的任務是修復所有這些錯誤。當您修復它們時,請解釋為什麼每個錯誤會發生。

import { useState } from 'react';

export default function Scoreboard() {
  const [player, setPlayer] = useState({
    firstName: 'Ranjani',
    lastName: 'Shettar',
    score: 10,
  });

  function handlePlusClick() {
    player.score++;
  }

  function handleFirstNameChange(e) {
    setPlayer({
      ...player,
      firstName: e.target.value,
    });
  }

  function handleLastNameChange(e) {
    setPlayer({
      lastName: e.target.value
    });
  }

  return (
    <>
      <label>
        Score: <b>{player.score}</b>
        {' '}
        <button onClick={handlePlusClick}>
          +1
        </button>
      </label>
      <label>
        First name:
        <input
          value={player.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={player.lastName}
          onChange={handleLastNameChange}
        />
      </label>
    </>
  );
}