使用状态响应输入

React 提供了一种声明式的方式来操作 UI。你不是直接操作 UI 的某一部分,而是描述组件可能处于的不同状态,并根据用户输入在这些状态之间切换。这和设计师思考 UI 的方式很相似。

You will learn

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

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

当你设计 UI 交互时,你大概会想到 UI 如何随着用户操作而变化。考虑一个让用户提交答案的表单:

  • 当你在表单中输入内容时,“提交”按钮变为可用。
  • 当你按下“提交”时,表单和按钮都会变为不可用,并且会出现一个加载动画。
  • 如果网络请求成功,表单会被隐藏,而“谢谢你”消息会出现。
  • 如果网络请求失败,会出现一条错误消息,并且表单再次变为可用。

命令式编程中,上面的描述会直接对应你如何实现交互。你必须根据刚刚发生的事情,编写精确的指令来操作 UI。换个方式来理解:想象你坐在车里,告诉旁边的人每一个转弯该往哪里走。

车里坐着一个看起来很焦虑的人代表 JavaScript,乘客指挥司机按复杂的转弯导航顺序行驶。

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。可以把它想象成坐进出租车,告诉司机你想去哪里,而不是告诉他每一个转弯该怎么走。把你送到目的地是司机的工作,而且他们甚至可能知道一些你没想到的捷径!

由 React 驾驶的车里,乘客要求被带到地图上的某个具体地点。React 负责想办法实现这一点。

Illustrated by Rachel Lee Nabors

声明式地思考 UI

你已经看过上面如何用命令式方式实现一个表单。为了更好地理解如何在 React 中思考,下面你将一步步在 React 中重新实现这个 UI:

  1. 识别组件的不同视觉状态
  2. 确定是什么触发了这些状态变化
  3. 使用 useState 在内存中表示状态
  4. 移除任何非必要的状态变量
  5. 连接事件处理函数来设置状态

第 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。 对于你正在开发的表单,你需要根据几种不同输入来改变状态:

  • 更改文本输入(人为)应该会把它从空白状态切换到输入中状态,或者相反,取决于文本框是否为空。
  • 点击提交按钮(人为)应该会把它切换到提交中状态。
  • 成功的网络响应(计算机)应该会切换到成功状态。
  • 失败的网络响应(计算机)应该会切换到带有匹配错误消息的错误状态。

Note

注意,人为输入通常需要事件处理函数

为了帮助你可视化这个流程,试着把每个状态画成一个带标签的圆圈,再把两个状态之间的变化画成箭头。你可以这样草绘出很多流程,并在实现之前就找出 bug。

从左到右移动的流程图,有 5 个节点。第一个标注为 'empty' 的节点有一条标注 'start typing' 的边连接到标注为 'typing' 的节点。该节点有一条标注 'press submit' 的边连接到标注为 'submitting' 的节点,而后者有两条边。左边标注 'network error',连接到标注为 'error' 的节点。右边标注 'network success',连接到标注为 'success' 的节点。
从左到右移动的流程图,有 5 个节点。第一个标注为 'empty' 的节点有一条标注 'start typing' 的边连接到标注为 'typing' 的节点。该节点有一条标注 'press submit' 的边连接到标注为 'submitting' 的节点,而后者有两条边。左边标注 'network error',连接到标注为 'error' 的节点。右边标注 'network success',连接到标注为 'success' 的节点。

表单状态

第 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。(例如,你绝不希望同时显示错误消息和禁用输入框,否则用户就无法修正错误了!)

你可以针对状态变量问自己几个问题:

  • 这个状态会造成悖论吗? 例如,isTypingisSubmitting 不可能同时为 true。悖论通常意味着状态约束得不够。两个布尔值有四种组合,但只有三种对应有效状态。要移除这个“不可能”状态,你可以把它们合并成一个必须取三个值之一的 status'typing''submitting''success'
  • 同样的信息是否已经可以从另一个状态变量中得到? 另一个悖论:isEmptyisTyping 不可能同时为 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

用 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>答对了!</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(命令式)。
  • 开发组件时:
    1. 识别它所有的视觉状态。
    2. 确定触发状态变化的人为和计算机输入。
    3. 使用 useState 建模状态。
    4. 移除非必要状态以避免 bug 和悖论。
    5. 连接事件处理函数来设置状态。

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