使用状态响应输入

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

  1. 识别你的组件的不同视觉状态
  2. 确定触发这些状态变化的原因
  3. 使用 useState 在内存中表示状态
  4. 移除任何不必要的 state 变量
  5. 连接事件处理程序以设置状态

步骤 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:确定触发这些状态变化的原因

你可以响应两种类型的输入来触发状态更新。

  • 人为输入,例如单击按钮、在字段中键入、导航链接。
  • 计算机输入,例如网络响应到达、超时完成、图像加载。
A finger.
人为输入
Ones and zeroes.
计算机输入

在这两种情况下,你必须设置状态变量以更新UI。对于你正在开发的表单,你需要响应一些不同的输入来更改状态。

  • 更改文本输入(人为)应根据文本框是否为空,将其从状态切换到输入中状态或返回。
  • 单击提交按钮(人为)应将其切换到提交中状态。
  • 成功的网络响应(计算机)应将其切换到成功状态。
  • 失败的网络响应(计算机)应将其切换到带有匹配错误消息的错误状态。

注意

请注意,人为输入通常需要事件处理程序

为了帮助可视化此流程,尝试在纸上将每个状态绘制为带标签的圆圈,并将两个状态之间的每次更改绘制为箭头。你可以以此方式绘制许多流程,并在实现之前就解决错误。

Flow chart moving left to right with 5 nodes. The first node labeled 'empty' has one edge labeled 'start typing' connected to a node labeled 'typing'. That node has one edge labeled 'press submit' connected to a node labeled 'submitting', which has two edges. The left edge is labeled 'network error' connecting to a node labeled 'error'. The right edge is labeled 'network success' connecting to a node labeled 'success'.
Flow chart moving left to right with 5 nodes. The first node labeled 'empty' has one edge labeled 'start typing' connected to a node labeled 'typing'. That node has one edge labeled 'press submit' connected to a node labeled 'submitting', which has two edges. The left edge is labeled 'network error' connecting to a node labeled 'error'. The right edge is labeled 'network success' connecting to a node labeled 'success'.

表单状态

步骤 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的情况。(例如,您永远不想同时显示错误消息并禁用输入,否则用户将无法纠正错误!)

以下是一些您可以针对状态变量提出的问题

  • 此状态是否会导致悖论?例如,isTypingisSubmitting 不能同时为 true。悖论通常意味着状态约束不足。两个布尔值有四种可能的组合,但只有三种对应于有效状态。要移除“不可能”的状态,您可以将它们组合成一个 status,它必须是三个值之一:'typing''submitting''success'
  • 另一个状态变量中是否已存在相同的信息?另一个悖论:isEmptyisTyping 不能同时为 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'

您知道它们是必要的,因为如果不移除它们中的任何一个,功能就会中断。

深入探讨

使用 reducer 消除“不可能”的状态

这三个变量足以表示此表单的状态。但是,仍然存在一些不完全合理的中间状态。例如,当 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(命令式)。
  • 开发组件时
    1. 确定其所有视觉状态。
    2. 确定状态变化的人工和计算机触发器。
    3. 使用 useState 建模状态。
    4. 移除非必要状态以避免错误和悖论。
    5. 连接事件处理程序以设置状态。

挑战 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>
  );
}