第三章 - 状态管理
随着你的应用不断变大,更有意识的去关注应用状态如何组织,以及数据如何在组件之间流动会对你很有帮助。冗余或重复的状态往往是缺陷的根源。在本节中,你将学习如何组织好状态,如何保持状态更新逻辑的可维护性,以及如何跨组件共享状态。
用state 响应输入
使用react,你不用直接从代码层面修改UI。例如,不用编写诸如"禁用按钮"、"启用按钮"、"显示成功消息"等命令。相反,你只需要描述组件在不同状态("初始状态"、"输入状态"、"成功状态")下希望展现的 UI,然后根据用户输入触发状态更改。这和设计师对 UI 的理解很相似。
下面是一个使用 React 编写的反馈表单。请注意看它是如何使用 status
这个状态变量来决定启用或禁用提交按钮,以及是否显示成功消息的。
react
import { useState } from 'react';
export default function Form() {
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing');
if (status === 'success') {
return <h1>答对了!</h1>
}
async function handleSubmit(e) {
e.preventDefault();
setStatus('submitting');
try {
await submitForm(answer);
setStatus('success');
} catch (err) {
setStatus('typing');
setError(err);
}
}
function handleTextareaChange(e) {
setAnswer(e.target.value);
}
return (
<>
<h2>城市测验</h2>
<p>
哪个城市有把空气变成饮用水的广告牌?
</p>
<form onSubmit={handleSubmit}>
<textarea
value={answer}
onChange={handleTextareaChange}
disabled={status === 'submitting'}
/>
<br />
<button disabled={
answer.length === 0 ||
status === 'submitting'
}>
提交
</button>
{error !== null &&
<p className="Error">
{error.message}
</p>
}
</form>
</>
);
}
function submitForm(answer) {
// 模拟接口请求
return new Promise((resolve, reject) => {
setTimeout(() => {
let shouldError = answer.toLowerCase() !== 'lima'
if (shouldError) {
reject(new Error('猜的不错,但答案不对。再试试看吧!'));
} else {
resolve();
}
}, 1500);
});
}
声明式地考虑UI
你已经从上面的例子看到如何去实现一个表单了,为了更好地理解如何在 React 中思考,接下来你将会学到如何用 React 重新实现这个 UI:
- 定位你的组件中不同的视图状态
- 确定是什么触发了这些 state 的改变
- 表示 内存中的 state(需要使用
useState
) - 删除任何不必要的 state 变量
- 连接事件处理函数去设置 state
步骤1:定位组件中不同的视图状态
在计算机科学中,你或许听过可处于多种"状态"之一的 "状态机"。如果你有与设计师一起工作,那么你可能已经见过不同"视图状态"的模拟图。正因为 React 站在设计与计算机科学的交点上,因此这两种思想都是灵感的来源。
首先,你需要去可视化 UI 界面中用户可能看到的所有不同的"状态":
- 无数据:表单有一个不可用状态的"提交"按钮。
- 输入中:表单有一个可用状态的"提交"按钮。
- 提交中:表单完全处于不可用状态,加载动画出现。
- 成功时:显示"成功"的消息而非表单。
- 错误时:与输入状态类似,但会多错误的消息。
像一个设计师一样,你会想要在你添加逻辑之前去"模拟"不同的状态或创建"模拟状态"。例如下面的例子,这是一个对表单可视部分的模拟。这个模拟被一个 status
的属性控制,并且这个属性的默认值为 empty
。
react
export default function Form({
status = 'empty'
}) {
if (status === 'success') {
return <h1>That's right!</h1>
}
return (
<>
<h2>City quiz</h2>
<p>
In which city is there a billboard that turns air into drinkable water?
</p>
<form>
<textarea />
<br />
<button>
Submit
</button>
</form>
</>
)
}
你可以随意命名这个属性,名字并不重要。试着将 status = 'empty'
改为 status = 'success'
,然后你就会看到成功的信息出现。模拟可以让你在书写逻辑前快速迭代 UI。这是同一组件的一个更加充实的原型,仍然由 status
属性"控制":
react
export default function Form({
// Try 'submitting', 'error', 'success':
status = 'empty'
}) {
if (status === 'success') {
return <h1>That's right!</h1>
}
return (
<>
<h2>City quiz</h2>
<p>
In which city is there a billboard that turns air into drinkable water?
</p>
<form>
<textarea disabled={
status === 'submitting'
} />
<br />
<button disabled={
status === 'empty' ||
status === 'submitting'
}>
Submit
</button>
{status === 'error' &&
<p className="Error">
Good guess but a wrong answer. Try again!
</p>
}
</form>
</>
);
}
深入探讨 - 同时展示大量的视图状态
如果一个组件有多个视图状态,你可以很方便地将它们展示在一个页面中:
react
import Form from './Form.js';
let statuses = [
'empty',
'typing',
'submitting',
'success',
'error',
];
export default function App() {
return (
<>
{statuses.map(status => (
<section key={status}>
<h4>Form ({status}):</h4>
<Form status={status} />
</section>
))}
</>
);
}
// Form.js
export default function Form({ status }) {
if (status === 'success') {
return <h1>That's right!</h1>
}
return (
<form>
<textarea disabled={
status === 'submitting'
} />
<br />
<button disabled={
status === 'empty' ||
status === 'submitting'
}>
Submit
</button>
{status === 'error' &&
<p className="Error">
Good guess but a wrong answer. Try again!
</p>
}
</form>
);
}
步骤2: 确定是什么触发了这些状态的改变
你可以触发 state 的更新来响应两种输入:
- 人为输入。比如点击按钮、在表单中输入内容,或导航到链接。
- 计算机输入。比如网络请求得到反馈、定时器被触发,或加载一张图片。
以上两种情况中,你必须设置 state 变量 去更新 UI。对于正在开发中的表单来说,你需要改变 state 以响应几个不同的输入:
- 改变输入框中的文本时 (人为)应该根据输入框的内容是否是空值 ,从而决定将表单的状态从空值状态切换到输入中或切换回原状态。
- 点击提交按钮时 (人为)应该将表单的状态切换到提交中的状态。
- 网络请求成功后 (计算机)应该将表单的状态切换到成功的状态。
- 网络请求失败后 (计算机)应该将表单的状态切换到失败的状态,与此同时,显示错误信息。
步骤3:通过 useState
表示内存中的 state
接下来你会需要在内存中通过 useState
表示组件中的视图状态。诀窍很简单:state 的每个部分都是"处于变化中的",并且你需要让"变化的部分"尽可能的少。更复杂的程序会产生更多 bug!
先从绝对必须 存在的状态开始。例如,你需要存储输入的 answer
以及用于存储最后一个错误的 error
(如果存在的话):
react
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
接下来,你需要一个状态变量来代表你想要显示的那个可视状态。通常有多种方式在内存中表示它,因此你需要进行实验。
如果你很难立即想出最好的办法,那就先从添加足够多的 state 开始,确保所有可能的视图状态都囊括其中:
react
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
你最初的想法或许不是最好的,但是没关系,重构 state 也是步骤中的一部分!
步骤4:删除任何不必要的state变量
你会想要避免 state 内容中的重复,从而只需要关注那些必要的部分。花一点时间来重构你的 state 结构,会让你的组件更容易被理解,减少重复并且避免歧义。你的目的是防止出现在内存中的 state 不代表任何你希望用户看到的有效 UI 的情况。(比如你绝对不会想要在展示错误信息的同时禁用掉输入框,导致用户无法纠正错误!)
这有一些你可以问自己的, 关于 state 变量的问题:
- 这个 state 是否会导致矛盾 ?例如,
isTyping
与isSubmitting
的状态不能同时为true
。矛盾的产生通常说明了这个 state 没有足够的约束条件。两个布尔值有四种可能的组合,但是只有三种对应有效的状态。为了将"不可能"的状态移除,你可以将他们合并到一个'status'
中,它的值必须是'typing'
、'submitting'
以及'success'
这三个中的一个。 - 相同的信息是否已经在另一个 state 变量中存在 ?另一个矛盾:
isEmpty
和isTyping
不能同时为true
。通过使它们成为独立的 state 变量,可能会导致它们不同步并导致 bug。幸运的是,你可以移除isEmpty
转而用message.length === 0
。 - 你是否可以通过另一个 state 变量的相反值得到相同的信息 ?
isError
是多余的,因为你可以检查error !== null
。
在清理之后,你只剩下 3 个(从原本的 7 个!)必要的 state 变量:
react
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'
正是因为你不能在不破坏功能的情况下删除其中任何一个状态变量,因此你可以确定这些都是必要的。
步骤5:连接事件处理函数以设置 state
最后,创建事件处理函数去设置 state 变量。下面是绑定好事件的最终表单:
react
import { useState } from 'react';
export default function Form() {
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing');
if (status === 'success') {
return <h1>That's right!</h1>
}
async function handleSubmit(e) {
e.preventDefault();
setStatus('submitting');
try {
await submitForm(answer);
setStatus('success');
} catch (err) {
setStatus('typing');
setError(err);
}
}
function handleTextareaChange(e) {
setAnswer(e.target.value);
}
return (
<>
<h2>City quiz</h2>
<p>
In which city is there a billboard that turns air into drinkable water?
</p>
<form onSubmit={handleSubmit}>
<textarea
value={answer}
onChange={handleTextareaChange}
disabled={status === 'submitting'}
/>
<br />
<button disabled={
answer.length === 0 ||
status === 'submitting'
}>
Submit
</button>
{error !== null &&
<p className="Error">
{error.message}
</p>
}
</form>
</>
);
}
function submitForm(answer) {
// Pretend it's hitting the network.
return new Promise((resolve, reject) => {
setTimeout(() => {
let shouldError = answer.toLowerCase() !== 'lima'
if (shouldError) {
reject(new Error('Good guess but a wrong answer. Try again!'));
} else {
resolve();
}
}, 1500);
});
}
摘要
- 声明式编程意味着为每个视图状态声明 UI 而非细致地控制 UI(命令式)。
- 当开发一个组件时:
- 写出你的组件中所有的视图状态。
- 确定是什么触发了这些 state 的改变。
- 通过
useState
模块化内存中的 state。 - 删除任何不必要的 state 变量。
- 连接事件处理函数去设置 state。
选择 state 结构
构建良好的state可以让组件变得更易于修改和调试,而不会经常出错。以下是你在构建state时应该考虑的一些建议
构建state的原则
当你编写一个存有state的组件时,你需要选择使用多少个state变量以及它们都是怎么样的数据格式。尽管选择次优的state结构下也可以编写正确的程序,但有几个原则可以指导您做出更好的决策:
- 合并关联的state。如果你总是同时更新两个或更多的state变量,请考虑将它们合并为一个单独的state变量。
- 避免互相矛盾的state。当state结构中存在多个互相矛盾的state时,你就可能为此留下隐患,应尽量避免这种情况。
- 避免冗余的state。如果你能在渲染期间从组件的props或其现有的state变量中计算出一些信息,则不应该将这些信息放入该组件的state中。
- 避免重复的state。当同一数据在多个state变量之间或在多个嵌套对象中重复时,这会很难保持它们同步。应该尽可能减少重复
- 避免深度嵌套的state。深度分层的state更新起来不是很方便。如果可能的话,最好扁平化方式构建state。
这些原则背后的目标是使得state易于更新而不引入错误。从 state 中删除冗余和重复数据有助于确保所有部分保持同步。这类似于数据库工程师想要 "规范化"数据库结构,以减少出现错误的机会。用爱因斯坦的话说,"让你的状态尽可能简单,但不要过于简单。"
现在让我们来看看这些原则在实际中是如何应用的。
合并关联的state
有时候你可能会不确定是使用单个 state 变量还是多个 state 变量。
你会像下面这样做吗?
const [x, setX] = useState(0);
const [y, setY] = useState(0);
或这样?
const [position, setPosition] = useState({ x: 0, y: 0 });
从技术上讲,你可以使用其中任何一种方法。但是,如果某两个 state 变量总是一起变化,则将它们统一成一个 state 变量可能更好。这样你就不会忘记让它们始终保持同步,就像下面这个例子中,移动光标会同时更新红点的两个坐标:
react
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>
)
}
另一种情况是,你将数据整合到一个对象或一个数组中时,你不知道需要多少个state片段。例如,当你有一个用户以添加自定义字段的表单时,这将会很有帮助
陷阱
如果你的 state 变量是一个对象时,请记住,你不能只更新其中的一个字段 而不显式复制其他字段。例如,在上面的例子中,你不能写成 setPosition({ x: 100 })
,因为它根本就没有 y
属性! 相反,如果你想要仅设置 x
,则可执行 setPosition({ ...position, x: 100 })
,或将它们分成两个 state 变量,并执行 setX(100)
避免矛盾的state
下面是带有isSending 和 isSent 两个state变量的酒店反馈表单:
react
import { useState } from 'react';
export default function FeedbackForm() {
const [text, setText] = useState('');
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setIsSending(true);
await sendMessage(text);
setIsSending(false);
setIsSent(true);
}
if (isSent) {
return <h1>Thanks for feedback!</h1>
}
return (
<form onSubmit={handleSubmit}>
<p>How was your stay at The Prancing Pony?</p>
<textarea
disabled={isSending}
value={text}
onChange={e => setText(e.target.value)}
/>
<br />
<button
disabled={isSending}
type="submit"
>
Send
</button>
{isSending && <p>Sending...</p>}
</form>
);
}
// 假装发送一条消息。
function sendMessage(text) {
return new Promise(resolve => {
setTimeout(resolve, 2000);
});
}
尽管这段代码是有效的,但也会让一些 state "极难处理"。例如,如果你忘记同时调用 setIsSent
和 setIsSending
,则可能会出现 isSending
和 isSent
同时为 true
的情况。你的组件越复杂,你就越难理解发生了什么。
因为 isSending
和 isSent
不应同时为 true
,所以最好用一个 status
变量来代替它们,这个 state 变量可以采取三种有效状态其中之一 :'typing'
(初始), 'sending'
, 和 'sent'
:
react
import { useState } from 'react';
export default function FeedbackForm() {
const [text, setText] = useState('');
const [status, setStatus] = useState('typing');
async function handleSubmit(e) {
e.preventDefault();
setStatus('sending');
await sendMessage(text);
setStatus('sent');
}
const isSending = status === 'sending';
const isSent = status === 'sent';
if (isSent) {
return <h1>Thanks for feedback!</h1>
}
return (
<form onSubmit={handleSubmit}>
<p>How was your stay at The Prancing Pony?</p>
<textarea
disabled={isSending}
value={text}
onChange={e => setText(e.target.value)}
/>
<br />
<button
disabled={isSending}
type="submit"
>
Send
</button>
{isSending && <p>Sending...</p>}
</form>
);
}
// 假装发送一条消息。
function sendMessage(text) {
return new Promise(resolve => {
setTimeout(resolve, 2000);
});
}
你仍然可以声明一些常量,以提高可读性:
const isSending = status === 'sending';
const isSent = status === 'sent';
但它们不是 state 变量,所以你不必担心它们彼此失去同步。
避免冗余state
如果你能在渲染期间从组件的 props 或其现有的 state 变量中计算出一些信息,则不应该把这些信息放到该组件的 state 中。
例如,以这个表单为例。它可以运行,但你能找到其中任何冗余的 state 吗?
react
import { useState } from 'react';
export default function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
function handleFirstNameChange(e) {
setFirstName(e.target.value);
setFullName(e.target.value + ' ' + lastName);
}
function handleLastNameChange(e) {
setLastName(e.target.value);
setFullName(firstName + ' ' + e.target.value);
}
return (
<>
<h2>Let's check you in</h2>
<label>
First name:{' '}
<input
value={firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:{' '}
<input
value={lastName}
onChange={handleLastNameChange}
/>
</label>
<p>
Your ticket will be issued to: <b>{fullName}</b>
</p>
</>
);
}
这个表单有三个 state 变量:firstName
、lastName
和 fullName
。然而,fullName
是多余的。在渲染期间,你始终可以从 firstName
和 lastName
中计算出 fullName
,因此需要把它从 state 中删除。
你可以这样做:
react
import { useState } from 'react';
export default function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = firstName + ' ' + lastName;
function handleFirstNameChange(e) {
setFirstName(e.target.value);
}
function handleLastNameChange(e) {
setLastName(e.target.value);
}
return (
<>
<h2>Let's check you in</h2>
<label>
First name:{' '}
<input
value={firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:{' '}
<input
value={lastName}
onChange={handleLastNameChange}
/>
</label>
<p>
Your ticket will be issued to: <b>{fullName}</b>
</p>
</>
);
}
这里的 fullName
不是 一个 state 变量。相反,它是在渲染期间中计算出的:
const fullName = firstName + ' ' + lastName;
因此,更改处理程序不需要做任何特殊操作来更新它。当你调用 setFirstName
或 setLastName
时,你会触发一次重新渲染,然后下一个 fullName
将从新数据中计算出来。
深入讨论 - 不要在state中镜像 props
以下代码是体现 state 冗余的一个常见例子:
react
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
这里,一个 color
state 变量被初始化为 messageColor
的 prop 值。这段代码的问题在于,如果父组件稍后传递不同的 messageColor
值(例如,将其从 'blue'
更改为 'red'
),则 color
state 变量将不会更新! state 仅在第一次渲染期间初始化。
这就是为什么在 state 变量中,"镜像"一些 prop 属性会导致混淆的原因。相反,你要在代码中直接使用 messageColor
属性。如果你想给它起一个更短的名称,请使用常量:
react
function Message({ messageColor }) {
const color = messageColor;
这种写法就不会与从父组件传递的属性失去同步。
只有当你 想要 忽略特定 props 属性的所有更新时,将 props "镜像"到 state 才有意义。按照惯例,prop 名称以 initial
或 default
开头,以阐明该 prop 的新值将被忽略:
react
function Message({ initialColor }) {
// 这个 `color` state 变量用于保存 `initialColor` 的 **初始值**。
// 对于 `initialColor` 属性的进一步更改将被忽略。
const [color, setColor] = useState(initialColor);
避免重复的state
下面这个菜单列表组件可以让你在多种旅行小吃中选择一个:
react
import { useState } from 'react';
const initialItems = [
{ title: 'pretzels', id: 0 },
{ title: 'crispy seaweed', id: 1 },
{ title: 'granola bar', id: 2 },
];
export default function Menu() {
const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(
items[0]
);
return (
<>
<h2>What's your travel snack?</h2>
<ul>
{items.map(item => (
<li key={item.id}>
{item.title}
{' '}
<button onClick={() => {
setSelectedItem(item);
}}>Choose</button>
</li>
))}
</ul>
<p>You picked {selectedItem.title}.</p>
</>
);
}
当前,它将所选元素作为对象存储在 selectedItem state变量中。然而,这并不好:selectedItem 的内容与 items列表中的某个项是同一个对象。这意味这关于该项本身的信息在两个地方产生了重复。
为什么这是个问题?让我们使每个项目都可以编辑:
react
import { useState } from 'react';
const initialItems = [
{ title: 'pretzels', id: 0 },
{ title: 'crispy seaweed', id: 1 },
{ title: 'granola bar', id: 2 },
];
export default function Menu() {
const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(
items[0]
);
function handleItemChange(id, e) {
setItems(items.map(item => {
if (item.id === id) {
return {
...item,
title: e.target.value,
};
} else {
return item;
}
}));
}
return (
<>
<h2>What's your travel snack?</h2>
<ul>
{items.map((item, index) => (
<li key={item.id}>
<input
value={item.title}
onChange={e => {
handleItemChange(item.id, e)
}}
/>
{' '}
<button onClick={() => {
setSelectedItem(item);
}}>Choose</button>
</li>
))}
</ul>
<p>You picked {selectedItem.title}.</p>
</>
);
}
请注意,如果你首先单击菜单上的"Choose" 然后 编辑它,输入会更新,但底部的标签不会反映编辑内容。 这是因为你有重复的 state,并且你忘记更新了 selectedItem
。
尽管你也可以更新 selectedItem
,但更简单的解决方法是消除重复项。在下面这个例子中,你将 selectedId
保存在 state 中,而不是在 selectedItem
对象中(它创建了一个与 items
内重复的对象),然后 通过搜索 items
数组中具有该 ID 的项,以此获取 selectedItem
:
react
import { useState } from 'react';
const initialItems = [
{ title: 'pretzels', id: 0 },
{ title: 'crispy seaweed', id: 1 },
{ title: 'granola bar', id: 2 },
];
export default function Menu() {
const [items, setItems] = useState(initialItems);
const [selectedId, setSelectedId] = useState(0);
const selectedItem = items.find(item =>
item.id === selectedId
);
function handleItemChange(id, e) {
setItems(items.map(item => {
if (item.id === id) {
return {
...item,
title: e.target.value,
};
} else {
return item;
}
}));
}
return (
<>
<h2>What's your travel snack?</h2>
<ul>
{items.map((item, index) => (
<li key={item.id}>
<input
value={item.title}
onChange={e => {
handleItemChange(item.id, e)
}}
/>
{' '}
<button onClick={() => {
setSelectedId(item.id);
}}>Choose</button>
</li>
))}
</ul>
<p>You picked {selectedItem.title}.</p>
</>
);
}
state 过去常常是这样复制的:
items = [{ id: 0, title: 'pretzels'}, ...]
selectedItem = {id: 0, title: 'pretzels'}
改了之后是这样的:
items = [{ id: 0, title: 'pretzels'}, ...]
selectedId = 0
重复的 state 没有了,你只保留了必要的 state!
现在,如果你编辑 selected 元素,下面的消息将立即更新。这是因为 setItems
会触发重新渲染,而 items.find(...)
会找到带有更新文本的元素。你不需要在 state 中保存 选定的元素 ,因为只有 选定的 ID 是必要的。其余的可以在渲染期间计算。
避免深度嵌套 的state
想象一下,一个由行星、大陆和国家组成的旅行计划。你可能会尝试使用嵌套对象和数组来构建它的 state,就像下面这个例子:
react
export const initialTravelPlan = {
id: 0,
title: '(Root)',
childPlaces: [{
id: 1,
title: 'Earth',
childPlaces: [{
id: 2,
title: 'Africa',
childPlaces: [{
id: 3,
title: 'Botswana',
childPlaces: []
}, {
id: 4,
title: 'Egypt',
childPlaces: []
}, {
id: 5,
title: 'Kenya',
childPlaces: []
}, {
id: 6,
title: 'Madagascar',
childPlaces: []
}, {
id: 7,
title: 'Morocco',
childPlaces: []
}, {
id: 8,
title: 'Nigeria',
childPlaces: []
}, {
id: 9,
title: 'South Africa',
childPlaces: []
}]
}, {
id: 10,
title: 'Americas',
childPlaces: [{
id: 11,
title: 'Argentina',
childPlaces: []
}, {
id: 12,
title: 'Brazil',
childPlaces: []
}, {
id: 13,
title: 'Barbados',
childPlaces: []
}, {
id: 14,
title: 'Canada',
childPlaces: []
}, {
id: 15,
title: 'Jamaica',
childPlaces: []
}, {
id: 16,
title: 'Mexico',
childPlaces: []
}, {
id: 17,
title: 'Trinidad and Tobago',
childPlaces: []
}, {
id: 18,
title: 'Venezuela',
childPlaces: []
}]
}, {
id: 19,
title: 'Asia',
childPlaces: [{
id: 20,
title: 'China',
childPlaces: []
}, {
id: 21,
title: 'India',
childPlaces: []
}, {
id: 22,
title: 'Singapore',
childPlaces: []
}, {
id: 23,
title: 'South Korea',
childPlaces: []
}, {
id: 24,
title: 'Thailand',
childPlaces: []
}, {
id: 25,
title: 'Vietnam',
childPlaces: []
}]
}, {
id: 26,
title: 'Europe',
childPlaces: [{
id: 27,
title: 'Croatia',
childPlaces: [],
}, {
id: 28,
title: 'France',
childPlaces: [],
}, {
id: 29,
title: 'Germany',
childPlaces: [],
}, {
id: 30,
title: 'Italy',
childPlaces: [],
}, {
id: 31,
title: 'Portugal',
childPlaces: [],
}, {
id: 32,
title: 'Spain',
childPlaces: [],
}, {
id: 33,
title: 'Turkey',
childPlaces: [],
}]
}, {
id: 34,
title: 'Oceania',
childPlaces: [{
id: 35,
title: 'Australia',
childPlaces: [],
}, {
id: 36,
title: 'Bora Bora (French Polynesia)',
childPlaces: [],
}, {
id: 37,
title: 'Easter Island (Chile)',
childPlaces: [],
}, {
id: 38,
title: 'Fiji',
childPlaces: [],
}, {
id: 39,
title: 'Hawaii (the USA)',
childPlaces: [],
}, {
id: 40,
title: 'New Zealand',
childPlaces: [],
}, {
id: 41,
title: 'Vanuatu',
childPlaces: [],
}]
}]
}, {
id: 42,
title: 'Moon',
childPlaces: [{
id: 43,
title: 'Rheita',
childPlaces: []
}, {
id: 44,
title: 'Piccolomini',
childPlaces: []
}, {
id: 45,
title: 'Tycho',
childPlaces: []
}]
}, {
id: 46,
title: 'Mars',
childPlaces: [{
id: 47,
title: 'Corn Town',
childPlaces: []
}, {
id: 48,
title: 'Green Hill',
childPlaces: []
}]
}]
};
现在,假设你想添加一个按钮来删除一个你已经去过的地方。你会怎么做呢?更新嵌套的 state 需要从更改部分一直向上复制对象。删除一个深度嵌套的地点将涉及复制其整个父级地点链。这样的代码可能非常冗长。
如果 state 嵌套太深,难以轻松更新,可以考虑将其"扁平化"。 这里有一个方法可以重构上面这个数据。不同于树状结构,每个节点的 place
都是一个包含 其子节点 的数组,你可以让每个节点的 place
作为数组保存 其子节点的 ID。然后存储一个节点 ID 与相应节点的映射关系。
这个数据重组可能会让你想起看到一个数据库表
react
export const initialTravelPlan = {
0: {
id: 0,
title: '(Root)',
childIds: [1, 42, 46],
},
1: {
id: 1,
title: 'Earth',
childIds: [2, 10, 19, 26, 34]
},
2: {
id: 2,
title: 'Africa',
childIds: [3, 4, 5, 6 , 7, 8, 9]
},
3: {
id: 3,
title: 'Botswana',
childIds: []
},
4: {
id: 4,
title: 'Egypt',
childIds: []
},
5: {
id: 5,
title: 'Kenya',
childIds: []
},
6: {
id: 6,
title: 'Madagascar',
childIds: []
},
7: {
id: 7,
title: 'Morocco',
childIds: []
},
8: {
id: 8,
title: 'Nigeria',
childIds: []
},
9: {
id: 9,
title: 'South Africa',
childIds: []
},
10: {
id: 10,
title: 'Americas',
childIds: [11, 12, 13, 14, 15, 16, 17, 18],
},
11: {
id: 11,
title: 'Argentina',
childIds: []
},
12: {
id: 12,
title: 'Brazil',
childIds: []
},
13: {
id: 13,
title: 'Barbados',
childIds: []
},
14: {
id: 14,
title: 'Canada',
childIds: []
},
15: {
id: 15,
title: 'Jamaica',
childIds: []
},
16: {
id: 16,
title: 'Mexico',
childIds: []
},
17: {
id: 17,
title: 'Trinidad and Tobago',
childIds: []
},
18: {
id: 18,
title: 'Venezuela',
childIds: []
},
19: {
id: 19,
title: 'Asia',
childIds: [20, 21, 22, 23, 24, 25],
},
20: {
id: 20,
title: 'China',
childIds: []
},
21: {
id: 21,
title: 'India',
childIds: []
},
22: {
id: 22,
title: 'Singapore',
childIds: []
},
23: {
id: 23,
title: 'South Korea',
childIds: []
},
24: {
id: 24,
title: 'Thailand',
childIds: []
},
25: {
id: 25,
title: 'Vietnam',
childIds: []
},
26: {
id: 26,
title: 'Europe',
childIds: [27, 28, 29, 30, 31, 32, 33],
},
27: {
id: 27,
title: 'Croatia',
childIds: []
},
28: {
id: 28,
title: 'France',
childIds: []
},
29: {
id: 29,
title: 'Germany',
childIds: []
},
30: {
id: 30,
title: 'Italy',
childIds: []
},
31: {
id: 31,
title: 'Portugal',
childIds: []
},
32: {
id: 32,
title: 'Spain',
childIds: []
},
33: {
id: 33,
title: 'Turkey',
childIds: []
},
34: {
id: 34,
title: 'Oceania',
childIds: [35, 36, 37, 38, 39, 40, 41],
},
35: {
id: 35,
title: 'Australia',
childIds: []
},
36: {
id: 36,
title: 'Bora Bora (French Polynesia)',
childIds: []
},
37: {
id: 37,
title: 'Easter Island (Chile)',
childIds: []
},
38: {
id: 38,
title: 'Fiji',
childIds: []
},
39: {
id: 40,
title: 'Hawaii (the USA)',
childIds: []
},
40: {
id: 40,
title: 'New Zealand',
childIds: []
},
41: {
id: 41,
title: 'Vanuatu',
childIds: []
},
42: {
id: 42,
title: 'Moon',
childIds: [43, 44, 45]
},
43: {
id: 43,
title: 'Rheita',
childIds: []
},
44: {
id: 44,
title: 'Piccolomini',
childIds: []
},
45: {
id: 45,
title: 'Tycho',
childIds: []
},
46: {
id: 46,
title: 'Mars',
childIds: [47, 48]
},
47: {
id: 47,
title: 'Corn Town',
childIds: []
},
48: {
id: 48,
title: 'Green Hill',
childIds: []
}
};
现在 state 已经"扁平化"(也称为"规范化"),更新嵌套项会变得更加容易。
现在要删除一个地点,您只需要更新两个 state 级别:
- 其 父级 地点的更新版本应该从其
childIds
数组中排除已删除的 ID。 - 其根级"表"对象的更新版本应包括父级地点的更新版本。
下面是展示如何处理它的一个示例:
react
import { useState } from 'react';
import { initialTravelPlan } from './places.js';
export default function TravelPlan() {
const [plan, setPlan] = useState(initialTravelPlan);
function handleComplete(parentId, childId) {
const parent = plan[parentId];
// 创建一个其父级地点的新版本
// 但不包括子级 ID。
const nextParent = {
...parent,
childIds: parent.childIds
.filter(id => id !== childId)
};
// 更新根 state 对象...
setPlan({
...plan,
// ...以便它拥有更新的父级。
[parentId]: nextParent
});
}
const root = plan[0];
const planetIds = root.childIds;
return (
<>
<h2>Places to visit</h2>
<ol>
{planetIds.map(id => (
<PlaceTree
key={id}
id={id}
parentId={0}
placesById={plan}
onComplete={handleComplete}
/>
))}
</ol>
</>
);
}
function PlaceTree({ id, parentId, placesById, onComplete }) {
const place = placesById[id];
const childIds = place.childIds;
return (
<li>
{place.title}
<button onClick={() => {
onComplete(parentId, id);
}}>
Complete
</button>
{childIds.length > 0 &&
<ol>
{childIds.map(childId => (
<PlaceTree
key={childId}
id={childId}
parentId={id}
placesById={placesById}
onComplete={onComplete}
/>
))}
</ol>
}
</li>
);
}
你确实可以随心所欲地嵌套 state,但是将其"扁平化"可以解决许多问题。这使得 state 更容易更新,并且有助于确保在嵌套对象的不同部分中没有重复。有时候,你也可以通过将一些嵌套 state 移动到子组件中来减少 state 的嵌套。这对于不需要保存的短暂 UI 状态非常有效,比如一个选项是否被悬停。
摘要
- 如果两个 state 变量总是一起更新,请考虑将它们合并为一个。
- 仔细选择你的 state 变量,以避免创建"极难处理"的 state。
- 用一种减少出错更新的机会的方式来构建你的 state。
- 避免冗余和重复的 state,这样您就不需要保持同步。
- 除非您特别想防止更新,否则不要将 props 放入 state 中。
- 对于选择类型的 UI 模式,请在 state 中保存 ID 或索引而不是对象本身。
- 如果深度嵌套 state 更新很复杂,请尝试将其展开扁平化。
在组件间共享状态
有时候,你希望两个组件的状态始终同步更改。要实现这一点,你可以将相关state从这两个组件上移除,并把state放到它们的公共父级,在通过props将state传递给这两个组件。这被称为"状态提升",这是编写 React 代码时常做的事。
举例说明一下状态提升
在这个例子中,父组件 Accordion
渲染了 2 个独立的 Panel
组件。
- accordion
Panel
Panel
每个 Panel
组件都有一个布尔值 isActive
,用于控制其内容是否可见。
请点击 2 个面板中的显示按钮:
react
import { useState } from 'react';
function Panel({ title, children }) {
const [isActive, setIsActive] = useState(false);
return (
<section className="panel">
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={() => setIsActive(true)}>
显示
</button>
)}
</section>
);
}
export default function Accordion() {
return (
<>
<h2>哈萨克斯坦,阿拉木图</h2>
<Panel title="关于">
阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
</Panel>
<Panel title="词源">
这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中"苹果"的意思,经常被翻译成"苹果之乡"。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
</Panel>
</>
);
}
假设现在您想改变这种行为,以便在任何时候只展开一个面板。在这种设计下,展开第二个面板应会折叠第一个面板,该如何做到呢?
要协调好这两个面板,我们需要分 3 步将状态"提升"到他们的父组件中。
- 从子组件中 移除 state 。
- 从父组件 传递 硬编码数据。
- 为共同的父组件添加 state ,并将其与事件处理函数一起向下传递。
这样,Accordion
组件就可以控制 2 个 Panel
组件,保证同一时间只能展开一个。
第一步:从子组件中移除状态
你将把 Panel
组件对 isActive
的控制权交给他们的父组件。这意味着,父组件会将 isActive
作为 prop
传给子组件 Panel
。我们先从 Panel
组件中 删除下面这一行:
const [isActive, setIsActive] = useState(false);
然后,把 isActive
加入 Panel
组件的 props
中:
function Panel({ title, children, isActive }) {
现在 Panel
的父组件就可以通过 向下传递 prop 来 控制 isActive
。但相反地,Panel
组件对 isActive
的值 没有控制权 ------ 现在完全由父组件决定!
第二步:从公共父组件传递硬编码数据
为了实现状态提升,必须定位到你想协调的两个子组件最近的公共父组件:
-
Accordion
(最近的公共父组件)
Panel
Panel
在这个例子中,公共父组件是 Accordion
。因为它位于两个面板之上,可以控制它们的 props,所以它将成为当前激活面板的"控制之源"。通过 Accordion
组件将硬编码值 isActive
(例如 true
)传递给两个面板:
react
import { useState } from 'react';
export default function Accordion() {
return (
<>
<h2>哈萨克斯坦,阿拉木图</h2>
<Panel title="关于" isActive={true}>
阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
</Panel>
<Panel title="词源" isActive={true}>
这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中"苹果"的意思,经常被翻译成"苹果之乡"。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
</Panel>
</>
);
}
function Panel({ title, children, isActive }) {
return (
<section className="panel">
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={() => setIsActive(true)}>
显示
</button>
)}
</section>
);
}
第三步:为公共父组件添加状态
状态提升通常会改变原状态的数据存储类型。
在这个例子中,一次只能激活一个面板。这意味着 Accordion
这个父组件需要记录 哪个 面板是被激活的面板。我们可以用数字作为当前被激活 Panel
的索引,而不是 boolean
值:
react
const [activeIndex, setActiveIndex] = useState(0);
当 activeIndex
为 0
时,激活第一个面板,为 1
时,激活第二个面板。
在任意一个 Panel
中点击"显示"按钮都需要更改 Accordion
中的激活索引值。 Panel
中无法直接设置状态 activeIndex
的值,因为该状态是在 Accordion
组件内部定义的。 Accordion
组件需要 显式允许 Panel
组件通过 将事件处理程序作为 prop 向下传递 来更改其状态:
react
<>
<Panel
isActive={activeIndex === 0}
onShow={() => setActiveIndex(0)}
>
...
</Panel>
<Panel
isActive={activeIndex === 1}
onShow={() => setActiveIndex(1)}
>
...
</Panel>
</>
现在 Panel
组件中的 <button>
将使用 onShow
这个属性作为其点击事件的处理程序:
react
import { useState } from 'react';
export default function Accordion() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<>
<h2>哈萨克斯坦,阿拉木图</h2>
<Panel
title="关于"
isActive={activeIndex === 0}
onShow={() => setActiveIndex(0)}
>
阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
</Panel>
<Panel
title="词源"
isActive={activeIndex === 1}
onShow={() => setActiveIndex(1)}
>
这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中"苹果"的意思,经常被翻译成"苹果之乡"。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
</Panel>
</>
);
}
function Panel({
title,
children,
isActive,
onShow
}) {
return (
<section className="panel">
<h3>{title}</h3>
{isActive ? (
<p>{children}</p>
) : (
<button onClick={onShow}>
显示
</button>
)}
</section>
);
}
这样,我们就完成了对状态的提升!将状态移至公共父组件中可以让你更好的管理这两个面板。使用激活索引值代替之前的 是否显示
标识确保了一次只能激活一个面板。而通过向下传递事件处理函数可以让子组件修改父组件的状态
每个状态都对应唯一的数据源
在 React
应用中,很多组件都有自己的状态。一些状态可能"活跃"在叶子组件(树形结构最底层的组件)附近,例如输入框。另一些状态可能在应用程序顶部"活动"。例如,客户端路由库也是通过将当前路由存储在 React
状态中,利用 props
将状态层层传递下去来实现的!
对于每个独特的状态,都应该存在且只存在于一个指定的组件中作为 state 。这一原则也被称为拥有 "可信单一数据源"。它并不意味着所有状态都存在一个地方------对每个状态来说,都需要一个特定的组件来保存这些状态信息。你应该 将状态提升 到公共父级,或 将状态传递 到需要它的子级中,而不是在组件之间复制共享的状态。
你的应用会随着你的操作而变化。当你将状态上下移动时,你依然会想要确定每个状态在哪里"活跃"。这都是过程的一部分!
想了解在更多组件中的实践,请阅读 React 思维.
摘要
- 当你想要整合两个组件时,将它们的 state 移动到共同的父组件中。
- 然后在父组件中通过
props
把信息传递下去。 - 最后,向下传递事件处理程序,以便子组件可以改变父组件的 state 。
- 考虑该将组件视为"受控"(由 prop 驱动)或是"不受控"(由 state 驱动)是十分有益的。