跳到主要内容

State 中更新对象

  • state 中可以保存任意类型的 JavaScript 值,包括对象。
  • 但是,你不应该直接修改存放在 React state 中的对象。
  • 相反,当你想要更新一个对象时,你需要创建一个新的对象(或者将其拷贝一份),然后将 state 更新为此对象。

什么是 mutation 突变?

从技术上来讲,可以改变对象自身的内容。当你这样做时,就制造了一个 mutation:

  • 虽然严格来说 React state 中存放的对象是可变的,
  • 但你应该像处理数字、布尔值、字符串一样将它们视为不可变的。
  • 因此你应该替换它们的值,而不是对它们进行修改。

将 state 视为只读的

import { useState } from 'react'
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0,
})
return (
<div
onPointerMove={(e) => {
setPosition({
x: e.clientX,
y: e.clientY,
})
}}
style={{
position: 'relative',
width: '100vw',
height: '100vh',
}}>
<div
style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}}
/>
</div>
)
}
  • 应该把在渲染过程中可以访问到的 state 视为只读的。
  • 只有当你改变已经处于 state 中的 现有 对象时,mutation 才会成为问题。
  • 而修改一个你刚刚创建的对象就不会出现任何问题,因为 还没有其他的代码引用它。
  • 改变它并不会意外地影响到依赖它的东西。这叫做“局部 mutation”。
  • 你甚至可以 在渲染的过程中 进行“局部 mutation”的操作。这种操作既便捷又没有任何问题!

使用展开语法复制对象

  • 最可靠的办法就是创建一个新的对象并将它传递给 setPerson
  • 但是在这里,你还需要 把当前的数据复制到新对象中,因为你只改变了其中一个字段:
通过展开语法复制对象
setPerson({
...person, // 复制上一个 person 中的所有字段
firstName: e.target.value, // 但是覆盖 firstName 字段
})
  • 请注意 ... 展开语法本质是是“浅拷贝”——它只会复制一层。
  • 这使得它的执行速度很快,但是也意味着当你想要更新一个嵌套属性时,你必须得多次使用展开语法。

更新一个嵌套对象

import { useState } from 'react'

export default function Form() {
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
},
})

function handleNameChange(e) {
setPerson({
...person,
name: e.target.value,
})
}

function handleTitleChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
title: e.target.value,
},
})
}

function handleCityChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
city: e.target.value,
},
})
}

function handleImageChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
image: e.target.value,
},
})
}

return (
<>
<label>
Name:
<input value={person.name} onChange={handleNameChange} />
</label>
<label>
Title:
<input value={person.artwork.title} onChange={handleTitleChange} />
</label>
<label>
City:
<input value={person.artwork.city} onChange={handleCityChange} />
</label>
<label>
Image:
<input value={person.artwork.image} onChange={handleImageChange} />
</label>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img src={person.artwork.image} alt={person.artwork.title} />
</>
)
}
  • 这虽然看起来有点冗长,但对于很多情况都能有效地解决问题

对象并非是真正嵌套的

下面这个对象从代码上来看是“嵌套”的:
let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
},
}

然而,当我们思考对象的特性时,“嵌套”并不是一个非常准确的方式。当这段代码运行的时候,不存在“嵌套”的对象。你实际上看到的是两个不同的对象:

对象 obj1 并不处于 obj2 的“内部”。obj3 中的属性也可以指向 obj1:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}

let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1,
}

let obj3 = {
name: 'Copycat',
artwork: obj1,
}
  • 如果你直接修改 obj3.artwork.city,就会同时影响 obj2.artwork.city 和 obj1.city。
  • 这是因为 obj3.artwork、obj2.artwork 和 obj1 都指向同一个对象。
  • 当你用“嵌套”的方式看待对象时,很难看出这一点。
  • 相反,它们是相互独立的对象,只不过是用属性“指向”彼此而已。

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

  • 如果你的 state 有多层的嵌套,你或许应该考虑 将其扁平化。
  • 但是,如果你不想改变 state 的数据结构,你可能更喜欢用一种更便捷的方式来实现嵌套展开的效果。
  • Immer 是一个非常流行的库,它可以让你使用简便但可以直接修改的语法编写代码,并会帮你处理好复制的过程。
  • 通过使用 Immer,你写出的代码看起来就像是你“打破了规则”而直接修改了对象:
updatePerson((draft) => {
draft.artwork.city = 'Lagos'
})

但是不同于一般的 mutation,它并不会覆盖之前的 state!

Immer 是如何运行的?

  • 由 Immer 提供的 draft 是一种特殊类型的对象,被称为 Proxy,它会记录你用它所进行的操作。
  • 这就是你能够随心所欲地直接修改对象的原因所在!
  • 从原理上说,Immer 会弄清楚 draft 对象的哪些部分被改变了,并会依照你的修改创建出一个全新的对象。

安装 Immer

    1. 安装 Immer
npm install use-immer
pnpm add use-immer
  • import { useImmer } from 'use-immer' 替换掉 import { useState } from 'react'
import { useImmer } from 'use-immer'

export default function Form() {
const [person, updatePerson] = useImmer({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
},
})

function handleNameChange(e) {
updatePerson((draft) => {
draft.name = e.target.value
})
}

function handleTitleChange(e) {
updatePerson((draft) => {
draft.artwork.title = e.target.value
})
}

function handleCityChange(e) {
updatePerson((draft) => {
draft.artwork.city = e.target.value
})
}

function handleImageChange(e) {
updatePerson((draft) => {
draft.artwork.image = e.target.value
})
}

return (
<>
<label>
Name:
<input value={person.name} onChange={handleNameChange} />
</label>
<label>
Title:
<input value={person.artwork.title} onChange={handleTitleChange} />
</label>
<label>
City:
<input value={person.artwork.city} onChange={handleCityChange} />
</label>
<label>
Image:
<input value={person.artwork.image} onChange={handleImageChange} />
</label>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img src={person.artwork.image} alt={person.artwork.title} />
</>
)
}
  • 可以看到,事件处理函数变得更简洁了。
  • 你可以随意在一个组件中同时使用 useState 和 useImmer。
  • 如果你想要写出更简洁的更新处理函数,Immer 会是一个不错的选择,尤其是当你的 state 中有嵌套,并且复制对象会带来重复的代码时。

为什么在 React 中不推荐直接修改 state?

有以下几个原因:

  1. 调试:如果你使用 console.log 并且不直接修改 state,你之前日志中的 state 的值就不会被新的 state 变化所影响。这样你就可以清楚地看到两次渲染之间 state 的值发生了什么变化
  2. 优化:React 常见的 优化策略 依赖于如果之前的 props 或者 state 的值和下一次相同就跳过渲染。如果你从未直接修改 state ,那么你就可以很快看到 state 是否发生了变化。如果 prevObj === obj,那么你就可以肯定这个对象内部并没有发生改变。
  3. 新功能:我们正在构建的 React 的新功能依赖于 state 被 像快照一样看待 的理念。如果你直接修改 state 的历史版本,可能会影响你使用这些新功能。
  4. 需求变更 :有些应用功能在不出现任何修改的情况下会更容易实现,比如实现撤销/恢复、展示修改历史,或是允许用户把表单重置成某个之前的值。这是因为你可以把 state 之前的拷贝保存到内存中,并适时对其进行再次使用。如果一开始就用了直接修改 state 的方式,那么后面要实现这样的功能就会变得非常困难。
  5. 更简单的实现:React 并不依赖于 mutation ,所以你不需要对对象进行任何特殊操作。它不需要像很多“响应式”的解决方案一样去劫持对象的属性、总是用代理把对象包裹起来,或者在初始化时做其他工作。这也是为什么 React 允许你把任何对象存放在 state 中——不管对象有多大——而不会造成有任何额外的性能或正确性问题的原因。

在实践中,你经常可以“侥幸”直接修改 state 而不出现什么问题,但是我们强烈建议你不要这样做,这样你就可以使用我们秉承着这种理念开发的 React 新功能。未来的贡献者甚至是你未来的自己都会感谢你的!