更新状态中的数组

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

您将学习

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

不使用突变更新数组

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

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

以下是常见数组操作的参考表。在处理 React 状态中的数组时,您需要避免使用左栏中的方法,而应首选使用右栏中的方法

避免(改变数组)首选(返回一个新数组)
添加push, unshiftconcat, [...arr] 展开语法 (示例)
删除pop, shift, splicefilter, slice (示例)
替换splice, arr[i] = ... 赋值map (示例)
排序reverse, sort先复制数组 (示例)

或者,您可以使用 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 重写的艺术品清单示例:

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