随着应用的增长,你会更需要有意识地组织状态,以及让数据如何在组件之间流动。冗余或重复的状态是常见的 bug 来源。在这一章中,你将学习如何良好地组织状态,如何让状态更新逻辑更易维护,以及如何在相距较远的组件之间共享状态。
In this chapter
用状态响应输入
在 React 中,你不会直接通过代码修改 UI。例如,你不会写出诸如“禁用按钮”“启用按钮”“显示成功消息”之类的命令。相反,你会描述在组件的不同视觉状态下你想看到的 UI(“初始状态”“输入状态”“成功状态”),然后根据用户输入触发状态变化。这类似于设计师思考 UI 的方式。
下面是一个使用 React 构建的测验表单。注意它如何使用 status 状态变量来决定是否启用或禁用提交按钮,以及是否改为显示成功消息。
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); }); }
选择状态结构
良好地组织状态,可以决定一个组件是易于修改和调试,还是持续成为 bug 来源。最重要的原则是:状态不应包含冗余或重复的信息。如果有不必要的状态,就很容易忘记更新它,从而引入 bug!
例如,这个表单有一个冗余的 fullName 状态变量:
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> </> ); }
你可以删除它,并在组件渲染时计算 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> </> ); }
这看起来可能只是一个小改动,但 React 应用中很多 bug 都是这样修复的。
在组件之间共享状态
有时你希望两个组件的状态始终一起变化。要做到这一点,就把它们各自的状态删除,将状态提升到它们最近的共同父组件中,然后通过 props 传递给它们。这被称为“状态提升”,也是你编写 React 代码时最常做的事情之一。
在这个例子中,一次只能有一个面板处于活动状态。为实现这一点,不再把活动状态保存在每个单独的面板内部,而是由父组件持有该状态,并为其子组件指定 props。
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 的自动行为已经足够好。默认情况下,React 会保留与之前渲染的组件树“匹配”的那部分树。
然而,有时这并不是你想要的。在这个聊天应用中,输入一条消息后再切换收件人,并不会重置输入框。这可能会导致用户不小心把消息发送给错误的人:
import { useState } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; export default function Messenger() { const [to, setTo] = useState(contacts[0]); return ( <div> <ContactList contacts={contacts} selectedContact={to} onSelect={contact => setTo(contact)} /> <Chat contact={to} /> </div> ) } const contacts = [ { name: 'Taylor', email: 'taylor@mail.com' }, { name: 'Alice', email: 'alice@mail.com' }, { name: 'Bob', email: 'bob@mail.com' } ];
React 允许你覆盖默认行为,并通过传递不同的 key 强制组件重置其状态,就像 <Chat key={email} /> 一样。这告诉 React:如果收件人不同,就应将其视为一个不同的 Chat 组件,需要用新数据(以及输入框等 UI)从头重新创建。现在,在收件人之间切换时会重置输入框——即使你渲染的是同一个组件。
import { useState } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; export default function Messenger() { const [to, setTo] = useState(contacts[0]); return ( <div> <ContactList contacts={contacts} selectedContact={to} onSelect={contact => setTo(contact)} /> <Chat key={to.email} contact={to} /> </div> ) } const contacts = [ { name: 'Taylor', email: 'taylor@mail.com' }, { name: 'Alice', email: 'alice@mail.com' }, { name: 'Bob', email: 'bob@mail.com' } ];
将状态逻辑提取到 reducer 中
在许多事件处理函数中分散着大量状态更新的组件,可能会变得难以管理。对于这种情况,你可以把所有状态更新逻辑集中到组件外面的一个单独函数中,这个函数称为 “reducer”。这样你的事件处理函数就会变得简洁,因为它们只需要说明用户的 “动作”。在文件底部,reducer 函数会指定状态应该如何响应每个动作进行更新!
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer( tasksReducer, initialTasks ); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId }); } return ( <> <h1>布拉格行程</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [...tasks, { id: action.id, text: action.text, done: false }]; } case 'changed': { return tasks.map(t => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter(t => t.id !== action.id); } default: { throw Error('未知的动作:' + action.type); } } } let nextId = 3; const initialTasks = [ { id: 0, text: '参观卡夫卡博物馆', done: true }, { id: 1, text: '看木偶戏', done: false }, { id: 2, text: '列侬墙照片', done: false } ];
使用 context 深层传递数据
通常,你会通过 props 将信息从父组件传递给子组件。但如果你需要把某个 prop 经过许多组件层层传递下去,或者许多组件都需要同样的信息,传递 props 可能会变得不方便。Context 允许父组件把某些信息提供给它下面树中的任意组件——不管层级有多深——而无需显式地通过 props 传递。
这里,Heading 组件通过“询问”最近的 Section 来确定自己的标题级别。每个 Section 通过询问父 Section 并在其基础上加一来跟踪自己的级别。每个 Section 都会通过 context 向其下方的所有组件提供信息,而不需要传递 props。
import Heading from './Heading.js'; import Section from './Section.js'; export default function Page() { return ( <Section> <Heading>标题</Heading> <Section> <Heading>标题</Heading> <Heading>标题</Heading> <Heading>标题</Heading> <Section> <Heading>副标题</Heading> <Heading>副标题</Heading> <Heading>副标题</Heading> <Section> <Heading>二级副标题</Heading> <Heading>二级副标题</Heading> <Heading>二级副标题</Heading> </Section> </Section> </Section> </Section> ); }
使用 reducer 和 context 扩展规模
Reducer 可以让你将组件的状态更新逻辑集中起来。Context 可以让你将信息向下传递到更深层的其他组件。你可以将 reducer 和 context 结合起来管理复杂界面的状态。
使用这种方法时,具有复杂状态的父组件会通过 reducer 来管理它。树中任意深处的其他组件都可以通过 context 读取它的状态。它们也可以分发动作来更新该状态。
import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; import { TasksProvider } from './TasksContext.js'; export default function TaskApp() { return ( <TasksProvider> <h1>京都休息日</h1> <AddTask /> <TaskList /> </TasksProvider> ); }