React 提供了一种声明式的方式来操作 UI。你无需直接操作 UI 的各个部分,而是描述组件可以处于的不同状态,并根据用户输入在这些状态之间切换。这与设计师思考 UI 的方式类似。
你将学习
- 声明式 UI 编程与命令式 UI 编程有何不同
- 如何枚举组件可以处于的不同视觉状态
- 如何从代码中触发不同视觉状态之间的变化
声明式 UI 与命令式 UI 的比较
设计 UI 交互时,你可能会考虑 UI 如何响应用户操作而发生变化。考虑一个允许用户提交答案的表单
- 当你输入内容到表单中时,“提交”按钮将被启用。
- 当你按下“提交”时,表单和按钮都将被禁用,并且会出现一个旋转指示器。
- 如果网络请求成功,表单将被隐藏,并且“谢谢”消息将出现。
- 如果网络请求失败,将出现错误消息,并且表单将再次启用。
在命令式编程中,以上内容直接对应于你如何实现交互。你必须编写精确的指令来操作 UI,具体取决于刚刚发生的情况。以下是另一种思考方式:想象一下,你坐在一辆车的旁边,告诉司机该怎么走。
他们不知道你想去哪里,他们只是遵循你的命令。(如果你方向错了,你就会到达错误的地方!)它被称为命令式,因为你必须“命令”每个元素,从旋转指示器到按钮,告诉计算机如何更新 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) { // Pretend it's hitting the network. return new Promise((resolve, reject) => { setTimeout(() => { if (answer.toLowerCase() === 'istanbul') { resolve(); } else { reject(new Error('Good guess but a wrong answer. Try again!')); } }, 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 元素或新的交互将需要仔细检查所有现有代码,以确保你没有引入错误(例如,忘记显示或隐藏某些内容)。
React 的创建就是为了解决这个问题。
在 React 中,你不会直接操作 UI——这意味着你不会直接启用、禁用、显示或隐藏组件。相反,你声明你想显示什么,React 会找出如何更新 UI。想想坐出租车并告诉司机你想去哪里,而不是告诉他们确切的转弯路线。让司机把你送到目的地是司机的责任,他们甚至可能知道你没有考虑到的捷径!
声明式地思考 UI
你已经看到了如何在上面命令式地实现一个表单。为了更好地理解如何在 React 中思考,你将学习如何在下面用 React 重现这个 UI
- 识别你的组件的不同视觉状态
- 确定触发这些状态变化的原因
- 使用
useState
在内存中表示状态 - 移除任何不必要的 state 变量
- 连接事件处理程序以设置状态
步骤 1:识别组件的不同视觉状态
在计算机科学中,你可能会听到关于“状态机”处于几种“状态”之一的情况。如果你与设计师合作,你可能已经看到过不同“视觉状态”的模型。React 位于设计和计算机科学的交汇点,因此这两个概念都是灵感的来源。
首先,你需要将用户可能看到的UI的所有不同“状态”可视化。
- 空:表单具有禁用的“提交”按钮。
- 输入中:表单具有启用的“提交”按钮。
- 提交中:表单完全禁用。显示加载动画。
- 成功:显示“谢谢”消息而不是表单。
- 错误:与“输入中”状态相同,但带有一个额外的错误消息。
就像设计师一样,在添加逻辑之前,你希望为不同的状态“模拟”或创建“模型”。例如,这是一个仅表单视觉部分的模型。此模型由名为status
的属性控制,其默认值为'empty'
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
属性“控制”
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> </> ); }
深入探讨
如果组件有很多视觉状态,则在一个页面上显示所有这些状态可能会很方便。
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> ))} </> ); }
这样的页面通常被称为“动态样式指南”或“故事书”。
步骤 2:确定触发这些状态变化的原因
你可以响应两种类型的输入来触发状态更新。
- 人为输入,例如单击按钮、在字段中键入、导航链接。
- 计算机输入,例如网络响应到达、超时完成、图像加载。
在这两种情况下,你必须设置状态变量以更新UI。对于你正在开发的表单,你需要响应一些不同的输入来更改状态。
- 更改文本输入(人为)应根据文本框是否为空,将其从空状态切换到输入中状态或返回。
- 单击提交按钮(人为)应将其切换到提交中状态。
- 成功的网络响应(计算机)应将其切换到成功状态。
- 失败的网络响应(计算机)应将其切换到带有匹配错误消息的错误状态。
为了帮助可视化此流程,尝试在纸上将每个状态绘制为带标签的圆圈,并将两个状态之间的每次更改绘制为箭头。你可以以此方式绘制许多流程,并在实现之前就解决错误。
步骤 3:使用useState
在内存中表示状态
接下来,你需要使用useState
在内存中表示组件的视觉状态。简洁性是关键:每个状态都是一个“活动部件”,并且你希望“活动部件”越少越好。越复杂,错误就越多!
从必须存在的那个状态开始。例如,你需要存储输入的答案
,以及(如果存在)错误
以存储最后的错误。
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:移除任何不必要的 state 变量
您需要避免状态内容中的重复,因此只跟踪必要的内容。花一点时间重构状态结构将使您的组件更容易理解,减少重复,并避免意外含义。您的目标是防止内存中的状态无法表示任何您希望用户看到的有效UI的情况。(例如,您永远不想同时显示错误消息并禁用输入,否则用户将无法纠正错误!)
以下是一些您可以针对状态变量提出的问题
- 此状态是否会导致悖论?例如,
isTyping
和isSubmitting
不能同时为true
。悖论通常意味着状态约束不足。两个布尔值有四种可能的组合,但只有三种对应于有效状态。要移除“不可能”的状态,您可以将它们组合成一个status
,它必须是三个值之一:'typing'
、'submitting'
或'success'
。 - 另一个状态变量中是否已存在相同的信息?另一个悖论:
isEmpty
和isTyping
不能同时为true
。通过将它们设置为单独的状态变量,您冒着它们不同步并导致错误的风险。幸运的是,您可以移除isEmpty
,并改用检查answer.length === 0
。 - 您可以从另一个状态变量的反面获得相同的信息吗?
isError
不需要,因为您可以改用检查error !== null
。
清理后,您只剩下 3 个(从 7 个减少!)必要的状态变量
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'
您知道它们是必要的,因为如果不移除它们中的任何一个,功能就会中断。
深入探讨
这三个变量足以表示此表单的状态。但是,仍然存在一些不完全合理的中间状态。例如,当 status
为 'success'
时,非空 error
没有意义。为了更精确地建模状态,您可以将其提取到 reducer 中。Reducers 允许您将多个状态变量统一到单个对象中,并整合所有相关逻辑!
步骤 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>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(命令式)。
- 开发组件时
- 确定其所有视觉状态。
- 确定状态变化的人工和计算机触发器。
- 使用
useState
建模状态。 - 移除非必要状态以避免错误和悖论。
- 连接事件处理程序以设置状态。
挑战 1的 3: 添加和删除 CSS 类
使其单击图片可移除外部 <div>
的 background--active
CSS 类,但添加 picture--active
类到 <img>
。再次单击背景应恢复原始 CSS 类。
视觉上,您应该预期单击图片会移除紫色背景并突出显示图片边框。单击图片外部会突出显示背景,但会移除图片边框高亮。
export default function Picture() { return ( <div className="background background--active"> <img className="picture" alt="Rainbow houses in Kampung Pelangi, Indonesia" src="https://i.imgur.com/5qwVYb1.jpeg" /> </div> ); }