使用状态对输入做出反应

React 提供了一种声明式的方式来操作 UI。 你无需直接操作 UI 的各个部分,而是描述组件可能处于的不同状态,并在响应用户输入时在这些状态之间切换。 这类似于设计师思考 UI 的方式。

你将学习

  • 声明式 UI 编程与命令式 UI 编程的区别
  • 如何枚举组件可能处于的不同视觉状态
  • 如何从代码中触发不同视觉状态之间的变化

声明式 UI 与命令式 UI 的比较

当你设计 UI 交互时,你可能会考虑 UI 如何响应用户操作而变化。 考虑一个允许用户提交答案的表单

  • 当你在表单中输入内容时,“提交”按钮将变为启用状态。
  • 当你按下“提交”时,表单和按钮都将变为禁用状态,出现一个加载指示器。
  • 如果网络请求成功,表单将被隐藏,出现“感谢”消息。
  • 如果网络请求失败,将出现一条错误消息,并且表单将再次变为启用状态

命令式编程中,上述内容直接对应于你如何实现交互。 你必须编写确切的指令来根据刚刚发生的事情来操作 UI。 以下是另一种思考方式:想象一下,你坐在一辆车的副驾驶座上,并告诉司机如何一步一步地到达目的地。

In a car driven by an anxious-looking person representing JavaScript, a passenger orders the driver to execute a sequence of complicated turn by turn navigations.

插图作者 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) {
  // 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。 想象一下,你坐上一辆出租车,告诉司机你想去哪里,而不是告诉他们确切的转弯路线。 到达目的地是司机的职责,他们甚至可能知道一些你没有考虑过的捷径!

In a car driven by React, a passenger asks to be taken to a specific place on the map. React figures out how to do that.

插图作者 Rachel Lee Nabors

以声明式的方式思考 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.
计算机输入

插图作者 Rachel Lee Nabors

在这两种情况下,您必须设置 状态变量 以更新 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 在内存中表示组件的视觉状态。 简单是关键:每个状态都是一个“活动部件”,而 您希望“活动部件”越少越好。 更高的复杂性会导致更多错误!

从*绝对必须*存在的状态开始。 例如,您需要存储输入的 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 的情况。 (例如,您永远不想同时显示错误消息并禁用输入,否则用户将无法更正错误!)

以下是您可以询问有关状态变量的一些问题

  • 此状态会导致矛盾吗? 例如,isTypingisSubmitting 不能同时为 true。 矛盾通常意味着状态的约束不够。 两个布尔值有四种可能的组合,但只有三种对应于有效状态。 要删除“不可能”的状态,您可以将它们合并为一个 status,它必须是以下三个值之一:'typing''submitting''success'
  • 相同的信息是否已在另一个状态变量中可用? 另一个矛盾:isEmptyisTyping 不能同时为 true。 通过将它们设为单独的状态变量,您可能会冒着它们不同步并导致错误的风险。 幸运的是,您可以删除 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', or 'success'

你知道它们是必要的,因为你不能在不破坏功能的情况下删除任何一个。

深入探讨

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

这三个变量足以表示此表单的状态。但是,仍然存在一些没有完全意义的中间状态。例如,当 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>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 类,但*添加* <img>picture--active 类。再次点击背景应该恢复原始的 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>
  );
}