React-核心概念-三-

36 阅读1小时+

React 核心概念(三)

原文:zh.annas-archive.org/md5/751c075f5f8c25e4f99f8fc75bd4f4d2

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:处理副作用

学习目标

到本章结束时,你将能够做到以下几件事情:

  • 识别你的 React 应用程序中的副作用

  • 理解和使用 useEffect() 钩子

  • 利用与 useEffect() 钩子相关的不同特性和概念,以避免错误并优化你的代码

  • 处理与状态变化相关和无关的副作用

简介

尽管本书之前涵盖的所有 React 示例都相对简单,并且介绍了许多关键 React 概念,但仅凭这些概念很难构建出许多真实的应用程序。

你作为 React 开发者将构建的大多数真实应用程序也需要发送 HTTP 请求,访问浏览器存储和日志分析数据,或执行任何其他类似任务,而仅凭组件、属性、事件和状态,你通常在尝试向应用程序添加此类功能时会遇到问题。详细的解释和示例将在本章后面讨论,但核心问题是这类任务通常会干扰 React 的组件渲染周期,导致意外的错误,甚至破坏应用程序。

本章将更深入地探讨这类操作,分析它们的共同点,最重要的是,教你如何在 React 应用程序中正确处理这类任务。

问题是什么?

在探索解决方案之前,首先理解具体问题是重要的。

与生成(新)用户界面状态无关的操作通常与 React 的组件渲染周期冲突。它们可能会引入错误,甚至破坏整个 Web 应用程序。

考虑以下示例代码片段(重要:不要执行此代码,因为它将导致无限循环并在幕后发送大量 HTTP 请求):

import { useState } from 'react';
import classes from './BlogPosts.module.css';
async function fetchPosts() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const blogPosts = await response.json();
  return blogPosts;
}
function BlogPosts() {
  const [loadedPosts, setLoadedPosts] = useState([]);
  fetchPosts().then((fetchedPosts) => setLoadedPosts(fetchedPosts));
  return (
    <ul className={classes.posts}>
      {loadedPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
export default BlogPosts; 

那么,这段代码有什么问题?为什么它会创建一个无限循环?

在这个例子中,创建了一个 React 组件(BlogPosts)。此外,还定义了一个非组件函数(fetchPosts())。该函数使用浏览器提供的内置 fetch() 函数发送 HTTP 请求到外部 应用程序编程接口API)并获取一些数据。

注意

fetch() 函数由浏览器提供(所有现代浏览器都支持此功能)。你可以在academind.com/tutorials/xhr-fetch-axios-the-fetch-api了解更多关于 fetch() 的信息。

fetch() 函数返回一个 promise,在这个例子中,它通过 async / await 来处理。就像 fetch() 一样,promises 是一个关键的 Web 开发概念,你可以在这里了解更多信息(包括 async / await):developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

在这个上下文中,API 是一个公开各种路径的网站,可以发送请求——无论是提交还是获取数据。jsonplaceholder.typicode.com 是一个模拟 API,响应模拟数据。它可以用于像前面的例子那样的场景,你只需要一个 API 来发送请求。你可以用它来测试一些概念或代码,而无需连接或创建真实的后端 API。在这种情况下,它被用来探索一些 React 问题和解。对于本章和本书整体,预期你将具备使用 fetch() 发送 HTTP 请求和了解 API 的基本知识。如果需要,你可以使用 MDN(developer.mozilla.org/)等页面来加强你对这些核心概念的了解。

在前面的代码片段中,BlogPosts 组件使用 useState() 注册了一个 loadedPosts 状态值。这个状态用于输出一系列博客帖子。尽管这些帖子并没有在应用本身中定义,而是从注释框中提到的外部 API 中获取的。

fetchPosts(),这是一个包含使用内置 fetch() 函数从后端 API 获取博客帖子数据的代码的实用函数,在组件函数体中被直接调用。由于 fetchPosts() 是一个 async 函数(使用 async / await),它返回一个承诺。在 BlogPosts 中,一旦承诺解决,应该执行的代码是通过内置的 then() 方法注册的。

注意

async / await 不会直接在组件函数体中使用,因为常规的 React 组件不能是 async 函数。这样的函数会自动返回一个承诺作为值(即使没有显式的 return 语句),这对于 React 组件来说是一个无效的返回值。

话虽如此,确实存在允许使用 async / await 并返回承诺的 React 组件。所谓的 React 服务器组件 并不局限于返回 JSX 代码、字符串等。这一特性将在 第十六章React 服务器组件与服务器操作 中详细讨论。

一旦 fetchPosts() 的承诺得到解决,提取的帖子数据(fetchedPosts)就被设置为新的 loadedPosts 状态(通过 setLoadedPosts(fetchedPosts))。

如果你运行前面的代码(你不应该这样做!),它最初似乎可以工作。但实际上,它会在幕后启动一个无限循环,不断地用 HTTP 请求打击 API。这是因为,由于从 HTTP 请求中得到了响应,setLoadedPosts() 被用来设置新的状态。

在本书的早期(在第 第四章处理事件和状态 中),你了解到每当组件的状态发生变化时,React 会重新评估该状态所属的组件。“重新评估”简单来说就是组件函数再次被执行(由 React 自动执行)。

由于这个BlogPosts组件在组件函数体内直接调用fetchPosts()(它发送 HTTP 请求),因此每次执行组件函数时都会发送这个 HTTP 请求。并且由于从该 HTTP 请求中获取响应而更新状态(loadedPosts),这个过程再次开始,从而创建了一个无限循环。

在这种情况下,根本问题是发送 HTTP 请求是一个副作用——一个将在下一节中更详细探讨的概念。

理解副作用

副作用是指除了另一个主要过程之外发生的动作或过程。至少,这是一个简洁的定义,有助于在 React 应用程序的上下文中理解副作用。

注意

如果你想深入了解副作用的概念,你还可以探索 Stack Overflow 上的以下关于副作用的讨论:softwareengineering.stackexchange.com/questions/40297/what-is-a-side-effect

在 React 组件的情况下,主过程将是组件渲染周期,其中组件的主要任务是渲染在组件函数中定义的用户界面(返回的 JSX 代码)。React 组件应该返回最终的 JSX 代码,然后将其转换为 DOM 操作指令。

因此,React 将状态变化视为更新用户界面的触发器。注册事件处理器(如onClick)、添加 refs 或渲染子组件(可能通过使用 props)将是属于这个主过程的另一个元素——因为这些概念都与渲染所需用户界面的主要任务直接相关。

正如前例所示,发送 HTTP 请求并不属于这个主过程,它不会直接影响用户界面。虽然响应数据最终可能会显示在屏幕上,但它肯定不会在发送请求的同一个组件渲染周期中被使用(因为 HTTP 请求是异步任务)。

由于发送 HTTP 请求不是由组件函数(渲染用户界面)执行的主过程的一部分,因此它被认为是副作用。它是由同一个函数(BlogPosts组件函数)调用的,而这个函数的主要目标不同。

如果 HTTP 请求是在点击按钮时发送,而不是作为主组件函数体的一部分,那么它就不会是副作用。考虑以下示例:

import { useState } from 'react';
import classes from './BlogPosts.module.css';
async function fetchPosts() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const blogPosts = await response.json();
  return blogPosts;
}
function BlogPosts() {
  const [loadedPosts, setLoadedPosts] = useState([]);
  **function****handleFetchPosts****() {**
    **fetchPosts****().****then****(****(****fetchedPosts****) =>****setLoadedPosts****(fetchedPosts));**
  **}**
  return (
    <>
      **<****button****onClick****=****{handleFetchPosts}****>****Fetch Posts****</****button****>**
      <ul className={classes.posts}>
        {loadedPosts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  );
}
export default BlogPosts; 

这段代码几乎与前一个示例相同,但它有一个重要的区别:JSX 代码中添加了一个<button>。正是这个按钮调用了新添加的handleFetchPosts()函数,然后发送 HTTP 请求(并更新状态)。

进行了此更改后,每次组件函数重新渲染(即,再次执行)时,都不会发送 HTTP 请求。相反,只有在按钮被点击时才会发送,因此,这不会创建无限循环。在这种情况下,HTTP 请求也不假设存在副作用,因为 handleFetchPosts()(即,主要过程)的主要目标是获取新帖子并更新状态。

副作用不仅仅是关于 HTTP 请求

在上一个例子中,你了解到了组件函数中可能发生的一种潜在副作用:HTTP 请求。你也了解到 HTTP 请求并不总是副作用,这取决于它们是在哪里创建的。

通常,任何在执行 React 组件函数时启动的动作,如果该动作与渲染组件用户界面的主要任务没有直接关系,则是一个副作用。

这里是一个副作用示例的非详尽列表:

  • 发送 HTTP 请求(如前所述)

  • 将数据存储到或从浏览器存储中获取数据(例如,通过内置的 localStorage 对象)

  • 设置定时器(通过 setTimeout())或间隔(通过 setInterval()

  • 通过 console.log() 将数据记录到控制台

然而,并非所有副作用都会导致无限循环。只有当副作用导致状态更新时,才会发生这样的循环。

这里是一个不会导致无限循环的副作用示例:

function ControlCenter() {
  function handleStart() {
    // do something ...
  }
  console.log('Component is rendering!'); // this is a side effect!
  return (
    <div>
      <p>Press button to start the review process</p>
      <button onClick={handleStart}>Start</button>
    </div>
  );
} 

在这个例子中,console.log(…) 是一个副作用,因为它作为每个组件函数执行的一部分执行,并且不会影响渲染的用户界面(在这种情况下,既不是针对这个特定的渲染周期,也不是间接地针对任何未来的渲染周期,与之前带有 HTTP 请求的例子不同)。

当然,像这样使用 console.log() 不会引起任何问题。在开发过程中,为了调试目的记录消息或数据是非常正常的。副作用并不一定是问题,实际上,这种副作用可以被使用或容忍。

但你也经常需要处理如之前所述的 HTTP 请求等副作用。有时,当组件渲染时需要获取数据——可能不是每个渲染周期,但通常是第一次执行时(即,当其生成的用户界面首次出现在屏幕上时)。

React 也为此类问题提供了一个解决方案。

使用 useEffect() Hook 处理副作用

为了以安全的方式(即,不创建无限循环)处理如前所述的 HTTP 请求等副作用,React 提供了另一个核心 Hook:useEffect() Hook。

第一个例子可以修复并重写如下:

import { useState, **useEffect** } from 'react';
import classes from './BlogPosts.module.css';
async function fetchPosts() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const blogPosts = await response.json();
  return blogPosts;
}
function BlogPosts() {
  const [loadedPosts, setLoadedPosts] = useState([]);
  **useEffect****(****function** **() {**
    **fetchPosts****().****then****(****(****fetchedPosts****) =>****setLoadedPosts****(fetchedPosts));**
  **}, []);**
  return (
    <ul className={classes.posts}>
      {loadedPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
export default BlogPosts; 

在这个例子中,导入了 useEffect() 钩子并使用它来控制副作用(因此钩子的名字叫 useEffect(),因为它处理 React 组件中的副作用)。确切的语法和用法将在下一节中探讨,但如果你使用这个钩子,你可以安全地运行示例并得到一些类似以下的输出:

计算机屏幕截图  自动生成的描述

图 8.1:一组示例博客文章列表,没有无限循环的 HTTP 请求

在前面的屏幕截图中,你可以看到示例博客文章标题的列表,最重要的是,在检查发送的网络请求时,你找不到无限请求列表。

因此,useEffect() 是解决之前概述的问题的解决方案。它帮助你处理副作用,以便你可以避免无限循环并将它们从组件函数的主要流程中提取出来。

useEffect() 是如何工作的,以及如何正确使用它?

如何使用 useEffect()

如前一个示例代码片段所示,useEffect(),像所有 React 钩子一样,作为组件函数(在这种情况下是 BlogPosts)内部的一个函数执行。

虽然,与 useState()useRef() 不同,useEffect() 不返回值,尽管它接受一个参数(或者实际上,两个参数)像那些其他钩子一样。第一个参数 总是 一个函数。在这种情况下,传递给 useEffect() 的函数是一个匿名函数,通过 function 关键字创建的。

或者,你也可以提供一个作为箭头函数创建的匿名函数(useEffect(() => { … }))或指向某个命名函数(useEffect(doSomething))。唯一重要的是,传递给 useEffect() 的第一个参数 必须 是一个函数。它不能是任何其他类型的值。

在前面的例子中,useEffect() 还接收第二个参数:一个空数组([])。第二个参数必须是一个数组,但提供它是 可选的。你也可以省略第二个参数,只传递第一个参数(函数)给 useEffect()。然而,在大多数情况下,第二个参数是必要的,以实现正确的行为。以下将更详细地探讨这两个参数及其用途。

第一个参数是一个函数,它将由 React 执行。它将在每个组件渲染周期之后执行(即,在每个组件函数执行之后)。

在前面的例子中,如果你只提供这个第一个参数并省略第二个,你仍然会创建一个无限循环。由于 HTTP 请求现在将在每次组件函数执行后发送(而不是作为它的一部分),因此会有一个(不可见的)时间差,但你仍然会触发状态变化,这仍然会触发组件函数再次执行。因此,效果函数将再次运行,并创建一个无限循环。在这种情况下,副作用在技术上是从组件函数中提取出来的,但无限循环的问题并没有得到解决:

useEffect(function () {
  fetchPosts().then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}); // this would cause an infinite loop again! 

将副作用从 React 组件函数中提取出来是useEffect()的主要任务,因此只有第一个参数(包含副作用代码的函数)是必需的。但是,如前所述,你通常还需要第二个参数来控制效果代码执行的频率,因为这就是第二个参数(一个数组)的作用。

useEffect()接收到的第二个参数总是一个数组(除非省略)。这个数组指定了效果函数的依赖项。任何在这个数组中指定的依赖项,一旦它发生变化,就会导致效果函数再次执行。如果没有指定数组(即省略第二个参数),效果函数将在每次组件函数执行时再次执行:

useEffect(function () {
  fetchPosts().then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}, []); 

在前面的例子中,第二个参数没有被省略,但它是一个空数组。这告诉 React 这个效果函数没有依赖项。因此,效果函数将不会再次执行。相反,它只会在组件首次渲染时执行一次。如果你设置没有依赖项(通过提供一个空数组),React 将只执行一次效果函数——直接在组件函数首次执行之后。

重要的是要注意,指定一个空数组与省略它非常不同。如果省略了它,就不会向 React 提供任何依赖信息。因此,React 会在每次组件重新评估后执行效果函数。如果提供了空数组,你明确表示这个效果没有依赖项,因此应该只运行一次。

尽管如此,这又引出了另一个重要的问题:你何时应该添加依赖项?以及依赖项是如何添加或指定的?

影响及其依赖关系

省略useEffect()的第二个参数会导致效果函数(第一个参数)在每次组件函数执行后执行。提供一个空数组会导致效果函数只运行一次(在首次调用组件函数之后)。但这是你能控制的全部吗?

不,不是的。

传递给useEffect()的数组可以也应该包含在效果函数内部使用的所有变量、常量或函数——如果这些变量、常量或函数是在组件函数内部(或在某些父组件函数中,通过 props 传递下来)定义的。

考虑这个例子:

import { useState, useEffect } from 'react';
import classes from './BlogPosts.module.css';
async function fetchPosts(url) {
  const response = await fetch(url);
  const blogPosts = await response.json();
  return blogPosts;
}
function BlogPosts(**{ url }**) {
  const [loadedPosts, setLoadedPosts] = useState([]);
  useEffect(function () {
    fetchPosts(**url**)
     .then((fetchedPosts) => setLoadedPosts(fetchedPosts));
  }, **[url]**);
  return (
    <ul className={classes.posts}>
      {loadedPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
export default BlogPosts; 

这个例子基于前面的例子,但在一个重要地方进行了调整:BlogPosts现在接受一个url属性。

因此,这个组件现在可以被其他组件使用和配置。当然,如果其他组件设置了一个不会返回博客文章列表的 URL,应用程序将无法按预期工作。因此,这个组件可能在实际应用中有限制,但它确实很好地展示了效果依赖的重要性。

但如果其他组件更改了 URL(例如,由于某些用户输入),当然应该发送一个新的请求。因此,每当url属性值发生变化时,BlogPosts应该发送另一个获取请求。

这就是为什么将url添加到useEffect()的依赖项数组中的原因。如果数组保持为空,效果函数将只运行一次(如前节所述)。因此,对url的任何更改都不会对效果函数或作为该函数一部分执行的 HTTP 请求产生影响。不会发送新的 HTTP 请求。

通过将url添加到依赖项数组中,React 注册了这个值(在这种情况下,是一个属性值,但任何值都可以注册),并且每当该值发生变化时(即,每当使用BlogPosts的组件设置新的url属性值时),都会重新执行效果函数。

最常见的效果依赖类型是状态值、属性以及可能在效果函数内部执行的函数。后者将在本章后面进行更深入的分析。

根据规则,你应该将效果函数内部使用的所有值(包括函数)添加到效果依赖项数组中。

在这个新知识的基础上,如果你再次查看前面的useEffect()示例代码,可能会发现一些缺失的依赖项:

useEffect(function () {
  fetchPosts(url)
    .then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}, [url]); 

为什么fetchPostsfetchedPostssetLoadedPosts没有被添加为依赖项?毕竟,这些都是效果函数内部使用的值和函数。下一节将详细说明这一点。

不必要的依赖项

在前面的例子中,可能会觉得应该将fetchPostsfetchedPostssetLoadedPosts作为依赖项添加到useEffect()中,如下所示:

useEffect(function () {
  fetchPosts(url)
    .then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}, [url, fetchPosts, fetchedPosts, setLoadedPosts]); 

然而,对于fetchPostsfetchedPosts,这将是不正确的。对于setLoadedPosts,这将是不必要的。

不应该将fetchedPosts添加,因为它不是一个外部依赖。它是一个局部变量(或更准确地说,是参数),在效果函数内部定义和使用。它没有在属于效果函数的组件函数中定义。如果你尝试将其作为依赖项添加,你会得到一个错误:

计算机屏幕截图 自动生成的描述

图 8.2:发生错误——无法找到 fetchedPosts

发送实际 HTTP 请求的 fetchPosts 函数不是在 effect 函数内部定义的函数。但仍然不应该添加,因为它是在组件函数外部定义的。

因此,这个函数无法改变。它只定义了一次(在 BlogPosts.jsx 文件中),并且无法改变。话虽如此,如果它在组件函数内部定义,情况就不同了。在这种情况下,每当组件函数再次执行时,fetchPosts 函数也会被重新创建。这种情况将在本章后面的部分(在 函数作为依赖 部分中)进行讨论。

然而,在这个例子中,fetchPosts 无法改变。因此,不需要将其作为依赖项添加(并且因此不应该添加)。对于浏览器或第三方包提供的函数,或任何类型的值,只要不是在组件函数内部定义的,都不应该添加到依赖项数组中。

注意

可能会让人困惑,一个函数可能会改变——毕竟,逻辑是硬编码的,对吧?但在 JavaScript 中,函数实际上只是对象,因此可能会改变。当包含函数的代码再次执行时(例如,React 再次执行组件函数),内存中会创建一个新的函数对象。

如果你对这个不熟悉,以下资源可能会有所帮助:academind.com/tutorials/javascript-functions-are-objects .

因此,fetchedPostsfetchPosts 都不应该添加(出于不同的原因)。那么 setLoadedPosts 呢?

setLoadedPosts 是由 useState() 返回用于更新 loadedPosts 状态值的函数。因此,像 fetchPosts 一样,它是一个函数。不过,与 fetchPosts 不同的是,它是一个在组件函数内部定义的函数(因为 useState() 是在组件函数内部调用的)。它是由 React 创建的函数(因为它是由 useState() 返回的),但它仍然是一个函数。因此,理论上应该将其添加为依赖项。实际上,你可以添加它而不会产生任何负面影响。

useState() 返回的状态更新函数是一个特殊情况:React 保证这些函数永远不会改变或被重新创建。当周围的组件函数(BlogPosts)再次执行时,useState() 也会再次执行。然而,只有在组件函数第一次被 React 调用时才会创建一个新的状态更新函数。随后的执行不会导致创建新的状态更新函数。

由于这种特殊行为(即,React 保证函数本身永远不会改变),状态更新函数可以(实际上也应该)省略在依赖项数组中。

由于所有这些原因,fetchedPostsfetchPostssetLoadedPosts 都不应添加到 useEffect() 的依赖项数组中。url 是效果函数使用的唯一可能变化的依赖项(即,当用户在输入字段中输入新的 URL 时),因此应列在数组中。

总结一下,当涉及到向效果依赖项数组添加值时,有三种类型的异常:

  • 在效果内部定义并使用的内部值(或函数)(例如 fetchedPosts

  • 组件函数内部未定义的外部值(例如 fetchPosts

  • 状态更新函数(例如 setLoadedPosts

在所有其他情况下,如果效果函数中使用了某个值,必须将其添加 到依赖项数组中!错误地省略值可能导致意外的效果执行(即,效果执行得太频繁或不够频繁)。

效果后的清理

要执行特定任务(例如,发送 HTTP 请求),许多效果应该在它们的依赖项发生变化时简单地触发。虽然某些效果可以多次重新执行而不会出现问题,但也有效果,如果在之前的任务完成之前再次执行,则表明执行的任务需要取消。或者,也许在相同的效果再次执行时,应该执行一些其他类型的清理工作。

这里有一个示例,其中效果设置了一个计时器:

import { useState, useEffect } from 'react';
function Alert() {
  const [alertDone, setAlertDone] = useState(false);
  useEffect(function () {
    console.log('Starting Alert Timer!');
    setTimeout(function () {
      console.log('Timer expired!');
      setAlertDone(true);
    }, 2000);
  }, []);
  return (
    <>
      {!alertDone && <p>Relax, you still got some time!</p>}
      {alertDone && <p>Time to get up!</p>}
    </>
  );
}
export default Alert; 

Alert 组件在 App 组件中使用:

import { useState } from 'react';
import Alert from './components/Alert.jsx';
function App() {
  const [showAlert, setShowAlert] = useState(false);
  function handleShowAlert() {
    // state updating is done by passing a function to setShowAlert
    // because the new state depends on the previous state (it's the opposite)
    setShowAlert((isShowing) => !isShowing);
  }
  return (
    <>
      <button onClick={handleShowAlert}>
        {showAlert ? 'Hide' : 'Show'} Alert
      </button>
      {showAlert && <Alert />}
    </>
  );
}
export default App; 

App 组件中,Alert 组件是条件性地显示的。showAlert 状态通过 handleShowAlert 函数切换(该函数在按钮点击时触发)。

Alert 组件中,使用 useEffect() 设置了一个计时器。如果没有 useEffect(),将会创建一个无限循环,因为计时器在到期时通过 setAlertDone 状态更新函数更改了一些组件状态(alertDone 状态)。

依赖项数组是一个空数组,因为此效果函数没有使用任何组件值、变量或函数。console.log()setTimeout() 是浏览器内置的函数(因此是外部函数),而 setAlertDone() 可以省略,因为前文提到的理由。

如果你运行此应用并开始切换警报(通过点击按钮),你会注意到奇怪的行为。计时器每次 Alert 组件渲染时都会设置。但它没有清除现有的计时器。这是因为同时运行了多个计时器,如果你查看浏览器开发者工具中的 JavaScript 控制台,可以清楚地看到这一点:

计算机屏幕截图  自动生成的描述

图 8.3:启动了多个计时器

这个示例故意保持简单,但还有其他场景,你可能需要在发送新的请求之前取消当前的 HTTP 请求。在这种情况下,应该先清理效果再重新运行。

React 也为这些情况提供了一个解决方案:传递给 useEffect() 的第一个参数的效果函数可以返回一个可选的清理函数。如果你在效果函数内部返回一个函数,React 将在每次再次运行效果之前执行该函数。

这是带有返回清理函数的 useEffect() 调用的 Alert 组件:

useEffect(function () {
  **let** **timer;**
  console.log('Starting Alert Timer!');
  **timer =** setTimeout(function () {
    console.log('Timer expired!');
    setAlertDone(true);
  }, 2000);
  **return****function****() {**
    **clearTimeout****(timer);**
  **}**
}, []); 

在这个更新的示例中,添加了一个新的 timer 变量(一个仅在效果函数内部可访问的局部变量)。该变量存储由 setTimeout() 创建的计时器的引用。然后可以使用这个引用与 clearTimeout() 一起使用来移除一个计时器。

计时器是在效果函数返回的函数中被移除的——这就是将在 React 下一次调用效果函数之前自动执行清理函数的清理函数。

如果你给它添加一个 console.log() 语句,你就可以看到清理函数的实际效果:

return function() {
  console.log('Cleanup!');
  clearTimeout(timer);
} 

在你的 JavaScript 控制台中,这看起来如下所示:

计算机屏幕截图  自动生成的描述

图 8.4:清理函数在效果再次运行之前执行

在前面的屏幕截图中,你可以看到清理函数是在效果函数再次执行之前执行的(由 Cleanup! 日志表示)。你还可以看到计时器已被成功清除:第一个计时器永远不会过期(屏幕截图中第一个计时器没有 Timer expired! 日志)。

当效果函数第一次被调用时,清理函数不会被执行。然而,每当包含效果的组件卸载(即从 DOM 中移除)时,React 都会调用它。

如果一个效果有多个依赖项,那么每当任何依赖项值发生变化时,效果函数都会被执行。因此,清理函数也会在每次某些依赖项发生变化时被调用。

处理多个效果

到目前为止,本章中的所有示例都只处理了一个 useEffect() 调用。尽管如此,你并不局限于每个组件只调用一次。你可以根据需要多次调用 useEffect()——因此可以注册所需数量的效果函数。

但你需要多少个效果函数呢?

你可以将每个副作用都放入它自己的 useEffect() 包装器中。你可以将每个 HTTP 请求、每个 console.log() 语句和每个计时器放入单独的效果函数中。

话虽如此,正如你在一些之前的示例中看到的那样——特别是前一个部分中的代码片段——这并不是必要的。在那里,你可以在一个 useEffect() 调用中实现多个效果(三个 console.log() 语句和一个计时器)。

一种更好的方法是按照依赖关系拆分你的效果函数。如果一个副作用依赖于状态 A,而另一个副作用依赖于状态 B,你可以将它们放入不同的效果函数中(除非这两个状态相关),如下所示:

function Demo() {
  const [a, setA] = useState(0); // state updating functions aren't called
  const [b, setB] = useState(0); // in this example
  useEffect(function() {
    console.log(a);
  }, [a]);  

  useEffect(function() {
    console.log(b);
  }, [b]);
  // return some JSX code ...
} 

但最好的方法是按照逻辑拆分你的效果函数。如果一个效果涉及通过 HTTP 请求获取数据,而另一个效果是设置计时器,那么将它们放入不同的效果函数(即不同的 useEffect() 调用)通常是有意义的。

作为依赖项的函数

不同的效果有不同的依赖类型,其中一种常见的依赖类型是函数。

如前所述,JavaScript 中的函数只是对象。因此,每当执行包含函数定义的代码时,就会创建一个新的函数对象并将其存储在内存中。调用函数时,执行的是内存中特定的函数对象。在某些情况下(例如,对于在组件函数中定义的函数),可能存在基于相同函数代码的多个对象在内存中。

由于这种行为,即使在基于相同的函数定义,代码中引用的函数也不一定是相等的。

考虑以下示例:

function Alert() {
  function setAlert() {
    setTimeout(function() {
      console.log('Alert expired!');
    }, 2000);
  }
  useEffect(function() {
    setAlert();
  }, [setAlert]);
  // return some JSX code ...
} 

在这个例子中,不是在效果函数内部直接创建计时器,而是在组件函数中创建一个单独的 setAlert() 函数。然后,在传递给 useEffect() 的效果函数中使用该 setAlert() 函数。由于该函数在那里使用,并且因为它是在组件函数中定义的,所以它应该被添加为 useEffect() 的依赖项。

另一个原因是,每当 Alert 组件函数再次执行(例如,因为某些状态或属性值发生变化)时,就会创建一个新的 setAlert 函数对象。在这个例子中,这不会成为问题,因为 setAlert 只包含静态代码。为 setAlert 创建的新函数对象将像上一个一样工作;因此,这不会产生影响。

但现在考虑这个调整后的示例:

注意

完整的应用可以在 GitHub 上找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/examples/function-dependencies

function Alert() {
  **const** **[alertMsg, setAlertMsg] =** **useState****(****'Expired!'****);**
  **function****handleChangeAlertMsg****(****event****) {**
    **setAlertMsg****(event.****target****.****value****);**
  **}**
  function setAlert() {
    setTimeout(function () {
      console.log(**alertMsg**);
    }, 2000);
  }
  useEffect(
    function () {
      setAlert();
    },
    []
  );
  **return****<****input****type****=****"text"****onChange****=****{handleChangeAlertMsg}** **/>****;**
}
export default Alert; 

现在,使用一个新的 alertMsg 状态来设置实际记录到控制台的警告消息。此外,setAlert 依赖关系已从 useEffect() 中移除。

如果你运行此代码,你会得到以下输出:

计算机屏幕截图  自动生成的描述

图 8.5:控制台日志没有反映输入的值

在这个屏幕截图中,你可以看到,尽管输入字段中输入了不同的值,但仍然输出了原始的警告消息。

这种行为的原因是新警报消息没有被捕获。它没有被使用,因为尽管组件函数再次执行(因为状态发生了变化),但效果并没有再次执行。并且原始的效果执行仍然使用旧的setAlert函数版本——旧的setAlert函数对象,其中锁定了旧的警报消息。这就是 JavaScript 函数的工作方式,这就是为什么在这种情况下,期望的结果没有实现。

解决这个问题的方法很简单:将setAlert作为依赖项添加到useEffect()中。你应该始终将效果中使用的所有值、变量或函数作为依赖项添加,这个例子展示了为什么你应该这样做。即使是函数也可以改变。

如果你将setAlert添加到效果依赖数组中,你会得到不同的输出:

useEffect(
  function () {
    setAlert();
  },
  [setAlert]
); 

请注意,只添加了setAlert函数的指针。你不需要在依赖项数组中执行函数(这会将函数的返回值作为依赖项添加,这通常不是目标)。

计算机截图  自动生成的描述

图 8.6:启动多个计时器

现在,每按一个键都会启动一个新的计时器,因此输入的消息会在控制台输出。

当然,这可能也不是你期望的结果。你可能只对最后输入的最终错误消息感兴趣。这可以通过向效果添加清理函数(并对setAlert进行一点调整)来实现:

function setAlert() {
  return setTimeout(function () {
    console.log(alertMsg);
  }, 2000);
}
useEffect(
  function () {
    **const** **timer =** **setAlert****();**
    **return****function** **() {**
      **clearTimeout****(timer);**
    **};**
  },
  [setAlert]
); 

效果清理部分所示,计时器是通过计时器引用和效果清理函数中的clearTimeout()来清除的。

调整代码后,只有最后输入的最终警报消息会被输出。

再次看到清理函数的作用是有帮助的;主要的启示是添加所有依赖项的重要性——包括函数依赖项。

将函数作为依赖项包括的替代方案是将整个函数定义移动到效果函数中,因为任何在效果函数内部定义并使用的值都不应该作为依赖项添加:

useEffect(
  function () {
    **function****setAlert****() {**
      **return****setTimeout****(****function** **() {**
        **console****.****log****(alertMsg);**
      **},** **2000****);**
    **}**
    const timer = setAlert();
    return function () {
      clearTimeout(timer);
    };
  },
  []
); 

当然,你也可以完全去掉setAlert函数,然后将函数的代码移动到效果函数中。

无论哪种方式,你都需要添加一个新的依赖项,alertMsg,现在它被用于效果函数内部。即使setAlert函数不再是依赖项,你仍然必须添加任何使用的值(现在alertMsg被用于效果函数):

useEffect(
  function () {
    function setAlert() {
      return setTimeout(function () {
        console.log(alertMsg);
      }, 2000);
    }
    const timer = setAlert();
    return function () {
      clearTimeout(timer);
    };
  },
  **[alertMsg]**
); 

因此,这种编写代码的替代方法只是个人偏好的问题。它并不会减少依赖项的数量。

如果你将函数移出组件函数,你就可以消除函数依赖。这是因为,如 不必要的依赖项 部分所述,外部依赖项(例如,内置在浏览器中或定义在组件函数之外的)不应作为依赖项添加。

然而,对于 setAlert 函数来说,这是不可能的,因为 setAlert 使用了 alertMsg。由于 alertMsg 是组件状态值,使用它的函数必须在组件函数内部定义;否则,它将无法访问该状态值。

这听起来可能相当复杂,但归结为两个简单的规则:

  • 总是添加所有非外部依赖项——无论它们是变量还是函数。

  • 函数只是对象,如果它们的周围代码再次执行,它们可能会发生变化。

避免不必要的副作用执行

由于所有依赖项都应该添加到 useEffect() 中,有时你最终会得到一些导致不必要的副作用执行的代码。

考虑以下示例组件:

注意

完整的示例可以在 GitHub 上找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/examples/unnecessary-executions

import { useState, useEffect } from 'react';
function Alert() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [enteredPassword, setEnteredPassword] = useState('');
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleUpdatePassword(event) {
    setEnteredPassword(event.target.value);
  }
  function validateEmail() {
    if (!enteredEmail.includes('@')) {
      console.log('Invalid email!');
    }
  }
  useEffect(function () {
    validateEmail();
  }, [validateEmail]);
  return (
    <form>
      <div>
        <label>Email</label>
        <input type="email" onChange={handleUpdateEmail} />
      </div>
      <div>
        <label>Password</label>
        <input type="password" onChange={handleUpdatePassword} />
      </div>
      <button>Save</button>
    </form>
  );
}
export default Alert; 

此组件包含一个带有两个输入字段的形式。输入的值存储在两个不同的状态值中(enteredEmailenteredPassword)。然后 validateEmail() 函数执行一些电子邮件验证,如果电子邮件地址无效,则将消息记录到控制台。validateEmail() 是通过 useEffect() 执行的。

此代码的问题在于,每当 validateEmail 发生变化时,副作用函数都会执行,因为正确地,validateEmail 被添加为依赖项。但是,每当组件函数再次执行时,validateEmail 都会发生变化。这不仅适用于 enteredEmail 的状态变化,也适用于 enteredPassword 的任何变化——即使这个状态值在 validateEmail 内部根本未使用。

可以通过各种解决方案避免这种不必要的副作用执行:

  • 你可以将 validateEmail 中的代码直接移动到副作用函数中(这样 enteredEmail 就会成为副作用的唯一依赖项,避免在任何其他状态变化时执行副作用)。

  • 你可以完全避免使用 useEffect(),因为你可以将电子邮件验证放在 handleUpdateEmail 中执行。其中包含 console.log()(副作用)是可以接受的,因为它不会造成任何伤害。

  • 你可以直接在组件函数中调用 validateEmail()——因为它不会改变任何状态,所以不会触发无限循环。

注意

官方 React 文档中有一篇文章强调了可能不需要 useEffect() 的场景:react.dev/learn/you-might-not-need-an-effect

此外,我创建了一个视频,总结了你需要或不需要 useEffect() 的最重要的情况:www.youtube.com/watch?v=V1f8MOQiHRw

当然,在某些其他场景中,你可能需要使用 useEffect()。幸运的是,React 也为这种情况提供了解决方案:你可以用另一个 React Hook,即 useCallback() Hook,包裹用作依赖项的函数。

调整后的代码将看起来像这样:

import { useState, useEffect, useCallback } from 'react';
function Alert() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [enteredPassword, setEnteredPassword] = useState('');
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleUpdatePassword(event) {
    setEnteredPassword(event.target.value);
  }
  **const** **validateEmail =** **useCallback****(**
    **function** **() {**
      **if** **(!enteredEmail.****includes****(****'@'****)) {**
        **console****.****log****(****'Invalid email!'****);**
      **}**
    **},**
    **[enteredEmail]**
  **);**
  useEffect(
    function() {
      validateEmail();
    },
    [validateEmail]
  );
  // return JSX code ...
}
export default Alert; 

useCallback(),像所有 React Hooks 一样,是一个在组件函数内部直接执行的功能。像 useEffect() 一样,它接受两个参数:另一个函数(可以是匿名函数或命名函数)和一个依赖项数组。

然而,与 useEffect() 不同,useCallback() 不会执行接收到的函数。相反,useCallback() 确保只有在指定的依赖项之一发生变化时,函数才会被重新创建。默认的 JavaScript 行为是在周围代码再次执行时创建一个新的函数对象(合成地)被禁用。

useCallback() 返回最新的保存的函数对象。因此,返回的值(它是一个函数)被保存在一个变量或常量中(在前面的例子中是 validateEmail)。

由于 useCallback() 包装的函数现在只有在依赖项之一发生变化时才会改变,因此返回的函数可以用作 useEffect() 的依赖项,而无需为所有类型的州变化或组件更新执行该效果。

在前一个例子的情况下,副作用函数只有在 enteredEmail 发生变化时才会执行——因为这是唯一会导致创建新的 validateEmail 函数对象的变化。

另一个导致不必要的副作用执行的原因是使用对象作为依赖项,如下例所示:

import { useEffect } from 'react';
function Error(props) {
  useEffect(
    function () {
      // performing some error logging
      // in a real app, a HTTP request might be sent to some analytics API
      console.log('An error occurred!');
      console.log(props.message);
    },
    [props]
  );
  return <p>{props.message}</p>;
}
export default Error; 

这个 Error 组件被用于另一个组件,即 Form 组件,如下所示:

import { useState } from 'react';
import Error from './Error.jsx';
function Form() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [errorMessage, setErrorMessage] = useState('');
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleSubmitForm(event) {
    event.preventDefault();
    if (!enteredEmail.endsWith('.com')) {
      setErrorMessage('Only email addresses ending with .com are accepted!');
    }
  }
  return (
    <form onSubmit={handleSubmitForm}>
      <div>
        <label>Email</label>
        <input type="email" onChange={handleUpdateEmail} />
      </div>
      {errorMessage && <Error message={errorMessage} />}
      <button>Submit</button>
    </form>
  );
}
export default Form; 

Error 组件通过 props(props.message)接收错误消息并在屏幕上显示它。此外,借助 useEffect(),它进行一些错误记录。在这个例子中,错误只是简单地输出到 JavaScript 控制台。在实际应用中,错误可能会通过 HTTP 请求发送到某个分析 API。无论如何,都会执行一个依赖于错误消息的副作用。

Form 组件包含两个状态值,跟踪输入的电子邮件地址以及输入的错误状态。如果提交了无效的输入值,errorMessage 将被设置,并且会显示 Error 组件。

这个例子中有趣的部分是Error组件内部的useEffect()的依赖数组。它包含props对象作为依赖项(props始终是一个对象,将所有属性值组合在一起)。当使用对象(props 或任何其他对象;这并不重要)作为useEffect()的依赖项时,可能会出现不必要的效果函数执行。

你可以在这个例子中看到这个问题。如果你运行应用程序并输入一个无效的电子邮件地址(例如,test@test.de),你会注意到在电子邮件输入字段中的后续按键会导致错误信息被记录(通过效果函数)。

注意

完整代码可以在 GitHub 上找到:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/examples/objects-as-dependencies

计算机屏幕截图 自动生成描述

图 8.7:每次按键都会记录一条新的错误信息

这些额外的执行可能发生,因为组件重新评估(即,组件函数再次被 React 调用)将产生全新的 JavaScript 对象。即使这些对象的属性值没有改变(如前例所示),技术上,JavaScript 会创建一个新的对象。由于效果依赖于整个对象,React 只“看到”该对象有一个新版本,因此会再次运行效果。

在前面的例子中,每当 React 调用Form组件函数时,就会创建一个新的props对象(用于Error组件)——即使错误信息(唯一设置的属性值)没有改变。

在这个例子中,这只会让人感到烦恼,因为它会弄乱开发者工具中的 JavaScript 控制台。然而,如果你向某个分析后端 API 发送 HTTP 请求,这可能会引起带宽问题并使应用程序变慢。因此,最好养成避免不必要的效果执行的惯例。

在对象依赖的情况下,避免不必要的执行的最佳方法是将对象解构,以便只传递那些效果所需的属性作为依赖项:

function Error(props) {
  const { message } = props; // destructure to extract required properties
  useEffect(
    function () {
      console.log('An error occurred!');
      console.log(message);
    },
    // [props] // don't use the entire props object!
    [message]
  );
  return <p>{message}</p>;
} 

在属性的情况下,你还可以在组件函数参数列表中直接解构对象:

function Error({message}) {
  // ...
} 

使用这种方法,你可以确保只有所需的属性值被设置为依赖项。因此,即使对象被重新创建,属性值(在这种情况下,message属性的值)是唯一重要的事情。如果它没有改变,效果函数将不会再次执行。

效果和异步代码

一些副作用处理异步代码(发送 HTTP 请求是一个典型的例子)。在效果函数中执行异步任务时,有一个重要的规则需要记住:效果函数本身不应该异步,也不应该返回承诺。这并不意味着你无法在副作用中处理承诺——你只是不能返回承诺。

你可能想使用async / await来简化异步代码,但在效果函数内部这样做时,很容易意外地返回一个承诺。例如,以下代码可以工作,但不符合最佳实践:

useEffect(async function () {
  const fetchedPosts = await fetchPosts();
  setLoadedPosts(fetchedPosts);
}, []); 

function前添加async关键字可以解锁函数内await的使用——这使得处理异步代码(即,处理承诺)更加方便。

但传递给useEffect()的效果函数应该只返回一个普通函数,如果有的话。它不应该返回承诺。实际上,当尝试运行前面代码片段中的代码时,React 会发出警告:

计算机代码的截图  自动生成的描述

图 8.8:React 显示关于在效果函数中使用异步的警告

为了避免这个警告,你可以像这样使用承诺而不使用async / await

useEffect(function () {
  fetchPosts().then((fetchedPosts) => setLoadedPosts(fetchedPosts));
}, []); 

这之所以有效,是因为效果函数没有返回承诺。

或者,如果你想使用async / await,你可以在效果函数内部创建一个单独的包装函数,然后在该效果中执行:

useEffect(function () {
  async function loadData() {
    const fetchedPosts = await fetchPosts();
    setLoadedPosts(fetchedPosts);
  }

  loadData();
}, []); 

通过这样做,效果函数本身不是异步的(它不返回承诺),但你仍然可以使用async / await

Hooks 规则

在本章中,介绍了两个新的 Hooks:useEffect()useCallback()。这两个 Hooks 都非常重要——useEffect()尤其重要,因为这是你通常会大量使用的 Hooks。与在第四章处理事件和状态中引入的useState()第七章Portals 和 Refs中引入的useRef()一起,你现在有一套坚实的核心 React Hooks。

当使用 React Hooks 时,你必须遵循两条规则(所谓的Hooks 规则):

  • 只在组件函数的最顶层调用 Hooks。不要在if语句、循环或嵌套函数内部调用它们。

  • 只能在 React 组件或自定义 Hook(自定义 Hook 将在第十二章构建自定义 React Hook)内部调用 Hooks。

这些规则存在的原因是,如果以不符合规定的方式使用,React Hooks 将无法按预期工作。幸运的是,如果你违反了这些规则之一,React 会生成一个警告消息;因此,如果你不小心这样做,你会注意到。

摘要和关键要点

  • 与函数的主要流程不直接相关的操作可以被认为是副作用。

  • 副作用可以是异步任务(例如,发送 HTTP 请求),但也可以是同步的(例如,console.log() 或访问浏览器存储)。

  • 副作用通常是为了实现某个目标而需要的,但将它们从函数的主要流程中分离出来是个好主意。

  • 如果副作用导致无限循环(因为效果和状态之间的更新周期),它们可能会变得有问题。

  • useEffect() 是一个 React 钩子,应该用于包装副作用并以安全的方式执行它们。

  • useEffect() 接收一个效果函数和一个效果依赖项数组。

  • 效果函数在组件函数调用后直接执行(不是同时执行)。

  • 在效果内部使用的任何值、变量或函数都应该添加到依赖数组中。

  • 依赖数组异常是外部值(在组件函数外部定义的)、状态更新函数或在效果函数内部定义和使用的值。

  • 如果没有指定依赖数组,效果函数在每次组件函数调用后执行。

  • 如果指定了一个空的依赖数组,效果函数将在组件首次挂载时运行一次(即,当它第一次被创建时)。

  • 效果函数还可以返回可选的清理函数,这些函数在效果函数再次执行之前(以及组件从 DOM 中移除之前)被调用。

  • 效果函数不得返回承诺。

  • 对于函数依赖项,useCallback() 可以帮助减少效果执行的次数。

  • 对于对象依赖项,解构可以有助于减少效果执行的次数。

接下来是什么?

在构建应用程序时处理副作用是一个常见问题,因为大多数应用程序需要某种形式的副作用(例如,发送 HTTP 请求)才能正确工作。因此,副作用本身并不是问题,但如果处理不当,它们可能会引起问题(例如,无限循环)。

通过本章获得的知识,你知道如何使用 useEffect() 和相关关键概念高效地处理副作用。

许多副作用都是由于用户输入或交互触发的——例如,因为某个表单已提交。下一章将通过探索 React 的 表单操作 功能来回顾表单提交的概念。

测试你的知识!

通过回答以下问题来测试你对本章涵盖的概念的了解。然后,你可以将你的答案与可以在 github.com/mschwarzmueller/book-react-key-concepts-e2/blob/08-effects/exercises/questions-answers.md 找到的示例进行比较。:

  1. 你会如何定义副作用?

  2. 在 React 组件中,某些副作用可能会出现什么潜在问题?

  3. useEffect() 钩子是如何工作的?

  4. 应该将哪些值添加到 useEffect() 依赖项数组中?

  5. 效果函数可以返回哪种值?以及哪种类型的值不得返回?

应用你所学的知识

现在你已经了解了效果,你可以在你的 React 应用中添加更多令人兴奋的功能。在组件渲染时通过 HTTP 获取数据与在状态变化时访问浏览器存储一样简单。

在下一节中,你将找到一个活动,让你练习使用效果和useEffect()。像往常一样,你需要应用前面章节中介绍的一些概念(例如,处理状态)。

活动第 8.1 节:构建基本博客

在这个活动中,你必须向现有的 React 应用添加逻辑,以渲染从后端 Web API 获取的博客文章列表,并将新添加的博客文章提交到同一个 API。使用的后端 API 是jsonplaceholder.typicode.com/,这是一个模拟 API,实际上不会存储你发送给它的任何数据。它总是会返回相同的模拟数据,但它非常适合练习发送 HTTP 请求。

作为奖励,你还可以添加逻辑来在保存新博客文章的 HTTP 请求进行时更改提交按钮的文本。

使用你关于效果和浏览器端 HTTP 请求的知识来实现解决方案。

注意

你可以在以下位置找到这个活动的起始代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/activities/practice-1-start。下载此代码时,你将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在这个例子中是activities/practice-1-start),以使用正确的代码快照。

对于这个活动,你需要知道如何通过 JavaScript 发送 HTTP 请求(例如,通过fetch()函数或使用第三方库)。如果你还没有这方面的知识,这个资源可以帮助你入门:packt.link/DJ6Hx

下载代码并在项目文件夹中运行npm install以安装所有必需的依赖项后,解决方案步骤如下:

  1. 向模拟 API 发送GET HTTP 请求,在App组件内部获取博客文章(当组件首次渲染时)。

  2. 在屏幕上显示获取到的模拟博客文章。

  3. 处理表单提交并向模拟后端 API 发送POST HTTP 请求(带有一些模拟数据)。

  4. 奖励:在请求进行时将按钮标题设置为Saving…(当请求完成时设置为Save)。

预期的结果应该是一个看起来像这样的用户界面:

img

图 8.9:最终用户界面

注意

你可以在以下位置找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/08-effects/activities/practice-1

第九章:使用表单操作处理用户输入和表单

学习目标

到本章结束时,你将能够做到以下几点:

  • 描述 React 表单操作的目的

  • 构建和使用自定义表单操作来处理表单提交

  • 使用 useActionState() 钩子管理表单相关的状态

  • 通过 useFormStatus() 钩子渲染提交期间的挂起 UI

  • 使用 useOptimistic() 钩子执行乐观状态更新

  • 实现同步和异步操作

简介

第四章与事件和状态一起工作 中,你学习了如何在 React 应用程序中处理表单提交。虽然那里展示的方法绝对没有问题——实际上,这可能是你在大多数 React 项目中找到的方法——当在使用 React 19 或更高版本的项目中工作时,React 提供了一种处理表单提交的替代方法。React 19 引入了一个名为 actions(在本章中也将称为 表单 actions)的新功能,它可以简化处理表单提交、提取用户输入和提供验证反馈的过程。

本章将首先回顾 第四章 中介绍的表单提交,并探讨如何提取和验证用户输入。之后,本章将介绍表单操作,并解释如何使用该功能执行相同的步骤(处理提交、提取值和验证值)。你还将了解与操作相关的 React 钩子,如 useActionState()

处理不带操作的表单提交

如你在 第四章与事件和状态一起工作 中所学,在不使用操作的情况下,你可以通过在 <form> 元素的 onSubmit 属性上监听 submit 事件来处理表单提交。

考虑以下示例代码片段:

function App() {
  **function****handleSubmit****(****event****) {**
    **event.****preventDefault****();**
    **console****.****log****(****'Submitted!'****);**
  **}**
  return (
    <form **onSubmit****=****{handleSubmit}**>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

你可以在 GitHub 上找到完整的示例:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/09-form-actions/examples/01-form-submission-without-actions

此代码显示了一个表单,并通过 handleSubmit() 函数处理其提交。此函数自动接收一个 event 对象,用于防止浏览器向托管网站的服务器发送 HTTP 请求的默认行为。

但是,当然,仅仅处理提交并不太有用。通常,你还需要提取并使用网站用户输入的值。

提取用户输入

当涉及到提取表单中输入的值时,你有几种选择:

  • 通过状态(即,使用 useState())跟踪值,如 第四章 中所述。

  • 第七章Portals 和 Refs 中所述,通过 useRef() 依赖 Refs。

  • 利用自动创建的 event 对象。

跟踪状态

您可以通过 useState() 管理的状态跟踪用户输入的值,如 第四章 中所述。例如,可以从上一个代码片段中跟踪和使用表单输入值,如下面的示例所示:

function App() {
  **const** **[email, setEmail] =** **useState****(****''****);**
  **const** **[password, setPassword] =** **useState****(****''****);**
  function handleSubmit(event) {
    event.preventDefault();
    **const** **credentials = { email, password };**
    **console****.****log****(credentials);**
  }
  **function****handleEmailChange****(****event****) {**
    **setEmail****(event.****target****.****value****);**
  **}**
  **function****handlePasswordChange****(****event****) {**
    **setPassword****(event.****target****.****value****);**
  **}**
  return (
    <form onSubmit={handleSubmit}>
      <p>
        <label htmlFor="email">Email</label>
        <input
          type="email"
          id="email"
          **value****=****{email}**
          **onChange****=****{handleEmailChange}**
        />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input
          type="password"
          id="password"
          **value****=****{password}**
          **onChange****=****{handlePasswordChange}**
        />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

在这个更新的代码片段中,useState() 钩子用于管理 emailpassword 状态值。每当输入字段上的键入时,状态值都会更新。因此,当表单提交时,handleSubmit() 中可以获取到最新的输入值。

这种方法效果很好,并且将在许多 React 项目中找到。然而,使用状态来跟踪输入值有一些潜在的缺点:

  • 由于状态在每次键入时都会更新,并且组件函数会在某个状态值更改时重新执行,因此应用程序的性能可能会受到影响。

  • 当处理具有更多输入字段的更复杂表单时,可能需要管理许多不同的状态值。

您可以通过实现代码优化(将在 第十章React 的幕后场景和优化机会 中讨论)以及按照 第十一章处理复杂状态 中解释的方式将状态作为对象来管理,来绕过这些问题。

但您也可以考虑使用 Refs 来提取输入值。

依赖 Refs

如果您正在构建一个不打算设置输入值,而只想在表单提交时读取这些值的表单,使用 React 的 ref 功能(在 第七章 中介绍)可能是有意义的:

function App() {
  **const** **emailRef =** **useRef****(****null****);**
  **const** **passwordRef =** **useRef****(****null****);**
  function handleSubmit(event) {
    event.preventDefault();
    const credentials = {
      **email****: emailRef.****current****.****value****,**
      **password****: passwordRef.****current****.****value****,**
    };
    console.log(credentials);
  }
  return (
    <form onSubmit={handleSubmit}>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" **ref****=****{emailRef}** />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" **ref****=****{passwordRef}** />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

在这个代码块中,useRef() 钩子用于创建两个与电子邮件和密码输入字段连接的 Refs。然后,这些 Refs 被用于在 handleSubmit() 中读取输入值。

当使用这种方法时,App 组件函数不再会在每次键入时执行。但您仍然需要编写通过 useRef() 创建 Refs 的代码,以及通过 ref 属性将它们连接到 JSX 元素的代码。

正因如此,您可以考虑依赖浏览器和自动创建的 event 对象(在 handleSubmit() 中接收),而不是使用 React 特性来提取这些输入值。

利用事件对象的优势

第四章处理事件和状态 中,您了解到当表单提交时,浏览器会尝试发送一个 HTTP 请求。这就是为什么在 handleSubmit() 中调用 event.preventDefault() 的原因——这个函数调用确保这个请求不会被发送。

然而,event 对象不仅仅用于防止默认行为。它还携带有关发生的 submit 事件的 重要信息。例如,您可以通过 event.currentTarget 获取底层表单 DOM 对象(即一个描述渲染的 <form> 元素、其配置及其当前状态的 JavaScript 对象)。

这非常有用,因为你可以将该表单 DOM 对象传递给浏览器提供的FormData构造函数。这个接口可以用来提取表单的输入字段值。

以下示例展示了该功能的具体用法:

function App() {
  function handleSubmit(event) {
    event.preventDefault();
    **const** **fd =** **new****FormData****(event.****currentTarget****);**
    const credentials = {
      email: **fd.****get****(****'email'****)**,
      password: **fd.****get****(****'password'****)**,
    };
    console.log(credentials);
  }
  return (
    <form onSubmit={handleSubmit}>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" **name****=****"email"** />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" **name****=****"password"** />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

如上代码片段所示,表单数据对象fd是通过实例化FormData来构建的。如前所述,FormData接口由浏览器提供;因此,不需要从 React 或任何其他库中导入。

这个表单数据对象提供了各种方法来帮助访问表单字段值——例如,get()方法用于提取特定输入字段的值。为了确定你想要获取值的输入字段,get()方法需要一个输入字段名称作为参数。这就是为什么你必须在表单控件元素(即上面示例中的<input>元素)上设置name属性的原因。

这种方法的优势在于你不需要状态或 refs;因此,需要编写的代码略少。此外,由于几乎不使用任何 React 特性,这段代码不太可能因为未来的 React 变化而出现错误。

因此,这种方法可能看起来是处理表单提交的最佳方式。但这是真的吗?

哪个解决方案最好?

处理表单提交没有正确或错误的方式。除了个人偏好外,应用程序的要求也可能使一种方法优于其他方法。

例如,如果你的应用程序需要更改输入值,仅使用上面显示的FormData可能不是最佳选择,因为你将不得不编写命令式代码来更新输入字段。

这是一个问题,因为,如第一章中所述,React – 什么是和为什么?,你应该避免在你的 React 应用程序中编写这样的代码:

function clearInput() {
  document.getElementById('email').value = ''; // imperative code :(
} 

因此,如果你需要编辑输入值,使用状态(即useState())是首选:

const [email, setEmail] = useState('');
// ... other code
function clearInput() {
  setEmail('');
}
// simplified JSX code below
return (
  <form>
    <input 
      value={email} 
      onChange={event => setEmail(event.target.value)} />
  </form>
); 

即使你不需要更新任何输入字段,仅使用event对象和FormData可能也不够。

例如,如果你需要在handleSubmit()之外访问输入字段,则event对象不可用。结果,通过event对象与表单元素及其子元素交互是不可能的。在这种情况下,使用直接连接到单个输入元素的 refs 可能会简化问题。

以下示例使用 ref 来在函数内部调用<input>元素的内置focus()方法:

const emailRef = useRef(null);
function showForm() {
  // other code ...
  emailRef.current.focus(); 
}
// simplified JSX code below
return (
  <form>
    <input ref={emailRef} />
  </form>
); 

因此,正如你所见,没有一劳永逸的解决方案。所有这些 React 特性和处理表单提交的不同方式都存在合理的理由。你可以根据需要混合使用它们;因此,了解这些不同的选项是有帮助的。

尽管已经有几种处理表单提交的方法,但 React 19 又提供了一种新的方法。

使用动作处理表单提交

React 19 引入了(表单)动作的概念——这个概念实际上包含两种类型的动作:客户端动作服务器动作。这两种类型的动作都可以帮助处理表单提交,但为了本章节的目的,术语表单动作将用于描述客户端动作(即,在网站用户的浏览器中执行的表单动作)。服务器动作将在第十六章React 服务器组件与服务器动作中单独介绍。

表单动作的引入是为了简化处理表单提交和数据提取的过程——尤其是在构建带有服务器动作的全栈应用程序时。此外,当与一些新的 React Hooks 结合使用时,它们也非常有用,这些 Hooks 将在本章的后面讨论。

下面是如何通过客户端表单动作处理表单提交的示例:

function App() {
  function **submitAction****(****formData****)** {
    const credentials = {
      email: formData.get('email'),
      password: formData.get('password'),
    };
    console.log(credentials);
  }
  return (
    <form **action****=****{submitAction}**>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" id="email" name="email" />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" name="password" />
      </p>
      <p className="actions">
        <button>Login</button>
      </p>
    </form>
  );
} 

初看,这个例子可能看起来与使用 event 对象和 currentTarget 来推导 FormData 的代码片段非常相似。但如果你仔细观察,你会发现一些关键的区别:

  • handleSubmit 已更名为 submitAction,并接受一个名为 formData 的参数,而不是 event

  • <form> 元素不再有 onSubmit 属性——相反,现在它有一个指向 submitAction 函数的 action 属性。

函数名称更改是可选的;没有技术要求必须将此函数命名为 submitAction 或类似名称。但更改名称是有意义的,因为该函数不再直接处理 submit 事件。相反,它被用作新添加的 action 属性的值。

这正是 React 的表单动作功能的核心所在:将 <form> 元素的 action 属性设置为函数,当表单提交时,React 将代表你调用该函数。然而,与使用 onSubmit 属性不同,React 将阻止浏览器默认行为,并为你创建一个表单数据对象(并将该对象作为参数传递给动作函数)。

你不再需要手动执行这些步骤,因此,表单提交可以用最少的代码来处理。

当然,如果你需要手动设置和管理输入值,或者在某些时候需要与表单字段交互(例如,调用 focus()),你仍然需要与状态或 Refs 一起工作。但如果你只是尝试处理提交并获取输入值,使用表单动作功能将非常方便。

但表单动作之所以有用,不仅仅是因为它们可能需要更少的代码。

同步动作与异步动作

客户端表单动作可以是同步的,也可以是异步的,这意味着你还可以在动作函数中使用并返回一个 Promise。因此,你还可以使用 async / await 与该函数一起使用。

例如,如果你有一个旨在将一些任务数据存储在浏览器存储中的表单(通过localStorage API),你可以使用同步操作来完成(因为localStorage是一个同步 API):

function storeTaskAction(formData) {
  const task = {
    title: formData.get('title'),
    body: formData.get('body'),
    dueDate: formData.get('date')
  };
  localStorage.setItem('daily-task', JSON.stringify(task));
} 

这个操作函数是同步的,因为它不返回Promise或使用async / await。因此,正如你所看到的,迄今为止的所有表单操作示例都使用了同步操作。

但是,如果你正在开发一个需要通过 HTTP 请求将输入数据提交到后端的项目,你可以利用对异步代码的支持:

**async** function storeTodoAction(formData) {
  const todoTitle = formData.get('title');
  const response = await fetch(
    'https://jsonplaceholder.typicode.com/todos', 
    {
      method: 'POST',
      body: JSON.stringify({ title: todoTitle }),
      headers: {
        'Content-type': 'application/json; charset=UTF-8',
      },
    }
  );
  const todo = await response.json();
  console.log(todo);
} 

在这个例子中,在函数前添加了async关键字。这会将函数转换为异步函数,该函数将返回一个Promise

React 表单操作功能提供的这种灵活性非常有用,因为它允许你在表单提交时执行各种操作。然而,重要的是要记住,目前所有这些操作都是在客户端执行的,即在网站访问者的浏览器中。服务器端操作将在第十六章中探讨。

底层:操作是过渡

在深入研究表单操作之前,简要地看看底层可能有所帮助。

这是因为,从技术上讲,React 中的操作(即客户端和服务器操作)被称为所谓的过渡。更准确地说,它们是异步过渡。

因此,问题是,React 中的过渡是什么?

在 React 应用中,过渡是一个概念,React 将确保一些可能耗时的状态更新不会阻塞 UI 更新。

表单操作可以被认为是(潜在的)耗时的状态更新;因此,在底层,React 以使其剩余 UI 保持响应性的方式处理它们。

因此,你在一个表单操作函数内部做出的任何状态更新调用都只会在该表单操作完成后由 React 处理。例如,以下代码可能会出乎意料地只更新 UI 三秒后:

import { useState } from 'react';
function App() {
  const [error, setError] = useState(null);
  async function storeTodoAction(formData) {
    const todoTitle = formData.get('title');
    if (!todoTitle || todoTitle.trim() === '') {
      **setError****(****'Title is required.'****);** **// state update BEFORE delay**
    }
    **// 3s delay to simulate a slow process**
    **await****new****Promise****(****(****resolve****) =>****setTimeout****(resolve,** **3000****));** 
    console.log('Submission done!');
  }
  return (
    <>
      <form action={storeTodoAction}>
        <p>
          <label htmlFor="title">Title</label>
          <input type="text" id="title" name="title" />
        </p>
        {error && <p className="errors">{error}</p>}
        <p className="actions">
          <button>Store Todo</button>
        </p>
      </form>
    </>
  );
} 

即使在延迟开始之前更新了error状态,React 也不会在表单操作整体完成之前重新执行组件函数(因此,更新 UI)。因此,错误信息只会在三秒后出现在屏幕上。

计算机屏幕截图  自动生成的描述

图 9.1:错误信息会延迟显示

注意

你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/09-form-actions/examples/08-transition

基于表单提交管理状态

在处理表单提交时,您可能还希望在提交后更新 UI。对于异步操作,执行的操作可能需要几秒钟(当然,这取决于操作),您甚至可能希望在提交过程中更新 UI,显示一些挂起状态,同时提交的表单正在处理中。

React 旨在通过提供两个特定的表单操作相关的 Hooks 来帮助您满足这两个要求:useActionState()useFormStatus()

使用 useActionState()更新 UI 状态

React 提供了一个名为useActionState()的 Hook,它旨在与表单操作一起使用——无论您是处理客户端还是服务器操作。

这个 Hook 的目标是帮助您根据表单操作的结果更新应用程序的 UI。

例如,这可以帮助验证表单输入值,并在输入无效时显示错误消息。为了执行此任务,可以从react包中导入useActionState() Hook 并按如下方式使用:

**import** **{ useActionState }** **from****'react'****;**
function App() {
  async function storeTodoAction(**prevState**, formData) {
    const todoTitle = formData.get('title');
    if (!todoTitle || todoTitle.trim() === '') {
      **return** **{**
        **error****:** **'Title must not be empty.'****,**
      **};**
    }
    // sending HTTP request etc...
    **return** **{**
      **error****:** **null****,**
    **};**
  }
  **const** **[formState, formAction] =** **useActionState****(storeTodoAction, {**
    **error****:** **null****,**
  **});**
  return (
    <form **action****=****{formAction}**>
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      **{formState.error &&** **<****p****className****=****'errors'****>**
        **{formState.error}**
      **</****p****>****}**
      <p className="actions">
        <button>Store Todo</button>
      </p>
    </form>
  );
} 

当运行此示例应用程序时,如果存在无效输入,用户将看到验证错误消息。

计算机屏幕截图  自动生成的描述

图 9.2:提交空输入字段时显示错误消息

在这个代码示例中发生了一些事情:

  • 表单操作函数已被修改为接受两个参数而不是一个:前一个状态(prevState)和提交的数据(formData)。

  • 现在表单操作也返回一个值:一个包含名为error的键的对象,其中包含错误消息或null

  • useActionState() Hook 被导入并使用:它接收表单操作函数(storeTodoAction)作为第一个参数,以及一些初始状态对象(在这种情况下为{error: null})作为第二个参数。

  • useActionState() Hook 也返回一个值:一个数组,从中解构出两个元素(formStateformAction)。

  • 解构的formAction取代了storeTodoAction作为<form>action属性的值。

  • formState用于有条件地显示存储在formStateerror键中的值。

因此,如您所见,useActionState()是一个 Hook,它期望一个表单操作函数(同步或异步)作为第一个参数,以及一个初始状态作为第二个输入。这个初始状态需要有一些状态可用,如果表单尚未提交。在表单提交后,初始状态将被表单操作函数返回的新状态值所取代。

由于useActionState()的目的在于提供一些可以用来更新(部分)UI 的状态值,因此这个派生状态通过useActionState()返回的值暴露出来:

const [**formState**, formAction] = useActionState(storeTodoAction, {
    error: null,
  }
); 

返回的值是一个包含恰好三个元素的数组,顺序如下:

  1. 当前状态值,要么是初始状态(如果表单尚未提交),要么是表单操作函数返回的状态值。

  2. 一个更新的表单操作函数,本质上就是你的操作函数,由 React 包装。这是必要的,以便 React 能够访问你的操作函数返回的值(即新状态)。

  3. 一个布尔值,表示表单当前是否正在提交。这个第三个元素在之前的代码示例中没有使用,将在本章的“管理待处理 UI 状态”部分进行讨论。

因此,当使用useActionState()时,你不再将你的操作函数绑定到<form>元素的action属性上。相反,你使用由useActionState()创建的操作函数——即你使用包装你的操作函数的操作函数。

当使用useActionState()时,你还必须调整你的表单操作函数,因为 React 将使用两个参数调用你的函数,而不是一个:前一个状态和提交的表单数据:

async function storeTodoAction(**prevState**, formData) {
  // ...
} 

将前一个表单状态传递给你的操作函数,这样你就可以使用它来从它推导出你的新状态(与提交的表单数据结合使用)。在上面的示例中,这实际上并不是这样——前一个状态参数在那里没有被使用。尽管如此,它仍然必须作为参数接受。

然而,对表单操作函数所做的更改不止这些。相反,现在它还应该返回一个新的状态值,然后通过useActionState()(通过useActionState()返回的数组中的第一个元素)暴露给组件函数:

async function storeTodoAction(**prevState**, formData) {
  // ...
  return {
    error: 'Title must not be empty.'
  };
} 

该状态值可以是任何东西——一个字符串、一个数字、一个数组、一个对象等。在之前的代码示例中,它是一个具有名为error的键的对象,该键包含null或一个字符串错误消息。

每当表单提交时,因此表单操作函数被执行或返回一个值,useActionState()将触发 React 重新执行周围的组件函数。因此,更新后的状态变得可用。如果你觉得这与useState()相似,你是对的!useActionState()本质上就像useState(),但经过微调,可以从操作中推导状态。

因此,useActionState()肯定是一个重要的 Hook,尽管它实际上并不仅限于仅将你的操作函数返回的值暴露给组件函数。

使用useActionState()管理待处理 UI 状态

考虑一个场景,你有一个表单操作需要几秒钟才能完成其操作。例如,你可以有一个向慢速服务器或通过慢速互联网连接发送请求的操作。在这种情况下,你可能想在表单提交期间更新 UI,以向用户显示正在发生某些事情。

在下面的示例中,从表单操作内部调用了名为saveTodo()的函数。该函数故意延迟三秒钟来模拟缓慢的网络或服务器:

**async****function****saveTodo****(****todo****) {**
  **// dummy function that simulates a slow backend which manages todos**
  **await****new****Promise****(****(****resolve****) =>****setTimeout****(resolve,** **3000****));** **// delay**
  **const** **response =** **await****fetch****(**
    **'https://jsonplaceholder.typicode.com/todos'****, {**
      **method****:** **'POST'****,**
      **body****:** **JSON****.****stringify****(todo),**
      **headers****: {**
        **'Content-type'****:** **'application/json; charset=UTF-8'****,**
      **},**
    **}**
  **);**
  **const** **fetchedTodo =** **await** **response.****json****();**
  **console****.****log****(fetchedTodo);**
**}**
function App() {
  async function storeTodoAction(prevState, formData) {
    const todoTitle = formData.get('title');
    if (!todoTitle || todoTitle.trim() === '') {
      return {
        error: 'Title must not be empty.',
      };
    }
    **await****saveTodo****({** **title****: todoTitle });**
    return {
      error: null,
    };
  }
  // same code as before, hence omitted
} 

当使用表单操作,如本例所示,在处理表单提交时更新 UI 相对容易,因为useActionState()在其返回的数组中暴露了第三个元素:一个布尔值,指示操作是否正在执行。

因此,上述示例可以调整如下,以利用该布尔值:

function App() {
  async function storeTodoAction(prevState, formData) {
    // same code as before, hence omitted
  }
  const [formState, formAction, **pending**] = useActionState(
    storeTodoAction, 
    {
      error: null,
    }
  );
  return (
    <form action={formAction}>
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      {formState.error && 
        <p className="errors">{formState.error}</p>}
      <p className="actions">
        <button **disabled****=****{pending}**>
          **{pending ? 'Saving' : 'Store'} Todo**
        </button>
      </p>
    </form>
  );
} 

通过解构从数组中检索pending元素,然后使用它来禁用<button>并更新按钮文本。

因此,一旦表单提交,UI 就会发生变化——直到三秒后完成(在这种情况下,由于之前在saveTodo()函数中添加的延迟)。

计算机截图,描述自动生成

图 9.3:按钮在表单提交期间被禁用,并显示“保存待办”回退文本

使用useFormStatus()处理待处理 UI 状态

useActionState()返回的pending元素是一个简单直接的方法,但不是唯一的方法,在表单操作执行时更新 UI。

React 还提供了一个useFormStatus() Hook,它提供了有关当前表单提交状态的信息。更准确地说,这是react-dom包(而不是react!)导出的useFormStatus() Hook。

useActionState()不同,useFormStatus()必须在某个嵌套组件中调用,该组件被包裹在您感兴趣的提交状态的<form>元素中。

例如,您可以构建一个SubmitButton组件,如以下代码片段所示:

**import** **{ useFormStatus }** **from****'react-dom'****;**
import { saveTodo } from './todos.js';
function SubmitButton() {
  **const** **{ pending } =** **useFormStatus****();**
  return (
    <button disabled={pending}>
      **{pending ? 'Saving' : 'Store'} Todo**
    </button>
  );
}
function App() {
  async function storeTodoAction(formData) {
    const todo = { title: formData.get('title') };
    await saveTodo(todo);
  }
  return (
    <form action={storeTodoAction}>
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      <p className="actions">
        **<****SubmitButton** **/>**
      </p>
    </form>
  );
} 

在此示例中,将待办事项发送到后端服务器的实际代码被提取到一个单独的saveTodo()函数中,该函数存储在todo.js文件中。该函数包含与之前示例中相同的代码(即,它向 JSONPlaceholder 发送 HTTP 请求)。此外,移除了useActionState()以使代码更短、更简单。然而,您绝对可以在useFormStatus()useActionState()结合使用。例如,您可以使用useActionState()输出验证错误,同时在单独的嵌套组件中通过useFormStatus()管理提交按钮的disabled状态。

useFormStatus()react-dom导入,并在SubmitButton组件函数内部调用。它返回一个包含一个pending属性,该属性产生一个布尔值的对象。

如前所述,useFormStatus()不能用于渲染<form>元素的组件中。相反,它必须在嵌套组件中使用——这就是为什么<SubmitButton>组件被放置在<form>标签之间。

除了pending之外,useFormStatus()返回的对象还包含三个其他属性:

  • data:一个FormData对象,包含提交父<form>时使用的数据(即,与表单操作函数接收的数据相同)。

  • method:一个字符串值,可以是 'get''post' ,反映 <form> 元素的 method 属性设置的值。默认情况下,它是 'get'

  • action:指向与 <form> 相连的表单操作函数的指针。

如果你只关心待处理状态,当然你可以使用 useActionState()useFormStatus() 。使用 useActionState() 的优点是无需构建单独的嵌套组件。另一方面,如果你在页面上有多个表单,创建这样一个额外的组件并依赖于 useFormStatus() 可能是有用的——例如,你可以在所有这些表单中重用 <SubmitButton>

执行乐观更新

除了 useActionState()useFormStatus() ,React 还提供了一个与表单和表单操作相关的重要的最后一个 Hook:useOptimistic() Hook。

这个 Hook 背后的想法是,你可以用它来显示一些临时的、乐观的 UI,同时异步表单操作(可能需要几秒钟)正在进行中。“乐观”意味着你可以使用这个 Hook 来渲染通常只有在表单提交完成后才存在的 UI(例如,已经包括新提交的任务的待办事项列表)。

以下示例代码使用 <form> 和表单操作管理待办事项列表,但没有使用 useOptimistic()

import { useFormStatus } from 'react-dom';
import { useState } from 'react';
let storedTodos = [];
export async function saveTodo(todo) {
  // dummy function that simulates a slow backend which manages todos
  **await****new****Promise****(****(****resolve****) =>****setTimeout****(resolve,** **3000****));**
  const newTodo = { ...todo, id: new Date().getTime() };
  storedTodos = [...storedTodos, newTodo];
  return storedTodos;
}
function SubmitButton() {
  // same as before, didn't change, hence omitted here
}
function App() {
  **const** **[todos, setTodos] =** **useState****(storedTodos);**
  async function storeTodoAction(formData) {
    const todo = { title: formData.get('title') };
    const updatedTodos = await saveTodo(todo); // takes 3s
    **setTodos****(updatedTodos);**
  }
  return (
    <>
      <form action={storeTodoAction}>
        <p>
          <label htmlFor="title">Title</label>
          <input type="text" id="title" name="title" />
        </p>
        <p className="actions">
          <SubmitButton />
       </p>
      </form>
      <div id="todos">
        <h2>My Todos</h2>
        **{todos.length === 0 &&** **<****p****>****No todos found.****</****p****>****}**
        **{todos.length > 0 && (**
          **<****ul****>**
            **{todos.map((todo) => (**
              **<****li****key****=****{todo.id}****>****{todo.title}****</****li****>**
            **))}**
          **</****ul****>**
        **)}**
      </div>
    </>
  );
} 

在这个例子中,由于 saveTodo() 函数再次内置了三秒钟的故意延迟,网站用户会看到过时的待办事项列表,直到表单提交过程完成。

img

图 9.4:没有乐观更新时,UI 更新被延迟

因此,可以通过引入 useOptimistic() Hook 来提高用户体验。

这个 Hook 需要两个参数,并返回一个包含恰好两个元素的数组:

const [optimisticState, addOptimistic] = useOptimistic(
  state, updateFunction
); 
  • state(第一个参数)是初始时应处于活动状态或没有待处理的表单操作时的组件状态。

  • updateFunction(第二个参数)是你定义的函数,它控制状态应该如何乐观地更新。

  • optimisticState 是在表单操作执行期间将处于活动状态的乐观更新状态。

  • addOptimistic 触发 updateFunction 并允许你向该函数传递一个值。

应用到上述示例中,useOptimistic() 可以用来管理一个替代的、乐观更新的待办事项数组,只要表单操作正在执行,这个数组就会是活动的。之后,常规状态将再次变得活跃(并相应地更新 UI):

**import** **{ useOptimistic }** **from****'react'****;**
import { saveTodo, getTodos } from './todos.js';
import { useState } from 'react';
function SubmitButton() {
  // same code as before, hence omitted
}
function App() {
  const loadedTodos = getTodos(); // initial fetch
  const [todos, setTodos] = useState(loadedTodos);
  **const** **[optimisticTodos, addOptimisticTodo] =** **useOptimistic****(**
    **todos,**
    **(****currentState, optimisticValue****) =>** **{**
      **return** **[...currentState, { ...optimisticValue,** **id****:** **'temp'** **}];**
    **}**
  **);**
  async function storeTodoAction(formData) {
    const todo = { title: formData.get('title') };
    **addOptimisticTodo****(todo);**
    const updatedTodos = await saveTodo(todo);
    setTodos(updatedTodos);
  }
  return (
    <form action={storeTodoAction}>
      <p>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" />
      </p>
      <p className="actions">
        <SubmitButton />
      </p>
    </form>
    <div id="todos">
      <h2>My Todos</h2>
      {**optimisticTodos**.length === 0 && <p>No todos found.</p>}
      {**optimisticTodos**.length > 0 && (
        <ul>
          {**optimisticTodos**.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
} 

如此例所示,optimisticTodos 状态现在被用于 JSX 代码中。该常量中存储的值要么是正常的 todos 状态(由 useState() 管理),如果 storeTodoAction() 表单操作没有执行,要么是传递给 useOptimistic() 的函数生成的数组(作为第二个参数)。

img

图 9.5:使用 useOptimistic() 后,提交后 UI 立即更新

使用 useOptimistic() 钩子可以帮助构建一个出色的用户体验,即使某些慢速进程可能仍在后台运行,您的应用程序也能提供即时反馈。由于一旦表单提交完成,临时的乐观状态总会被常规状态(即 todos 状态)所取代,因此也不会有显示不正确用户界面的风险。如果操作失败,React 将会自动将暂时不正确的用户界面替换为正确的界面,当它回退到使用常规状态时。

摘要和关键要点

  • 表单提交可以通过手动监听 submit 事件通过 onSubmit 属性来处理。

  • 或者,可以使用表单操作——即绑定到 <form> 元素的 action 属性的函数。

  • 当手动处理表单提交(通过 onSubmit)时,您可以使用状态(useState())、Refs(useRef())或从 event.currentTarget 创建一个 FormData 对象来提取表单字段值。

  • 当使用表单操作时,一个包含表单字段输入值的表单数据对象会自动作为参数传递给操作函数。

  • useActionState() 钩子可以用来管理与表单相关的状态(例如,验证错误消息)。

  • useActionState() 也提供了一个待定布尔值,可以在表单操作处理时用于更新用户界面。

  • 在嵌套组件(嵌套在 <form> 内)中,可以调用 useFormStatus() 钩子来获取和使用有关父表单提交状态的信息。

  • 为了在处理慢速后台进程(例如,慢速 HTTP 请求)时提供快速的用户界面更新,useOptimistic() 钩子可能有所帮助。

接下来是什么?

处理表单和处理用户输入是大多数网络应用程序中一个非常常见的任务。当然,React 应用程序也不例外。

正因如此,React 提供了广泛的方法和可能的模式,您可以使用它们来处理表单提交和提取用户输入。本章探讨了并比较了两种主要的方法:使用 onSubmit 属性或依赖表单操作(仅从 React 19 开始可用)。

正如本章中解释和展示的那样,这两种方法都是有效的,并且各有用例。个人偏好以及应用程序需求都很重要,并将影响您的决策。

到这本书的这一部分,您已经了解了构建功能丰富的网络应用程序所需的所有关键 React 概念。下一章将深入 React 的幕后,探索它是如何内部工作的。您还将了解一些常见的优化技术,这些技术可以使您的应用程序性能更佳。

测试你的知识!

通过回答以下问题来测试您对本章所涵盖概念的了解。然后,您可以比较您的答案与可在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/09-form-actions/exercises/questions-answers.md 找到的示例:

  1. “表单操作”是什么?

  2. 如何在表单操作内部访问用户输入?

  3. useActionState() 钩子的目的是什么?它是如何使用的?

  4. useFormStatus() 钩子的目的是什么?它是如何使用的?

  5. useActionState()useFormStatus() 之间的区别是什么?

  6. useOptimistic() 钩子的目的是什么?它是如何使用的?

应用所学知识

在您的 React 工具包中添加表单操作,您又有另一种强大的处理表单提交和提取用户输入的方法。

在以下部分,您将找到一个活动,允许您练习使用表单操作和 React 提供的与表单相关的钩子。一如既往,您还需要应用之前章节中介绍的一些概念(例如处理状态或输出列表)。

活动九.1:管理反馈表单

在这个活动中,您的任务是构建一个现有的、基本的反馈表单应用程序,并使用表单操作处理表单提交。作为此活动的一部分,您应该验证提交的标题和反馈文本,并在提交空值时显示错误消息。您还应该乐观地更新提交的反馈项列表,并在表单操作进行时禁用提交按钮。

注意

您可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/09-form-actions/activities/practice-1-start 找到此活动的起始代码。在下载此代码时,您将始终下载整个存储库。请确保导航到包含起始代码的子文件夹(在这种情况下为 activities/practice-1-start)以使用正确的代码快照。

下载代码后,在项目文件夹中运行 npm install 以安装所有必需的依赖项,解决方案步骤如下:

  1. 将现有的 onSubmit 处理器函数替换为表单操作—之后清理并删除不再需要的任何代码。

  2. 在表单操作处理过程中禁用表单提交按钮。

  3. 使用 useActionState() 钩子验证用户输入并输出任何错误消息。

  4. 通过利用 useOptimistic() 钩子乐观地更新提交的反馈项列表。

预期结果应类似于以下截图:

img

图 9.6:在表单提交期间,按钮被禁用,但提交的项目立即显示

img

图 9.7:当提交无效值时,会显示适当的错误信息

注意

您可以在此处找到完整的示例解决方案:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/09-form-actions/activities/practice-1 .

第十章:React 背后的场景和优化机会

学习目标

在本章结束时,你将能够做到以下事项:

  • 通过useMemo()useCallback()钩子避免不必要的代码执行

  • 通过 React 的lazy()函数按需加载可选代码,仅在需要时

  • 使用 React 的开发者工具分析和优化你的应用

  • 探索 React 编译器以实现自动性能提升

简介

使用到目前为止所涵盖的所有功能,你可以构建非平凡的 React 应用,因此可以构建高度交互和响应式的 UI。

本章虽然介绍了一些新的函数和概念,但不会提供让你能够构建更高级 Web 应用的工具。你将不会学习到像状态或属性这样的突破性、关键概念(尽管你将在后面的章节中学习到更高级的概念)。

相反,本章让你能够深入了解 React 背后的场景。你将学习 React 如何计算所需的 DOM 更新,以及它是如何确保这些更新不会以不可接受的方式影响性能。你还将了解 React 使用的其他一些优化技术——所有这些技术都是为了确保你的 React 应用尽可能流畅地运行。

除了这个幕后场景之外,你还将了解各种内置函数和概念,这些函数和概念可以用来进一步优化应用性能。本章不仅将介绍这些概念,还将解释为什么它们存在,如何使用它们,以及何时使用哪个功能。

重新审视组件评估和更新

在探索 React 的内部工作原理之前,简要回顾 React 执行组件函数的逻辑是有意义的。

组件函数会在某些状态(通过useState()管理)改变或其父组件函数再次执行时执行。后者发生是因为,如果调用父组件函数,其整个 JSX 代码(指向子组件函数)将被重新评估。因此,在该 JSX 代码中引用的任何组件函数也将再次被调用。

考虑以下组件结构:

function NestedChild() {
  console.log('<NestedChild /> is called.');
  return (
    <p id="nested-child">
      A component, deeply nested into the component tree.
    </p>
  );
}
function Child() {
  console.log('<Child /> is called.');
  return (
    <div id="child">
      <p>
        A component, rendered inside another component, 
        containing yet another component.
      </p>
      <NestedChild />
    </div>
  );
}
function Parent() {
  console.log('<Parent /> is called.');
  const [counter, setCounter] = useState(0);
  function handleIncCounter() {
    setCounter((prevCounter) => prevCounter + 1);
  }
  return (
    <div id="parent">
      <p>
        A component, nested into App, 
        containing another component (Child).
      </p>
      <p>Counter: {counter}</p>
      <button onClick={handleIncCounter}>Increment</button>
      <Child />
    </div>
  );
} 

在这个示例结构中,Parent组件渲染一个包含两个段落、一个按钮和另一个组件的<div>Child组件。该组件随后输出一个包含一个段落和另一个组件的<div>NestedChild组件(然后只输出一个段落)。

Parent组件还管理一些状态(一个虚拟计数器),每当按钮被点击时,该状态就会改变。所有三个组件通过console.log()打印一条消息,只是为了方便在 React 调用每个组件时识别。

以下截图显示了这些组件在按钮点击后的动作:

计算机的截图 自动生成描述

图 10.1:每个组件函数都会执行

在这个屏幕截图中,你可以不仅看到组件是如何嵌套在一起的,还可以看到当点击“增加”按钮时,React 是如何调用所有组件的。即使“子”和“嵌套子”组件没有管理或使用任何状态,它们也会被调用。但既然它们是“父”组件(Child)或后代组件(NestedChild),而“父”组件确实接收到了状态变化,因此嵌套的组件函数也会被调用。

理解组件函数执行流程的重要性在于,这个流程意味着任何组件函数的调用也会影响其子组件。它还展示了 React 如何频繁地调用组件函数,以及单个状态变化可能影响多少组件函数。

因此,有一个重要的问题需要回答:当调用一个或多个组件函数时,实际的页面 DOM(即浏览器中加载和渲染的网站)会发生什么?DOM 是否被重新创建?渲染的 UI 是否被更新?

组件函数被调用时会发生什么

每当组件函数执行时,React 都会评估渲染的 UI(即加载页面的 DOM)是否需要更新。

这很重要:React 会评估是否需要更新。它不会自动强制更新!

内部,React 不会用组件(或多个组件)返回的 JSX 代码替换页面 DOM。

这是可以做到的,但这意味着每次组件函数执行都会导致某种形式的 DOM 操作——即使只是用新的、类似的内容替换旧的 DOM 内容。在上面的示例中,每次执行那些组件函数时,都会使用“子”和“嵌套子”JSX 代码来替换当前渲染的 DOM。

正如你在上面的示例中看到的,那些组件函数执行得相当频繁。但返回的 JSX 代码始终相同,因为它是静态的。它不包含任何动态值(例如状态或属性)。

如果实际的页面 DOM 被替换为返回 JSX 代码所表示的 DOM 元素,视觉结果将始终相同。但幕后仍然会有一些 DOM 操作。这是一个问题,因为操作 DOM 是一项性能密集型任务——尤其是在高频操作时。因此,只有在需要时才应该进行 DOM 的删除、添加或更新——而不是不必要的操作。

由于这个原因,React 不会因为组件函数的执行而丢弃当前的 DOM 并替换成新的 DOM(由 JSX 代码表示)。相反,React 首先检查是否需要更新。如果需要,只有需要更改的 DOM 部分才会被替换或更新。

为了确定是否需要更新(以及在哪里),React 使用了一个称为虚拟 DOM的概念。

虚拟 DOM 与真实 DOM

为了确定是否(以及在哪里)可能需要 DOM 更新,React(特别是react-dom包)将当前 DOM 结构与由执行组件函数返回的 JSX 代码隐含的结构进行比较。如果有差异,DOM 将相应更新;否则,保持不变。

然而,正如操作 DOM 相对性能开销较大一样,读取 DOM 也是如此。即使不更改 DOM 中的任何内容,访问它、遍历 DOM 元素并从中推导结构也是您通常希望减少到最小的事情。

如果多个组件函数被执行,并且每个函数都触发一个过程,其中渲染的 DOM 元素被读取并与由调用组件函数隐含的 JSX 结构进行比较,那么在非常短的时间内,渲染的 DOM 将被多次执行读取操作。

对于由数十、数百甚至数千个组件组成的较大 React 应用,在单个秒内可能发生数十次组件函数执行的可能性非常高。如果这导致相同数量的 DOM 读取操作,那么 web 应用对用户来说可能会感觉缓慢或滞后。

这就是为什么 React 不使用真实 DOM 来确定是否需要任何 UI 更新。相反,它内部构建并管理一个虚拟 DOM——这是在浏览器中渲染的 DOM 的内存表示。虚拟 DOM 不是浏览器功能,而是 React 功能。您可以将它想象为一个深度嵌套的 JavaScript 对象,它反映了您的 web 应用的组件,包括所有内置的 HTML 组件,如<div><p>等。(即最终应在页面上显示的实际 HTML 元素)。

计算机程序图  自动生成描述

图 10.2:React 管理预期元素结构的虚拟表示

在上面的图中,您可以看到预期的元素结构(换句话说,预期的最终 DOM)实际上存储为一个 JavaScript 对象(或一个包含对象列表的数组)。这是虚拟 DOM,由 React 管理并用于识别所需的 DOM 更新。

注意

请注意,虚拟 DOM 的实际结构比图中显示的结构更复杂。上面的图表旨在让您了解虚拟 DOM 是什么以及它可能看起来像什么。它不是 React 管理的 JavaScript 数据结构的精确技术表示。

React 管理这个虚拟 DOM,因为将这个虚拟 DOM 与预期的 UI 状态进行比较,比访问真实 DOM 要少得多,性能开销更小。

每当调用组件函数时,React 会将返回的 JSX 代码与虚拟 DOM 中存储的相关虚拟 DOM 节点进行比较。如果检测到差异,React 将确定需要更新的 DOM 更改。一旦推导出所需的调整,这些更改将应用于虚拟和真实 DOM。这确保了真实 DOM 反映了预期的 UI 状态,而无需不断访问或更新它。

计算机程序图  自动生成的描述

图 10.3:React 通过虚拟 DOM 检测所需的更新

在上面的图中,你可以看到 React 如何首先使用虚拟 DOM 比较当前的 DOM 和预期的结构,然后再去操作真实的 DOM。

作为 React 开发者,你不需要主动与虚拟 DOM 交互。技术上,你甚至不需要知道它的存在以及 React 在内部使用它。但了解你正在使用的工具(在这种情况下是 React)总是有帮助的。了解 React 为你做了各种性能优化,并且你可以在许多其他使你的开发者生活(希望)更轻松的功能之上获得这些优化,这是很好的。

状态批处理

由于 React 使用虚拟 DOM 的概念,频繁的组件函数执行并不是一个大问题。但当然,即使比较是在虚拟层面上进行的,仍然有一些内部代码必须执行。即使在虚拟 DOM 的情况下,如果必须进行大量的不必要的(同时相当复杂的)虚拟 DOM 比较,性能可能会下降。

在执行多个连续状态更新时,进行不必要的比较的一个场景是。由于每个状态更新都会导致组件函数再次执行(以及所有潜在的嵌套组件),一起执行(例如,在同一个事件处理函数中)的多个状态更新将导致多次组件函数调用。

考虑这个例子:

function App() {
  const [counter, setCounter] = useState(0);
  const [showCounter, setShowCounter] = useState(false);
  function handleIncCounter() {
    setCounter((prevCounter) => prevCounter + 1);
    setShowCounter(true);
  }
  return (
    <>
      <p>Click to increment + show or hide the counter</p>
      <button onClick={handleIncCounter}>Increment</button>
      {showCounter && <p>Counter: {counter}</p>}
    </>
  );
} 

此组件包含两个状态值:countershowCounter。当按钮被点击时,计数器增加1。此外,showCounter被设置为true。因此,第一次点击按钮时,countershowCounter状态都会发生变化(因为showCounter最初为false)。

由于有两个状态值被更改,预期 React 会调用App组件函数两次——因为每次状态更新都会导致连接的组件函数再次被调用。

然而,如果你在App组件函数中添加一个console.log()语句(用于跟踪其执行频率),你会看到它只被调用一次,当点击Increment按钮时:

计算机截图  自动生成的描述

图 10.4:只显示一条控制台日志消息

注意

如果你看到两条日志消息而不是一条,请确保你没有使用 React 的“严格模式”。在开发期间运行严格模式时,React 会比通常情况下更频繁地执行组件函数。

如果需要,你可以通过从你的 main.jsx 文件中移除 <React.StrictMode> 组件来禁用严格模式。你将在本章的末尾了解更多关于 React 严格模式的内容。

这种行为被称为 状态批处理。当你的代码中的同一位置(例如,在同一个事件处理函数内部)发起多个状态更新时,React 会执行状态批处理。

这是一个内置的功能,确保你的组件函数不会被调用得比需要的更频繁。这防止了不必要的虚拟 DOM 比较。

状态批处理是一个非常有用的机制。但是,它无法防止另一种不必要的组件评估:当父组件函数被调用时执行的子组件函数。

避免不必要的子组件评估

每当组件函数被调用(例如,因为其状态改变),任何嵌套的组件函数也将被调用。请参阅本章的第一部分以获取更多详细信息。

正如你在本章第一部分的例子中所看到的,通常情况下,那些嵌套的组件实际上并不需要再次评估。它们可能不依赖于父组件中改变的状态值。它们甚至可能不依赖于父组件的任何值。

这里有一个例子,其中父组件函数包含一些子组件不使用的状态:

function Error({ message }) {
  if (!message) {
    return null;
  }
  return <p className={classes.error}>{message}</p>;
}
function Form() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [errorMessage, setErrorMessage] = useState();
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleSubmit(event) {
    event.preventDefault();
    if (!enteredEmail.endsWith('.com')) {
      setErrorMessage('Email must end with .com.');
    }
  }
  return (
    <form className={classes.form} onSubmit={handleSubmit}>
      <div className={classes.control}>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={enteredEmail}
          onChange={handleUpdateEmail}
        />
      </div>
      <Error message={errorMessage} />
      <button>Sign Up</button>
    </form>
  );
} 

注意

你可以在 GitHub 上找到完整的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/examples/03-memo

在这个例子中,Error 组件依赖于 message 属性,该属性设置为 Form 组件中 errorMessage 状态存储的值。然而,Form 组件还管理一个 enteredEmail 状态,该状态没有被 Error 组件使用(未通过属性接收)。因此,enteredEmail 状态的更改将导致 Error 组件再次执行,尽管该组件不需要那个值。

你可以通过向该组件函数添加 console.log() 语句来跟踪不必要的 Error 组件函数调用:

function Error({ message }) {
  console.log('<Error /> component function is executed.');
  if (!message) {
    return null;
  }
  return <p className={classes.error}>{message}</p>;
} 

计算机屏幕截图  描述自动生成

图 10.5:Error 组件函数在输入字段上的每次按键都会执行

在前面的屏幕截图中,你可以看到 Error 组件函数在输入字段上的每次按键都会执行(即,每次 enteredEmail 状态改变时执行一次)。

这与之前你所学的相符,但这也是不必要的。Error组件确实依赖于errorMessage状态,并且每当该状态发生变化时,都应该重新评估该组件,但显然不需要因为enteredEmail状态值更新而执行Error组件函数。

正因如此,React 提供了一个内置的函数,你可以用它来控制(并防止)这种行为:memo()函数。

memo是从react导入的,并像这样使用:

**import** **{ memo }** **from****'react'****;**
import classes from './Error.module.css';
function Error({ message }) {
  console.log('<Error /> component function is executed.');
  if (!message) {
    return null;
  }
  return <p className={classes.error}>{message}</p>;
}
export default **memo****(****Error****);** 

你用memo()包裹应该避免不必要的、由父组件触发的重新评估的组件函数。这会导致 React 检查组件的 props 是否与上一次调用组件函数时有所不同。如果 prop 值相等,React 知道组件函数不需要再次执行。

通过添加memo(),可以避免不必要的组件函数调用,如下所示:

计算机截图  自动生成的描述

图 10.6:控制台没有出现日志消息

如图中所示,没有消息打印到控制台。这证明了避免了不必要的组件执行(记住:在添加memo()之前,许多消息都打印到了控制台)。

memo()还接受一个可选的第二个参数,可以用来添加自己的逻辑以确定 prop 值是否已更改。如果你处理的是更复杂的 prop 值(例如,对象或数组),并且可能需要自定义比较逻辑,这可能会很有用,如下面的示例所示:

memo(SomeComponent, function(prevProps, nextProps) {
  return prevProps.user.firstName !== nextProps.user.firstName;
}); 

传递给memo()的(可选)第二个参数必须是一个函数,该函数自动接收前一个 props 对象和下一个 props 对象。然后,该函数必须返回true,如果组件(例如,本例中的SomeComponent)应该重新评估,如果不应重新评估则返回false

通常,第二个参数是不需要的,因为memo()的默认行为(比较所有 props 的不等性)正是你所需要的。但如果需要更多的定制或控制,memo()允许你添加自己的逻辑。

在你的工具箱中有memo()后,你会倾向于用memo()包裹每一个 React 组件函数。为什么不这样做呢?毕竟,它避免了不必要的组件函数执行。

你当然可以在所有组件上使用它——但这并不一定有帮助,因为使用memo()避免不必要的组件重新评估是有代价的:比较 props(旧值与新值)也需要运行一些代码。它不是“免费的”。但这并不是一个巨大的成本。在许多(或所有)组件上使用memo()可能不会显著减慢你的应用程序。但如果你有需要大量重新评估的组件,这仍然是多余的。对于接收大量变化的 props 的组件使用memo()没有任何实际作用。

因此,如果你有相对简单的属性(即没有需要手动与自定义比较函数比较的深度嵌套对象的属性)并且大多数父组件的状态变化不会影响这些子组件的属性,那么 memo() 就非常有意义。即使在那些情况下,如果你有一个相对简单的组件函数(即没有复杂逻辑的函数),使用 memo() 仍然可能不会带来任何可衡量的好处。

上面的示例代码(Error 组件)是一个很好的例子:从理论上讲,在这里使用 memo() 是有意义的。父组件中的大多数状态变化不会影响 Error,而且属性比较将会非常简单,因为它只涉及一个属性(message 属性,它包含一个字符串)需要比较。但尽管如此,使用 memo() 包装 Error 很可能并不值得。Error 是一个非常基础的组件,其中没有任何复杂的逻辑。如果组件函数频繁调用,这根本无关紧要。因此,在这个位置使用 memo() 完全是可以接受的——同样,不使用它也是可以的。

另一个非常适合使用 memo() 的地方是位于组件树顶部(或组件树中深度嵌套的组件分支)的组件。如果你能够通过 memo() 避免执行该组件的不必要调用,那么你也会隐式地避免执行该组件下所有嵌套组件的不必要调用。这一点在下图中得到了说明:

img

图 10.7:在组件树分支的起始处使用 memo()

在前面的图中,memo() 被用于 Shop 组件,它有多个嵌套的子组件。没有 memo() 的情况下,每当 Shop 组件函数被调用时,ProductsProdItemCart 等也会被执行。有了 memo(),假设它能够避免一些 Shop 组件函数的不必要调用,所有这些子组件就不再需要评估。

避免昂贵的计算

memo() 函数可以帮助避免不必要的组件函数执行。正如前文所述,如果组件函数执行了大量工作(例如,对长列表进行排序),这一点尤其有价值。

但作为一个 React 开发者,你也会遇到一些情况,其中你有一个需要因为某些属性值变化而再次执行的工作密集型组件。在这种情况下,使用 memo() 无法阻止组件函数再次执行。然而,变化的属性可能并不需要用于组件中作为性能密集型任务执行的部分。

考虑以下示例:

function sortItems(items) {
  console.log('Sorting');
  return items.sort(function (a, b) {
    if (a.id > b.id) {
      return 1;
    } else if (a.id < b.id) {
      return -1;
    }
    return 0;
  });
}
function List({ items, maxNumber }) {
  const sortedItems = sortItems(items);
  const listItems = sortedItems.slice(0, maxNumber);
  return (
    <ul>
      {listItems.map((item) => (
        <li key={item.id}>
          {item.title} (ID: {item.id})
        </li>
      ))}
    </ul>
  );
}
export default List; 

List组件接收两个 prop 值:itemsmaxNumber。然后它调用sortItems()id对项目进行排序。之后,排序后的列表限制为一定数量的项目(maxNumber)。最后一步,通过 JSX 代码中的map()将排序和缩短后的列表渲染到屏幕上。

注意

一个完整的示例应用程序可以在 GitHub 上找到,地址为github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/examples/04-usememo

根据传递给List组件的项目数量,排序可能需要相当长的时间(对于非常长的列表,甚至可能长达几秒钟)。这绝对不是你希望不必要或过于频繁执行的操作。每当items发生变化时,都需要对列表进行排序,但如果maxNumber发生变化,则不应进行排序——因为这不影响列表中的项目(即,不影响顺序)。但是,根据上面共享的代码片段,sortItems()将在两个 prop 值中的任何一个发生变化时执行,无论它是items还是maxNumber

因此,当运行应用程序并更改显示的项目数量时,你可以看到多个"Sorting"日志消息——这意味着每次更改项目数量时都会执行sortItems()

计算机屏幕截图  自动生成描述

图 10.8:控制台中出现多个“Sorting”日志消息

memo()函数在这里无济于事,因为List组件函数应该在(并且将会)itemsmaxNumber发生变化时执行。memo()不能帮助控制组件函数内部的局部代码执行。

为了实现这一点,你可以使用 React 提供的另一个功能:useMemo() Hook。

useMemo()可以用来包装计算密集型操作。为了正确工作,你还必须定义一个列表,其中包含应导致操作再次执行依赖项。在某种程度上,它与useEffect()(它也包装操作并定义依赖项列表)类似,但关键区别在于useMemo()与组件函数中的其余代码同时运行,而useEffect()在组件函数执行完成后执行包装逻辑。不应使用useEffect()来优化计算密集型任务,而应用于副作用。

另一方面,useMemo()存在是为了控制性能密集型任务的执行。应用于上述示例,代码可以调整如下:

import { useMemo } from 'react';
function List({ items, maxNumber }) {
  const sortedItems = useMemo(
    function() {
      console.log('Sorting');
      return items.sort(function (a, b) {
        if (a.id > b.id) {
          return 1;
        } else if (a.id < b.id) {
          return -1;
        }
        return 0;
      });
    },
    [items]
  );
  const listItems = sortedItems.slice(0, maxNumber);
  return (
    <ul>
      {listItems.map((item) => (
        <li key={item.id}>
          {item.title} (ID: {item.id})
        </li>
      ))}
    </ul>
  );
}
export default List; 

useMemo() 包装了一个匿名函数(之前作为命名函数 sortItems 存在的函数),其中包含整个排序代码。传递给 useMemo() 的第二个参数是函数应再次执行的依赖项数组(当依赖项值发生变化时)。在这种情况下,items 是包装函数的唯一依赖项,因此该值被添加到数组中。

使用 useMemo() 如此,排序逻辑仅在项目发生变化时执行,而不是在 maxNumber(或任何其他内容)发生变化时执行。因此,你会在开发者工具控制台中只看到一次输出“Sorting”:

计算机屏幕截图  自动生成描述

图 10.9:控制台只有一个“排序”输出

useMemo() 在控制组件函数内部的代码执行方面非常有用。它可以作为 memo()(控制整体组件函数执行)的一个很好的补充。但是,就像 memo() 一样,你不应该开始用 useMemo() 包装所有的逻辑。仅在使用非常性能密集的计算时使用它,因为检查依赖项变化以及存储和检索过去计算结果(useMemo() 内部执行的操作)也会带来性能成本。

利用 useCallback()

在前面的章节中,你学习了关于 useCallback() 的内容。就像 useMemo() 可以用于“昂贵”的计算一样,useCallback() 可以用来防止不必要的函数重新创建。在本章的上下文中,useCallback() 可能很有帮助,因为与 memo()useMemo() 结合使用时,它可以帮助你避免不必要的代码执行。它可以帮助你处理函数作为属性传递的情况(即你可能使用 memo() 的情况)或作为某些“昂贵”计算中的依赖项(即可能通过 useMemo() 解决)。

这里有一个例子,说明 useCallback() 可以与 memo() 结合使用,以防止不必要的组件函数执行:

import { memo } from 'react';
import classes from './Error.module.css';
function Error({ message, onClearError }) {
  console.log('<Error /> component function is executed.');
  if (!message) {
    return null;
  }
  return (
    <div className={classes.error}>
      <p>{message}</p>
      <button className={classes.errorBtn} onClick={onClearError}>X</button>
    </div>
  );
}
export default memo(Error); 

Error 组件被 memo() 函数包装,因此只有在接收到的属性值之一发生变化时才会执行。

Error 组件被另一个组件,即 Form 组件,这样使用:

function Form() {
  const [enteredEmail, setEnteredEmail] = useState('');
  const [errorMessage, setErrorMessage] = useState();
  function handleUpdateEmail(event) {
    setEnteredEmail(event.target.value);
  }
  function handleSubmit(event) {
    event.preventDefault();
    if (!enteredEmail.endsWith('.com')) {
      setErrorMessage('Email must end with .com.');
    }
  }
  function handleClearError() {
    setErrorMessage(null);
  }
  return (
    <form className={classes.form} onSubmit={handleSubmit}>
      <div className={classes.control}>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={enteredEmail}
          onChange={handleUpdateEmail}
        />
      </div>
      <Error message={errorMessage} onClearError={handleClearError} />
      <button>Sign Up</button>
    </form>
  );
} 

在这个组件中,Error 组件接收对 handleClearError 函数的指针(作为 onClearError 属性的值)。你可能还记得本章早期(在 避免不必要的子组件评估 部分中)的一个非常类似的例子。在那里,memo() 被用来确保当 enteredEmail 发生变化时,Error 组件函数不会被调用(因为它的值在 Error 组件函数中根本未使用)。

现在,随着调整后的示例和将 handleClearError 函数指针传递给 Errormemo() 很遗憾不再阻止组件函数的执行了。为什么?因为在 JavaScript 中,函数是对象,而 handleClearError 函数在每次 Form 组件函数执行时都会被重新创建(这发生在每次状态变化时,包括 enteredEmail 状态的变化)。

由于每次状态变化都会创建一个新的函数对象,因此 handleClearError 在技术上对于 Form 组件的每次执行都是一个不同的值。因此,每当 Form 组件函数被调用时,Error 组件都会接收到一个新的 onClearError 属性值。对于 memo() 来说,旧的和新旧的 handleClearError 函数对象是不同的,因此它不会阻止 Error 组件函数再次运行。

这正是 useCallback() 可以帮助的地方:

const handleClearError = useCallback(() => {
  setErrorMessage(null);
}, []); 

通过使用 useCallback() 包装 handleClearError,可以防止函数的重新创建,因此不会将新的函数对象传递给 Error 组件。因此,memo() 能够检测旧的和新的 onClearError 属性值之间的相等性,并再次防止不必要的函数组件执行。

同样,useCallback() 可以与 useMemo() 结合使用。如果 useMemo() 包装的计算密集型操作使用函数作为依赖项,你可以使用 useCallback() 来确保这个依赖函数不会被不必要地重新创建。

使用 React 编译器

考虑和使用 memo()useMemo()useCallback() 来防止不必要的组件重新评估可能是一项繁琐的工作。尽管性能优化很重要,但作为一名 React 开发者,你通常希望专注于构建出色的 UI 并在其中实现有用的功能。

正是因此,React 团队开发了一个旨在为你优化代码的编译器——一个可以添加到 React 项目中的独立工具,该工具将自动使用 memo() 包装你的组件,在需要时使用 useMemo(),并使用 useCallback() 包装函数。

因此,当使用此编译器时,你不必再考虑或使用这些优化函数和 Hook 了。

换句话说,React 编译器会为你优化代码。至少,这是理论上的。

然而,在撰写本文时,此编译器仅以实验模式提供。这意味着你不应该将其用于生产,并且可能存在错误或次优编译结果。

尽管如此,你可以在使用 React 19 或更高版本的项目上尝试它(编译器不适用于旧版本的 React)。

将编译器添加到项目中很容易,因为它只是一个额外的依赖项,必须在你的项目中安装:

npm install babel-plugin-react-compiler 

注意

由于编译器尚未稳定,安装步骤和使用说明可能会随时间而变化。

因此,你应该访问官方 React 编译器文档页面以获取最新细节和说明:react.dev/learn/react-compiler

安装了编译器插件后,你必须调整你的构建过程配置,以便使用编译器。当在一个基于 Vite 的项目上工作时,你只需编辑vite.config.js文件,该文件应位于你的根项目文件夹中:

// vite.config.js
const ReactCompilerConfig = { /* ... */ };
export default defineConfig(() => {
  return {
    plugins: [
      react({
        babel: {
          plugins: [
            ["babel-plugin-react-compiler", ReactCompilerConfig],
          ],
        },
      }),
    ],
    // ...
  };
}); 

如果你使用的是其他项目设置,可以遵循官方编译器文档页面上的安装说明。

安装了编译器后,它将自动执行以分析和调整你的代码,包括memo()useMemo()等优化。请记住,这些优化是在运行npm run devnpm run build触发的构建过程中执行的。因此,你的原始源代码不会改变——相反,编译器会在幕后优化你的代码。

一旦 React 编译器稳定,它很可能成为每个 React 项目构建过程的一部分的标准工具。因此,你将不再需要在代码中手动使用memo()useMemo()useCallback()。但在那之前,或者在无法使用编译器的 React 项目中,你仍然需要手动优化代码。

避免不必要的代码下载

到目前为止,这一章主要讨论了避免不必要的代码执行的战略。但问题不仅在于代码的执行。如果你的网站访客需要下载大量可能根本不会执行的代码,那也不是什么好事。因为每下载一千字节 JavaScript 代码都会减慢你网页的初始加载时间——这不仅是因为下载代码包所需的时间(如果用户在慢速网络且代码包很大,这可能会非常显著),而且因为浏览器必须在你的页面变得交互之前解析所有下载的代码。

因此,社区和生态系统投入了大量努力来减少 JavaScript 代码包的大小。最小化(自动缩短变量名和其他减少最终代码的措施)和压缩可以极大地帮助,因此这是一种常见的技巧。实际上,使用 Vite 创建的项目已经自带了构建工作流程(通过运行npm run build启动),这将生成尽可能小的生产优化代码包。

但你也可以采取一些步骤来减少整体代码包的大小:

  1. 尽量编写简短和简洁的代码。

  2. 在包含第三方库时要深思熟虑,除非你真的需要,否则不要使用它们。

  3. 考虑使用代码拆分技术。

第一点应该是相当明显的。如果你写的代码越少,你的网站访客需要下载的代码也就越少。因此,尽量简洁并编写优化后的代码是有意义的。

第二点也应该是有意义的。对于某些任务,你实际上可以通过包含可能比你自己编写的代码更复杂的第三方库来节省代码。但也有一些情况和任务,你可能可以通过编写自己的代码或使用一些内置函数来避免包含第三方库。你至少应该始终考虑这种替代方案,并且只包含你绝对需要的第三方库。

最后一点是 React 可以帮助解决的问题。

通过代码拆分(懒加载)减少包大小

React 提供了一个 lazy() 函数,可以用来有条件地加载组件代码——这意味着只有在实际需要时(而不是一开始)才会加载。

考虑以下由两个组件协同工作的例子。

DateCalculator 组件的定义如下:

import { useState } from 'react';
import { add, differenceInDays, format, parseISO } from 'date-fns';
import classes from './DateCalculator.module.css';
const initialStartDate = new Date();
const initialEndDate = add(initialStartDate, { days: 1 });
function DateCalculator() {
  const [startDate, setStartDate] = useState(
    format(initialStartDate, 'yyyy-MM-dd')
  );
  const [endDate, setEndDate] = useState(
    format(initialEndDate, 'yyyy-MM-dd')
  );
  const daysDiff = differenceInDays(
    parseISO(endDate), 
    parseISO(startDate)
  );
  function handleUpdateStartDate(event) {
    setStartDate(event.target.value);
  }
  function handleUpdateEndDate(event) {
    setEndDate(event.target.value);
  }
  return (
    <div className={classes.calculator}>
      <p>Calculate the difference (in days) between two dates.</p>
      <div className={classes.control}>
        <label htmlFor="start">Start Date</label>
        <input
          id="start"
          type="date"
          value={startDate}
          onChange={handleUpdateStartDate}
        />
      </div>
      <div className={classes.control}>
        <label htmlFor="end">End Date</label>
        <input
          id="end"
          type="date"
          value={endDate}
          onChange={handleUpdateEndDate}
        />
      </div>
      <p className={classes.difference}>
        Difference: {daysDiff} days
      </p>
    </div>
  );
}
export default DateCalculator; 

然后,DateCalculator 组件由 App 组件有条件地渲染:

import { useState } from 'react';
import DateCalculator from './components/DateCalculator.jsx';
function App() {
  const [showDateCalc, setShowDateCalc] = useState(false);
  function handleOpenDateCalc() {
    setShowDateCalc(true);
  }
  return (
    <>
      <p>This app might be doing all kinds of things.</p>
      <p>
        But you can also open a calculator which calculates 
        the difference between two dates.
      </p>
      <button onClick={handleOpenDateCalc}>Open Calculator</button>
      **{showDateCalc &&** **<****DateCalculator** **/>****}**
    </>
  );
}
export default App; 

在这个例子中,DateCalculator 组件使用第三方库(date-fns 库)来访问各种日期相关实用函数(例如,计算两个日期之间差异的函数,或 differenceInDays)。

该组件接受两个日期值,并计算这两个日期之间的天数差异——尽管组件的实际逻辑在这里并不重要。重要的是,使用了第三方库和各种实用函数。这给整体代码包增加了相当多的 JavaScript 代码,并且所有这些代码都必须在第一次加载整个网站时下载,即使那时日期计算器甚至都不可见(因为它是有条件渲染的)。

在构建用于生产的应用程序(通过 npm run build)后,当预览该生产版本(通过 npm run preview)时,你可以在以下屏幕截图中看到下载了一个主要的代码包文件:

img

图 10.10:下载了一个主要的包文件

浏览器开发者工具中的“网络”选项卡揭示了发出的网络请求。正如你在屏幕截图中看到的,下载了一个主要的 JavaScript 包文件。当点击按钮时,你不会看到任何额外的请求被发送。这表明所有代码,包括 DateCalculator 所需的代码,都是一开始就下载的。

这就是 React 的 lazy() 函数进行代码拆分变得有用的地方。

这个函数可以围绕动态导入包装,以便仅在需要时加载导入的组件。

注意

动态导入是原生 JavaScript 功能,允许动态导入 JavaScript 代码文件。有关此主题的更多信息,请访问 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import

在前面的例子中,它将在 App 组件文件中这样使用:

import { lazy, useState } from 'react';
**const****DateCalculator** **=** **lazy****(****() =>****import****(**
**'./components/DateCalculator.jsx'**
 **)**
**);**
function App() {
  const [showDateCalc, setShowDateCalc] = useState(false);
  function handleOpenDateCalc() {
    setShowDateCalc(true);
  }
  return (
    <>
      <p>This app might be doing all kinds of things.</p>
      <p>
        But you can also open a calculator which calculates 
        the difference between two dates.
      </p>
      <button onClick={handleOpenDateCalc}>Open Calculator</button>
      {showDateCalc && <DateCalculator />}
    </>
  );
}
export default App; 

仅此还不够。你还必须将条件 JSX 代码(其中使用了动态导入的组件)包装在 React 提供的另一个组件 <Suspense> 中,如下所示:

import { lazy, **Suspense**, useState } from 'react';
const DateCalculator = lazy(() => import(
    './components/DateCalculator.jsx'
  )
);
function App() {
  const [showDateCalc, setShowDateCalc] = useState(false);
  function handleOpenDateCalc() {
    setShowDateCalc(true);
  }
  return (
    <>
      <p>This app might be doing all kinds of things.</p>
      <p>
        But you can also open a calculator which calculates 
        the difference between two dates.
      </p>
      <button onClick={handleOpenDateCalc}>Open Calculator</button>
      **<****Suspense****fallback****=****{****<****p****>****Loading...****</****p****>****}>**
        {showDateCalc && <DateCalculator />}
      **</****Suspense****>**
    </>
  );
}
export default App; 

注意

你可以在 GitHub 上找到完成的示例代码:github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/examples/06-code-splitting

Suspense 是 React 内置的一个组件,旨在在加载某些资源或数据时显示回退内容。因此,当用于懒加载时,你必须将其包装在任何使用 React 的 lazy() 函数的条件的代码周围。Suspense 还有一个必须提供的强制属性,即 fallback 属性,它期望一个 JSX 值,该值将在动态加载的内容可用之前作为回退内容渲染。

lazy() 导致整体 JavaScript 代码被拆分为多个包。包含 DateCalculator 组件(及其依赖项,如 date-fns 库代码)的包只有在需要时才会下载——也就是说,当在 App 组件中点击按钮时。如果下载需要更长的时间,那么在 Suspense 的同时,屏幕上会显示回退内容。

注意

React 的 Suspense 组件不仅限于与 lazy() 函数一起使用。第十四章,使用 React Router 管理数据,和第十七章,理解 React Suspense 与 use() 钩子,将探讨如何使用 Suspense 组件在加载数据时显示回退内容。

添加 lazy()Suspense 组件后,最初下载的包会更小。此外,如果点击按钮,还会下载更多的代码文件:

img

图 10.11:点击按钮后,会下载额外的代码文件

就像迄今为止描述的所有其他优化技术一样,lazy() 函数并不是你应该开始围绕所有导入进行包装的函数。如果一个导入的组件非常小且简单(并且不使用任何第三方代码),拆分代码实际上并不值得,尤其是考虑到下载额外包所需的额外 HTTP 请求也带来了一些开销。

在那些一开始就会加载的组件上使用 lazy() 也没有意义。只有考虑在条件加载的组件上使用它。

Strict Mode

在本章中,你学习了关于 React 内部结构和各种优化技术的很多内容。虽然这不是一种优化技术,但仍然相关,React 还提供了一个名为 Strict Mode 的功能。

你可能之前遇到过这样的代码:

import React from 'react';
// ... other code ...
root.render(<React.StrictMode><App /></React.StrictMode >); 

<React.StrictMode> 是 React 提供的另一个内置组件。它不会渲染视觉元素,但它将启用一些额外的检查,这些检查由 React 在幕后执行。

大多数检查都与识别不安全或过时代码(即未来将被移除的功能)的使用相关。但也有一些检查旨在帮助你识别代码中可能存在的问题。

例如,当使用严格模式时,React 将执行组件函数两次,并在组件首次挂载时卸载和重新挂载每个组件。这样做是为了确保你以一致和正确的方式管理你的状态和副作用(例如,确保你的副作用函数中有清理函数)。

注意

严格模式(Strict Mode)仅影响你的应用程序及其在开发过程中的行为。一旦你为生产环境构建了应用程序,它就不会再影响你的应用程序。在生产环境中,不会执行额外的检查,例如双重组件函数执行。

启用严格模式构建 React 应用程序有时可能会导致混淆或令人烦恼的错误消息。例如,你可能会想知道为什么你的组件副作用执行得太频繁。

因此,是否使用严格模式是你个人的决定。启用它可以帮助你及早捕获和修复错误。

调试代码和 React 开发者工具

在本章的早期,你了解到组件函数可能会非常频繁地执行,并且你可以使用 memo()useMemo()(以及你不应该总是阻止它们)来防止不必要的执行。

通过在组件函数内部添加 console.log() 来识别组件执行是获取组件洞察的一种方法。这是本章使用的方法。然而,对于拥有数十、数百甚至数千个组件的大型 React 应用程序,使用 console.log() 可能会变得繁琐。

正因如此,React 团队也构建了一个官方工具来帮助获取应用程序洞察。React 开发者工具是一个可以安装在所有主要浏览器(Chrome、Firefox 和 Edge)上的扩展程序。你可以通过在网络上搜索 "<你的浏览器> react 开发者工具"(例如,chrome react 开发者工具)来查找并安装该扩展程序。

安装扩展程序后,你可以直接从浏览器内部访问它。例如,当使用 Chrome 时,你可以直接从 Chrome 的开发者工具(可以通过 Chrome 的菜单打开)中访问 React 开发者工具扩展程序。探索特定扩展程序的文档(在你的浏览器扩展商店中)以获取有关如何访问它的详细信息。

React 开发者工具扩展提供了两个区域:一个 Components 页面和一个 Profile 页面:

计算机屏幕截图  描述自动生成

图 10.12:React 开发者工具可以通过浏览器开发者工具访问

Components页面可以用来分析当前渲染页面的组件结构。你可以使用这个页面来了解你的组件结构(即“组件树”),组件是如何嵌套在一起的,甚至组件的配置(属性、状态)。

计算机屏幕截图  自动生成的描述

图 10.13:组件关系和数据展示

当尝试理解组件的当前状态、组件与其他组件的关系以及哪些其他组件可能因此影响组件(例如,导致它重新评估)时,这个页面非常有用。

然而,在本章的上下文中,更有用的页面是Profiler页面:

计算机屏幕截图  自动生成的描述

图 10.14:分析器页面(没有收集任何数据)

在这个页面上,你可以开始记录组件评估(即组件函数执行)。你可以通过简单地点击左上角的Record按钮(蓝色圆圈)来完成此操作。然后,此按钮将被Stop按钮替换,你可以点击它来结束记录。

在记录 React 应用几秒钟(并在该期间与之交互)后,一个示例结果可能看起来像这样:

计算机屏幕截图  自动生成的描述

图 10.15:记录完成后,分析器页面显示了各种条形图

这个结果由两个主要区域组成:

  • 一系列条形图,表示组件重新评估的次数(每个条形图反映一个影响了零个或多个组件的重新评估周期)。你可以点击这些条形图来探索特定周期更详细的信息。

  • 对于所选的评估周期,会显示受影响组件的列表。你可以很容易地识别受影响的组件,因为它们的条形图被着色,并且会显示它们的计时信息。

你可以从1(在这种情况下,这个记录会话有两个)选择任何渲染周期来查看哪些组件受到了影响。窗口的底部部分(2)通过突出显示它们并用某种颜色标记,显示了所有受影响的组件,并输出了组件重新评估所花费的总时间(例如,0.1ms of 0.3ms)。

注意

值得注意的是,这个工具还证明组件评估非常快——重新评估一个组件的0.1ms对于任何人类来说都太快,以至于无法意识到幕后发生了什么。

在窗口的右侧,你还可以了解更多关于这个组件评估周期的信息。例如,你可以了解它是如何被触发的。在这种情况下,它是由Form组件触发的(这与本章前面在避免不必要的子组件评估部分讨论的例子相同)。

因此,Profiler页面也可以帮助你识别组件评估周期并确定哪些组件受到影响。在这个例子中,如果你将memo()函数包裹在Error组件周围,你可以看到差异:

计算机屏幕截图 自动生成的描述

图 10.16:只有表单组件受到影响,而不是错误组件

在将memo()函数作为包装器重新添加到Error组件(如本章前面所述)之后,你可以使用 React 开发者工具的Profiler页面来确认Error组件不再被不必要地评估。为此,你应该开始一个新的录制会话并重现之前没有memo()Error组件会被再次调用的情景。

Profiler窗口中,Error组件上对角线的灰色线条表示该组件未受到其他组件函数调用的任何影响。

因此,可以使用 React 开发者工具来深入了解你的 React 应用和组件。你可以在组件函数中调用console.log()的同时使用它们,或者完全替代调用console.log()

摘要和关键要点

  • 当 React 组件的状态发生变化或父组件被评估时,它们会被重新评估(执行)。

  • React 通过首先使用虚拟 DOM 来计算所需的 UI 更改,从而优化组件评估。

  • 同时在同一位置发生的多个状态更新会被 React 批处理在一起。这确保了避免不必要的组件评估。

  • memo()函数可以用来控制组件函数的执行。

  • memo()函数会查找属性值的变化(旧属性与新属性之间的差异),以确定组件函数是否需要再次执行。

  • useMemo()可以用来包装性能密集型计算,并且只有在关键依赖项发生变化时才执行这些计算。

  • 由于memo()useMemo()也会带来成本(比较操作),因此应谨慎使用它们。

  • 当使用 React 19 或更高版本时,你可以安装并启用(实验性的)React 编译器,以在构建过程中自动优化你的代码。

  • 可以通过lazy()函数(与内置的Suspense组件结合使用)的帮助,通过代码拆分来减少初始代码下载的大小。

  • 可以通过内置的<React.StrictMode>组件启用 React 的严格模式,以执行各种额外检查并检测应用程序中的潜在错误。

  • 可以使用 React 开发者工具来深入了解你的 React 应用(例如,组件结构和重新评估周期)。

接下来是什么?

作为一名开发者,你应该始终了解并理解你所使用的工具——在本例中是 React。

本章使您更好地了解了 React 在底层的工作原理以及自动实现的优化。此外,您还了解了您可以实施的多种优化技术。

下一章将回到解决您在尝试构建 React 应用程序时可能遇到的实际问题。您将学习更多关于可以用于解决与组件和应用状态管理相关的更复杂问题的技术和功能,而不是优化 React 应用程序。

测试您的知识!

通过回答以下问题来测试您对本章涵盖的概念的了解。然后,您可以比较您的答案与可在github.com/mschwarzmueller/book-react-key-concepts-e2/blob/10-behind-scenes/exercises/questions-answers.md找到的示例:

  1. 为什么 React 使用虚拟 DOM 来检测所需的 DOM 更新?

  2. 当组件函数执行时,真实 DOM 会受到什么影响?

  3. 哪些组件是memo()函数的优秀候选者?哪些组件是不合适的候选者?

  4. useMemo()memo()有何不同?

  5. 代码拆分和lazy()函数背后的理念是什么?

应用您所学到的知识

在您对 React 内部结构和可以用于改进您的应用程序的一些优化技术有了新的了解之后,您现在可以在以下活动中应用这些知识。

活动十.1:优化现有应用程序

在这个活动中,您将获得一个可以优化多个位置的现有 React 应用程序。您的任务是识别优化机会并实施适当的解决方案。请记住,过多的优化实际上可能导致结果更差。

注意

您可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/activities/practice-1-start找到这个活动的起始代码。在下载此代码时,您将始终下载整个仓库。请确保然后导航到包含起始代码的子文件夹(在本例中为activities/practice-1-start),以使用正确的代码快照。

提供的项目还使用了之前章节中介绍的一些许多功能。花时间分析它并理解提供的代码。这是一个很好的实践,让您看到许多关键概念的实际应用。

下载代码并在项目文件夹中运行npm install(安装所有必需的依赖项)后,您可以通过npm run dev启动开发服务器。结果,当访问localhost:5173时,您应该看到以下 UI:

img

图 10.17:运行中的起始项目

仔细熟悉提供的项目。在 UI 中尝试不同的按钮,在表单输入字段中填写一些示例数据,并分析提供的代码。请注意,此示例项目不会向任何服务器发送任何 HTTP 请求。所有输入的数据一旦输入即被丢弃。

要完成此活动,解决方案步骤如下:

  1. 通过寻找不必要的组件函数执行来寻找优化机会。

  2. 还应识别组件函数内部不必要的代码执行(其中无法阻止整个组件函数的调用)。

  3. 确定哪些代码可以懒加载而不是立即加载。

  4. 使用memo()函数、useMemo() Hook 和 React 的lazy()函数来改进代码。

如果你能在浏览器开发者工具的网络标签页中看到点击重置密码创建新账户按钮时额外的代码获取网络请求,那么你可以知道你提出了一个好的解决方案和合理的调整:

计算机截图  自动生成的描述

图 10.18:在最终解决方案中,一些代码是懒加载的

此外,当在注册表单(即点击创建新账户时切换到的表单)的电子邮件输入字段(电子邮件确认电子邮件)中输入时,不应看到任何Validated password.控制台消息:

计算机截图  自动生成的描述

图 10.19:控制台没有“验证密码。”输出

点击更多信息按钮时,也不应该有任何控制台输出:

计算机截图  自动生成的描述

图 10.20:点击“更多信息”时没有控制台消息

注意

所有用于此活动的代码文件和解决方案都可以在github.com/mschwarzmueller/book-react-key-concepts-e2/tree/10-behind-scenes/activities/practice-1找到。