在状态中更新数组

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

You will learn

  • 如何在 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,这样你就能同时使用两列中的方法。

Pitfall

不幸的是,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>鼓舞人心的雕塑家:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        artists.push({
          id: nextId++,
          name: name,
        });
      }}>添加</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

相反,创建一个包含现有项目以及末尾新项目的数组。有多种方法可以做到这一点,但最简单的方法是使用 ... 数组展开语法:

setArtists( // 替换状态
[ // 为一个新数组
...artists, // 它包含所有旧项目
{ id: nextId++, name: name } // 以及末尾的一个新项目
]
);

现在它可以正确工作了:

import { useState } from 'react';

let nextId = 0;

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

  return (
    <>
      <h1>鼓舞人心的雕塑家:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        setArtists([
          ...artists,
          { id: nextId++, name: name }
        ]);
      }}>添加</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

数组展开语法还允许你通过把项目放在原始 ...artists前面来在开头插入一个项目:

setArtists([
{ id: nextId++, name: name },
...artists // 把旧项目放到末尾
]);

这样,展开语法既可以通过在数组末尾添加内容来实现 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>鼓舞人心的雕塑家:</h1>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>
            {artist.name}{' '}
            <button onClick={() => {
              setArtists(
                artists.filter(a =>
                  a.id !== artist.id
                )
              );
            }}>
              删除
            </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') {
        // 没有变化
        return shape;
      } else {
        // 返回一个向下 50px 的新圆
        return {
          ...shape,
          y: shape.y + 50,
        };
      }
    });
    // 使用新数组重新渲染
    setShapes(nextShapes);
  }

  return (
    <>
      <button onClick={handleClick}>
        向下移动圆形!
      </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) {
        // 增加被点击的计数器
        return c + 1;
      } else {
        // 其余的都没有改变
        return c;
      }
    });
    setCounters(nextCounters);
  }

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

插入到数组中

有时,你可能想在一个既不在开头也不在末尾的特定位置插入项目。为此,你可以将 ... 数组展开语法与 slice() 方法一起使用。slice() 方法可以让你切出数组的一“片”。要插入一个项目,你需要创建一个数组:先展开插入点之前的切片,然后是新项目,最后是原始数组的其余部分。

在这个例子中,Insert 按钮总是在索引 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; // 可以是任意索引
    const nextArtists = [
      // 插入点之前的项目:
      ...artists.slice(0, insertAt),
      // 新项目:
      { id: nextId++, name: name },
      // 插入点之后的项目:
      ...artists.slice(insertAt)
    ];
    setArtists(nextArtists);
    setName('');
  }

  return (
    <>
      <h1>鼓舞人心的雕塑家:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={handleClick}>
        插入
      </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}>
        反转
      </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; // 问题:修改了 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>艺术待办清单</h1>
      <h2>我想看的艺术作品列表:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>你想看的艺术作品列表:</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; // 问题:修改了现有项
setMyList(myNextList);

虽然 myNextList 数组本身是新的,但其中的 项目本身 和原来的 myList 数组里的是同一个对象。所以修改 artwork.seen 实际上修改的是 原始 的 artwork 项。这个 artwork 项也在 yourList 中,于是就引发了这个 bug。这样的 bug 可能很难一下子想明白,但幸运的是,只要避免直接修改状态,它们就会消失。

你可以使用 map 用更新后的版本替换旧项,而无需修改原对象。

setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// 创建一个带有修改的新对象
return { ...artwork, seen: nextSeen };
} else {
// 没有变化
return artwork;
}
}));

这里的 ... 是对象展开语法,用于创建对象副本。

采用这种方式后,现有状态项都没有被修改,bug 也就修复了:

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) {
        // 创建一个带有修改的新对象
        return { ...artwork, seen: nextSeen };
      } else {
        // 没有变化
        return artwork;
      }
    }));
  }

  function handleToggleYourList(artworkId, nextSeen) {
    setYourList(yourList.map(artwork => {
      if (artwork.id === artworkId) {
        // 创建一个带有修改的新对象
        return { ...artwork, seen: nextSeen };
      } else {
        // 没有变化
        return artwork;
      }
    }));
  }

  return (
    <>
      <h1>艺术待办清单</h1>
      <h2>我想看的艺术作品列表:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>你想看的艺术作品列表:</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 对象。同样地,你也可以对 draft 的内容使用像 push()pop() 这样的可变方法。

在内部,Immer 会根据你对 draft 所做的更改,从头开始构建下一个状态。这样一来,你的事件处理函数就能保持非常简洁,同时又不会修改状态。

Recap

  • 你可以把数组放进状态里,但不能修改它们。
  • 不要直接修改数组,而是创建它的一个 版本,并把状态更新为这个新版本。
  • 你可以使用 [...arr, newItem] 这种数组展开语法来创建包含新项的数组。
  • 你可以使用 filter()map() 来创建经过过滤或转换的新数组。
  • 你可以使用 Immer 来保持代码简洁。

Challenge 1 of 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>
  );
}