状态可以保存任何类型的 JavaScript 值,包括对象。但是,你不应该直接更改保存在 React 状态中的对象。相反,当你想要更新一个对象时,你需要创建一个新的对象(或复制现有对象),然后将状态设置为使用该副本。
你将学习
- 如何在 React 状态中正确更新对象
- 如何在不修改的情况下更新嵌套对象
- 什么是不可变性,以及如何不破坏它
- 如何使用 Immer 使对象复制不那么重复
什么是变异?
你可以在状态中存储任何类型的 JavaScript 值。
const [x, setX] = useState(0);
到目前为止,你一直在使用数字、字符串和布尔值。这些类型的 JavaScript 值是“不可变的”,这意味着不可更改或“只读”。你可以触发重新渲染来 *替换* 值
setX(5);
x
状态从 0
更改为 5
,但 *数字 0
本身* 没有改变。在 JavaScript 中,不可能对数字、字符串和布尔值等内置原始值进行任何更改。
现在考虑状态中的一个对象
const [position, setPosition] = useState({ x: 0, y: 0 });
从技术上讲,可以更改 *对象本身* 的内容。 这称为变异:
position.x = 5;
但是,尽管 React 状态中的对象在技术上是可变的,但你应该将它们视为不可变的——就像数字、布尔值和字符串一样。你不应该修改它们,而应该始终替换它们。
将状态视为只读
换句话说,你应该 将放入状态的任何 JavaScript 对象视为只读。
此示例在状态中保存一个对象以表示当前指针位置。当你触摸或将光标移动到预览区域上方时,红点应该移动。但点仍然保持在初始位置
import { useState } from 'react'; export default function MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { position.x = e.clientX; position.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> ); }
问题出在这段代码上。
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
这段代码修改了从 之前的渲染 分配给 position
的对象。但是,如果不使用状态设置函数,React 就不知道对象已更改。因此,React 不会做出任何响应。这就像试图在你吃完饭后更改订单一样。虽然在某些情况下修改状态可以工作,但我们不推荐这样做。你应该将你在渲染中可以访问的状态值视为只读。
要实际 触发重新渲染,创建一个 *新的* 对象并将其传递给状态设置函数:
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
使用 setPosition
,你告诉 React
- 用这个新对象替换
position
- 并再次渲染此组件
注意,当你在预览区域触摸或悬停时,红点现在会跟随你的指针。
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> ); }
深入探讨
像这样的代码有问题,因为它修改了状态中 *现有* 的对象
position.x = e.clientX;
position.y = e.clientY;
但是像这样的代码 绝对没问题,因为你正在修改你刚刚创建的新的对象
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);
事实上,它与编写这段代码完全等效
setPosition({
x: e.clientX,
y: e.clientY
});
只有当你更改已经存在于状态中的 *现有* 对象时,变异才是一个问题。修改你刚刚创建的对象是可以的,因为 *没有其他代码引用它*。更改它不会意外地影响依赖它的任何内容。这称为“局部变异”。你甚至可以在 渲染时 进行局部变异。非常方便,完全没问题!
使用扩展语法复制对象
在之前的例子中,position
对象总是根据当前光标位置新建。但是,你通常希望将现有数据作为你创建的新对象的一部分包含进去。例如,你可能只想更新表单中的一个字段,但保留所有其他字段的先前值。
这些输入字段无效,因为onChange
处理程序会更改状态
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: '[email protected]' }); function handleFirstNameChange(e) { person.firstName = e.target.value; } function handleLastNameChange(e) { person.lastName = e.target.value; } function handleEmailChange(e) { person.email = e.target.value; } return ( <> <label> First name: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label> Email: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> ); }
例如,这一行会更改来自过去渲染的状态
person.firstName = e.target.value;
获得你想要的行为的可靠方法是创建一个新对象并将其传递给setPerson
。但是在这里,你还想将现有数据复制到其中,因为只有一个字段发生了变化。
setPerson({
firstName: e.target.value, // New first name from the input
lastName: person.lastName,
email: person.email
});
你可以使用...
对象扩展 语法,这样你就不需要单独复制每个属性了。
setPerson({
...person, // Copy the old fields
firstName: e.target.value // But override this one
});
现在表单可以工作了!
请注意,你没有为每个输入字段声明单独的状态变量。对于大型表单,将所有数据分组在一个对象中非常方便——只要你正确地更新它!
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: '[email protected]' }); function handleFirstNameChange(e) { setPerson({ ...person, firstName: e.target.value }); } function handleLastNameChange(e) { setPerson({ ...person, lastName: e.target.value }); } function handleEmailChange(e) { setPerson({ ...person, email: e.target.value }); } return ( <> <label> First name: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label> Email: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> ); }
注意,...
扩展语法是“浅复制”——它只复制一层深度的内容。这使得它速度很快,但也意味着如果你想更新嵌套属性,你需要多次使用它。
深入探讨
你也可以在对象定义中使用[
和 ]
大括号来指定具有动态名称的属性。这是一个相同的例子,但使用单个事件处理程序而不是三个不同的事件处理程序。
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: '[email protected]' }); function handleChange(e) { setPerson({ ...person, [e.target.name]: e.target.value }); } return ( <> <label> First name: <input name="firstName" value={person.firstName} onChange={handleChange} /> </label> <label> Last name: <input name="lastName" value={person.lastName} onChange={handleChange} /> </label> <label> Email: <input name="email" value={person.email} onChange={handleChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> ); }
这里,e.target.name
指的是赋予<input>
DOM 元素的name
属性。
更新嵌套对象
考虑一下这样的嵌套对象结构
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
如果你想更新person.artwork.city
,很清楚如何通过修改来实现
person.artwork.city = 'New Delhi';
但在 React 中,你将状态视为不可变的!为了更改city
,你首先需要生成新的artwork
对象(预填充来自先前对象的数据),然后生成新的person
对象,该对象指向新的artwork
。
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
或者,写成一个函数调用
setPerson({
...person, // Copy other fields
artwork: { // but replace the artwork
...person.artwork, // with the same one
city: 'New Delhi' // but in New Delhi!
}
});
这有点冗长,但对于许多情况来说效果很好
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',
}
};
但是,“嵌套”是一种不准确的思考对象行为的方式。当代码执行时,根本不存在“嵌套”对象。你实际上看到的是两个不同的对象。
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
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 编写简洁的更新逻辑
如果你的状态深度嵌套,你可能需要考虑将其扁平化。 但是,如果你不想更改你的状态结构,你可能更喜欢嵌套扩展的快捷方式。Immer 是一个流行的库,它允许你使用方便但可变的语法进行编写,并负责为你生成副本。使用 Immer,你编写的代码看起来像你正在“违反规则”并修改对象。
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
但与常规修改不同,它不会覆盖过去的状态!
深入探讨
Immer 提供的draft
是一种特殊类型的对象,称为Proxy,它会“记录”你对它的操作。这就是为什么你可以随意修改它!在幕后,Immer 会找出draft
的哪些部分发生了更改,并生成一个包含你的编辑的全新对象。
要尝试 Immer
- 运行
npm install use-immer
将 Immer 添加为依赖项 - 然后将
import { useState } from 'react'
替换为import { useImmer } from 'use-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": {} }
注意事件处理程序变得多么简洁。您可以根据需要在单个组件中随意混合和匹配useState
和useImmer
。Immer 是一种保持更新处理程序简洁的好方法,尤其是在状态存在嵌套并且复制对象会导致代码重复的情况下。
深入探讨
原因如下:
- 调试:如果您使用
console.log
并且不修改状态,则过去的日志不会被最近的状态更改覆盖。因此,您可以清楚地看到状态在渲染之间是如何变化的。 - 优化:常见的 React 优化策略 依赖于如果之前的 props 或状态与下一个相同则跳过工作。如果您从未修改状态,则检查是否发生了任何更改的速度非常快。如果
prevObj === obj
,您可以确定其内部没有任何变化。 - 新功能:我们正在构建的新 React 功能依赖于状态被视为快照。如果您正在修改过去的状态版本,这可能会阻止您使用新功能。
- 需求变更:一些应用程序功能,例如实现撤消/重做、显示更改历史记录或允许用户将表单重置为较早的值,在不进行任何修改的情况下更容易实现。这是因为您可以将过去的状态副本保存在内存中,并在适当的时候重用它们。如果您从修改方法开始,则此类功能以后可能难以添加。
- 更简单的实现:因为 React 不依赖于修改,所以它不需要对您的对象进行任何特殊操作。它不需要劫持它们的属性,总是将它们包装到代理中,或者像许多“反应式”解决方案那样在初始化时进行其他工作。这也是为什么 React 允许您将任何对象放入状态中的原因——无论大小——都不会出现额外的性能或正确性问题。
实际上,您通常可以“侥幸逃脱”在 React 中修改状态,但我们强烈建议您不要这样做,以便您可以使用考虑到这种方法而开发的新 React 功能。未来的贡献者,甚至未来的您,都会感谢您!
回顾
- 将 React 中的所有状态都视为不可变的。
- 当您将对象存储在状态中时,修改它们不会触发渲染,并且会更改先前渲染的“快照”中的状态。
- 不要修改对象,而是创建一个新的版本,并通过将其设置为状态来触发重新渲染。
- 您可以使用
{...obj, something: 'newValue'}
对象展开语法来创建对象的副本。 - 展开语法是浅拷贝:它只复制一层。
- 要更新嵌套对象,您需要从要更新的位置一直向上创建副本。
- 为了减少重复的复制代码,请使用 Immer。
挑战 1的 3: 修复不正确的状态更新
此表单有一些错误。单击几次增加分数的按钮。注意它没有增加。然后编辑名字,注意分数突然“追上”了您的更改。最后,编辑姓氏,注意分数完全消失了。
您的任务是修复所有这些错误。在修复它们时,解释每个错误发生的原因。
import { useState } from 'react'; export default function Scoreboard() { const [player, setPlayer] = useState({ firstName: 'Ranjani', lastName: 'Shettar', score: 10, }); function handlePlusClick() { player.score++; } function handleFirstNameChange(e) { setPlayer({ ...player, firstName: e.target.value, }); } function handleLastNameChange(e) { setPlayer({ lastName: e.target.value }); } return ( <> <label> Score: <b>{player.score}</b> {' '} <button onClick={handlePlusClick}> +1 </button> </label> <label> First name: <input value={player.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={player.lastName} onChange={handleLastNameChange} /> </label> </> ); }