React 18 更新 state 中的数组

参考文章

更新 state 中的数组

数组是另外一种可以存储在 state 中的 JavaScript 对象,它虽然是可变的,但是却应该被视为不可变。同对象一样,当想要更新存储于 state 中的数组时,需要创建一个新的数组(或者创建一份已有数组的拷贝值),并使用新数组设置 state。

在没有 mutation 的前提下更新数组

在 JavaScript 中,数组只是另一种对象。同对象一样需要将 React state 中的数组视为只读的 。这意味着不应该使用类似于 arr[0] = 'bird' 这样的方式来重新分配数组中的元素,也不应该使用会直接修改原始数组的方法,例如 push()pop()

相反,每次要更新一个数组时,需要把一个 的数组传入 state 的 setting 方法中。为此,可以通过使用像 filter()map() 这样不会直接修改原始值的方法,从原始数组生成一个新的数组。然后就可以将 state 设置为这个新生成的数组。

下面是常见数组操作的参考表。当操作 React state 中的数组时,需要避免使用左列的方法,而首选右列的方法:

避免使用 (会改变原始数组) 推荐使用 (会返回一个新数组)
添加元素 pushunshift concat[...arr] 展开语法(例子
删除元素 popshiftsplice filterslice例子
替换元素 splicearr[i] = ... 赋值 map例子
排序 reversesort 先将数组复制一份(例子

或者,可以使用 Immer ,这样便可以使用表格中的所有方法了。

注意:不幸的是,虽然 slicesplice 的名字相似,但作用却迥然不同:

  • slice 可以拷贝数组或是数组的一部分。
  • splice 会直接修改 原始数组(插入或者删除元素)。

在 React 中,更多情况下会使用 slice,因为不想改变 state 中的对象或数组。

向数组中添加元素

应该创建一个 数组,其包含了原始数组的所有元素 以及 一个在末尾的新元素。这可以通过很多种方法实现,最简单的一种就是使用 ... 数组展开 语法:

js 复制代码
setArtists( // 替换 state
  [ // 是通过传入一个新数组实现的
    ...artists, // 新数组包含原数组的所有元素
    { id: nextId++, name: name } // 并在末尾添加了一个新的元素
  ]
);

示例代码:

js 复制代码
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 之前:

js 复制代码
setArtists([
  { id: nextId++, name: name },
  ...artists // 将原数组中的元素放在末尾
]);

这样一来,展开操作就可以完成 push()unshift() 的工作,将新元素添加到数组的末尾和开头。

从数组中删除元素

从数组中删除一个元素最简单的方法就是将它过滤出去 。换句话说,需要生成一个不包含该元素的新数组。这可以通过 filter 方法实现,例如:

js 复制代码
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>
    </>
  );
}

点击"删除"按钮几次,并且查看按钮处理点击事件的代码。

js 复制代码
setArtists(
  artists.filter(a => a.id !== artist.id)
);

这里,artists.filter(s => s.id !== artist.id) 表示"创建一个新的数组,该数组由那些 ID 与 artists.id 不同的 artists 组成"。换句话说,每个 artist 的"删除"按钮会把 那一个 artist 从原始数组中过滤掉,并使用过滤后的数组再次进行渲染。注意,filter 并不会改变原始数组。

转换数组

如果想改变数组中的某些或全部元素,可以用 map() 创建一个 数组。传入 map 的函数决定了要根据每个元素的值或索引(或二者都要)对元素做何处理。

在下面的例子中,一个数组记录了两个圆形和一个正方形的坐标。当点击按钮时,仅有两个圆形会向下移动 100 像素。这是通过使用 map() 生成一个新数组实现的。

js 复制代码
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 回调里,第二个参数是元素的索引。使用索引来判断最终是返回原始的元素(即回调的第一个参数)还是替换成其他值:

js 复制代码
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() 方法让从数组中切出"一片"。为了将元素插入数组,需要先展开原数组在插入点之前的切片,然后插入新元素,最后展开原数组中剩下的部分。

下面的例子中,插入按钮总是会将元素插入到数组中索引为 1 的位置。

js 复制代码
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() 方法会改变原数组,所以无法直接使用它们。

然而,可以先拷贝这个数组,再改变这个拷贝后的值。

例如:

js 复制代码
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" 这样的方式对数组中的特定元素进行赋值。

然而,即使拷贝了数组,还是不能直接修改其内部的元素 。这是因为数组的拷贝是浅拷贝------新的数组中依然保留了与原始数组相同的元素。因此,如果修改了拷贝数组内部的某个对象,其实正在直接修改当前的 state。举个例子,像下面的代码就会带来问题。

js 复制代码
const nextList = [...list];
nextList[0].seen = true; // 问题:直接修改了 list[0] 的值
setList(nextList);

虽然 nextListlist 是两个不同的数组,nextList[0]list[0] 却指向了同一个对象 。因此,通过改变 nextList[0].seenlist[0].seen 的值也被改变了。这是一种 state 的 mutation 操作,应该避免这么做!可以用类似于 更新嵌套的 JavaScript 对象 的方式解决这个问题------拷贝想要修改的特定元素,而不是直接修改它。下面是具体的操作。

更新数组内部的对象

对象并不是 真的 位于数组"内部"。可能他们在代码中看起来像是在数组"内部",但其实数组中的每个对象都是这个数组"指向"的一个存储于其它位置的值。这就是当在处理类似 list[0] 这样的嵌套字段时需要格外小心的原因。其他人的艺术品清单可能指向了数组的同一个元素!

当更新一个嵌套的 state 时,需要从想要更新的地方创建拷贝值,一直这样,直到顶层。 让我们看一下这该怎么做。

在下面的例子中,两个不同的艺术品清单有着相同的初始 state。他们本应该互不影响,但是因为一次 mutation,他们的 state 被意外地共享了,勾选一个清单中的事项会影响另外一个清单:

js 复制代码
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>
  );
}

问题出在下面这段代码中:

js 复制代码
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 可能难以想到,但好在如果避免直接修改 state,它们就会消失。

可以使用 map 在没有 mutation 的前提下将一个旧的元素替换成更新的版本。

js 复制代码
setMyList(myList.map(artwork => {
  if (artwork.id === artworkId) {
    // 创建包含变更的新对象
    return { ...artwork, seen: nextSeen };
  } else {
    // 没有变更
    return artwork;
  }
}));

此处的 ... 是一个对象展开语法,被用来创建一个对象的拷贝.

通过这种方式,没有任何现有的 state 中的元素会被改变,bug 也就被修复了。

通常来讲,应该只直接修改刚刚创建的对象 。如果正在插入一个的 artwork,可以修改它,但是如果想要改变的是 state 中已经存在的东西,就需要先拷贝一份了。

使用 Immer 编写简洁的更新逻辑

在没有 mutation 的前提下更新嵌套数组可能会变得有点重复。就像对对象一样:

  • 通常情况下,应该不需要更新处于非常深层级的 state 。如果有此类需求,或许需要调整一下数据的结构,让数据变得扁平一些。
  • 如果想改变 state 的数据结构,可以使用 Immer ,它让你可以继续使用方便的,但会直接修改原值的语法,并负责为你生成拷贝值。

下面是我们用 Immer 来重写的艺术愿望清单的例子:

js 复制代码
import { useState } from 'react';
import { useImmer } from 'use-immer';

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, updateMyList] = useImmer(
    initialList
  );
  const [yourList, updateYourList] = useImmer(
    initialList
  );

  function handleToggleMyList(id, nextSeen) {
    updateMyList(draft => {
      const artwork = draft.find(a =>
        a.id === id
      );
      artwork.seen = nextSeen;
    });
  }

  function handleToggleYourList(artworkId, nextSeen) {
    updateYourList(draft => {
      const artwork = draft.find(a =>
        a.id === artworkId
      );
      artwork.seen = nextSeen;
    });
  }

  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 时,类似 artwork.seen = nextSeen 这种会产生 mutation 的语法不会再有任何问题了:

js 复制代码
updateMyTodos(draft => {
  const artwork = draft.find(a => a.id === artworkId);
  artwork.seen = nextSeen;
});

这是因为并不是在直接修改原始的 state,而是在修改 Immer 提供的一个特殊的 draft 对象。同理,也可以为 draft 的内容使用 push()pop() 这些会直接修改原值的方法。

在幕后,Immer 总是会根据对 draft 的修改来从头开始构建下一个 state。这使得你的事件处理程序非常的简洁,同时也不会直接修改 state。

摘要

  • 可以把数组放入 state 中,但不应该直接修改它。
  • 不要直接修改数组,而是创建它的一份 新的 拷贝,然后使用新的数组来更新它的状态。
  • 可以使用 [...arr, newItem] 这样的数组展开语法来向数组中添加元素。
  • 可以使用 filter()map() 来创建一个经过过滤或者变换的数组。
  • 可以使用 Immer 来保持代码简洁。
相关推荐
Ares码农人生1 小时前
React 前端框架简介
前端·react.js·前端框架
GISer_Jing2 小时前
前端经典面试合集(二)——Vue/React/Node/工程化工具/计算机网络
前端·vue.js·react.js·node.js
夕水14 小时前
你可能需要避免的5个react的ref错误用法
javascript·react.js
傻小胖15 小时前
React 脚手架配置代理完整指南
前端·react.js·前端框架
发呆的薇薇°18 小时前
React里使用lodash工具库
javascript·react.js
李宏伟~20 小时前
通过交叉实现数据触底分页效果new IntersectionObserver()(html、react、vue2、vue3)中使用
前端·javascript·react.js
冴羽1 天前
Solid.js 最新官方文档翻译(12)—— 派生信号与 Memos
前端·javascript·react.js
发呆的薇薇°1 天前
React里使用uuid插件--生成随机的id
react.js·uuid·javascirpt
发呆的薇薇°1 天前
react里使用Day.js显示时间
前端·javascript·react.js
跑跑快跑1 天前
React vite + less
前端·react.js·less