React 提供了一种声明式的方式来操作 UI。你不是直接操作 UI 的某一部分,而是描述组件可能处于的不同状态,并根据用户输入在这些状态之间切换。这和设计师思考 UI 的方式很相似。
You will learn
- 声明式 UI 编程与命令式 UI 编程有何不同
- 如何列举组件可能处于的不同视觉状态
- 如何从代码中触发不同视觉状态之间的变化
声明式 UI 与命令式 UI 的比较
当你设计 UI 交互时,你大概会想到 UI 如何随着用户操作而变化。考虑一个让用户提交答案的表单:
- 当你在表单中输入内容时,“提交”按钮变为可用。
- 当你按下“提交”时,表单和按钮都会变为不可用,并且会出现一个加载动画。
- 如果网络请求成功,表单会被隐藏,而“谢谢你”消息会出现。
- 如果网络请求失败,会出现一条错误消息,并且表单再次变为可用。
在命令式编程中,上面的描述会直接对应你如何实现交互。你必须根据刚刚发生的事情,编写精确的指令来操作 UI。换个方式来理解:想象你坐在车里,告诉旁边的人每一个转弯该往哪里走。

Illustrated by Rachel Lee Nabors
他们不知道你想去哪里,只是照着你的命令做。(如果你给的路线错了,最后就会到错的地方!)之所以叫命令式,是因为你必须“命令”每个元素,从加载动画到按钮,告诉计算机如何更新 UI。
在这个命令式 UI 编程示例中,表单是在没有 React 的情况下构建的。它只使用浏览器的 DOM:
async function handleFormSubmit(e) { e.preventDefault(); disable(textarea); disable(button); show(loadingMessage); hide(errorMessage); try { await submitForm(textarea.value); show(successMessage); hide(form); } catch (err) { show(errorMessage); errorMessage.textContent = err.message; } finally { hide(loadingMessage); enable(textarea); enable(button); } } function handleTextareaChange() { if (textarea.value.length === 0) { disable(button); } else { enable(button); } } function hide(el) { el.style.display = 'none'; } function show(el) { el.style.display = ''; } function enable(el) { el.disabled = false; } function disable(el) { el.disabled = true; } function submitForm(answer) { // 假装它在请求网络。 return new Promise((resolve, reject) => { setTimeout(() => { if (answer.toLowerCase() === 'istanbul') { resolve(); } else { reject(new Error('猜得不错,但答案错了。再试一次!')); } }, 1500); }); } let form = document.getElementById('form'); let textarea = document.getElementById('textarea'); let button = document.getElementById('button'); let loadingMessage = document.getElementById('loading'); let errorMessage = document.getElementById('error'); let successMessage = document.getElementById('success'); form.onsubmit = handleFormSubmit; textarea.oninput = handleTextareaChange;
对于独立的例子来说,命令式地操作 UI 还算足够好用,但在更复杂的系统中,管理起来会变得指数级更困难。想象一下要更新一个充满类似表单的页面。添加一个新的 UI 元素或新的交互,都需要仔细检查所有现有代码,以确保你没有引入 bug(例如,忘记显示或隐藏某些内容)。
React 就是为了解决这个问题而构建的。
在 React 中,你不会直接操作 UI——也就是说,你不会直接启用、禁用、显示或隐藏组件。相反,你会**声明你想要显示什么,**然后由 React 来决定如何更新 UI。可以把它想象成坐进出租车,告诉司机你想去哪里,而不是告诉他每一个转弯该怎么走。把你送到目的地是司机的工作,而且他们甚至可能知道一些你没想到的捷径!

Illustrated by Rachel Lee Nabors
声明式地思考 UI
你已经看过上面如何用命令式方式实现一个表单。为了更好地理解如何在 React 中思考,下面你将一步步在 React 中重新实现这个 UI:
- 识别组件的不同视觉状态
- 确定是什么触发了这些状态变化
- 使用
useState在内存中表示状态 - 移除任何非必要的状态变量
- 连接事件处理函数来设置状态
第 1 步:识别组件的不同视觉状态
在计算机科学中,你可能会听到“状态机”处于若干“状态”之一。如果你和设计师合作过,你可能见过不同“视觉状态”的设计稿。React 处于设计和计算机科学的交汇点,因此这两种想法都是灵感来源。
首先,你需要把用户可能看到的 UI 的所有不同“状态”都想象出来:
- 空白:表单有一个不可用的“提交”按钮。
- 输入中:表单有一个可用的“提交”按钮。
- 提交中:表单完全不可用。显示加载动画。
- 成功:显示“谢谢你”消息,而不是表单。
- 错误:与输入中状态相同,但额外显示一条错误消息。
就像设计师一样,在添加逻辑之前,你最好先为不同状态“画出原型”或创建“模拟图”。例如,下面只是表单视觉部分的一个模拟图。这个模拟图由一个名为 status 的 prop 控制,默认值为 'empty':
export default function Form({ status = 'empty' }) { if (status === 'success') { return <h1>答对了!</h1> } return ( <> <h2>城市测验</h2> <p> 在哪个城市有一块把空气变成可饮用水的广告牌? </p> <form> <textarea /> <br /> <button> 提交 </button> </form> </> ) }
这个 prop 叫什么都可以,命名并不重要。试着把 status = 'empty' 改成 status = 'success',看看成功消息出现。模拟图可以让你在编写任何逻辑之前快速迭代 UI。下面是同一个组件更完整的原型,仍然由 status prop“控制”:
export default function Form({ // 试试 'submitting'、'error'、'success': status = 'empty' }) { if (status === 'success') { return <h1>答对了!</h1> } return ( <> <h2>城市测验</h2> <p> 在哪个城市有一块把空气变成可饮用水的广告牌? </p> <form> <textarea disabled={ status === 'submitting' } /> <br /> <button disabled={ status === 'empty' || status === 'submitting' }> 提交 </button> {status === 'error' && <p className="Error"> 猜得不错,但答案错了。再试一次! </p> } </form> </> ); }
Deep Dive
如果一个组件有很多视觉状态,把它们都显示在同一页上会很方便:
import Form from './Form.js'; let statuses = [ 'empty', 'typing', 'submitting', 'success', 'error', ]; export default function App() { return ( <> {statuses.map(status => ( <section key={status}> <h4>表单({status}):</h4> <Form status={status} /> </section> ))} </> ); }
这样的页面常被称为“活的样式指南”或“Storybook”。
第 2 步:确定是什么触发这些状态变化
你可以响应两类输入来触发状态更新:
- 人为输入,例如点击按钮、在字段中输入、点击链接导航。
- 计算机输入,例如网络响应到达、超时完成、图片加载完成。


Illustrated by Rachel Lee Nabors
在这两种情况下,你都必须设置状态变量来更新 UI。 对于你正在开发的表单,你需要根据几种不同输入来改变状态:
- 更改文本输入(人为)应该会把它从空白状态切换到输入中状态,或者相反,取决于文本框是否为空。
- 点击提交按钮(人为)应该会把它切换到提交中状态。
- 成功的网络响应(计算机)应该会切换到成功状态。
- 失败的网络响应(计算机)应该会切换到带有匹配错误消息的错误状态。
为了帮助你可视化这个流程,试着把每个状态画成一个带标签的圆圈,再把两个状态之间的变化画成箭头。你可以这样草绘出很多流程,并在实现之前就找出 bug。


表单状态
第 3 步:使用 useState 在内存中表示状态
接下来,你需要使用 useState. 在内存中表示组件的视觉状态。简洁是关键:每一份状态都是一个“活动部件”,而你希望这样的“活动部件”越少越好。 复杂度越高,bug 就越多!
先从绝对必须存在的状态开始。例如,你需要存储输入框的 answer,以及(如果存在)存储上一次错误的 error:
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);然后,你还需要一个状态变量来表示你想显示哪一种视觉状态。通常,在内存中表示这件事的方法不止一种,所以你需要进行尝试。
如果你一时想不出最好的方式,可以先添加足够多的状态,确保你完全确定覆盖了所有可能的视觉状态:
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);你最初的想法很可能不是最好的,但没关系——重构状态本身就是这个过程的一部分!
第 4 步:移除任何非必要的状态变量
你希望避免状态内容中的重复,这样你只追踪必要的信息。花一点时间重构状态结构,会让你的组件更容易理解、减少重复,并避免不想要的含义。你的目标是防止出现这种情况:内存中的状态并不代表你希望用户看到的任何有效 UI。(例如,你绝不希望同时显示错误消息和禁用输入框,否则用户就无法修正错误了!)
你可以针对状态变量问自己几个问题:
- 这个状态会造成悖论吗? 例如,
isTyping和isSubmitting不可能同时为true。悖论通常意味着状态约束得不够。两个布尔值有四种组合,但只有三种对应有效状态。要移除这个“不可能”状态,你可以把它们合并成一个必须取三个值之一的status:'typing'、'submitting'或'success'。 - 同样的信息是否已经可以从另一个状态变量中得到? 另一个悖论:
isEmpty和isTyping不可能同时为true。把它们拆成独立状态变量,会有不同步并引发 bug 的风险。幸运的是,你可以移除isEmpty,改为检查answer.length === 0。 - 是否可以通过另一个状态变量的反面得到同样的信息? 不需要
isError,因为你可以改为检查error !== null。
经过这一步清理后,你只剩下 3 个(从 7 个减少到了 3 个!)必要状态变量:
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing'、'submitting' 或 'success'你知道它们是必要的,因为如果移除其中任何一个,功能就会被破坏。
Deep Dive
这三个变量已经足够表示这个表单的状态了。不过,仍然存在一些中间状态并不完全合理。例如,当 status 是 'success' 时,非空的 error 就没有意义。为了更精确地建模状态,你可以把它提取到 reducer 中。 Reducer 可以让你把多个状态变量统一到一个对象里,并将所有相关逻辑集中起来!
第 5 步:连接事件处理函数来设置状态
最后,创建更新状态的事件处理函数。下面是最终版本,已经连接好了所有事件处理函数:
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); }); }
虽然这段代码比原来的命令式示例更长,但它脆弱性要小得多。把所有交互都表达成状态变化,可以让你以后在不破坏现有视觉状态的情况下引入新的视觉状态。它还允许你在不改变交互逻辑本身的前提下,改变每个状态下应该显示什么。
Recap
- 声明式编程意味着描述每个视觉状态下的 UI,而不是微观管理 UI(命令式)。
- 开发组件时:
- 识别它所有的视觉状态。
- 确定触发状态变化的人为和计算机输入。
- 使用
useState建模状态。 - 移除非必要状态以避免 bug 和悖论。
- 连接事件处理函数来设置状态。
Challenge 1 of 3: 添加和移除 CSS 类
使得点击图片时,会从外层 <div> 移除 background--active 这个 CSS 类,但会给 <img> 添加 picture--active 类。再次点击背景时,应恢复原始的 CSS 类。
从视觉上看,你应该预期点击图片会移除紫色背景,并高亮图片边框。点击图片外部会高亮背景,但移除图片边框的高亮。
export default function Picture() { return ( <div className="background background--active"> <img className="picture" alt="印度尼西亚 Kampung Pelangi 的彩虹房屋" src="https://react.dev/images/docs/scientists/5qwVYb1.jpeg" /> </div> ); }