跳到主要内容

State 中更新数组

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

摘要

  • 你可以把数组放入 state 中,但你不应该直接修改它。
  • 不要直接修改数组,而是创建它的一份 新的 拷贝,然后使用新的数组来更新它的状态。
  • 你可以使用 [...arr, newItem] 这样的数组展开语法来向数组中添加元素。
  • 你可以使用 filter()map() 来创建一个经过过滤或者变换的数组。
  • 你可以使用 Immer 来保持代码简洁。

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

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

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

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

避免使用 (会改变原始数组)

  1. 添加元素 push,unshift
  2. 删除元素 pop,shift,splice
  3. 替换元素 splice,arr[i] = ... 赋值
  4. 排序 reverse,sort

推荐使用 (会返回一个新数组)

  1. concat,[...arr] 展开语法
  2. filter,slice
  3. map
  4. 先将数组复制一份

或者,你可以使用 Immer https://www.npmjs.com/package/immer ,这样你便可以使用所有方法了。

虽然 slice 和 splice 的名字相似,但作用却迥然不同:

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

1. 向数组中添加元素

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

2. 从数组中删除元素

  • 从数组中删除一个元素最简单的方法就是将它过滤出去。
  • 换句话说,你需要生成一个不包含该元素的新数组。
  • 这可以通过 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>
</>
)
}
  • 这里,artists.filter(s => s.id !== artist.id) 表示“创建一个新的数组,
  • 该数组由那些 IDartists.id 不同的 artists 组成”。
  • 换句话说,每个 artist 的“删除”按钮会把 那一个 artist 从原始数组中过滤掉,并使用过滤后的数组再次进行渲染。
  • 注意,filter 并不会改变原始数组。

3. 转换数组

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

  • 在下面的例子中,一个数组记录了两个圆形和一个正方形的坐标。
  • 当你点击按钮时,仅有两个圆形会向下移动 100 像素。这是通过使用 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,
}}
/>
))}
</>
)
}

4. 替换数组中的元素

  • 想要替换数组中一个或多个元素是非常常见的。
  • 类似 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>
)
}

5. 向数组中插入元素

  • 有时,你也许想向数组特定位置插入一个元素,这个位置既不在数组开头,也不在末尾。
  • 为此,你可以将数组展开运算符 ...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 // 可能是任何索引
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>
</>
)
}

6. 其他改变数组的情况

  • 总会有一些事,是你仅仅依靠展开运算符和 map() 或者 filter() 等不会直接修改原值的方法所无法做到的。
  • 例如,你可能想翻转数组,或是对数组排序。而 JavaScript 中的 reverse() 和 sort() 方法会改变原数组,所以你无法直接使用它们。
  • 然而,你可以先拷贝这个数组,再改变这个拷贝后的值
import { useState } from 'react'

let nextId = 3
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>
</>
)
}
  • 然而,即使你拷贝了数组,你还是不能直接修改其内部的元素。
  • 这是因为数组的拷贝是浅拷贝——新的数组中依然保留了与原始数组相同的元素。
  • 因此,如果你修改了拷贝数组内部的某个对象,其实你正在直接修改当前的 state。
举个例子,像下面的代码就会带来问题。
const nextList = [...list]
nextList[0].seen = true // 问题:直接修改了 list[0] 的值
setList(nextList)
  • 虽然 nextListlist 是两个不同的数组,nextList[0]list[0] 却指向了同一个对象。
  • 因此,通过改变 nextList[0].seen,list[0].seen 的值也被改变了。
  • 这是一种 state 的 mutation 操作,你应该避免这么做!
  • 你可以用类似于 更新嵌套的 JavaScript 对象 的方式解决这个问题——拷贝想要修改的特定元素,而不是直接修改它。
  • 下面是具体的操作。

7. 更新数组内部的对象

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

当你更新一个嵌套的 state 时,你需要从想要更新的地方创建拷贝值,一直这样,直到顶层。 ... 扩展运算符只是浅拷贝一层

这是个有问题的代码
// 因为浅拷贝的只是一层
const myNextList = [...myList]
const artwork = myNextList.find((a) => a.id === artworkId)
artwork.seen = nextSeen // 问题:直接修改了已有的元素
setMyList(myNextList)
你可以使用 map 在没有 mutation 的前提下将一个旧的元素替换成更新的版本。
setMyList(
myList.map((artwork) => {
if (artwork.id === artworkId) {
// 创建包含变更的*新*对象
return { ...artwork, seen: nextSeen }
} else {
// 没有变更
return artwork
}
})
)

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

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

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

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

安装immer
pnpm add use-immer
pnpm add immer
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 的语法不会再有任何问题了
updateMyTodos((draft) => {
const artwork = draft.find((a) => a.id === artworkId)
artwork.seen = nextSeen
})
  • 这是因为你并不是在直接修改原始的 state,而是在修改 Immer 提供的一个特殊的 draft 对象。
  • 同理,你也可以为 draft 的内容使用 push() 和 pop() 这些会直接修改原值的方法。
  • 在幕后,Immer 总是会根据你对 draft 的修改来从头开始构建下一个 state。
  • 这使得你的事件处理程序非常的简洁,同时也不会直接修改 state。