React + TypeScript: 状态的保存和重置

React官方网站的文档已于2023年3月16日进行了修订(请参阅”Introducing react.dev”)。本文是对”Preserving and Resetting State”基本解释的简要总结。但是,我们增加了TypeScript的代码。与此同时,我们省略了面向初学者的JavaScript基础解释。

另外,请参阅其他有关本系列解释的文章:“React + TypeScript: 学习React官方文档的基础解释《学习React》”。

每个组件都有不同的状态。React根据状态属于哪个组件以及其在UI树中的位置来进行跟踪。而且,在重新渲染之间,你可以控制何时保持状态以及何时重置状态。

用户界面树

浏览器通过各种树状结构对用户界面进行建模。

DOM: Web文書(HTML)

CSSOM: CSS

AOM: アクセシビリティ

React使用树状结构来管理和建模UI。

    1. React以JSX构建UI树。

 

    根据UI树更新浏览器的DOM元素是React DOM。

另外,React Native会将这些树转换成移动平台特定的元素。

状态相关联于树中的位置

给定的状态可能存在于组件中,但实际上,React会负责维护状态。每个React组件的状态会根据其在UI树中的位置正确地关联起来。

在React中,即使将相同的组件排列在一个页面上,它们的状态也是分开的。每个同名的状态变量都有自己的值,这是因为组件被呈现在树的不同位置。通常情况下,我们不需要过于关注组件在树中的位置。但了解这个机制是有用的。

例如,对于下面的组件Counter,其状态包括分数(score)和悬停(hover)。分数是指计数器的数值,悬停是指组件是否被光标指针指向的布尔值。

import { useState } from 'react';
import type { FC } from 'react';

export const Counter: FC = () => {
	const [score, setScore] = useState(0);
	const [hover, setHover] = useState(false);
	const className = 'counter' + (hover ? ' hover' : '');
	return (
		<div
			className={className}
			onPointerEnter={() => setHover(true)}
			onPointerLeave={() => setHover(false)}
		>
			<h1>{score}</h1>
			<button onClick={() => setScore(score + 1)}>Add one</button>
		</div>
	);
};

将两个计数器放在父组件App中,它们的状态是独立的,互相不会产生影响。通过计数器的按钮点击来改变score数值和hover值,hover的样式根据指针的重叠情况而变化,这些值都分别属于每个组件(示例001)。

import { Counter } from './Counter';

export default function App() {
	return (
		<div>
			<Counter />
			<Counter />
		</div>
	);
}

React + TypeScript: 保留和重置状态的样本001

 

只要相同的组件在同一位置渲染,React就会保持状态。例如,我们可以通过重写父组件App来实现第二个Counter组件可以通过复选框选择是否消除。

先增加两个计数器的数值,然后将第二个计数器的复选框设为关闭,将其从屏幕上移除。然后将复选框设为开启,重新显示计数器。

import { useState } from 'react';

export default function App() {
	const [showB, setShowB] = useState(true);
	return (
		<div>

			{/* <Counter /> */}
			{showB && <Counter />}
			<label>
				<input
					type="checkbox"
					checked={showB}
					onChange={({ target: { checked } }) => {
						setShowB(checked);
					}}
				/>
				Render the second counter
			</label>
		</div>
	);
}

再次显示的第二个计数器的值将被重置为0(示例002)。当React删除组件并停止渲染时,该组件的状态将全部丢弃。重新显示并加入DOM的组件状态将恢复为初始值。

React + TypeScript示例002:保存和重置状态02

 

在React中,组件的状态只会在该组件在UI树中的同一位置进行渲染期间被保留。如果组件被删除或者其他组件被渲染到该位置,React将会丢弃之前组件的状态。

保持相同组件在相同位置的状态。

让我们在父组件中只放置一个计数器。然后,从父组件传递一个新的属性,该属性是一个布尔值isFancy。根据这个值,子组件将被分配不同的样式。

type Props = {
	isFancy: boolean;
};
// export const Counter: FC = () => {
export const Counter: FC<Props> = ({ isFancy }) => {

	// const className = 'counter' + (hover ? ' hover' : '');
	let className = 'counter';
	if (hover) {
		className += ' hover';
	}
	if (isFancy) {
		className += ' fancy';
	}

};

请关注一下父组件App返回的JSX代码。根据状态变量isFancy的值进行条件分支,有两个标签。它们会被分别对待为不同的组件吗?

export default function App() {
	const [isFancy, setIsFancy] = useState(false);
	return (
		<div>
			{isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
			<label>
				<input
					type="checkbox"
					checked={isFancy}
					onChange={({ target: { checked } }) => {
						setIsFancy(checked);
					}}
				/>
				Use fancy styling
			</label>
		</div>
	);
}

请在下一个示例003中尝试。通过复选框的开/关,计数器的样式会改变。但是,计数器递增的数值不会被重置。无论isFancy状态变量的值如何,在UI树上,Counter组件始终是父组件App返回的根元素

的第一个子元素。

示例003■React + TypeScript:保留和重置状态03

 

在React中,具有相同位置的相同组件被视为相同。然而,React将状态绑定到UI树的位置上,而不是JSX的标记。

例如,我尝试重写前面提到的示例003中根组件App的JSX,将放在了if条件语句的内部和外部。

export default function App() {

	if (isFancy) {
		return (
			<div>
				<Counter isFancy={true} />

			</div>
		);
	}
	return (
		<div>
			<Counter isFancy={false} />

		</div>
	);
}

根元素的

被分成两个部分。通过复选框切换状态变量isFancy,Counter组件是否会重置呢? 结果与前面的示例003没有变化(示例004)。这是因为Counter在UI树中的渲染位置不会改变。React只关注UI树上的位置,不会检查JSX条件语句的内容。

React + TypeScript的示例004:保留和重置状态04

 

无论如何,组件App的返回值都会在根元素

的第一个子元素中包含。也就是说,从React的角度来看,Counter组件的“地址”不会改变。React会检查渲染前后是否匹配。代码逻辑如何构建并不重要。

当在同一位置替换另一个组件时,状态将被重置。

让我们假设在父组件(App)中,通过单击复选框,替换了Counter组件和

元素。

export default function App() {
	const [isPaused, setIsPaused] = useState(false);
	return (
		<div>
			{isPaused ? <p>See you later!</p> : <Counter />}
			<label>
				<input
					type="checkbox"
					checked={isPaused}
					onChange={({ target: { checked } }) => {
						setIsPaused(checked);
					}}
				/>
					Take a break
			</label>
		</div>
	);
}

置換的是同一位置但不同的组件(样本005)。最初,返回的JSX元素作为Counter的第一个子元素,然后替换为

元素时,React会从UI树中删除Counter并丢弃状态。

使用React和TypeScript:保留和重置状态 05的样本005

 

此外,即使组件相同,如果渲染的UI树有所改变,子树的整体状态也会重置。例如,当父组件(App)切换计数器时(示例006)。

export default function App() {
	const [isFancy, setIsFancy] = useState(false);
	return (
		<div>
			{isFancy ? (
				<div>
					<Counter isFancy={true} />
				</div>
			) : (
				<section>
					<Counter isFancy={false} />
				</section>
			)}
		<label>
			<input
				type="checkbox"
				checked={isFancy}
				onChange={({ target: { checked } }) => {
					setIsFancy(checked);
				}}
			/>
			Use fancy styling
		</label>
		</div>
	);
}

React + TypeScript的示例006:保留和重置状态06

 

每次点击复选框时,计数器的状态(数字)会被重置。虽然渲染的计数器组件是相同的,但根元素`

`的第一个子元素会在`

`和`

`之间切换。当子元素从DOM中移除时,整个子树包括计数器及其状态都会被销毁。如果想在渲染间保留状态作为基本方法,就必须确保树结构在每次渲染时“匹配”。如果结构不同,状态将会丢失。这是因为React在从树中移除组件时也会销毁状态。

此外,不应该嵌套定义组件。因为子组件会在每次渲染时重新创建。

在下面的代码示例中,每次点击按钮时,子组件MyTextField的文本输入字段状态(text)会被重置。而父组件的状态(counter)将保持不变(示例007)。嵌套的函数将在每次父组件的渲染时重新创建。这将导致在相同的位置渲染不同的组件。React将重置子组件以下的所有状态。这可能会导致错误和性能问题。为了避免这种情况,请在组件函数中不要嵌套,并始终在顶层进行声明。

export default function MyComponent() {
	const [counter, setCounter] = useState(0);
	const MyTextField: FC = () => {
		const [text, setText] = useState('');
		return (
			<input
				value={text}
				onChange={({ target: { value } }) => setText(value)}
			/>
		);
	};
	return (
		<>
			<MyTextField />
			<button
				onClick={() => {
					setCounter(counter + 1);
				}}
			>
				Clicked {counter} times
			</button>
		</>
	);
}

样本007■React + TypeScript:保留和重置状态07

 

重新设置为相同位置的状态

React的默认行为是在组件保持在相同位置时保持其状态不变。这是默认的,因为在许多情况下这样更方便。但是当切换组件时,有时候我们希望明确地重置状态。例如,在两个玩家的计分器中分别显示他们的分数(状态)的情况下。

在下面的代码示例中,对于每个玩家(isPlayerA),切换子组件的计数器(Counter)不会影响子组件的状态。由于Counter组件的位置相同,React将其视为具有不同person属性的相同组件。

export default function App() {
	const [isPlayerA, setIsPlayerA] = useState(true);
	return (
		<div>
			{isPlayerA ? <Counter person="Taylor" /> : <Counter person="Sarah" />}
			<button
				onClick={() => {
					setIsPlayerA(!isPlayerA);
				}}
			>
				Next player!
			</button>
		</div>
	);
}

然而,在这个应用程序中,必须将这两个计数器视为独立的。即使组件在UI上放置的位置相同,计数器也是针对每个玩家单独的。

在切换时,有两种可能的方法可以重置状态。

    1. 请在不同位置渲染组件。
    为每个组件分配一个明确的标识键。

选项1:在不同的位置渲染组件

如果您想分别处理两个计数器,请将它们渲染到不同的位置上。在下面的代码中,根据状态变量isPlayerA的值,切换两个位置的组件(示例008)。

isPlayerAがtrue: ひとつめの位置にCounterの状態が含まれ、ふたつめは空です。

isPlayerAがfalse: ひとつめの位置はクリアされ、ふたつめにCounterが加わります。

当然,如果逻辑与(&&)的左边作为值返回false时,该JSX表达式将不会被渲染(请参见”条件渲染(Conditional Rendering)”)。

export default function App() {

	return (
		<div>
			{/* {isPlayerA ? <Counter person="Taylor" /> : <Counter person="Sarah" />} */}
			{isPlayerA && <Counter person="Taylor" />}
			{!isPlayerA && <Counter person="Sarah" />}

		</div>
	);
}

样本008■React + TypeScript: 保留和重置状态08

 

每个计数器的状态在组件从DOM中移除时被销毁。因此,每次点击按钮,状态都会被重置。

这种方法在只有少量想要独立渲染的组件时非常方便。在这个示例中,只有两个Counter。这是一个适合使用JSX改变渲染位置的示例。

为每个组件提供一个明确的识别标识符

还有一种更常见的方法是重置组件的状态。

在渲染列表时,添加的是key属性(参考“列表的渲染”)。然而,它不仅适用于列表。通过给予key,React可以区分组件。

默认情况下,React通过父级中的顺序来识别组件。但是,通过给组件设置key,React可以无视顺序,每个组件都可以被识别。这样,无论组件渲染在树的哪个位置,都可以被React识别到。

如果在下面的代码中向两个Counter组件添加键(key),即使它们被放置在JSX的相同位置,它们的状态也不会被共享。通过不同的键,这两个组件被区分开来,在切换时状态将不会被保留(示例009)。

export default function App() {

	return (
		<div>
			{isPlayerA ? (
				<Counter key="Taylor" person="Taylor" />
			) : (
				<Counter key="Sarah" person="Sarah" />
			)}

		</div>
	);
}

样品009■React + TypeScript:保留和重置状态09

 

设置key属性后,React将根据key来识别位置,而不是在父组件中的顺序。因此,即使在JSX中渲染在相同位置,React也将识别它们为不同的Counter。因此,状态是不共享的。每次组件被渲染时,状态都会被重新创建。而当组件被移除时,状态会被丢弃。这样,每次切换组件时,状态都会被重置。

此外,关键值不需要全局唯一。只需要在父级中能够确定其位置即可。

用键盘重置表单

当处理表单时,通过键(key)来重置状态(reset)特别有用。让我们在聊天应用程序中假设Chat组件具有文本输入的状态。

首先,这是一个关于创建聊天应用程序的组件。Chat组件拥有一个文本区域,输入的消息可以通过[发送到<目标邮箱>]按钮进行发送(按钮点击时不会发生任何特别的事情)。接收目标信息的属性是从父组件传递过来的,名为contact。

export const Chat: FC<Props> = ({ contact }) => {
	const [text, setText] = useState('');
	return (
		<section className="chat">
			<textarea
				value={text}
				onChange={({ target: { value } }) => setText(value)}
			/>
			<br />
			<button>Send to {contact.email}</button>
		</section>
	);
};

在父组件Messenger中,有3个联系人的目标信息(contacts)可用,并通过属性将信息传递给子组件Chat和ContactList。

const contacts: Contact[] = [
	{ id: 0, name: 'Taylor', email: 'taylor@mail.com' },
	{ id: 1, name: 'Alice', email: 'alice@mail.com' },
	{ id: 2, name: 'Bob', email: 'bob@mail.com' }
];
export default function Messenger() {
	const [to, setTo] = useState(contacts[0]);
	return (
		<div>
			<ContactList contacts={contacts} onSelect={(contact) => setTo(contact)} />
			<Chat contact={to} />
		</div>
	);
}

ContactList是一个选择联系人的列表组件,通过按钮可在三个选项中选择目标。当切换目标时,将通过onSelect方法将该联系人信息发送给父组件。

export const ContactList: FC<Props> = ({ contacts, onSelect }) => {
	return (
		<section className="contact-list">
			<ul>
				{contacts.map((contact) => (
					<li key={contact.id}>
						<button
							onClick={() => {
								onSelect(contact);
							}}
						>
							{contact.name}
						</button>
					</li>
				))}
			</ul>
		</section>
	);
};

请在示例010中的文本区域中输入一些内容,然后尝试通过按钮切换对话对象。输入的文本不会消失。由于Chat组件的渲染树位置不会改变,状态会被保留下来。

样品010■React + TypeScript:保留和重置状态10

 

在这种情况下,聊天应用程序中的状态保持是不可取的,因为可能会不小心将之前发送给对方的消息发送给下一个收件人。

在这种情况下,可以考虑一种方法:父母捕捉到从联系人列表发送的收件人更改,并传达给另一个子组件Chat,告诉它要删除输入的文本。

如果Chat组件的状态需要重置,那么使用key就很简单了。只需要按照下面的方法添加key,当目标切换时文本将被清空(示例011)。

export default function Messenger() {

	return (
		<div>

			{/* <Chat contact={to} /> */}
			<Chat key={to.id} contact={to} />
		</div>
	);
}

React + TypeScript实例011:保留和重置状态11。

 

当切换对方时,Chat组件会重新创建包括子树在内的新状态。React会重新构建DOM元素而不是重复利用。

保留已删除的组件的状态。

在实际的聊天应用程序中,当我们返回到之前的收件人时,可能会希望恢复之前输入的状态。我们可以考虑一些方法来保持已删除组件的状态并”复活”它们。

    • 現在だけでなく、すべてのチャットをレンダーすることです。要らないチャットはCSSで隠します。ツリーのすべてのチャットは削除されません。ローカルの状態に保持されます。シンプルなUIには適しているでしょう。けれど、隠れたツリーが大きくなり、DOMノードもたくさん含まれるようになると、速度の大幅な低下を招くかもしれません。
    • 状態を引き上げて、宛先ごとに残しておくメッセージは親コンポーネントに保持することです(「React + TypeScript: コンポーネント間で状態を共有する」参照)。これなら、子コンポーネントが除かれても、情報は親がもっているので失われません。これがもっとも一般的な解決方法でしょう。
    Reactの状態に加えて、異なるソースを使うことも考えられます。たとえば、ユーザーがうっかりページを閉じても、書きかけたメッセージは残しておきたいという場合です。その実装としては、Chatコンポーネントが状態を初期化するとき、localStorageから読み込みます。そうすれば、下書きも保存できるでしょう。

无论采取哪种方法,聊天应被视为一个根据不同目标进行区分的单独组件。因此,为Chat组件树中的每个目标提供一个键是合适的。

总结

在这篇文章中,我们对以下主题进行了说明。

    • Reactは、同じコンポーネントが同じ位置にレンダリングされるかぎり、状態を保持します。
    • 状態はJSXの中に保持されるのではありません。JSXが配置されたツリーの位置に紐づけられるのです。
    • 一意のkeyを変えれば、サブツリーの状態は強制的にリセットできます。
    コンポーネントの定義を入れ子にしないでください。状態が意図せずリセットされることになります。
bannerAds