更新状态中的数组

在 JavaScript 中,数组是另一种可变的对象,但在将它们存储在状态中时,应将其视为不可变的。与对象一样,当要更新存储在状态中的数组时,需要创建一个新的数组(或现有数组的副本),然后设置状态以使用新数组。

你将学习

  • 如何在 React 状态中添加、删除或更改数组中的项目
  • 如何更新数组中的对象
  • 如何使用 Immer 减少数组复制的重复性

无变异更新数组

在 JavaScript 中,数组只是另一种对象。与对象一样你应该将 React 状态中的数组视为只读。这意味着你不应该重新分配数组内部的项目,例如arr[0] = 'bird',也不应该使用会更改数组的方法,例如push()pop()

相反,每次想要更新数组时,都应该将一个新的数组传递给你的状态设置函数。为此,可以通过调用其非变异方法(如filter()map())从状态中的原始数组创建一个新数组。然后可以将状态设置为生成的新的数组。

这是一个常用数组操作的参考表。在处理 React 状态中的数组时,需要避免左列中的方法,而应选择右列中的方法

避免使用(会修改数组)推荐使用(返回新数组)
添加pushunshiftconcat[...arr] 展开语法 (示例)
删除popshiftsplicefilterslice (示例)
替换splicearr[i] = ... 赋值map (示例)
排序reversesort首先复制数组 (示例)

或者,可以使用 Immer,它允许你使用两列中的方法。

陷阱

不幸的是,slicesplice 的名称相似,但含义大相径庭

  • slice 允许你复制数组或其一部分。
  • splice 会修改数组(以插入或删除项目)。

在React中,你会更频繁地使用slice(没有p!)方法,因为你不想改变状态中的对象或数组。 更新对象 解释了什么是变异以及为什么不推荐用于状态。

向数组添加元素

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>
  );
}