在 State 中更新陣列

在 JavaScript 中,陣列是可變的,但在將它們儲存在 state 中時,你應該將它們視為不可變的。就像物件一樣,當你想更新儲存在 state 中的陣列時,你需要建立一個新的陣列(或複製現有的陣列),然後將 state 設定為使用新的陣列。

你將學到

  • 如何在 React state 中的陣列新增、移除或更改項目
  • 如何更新陣列內的物件
  • 如何使用 Immer 減少陣列複製的重複性

不使用突變更新陣列

在 JavaScript 中,陣列只是另一種物件。如同物件你應該將 React state 中的陣列視為唯讀。這表示你不應該重新指派陣列內的項目,例如 arr[0] = 'bird',也不應該使用會改變陣列的方法,例如 push()pop()

相反地,每次你想更新陣列時,你都需要將一個*新的*陣列傳遞給你的 state 設定函式。要做到這一點,你可以透過呼叫其非變異方法(例如 filter()map())從 state 中的原始陣列建立一個新的陣列。然後,你可以將 state 設定為產生的新陣列。

以下是常見陣列操作的參考表格。在處理 React state 內的陣列時,你需要避免使用左欄中的方法,而應優先使用右欄中的方法

避免(會改變陣列)建議(回傳一個新陣列)
新增pushunshiftconcat[...arr] 展開語法(範例
移除popshiftsplicefilterslice範例
取代splicearr[i] = ... 指派map範例
排序reversesort先複製陣列(範例

或者,你可以使用 Immer,它允許你使用兩欄中的方法。

陷阱

遺憾的是,slicesplice 名稱相似,但卻截然不同

  • slice 允許你複製陣列或其中一部分。
  • splice 會改變陣列(用於插入或刪除項目)。

在 React 中,你會更常使用 slice(沒有 p!),因為你不希望改變 state 中的物件或陣列。更新物件說明了什麼是突變以及為什麼不建議對 state 進行突變。

新增陣列元素

使用 push() 會改變原始陣列,這不是我們想要的

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        artists.push({
          id: nextId++,
          name: name,
        });
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

相反地,我們應該建立一個**新的**陣列,包含現有的元素以及新增在最後的新元素。 有幾種方法可以做到,但最簡單的方法是使用 ... 陣列展開運算子

setArtists( // Replace the state
[ // with a new array
...artists, // that contains all the old items
{ id: nextId++, name: name } // and one new item at the end
]
);

現在可以正常運作了

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        setArtists([
          ...artists,
          { id: nextId++, name: name }
        ]);
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

陣列展開運算子也可以讓你把新的元素加到陣列的**開頭**,只要把它放在原始 ...artists 的**前面**

setArtists([
{ id: nextId++, name: name },
...artists // Put old items at the end
]);

透過這種方式,展開運算子可以同時做到 push()(加到陣列尾端)和 unshift()(加到陣列開頭)的功能。 在上面的沙盒中試試看!

移除陣列元素

從陣列中移除元素最簡單的方法是將其**過濾掉**。 換句話說,你將產生一個不包含該元素的新陣列。 要做到這一點,可以使用 filter 方法,例如:

import { useState } from 'react';

let initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [artists, setArtists] = useState(
    initialArtists
  );

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>
            {artist.name}{' '}
            <button onClick={() => {
              setArtists(
                artists.filter(a =>
                  a.id !== artist.id
                )
              );
            }}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </>
  );
}

點擊幾次「刪除」按鈕,並查看它的點擊處理程序。

setArtists(
artists.filter(a => a.id !== artist.id)
);

這裡,artists.filter(a => a.id !== artist.id) 的意思是「建立一個由 ID 與 artist.id 不同的 artists 組成的陣列」。 換句話說,每個藝術家的「刪除」按鈕會將**該**藝術家從陣列中過濾掉,然後使用產生的新陣列請求重新渲染。 注意, filter 不會修改原始陣列。

轉換陣列

如果你想改變陣列中的某些或所有元素,可以使用 map() 來建立一個**新的**陣列。 你傳遞給 map 的函式可以根據每個元素的資料或索引(或兩者)來決定如何處理它。

在此範例中,一個陣列儲存了兩個圓形和一個正方形的坐標。 當你按下按鈕時,它只會將圓形向下移動 50 個像素。 它是透過使用 map() 產生一個新的資料陣列來做到這一點的

import { useState } from 'react';

let initialShapes = [
  { id: 0, type: 'circle', x: 50, y: 100 },
  { id: 1, type: 'square', x: 150, y: 100 },
  { id: 2, type: 'circle', x: 250, y: 100 },
];

export default function ShapeEditor() {
  const [shapes, setShapes] = useState(
    initialShapes
  );

  function handleClick() {
    const nextShapes = shapes.map(shape => {
      if (shape.type === 'square') {
        // No change
        return shape;
      } else {
        // Return a new circle 50px below
        return {
          ...shape,
          y: shape.y + 50,
        };
      }
    });
    // Re-render with the new array
    setShapes(nextShapes);
  }

  return (
    <>
      <button onClick={handleClick}>
        Move circles down!
      </button>
      {shapes.map(shape => (
        <div
          key={shape.id}
          style={{
          background: 'purple',
          position: 'absolute',
          left: shape.x,
          top: shape.y,
          borderRadius:
            shape.type === 'circle'
              ? '50%' : '',
          width: 20,
          height: 20,
        }} />
      ))}
    </>
  );
}

取代陣列中的元素

想要替換陣列中的一個或多個元素是很常見的需求。 像 arr[0] = 'bird' 這樣的賦值會改變原始陣列,因此你應該使用 map 來處理這種情況。

要替換一個元素,請使用 map 建立一個新的陣列。 在你的 map 呼叫中,你會收到元素索引作為第二個參數。 使用它來決定要返回原始元素(第一個參數)還是其他內容

import { useState } from 'react';

let initialCounters = [
  0, 0, 0
];

export default function CounterList() {
  const [counters, setCounters] = useState(
    initialCounters
  );

  function handleIncrementClick(index) {
    const nextCounters = counters.map((c, i) => {
      if (i === index) {
        // Increment the clicked counter
        return c + 1;
      } else {
        // The rest haven't changed
        return c;
      }
    });
    setCounters(nextCounters);
  }

  return (
    <ul>
      {counters.map((counter, i) => (
        <li key={i}>
          {counter}
          <button onClick={() => {
            handleIncrementClick(i);
          }}>+1</button>
        </li>
      ))}
    </ul>
  );
}

插入陣列元素

有時,你可能想要在特定位置插入一個元素,而該位置既不是開頭也不是結尾。 為此,可以將 ... 陣列展開運算子與 slice() 方法一起使用。 slice() 方法允許你剪切陣列的「切片」。 要插入元素,你將建立一個陣列,該陣列展開插入點**之前**的切片,然後是新元素,然後是原始陣列的其餘部分。

在此範例中,「插入」按鈕始終在索引 1 處插入元素

import { useState } from 'react';

let nextId = 3;
const initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState(
    initialArtists
  );

  function handleClick() {
    const insertAt = 1; // Could be any index
    const nextArtists = [
      // Items before the insertion point:
      ...artists.slice(0, insertAt),
      // New item:
      { id: nextId++, name: name },
      // Items after the insertion point:
      ...artists.slice(insertAt)
    ];
    setArtists(nextArtists);
    setName('');
  }

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={handleClick}>
        Insert
      </button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

對陣列進行其他更改

有些事情是您無法單獨使用展開運算符和非變異方法(例如 map()filter())來完成的。例如,您可能想要反轉或排序陣列。JavaScript 的 reverse()sort() 方法會改變原始陣列,因此您不能直接使用它們。

但是,您可以先複製陣列,然後再進行更改。

例如:

import { useState } from 'react';

const initialList = [
  { id: 0, title: 'Big Bellies' },
  { id: 1, title: 'Lunar Landscape' },
  { id: 2, title: 'Terracotta Army' },
];

export default function List() {
  const [list, setList] = useState(initialList);

  function handleClick() {
    const nextList = [...list];
    nextList.reverse();
    setList(nextList);
  }

  return (
    <>
      <button onClick={handleClick}>
        Reverse
      </button>
      <ul>
        {list.map(artwork => (
          <li key={artwork.id}>{artwork.title}</li>
        ))}
      </ul>
    </>
  );
}

在這裡,您先使用 [...list] 展開運算符建立原始陣列的副本。現在您有了副本,就可以使用變異方法,例如 nextList.reverse()nextList.sort(),甚至可以使用 nextList[0] = "something" 指派個別項目。

然而,即使您複製了一個陣列,您也不能直接改變它*內部*的現有項目。 這是因為複製是淺層的——新的陣列將包含與原始陣列相同的項目。因此,如果您修改複製陣列內的物件,您就是在改變現有的狀態。例如,像這樣的程式碼就會有問題。

const nextList = [...list];
nextList[0].seen = true; // Problem: mutates list[0]
setList(nextList);

雖然 nextListlist 是兩個不同的陣列,但 nextList[0]list[0] 指向同一個物件。 因此,透過更改 nextList[0].seen,您也會更改 list[0].seen。這是一種狀態變異,您應該避免!您可以用類似於更新巢狀 JavaScript 物件的方式來解決這個問題——複製您想要更改的個別項目,而不是改變它們。方法如下。

更新陣列內的物件

物件並不是*真的*位於陣列「內部」。它們在程式碼中可能看起來像是在「內部」,但陣列中的每個物件都是一個單獨的值,陣列「指向」該值。這就是為什麼您在更改巢狀欄位(例如 list[0])時需要小心謹慎的原因。其他人的作品列表可能指向陣列的同一個元素!

更新巢狀狀態時,您需要從要更新的位置開始,一直到頂層建立副本。 讓我們看看它是如何運作的。

在此範例中,兩個獨立的作品列表具有相同的初始狀態。它們應該是被隔離的,但由於一個變異,它們的狀態被意外共用,並且在一個列表中勾選一個核取方塊會影響另一個列表。

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    const myNextList = [...myList];
    const artwork = myNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setMyList(myNextList);
  }

  function handleToggleYourList(artworkId, nextSeen) {
    const yourNextList = [...yourList];
    const artwork = yourNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setYourList(yourNextList);
  }

  return (
    <>
      <h1>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

問題出在像這樣的程式碼中:

const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen; // Problem: mutates an existing item
setMyList(myNextList);

雖然 myNextList 陣列本身是新的,但*項目本身*與原始 myList 陣列中的項目相同。因此,更改 artwork.seen 會更改*原始*作品項目。該作品項目也在 yourList 中,這就導致了錯誤。像這樣的錯誤可能難以思考,但幸好如果您避免狀態變異,它們就會消失。

您可以使用 map 將舊項目替換為其更新版本,而無需變異。

setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// Create a *new* object with changes
return { ...artwork, seen: nextSeen };
} else {
// No changes
return artwork;
}
}));

在這裡,... 是物件展開運算符,用於 建立物件的副本

使用這種方法,不會改變任何現有的狀態項目,並且錯誤已修復。

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    setMyList(myList.map(artwork => {
      if (artwork.id === artworkId) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        return artwork;
      }
    }));
  }

  function handleToggleYourList(artworkId, nextSeen) {
    setYourList(yourList.map(artwork => {
      if (artwork.id === artworkId) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        return artwork;
      }
    }));
  }

  return (
    <>
      <h1>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

一般來說,您應該只改變您剛建立的物件。 如果您要插入一個*新的*作品,您可以改變它,但如果您正在處理已經處於狀態中的東西,您需要建立一個副本。

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

在沒有變異的情況下更新巢狀陣列可能會有點重複。就像物件一樣

  • 通常,您不需要更新超過兩三層的狀態。如果您的狀態物件非常深,您可能需要以不同的方式重新建構它們,使它們變得扁平。
  • 如果您不想更改狀態結構,您可能更喜歡使用 Immer,它允許您使用方便但會變異的語法編寫程式碼,並負責為您產生副本。

以下是使用 Immer 改寫的 Art Bucket List 範例

{
  "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": {}
}

請注意,使用 Immer 時,artwork.seen = nextSeen 這樣的變異現在是可以的:

updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});

這是因為您沒有改變*原始*狀態,而是改變了 Immer 提供的特殊 draft 物件。類似地,您可以將變異方法(例如 push()pop())應用於 draft 的內容。

在幕後,Immer 總是根據您對 draft 所做的更改從頭建構下一個狀態。這可以讓您的事件處理器保持非常簡潔,而不會改變狀態。

重點回顧

  • 您可以將陣列放入狀態中,但您不能更改它們。
  • 不要改變陣列,而是建立一個它的*新*版本,並將狀態更新為它。
  • 您可以使用 [...arr, newItem] 陣列展開運算符建立包含新項目的陣列。
  • 您可以使用 filter()map() 建立包含已篩選或轉換項目的新陣列。
  • 您可以使用 Immer 保持程式碼簡潔。

挑戰 1 4:
更新購物車中的商品

填寫 handleIncreaseClick 的邏輯,以便按下「+」增加對應的數字

import { useState } from 'react';

const initialProducts = [{
  id: 0,
  name: 'Baklava',
  count: 1,
}, {
  id: 1,
  name: 'Cheese',
  count: 5,
}, {
  id: 2,
  name: 'Spaghetti',
  count: 2,
}];

export default function ShoppingCart() {
  const [
    products,
    setProducts
  ] = useState(initialProducts)

  function handleIncreaseClick(productId) {

  }

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          {product.name}
          {' '}
          (<b>{product.count}</b>)
          <button onClick={() => {
            handleIncreaseClick(product.id);
          }}>
            +
          </button>
        </li>
      ))}
    </ul>
  );
}