参考文章
更新 state 中的对象
state 中可以保存任意类型的 JavaScript 值,包括对象。但是,不应该直接修改存放在 React state 中的对象。相反,当想要更新一个对象时,需要创建一个新的对象(或者将其拷贝一份),然后将 state 更新为此对象。
什么是 mutation?
可以在 state 中存放任意类型的 JavaScript 值。
const [x, setX] = useState(0);
在 state 中存放数字、字符串和布尔值,这些类型的值在 JavaScript 中是不可变(immutable)的,这意味着它们不能被改变或是只读的。可以通过替换它们的值以触发一次重新渲染。
setX(5);
state x
从 0
变为 5
,但是数字 0
本身并没有发生改变。在 JavaScript 中,无法对内置的原始值,如数字、字符串和布尔值,进行任何更改。
现在考虑 state 中存放对象的情况:
const [position, setPosition] = useState({ x: 0, y: 0 });
从技术上来讲,可以改变对象自身的内容。当这样做时,就制造了一个 mutation:
position.x = 5;
然而,虽然严格来说 React state 中存放的对象是可变的,但应该像处理数字、布尔值、字符串一样将它们视为不可变的。因此应该替换它们的值,而不是对它们进行修改。
将 state 视为只读的
换句话说,应该 把所有存放在 state 中的 JavaScript 对象都视为只读的。
在下面的例子中,用一个存放在 state 中的对象来表示指针当前的位置。当在预览区触摸或移动光标时,红色的点本应移动。但是实际上红点仍停留在原处:
import { useState } from 'react';
export default function MovingDot() {const [position, setPosition] = useState({x: 0,y: 0});return (<divonPointerMove={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
的对象。但是因为并没有使用 state 的设置函数,React 并不知道对象已更改。所以 React 没有做出任何响应。虽然在一些情况下,直接修改 state 可能是有效的,但并不推荐这么做。应该把在渲染过程中可以访问到的 state 视为只读的。
在这种情况下,为了真正地 触发一次重新渲染,需要创建一个新对象并把它传递给 state 的设置函数:
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 (<divonPointerMove={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
对象。但是通常,会希望把 现有 数据作为所创建的新对象的一部分。例如,可能只想要更新表单中的一个字段,其他的字段仍然使用之前的值。
下面的代码中,输入框并不会正常运行,因为 onChange
直接修改了 state :
import { useState } from 'react';export default function Form() {const [person, setPerson] = useState({firstName: 'Barbara',lastName: 'Hepworth',email: 'bhepworth@sculpture.com'});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:<inputvalue={person.firstName}onChange={handleFirstNameChange}/></label><label>Last name:<inputvalue={person.lastName}onChange={handleLastNameChange}/></label><label>Email:<inputvalue={person.email}onChange={handleEmailChange}/></label><p>{person.firstName}{' '}{person.lastName}{' '}({person.email})</p></>);
}
例如,下面这行代码修改了上一次渲染中的 state:
person.firstName = e.target.value;
想要实现需求,最可靠的办法就是创建一个新的对象并将它传递给 setPerson
。但是在这里,还需要 把当前的数据复制到新对象中,因为只改变了其中一个字段:
setPerson({firstName: e.target.value, // 从 input 中获取新的 first namelastName: person.lastName,email: person.email
});
可以使用 ...
对象展开 语法,这样就不需要单独复制每个属性。
setPerson({...person, // 复制上一个 person 中的所有字段firstName: e.target.value // 但是覆盖 firstName 字段
});
现在表单可以正常运行了!
可以看到,并没有为每个输入框单独声明一个 state。对于大型表单,将所有数据都存放在同一个对象中是非常方便的——前提是能够正确地更新它!
import { useState } from 'react';export default function Form() {const [person, setPerson] = useState({firstName: 'Barbara',lastName: 'Hepworth',email: 'bhepworth@sculpture.com'});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:<inputvalue={person.firstName}onChange={handleFirstNameChange}/></label><label>Last name:<inputvalue={person.lastName}onChange={handleLastNameChange}/></label><label>Email:<inputvalue={person.email}onChange={handleEmailChange}/></label><p>{person.firstName}{' '}{person.lastName}{' '}({person.email})</p></>);
}
请注意 ...
展开语法本质是“浅拷贝”——它只会复制一层。这使得它的执行速度很快,但是也意味着当想要更新一个嵌套属性时,必须得多次使用展开语法。
更新一个嵌套对象
考虑下面这种结构的嵌套对象:
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
的值,用 mutation 来实现的方法非常容易理解:
person.artwork.city = 'New Delhi';
但是在 React 中,需要将 state 视为不可变的!为了修改 city
的值,首先需要创建一个新的 artwork
对象(其中预先填充了上一个 artwork
对象中的数据),然后创建一个新的 person
对象,并使得其中的 artwork
属性指向新创建的 artwork
对象:
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
或者,写成一个函数调用:
setPerson({...person, // 复制其它字段的数据 artwork: { // 替换 artwork 字段 ...person.artwork, // 复制之前 person.artwork 中的数据city: 'New Delhi' // 但是将 city 的值替换为 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:<inputvalue={person.name}onChange={handleNameChange}/></label><label>Title:<inputvalue={person.artwork.title}onChange={handleTitleChange}/></label><label>City:<inputvalue={person.artwork.city}onChange={handleCityChange}/></label><label>Image:<inputvalue={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}/></>);
}
使用 Immer 编写简洁的更新逻辑
如果 state 有多层的嵌套,或许应该考虑 将其扁平化。但是,如果不想改变 state 的数据结构,可以使用 Immer 来实现嵌套展开的效果。Immer 是一个非常流行的库,它可以让你使用简便但可以直接修改的语法编写代码,并会帮你处理好复制的过程。通过使用 Immer,写出的代码看起来就像是“打破了规则”而直接修改了对象:
updatePerson(draft => {draft.artwork.city = 'Lagos';
});
但是不同于一般的 mutation,它并不会覆盖之前的 state!
尝试使用 Immer:
- 运行
npm install use-immer
添加 Immer 依赖 - 用
import { useImmer } from 'use-immer'
替换掉import { useState } from 'react'
下面我们把上面的例子用 Immer 实现一下:
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:<inputvalue={person.name}onChange={handleNameChange}/></label><label>Title:<inputvalue={person.artwork.title}onChange={handleTitleChange}/></label><label>City:<inputvalue={person.artwork.city}onChange={handleCityChange}/></label><label>Image:<inputvalue={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}/></>);
}
package.json:
{"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 会是一个不错的选择,尤其是当 state 中有嵌套,并且复制对象会带来重复的代码时。
摘要
- 将 React 中所有的 state 都视为不可直接修改的。
- 当在 state 中存放对象时,直接修改对象并不会触发重渲染,并会改变前一次渲染“快照”中 state 的值。
- 不要直接修改一个对象,而要为它创建一个 新 版本,并通过把 state 设置成这个新版本来触发重新渲染。
- 可以使用这样的
{...obj, something: 'newValue'}
对象展开语法来创建对象的拷贝。 - 对象的展开语法是浅层的:它的复制深度只有一层。
- 想要更新嵌套对象,需要从更新的位置开始自底向上为每一层都创建新的拷贝。
- 想要减少重复的拷贝代码,可以使用 Immer。