良好地组织状态可以决定一个组件是易于修改和调试,还是不断产生 bug。以下是一些在组织状态时你应该考虑的建议。
You will learn
- 何时使用单个状态变量与多个状态变量
- 组织状态时应避免什么
- 如何修复状态结构中的常见问题
状态结构的原则
当你编写一个保存某些状态的组件时,你需要决定使用多少个状态变量,以及它们的数据形状应该是什么。即使状态结构不够理想,也仍然可以写出正确的程序,但有一些原则可以帮助你做出更好的选择:
- 将相关状态组合在一起。 如果你总是同时更新两个或更多状态变量,考虑将它们合并为一个状态变量。
- 避免状态中的矛盾。 当状态被组织成多个部分可能互相矛盾、彼此“不一致”时,就会给错误留下空间。尽量避免这种情况。
- 避免冗余状态。 如果你可以在渲染期间从组件的 props 或其已有的状态变量计算出某些信息,就不应该把这些信息放进该组件的状态里。
- 避免状态重复。 当相同的数据在多个状态变量之间,或者在嵌套对象中被重复保存时,就很难保持它们同步。尽可能减少重复。
- 避免深层嵌套状态。 过深的层级状态不太方便更新。尽可能将状态结构设计得扁平一些。
这些原则背后的目标是 让状态易于更新,同时不引入错误。从状态中移除冗余和重复数据,有助于确保各部分始终保持同步。这类似于数据库工程师希望对数据库结构进行 “规范化” 以减少 bug 的做法。借用阿尔伯特·爱因斯坦的话来说,“让你的状态尽可能简单——但不要更简单。”
现在让我们看看这些原则如何在实践中发挥作用。
将相关状态组合在一起
你有时可能会在使用单个状态变量还是多个状态变量之间犹豫不决。
你应该这样做吗?
const [x, setX] = useState(0);
const [y, setY] = useState(0);还是这样?
const [position, setPosition] = useState({ x: 0, y: 0 });从技术上讲,这两种方式都可以。但如果某两个状态变量总是一起变化,那么把它们统一到一个状态变量中可能是个好主意。 这样你就不会忘记始终让它们保持同步,例如在这个例子中,移动鼠标会更新红点的两个坐标:
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> ) }
另一种将数据组合成对象或数组的情况是,你不知道自己需要多少个状态。比如,当你有一个表单,用户可以添加自定义字段时,这样做就很有帮助。
避免状态中的矛盾
下面是一个酒店反馈表单,其中有 isSending 和 isSent 两个状态变量:
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>感谢你的反馈!</h1> } return ( <form onSubmit={handleSubmit}> <p>你在 The Prancing Pony 住得怎么样?</p> <textarea disabled={isSending} value={text} onChange={e => setText(e.target.value)} /> <br /> <button disabled={isSending} type="submit" > 发送 </button> {isSending && <p>正在发送...</p>} </form> ); } // 假装发送一条消息。 function sendMessage(text) { return new Promise(resolve => { setTimeout(resolve, 2000); }); }
虽然这段代码可以工作,但它为“不可能”的状态留下了空间。例如,如果你忘记同时调用 setIsSent 和 setIsSending,就可能出现 isSending 和 isSent 同时为 true 的情况。组件越复杂,就越难理解发生了什么。
由于 isSending 和 isSent 不应该同时为 true,最好把它们替换为一个 status 状态变量,它可以取 三个 有效状态中的一个: 'typing'(初始)、'sending' 和 'sent':
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>感谢你的反馈!</h1> } return ( <form onSubmit={handleSubmit}> <p>你在 The Prancing Pony 住得怎么样?</p> <textarea disabled={isSending} value={text} onChange={e => setText(e.target.value)} /> <br /> <button disabled={isSending} type="submit" > 发送 </button> {isSending && <p>正在发送...</p>} </form> ); } // 假装发送一条消息。 function sendMessage(text) { return new Promise(resolve => { setTimeout(resolve, 2000); }); }
为了提高可读性,你仍然可以声明一些常量:
const isSending = status === 'sending';
const isSent = status === 'sent';但它们不是状态变量,所以你不需要担心它们彼此不同步。
避免冗余状态
如果你可以在渲染期间从组件的 props 或其已有的状态变量计算出某些信息,你不应该把这些信息放进该组件的状态里。
例如,看看这个表单。它可以工作,但你能找出其中有任何冗余状态吗?
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>让我们为你办理入住</h2> <label> 名字:{' '} <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> 姓氏:{' '} <input value={lastName} onChange={handleLastNameChange} /> </label> <p> 你的票将签发给:<b>{fullName}</b> </p> </> ); }
这个表单有三个状态变量:firstName、lastName 和 fullName。然而,fullName 是冗余的。你总是可以在渲染期间根据 firstName 和 lastName 计算出 fullName,所以把它从状态中移除。
你可以这样做:
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>让我们为你办理入住</h2> <label> 名字:{' '} <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> 姓氏:{' '} <input value={lastName} onChange={handleLastNameChange} /> </label> <p> 你的票将签发给:<b>{fullName}</b> </p> </> ); }
这里,fullName 不是状态变量。相反,它是在渲染期间计算出来的:
const fullName = firstName + ' ' + lastName;因此,变更处理函数不需要做任何特殊事情来更新它。当你调用 setFirstName 或 setLastName 时,你会触发一次重新渲染,然后下一个 fullName 就会根据新的数据计算出来。
Deep Dive
冗余状态的一个常见例子是这样的代码:
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);这里,color 状态变量被初始化为 messageColor prop。问题在于,如果父组件之后传入了不同的 messageColor 值(例如从 'blue' 变成 'red'),color 状态变量 不会被更新! 这个状态只会在第一次渲染时初始化。
这就是为什么将某个 prop “镜像”到状态变量中会让人困惑。相反,请在代码中直接使用 messageColor prop。如果你想给它一个更短的名字,可以使用一个常量:
function Message({ messageColor }) {
const color = messageColor;这样它就不会和父组件传入的 prop 不同步了。
只有当你想要忽略某个特定 prop 的所有更新时,把 props “镜像”到 state 才有意义。按照约定,可以在 prop 名称前加上 initial 或 default,以明确表示它的新值会被忽略:
function Message({ initialColor }) {
// `color` 状态变量保存 `initialColor` 的 *第一个* 值。
// 对 `initialColor` prop 后续的变化会被忽略。
const [color, setColor] = useState(initialColor);避免状态中的重复
这个菜单列表组件可以让你从多个旅行零食中选择一个:
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 状态变量中。不过,这并不理想:selectedItem 的内容和 items 列表中的某一项是同一个对象。 这意味着关于该项目本身的信息在两个地方被重复保存了。
为什么这会有问题?我们把每一项都改成可编辑的:
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”,然后再编辑它,输入框会更新,但底部的标签不会反映这些编辑。 这是因为你有重复状态,而且你忘记更新 selectedItem 了。
虽然你也可以同时更新 selectedItem,但更简单的修复方式是移除重复。这个例子中,不再使用一个 selectedItem 对象(它与 items 里的对象重复),而是在状态中保存 selectedId,然后通过在 items 数组里查找该 ID 对应的项来得到 selectedItem:
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> </> ); }
之前的状态是这样重复的:
items = [{ id: 0, title: 'pretzels'}, ...]selectedItem = {id: 0, title: 'pretzels'}
但修改后就变成这样:
items = [{ id: 0, title: 'pretzels'}, ...]selectedId = 0
重复消失了,你只保留了必要的状态!
现在如果你编辑被选中的项目,下方消息会立即更新。这是因为 setItems 会触发重新渲染,而 items.find(...) 会找到标题已更新的项目。你不需要在状态中保留被选中的项目,因为只有被选中的 ID是必要的。其余内容都可以在渲染时计算出来。
避免深层嵌套的状态
想象一个由行星、大洲和国家组成的旅行计划。你可能会想用嵌套的对象和数组来组织它的状态,就像这个例子:
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: [] }] }] };
现在假设你想添加一个按钮,用来删除你已经访问过的某个地点。你会怎么做?更新嵌套状态 需要从变化的那一部分一直向上复制对象。删除一个深层嵌套的地点,意味着要复制它整个父级链。这类代码会非常冗长。
如果状态嵌套得太深,导致难以更新,可以考虑把它变“平”。 下面是一种重构这些数据的方法。与其使用树形结构,让每个 place 持有一个包含其子地点的数组,不如让每个地点持有一个包含其子地点 ID 的数组。然后再存一个从每个地点 ID 到对应地点的映射。
这种数据重构可能会让你联想到数据库表:
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: [] } };
现在状态是“平”的了(也叫“规范化”),更新嵌套项就更容易了。
为了删除一个地点,你现在只需要更新两层状态:
- 它的父级地点的更新版本,应该从
childIds数组中移除被删除的 ID。 - 根“表”对象的更新版本,应该包含父级地点的更新版本。
下面是一个实现示例:
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) }; // 更新根状态对象…… 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); }}> 完成 </button> {childIds.length > 0 && <ol> {childIds.map(childId => ( <PlaceTree key={childId} id={childId} parentId={id} placesById={placesById} onComplete={onComplete} /> ))} </ol> } </li> ); }
你可以按任意深度嵌套状态,但把它变“平”可以解决很多问题。这样会让状态更容易更新,也有助于确保你不会在嵌套对象的不同部分中产生重复数据。
Deep Dive
理想情况下,你还应该把已删除的项目(以及它们的子项!)从“表”对象中移除,以改善内存使用。这个版本就是这么做的。它还 使用了 Immer 来让更新逻辑更简洁。
{ "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": {} }
有时候,你也可以通过把部分嵌套状态移到子组件中来减少状态嵌套。这对于不需要持久保存的瞬时 UI 状态非常有效,比如某一项是否处于悬停状态。
Recap
- 如果两个状态变量总是一起更新,可以考虑把它们合并成一个。
- 仔细选择你的状态变量,避免创建“不可能”的状态。
- 以一种能降低更新出错概率的方式组织状态。
- 避免冗余和重复状态,这样你就不需要让它们保持同步。
- 不要把 props 放进 state,除非你明确想阻止更新。
- 对于选择这类 UI 模式,状态里保留 ID 或索引,而不是对象本身。
- 如果更新深层嵌套状态很复杂,尝试把它扁平化。
Challenge 1 of 4: 修复一个没有更新的组件
这个 Clock 组件接收两个 props:color 和 time。当你在下拉框中选择不同的颜色时,Clock 组件会从它的父组件接收到不同的 color prop。然而,出于某些原因,显示的颜色没有更新。为什么?修复这个问题。
import { useState } from 'react'; export default function Clock(props) { const [color, setColor] = useState(props.color); return ( <h1 style={{ color: color }}> {props.time} </h1> ); }