React关键概念——处理事件和状态

介绍

在前几章中,您学习了如何通过React组件构建用户界面。您还了解了属性(props)------一个使React开发者能够构建和重用可配置组件的概念和功能。

这些都是React的重要特性和构建块,但仅凭这些功能,您只能构建静态React应用(即从不改变的网页应用)。如果您只有这些功能,您将无法更改或更新屏幕上的内容。您也无法响应任何用户事件并根据这些事件更新UI(例如,在点击按钮时显示叠加窗口)。

换句话说,如果您仅限于组件和属性,您将无法构建真正的网站和Web应用。

因此,在这一章中,引入了一个全新的概念:状态(state)。状态是React的一个特性,它允许开发者更新内部数据,并基于这些数据的调整触发UI更新。此外,您还将学习如何响应(不做文字游戏)用户事件,例如点击按钮或输入文本到输入框中。

问题是什么?

如前所述,目前在本书的这一部分,您可能构建的所有React应用和网站都存在一个问题:它们是静态的。UI无法改变。

为了更好地理解这个问题,请看一下您到目前为止可以构建的一个典型React组件:

javascript 复制代码
function EmailInput() {
  return (
    <div>
      <input placeholder="Your email" type="email" />
      <p>The entered email address is invalid.</p>
    </div>
  );
};

这个组件看起来有点奇怪。为什么会有一个<p>元素来告知用户邮箱地址不正确呢?

实际上,目标可能是仅当用户输入了不正确的邮箱地址时,才显示这个段落。也就是说,Web应用应该等待用户开始输入,并在用户完成输入后(即输入框失去焦点时)评估用户的输入。然后,如果邮箱地址被认为无效(例如,输入框为空或缺少@符号),错误信息应该显示出来。

但此时,凭借到目前为止学到的React知识,您无法构建这样的功能。相反,错误信息将始终显示,因为没有办法基于用户事件和动态条件来更改它。换句话说,这个React应用是一个静态应用,UI无法改变。

当然,更改UI和动态Web应用是您可能希望构建的东西。几乎每个现有的网站都包含一些动态的UI元素和功能。因此,这就是本章将要解决的问题。

如何不解决这个问题

如何使前面显示的组件更具动态性?

以下是您可能会想到的一种解决方案(剧透:代码无法工作,因此您不需要尝试运行它):

ini 复制代码
function EmailInput() {
  return (
    <div>
      <input placeholder="Your email" type="email" />
      <p></p>
    </div>
  );
};
const input = document.querySelector('input');
const errorParagraph = document.querySelector('p');
function evaluateEmail(event) {
  const enteredEmail = event.target.value;
  if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
    errorParagraph.textContent = ' The entered email address is invalid.';
  } else {
    errorParagraph.textContent = '';
  }
};
input.addEventListener('blur', evaluateEmail);

这段代码无法工作,因为您不能这样从同一个组件文件内部选择React渲染的DOM元素。这仅仅是一个虚拟示例,说明您可能会尝试如何解决这个问题。话虽如此,您可以将代码放在组件函数下面,放在可以成功执行的地方(例如,放在setTimeout()回调中,等待一秒钟,让React应用将所有元素渲染到屏幕上)。

如果放在正确的位置,这段代码将添加本章前面描述的邮箱验证行为。在内建的blur事件上,evaluateEmail函数会被触发。该函数将事件对象作为参数(由浏览器自动传递),因此evaluateEmail函数能够通过event.target.value解析输入的值。然后,输入的值可以用在if检查中,以有条件地显示或删除错误信息。

注意

所有涉及blur事件(例如addEventListener)和事件对象的前述代码,包括if检查中的代码,都是标准JavaScript代码。它与React没有任何特定的关联。

如果您发现自己在与这种非React代码作斗争,强烈建议您先深入学习更多原生JavaScript资源(例如,MDN网站上的指南:developer.mozilla.org/en-US/docs/...)。 但是,如果这段代码在应用程序的某些地方有效,为什么不行呢?

它是命令式代码!这意味着您在逐步编写浏览器应该做的操作,而不是声明期望的最终状态;您在描述如何到达那里,而不是声明目标状态,并且这不是使用React的方式。

请记住,React的核心理念是控制UI,编写React代码是编写声明式代码------而不是命令式代码。如果这对您来说是全新的,建议您回顾第二章《理解React组件与JSX》。

您可以通过引入这种代码来实现目标,但这会与React及其哲学背道而驰(React的哲学是声明期望的最终状态,然后让React自己找出如何达到这一目标)。这显然表明,您必须找到这个代码的正确位置才能使其工作。

这不仅仅是一个哲学问题,也不是一个奇怪的硬性规则要求您遵循。相反,像这样与React对抗,您会让自己作为开发者的工作变得不必要地困难。您既没有使用React提供的工具,也没有让React找出如何实现期望的UI状态。

这不仅意味着您花时间解决本不需要解决的问题,还意味着您可能错过了React在后台可能进行的优化。您的解决方案很可能不仅会导致更多工作(即更多代码),还可能导致错误的结果,并且可能导致性能不佳。

前面展示的例子是一个简单的例子。试想一些更复杂的网站和Web应用,例如在线商店、度假租赁网站或像Google Docs这样的Web应用。在那里,您可能会有几十或几百个(动态的)UI特性和元素。用React代码和标准原生JavaScript代码的混合方式来管理它们会很快变成一场噩梦。再次参考本书第二章《理解React组件与JSX》,以理解React的优点。

更好的错误解决方案

之前讨论的简单方法并不奏效。它迫使您去找出如何让代码正确运行(例如,通过将部分代码包装在setTimeout()调用中以延迟执行),并导致代码分散在各处(即,React组件函数内部、这些函数外部,甚至可能在完全不相关的文件中)。那么,如果有一个更符合React哲学的解决方案,应该是怎样的呢?

ini 复制代码
function EmailInput() {
  let errorMessage = '';
  function evaluateEmail(event) {
    const enteredEmail = event.target.value;
    if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
      errorMessage = 'The entered email address is invalid.';
    } else {
      errorMessage = '';
    }
  };
  const input = document.querySelector('input');
  input.addEventListener('blur', evaluateEmail);
  return (
    <div>
      <input placeholder="Your email" type="email" />
      <p>{errorMessage}</p>
    </div>
  );
};

这段代码依然无法工作(尽管它是有效的JavaScript代码)。选择JSX元素不能像这样工作。之所以不行,是因为document.querySelector('input')在任何内容渲染到DOM之前就执行了(即,组件函数第一次执行时)。同样,您需要将代码的执行延迟到第一个渲染周期结束后(因此,您将再次与React对抗)。

但即使这段代码仍然无法工作,它更接近正确的解决方案。

它更接近理想的实现,因为它比第一种尝试的解决方案更好地拥抱了React。所有代码都包含在它所属的组件函数内。错误消息通过errorMessage变量进行处理,并作为JSX代码的一部分输出。

这个可能的解决方案的思路是,控制某个UI特性或元素的React组件,也应该负责它的状态和事件。您在这里可能已经看到了本章两个重要的关键词!

这种方法显然朝着正确的方向发展,但它仍然无法工作的两个原因是:

  1. 通过document.querySelector('input')选择JSX的<input>元素会失败。
  2. 即使能够选择输入框,UI也不会按预期更新。

这两个问题将在接下来的部分解决------最终会导致一个完全符合React哲学的实现。即将提供的解决方案将避免混合React代码和非React代码。正如您将看到的,结果是代码更简洁,您需要做的工作更少(也就是写更少的代码)。

通过正确响应事件改进解决方案

与其混合使用命令式JavaScript代码(例如document.querySelector('input'))和React特有的代码,不如完全拥抱React及其特性。

由于监听事件并在事件发生时触发动作是非常常见的需求,React提供了内建的解决方案。您可以直接将事件监听器附加到属于它们的JSX元素上。

前面的示例可以改写为:

ini 复制代码
function EmailInput() {
  let errorMessage = '';
  function evaluateEmail(event) {
    const enteredEmail = event.target.value;
    if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
      errorMessage = 'The entered email address is invalid.';
    } else {
      errorMessage = '';
    }
  };
  return (
    <div>
      <input 
        placeholder="Your email" 
        type="email" 
        onBlur={evaluateEmail} />
      <p>{errorMessage}</p>
    </div>
  );
};

这段代码仍然不会更新UI,但至少事件处理已正确实现。

onBlur属性被添加到了内建的input元素中。这个属性是由React提供的,就像所有这些基础的HTML元素(如<input><p>)作为组件由React提供一样。实际上,所有这些内建的HTML组件都带有它们标准的HTML属性,作为React属性(加上一些额外的属性,例如事件处理属性onBlur)。

React暴露了所有标准的事件,可以通过onXYZ属性连接到DOM元素上(其中XYZ是事件名称,如blurclick,首字母大写)。您可以通过添加onBlur属性来响应blur事件。您可以通过onClick属性来监听点击事件。您明白了吧。

注意

有关标准事件的更多信息,请查看MDN上的事件列表

这些属性需要值来完成它们的工作。确切地说,它们需要一个指向应该在事件发生时执行的函数的指针。在上面的示例中,onBlur属性接收指向evaluateEmail函数的指针作为值。

注意

evaluateEmailevaluateEmail()之间有一个微妙的区别。前者是指向函数的指针;后者实际上执行了函数(并返回结果,如果有的话)。这同样是标准JavaScript的概念,而不是React特有的。如果不清楚,您可以查看这篇MDN文档获取更多细节。

通过使用这些事件属性,前面的示例代码现在终于可以执行而不会抛出任何错误。您可以通过在evaluateEmail函数中添加console.log('Hello');语句来验证这一点。每当输入框失去焦点时,'Hello'文本将在浏览器开发者工具的控制台中显示出来:

ini 复制代码
function EmailInput() {
  let errorMessage = '';
  function evaluateEmail(event) {
    console.log('Hello');
    const enteredEmail = event.target.value;
    if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
      errorMessage = 'The entered email address is invalid.';
    } else {
      errorMessage = '';
    }
  };
  return (
    <div>
      <input 
        placeholder="Your email" 
        type="email" 
        onBlur={evaluateEmail} />
      <p>{errorMessage}</p>
    </div>
  );
};

在浏览器控制台中,这将显示如下:

这无疑是朝着最佳实现更近了一步,但它仍然无法实现动态更新页面内容的预期结果。

正确更新状态

到目前为止,您已经了解了如何正确设置事件监听器并在特定事件发生时执行函数。现在缺少的功能是一个能够强制React更新屏幕上可见UI和显示给应用用户的内容的功能。

这就是React的状态(state)概念的作用。像属性(props)一样,状态是React的一个关键概念,但与属性是用于接收外部数据不同,状态是用于管理和更新内部数据。最重要的是,每当状态更新时,React会更新受状态变化影响的UI部分。

以下是如何在React中使用状态(当然,代码将在之后详细解释):

javascript 复制代码
import { useState } from 'react';

function EmailInput() {
  const [errorMessage, setErrorMessage] = useState('');
  
  function evaluateEmail(event) {
    const enteredEmail = event.target.value;
    if (enteredEmail.trim() === '' || !enteredEmail.includes('@')) {
      setErrorMessage('The entered email address is invalid.');
    } else {
      setErrorMessage('');
    }
  };

  return (
    <div>
      <input 
        placeholder="Your email" 
        type="email" 
        onBlur={evaluateEmail} />
      <p>{errorMessage}</p>
    </div>
  );
};

与本章前面讨论的示例代码相比,这段代码看起来差别不大。但有一个关键的不同:使用了useState()钩子。

钩子(Hooks)是React的另一个关键概念。钩子是只能在React组件内部使用的特殊函数(或者在其他钩子内部使用,如第12章"构建自定义React钩子"中会讨论的内容)。钩子为React组件添加了特殊的功能和行为。例如,useState()钩子允许组件(因此也隐含地允许React)设置和管理与该组件相关的状态。React提供了各种内置钩子,它们并不都集中在状态管理上。您将在本书中学习到其他钩子及其用途。

useState()钩子是一个非常重要且常用的钩子,因为它使您能够在组件内部管理数据,并在数据更新时告知React更新UI。

这就是状态管理和状态概念的核心思想:状态是数据,当它发生变化时,应该强制React重新评估组件并在需要时更新UI。

更深入了解 useState()

useState()钩子如何工作,它在内部做了什么?

通过在组件函数内部调用useState(),您向React注册了一些数据。这有点像在原生JavaScript中定义一个变量或常量。但有一点特别之处:React会在内部跟踪这个注册的值,并且每当您更新它时,React会重新评估该组件函数。

React通过检查组件中使用的数据是否发生变化来完成这一操作。最重要的是,React会验证UI是否需要因数据变化而更新(例如,某个值是否在JSX代码中被输出)。如果React判断UI需要更新,它会在需要更新的地方更新真实的DOM(例如,更改屏幕上显示的文本)。如果不需要更新,React会结束组件的重新评估而不更新DOM。

React的内部工作原理将在第10章"React幕后和优化机会"中详细讨论。

整个过程从在组件内部调用useState()开始。这会创建一个状态值(该值由React存储和管理),并将其绑定到特定的组件。初始状态值通过将其作为参数传递给useState()来注册。在前面的示例中,空字符串('')被注册为第一个值:

scss 复制代码
const [errorMessage, setErrorMessage] = useState('');

正如您在示例中看到的,useState()不仅接受一个参数值。它还返回一个值:一个包含两个元素的数组。

前面的示例使用了数组解构,这是一个标准的JavaScript特性,允许开发者从数组中提取值并立即将它们赋给变量或常量。在示例中,通过解构数组,useState()返回的两个元素被提取出来并存储在两个常量(errorMessagesetErrorMessage)中。然而,您在使用React或useState()时,并不一定非要使用数组解构。

您也可以将代码写成这样:

ini 复制代码
const stateData = useState('');
const errorMessage = stateData[0];
const setErrorMessage = stateData[1];

这样也完全没问题,但使用数组解构使得代码更简洁。这就是为什么在浏览React应用和示例时,您通常会看到使用数组解构的语法。您也不一定非要使用常量;使用变量(通过let)也可以。不过,正如您在本章和本书接下来的部分中看到的那样,由于这些变量不会被重新赋值,因此使用常量是有意义的(但这不是强制要求的)。

注意

如果数组解构或变量与常量之间的区别对您来说是全新的,强烈建议您在继续本书之前复习JavaScript基础知识。像往常一样,MDN提供了很好的资源供您参考(有关数组解构,请参考这里,有关let变量的使用,请参考这里,以及有关const使用的指导,请参考这里)。

如前所述,useState()返回一个包含两个元素的数组。它总是返回正好两个元素------而且总是相同类型的元素。第一个元素始终是当前的状态值,第二个元素是一个函数,您可以调用它来将状态设置为新值。

但这两个值(状态值和更新状态的函数)如何协同工作?React如何在内部使用它们?这两个数组元素是如何被React用来更新UI的?

React内部机制剖析

React为您管理状态值,这些状态值存储在一些内部存储中,作为开发者的您无法直接访问。由于您经常需要访问状态值(例如,在前面的示例中,用户输入的电子邮件地址),React提供了一种读取状态值的方式:通过useState()返回的数组中的第一个元素。返回数组的第一个元素包含当前的状态值。因此,您可以在任何需要使用状态值的地方使用这个元素(例如,在JSX代码中输出它)。

此外,您通常还需要更新状态------例如,因为用户输入了新的电子邮件地址。由于您不直接管理状态值,React为您提供了一个可以调用的函数,用于告知React新的状态值。这就是返回数组中的第二个元素。

在前面的示例中,您调用setErrorMessage('Error!')来将errorMessage的状态值设置为一个新的字符串('Error!')。

但为什么会这样管理呢?为什么不直接使用一个标准的JavaScript变量,您可以根据需要赋值和重新赋值呢?

原因是:每当有影响UI变化的状态时,React必须被告知。如果没有通知React,UI将完全不变化,即使在应该变化的情况下也不会改变。React不会跟踪常规变量及其值的变化,因此它们对UI状态没有任何影响。

React暴露的状态更新函数(即useState()返回的第二个数组元素)会触发一些内部的UI更新效果。这个状态更新函数不仅仅是设置一个新的值;它还通知React状态值已更改,因此UI可能需要更新。

因此,每当您调用setErrorMessage('Error!')时,React不仅仅更新它内部存储的值;它还检查UI,并在需要时更新UI。UI更新可能涉及从简单的文本变化到完全删除和添加各种DOM元素,所有这些都有可能发生!

React通过重新运行(也叫做重新评估)任何受状态变化影响的组件函数来确定新的目标UI。这包括执行useState()函数的组件函数,该函数返回了被调用的状态更新函数。但它还包括任何子组件,因为父组件中的更新可能会导致新的状态数据,这些数据也被某些子组件使用(状态值可以通过属性传递给子组件)。

如果您需要一个视觉化的示意图来理解这一切是如何结合在一起的,可以考虑以下图示:

理解并牢记一点非常重要:如果在组件函数或某个父组件函数中调用了状态更新函数,React会重新执行(重新评估)该组件函数。这也解释了为什么通过useState()返回的状态值(即,第一个数组元素)可以是常量,即使您可以通过调用状态更新函数(第二个数组元素)赋予它新的值。由于整个组件函数会重新执行,useState()也会再次被调用(因为组件函数的所有代码都会重新执行),因此React会返回一个包含两个新元素的新数组。第一个数组元素仍然是当前的状态值。

然而,由于组件函数是因为状态更新而被调用的,当前状态值现在是更新后的值。

这可能有点难以理解,但这就是React内部的工作原理。最终,这只是关于React多次调用组件函数,就像任何JavaScript函数可以被多次调用一样。

命名约定

useState()钩子通常与数组解构一起使用,如下所示:

scss 复制代码
const [enteredEmail, setEnteredEmail] = useState('');

但在使用数组解构时,变量或常量的名称(在本例中为enteredEmailsetEnteredEmail)由您------开发者决定。因此,一个合理的问题是,您应该如何命名这些变量或常量。幸运的是,关于React和useState()的命名约定非常明确。

  • 第一个元素(即当前状态值)应该命名为能描述该状态值的名称。例如,可以使用enteredEmailuserEmailprovidedEmailemail或类似名称。您应该避免使用像avalue这样的通用名称,也避免使用像setValue这样的误导性名称(因为它听起来像是一个函数------但它不是)。
  • 第二个元素(即状态更新函数)应该命名为能明确表明它是一个函数并且说明其功能的名称。例如,可以使用setEnteredEmailsetEmail。通常,给这个函数命名的约定是使用setXYZ的形式,其中XYZ是您为第一个元素(当前状态值变量)选择的名称。(需要注意的是,您应该使用首字母大写,如setEnteredEmail,而不是setenteredEmail。)
允许的状态值类型

管理输入的电子邮件地址(或一般的用户输入)确实是一个常见的使用案例,也是使用状态的示例。然而,您并不仅限于这个场景和这个值类型。

在处理用户输入的情况下,您通常会处理像电子邮件地址、密码、博客文章等字符串值。但任何有效的JavaScript值类型都可以通过useState()来管理。例如,您可以管理多个购物车项的总和------即一个数字------或者一个布尔值(例如,"用户是否确认了使用条款?")。

除了管理原始值类型之外,您还可以存储和更新引用数据类型,如对象和数组。

注意

如果原始数据类型和引用数据类型之间的区别还不完全清楚,强烈建议您在继续本书之前,先深入了解这个核心JavaScript概念。可以通过以下链接进一步学习:参考类型与原始类型

React为您提供了管理所有这些值类型作为状态的灵活性。您甚至可以在运行时切换值类型(就像在原生JavaScript中一样)。完全可以将数字作为初始状态值存储,并在后续某个时间点将其更新为字符串。

当然,就像在原生JavaScript中一样,您应该确保您的程序能适当处理这种行为,尽管技术上切换类型是完全没有问题的。

处理多个状态值

在构建任何复杂的Web应用或UI时,您需要管理多个状态值。可能用户不仅需要输入电子邮件,还需要输入用户名或地址。您可能还需要跟踪某些错误状态或保存购物车项。可能用户可以点击一个"喜欢"按钮,其状态应该被保存并反映在UI中。许多值会频繁变化,并且这些变化应该在UI中反映出来。

考虑以下具体场景:您有一个组件,需要同时管理用户输入的电子邮件输入字段的值和密码字段的值。每个值应该在字段失去焦点后捕获。

由于您有两个输入字段来保存不同的值,因此您有两个状态值:输入的电子邮件和输入的密码。即使您可能在某些时刻将这两个值一起使用(例如,用于登录),这些值并不是同时提供的。此外,您可能还需要让每个值单独存在,因为您用它来在用户输入数据时显示潜在的错误消息(例如,"密码太短")。

像这样的场景非常常见,因此,您可以使用useState()钩子来管理多个状态值。主要有两种方式来处理这个问题:

  1. 使用多个状态切片(多个状态值)
  2. 使用一个单一的、大的状态对象

使用多个状态切片

您可以通过在组件函数中多次调用useState()来管理多个状态值(通常也叫做状态切片)。

对于前面描述的示例,简化后的组件函数可以如下所示:

ini 复制代码
function LoginForm() {
  const [enteredEmail, setEnteredEmail] = useState(''); 
  const [enteredPassword, setEnteredPassword] = useState('');
  
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  };
  
  function handleUpdatePassword(event) {
    setEnteredPassword(event.target.value);
  };

  return (
    <form>
      <input
        type="email"
        placeholder="Your email"
        onBlur={handleUpdateEmail} />
      <input
        type="password"
        placeholder="Your password"
        onBlur={handleUpdatePassword} />
    </form>
  );
};

在这个示例中,通过调用useState()两次,管理了两个状态切片。因此,React会在内部注册并管理两个状态值。这两个值可以独立地读取和更新。

注意

在这个示例中,触发事件的函数以handle开头(handleUpdateEmailhandleUpdatePassword)。这是一些React开发者使用的约定。事件处理函数以handle...开头,以明确表明这些函数处理某些(由用户触发的)事件。虽然这是一个常见的约定,但并不是您必须遵循的规则。这些函数也可以命名为updateEmailupdatePasswordemailUpdateHandlerpasswordUpdateHandler,或任何其他名称。如果名称有意义并遵循某种严格的约定,那就是一个有效的选择。

您可以在一个组件中根据需要注册任意多个状态切片(通过多次调用useState())。您可以有一个状态值,也可以有几十个甚至上百个。通常来说,您每个组件中只有几个状态切片,因为您应该尝试将较大的组件(可能在做很多不同的事情)拆分成多个更小的组件,以便更好地管理。

使用多个状态值的优势

管理多个状态值的优势在于,您可以独立地更新它们。如果用户输入了一个新的电子邮件地址,您只需要更新该电子邮件状态值。密码状态值在此时不重要。

使用多个状态值的潜在劣势

一个潜在的劣势是,多个状态切片------因此需要多个useState()调用------可能会导致代码行数过多,从而使组件臃肿。尽管如此,如前所述,您通常应该尽量将大组件(处理多个状态切片)拆分成多个更小的组件。

然而,管理多个状态值仍有替代方案:您也可以管理一个合并的单一状态值对象。

管理合并的状态对象

您可以选择使用一个大的状态对象来合并所有不同的状态值,而不是为每个状态切片调用useState()

php 复制代码
function LoginForm() {
  const [userData, setUserData] = useState({
    email: '',
    password: ''
  }); 

  function handleUpdateEmail(event) {
    setUserData({
      email: event.target.value,
      password: userData.password
    });
  };

  function handleUpdatePassword(event) {
    setUserData({
      email: userData.email,
      password: event.target.value
    });
  };

  // ... 省略代码,因为返回的JSX代码与之前相同
};

在这个示例中,useState()只被调用一次(即只有一个状态切片),并且传递给useState()的初始值是一个JavaScript对象。这个对象包含两个属性:emailpassword。属性名由您决定,但它们应该描述将存储在属性中的值。

useState()仍然返回一个包含两个元素的数组。初始值是对象并不会改变这一点。返回数组的第一个元素现在是一个对象,而不是像之前示例中的字符串。如前所述,在使用useState()时,任何有效的JavaScript值类型都可以使用。原始值类型,如字符串或数字,可以像引用对象或数组(从技术上讲,数组也是对象)一样使用。

状态更新函数(在上面的示例中为setUserData)仍然是React创建的函数,您可以调用它将状态设置为新值。此外,您不必再次将其设置为一个对象,尽管通常这是默认行为。除非有充分的理由,否则更新状态时不会改变值类型(尽管从技术上讲,您可以随时切换到不同的类型)。

注意

在前面的示例中,状态更新函数的使用方式并不完全正确。它可以工作,但它违反了推荐的最佳实践。稍后您将了解为什么会这样,以及应该如何使用状态更新函数。

管理状态对象时的注意事项

在前面示例中管理状态对象时,有一件关键的事情需要记住:您必须始终设置对象中包含的所有属性,甚至是那些没有改变的属性。这是必要的,因为在调用状态更新函数时,您告诉React应该将哪个新状态值存储在内部。

因此,您传递给状态更新函数的任何值都会覆盖先前存储的值。如果您提供的对象只包含已更改的属性,所有其他属性将丢失,因为之前的状态对象会被新的对象替代,而新对象的属性较少。

这是一个常见的陷阱,因此必须特别注意这一点。出于这个原因,在前面示例中,没有更改的属性被设置为之前的状态值------例如,email: userData.email,其中userData是当前状态快照,也是useState()返回的数组的第一个元素,同时将password设置为event.target.value

使用状态对象还是多个状态切片

是否使用一个单一的状态对象(将多个值组合在一起)还是多个状态切片(多个useState()调用)完全取决于您。没有对错之分,两种方法各有优缺点。

然而,值得注意的是,您通常应该尝试将大组件拆分为更小的组件。就像常规的JavaScript函数不应该在单个函数中做太多工作一样(通常建议为不同的任务创建不同的函数),组件每次也应该专注于一个或少数几个任务。与其拥有一个巨大的<App />组件,直接处理多个表单、用户认证和购物车,不如将该组件的代码拆分为多个较小的组件,再将它们组合起来构建整体应用。

遵循这个建议时,大多数组件通常不会有太多状态需要管理,因为管理多个状态值是组件做得太多的标志。这就是为什么您最终可能会在每个组件中使用几个状态切片,而不是使用大的状态对象。

正确基于前一个状态更新状态

在学习对象作为状态值时,您了解到,如果仅将新状态设置为一个只包含已更改属性的对象,而不是包含所有属性的对象,可能会不小心覆盖(并丢失)数据。这就是为什么在处理对象或数组作为状态值时,始终要将现有的属性和元素添加到新的状态值中。

此外,通常,设置一个新状态值,该值(至少部分)依赖于前一个状态是一个常见任务。您可能会将密码设置为event.target.value,但同时也将电子邮件设置为userData.email,以确保在更新部分状态(即更新密码为新输入的值)时,存储的电子邮件地址不会丢失。

然而,这并不是唯一的场景,新的状态值可能依赖于前一个状态。例如,一个计数器组件------例如,像这样的组件:

javascript 复制代码
function Counter() {
  const [counter, setCounter] = useState(0);
  
  function handleIncrement() {
    setCounter(counter + 1);
  };
  
  return (
    <>
      <p>Counter Value: {counter}</p>
      <button onClick={handleIncrement}>Increment</button>
    </>
  );
};

在这个示例中,<button>注册了一个点击事件处理程序(通过onClick属性)。每次点击时,计数器状态值增加1。

这个组件是可以工作的,但示例代码实际上违反了一个重要的最佳实践和推荐:依赖于前一个状态的状态更新应该通过一个函数来完成,该函数传递给状态更新函数。准确地说,示例代码应该改写为这样:

javascript 复制代码
function Counter() {
  const [counter, setCounter] = useState(0);
  
  function handleIncrement() {
    setCounter(function(prevCounter) { 
      return prevCounter + 1; 
    });
    // 或者使用箭头函数:
    // setCounter(prevCounter => prevCounter + 1);
  };
  
  return (
    <>
      <p>Counter Value: {counter}</p>
      <button onClick={handleIncrement}>Increment</button>
    </>
  );
};

这看起来可能有点奇怪。现在,似乎是将一个函数作为新状态值传递给了状态更新函数(也就是,counter中存储的数字被替换为一个函数)。但事实上,情况并非如此。

从技术上讲,一个函数作为参数传递给了状态更新函数,但React不会将该函数作为新状态值存储。相反,当React接收到一个函数作为新状态值时,它会为您调用该函数,并将最新的状态值传递给该函数。因此,您应该提供一个接受至少一个参数的函数:前一个状态值。当React执行该函数时,它会自动将该值传递给函数(这是React内部执行的)。

函数应该返回一个值------即应该由React存储的新状态值。此外,由于函数接收前一个状态值,因此您现在可以根据前一个状态值推导出新的状态值(例如,通过将1加到它上面,但这里可以进行任何操作)。

为什么在应用之前没有这个变化时代码就能正常工作呢?这是因为,在更复杂的React应用和UI中,React可能会同时处理多个状态更新------这些更新可能是由不同来源在不同时间触发的。

如果不使用前面讨论的方式,状态更新的顺序可能不是预期的顺序,从而可能引入应用程序的bug。即使您知道您的用例不会受到影响,并且应用程序按预期工作,也强烈建议您遵循讨论的最佳实践------如果新状态依赖于前一个状态,则将函数传递给状态更新函数。

示例分析

回到之前的代码示例:

php 复制代码
function LoginForm() {
  const [userData, setUserData] = useState({
    email: '',
    password: ''
  }); 

  function handleUpdateEmail(event) {
    setUserData({
      email: event.target.value,
      password: userData.password
    });
  };

  function handleUpdatePassword(event) {
    setUserData({
      email: userData.email,
      password: event.target.value
    });
  };

  // ... 省略代码,因为返回的JSX代码与之前相同
};

您能发现这个代码中的问题吗?

它不是技术性错误;代码将正常执行,应用程序也会按预期工作。但这段代码仍然存在问题。它违反了我们讨论的最佳实践。在这段代码中,两个事件处理函数都通过引用当前状态快照来更新状态,即通过userData.passworduserData.email来分别更新状态。

这段代码应该改写为:

php 复制代码
function LoginForm() {
  const [userData, setUserData] = useState({
    email: '',
    password: ''
  }); 

  function handleUpdateEmail(event) {
    setUserData(prevData => ({
      email: event.target.value,
      password: prevData.password
    }));
  };

  function handleUpdatePassword(event) {
    setUserData(prevData => ({
      email: prevData.email,
      password: event.target.value
    }));
  };

  // ... 省略代码,因为返回的JSX代码与之前相同
};

通过将箭头函数作为参数传递给setUserData,您允许React调用该函数。React会自动执行该函数(即,如果它在这里接收到一个函数,React会调用它),并自动提供前一个状态(prevState)。返回的值(存储更新后的电子邮件或密码,以及当前存储的电子邮件或密码)随后作为新状态被设置。在这种情况下,结果可能与之前相同,但现在代码符合推荐的最佳实践。

注意

在前面的示例中,使用了箭头函数,而不是"常规"函数。两种方法都可以,您可以使用这两种函数类型,结果是一样的。

总之,如果新状态依赖于前一个状态,您应该始终将函数传递给状态更新函数。否则,如果新状态依赖于其他值(例如,用户输入),直接将新状态值作为函数参数传递是完全可以的,并且是推荐的做法。

双向绑定

有一个特别的React状态概念值得讨论:双向绑定。

双向绑定是一种概念,用于在有输入源(通常是<input>元素)的情况下,当用户输入时设置某个状态(例如,在change事件发生时),并同时将输入的值输出。

以下是一个例子:

ini 复制代码
function NewsletterField() {
  const [email, setEmail] = useState('');
  
  function handleUpdateEmail(event) {
    setEmail(event.target.value);
  };

  return (
    <>
      <input
        type="email"
        placeholder="Your email address"
        value={email}
        onChange={handleUpdateEmail} />
    </>
  );
};

与其他代码示例相比,这里的区别在于,组件不仅仅是存储用户的输入(在本例中是change事件),而且输入的值也会在<input>元素中显示出来(通过默认的value属性)。

这看起来可能像是一个无限循环,但React会处理这种情况,确保它不会变成无限循环。相反,这通常被称为双向绑定,因为值既被设置也被从同一个源读取。

您可能会想知道为什么在这里讨论这个问题,但重要的是要知道,像这样的代码是完全有效的。此外,如果您不仅仅希望在<input>字段中根据用户输入设置一个值(在这个例子中是电子邮件值),还希望从其他来源设置值,那么这种代码可能是必要的。例如,您可能在组件中有一个按钮,当点击时,它应该清除输入的电子邮件地址。

例如,它可能是这样的:

javascript 复制代码
function NewsletterField() {
  const [email, setEmail] = useState('');
  
  function handleUpdateEmail(event) {
    setEmail(event.target.value);
  };

  function handleClearInput() {
    setEmail(''); // 重置电子邮件输入框(回到空字符串)
  };

  return (
    <>
      <input
        type="email"
        placeholder="Your email address"
        value={email}
        onChange={handleUpdateEmail} />
      <button onClick={handleClearInput}>Reset</button>
    </>
  );
};

在这个更新的示例中,当点击<button>时,handleClearInput函数会被执行。在函数内部,email状态被设置为空字符串。如果没有双向绑定,状态会更新,但<input>元素中的变化不会反映出来。用户仍然会看到他们最后输入的内容。UI(网站)上反映的状态和React内部管理的状态将是不同的------这是一个您必须避免的bug。

从状态中派生值

如您现在可能已经察觉到的,状态(state)是React中的一个关键概念。状态允许您管理数据,当数据发生变化时,强制React重新评估组件,最终更新UI。

作为开发者,您可以在组件的任何地方使用状态值(并通过props将状态传递给子组件)。例如,您可以像这样重复用户输入的内容:

javascript 复制代码
function Repeater() {
  const [userInput, setUserInput] = useState('');
  
  function handleChange(event) {
    setUserInput(event.target.value);
  };
  
  return (
    <>
      <input type="text" onChange={handleChange} />
      <p>You entered: {userInput}</p>
    </>
  );
};

这个组件可能不是很有用,但它能够工作,并且确实使用了状态。

通常,为了做更有用的事情,您需要将状态值作为基础来派生一个新的(通常更复杂的)值。例如,您不仅仅重复用户输入的内容,而是可以计算输入的字符数并将该信息显示给用户:

javascript 复制代码
function CharCounter() {
  const [userInput, setUserInput] = useState('');
  
  function handleChange(event) {
    setUserInput(event.target.value);
  };
  
  const numChars = userInput.length;
  
  return (
    <>
      <input type="text" onChange={handleChange} />
      <p>Characters entered: {numChars}</p>
    </>
  );
};

注意添加了新的numChars常量(它也可以是变量,通过let声明)。这个常量通过访问存储在userInput状态中的字符串值的length属性,从userInput状态派生出来。

这很重要!您不仅限于操作状态值。您可以将一些关键值作为状态(即会变化的值)进行管理,并根据该状态值派生其他值------例如,在这个例子中,是计算用户输入的字符数。事实上,这正是您作为React开发者经常会做的事情。

您可能还会想,为什么numChars是一个常量,并且位于handleChange函数外部。毕竟,这个函数是在用户输入时执行的(即每当用户敲击键盘时)。

请记住,您已经了解了React是如何在内部处理状态的。当您调用状态更新函数(在本例中是setUserInput)时,React会重新评估与该状态相关的组件。这意味着,CharCounter组件函数会被React再次调用。因此,该函数中的所有代码都会重新执行。

React 会重新执行组件函数,以确定在状态更新后 UI 应该是什么样子;如果它检测到与当前渲染的 UI 有任何差异,React 会相应地更新浏览器 UI(即 DOM)。否则,不会发生任何变化。

由于 React 会再次调用组件函数,useState() 将返回其值数组(当前状态值和更新状态的函数)。当前状态值将是调用 setUserInput 时设置的状态。因此,新的 userInput 值可以在组件函数的任何地方用于执行其他计算------例如,通过访问 userInput 的长度属性来得出 numChars(如图 4.3 所示)。

这就是为什么 numChars 可以是一个常量的原因。对于这个组件执行,它不会被重新赋值。只有在未来组件函数再次执行时(即如果 setUserInput 被再次调用),才可能导出一个新的值。在这种情况下,会创建一个全新的 numChars 常量(而旧的则会被丢弃)。

与表单和表单提交的互动

在处理表单和用户输入时,状态通常是必需的。事实上,本章的大多数示例都涉及某种形式的用户输入。

到目前为止,所有的示例都集中在监听直接附加到单个输入元素的用户事件上。这是有道理的,因为你通常希望监听键盘输入或输入元素失去焦点等事件。特别是在添加输入验证时(即检查输入的值),你可能希望使用输入事件,在用户输入时给网站用户提供有用的反馈。

但是,通常情况下,你也会反应整体的表单提交。例如,目标可能是将多个输入字段的输入数据合并,并将数据发送到某个后端服务器。你如何实现这一点?如何监听并响应表单的提交?

你可以借助标准的 JavaScript 事件和 React 提供的适当事件处理程序 props 来完成这些操作。具体而言,可以将 onSubmit prop 添加到 <form> 元素中,以指定一个应在表单提交时执行的函数。然后,为了在 React 和 JavaScript 中处理提交,你必须确保浏览器不会执行默认操作,即自动生成(并发送) HTTP 请求。

像在原生 JavaScript 中一样,这可以通过在自动生成的事件对象上调用 preventDefault() 方法来实现。

以下是完整示例:

javascript 复制代码
function NewsletterSignup() {
  const [email, setEmail] = useState('');
  const [agreed, setAgreed] = useState(false);

  function handleUpdateEmail(event) {
    // 可以在这里添加邮箱验证
    setEmail(event.target.value);
  };

  function handleUpdateAgreement(event) {
    setAgreed(event.target.checked); // checked 是默认的 JS 布尔属性
  };

  function handleSignup(event) {
    event.preventDefault(); // 防止浏览器默认发送 HTTP 请求
    const userData = { userEmail: email, userAgrees: agreed };
    // doWhateverYouWant(userData);
  };

  return (
    <form onSubmit={handleSignup}>
      <div>
        <label htmlFor="email">Your email</label>
        <input type="email" id="email" onChange={handleUpdateEmail}/>
      </div>
      <div>
        <input type="checkbox" id="agree" onChange={handleUpdateAgreement}/>
        <label htmlFor="agree">Agree to terms and conditions</label>
      </div>
    </form>
  );
};

这段代码通过 handleSignup() 函数处理表单提交,该函数被分配给内置的 onSubmit prop。用户输入仍然通过两个状态切片(emailagreed)获取,这些状态在输入事件发生时会更新。

注意: 在上述代码示例中,你可能注意到一个新 props,它在本书之前并未使用过:htmlFor。这是一个特殊的 props,内置于 React 及其提供的核心 JSX 元素中。它可以添加到 <label> 元素中,以设置这些元素的 for 属性。之所以称其为 htmlFor 而不是仅仅使用 for,是因为正如本书早期所解释的,JSX 看起来像 HTML,但实际上不是 HTML,它是在幕后运行的 JavaScript。在 JavaScript 中,for 是一个保留的关键字,用于 for 循环。为了避免问题,props 被命名为 htmlFor

在 React 中,使用 onSubmit(结合 preventDefault())处理表单提交是处理用户输入和表单的常见方式。但当使用 React 19 或更高版本时,你还可以使用另一种处理表单提交的方式:你可以使用 React 的 Form Actions 特性,在第九章《使用表单操作处理用户输入和表单》中将详细讲解这种方法。

提升状态(Lifting State Up)

这是一个常见的场景和问题:在你的 React 应用中,你有两个组件,组件 A 中的变化或事件应该改变组件 B 中的状态。为了让这个问题更加具体,我们来看一个简单的例子:

javascript 复制代码
function SearchBar() {
  const [searchTerm, setSearchTerm] = useState('');
  function handleUpdateSearchTerm(event) {
    setSearchTerm(event.target.value);
  };
  return <input type="search" onChange={handleUpdateSearchTerm} />;
};

function Overview() {
  return <p>Currently searching for {searchTerm}</p>;
};

function App() {
  return (
    <>
      <SearchBar />
      <Overview />
    </>
  );
};

在这个例子中,Overview 组件应该输出输入的搜索词。然而,搜索词实际上是由另一个组件管理的------即 SearchBar 组件。在这个简单的例子中,当然可以将这两个组件合并成一个单一的组件,问题就解决了。但在构建更实际的应用时,你很可能会遇到类似的场景,且组件会更加复杂。将组件拆分成更小的部分被认为是一种良好的做法,因为这样可以保持每个组件的可管理性。

因此,多个组件依赖某个共享的状态是你在使用 React 时经常会遇到的场景。

这个问题可以通过提升状态 来解决。在提升状态时,状态不是由使用它的两个组件中的任何一个管理------既不是读取状态的 Overview,也不是设置状态的 SearchBar,而是由一个共享的祖先组件来管理。准确地说,状态是由最近的共享祖先组件来管理的。请记住,组件是相互嵌套的,因此最终会构建起一个"组件树"(以 App 组件为根组件)。

在前面的简单代码示例中,App 组件是 SearchBarOverview 两个组件的最近(并且在这个案例中是唯一的)祖先组件。如果应用程序的结构如图所示,状态在某个 Product 组件中设置,并在 Cart 中使用,那么 Products 就是最近的祖先组件。

状态通过在需要操作(即设置)或读取状态的组件中使用 props,并通过在共享的祖先组件中注册状态来提升。这里是之前更新后的例子:

javascript 复制代码
function SearchBar({ onUpdateSearch }) {
  return <input type="search" onChange={onUpdateSearch} />;
};

function Overview({ currentTerm }) {
  return <p>Currently searching for {currentTerm}</p>;
};

function App() {
  const [searchTerm, setSearchTerm] = useState('');
  function handleUpdateSearchTerm(event) {
    setSearchTerm(event.target.value);
  };

  return (
    <>
      <SearchBar onUpdateSearch={handleUpdateSearchTerm} />
      <Overview currentTerm={searchTerm} />
    </>
  );
};

代码实际上没有发生太多变化;它主要是做了些调整。现在,状态在共享的祖先和 App 组件中进行管理,其他两个组件通过 props 获取状态。

在这个例子中发生了三件关键的事情:

  1. SearchBar 组件接收一个名为 onUpdateSearch 的 prop,其值是一个函数------这个函数是在 App 组件中创建的,并从 App 传递给 SearchBar
  2. onUpdateSearch prop 被设置为 SearchBar 组件中 <input> 元素的 onChange prop 的值。
  3. searchTerm 状态(即它的当前值)通过一个名为 currentTerm 的 prop 从 App 传递给 Overview

前两点可能会让人感到困惑。但请记住,在 JavaScript 中,函数是第一类对象和常规值。你可以将函数存储在变量中,并且在使用 React 时,可以将函数作为 props 的值传递。实际上,你在本章一开始就已经看到过这种方式。当介绍事件和事件处理时,函数被作为值传递给所有这些 onXYZ props(如 onChangeonBlur 等等)。

在这个代码片段中,函数被作为一个值传递给一个自定义 prop(即由你创建的组件中期望的 prop,而不是 React 内置的)。onUpdateSearch prop 期望一个函数作为值,因为该 prop 本身将作为 <input> 元素的 onChange prop 的值来使用。

onUpdateSearch 这个 prop 的命名明确表明它期望一个函数作为值,并且该函数将连接到一个事件。尽管可以选择任何名字;它不一定要以 on 开头。但通常的约定是,期望函数作为值并且打算连接到事件的 props 会这样命名。

当然,updateSearch 不是一个默认的事件,但由于该函数将在 <input> 元素的 change 事件上被调用,所以这个 prop 的行为就像是一个自定义事件。

通过这种结构,状态被提升到了 App 组件中。该组件注册并管理状态。然而,它也通过 handleUpdateSearchTerm 函数(间接地)向 SearchBar 组件暴露了状态更新函数。同时,它还通过 currentTerm prop 向 Overview 组件提供了当前的状态值(searchTerm)。

由于子组件和后代组件在状态变化时也会被 React 重新评估,App 组件的变化也会导致 SearchBarOverview 组件的重新评估。因此,新的 searchTerm prop 值将被捕捉到,UI 将被 React 更新。

这一切不需要新的 React 特性。它只是状态和 props 的结合。然而,根据这些特性如何连接以及它们在何处使用,可以实现简单或更复杂的应用模式。

总结与关键要点

  • 事件处理程序可以通过 on[事件名称] props(例如 onClickonChange)添加到 JSX 元素中。

  • 任何函数都可以在(用户)事件发生时执行。

  • 为了强制 React 重新评估组件并(可能)更新渲染的 UI,必须使用状态。

  • 状态是 React 内部管理的数据,状态值可以通过 useState() Hook 定义。

  • React Hooks 是向 React 组件添加特殊功能的 JavaScript 函数(例如,本章中的状态功能)。

  • useState() 始终返回一个包含两个元素的数组:

    • 第一个元素是当前的状态值。
    • 第二个元素是一个函数,用于将状态设置为新值(即状态更新函数)。
  • 当设置状态为依赖于先前状态的新值时,应将一个函数传递给状态更新函数。该函数接收先前的状态作为参数(React 会自动提供),并返回应设置的新状态。

  • 任何有效的 JavaScript 值都可以作为状态设置------除了字符串或数字等原始值之外。这也包括引用值,如对象和数组。

  • 如果因为另一个组件中的某个事件需要更改状态,则应将状态提升到更高的共享层级(即一个公共祖先组件)进行管理。

相关推荐
Nick_zcy1 小时前
开发基于python的商品推荐系统,前端框架和后端框架的选择比较
开发语言·python·前端框架·flask·fastapi
vvilkim2 小时前
React 与 Vue 虚拟 DOM 实现原理深度对比:从理论到实践
前端·vue.js·react.js
三原4 小时前
2025 乾坤(qiankun)和 Vue3 最佳实践(提供模版)
vue.js·架构·前端框架
小矮马4 小时前
React-组件和props
前端·javascript·react.js
懒羊羊我小弟4 小时前
React Router v7 从入门到精通指南
前端·react.js·前端框架
gaog2zh4 小时前
0803分页_加载更多-网络ajax请求2-react-仿低代码平台项目
react.js·ajax·分页·加载更多
Mars狐狸5 小时前
AI项目改用服务端组件实现对话?包体积减小50%!
前端·react.js
吃面必吃蒜6 小时前
从 Vue 到 React:React 合成事件
javascript·vue.js·react.js
举个栗子dhy6 小时前
【血缘关系图下钻节点,节点展开收起功能,递归和迭代问题处理】
javascript·react.js
Aiolimp7 小时前
React中CSS使用方法
前端·react.js