React + TypeScript:更新包含状态的对象

React公式网站的文档已于2023年3月16日进行了更新(参见「Introducing react.dev」)。本文是基本解释「Updating Objects in State」的简洁总结文章。但是,我们添加了TypeScript的代码。与此相反,我们省略了针对初学者的JavaScript基础介绍。

请参考其他本系列解说文章的”React + TypeScript: 学习React官方文档基础解说《Learn React》”。

React可以保存状态,包括所有JavaScript值,包括对象。但是,不应直接更改保存在状态中的对象。当要更新状态时,应先创建一个新对象(或原始对象的副本),然后对其进行编辑。然后,使用该对象来设置状态。

「イミュータブル(不可变)」是指在程序运行过程中无法修改或更改的属性或对象。

首先,在JavaScript中,所有原始值都是不可变的,也就是说它们无法被重写。即使将另一个原始值赋给变量,原始值仍然保持不变,只是值被替换而已。

所有原始值都是不可变的,即不能被修改。虽然可以将新值重新分配给变量,但原有的值无法被改变,而对象、数组和函数可以被修改。在这种语言中,没有提供修改原始值的实用程序。
“原始(Primitive)”

对此,JavaScript对象是可变的(称为可变的)。然而,在React中,无论是原始值还是对象,都被视为只读的(称为不可变的)状态变量。然后,状态设置函数用于替换值而不是直接修改值。

在下面的代码示例中,我们将对象放置在状态变量(position)中。不需要直接接触状态变量来更新值,而是通过设置函数(setPosition)来替换值(示例001)。通过这样做,还会触发重新渲染(参见「通过设置状态来触发渲染」)。

import { CSSProperties, PointerEventHandler, useState } from 'react';
import { Dot } from './Dot';

export default function MovingDot() {
	const [position, setPosition] = useState({
		x: 0,
		y: 0
	});
	const handlePointerMove: PointerEventHandler = ({
		clientX: x,
		clientY: y
	}) => {
		setPosition({ x, y });
	};
	const style: CSSProperties = {
		position: 'relative',
		width: '100vw',
		height: '100vh'
	};
	return (
		<div onPointerMove={handlePointerMove} style={style}>
			<Dot position={position} />
		</div>
	);
}
import { CSSProperties } from 'react';
import { FC } from 'react';

type Props = {
	position: { x: number; y: number };
};
export const Dot: FC<Props> = ({ position: { x, y } }) => {
	const style: CSSProperties = {
		position: 'absolute',
		backgroundColor: 'deeppink',
		borderRadius: '50%',
		transform: `translate(${x}px, ${y}px)`,
		left: -10,
		top: -10,
		width: 20,
		height: 20
	};
	return <div style={style} />;
};

样本001■React + TypeScript: 更新状态中的对象 01

 

只要是在函数的范围内创建的对象,进行修改也没有问题。例如,前面的代码示例中的处理函数(handlePointerMove)可以按照下面的方式重新编写,实际上是一样的。

const handlePointerMove: PointerEventHandler = ({
		clientX: x,
		clientY: y
	}) => {
	const nextPosition = { x: 0, y: 0 };
	nextPosition.x = x;
	nextPosition.y = y;
	// setPosition({ x, y });
	setPosition(nextPosition);
};

在中文中,以下是一个可能的表达方式:
纯函数不应该重写超出范围的已有变量或对象(参考“保持组件纯净”)。在作用域内创建的对象是无法从其他代码中引用的。换句话说,这意味着修改不会影响其他地方。这被称为“本地变化”,可以在渲染时执行而不会出现问题。

复制对象

在前面的代码示例中,我们始终使用新值创建对象并进行状态设置。但是,有时候我们只想更改对象的部分属性。这意味着我们需要使用原始对象的拷贝来填充剩余的属性。

以下是一段代码示例,其中事件处理程序(handleNameChange)为应更改的属性(name)提供了新值,并将其他属性(artwork)保持不变并设置为副本。

import { ChangeEventHandler, useState } from 'react';
import { Input } from './Input';

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'
		}
	});
	const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value }
	}) => {
		setPerson({
			artwork: {
				title: person.artwork.title,
				city: person.artwork.city,
				image: person.artwork.image
			},
			name: value
		});
	};
	return (
		<>
			<Input name="name" onChange={handleNameChange} value={person.name} />
			<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} />
		</>
	);
}

这段代码没有任何问题。但是,如果属性的数量增加,那么代码将变得冗长而繁琐。可以使用对象的扩展语法(…)进行复制。只需覆盖新的属性(name)就可以了。

const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
	target: { value }
}) => {
	setPerson({
		artwork: {
			/* title: person.artwork.title,
			city: person.artwork.city,
			image: person.artwork.image */
			...person.artwork
		},
		name: value
	});
};

将person状态变量的artwork属性中的值以及每个值都能被替换成文本输入字段(Input组件)。在此过程中,onChange事件处理函数的代码基本相同。唯一不同的是要修改的属性名称。通过使用[]运算符来计算属性名称,可以将属性名称转换为变量。也就是说,一个处理函数可以处理多个名称不同的属性(样例002)。

import { ChangeEventHandler, useState } from 'react';
import { Input } from './Input';

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'
		}
	});
	const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value }
	}) => {
		setPerson({
			...person,
			name: value
		});
	};
	const handleArtworkChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value, name }
	}) => {
		setPerson({
			...person,
			artwork: {
				...person.artwork,
				[name]: value
			}
		});
	};
	return (
		<>
			<Input name="name" onChange={handleNameChange} value={person.name} />
			<Input
				name="title"
				onChange={handleArtworkChange}
				value={person.artwork.title}
			/>
			<Input
				name="city"
				onChange={handleArtworkChange}
				value={person.artwork.city}
			/>
			<Input
				name="image"
				onChange={handleArtworkChange}
				value={person.artwork.image}
			/>
			<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} />
		</>
	);
}

样本002 ■ React + TypeScript:更新状态中的对象02

 

使用Immer将对象保持为不可变

保持对象的不变性时,需要注意的是嵌套对象。使用扩展语法…(或Object.assign()方法)只会复制对象的第一层属性。更深层的嵌套对象将传递引用。

const artwork = {
	title: 'Blue Nana',
	city: 'Hamburg',
	image: 'https://i.imgur.com/Sd1AgUOm.jpg'
};
const perton1 = { name: 'Niki de Saint Phalle', artwork: artwork };
const perton2 = { ...perton1, name: 'Copycat' };
artwork.city = 'Tokyo';
console.log(perton1.artwork.city, perton2.artwork.city); // Tokyo Tokyo

尽量避免使用嵌套的状态对象是一种方法(请参考「避免深度嵌套状态」)。但是,您可能仍然希望对状态数据进行结构化。

使用Immer库,可以将数据结构保持为不可变的。如果对React的状态的引用没有改变,那么对象也不会发生变化。此外,复制成本相对较低。在数据树中未发生更改的部分将不会被复制,而是与之前的状态在内存中共享(参考「React + TypeScript:使用Immer保持状态不可变」)。

使用的是useImmer钩子(参见”useImmer钩子”)。它将从不可变状态(不可更改)修改为可变状态(可更改)。语法与useState钩子几乎相同。返回数组的第一个元素是当前状态(对象),第二个元素是设置函数。请在钩子参数中提供状态的初始值。

传递给状态设置函数的是回调。回调函数将转换为可变对象,并将该对象作为参数接收。因此,无论如何重写这个对象都没关系。原始的不可变状态(对象)将保持不变。

// import { ChangeEventHandler, useState } from 'react';
import { ChangeEventHandler } from 'react';
import { useImmer } from 'use-immer';

export default function Form() {
	// const [person, setPerson] = useState({
	const [person, updatePerson] = useImmer({
		name: 'Niki de Saint Phalle',
		// ...[略]...
	});
	const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value }
	}) => {
		/* setPerson({
			...person,
			name: value
		}); */
		updatePerson((draft) => {
			draft.name = value;
		});
	};

}

我在用户界面Immer中重新编写的模块src/App.tsx的完整代码如下所示。有关操作,请参阅以下示例003以进行确认。

import { ChangeEventHandler } from 'react';
import { useImmer } from 'use-immer';
import { Input } from './Input';
import './styles.css';

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'
		}
	});
	const handleNameChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value }
	}) => {
		updatePerson((draft) => {
			draft.name = value;
		});
	};
	const handleArtworkChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value, name }
	}) => {
		updatePerson((draft) => {
			draft.artwork = { ...draft.artwork, [name]: value };
		});
	};
	return (
		<>
			<Input name="name" onChange={handleNameChange} value={person.name} />
			<Input
				name="title"
				onChange={handleArtworkChange}
				value={person.artwork.title}
			/>
			<Input
				name="city"
				onChange={handleArtworkChange}
				value={person.artwork.city}
			/>
			<Input
				name="image"
				onChange={handleArtworkChange}
				value={person.artwork.image}
			/>
			<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} />
		</>
	);
}

样本003■React + TypeScript:更新状态中的对象03

 

为什么在React中保持状态不可变更更好?

在React中保持状态不可变的原因有以下几点。特别是,在使用开发中的新功能时,请避免修改状态。

デバッグ: console.log()を使い、状態は直接触れないようにすれば、過去のログが直近の変更で上書きされることはありません。レンダー間で状態がどう変わったか、はっきりと確かめられます。

最適化: Reactの一般的な最適化の仕方は、つぎのプロパティや状態がつぎと変わっていなければ、処理を省くことです。状態を変更していない場合、変わっていないことはきわめて高速に確認できます。prevObj === objで内部に何も変化のないことがわかるからです。

新機能: 現在構築されている新しいReactの機能は、状態がスナップショットのように扱われることを前提としています。状態の古いバージョンを変更してしまうと、新機能は使えないかもしれません。

要件の変更: アプリケーションの機能によっては、イミュータブルによって実装しやすくなりまます。元に戻す/やり直し、変更履歴の表示、ユーザーがフォームを以前の値にリセットできるようにするなどです。変更がなければ、状態の過去のコピーをメモリに保持して、必要に応じて再利用できます。ミュータブルなやり方では、こうした機能をあとから加えることは難しくなるかもしれません。

実装のシンプル化: Reactはミューテーションに依存しないので、オブジェクトには特別なことをしなくて済みます。プロパティを奪ったり、つねにプロキシに包んだり、初期化時に余計な作業は必要ありません。Reactは大きなオブジェクトでも、パフォーマンスや正確性を損なうことなく状態として扱えるのです。

總結

在这篇文章中,我们对以下项目进行了解释。

    • Reactの状態はすべてイミュータブルとして扱ってください。

 

    • オブジェクトを状態に保持すると、直に変更してもレンダーは起動しません。そして、前にレンダリングした「スナップショット」の状態が変わります。

 

    • オブジェクトは書き替えずに、新たなバージョンをつくってください。そのオブジェクトを状態に設定すれば、再レンダーが起動します。

 

    • オブジェクトの複製に使えるのが、スプレッド構文…です。

 

    • スプレッド構文がつくるのは、オブジェクトの第一階層だけの浅い複製であることにご注意ください。

 

    • 入れ子のオブジェクトを複製するには、必要な階層の子オブジェクトまで下ってコピーしなければなりません。

 

    データ構造をイミュータブルに保って変更するにはImmerが便利です。
bannerAds