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。
-
- 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
每次点击复选框时,计数器的状态(数字)会被重置。虽然渲染的计数器组件是相同的,但根元素`
`的第一个子元素会在`
`和`
此外,不应该嵌套定义组件。因为子组件会在每次渲染时重新创建。
在下面的代码示例中,每次点击按钮时,子组件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:在不同的位置渲染组件
如果您想分别处理两个计数器,请将它们渲染到不同的位置上。在下面的代码中,根据状态变量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を変えれば、サブツリーの状態は強制的にリセットできます。
- コンポーネントの定義を入れ子にしないでください。状態が意図せずリセットされることになります。