React-启动指南第二版-全-

React 启动指南第二版(全)

原文:zh.annas-archive.org/md5/bcc84655d780295ae120bf40cbbda7f0

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

又是一个美好的加利福尼亚温暖的夜晚。微弱的海风让你感到百分之百的“啊啊!”地方:洛杉矶;年份:2000 多年。我正准备将我的新网页应用程序 CSSsprites.com 通过 FTP 上传到服务器并发布到世界上。在最后几个晚上我花在应用程序上的时间里,我考虑了一个问题:为什么要花费 20% 的精力来完成应用程序的“核心”,然后花 80% 的精力与用户界面搏斗呢?如果我不需要一直使用 getElementById() 并担心应用程序的状态会有多好?(用户完成上传了吗?什么,出错了?这个对话框还在吗?)为什么 UI 开发如此耗时?各种浏览器又是怎么回事?慢慢地,“啊啊”变成了“啊啊哦!”

快进到 2015 年 3 月,在 Facebook 的 F8 大会上。我所在的团队准备宣布两个 Web 应用程序的完全重写:我们的第三方评论服务以及相应的管理工具。与我自己的 CSSsprites.com 应用程序相比,这些都是功能齐全的 Web 应用程序,具有更多的功能,更强大的能力和大量的流量。然而,开发过程非常愉快。团队中的新成员(甚至有些人对 JavaScript 和 CSS 都是新手)能够快速而轻松地加入,贡献自己的一点一滴,迅速提升速度。正如团队中的一名成员所说:“啊哈,现在我明白了为什么大家都这么喜欢它!”

路上发生了什么?React。

React 是用于构建用户界面的库——它帮助你一次性定义界面。然后,当应用程序的状态发生变化时,界面将重新构建以反应这些变化,而你无需做任何额外的工作。毕竟,你已经定义了界面。定义?更像是声明。你使用小巧而易管理的组件来构建一个功能强大的大型应用程序。不再需要在函数体中花费大量时间寻找 DOM 节点;你所需做的只是维护应用程序的状态(使用一个普通的 JavaScript 对象),剩下的事情就跟着进行了。

学习 React 是一笔划算的买卖——你学习一个库,然后用它创建以下所有内容:

  • Web 应用程序

  • 本地 iOS 和 Android 应用程序

  • 电视应用程序

  • 本地桌面应用程序

你可以使用构建组件和用户界面的相同思想来创建具有本地性能和本地控件(真正的本地控件,而不是看起来像本地的复制品)的本地应用程序。这不是“一次编写,到处运行”(我们行业一直在失败),而是“学一次,到处使用”。

简而言之:学习 React,节省 80% 的时间,并专注于真正重要的事情(比如你的应用程序存在的真正原因)。

关于本书

本书侧重于从 Web 开发角度学习 React。在前三章中,你从一个空白的 HTML 文件开始,然后逐步构建起来。这样可以让你专注于学习 React,而不是学习新的语法或辅助工具。

第五章更多关注 JSX,这是一种通常与 React 一起使用的单独可选技术。

从这里,您将了解开发真实应用程序所需的内容,以及可以帮助您完成这一过程的附加工具。该书使用create-react-app快速起步,并将辅助技术的讨论保持在最低限度。其目标是专注于 React。

一个有争议的决定是除了函数组件外,还包括组件。函数组件很可能是未来的趋势;然而,读者可能会遇到仅讨论类组件的现有代码和教程。了解两种语法方式可以提高读懂和理解现有代码的机会。

祝您在学习 React 的旅程中好运,愿其顺利而富有成效!

本书使用的约定

本书使用以下印刷约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序清单,以及在段落中用来指代程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应按字面意义键入的命令或其他文本。

小贴士

该元素表示提示或建议。

注意

该元素表示一般性注释。

使用代码示例

补充材料(例如代码示例、练习等)可在https://github.com/stoyan/reactbook2下载。

如果您在使用代码示例时遇到技术问题或困难,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。通常情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则您无需联系我们以获取许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书示例需要许可。通过引用本书并引用示例代码回答问题不需要许可。将本书的大量示例代码整合到您产品的文档中需要许可。

我们感谢您的使用,但不需要署名。署名通常包括标题、作者、出版商和 ISBN 号。例如:“React: Up & Running, 第 2 版,作者 Stoyan Stefanov(O’Reilly)。版权所有 2022 Stoyan Stefanov,978-1-492-05146-6。”

如果您认为您使用的代码示例超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。

O’Reilly 在线学习

注意

超过 40 年来,O’Reilly Media 提供技术和商业培训,知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问 https://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书制作了一个网页,列出了勘误、示例和任何额外信息。您可以访问这个页面:https://oreil.ly/reactUR_2e

发送电子邮件至 bookquestions@oreilly.com 对本书提出评论或技术问题。

获取关于我们的书籍和课程的新闻和信息,请访问 https://oreilly.com

在 Facebook 上找到我们:https://facebook.com/oreilly

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://www.youtube.com/oreillymedia

致谢

我想感谢所有阅读过本书不同草稿并发送反馈和更正意见的人。

对于第一版:Andreea Manole、Iliyan Peychev、Kostadin Ilov、Mark Duppenthaler、Stephan Alber 和 Asen Bozhilov。对于第二版:Adam Rackis、Maximiliano Firtman、Chetan Karande、Kiril Christov 和 Scott Satoshi Iwako。

感谢所有在 Facebook 上工作(或与之合作)并在日常工作中回答我的问题的人,以及不断推出优秀工具、库、文章和使用模式的扩展 React 社区。

非常感谢 Jordan Walke。

感谢所有使本书成为可能的 O’Reilly 的工作人员:Angela Rufino、Jennifer Pollock、Meg Foley、Kim Cofer、Justin Billing、Nicole Shelby、Kristen Brown 和许多其他人。

感谢 Javor Vatchkov 设计了本书中开发的示例应用的用户界面(您可以在 whinepad.com 上试用)。

第一章:Hello World

让我们开始使用 React 掌握应用程序开发之旅。在本章中,您将学习如何设置 React 并编写您的第一个“Hello World”Web 应用程序。

设置

首先要做的事情是获取 React 库的副本。有多种方法可以做到这一点。让我们选择最简单的方法,它不需要任何特殊工具,可以让您很快地学习和进行开发。

在一个您可以找到的位置为本书中的所有代码创建一个文件夹。

例如:

$ mkdir ~/reactbook

创建一个/react文件夹来保留 React 库代码的分离。

$ mkdir ~/reactbook/react

接下来,您需要添加两个文件:一个是 React 本身,另一个是 ReactDOM 插件。您可以从unpkg.com主机获取最新的 17.*版本,如下所示:

$ curl -L https://unpkg.com/react@17/umd/react.development.js > ~/reactbook/react/react.js
$ curl -L https://unpkg.com/react-dom@17/umd/react-dom.development.js > ~/reactbook/react/react-dom.js

请注意,React 不强制任何目录结构;您可以自由移动到不同的目录或根据需要重命名react.js

您不必下载这些库;您可以直接从unpkg.com使用它们。但是,将它们本地化使得可以在任何地方学习,而不需要互联网连接。

注意

在前面示例中显示的 URL 中的@17获取了当前写作本书时最新的 React 17 版本。省略@17以获取最新可用的 React 版本。或者,您可以显式指定所需的版本,例如@17.0.2

Hello React World

让我们从您工作目录中的一个简单页面开始(~/reactbook/01.01.hello.html):

<!DOCTYPE html>
<html>
  <head>
    <title>Hello React</title>
    <meta charset="utf-8">
  </head>
  <body>
    <div id="app">
      <!-- my app renders here -->
    </div>
    <script src="react/react.js"></script>
    <script src="react/react-dom.js"></script>
    <script>
      // my app's code
    </script>
  </body>
</html>
注意

您可以在本书的附带存储库中找到所有代码

此文件中仅发生了两件显著的事情:

  • 您包含了 React 库及其文档对象模型(DOM)插件(通过<script src>标签)

  • 您定义应用程序应放置在页面上的位置(<div id="app">

注意

您始终可以混合常规 HTML 内容以及其他 JavaScript 库与 React 应用程序。您还可以在同一页上拥有几个 React 应用程序。您所需要的只是 DOM 中可以指向 React 并说“在这里施展你的魔法”的地方。

现在让我们添加说“hello”的代码—更新 01.01.hello.html 并将 // my app's code 替换为:

ReactDOM.render(
  React.createElement('h1', null, 'Hello world!'),
  document.getElementById('app')
);

在浏览器中加载 01.01.hello.html,您将看到您的新应用程序正在运行(如图 1-1 所示)。

rur2 0101

图 1-1. “Hello world!”在运行中

恭喜您,您刚刚构建了您的第一个 React 应用程序!

图 1-1](#FIG0101)还显示了在 Chrome 开发工具中生成的代码,您可以在其中看到<div id="app">占位符的内容被您的 React 应用程序生成的内容替换。

刚刚发生了什么?

代码中有一些值得注意的地方,这些地方使您的第一个应用程序起作用。

首先,你看到了React对象的使用。所有可用的 API 都可以通过这个对象访问。API 故意保持最小化,因此记住的方法名称并不多。

你还可以看到ReactDOM对象。它只有少数几个方法,其中最有用的是render()ReactDOM负责在浏览器中呈现应用程序。实际上,你可以创建 React 应用并在不同的环境中呈现它们,例如在画布中,或者在 Android 或 iOS 本地应用中。

接下来,有组件的概念。你可以使用组件构建你的 UI,并以任何你认为合适的方式组合这些组件。在你的应用程序中,你将会创建自定义组件,但是为了让你入门,React 提供了围绕 HTML DOM 元素的包装器。你可以通过React.createElement函数使用这些包装器。在这个第一个示例中,你可以看到使用了h1元素。它对应于 HTML 中的<h1>,并且可以通过调用React.createElement('h1')来使用。

最后,你看到了老旧的document.getElementById('app') DOM 访问方式。你可以使用这个方法告诉 React 应用程序在页面上的位置。这就是从你知道的 DOM 操作到 React 领域的桥梁。

一旦你从 DOM 转向 React,你就不必再担心 DOM 操作,因为 React 会将组件转换为底层平台(浏览器 DOM、画布、本地应用)。事实上,不再担心 DOM 是 React 的伟大之处之一。你只需关心组件及其数据的组合——这是应用程序的核心部分——让 React 以最高效的方式更新 DOM。不再寻找 DOM 节点、firstChildappendChild()等。

注意

不必担心 DOM,但这并不意味着你不能。如果有必要,React 为你提供了“逃生通道”,让你可以返回到 DOM 领域。

现在你知道每行代码的作用,让我们来看看整体。发生的事情是:你在你选择的 DOM 位置中渲染了一个 React 组件。你总是渲染一个顶级组件,它可以有任意多的子组件(和孙子组件等)。即使在这个简单的例子中,h1组件也有一个子组件——“Hello world!”文本。

React.createElement()

正如你现在所知,你可以通过React.createElement()方法将多个 HTML 元素用作 React 组件。让我们仔细看看这个 API。

记住,“Hello world!”应用程序看起来像这样:

ReactDOM.render(
  React.createElement('h1', null, 'Hello world!'),
  document.getElementById('app')
);

createElement的第一个参数是要创建的元素类型。第二个参数(在本例中为null)是一个对象,用于指定要传递给元素的任何属性(类似于 DOM 属性)。例如,你可以这样做:

React.createElement(
  'h1',
  {
    id: 'my-heading',
  },
  'Hello world!'
),

此示例生成的 HTML 显示在图 1-2 中。

rur2 0102

图 1-2. 由React.createElement()调用生成的 HTML

第三个参数(在这个例子中是 "Hello world!")定义了组件的子元素。最简单的情况就是一个文本子元素(在 DOM 中叫做 Text 节点),就像你在前面的代码中看到的那样。但你可以有任意多个嵌套的子元素,并且将它们作为额外的参数传递。例如:

React.createElement(
  'h1',
  {id: 'my-heading'},
  React.createElement('span', null, 'Hello'),
  ' world!'
),

另一个例子,这次是带有嵌套组件的(结果显示在 图 1-3 中)如下所示:

React.createElement(
  'h1',
  {id: 'my-heading'},
  React.createElement(
    'span',
    null,
    'Hello ',
    React.createElement('em', null, 'Wonderful'),
  ),
  ' world!'
),

rur2 0103

图 1-3. 由嵌套 React.createElement() 调用生成的 HTML

你可以在 图 1-3 中看到,React 生成的 DOM 中 <em> 元素是 <span> 的子元素,而 <span> 又是 <h1> 元素的子元素(也是 “world” 文本节点的同级节点)。

JSX

当你开始嵌套组件时,你很快会遇到许多函数调用和括号要跟踪。为了简化操作,你可以使用 JSX 语法。JSX 有点争议:人们经常一见面就觉得它不好看(啊,XML 混在我的 JavaScript 里面!),但是在使用后却不可或缺。

注意

JSX 这个缩写具体代表什么并不十分清楚,但很可能是 JavaScriptXML 或 JavaScript 语法扩展。这个开源项目的官方主页是 https://facebook.github.io/jsx

下面是上面代码片段,这次使用 JSX 语法:

ReactDOM.render(
  <h1 id="my-heading">
    <span>Hello <em>Wonderful</em></span> world!
  </h1>,
  document.getElementById('app')
);

这样更易读。这种语法看起来非常像 HTML,而且你已经熟悉 HTML。但它并不是浏览器能理解的有效 JavaScript。你需要 转译 这段代码以使其在浏览器中运行。同样地,出于学习目的,你可以在没有特殊工具的情况下完成这项工作。你需要 Babel 库,它可以将前沿的 JavaScript(和 JSX)转换为老式浏览器可以运行的 JavaScript。

设置 Babel

就像使用 React 一样,先获取 Babel 的本地副本:

$ curl -L https://unpkg.com/babel-standalone/babel.min.js > ~/reactbook/react/babel.js

接着,你需要更新学习模板以包含 Babel。像这样创建一个名为 01.04.hellojsx.html 的文件:

<!DOCTYPE html>
<html>
  <head>
    <title>Hello React+JSX</title>
    <meta charset="utf-8">
  </head>
  <body>
    <div id="app">
      <!-- my app renders here -->
    </div>
    <script src="react/react.js"></script>
    <script src="react/react-dom.js"></script>
    <script src="react/babel.js"></script>
    <script type="text/babel">
      // my app's code
    </script>
  </body>
</html>
注意

注意 <script> 如何变成 <script type="text/babel">。这是一个技巧,通过指定一个无效的 type,浏览器会忽略这段代码。这给了 Babel 解析和转换 JSX 语法的机会,将其转换成浏览器可以运行的代码。

Hello JSX 世界

现在设置完成,让我们尝试一下 JSX。将前面 HTML 中 // my app's code 部分替换为:

ReactDOM.render(
  <h1 id="my-heading">
    <span>Hello <em>JSX</em></span> world!
  </h1>,
  document.getElementById('app')
);

在浏览器中运行的结果显示在 图 1-4 中。

rur2 0104

图 1-4. 你好 JSX 世界

关于转译

很高兴你已经让 JSX 和 Babel 正常工作了,但也许再多说几句也无妨,特别是如果你对 Babel 和转译过程还不太熟悉的话。如果你已经熟悉了,请随意跳过这部分,我们稍微了解一下 JSXBabel转译 的术语。

JSX是 React 的一项独立技术,完全是可选的。正如你所见,本章的第一个示例甚至没有使用 JSX。你完全可以选择永远不接触 JSX。但很可能一旦尝试过,你就不会再回到函数调用。

转译的过程是将源代码重写成使用旧版浏览器理解的语法来实现相同结果的过程。这与使用垫片不同。例如,向Array.prototype添加map()方法,该方法是在 ECMAScript5 中引入的,在仅支持 ECMAScript3 的浏览器中使其生效。垫片是 JavaScript 领域的一种解决方案。当向现有对象添加新方法或实现新对象(例如JSON)时,它是一个很好的解决方案。但是,当语言引入新的语法时,垫片是不够的。对于不支持它的浏览器,任何新语法都是无效的,并且会抛出解析错误。因此,新语法需要一个编译(转译)步骤,以便在提供给浏览器之前进行转换。

随着程序员希望使用最新的 JavaScript(ECMAScript)功能而不必等待浏览器实现它们,JavaScript 的转译变得越来越普遍。如果您已经设置了构建流程(例如,缩小或任何其他代码转换),您可以简单地将 JSX 步骤添加到其中。假设您没有构建过程,您将在本书的后面看到设置一个所需步骤。

目前,让我们将 JSX 转译留在客户端(在浏览器中),继续学习 React。只需注意,这仅用于教育和实验目的。客户端转换不适用于实时生产站点,因为它们比提供已转译代码更慢且资源消耗更大。

接下来:自定义组件

到此为止,你已经完成了基本的“Hello world”应用程序。现在你知道如何:

  • 为了实验和学习,设置 React 库(实际上只需几个<script>标签)

  • 在你选择的 DOM 位置中渲染一个 React 组件(例如,ReactDOM.render(reactWhat, domWhere)

  • 使用内置组件,它们是常规 DOM 元素的包装器(例如,React.createElement(element, attributes, content, children)

然而,React 的真正强大之处在于当你开始使用自定义组件来构建(和更新!)你的应用程序用户界面(UI)时。让我们在下一章中学习如何做到这一点。

第二章:组件的生命周期

现在你知道如何使用现成的 DOM 组件了,现在是时候学习如何制作自己的组件了。

有两种定义自定义组件的方式,但两种方式都能实现同样的结果,只是使用了不同的语法:

  • 使用函数(这种方式创建的组件称为函数组件)

  • 使用扩展 React.Component 类的类(通常称为组件)

自定义函数组件

这里有一个函数组件的示例:

const MyComponent = function() {
  return 'I am so custom';
};

但是,等等,这只是一个函数!是的,自定义组件只是一个返回所需 UI 的函数。在这种情况下,UI 只是文本,但通常你会需要更多,很可能是其他组件的组合。这里有一个使用 span 包装文本的示例:

const MyComponent = function() {
  return React.createElement('span', null, 'I am so custom');
};

在应用程序中使用你的全新组件与使用第一章中的 DOM 组件类似,只是你调用定义组件的函数:

ReactDOM.render(
  MyComponent(),
  document.getElementById('app')
);

渲染你的自定义组件的结果显示在图 2-1 中。

rur2 0201

图 2-1. 你的第一个自定义组件(书中存储库的 02.01.custom-functional.html

JSX 版本

使用 JSX 的相同示例看起来会更容易阅读。定义组件的方式如下:

const MyComponent = function() {
  return <span>I am so custom</span>;
};

无论组件本身是如何定义的(使用 JSX 还是不使用 JSX),使用 JSX 方式使用组件如下:

ReactDOM.render(
  <MyComponent />,
  document.getElementById('app')
);
注意

注意,在自闭合标签 <MyComponent /> 中,斜杠是不可选的。这也适用于 JSX 中使用的 HTML 元素。<br><img> 是行不通的;你需要像 <br/><img/> 这样关闭它们。

自定义类组件

创建组件的第二种方式是定义一个扩展 React.Component 并实现 render() 函数的类:

class MyComponent extends React.Component {
  render() {
    return React.createElement('span', null, 'I am so custom');
    // or with JSX:
    // return <span>I am so custom</span>;
  }
}

在页面上渲染组件:

ReactDOM.render(
  React.createElement(MyComponent),
  document.getElementById('app')
);

如果你使用 JSX,你不需要知道组件是如何定义的(使用类或函数)。在使用组件的两种情况下,都是一样的:

ReactDOM.render(
  <MyComponent />,
  document.getElementById('app')
);

使用哪种语法?

你可能会想:在所有这些选项(JSX vs. 纯 JavaScript,类组件 vs. 函数组件)中,应该选择哪一个?JSX 是最常见的。而且,除非你不喜欢在 JavaScript 中使用 XML 语法,选择 JSX 是最少阻力和打字量最少的路径。从现在开始,本书将使用 JSX,除非是为了说明概念。那么,为什么还要讨论非 JSX 的方式呢?嗯,你应该知道还另一种方式,而且 JSX 并不是魔法,而是一层薄薄的语法层,在将代码发送到浏览器之前,它会将 XML 转换为普通的 JavaScript 函数调用,比如React.createElement()

类组件与函数组件之争?这是一个偏好问题。如果您熟悉面向对象编程(OOP),并且喜欢类的布局方式,那么尽管去选择类组件。函数组件在计算机 CPU 上稍轻,输入少一些。它们也更符合 JavaScript 的本性。实际上,在 JavaScript 语言的早期版本中并不存在;它们是后来的想法,仅仅是函数和原型的语法糖。

从 React 的历史角度来看,函数组件无法像类组件那样完成所有任务,直到hooks的发明。关于未来,只能进行推测,但很可能 React 会更多地向函数组件靠拢。但是,类组件不太可能很快就会被废弃。本书将教授您两种方式,并不会为您做出决定,尽管您可能会感觉到对函数组件的轻微偏爱。您可能会问,为什么本书还要使用类?(正如手稿的大多数技术编辑所问。)

在现实世界中有很多使用类编写的代码和许多在线教程。事实上,在撰写本文时,即使 React 的官方文档也显示大多数示例为类组件。因此,作者认为读者应该熟悉两种语法,以便能够阅读和理解所有呈现给他们的代码,并且在出现非函数组件时不会感到困惑。

属性

在自定义组件中渲染硬编码的 UI 是完全可以的,并且具有其用途。但是组件也可以接受属性并根据属性值的不同进行渲染或行为上的差异。考虑 HTML 中的<a>元素及其根据href属性值的不同而行为不同。React 中的属性概念与此类似(JSX 语法也是如此)。

在类组件中,所有属性都可以通过this.props对象访问。让我们看一个例子:

class MyComponent extends React.Component {
  render() {
    return <span>My name is <em>{this.props.name}</em></span>;
  }
}
注意

如此示例所示,您可以打开花括号,并在您的 JSX 中添加 JavaScript 值(包括表达式)。随着您在本书中的进展,您将了解更多有关此行为的信息。

当渲染组件时为name属性传递一个值的示例如下所示:

ReactDOM.render(
  <MyComponent name="Bob" />,
  document.getElementById('app')
);

结果显示在图 2-2 中。

rur2 0202

图 2-2. 使用组件属性(02.05.this.props.html

需要记住的重要一点是this.props是只读的。它旨在从父组件传递配置到子组件,但它不是通用的值存储器。如果您感到诱惑设置this.props的属性,只需使用额外的局部变量或组件类的属性即可(意思是使用this.thing而不是this.props.thing)。

函数组件中的属性

在函数组件中,没有this(在 JavaScript 的严格模式下),或者this指向全局对象(在非严格模式下,我们可以说是松散模式)。所以,你不再使用this.props,而是将一个props对象作为第一个参数传递给你的函数:

const MyComponent = function(props) {
  return <span>My name is <em>{props.name}</em></span>;
};

一个常见的模式是使用 JavaScript 的解构赋值将属性值分配给局部变量。换句话说,前面的例子变成了:

// 02.07.props.destructuring.html in the book's repository
const MyComponent = function({name}) {
  return <span>My name is <em>{name}</em></span>;
};

你可以拥有任意多的属性。例如,如果你需要两个属性(namejob),你可以像这样使用它们:

// 02.08.props.destruct.multi.html in the book's repository
const MyComponent = function({name, job}) {
  return <span>My name is <em>{name}</em>, the {job}</span>;
};
ReactDOM.render(
  <MyComponent name="Bob" job="engineer"/>,
  document.getElementById('app')
);

默认属性

你的组件可能提供多个属性,但有时少数属性可能有适合大多数情况的默认值。你可以为函数和类组件使用defaultProps属性来指定默认属性值。

函数组件:

const MyComponent = function({name, job}) {
  return <span>My name is <em>{name}</em>, the {job}</span>;
};
MyComponent.defaultProps = {
  job: 'engineer',
};
ReactDOM.render(
  <MyComponent name="Bob" />,
  document.getElementById('app')
);

类组件:

class MyComponent extends React.Component {
  render() {
    return (
      <span>My name is <em>{this.props.name}</em>,
      the {this.props.job}</span>
    );
  }
}
MyComponent.defaultProps = {
  job: 'engineer',
};
ReactDOM.render(
  <MyComponent name="Bob" />,
  document.getElementById('app')
);

在这两种情况下,结果都是输出:

My name is *Bob*, the engineer

提示

注意render()方法的return语句如何用括号包裹返回的值。这只是因为 JavaScript 的自动分号插入(ASI)机制。跟在新行后面的return语句相当于return;,这等同于return undefined;,这绝对不是你想要的。用括号包裹返回的表达式可以在保持正确性的同时,提供更好的代码格式化。

状态

到目前为止的例子都比较静态(或称为“无状态”)。目标仅仅是让你了解如何组合你的 UI 的构建模块。但是,React 真正闪耀的地方(以及旧式浏览器 DOM 操作和维护变得复杂的地方)是当你的应用程序中的数据发生变化时。React 具有状态的概念,它是组件想要用来渲染自己的任何数据。当状态发生变化时,React 会在 DOM 中重新构建 UI,而你无需做任何事情。在你的render()方法(或函数组件的渲染函数)中首次构建 UI 后,你只需关心更新数据。你根本不需要担心 UI 的变化。毕竟,你的渲染方法/函数已经提供了组件应该看起来像什么的蓝图。

注意

“无状态”并不是一个坏词,一点也不是。无状态组件更容易管理和思考。然而,尽可能地使组件无状态通常是可取的,但应用程序是复杂的,你确实需要状态。

与通过this.props访问属性类似,你通过this.state对象读取状态。要更新状态,你使用this.setState()。当调用this.setState()时,React 调用你组件的渲染方法(及其所有子组件),并更新 UI。

在调用this.setState()后更新 UI 是通过一个高效批处理更改的队列机制完成的。直接更新this.state可能会产生意外的行为,你不应该这样做。和this.props一样,考虑this.state对象为只读,不仅因为在语义上这是一个不好的主意,而且因为它可能以你意想不到的方式行事。同样,不要自行调用this.render()—而是让 React 来批处理更改、找出最少的工作量,并在适当时调用render()

一个文本域组件

让我们构建一个新组件——一个textarea,它会计算键入字符的数量(如图 2-3 所示)。

rur2 0203

图 2-3. 自定义textarea组件的最终结果

你(以及其他未来使用这个非常可重用组件的人)可以像这样使用新组件:

ReactDOM.render(
  <TextAreaCounter text="Bob" />,
  document.getElementById('app')
);

现在,让我们来实现这个组件。首先创建一个不处理更新的“无状态”版本,这与之前的所有示例并没有太大不同:

class TextAreaCounter extends React.Component {
  render() {
    const text = this.props.text;
    return (
      <div>
        <textarea defaultValue={text}/>
        <h3>{text.length}</h3>
      </div>
    );
  }
}
TextAreaCounter.defaultProps = {
  text: 'Count me as I type',
};
注意

你可能已经注意到,在前面的片段中,<textarea>使用defaultValue属性而不是像你在常规 HTML 中习惯的文本子节点。这是因为在 React 和老式 HTML 之间存在一些细微差别。这些差异在书中进一步讨论,但请放心,它们并不多。此外,你会发现这些差异使开发者的生活更轻松。

正如你所看到的,TextAreaCounter组件接受一个可选的text字符串属性,并渲染一个带有给定值的textarea,以及一个显示字符串长度的<h3>元素。如果未提供text属性,则使用默认的“Count me as I type”值。

使其具有状态

下一步是将这个无状态组件转换为一个有状态组件。换句话说,让组件维护一些数据(状态),并使用这些数据来初始化自身,并在数据变化时更新自身(重新渲染)。

首先,你需要在类构造函数中使用this.state来设置初始状态。请记住,构造函数是唯一可以直接设置状态而不调用this.setState()的地方。

初始化this.state是必需的;如果不这样做,在render()方法中连续访问this.state将失败。

在这种情况下,不需要使用一个值来初始化this.state.text,因为你可以回退到属性this.prop.text(尝试在书的仓库中查看02.12.this.state.html):

class TextAreaCounter extends React.Component {
  constructor() {
    super();
    this.state = {};
  }
  render() {
    const text = 'text' in this.state ? this.state.text : this.props.text;
    return (
      <div>
        <textarea defaultValue={text} />
        <h3>{text.length}</h3>
      </div>
    );
  }
}
注意

在使用this之前,需要在构造函数中调用super()

这个组件维护的数据是textarea的内容,因此状态只有一个名为text的属性,可以通过this.state.text访问。接下来,你需要更新状态。你可以使用一个辅助方法来实现这个目的:

onTextChange(event) {
  this.setState({
    text: event.target.value,
  });
}

你总是使用 this.setState() 更新状态,它接受一个对象并将其与 this.state 中已有的数据合并。你可能已经猜到,onTextChange() 是一个事件处理程序,它接收一个 event 对象并从中获取 textarea 输入的内容。

最后要做的是更新 render() 方法来设置事件处理程序:

render() {
  const text = 'text' in this.state ? this.state.text : this.props.text;
  return (
    <div>
      <textarea
        value={text}
        onChange={event => this.onTextChange(event)}
      />
      <h3>{text.length}</h3>
    </div>
  );
}

现在,每当用户在 textarea 中输入时,计数器的值都会更新以反映其内容(见图 2-4)。

请注意,在之前你使用了 <textarea defaultValue...>,现在在前述代码中改为了 <textarea value...>。这是因为 HTML 中输入的工作方式,浏览器通过维护它们的状态来完成。但是 React 可以做得更好。在这个例子中,实现 onChange 意味着 textarea 现在被 React 控制。关于受控组件的更多内容将在本书后面介绍。

rur2 0204

图 2-4. 在 textarea 中输入(02.12.this.state.html

DOM 事件的一则说明

为了避免混淆,以下是关于以下行的一些澄清:

onChange={event => this.onTextChange(event)}

React 使用自己的合成事件系统以提升性能(以及便利性和理智)。为了理解其中的原因,你需要考虑纯 DOM 世界中的工作方式。

旧日的事件处理

使用内联事件处理程序来做这样的事情非常方便:

<button onclick="doStuff">

尽管方便且易于阅读(事件监听器直接与 UI 代码并列),但是如此散落的事件监听器过多效率低下。尤其是在同一个按钮上安装多个监听器,特别是当该按钮位于他人的“组件”或库中,你不想进去“修复”或分叉他们的代码时。这就是为什么在 DOM 世界中使用 element.addEventListener 来设置监听器是常见的(这导致代码分布在两个或更多地方),并且事件委托(以解决性能问题)。事件委托意味着你在某个父节点(如包含多个按钮的 <div>)上监听事件,并为所有按钮设置一个监听器,而不是每个按钮都设置一个监听器。因此,你委托事件处理给父级授权。

使用事件委托,你可以这样做:

<div id="parent">
  <button id="ok">OK</button>
  <button id="cancel">Cancel</button>
</div>

<script>
document.getElementById('parent').addEventListener('click', function(event) {
  const button = event.target;

  // do different things based on which button was clicked
  switch (button.id) {
    case 'ok':
      console.log('OK!');
      break;
    case 'cancel':
      console.log('Cancel');
      break;
    default:
      new Error('Unexpected button ID');
  };
});
</script>

这种方法确实有效且运行良好,但也存在一些缺点:

  • 声明监听器离 UI 组件更远,这使得代码更难找到和调试。

  • 使用委托和总是进行 switch 创建不必要的样板代码,甚至在实际工作(例如响应按钮点击)之前。

  • 浏览器不一致性(此处省略)实际上要求这段代码更长。

不幸的是,当你将此代码发布到真实用户面前时,如果想支持旧版本浏览器,你需要添加更多内容:

  • 你需要在 addEventListener 之外还要使用 attachEvent

  • 你需要在监听器顶部添加 const event = event || window.event;

  • 当涉及到在render()方法中显示你的组件时,你需要const button = event.target || event.srcElement;

所有这些都是必要的,而且足够让人讨厌,以至于最终你会使用某种事件库。但是当 React 已经捆绑了处理事件噩梦的解决方案时,为什么要再添加另一个库(并学习更多的 API)呢?

React 中的事件处理

React 使用合成事件来包装和规范化浏览器事件,这意味着不再存在浏览器的不一致性。你始终可以依赖于event.target在所有浏览器中都是可用的。这就是为什么在TextAreaCounter片段中,你只需要event.target.value,它就能正常工作。这也意味着取消事件的 API 在所有浏览器中都是相同的;换句话说,event.stopPropagation()event.preventDefault()即使在旧版的 Internet Explorer 中也能工作。

这种语法使得将 UI 和事件监听器放在一起变得容易。它看起来像是旧式的内联事件处理程序,但在幕后并非如此。实际上,React 出于性能原因使用了事件委托。

React 使用驼峰式语法来处理事件处理程序,因此你使用onClick而不是onclick

如果因为某种原因需要原始的浏览器事件,可以通过event.nativeEvent获取到,但你很少会需要这样做。

还有一件事:onChange事件(就像在textarea示例中使用的那样)的行为与你期望的一样:用户输入时触发,而不是在他们完成输入并离开字段后触发,这是纯 DOM 中的行为。

事件处理语法

前面的示例使用箭头函数调用辅助的onTextChange事件:

onChange={event => this.onTextChange(event)}

这是因为短语法onChange={this.onTextChange}不起作用的原因。

另一种选择是像这样绑定方法:

onChange={this.onTextChange.bind(this)}

另一个选项,也是一个常见模式,是在构造函数中绑定所有事件处理方法:

constructor() {
  super();
  this.state = {};
  this.onTextChange = this.onTextChange.bind(this);
}
// ....
<textarea
  value={text}
  onChange={this.onTextChange}
/>

这是一些必要的样板代码,但通过这种方式,事件处理程序仅绑定一次,而不是每次调用render()方法时都绑定,这有助于减少应用程序的内存占用。

一旦在 JavaScript 中可以将函数用作类属性,这种常见模式就大部分被取代了。

之前:

class TextAreaCounter extends React.Component {
  constructor() {
    // ...
    this.onTextChange = this.onTextChange.bind(this);
  }

  onTextChange(event) {
    // ...
  }
}

之后:

class TextAreaCounter extends React.Component {
  constructor() {
    // ...
  }

  onTextChange = (event) => {
    // ...
  };
}

在书的存储库中查看02.12.this.state2.html以获取完整的示例。

属性与状态的区别

现在你知道在渲染你的组件时,可以访问this.propsthis.state。你可能想知道何时使用其中的一个而不是另一个。

属性是外部世界(组件的用户)配置你的组件的机制。状态是你的内部数据维护。因此,如果将其与面向对象编程进行类比,this.props就像是传递给类构造函数的所有参数的集合,而this.state则是你的私有属性的集合。

总的来说,最好将你的应用程序拆分为更少的有状态组件和更多的无状态组件。

Props 在初始状态中的使用:反模式

在前述 textarea 示例中,很容易使用 this.props 设置初始的 this.state

// Warning: Anti-pattern
this.state = {
  text: props.text,
};

这被视为一种反模式。理想情况下,您可以根据需要结合 this.statethis.propsrender() 方法中构建您的 UI。但有时,您可能希望使用传递给组件的值来构建初始状态。这本身没有问题,但您组件的调用者可能期望属性(例如前述示例中的 text)始终具有最新的值,而前述代码将违反此预期。为了明确预期,仅需进行简单的命名更改即可,例如将属性命名为 defaultTextinitialValue 而不仅仅是 text

注意

第四章 展示了 React 如何解决其输入和 textarea 的实现,人们可能会从他们先前的 HTML 知识中有所期待。

从外部访问组件

您并非总能够奢侈地从头开始创建一个全新的 React 应用程序。有时候,您需要连接到现有的应用程序或网站,并逐步迁移到 React。幸运的是,React 设计时考虑到了您可能有的任何预先存在的代码库。毕竟,在 React 刚刚起步的早期,原始的 React 创建者并没有办法停止整个世界并完全从头开始重写一个庞大的应用程序(如 Facebook.com)。

React 应用程序与外部世界通信的一种方法是获取您使用 ReactDOM.render() 渲染的组件的引用,然后从组件外部使用它:

const myTextAreaCounter = ReactDOM.render(
  <TextAreaCounter text="Bob" />,
  document.getElementById('app')
);

现在,您可以使用 myTextAreaCounter 访问与在组件内部使用 this 访问的相同方法和属性。甚至可以使用您的 JavaScript 控制台玩弄该组件(如 图 2-5 所示)。

rur2 0205

图 2-5. 通过保持引用访问渲染的组件

在此示例中,myTextAreaCounter.state 检查当前状态(最初为空);myTextAreaCounter.props 检查属性,并通过此行设置新状态:

myTextAreaCounter.setState({text: "Hello outside world!"});

此行获取了 React 创建的主父 DOM 节点的引用:

const reactAppNode = ReactDOM.findDOMNode(myTextAreaCounter);

这是 <div id="app"> 的第一个子元素,这是您告诉 React 进行其魔术的地方。

注意

您可以从组件外部访问整个组件 API。但是,如果可能的话,应该谨慎使用您的新超能力。也许您会心生把控不属于您的组件状态并“修复”它们的诱惑,但这会违反预期,从而在未来引起 bug,因为该组件并不预期此类干预。

生命周期方法

React 提供了几个所谓的 生命周期 方法。您可以使用生命周期方法监听组件在 DOM 操作方面的变化。组件的生命周期经历三个步骤:

挂载

初始时将组件添加到 DOM 中。

更新

组件通过调用 setState() 和/或者提供给组件的 prop 发生变化而更新。

卸载

将组件从 DOM 中移除。

React 在更新 DOM 之前会做一部分工作。这也被称为 渲染阶段。然后它更新 DOM,这个阶段称为 提交阶段。在此背景下,让我们考虑一些生命周期方法:

  • 在初始挂载和提交到 DOM 之后,如果存在,你的组件的 componentDidMount() 方法会被调用。这是进行任何需要 DOM 的初始化工作的地方。任何不需要 DOM 的初始化工作应该在构造函数中完成。大多数初始化工作不应该依赖 DOM。但在这个方法中,你可以例如测量刚刚渲染的组件的高度,添加任何事件监听器(例如 addEventListener('resize')),或从服务器获取数据。

  • 在组件从 DOM 中被移除之前,方法 componentWillUnmount() 会被调用。这是进行任何可能需要的清理工作的地方。任何事件处理程序或其他可能会泄露内存的东西都应该在这里清理。之后,组件永远消失了。

  • 在组件更新之前(例如通过 setState() 的结果),你可以使用 getSnapshotBeforeUpdate()。该方法接收先前的属性和状态作为参数。它可以返回一个“快照”值,这个值可以传递给下一个生命周期方法 componentDidUpdate()

  • componentDidUpdate(previousProps, previousState, snapshot)。每当组件被更新时调用此方法。由于此时 this.propsthis.state 已经更新了,你会得到之前的副本。你可以用这些信息比较旧状态和新状态,并在必要时进行更多的网络请求。

  • 然后是 shouldComponentUpdate(newProps, newState),这是一个优化的机会。你可以拿到即将更新的状态,与当前状态进行比较,决定是否更新组件,如果不更新,其 render() 方法就不会被调用。

其中,componentDidMount()componentDidUpdate() 是最常见的方法之一。

生命周期示例:全部记录下来

为了更好地理解组件的生命周期,让我们在 TextAreaCounter 组件中添加一些日志记录。简单地实现所有的生命周期方法,在它们被调用时在控制台打印日志及相关参数:

class TextAreaCounter extends React.Component {
  // ...

  componentDidMount() {
    console.log('componentDidMount');
  }
  componentWillUnmount() {
    console.log('componentWillUnmount');
  }
  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log('componentDidUpdate     ', prevProps, prevState, snapshot);
  }
  getSnapshotBeforeUpdate(prevProps, prevState) {
    console.log('getSnapshotBeforeUpdate', prevProps, prevState);
    return 'hello';
  }
  shouldComponentUpdate(newProps, newState) {
    console.log('shouldComponentUpdate  ', newProps, newState);
    return true;
  }

  // ...
}

页面加载后,控制台中唯一的消息是 componentDidMount

接下来,当您键入“b”以使文本变为“Bobb”(参见图 2-6)时,shouldComponentUpdate()将使用新的 props(与旧的相同)和新的状态进行调用。由于此方法返回true,React 继续调用getSnapshotBeforeUpdate(),传递旧的 props 和状态。这是您处理它们和旧的 DOM 并将任何生成的信息作为快照传递给下一个方法的机会。例如,这是一个机会来做一些元素的测量或滚动位置,并将它们快照以查看它们是否在更新后改变。最后,componentDidUpdate()被调用时使用旧信息(您在this.statethis.props中有新的信息)以及由前一个方法定义的任何快照。

rur2 0206

图 2-6. 更新组件

让我们再次更新textarea,这次键入“y”。结果显示在图 2-7 中。

rur2 0207

图 2-7. 对组件的又一次更新

最后,为了演示componentWillUnmount()的实际效果(使用本书 GitHub 存储库中的示例02.14.lifecycle.html),您可以在控制台中键入:

ReactDOM.render(React.createElement('p', null, 'Enough counting!'), app);

这将整个textarea组件替换为一个新的<p>组件。然后,您可以在控制台中看到componentWillUnmount的日志消息(显示在图 2-8 中)。

rur2 0208

图 2-8. 从 DOM 中移除组件

偏执状态保护

假设您想要限制在textarea中键入的字符数。您应该在事件处理程序onTextChange()中执行此操作,该事件在用户键入时调用。但是如果有人(比如一个更年轻、更天真的你?)从组件外部调用setState()会怎么样(正如前面提到的,这是一个坏主意)?你仍然可以保护组件的一致性和健康。您可以在componentDidUpdate()中进行验证,如果字符数超过允许的限制,将状态恢复到原来的状态。类似这样:

componentDidUpdate(prevProps, prevState) {
  if (this.state.text.length > 3) {
    this.setState({
      text: prevState.text || this.props.text,
    });
  }
}

条件prevState.text || this.props.text是在第一次更新时没有前一个状态时的情况下。

这可能看起来过于偏执,但仍然是可能的。实现相同保护的另一种方法是利用shouldComponentUpdate()

shouldComponentUpdate(_, newState) {
  return newState.text.length > 3 ? false : true;
}

在书籍的存储库中查看02.15.paranoid.html以尝试这些概念。

注意

在上述代码中,将_作为函数参数名是一种约定,向代码的未来读者传达:“我知道函数签名中还有另一个参数,但我不打算使用它。”

生命周期示例:使用子组件

您知道您可以根据需要混合和嵌套 React 组件。到目前为止,您只在render()方法中看到了ReactDOM组件(而不是自定义组件)。让我们看看另一个简单的自定义组件,可以用作子组件。

让我们将负责计数的部分隔离到自己的组件中。毕竟,分而治之就是这一切的关键!

首先,让我们将生活方式日志记录单独隔离到一个独立的类中,并让这两个组件继承它。在 React 中,几乎从不需要继承,因为对于 UI 工作,组合 更可取,对于非 UI 工作,普通的 JavaScript 模块就可以了。不过,了解它的工作原理是有用的,它可以帮助你避免复制粘贴日志记录方法。

这是父组件:

class LifecycleLoggerComponent extends React.Component {
  static getName() {}
  componentDidMount() {
    console.log(this.constructor.getName() + '::componentDidMount');
  }
  componentWillUnmount() {
    console.log(this.constructor.getName() + '::componentWillUnmount');
  }
  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log(this.constructor.getName() + '::componentDidUpdate');
  }
}

新的 Counter 组件只显示计数。它不维护状态,而是显示父组件给出的 count 属性:

class Counter extends LifecycleLoggerComponent {
  static getName() {
    return 'Counter';
  }
  render() {
    return <h3>{this.props.count}</h3>;
  }
}
Counter.defaultProps = {
  count: 0,
};

textarea 组件设置了一个静态的 getName() 方法:

class TextAreaCounter extends LifecycleLoggerComponent {
  static getName() {
    return 'TextAreaCounter';
  }
  // ....
}

最后,textarearender() 可以使用 <Counter/> 并有条件地使用它;如果计数为 0,则不显示任何内容:

render() {
  const text = 'text' in this.state ? this.state.text : this.props.text;
  return (
    <div>
      <textarea
        value={text}
        onChange={this.onTextChange}
      />
      {text.length > 0
        ? <Counter count={text.length} />
        : null
      }
    </div>
  );
}
注意

注意 JSX 中的条件语句。你将表达式包裹在 {} 中,有条件地渲染 <Counter/> 或什么都不渲染(null)。只是为了演示:另一个选项是将条件移到 return 外。将 JSX 表达式的结果赋给变量是完全可以的。

render() {
  const text = 'text' in this.state
    ? this.state.text
    : this.props.text;
  let counter = null;
  if (text.length > 0) {
    counter = <Counter count={text.length} />;
  }
  return (
    <div>
      <textarea
        value={text}
        onChange={this.onTextChange}
      />
      {counter}
    </div>
  );
}

现在你可以观察到两个组件的生命周期方法被记录下来。在书的存储库中打开 02.16.child.html,在浏览器中查看当加载页面然后更改 textarea 内容时会发生什么。

在初始加载时,子组件会在父组件之前挂载和更新。你可以在控制台日志中看到:

Counter::componentDidMount
TextAreaCounter::componentDidMount

删除两个字符后,你会看到子组件如何更新,然后是父组件:

Counter::componentDidUpdate
TextAreaCounter::componentDidUpdate
Counter::componentDidUpdate
TextAreaCounter::componentDidUpdate

删除最后一个字符后,子组件完全从 DOM 中移除:

Counter::componentWillUnmount
TextAreaCounter::componentDidUpdate

最后,输入一个字符会将计数组件带回 DOM 中:

Counter::componentDidMount
TextAreaCounter::componentDidUpdate

性能优化:阻止组件更新

你已经了解了 shouldComponentUpdate() 并看到它的作用。在构建应用程序的性能关键部分时,这一点尤为重要。它在 componentWillUpdate() 之前被调用,让你有机会取消更新,如果你认为不必要的话。

有一类组件在其 render() 方法中只使用 this.propsthis.state,没有额外的函数调用。这些组件被称为“纯”组件。它们可以实现 shouldComponentUpdate(),在更新前后比较状态和属性,如果没有任何变化,则返回 false,节省一些处理能力。此外,还可以有纯静态组件,既不使用 props 也不使用 state。这些组件可以直接返回 false

React 可以更容易地使用常见(和通用)情况下在 shouldComponentUpdate() 中检查所有 props 和 state:而不是重复这项工作,你可以让你的组件继承 React.PureComponent 而不是 React.Component。这样你就不需要实现 shouldComponentUpdate() —— 它已经为你做好了。让我们利用这一点并调整前面的例子。

由于两个组件都继承了日志记录器,你只需要:

class LifecycleLoggerComponent extends React.PureComponent {
  // ... no other changes
}

现在这两个组件都是纯的。让我们还在render()方法中添加一个日志消息:

render() {
  console.log(this.constructor.getName() + '::render');
  // ... no other changes
}

现在加载页面(从存储库中的02.17.pure.html)打印出:

TextAreaCounter::render
Counter::render
Counter::componentDidMount
TextAreaCounter::componentDidMount

将“Bob”更改为“Bobb”会得到预期的渲染和更新结果:

TextAreaCounter::render
Counter::render
Counter::componentDidUpdate
TextAreaCounter::componentDidUpdate

现在,如果你将字符串“LOLz”粘贴到替换“Bobb”(或任何包含 4 个字符的字符串),你会看到:

TextAreaCounter::render
TextAreaCounter::componentDidUpdate

正如你所见,无需重新渲染<Counter>,因为它的 props 没有改变。新字符串的字符数相同。

函数组件究竟发生了什么?

你可能已经注意到,一旦this.state参与其中,函数组件就不再本章中。它们在本书的后面会再次出现,那时你还会学到hooks的概念。因为函数中没有this,所以在组件中管理状态需要另一种方法。好消息是,一旦你理解了状态和 props 的概念,函数组件的差异只是语法问题。

尽管花费大量时间在一个textarea上是多么“有趣”,让我们转向更具挑战性的内容。在下一章中,你将看到 React 的好处体现在哪里——即专注于你的数据,而让 React 处理所有的 UI 更新。

第三章:Excel:一个花哨的表格组件

现在你知道如何创建自定义的 React 组件,使用通用 DOM 组件以及你自己的定制组件来组合 UI,设置属性,维护状态,钩入组件的生命周期,并通过在不必要时避免重新渲染来优化性能。

让我们将所有内容整合在一起(并在此过程中学习更多关于 React 的知识),创建一个更强大的组件——数据表格。类似于 Microsoft Excel 的早期原型,它允许你编辑数据表格的内容,还可以对数据进行排序、搜索和导出为可下载的文件。

数据优先

表格关乎数据,所以这个花哨的表格组件(为什么不称它为 Excel?)应该接受一个数据数组和描述每列数据的表头数组。为了测试,让我们从 Wikipedia 获取一份畅销书列表:

const headers = ['Book', 'Author', 'Language', 'Published', 'Sales'];

const data = [
  [
    'A Tale of Two Cities', 'Charles Dickens',
      'English', '1859', '200 million',
  ],
  [
    'Le Petit Prince (The Little Prince)', 'Antoine de Saint-Exupéry',
      'French', '1943', '150 million',
  ],
  [
    "Harry Potter and the Philosopher's Stone", 'J. K. Rowling',
      'English', '1997', '120 million',
  ],
  [
    'And Then There Were None', 'Agatha Christie',
      'English', '1939', '100 million',
  ],
  [
    'Dream of the Red Chamber', 'Cao Xueqin',
      'Chinese', '1791', '100 million',
  ],
  [
    'The Hobbit', 'J. R. R. Tolkien',
      'English', '1937', '100 million',
  ],
];

那么,如何在表格中渲染这些数据呢?

表头循环

第一步,为了让新组件能够运行起来,只显示表格的表头。以下是一个最简实现的示例(03.01.table-th-loop.html 在书籍的代码库中):

class Excel extends React.Component {
  render() {
    const headers = [];
    for (const title of this.props.headers) {
      headers.push(<th>{title}</th>);
    }
    return (
      <table>
        <thead>
          <tr>{headers}</tr>
        </thead>
      </table>
    );
  }
}

现在你有一个工作中的组件,下面是如何使用它:

ReactDOM.render(
  <Excel headers={headers} />,
  document.getElementById('app'),
);

这个起步示例的结果显示在图 3-1 中。在这个讨论中,CSS 只是稍微提及了一下,不是关注的重点,但你可以在书籍仓库的 03.table.css 中找到它。

rur2 0301

图 3-1. 渲染表头

组件的 return 部分相当简单。它看起来就像一个 HTML 表格,除了 headers 数组。

return (
  <table>
    <thead>
      <tr>{headers}</tr>
    </thead>
  </table>
);

正如你在上一章节中看到的,你可以在 JSX 中使用大括号打开并放置任何 JavaScript 值或表达式。如果这个值恰好是一个数组,就像前面的例子一样,JSX 解析器会将其视为你单独传递了数组的每个元素,就像{headers[0]}{headers[1]}...

在这个例子中,headers 数组的元素包含更多的 JSX 内容,这完全没问题。在return之前的循环使用 JSX 值填充了 headers 数组,如果你在硬编码数据,它看起来可能是这样的:

const headers = [
  <th>Book</th>,
  <th>Author</th>,
  // ...
];

你可以在 JSX 中的大括号内使用 JavaScript 表达式,你可以按需嵌套它们。这是 React 的美妙之处之一——JavaScript 的全部功能都可以用来创建你的 UI。循环和条件语句都像往常一样工作,你不需要学习另一种“模板”语言或语法来构建 UI。

表头循环,简洁版本

前面的例子运行良好(我们称之为“v1”,代表“版本 1”),但让我们看看如何用更少的代码完成相同的功能。让我们将循环移到 JSX 返回的末尾。实质上,整个 render() 方法变成了一个单独的 return(参见书籍仓库中的 03.02.table-th-map.html)。

class Excel extends React.Component {
  render() {
    return (
      <table>
        <thead>
          <tr>
            {this.props.headers.map(title => <th>{title}</th>)}
          </tr>
        </thead>
      </table>
    );
  }
}

看看如何通过调用this.props.headers传递的数据在map()上调用生成表头内容的数组。map()调用接受一个输入数组,在每个元素上执行一个回调函数,并创建一个新数组。

在前面的示例中,回调函数使用了最简洁的箭头函数语法。如果这对你来说有点晦涩,我们称之为 v2,并探讨一些其他选项。

这里是 v3:使用大括号包裹回调函数体的更详细的map()循环和一个函数表达式,而不是箭头函数:

{
  this.props.headers.map(
    function(title) {
      return <th>{title}</th>;
    }
  )
}

接下来是 v4,它稍微简洁一些,回到了使用箭头函数:

{
  this.props.headers.map(
    (title) => {
      return <th>{title}</th>;
    }
  )
}

这可以使用更少的缩进格式化为 v5:

{this.props.headers.map((title) => {
  return <th>{title}</th>;
})}

你可以根据个人偏好和要渲染内容的复杂性选择适合你的方式来遍历数组生成 JSX 输出。简单的数据可以在 JSX 中方便地内联循环(从 v2 到 v5)。如果数据类型对于内联的map()来说有点复杂,你可能会发现将内容生成放在渲染函数的顶部更容易阅读,保持 JSX 简洁,从某种意义上将数据与展示分离(v1 是一个例子)。有时候太多的内联表达式会在跟踪所有的闭合括号和大括号时变得混乱。

至于 v2 和 v5 的比较,它们基本相同,只是 v5 在回调参数周围有额外的括号,并且在回调函数体周围有大括号包裹。虽然这两者都是可选的,但它们使未来在差异/代码审查上或者在调试时更容易解析。例如,在 v5 中添加一个新行到函数体中(可能是一个临时的console.log())非常简单——只需添加一个新行。而在 v2 中,添加一个新行还需要添加大括号并重新格式化和重新缩进代码。

调试控制台警告

如果你在加载前面的两个示例(03.01.table-th-loop.html03.01.table-th-map.html)时查看浏览器控制台,你会看到一个警告。它说:

Warning: Each child in a list should have a unique "key" prop.
Check the render method of `Excel`.

它是什么问题以及如何修复它?正如警告信息所述,React 希望你为数组元素提供一个唯一的标识符,以便以后更高效地更新它们。为了解决警告,你需要为每个标题添加一个key属性。这个新属性的值可以是任何唯一的内容,比如数组元素的索引(0、1、2……):

// before
for (const title of this.props.headers) {
  headers.push(<th>{title}</th>);
}

// after - 03.03.table-th-loop-key.html
for (const idx in this.props.headers) {
  const title = this.props.headers[idx];
  headers.push(<th key={idx}>{title}</th>);
}

键值只需要在每个数组循环内唯一,不需要在整个 React 应用程序中唯一,因此 0、1 等值是完全可以接受的。

对于内联版本(v5)的相同修复,从回调函数的第二个参数中获取元素的索引:

// before
<tr>
  {this.props.headers.map((title) => {
    return <th>{title}</th>;
  })}
</tr>

// after - 03.04.table-th-map-key.html
<tr>
  {this.props.headers.map((title, idx) => {
    return <th key={idx}>{title}</th>;
  })}
</tr>

添加 内容

现在你有了一个漂亮的表头,是时候添加表体了。要渲染的数据是一个二维数组(行和列),看起来像这样:

const data = [
  [
    'A Tale of Two Cities', 'Charles Dickens',
      'English', '1859', '200 million',
  ],
  ....
];

要将数据传递给<Excel>,让我们使用一个名为initialData的新 prop。为什么是“initial”而不仅仅是“data”?正如在上一章中简要提到的,这关乎管理期望。你的Excel组件的调用者应该能够传递数据以初始化表格。但随后,随着表格的存在,数据会发生变化,因为用户可以排序、编辑等。换句话说,组件的状态会改变。所以让我们使用this.state.data来跟踪这些变化,并使用this.props.initialData来让调用者初始化组件。

渲染一个新的Excel组件看起来像这样:

ReactDOM.render(
  <Excel headers={headers} initialData={data} />,
  document.getElementById('app'),
);

接下来你需要添加一个构造函数来从给定的数据中设置初始状态。构造函数接收props作为参数,并且还需要通过super()调用其父类的构造函数。

constructor(props) {
  super();
  this.state = {data: props.initialData};
}

现在渲染this.state.data。数据是二维的,所以你需要两个循环:一个用于遍历行,另一个用于遍历每行的数据(单元格)。这可以通过使用两个相同的.map()循环来实现,这些循环你已经知道如何使用了:

{this.state.data.map((row, idx) => (
  <tr key={idx}>
    {row.map((cell, idx) => (
      <td key={idx}>{cell}</td>
    ))}
  </tr>
))}

如你所见,这两个循环都需要key={idx},在这种情况下,名称idx被重新用作每个循环内的局部变量。

完整的实现可能看起来像这样(结果显示在图 3-2 中):

class Excel extends React.Component {
  constructor(props) {
    super();
    this.state = {data: props.initialData};
  }
  render() {
    return (
      <table>
        <thead>
          <tr>
            {this.props.headers.map((title, idx) => (
              <th key={idx}>{title}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {this.state.data.map((row, idx) => (
            <tr key={idx}>
              {row.map((cell, idx) => (
                <td key={idx}>{cell}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    );
  }
}

rur2 0302

图 3-2. 渲染整个表格(03.05.table-th-td.html

Prop Types

在 JavaScript 语言中,无法指定你要处理的变量类型(字符串、数字、布尔值等)。但是来自其他语言的开发人员,以及在有许多其他开发人员参与的大型项目上工作的人会错过它。存在两个流行的选项,可以让你使用类型编写 JavaScript:Flow 和 TypeScript。你当然可以使用它们来编写 React 应用程序。但另一个选项存在,它仅限于指定组件期望的 props 类型:prop types。最初它们是 React 本身的一部分,但从 React v15.5 开始已移动到一个单独的库中。

Prop types 允许你更具体地指定Excel接收的数据类型,从而及早向开发人员展示错误。你可以这样设置 prop types(03.06.table-th-td-prop-types.html):

Excel.propTypes = {
  headers: PropTypes.arrayOf(PropTypes.string),
  initialData: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
};

这意味着headers prop 预期是一个字符串数组,initialData预期是一个数组,其中每个元素是另一个字符串元素的数组。

要使这段代码工作,你需要获取暴露了全局变量PropTypes的库,就像你在第一章开始时所做的那样:

$ curl -L https://unpkg.com/prop-types/prop-types.js > ~/reactbook/react/prop-types.js

然后,在 HTML 中,你将新库与其他库一起包含:

<script src="react/react.js"></script>
<script src="react/react-dom.js"></script>
<script src="react/babel.js"></script>
<script src="react/prop-types.js"></script>
<script type="text/babel">
  class Excel extends React.Component {
    /* ... */
  }
</script>

现在你可以通过修改headers来测试所有这些工作,例如:

// before
const headers = ['Book', 'Author', 'Language', 'Published', 'Sales'];
// after
const headers = [0, 'Author', 'Language', 'Published', 'Sales'];

现在当你加载页面时(存储库中的03.06.table-th-td-prop-types.html),你可以在控制台中看到:

Warning: Failed prop type: Invalid prop `headers[0]` of type `number` supplied to `Excel`, expected `string`.

现在这就严格了!

要探索其他PropTypes,请在控制台中输入PropTypes(如图 3-3 所示)。

rur2 0303

图 3-3. 探索PropTypes

你能改进组件吗?

仅允许字符串数据对于通用的 Microsoft Excel 电子表格来说有些太过于限制性。作为自己娱乐的练习,你可以改变这个例子以允许更多的数据类型(PropTypes.any),然后根据类型不同进行不同的渲染(例如,将数字右对齐)。

排序

有多少次你看到一个网页上的表格,希望它排序方式不同?幸运的是,使用 React 做到这一点非常简单。实际上,这正是 React 突出的一个例子,因为你所需做的就是对data数组进行排序,所有的 UI 更新都由它处理。

为了方便和可读性,所有排序逻辑都在Excel类的sort()方法中。一旦创建,需要两个连接件。首先,在标题行中添加一个点击处理程序:

<thead onClick={this.sort}>

然后在构造函数中绑定this.sort,就像你在第二章中所做的一样:

class Excel extends React.Component {
  constructor(props) {
    super();
    this.state = {data: props.initialData};
    this.sort = this.sort.bind(this);
  }
  sort(e) {
    // TODO: implement me
  }
  render() { /* ...*/}
}

现在让我们实现sort()方法。你需要知道按哪一列排序,可以通过事件目标(表头 <th>)的cellIndex DOM 属性方便地获取:

const column = e.target.cellIndex;
注意

你可能很少在应用程序开发中看到使用cellIndex。它是一个早在 DOM Level 1(约 1998 年)定义的属性,表示“该单元格在行中的索引”,后来在 DOM Level 2 中被定义为只读。

你还需要一个数据的 副本 进行排序。否则,如果直接使用数组的sort()方法,它会修改数组。意味着调用this.state.data.sort()会修改this.state。正如你已经了解的,应该避免直接修改this.state,而只能通过setState()来修改。

JavaScript 中存在多种方法来对对象或数组进行 浅复制(数组在 JavaScript 中是对象,例如,Object.assign())或使用扩展运算符 {...state}。然而,没有内建的方法来对对象进行 深复制。一个快速的解决方案是将对象编码为 JSON 字符串,然后解码回对象。让我们出于简洁性使用这种方法,尽管要注意,如果你的对象/数组包含Date对象,这种方法会失败。

function clone(o) {
  return JSON.parse(JSON.stringify(o));
}

利用方便的clone()实用函数,在开始操作数组之前先复制它:

// copy the data
const data = clone(this.state.data);

实际的排序是通过对数组的sort()方法进行回调来完成的:

data.sort((a, b) => {
  if (a[column] === b[column]) {
    return 0;
  }
  return a[column] > b[column] ? 1 : -1;
});

最后,这一行设置了新排序后的状态:

this.setState({
  data,
});

现在,当你点击标题时,内容按字母顺序排序(如图 3-4 所示)。

rur2 0304

图 3-4. 按书名排序 (03.07.table-sort.html)

就是这样——你根本不必触碰 UI 渲染。在render()方法中,你已经定义了组件如何根据一些数据呈现。数据变化时,UI 也随之变化;但这已经不再是你的责任。

注意

此示例使用了 ECMAScript 的 属性值简写 功能,其中 this.setState({data}) 是通过跳过与变量同名的键来更简洁地表示 this.setState({data: data})

你能改进这个组件吗?

上面的示例使用了相当简单的排序,刚好足够涉及到 React 的讨论。你可以按需进行更复杂的排序,解析内容以查看值是否是数字,是否带有单位等。

排序 UI 提示

表格已经很好地排序了,但是不清楚它按哪一列排序。让我们更新 UI 以显示基于正在排序的列的箭头。在此过程中,让我们也实现降序排序。

为了跟踪新状态,你需要向 this.state 添加两个新属性:

this.state.sortby

当前正在排序的列的索引

this.state.descending

一个布尔值以确定升序还是降序排序

现在构造函数可以是这样的:

constructor(props) {
  super();
  this.state = {
    data: props.initialData,
    sortby: null,
    descending: false,
  };
  this.sort = this.sort.bind(this);
}

sort() 函数中,你需要弄清楚排序的方式。默认为升序(A 到 Z),除非新列的索引与当前按照的列相同,并且排序不是由于之前点击头部而变成降序:

const column = e.target.cellIndex;
const data = clone(this.state.data);
const descending = this.state.sortby === column && !this.state.descending;

你还需要对排序回调进行小的调整:

data.sort((a, b) => {
  if (a[column] === b[column]) {
    return 0;
  }
  return descending
    ? a[column] < b[column]
      ? 1
      : -1
    : a[column] > b[column]
      ? 1
      : -1;
});

最后,你需要设置新状态:

this.setState({
  data,
  sortby: column,
  descending,
});

在此时,降序排序已经可以工作了。点击表头首先按升序排序数据,然后按降序排序,之后在两者之间切换。

唯一剩下的任务是更新 render() 函数以指示排序方向。对于当前已排序的列,让我们在标题中添加一个箭头符号。现在 headers 循环看起来像下面这样:

{this.props.headers.map((title, idx) => {
  if (this.state.sortby === idx) {
    title += this.state.descending ? ' \u2191' : ' \u2193'
  }
  return <th key={idx}>{title}</th>
})}

排序功能已经完备,用户可以按任何列排序,一次点击升序,再次点击降序,并且 UI 会通过视觉提示进行更新(如图 3-5 所示)。

rur2 0305

图 3-5. 升序/降序排序(注意“发布日期”旁边的箭头)

编辑数据

Excel 组件的下一步是为人们提供在表格中编辑数据的选项。一个解决方案可以这样工作:

  1. 你双击了一个单元格。Excel 确定了被点击的单元格,并将其内容从简单文本转换为预先填充内容的输入字段(如图 3-6 所示)。

  2. 你编辑了内容(如图 3-7 所示)。

  3. 你按下 Enter 键。输入字段消失,表格更新为新文本(如图 3-8 所示)。

rur2 0306

图 3-6. 双击后表格单元格变成输入字段

rur2 0307

图 3-7. 编辑内容

rur2 0308

图 3-8. 按 Enter 键更新内容

可编辑单元格

第一件事是设置一个简单的事件处理程序。双击时,组件“记住”选定的单元格:

<tbody onDoubleClick={this.showEditor}>
注意

注意友好易读的 onDoubleClick,而不是 W3C 的 ondblclick

让我们看看 showEditor 方法的实现:

showEditor(e) {
  this.setState({
    edit: {
      row: parseInt(e.target.parentNode.dataset.row, 10),
      column: e.target.cellIndex,
    },
  });
}

这里发生了什么?

  • 函数设置了 this.stateedit 属性。当未进行编辑时,此属性为 null,然后变为一个包含正在编辑的单元格的行索引和列索引的对象。因此,如果双击第一个单元格,this.state.edit 的值将为 {row: 0, column: 0}

  • 要确定列索引,您可以像之前一样使用 e.target.cellIndex,其中 e.target 是双击的 <td>

  • 在 DOM 中,并没有 rowIndex 可以免费获取,因此您需要通过 data- 属性自行完成。每一行应该有一个 data-row 属性来存储行索引,您可以使用 parseInt() 来获取索引。

让我们先处理一些先决条件。首先,edit 属性之前并不存在,在构造函数中也应该进行初始化。在处理构造函数时,让我们绑定 showEditor()save() 方法。save() 方法用于在用户编辑完成后进行数据更新。更新后的构造函数如下所示:

constructor(props) {
  super();
  this.state = {
    data: props.initialData,
    sortby: null,
    descending: false,
    edit: null, // {row: index, column: index}
  };
  this.sort = this.sort.bind(this);
  this.showEditor = this.showEditor.bind(this);
  this.save = this.save.bind(this);
}

data-row 属性是您需要的,以便您可以跟踪行索引。在循环中,您可以通过数组索引获取索引。之前您看到 idx 被同时行和列循环中的本地变量重复使用。让我们为了清晰起见将其重命名为 rowidxcolumnidx

整个 <tbody> 构造可能如下所示:

<tbody onDoubleClick={this.showEditor}>
  {this.state.data.map((row, rowidx) => (
    <tr key={rowidx} data-row={rowidx}>
      {row.map((cell, columnidx) => {

        // TODO - turn `cell` into an input if the `columnidx`
        // and the `rowidx` match the one being edited;
        // otherwise, just show it as text

        return <td key={columnidx}>{cell}</td>;
      })}
    </tr>
  ))}
</tbody>

最后,让我们按照 TODO 中的要求——在必要时创建一个输入字段。由于 setState() 调用设置了 edit 属性,整个 render() 函数被再次调用,React 重新渲染了表格,这给了您更新被双击的表格单元格的机会。

输入字段单元格

让我们来看看替换 TODO 注释的代码。首先,为了简洁起见,记住编辑状态:

const edit = this.state.edit;

检查是否已设置 edit,以及是否正在编辑此确切单元格:

if (edit && edit.row === rowidx && edit.column === columnidx) {
  // ...
}

如果这是目标单元格,让我们创建一个表单和一个带有单元格内容的输入字段:

cell = (
  <form onSubmit={this.save}>
    <input type="text" defaultValue={cell} />
  </form>
);

如您所见,这是一个带有单个输入字段的表单,输入字段的内容预先填充。当提交表单时,提交事件被 save() 方法捕获。

保存

编辑过程的最后一部分是在用户完成输入并提交表单(通过 Enter 键)后保存内容更改:

save(e) {
  e.preventDefault();
  // ... do the save
}

在阻止默认行为(以避免页面重新加载)之后,您需要获取输入字段的引用。事件目标 e.target 是表单,它的第一个子元素就是输入字段:

const input = e.target.firstChild;

克隆数据,以避免直接操作 this.state

const data = clone(this.state.data);

根据 stateedit 属性中存储的新值以及列和行索引,更新给定的数据片段:

data[this.state.edit.row][this.state.edit.column] = input.value;

最后,设置状态,导致 UI 重新渲染:

this.setState({
  edit: null,
  data,
});

现在表格可以编辑了。要查看完整列表,请参阅 03.09.table-editable.html

结论和虚拟 DOM 差异

现在编辑功能已完成。不需要太多的代码。你只需要:

  • 通过 this.state.edit 跟踪要编辑的单元格。

  • 在显示表格时,如果行和列索引与用户双击的单元格匹配,则渲染一个输入字段。

  • 使用输入字段中的新值更新数据数组。

一旦使用新数据调用 setState(),React 就会调用组件的 render() 方法,UI 就会神奇地更新。看起来每次只更改一个单元格的内容可能不会特别高效,因为事实上,React 只更新浏览器 DOM 中的一个单元格。

如果你打开浏览器的开发者工具,可以看到当你与应用程序交互时,DOM 树的哪些部分被更新了。在图 3-9 中,你可以看到开发工具在将《霍比特人》的语言从英语改为精灵语后,突出显示了 DOM 的变化。

在幕后,React 调用您的 render() 方法并创建所需 DOM 结果的轻量级树表示。这被称为虚拟 DOM 树。当再次调用 render() 方法(例如,在调用 setState() 后)时,React 取出前后的虚拟树并计算差异。根据这些差异,React 找出执行所需的最小 DOM 操作(例如 appendChild()textContent 等),将该变化传递到浏览器的 DOM 中。

在图 3-9 中,只需要对单元格进行一个变化,而不必重新渲染整个表格。通过计算最小的变化集并批处理 DOM 操作,React 轻轻地“触碰” DOM,因为已知 DOM 操作慢(与纯 JavaScript 操作、函数调用等比较),通常是丰富的 Web 应用程序渲染性能的瓶颈。

rur2 0309

图 3-9. 突出显示 DOM 变化

当涉及到性能和更新 UI 时,React 会给你提供支持:

  • 轻触 DOM

  • 使用事件委托处理用户交互

搜索

接下来,让我们给 Excel 组件添加一个搜索功能,允许用户过滤表格的内容。这是计划:

  1. 添加一个按钮来切换新功能的开关状态(如图 3-10)。

  2. 如果搜索开启,在每个输入框处添加一行输入框,每个输入框都在相应的列中搜索(如图 3-11)。

  3. 当用户在输入框中输入时,过滤 state.data 数组以仅显示匹配的内容(如图 3-12)。

rur2 0310

图 3-10. 搜索按钮

rur2 0311

图 3-11. 搜索/筛选输入行

rur2 0312

图 3-12. 搜索结果

状态和用户界面

首先要做的是更新构造函数:

  • this.state对象添加一个search属性,以跟踪搜索功能的开关状态

  • 绑定了两个新方法:this.toggleSearch()用于打开和关闭搜索框,以及this.search()用于实际执行搜索。

  • 设置一个新的类属性this.preSearchData

  • 使用连续的 ID 更新传入的初始数据,以帮助标识编辑已过滤数据内容的行

constructor(props) {
  super();
  const data = clone(props.initialData).map((row, idx) => {
    row.push(idx);
    return row;
  });
  this.state = {
    data,
    sortby: null,
    descending: false,
    edit: null, // {row: index, column: index}
    search: false,
  };

  this.preSearchData = null;

  this.sort = this.sort.bind(this);
  this.showEditor = this.showEditor.bind(this);
  this.save = this.save.bind(this);
  this.toggleSearch = this.toggleSearch.bind(this);
  this.search = this.search.bind(this);
}

克隆和更新initialData通过添加一种记录 ID,改变了状态中使用的数据。当编辑已经过滤的数据时,这将非常方便。您使用map()函数映射数据,添加了一个额外的列,即整数 ID。

const data = clone(props.initialData).map((row, idx) =>
  row.concat(idx),
);

因此,this.state.data现在看起来如下:

  [
    'A Tale of Two Cities', ..., 0
  ],
  [
    'Le Petit Prince (The Little Prince)', ..., 1
  ],
  // ...

此更改还需要更改render()方法,即使用此记录 ID 来标识行,无论是查看所有数据还是查看其过滤子集(作为搜索结果):

{this.state.data.map((row, rowidx) => {
  // the last piece of data in a row is the ID
  const recordId = row[row.length - 1];
  return (
    <tr key={recordId} data-row={recordId}>
      {row.map((cell, columnidx) => {
        if (columnidx === this.props.headers.length) {
          // do not show the record ID in the table UI
          return;
        }
        const edit = this.state.edit;
        if (
          edit &&
          edit.row === recordId &&
          edit.column === columnidx
        ) {
          cell = (
            <form onSubmit={this.save}>
              <input type="text" defaultValue={cell} />
            </form>
          );
        }
        return <td key={columnidx}>{cell}</td>;
      })}
    </tr>
  );
})}

接下来是用搜索按钮更新 UI。在此之前,根元素是<table>,现在我们使用一个<div>来包含搜索按钮和相同的表格。

<div>
  <button className="toolbar" onClick={this.toggleSearch}>
    {this.state.search ? 'Hide search' : 'Show search'}
  </button>
  <table>
    {/* ... */}
  </table>
</div>

正如您所见,搜索按钮的标签是动态的,以反映搜索功能的开启或关闭(this.state.searchtruefalse)。

接下来是搜索框的行。您可以将其添加到越来越大的 JSX 块中,或者可以直接组合并添加到常量中,然后包含在主 JSX 中。我们选择第二种方法。如果搜索功能关闭,则不需要渲染任何内容,因此searchRow只是null。否则,将创建一个新的表格行,其中每个单元格都是一个输入元素。

const searchRow = !this.state.search ? null : (
  <tr onChange={this.search}>
    {this.props.headers.map((_, idx) => (
      <td key={idx}>
        <input type="text" data-idx={idx} />
      </td>
    ))}
  </tr>
);
注意

使用(_, idx)是一个约定的示例,其中回调中未使用的变量用下划线_命名,以向代码读者表明它未被使用。

搜索输入框的行只是在主data循环(创建所有表格行和单元格的循环)之前的另一个子节点。在那里包括searchRow

<tbody onDoubleClick={this.showEditor}>
  {searchRow}
  {this.state.data.map((row, rowidx) => (....

到此为止,UI 更新就完成了。现在让我们来看看功能的核心部分,“业务逻辑”,如果您愿意的话:实际的搜索功能。

过滤内容

搜索功能将非常简单:在数据数组上调用Array.prototype.filter()方法,并返回一个匹配搜索字符串的过滤后数组。UI 仍然使用this.state.data进行渲染,但this.state.data已经是其自身的减少版本。

在进行搜索之前,您需要引用数据,以免永久丢失数据。这样用户就可以返回到完整的表格或更改搜索字符串以获取不同的匹配项。我们将这个引用称为this.preSearchData

当用户单击“搜索”按钮时,将调用toggleSearch()函数。此函数的任务是打开和关闭搜索功能。它通过以下方式执行其任务:

  • this.state.search设置为相应的truefalse

  • 启用搜索时,“记住”当前数据

  • 当禁用搜索时,恢复到记住的数据

函数可以编写如下:

toggleSearch() {
  if (this.state.search) {
    this.setState({
      data: this.preSearchData,
      search: false,
    });
    this.preSearchData = null;
  } else {
    this.preSearchData = this.state.data;
    this.setState({
      search: true,
    });
  }
}

最后要做的是实现search()函数,每当搜索行中的内容发生变化时调用该函数,这意味着用户正在输入其中一个输入框。以下是完整的实现方式,随后是一些更多细节:

search(e) {
  const needle = e.target.value.toLowerCase();
  if (!needle) {
    this.setState({data: this.preSearchData});
    return;
  }
  const idx = e.target.dataset.idx;
  const searchdata = this.preSearchData.filter((row) => {
    return row[idx].toString().toLowerCase().indexOf(needle) > -1;
  });
  this.setState({data: searchdata});
}

从更改事件的目标(即输入框)获取搜索字符串。让我们称之为“needle”,因为我们正在一堆数据中寻找针:

const needle = e.target.value.toLowerCase();

如果没有搜索字符串(用户删除了他们输入的内容),该函数会获取原始缓存数据,并将此数据作为新的状态:

if (!needle) {
  this.setState({data: this.preSearchData});
  return;
}

如果存在搜索字符串,则将原始数据进行过滤,并将过滤后的结果设置为数据的新状态:

const idx = e.target.dataset.idx;
const searchdata = this.preSearchData.filter((row) => {
  return row[idx].toString().toLowerCase().indexOf(needle) > -1;
});
this.setState({data: searchdata});

有了这个功能,搜索功能就完成了。要实现这个功能,您需要做的只是:

  • 添加搜索 UI

  • 根据请求显示/隐藏新 UI

  • 实际的“业务逻辑”:一个简单的数组filter()调用

一如既往,您只需关注数据的状态,并让 React 在数据状态更改时进行渲染(以及所有相关的 DOM 工作)。

更新save()方法

以前只需克隆和更新state.data,但现在还有“remembered”preSearchData。如果用户正在编辑(甚至在搜索时),现在这两个数据片段都需要更新。这正是添加记录 ID 的全部原因——这样你就可以在过滤状态下找到真正的行。

更新preSearchData与之前的save()实现方式类似;只需找到行和列。更新状态数据需要额外的步骤,找到正在编辑的行的记录 ID(this.state.edit.row)并与之匹配。

save(e) {
  e.preventDefault();
  const input = e.target.firstChild;
  const data = clone(this.state.data).map((row) => {
    if (row[row.length - 1] === this.state.edit.row) {
      row[this.state.edit.column] = input.value;
    }
    return row;
  });
  this.logSetState({
    edit: null,
    data,
  });
  if (this.preSearchData) {
    this.preSearchData[this.state.edit.row][this.state.edit.column] =
      input.value;
  }
}

在书的存储库中查看03.10.table-search.html以获取完整代码。

您能改进搜索吗?

这只是一个简单的工作示例以进行说明。您能改进这个功能吗?

尝试在多个框中实现增量搜索,过滤已经过滤过的数据。如果用户在语言行中输入“Eng”,然后使用另一个搜索框进行搜索,为什么不仅在前一个搜索的搜索结果中搜索呢?您如何实现这个功能?

即时重播

现在您知道了,您的组件关心它们的状态,并让 React 在适当时渲染和重新渲染。这意味着在给定相同数据(状态和属性)的情况下,应用程序看起来完全相同,无论在这特定数据状态之前还是之后发生了什么变化。这为您提供了一个在实际环境中进行优秀调试的机会。

想象有人在使用你的应用程序时遇到了一个 bug —— 他们可以点击一个按钮报告这个 bug,而不需要解释发生了什么。Bug 报告可以直接发送给你this.statethis.props的副本,你应该能够重新创建确切的应用程序状态并看到视觉结果。

"撤销"功能可能是另一个功能,因为 React 根据相同的 props 和 state 渲染你的应用程序。实际上,"撤销"的实现有点微不足道:你只需回到上一个状态。

让我们进一步发展这个想法,只是为了好玩。让我们记录Excel组件中的每个状态变化,然后回放它。看到所有你的操作在你面前回放是很迷人的。变化发生的时间并不那么重要,所以让我们以 1 秒的间隔“播放”应用状态变化。

要实现这个功能,你需要添加一个logSetState()方法,它首先将新状态记录到this.log数组中,然后调用setState()。代码中所有调用setState()的地方现在都应该改为调用这个新函数。

所有对…

this.setState(...);

…变成:

this.logSetState(...);

现在让我们继续构造函数。你需要绑定两个新函数logSetState()replay(),声明this.log数组,并将初始状态赋给它。

constructor(props) {
  // ...

  // log the initial state
  this.log = [clone(this.state)];

  // ...
  this.replay = this.replay.bind(this);
  this.logSetState = this.logSetState.bind(this);
}

logSetState需要做两件事情:记录新状态然后将其传递给setState()。这里有一个示例实现,你可以对状态进行深拷贝然后将其附加到this.log

logSetState(newState) {
  // remember the old state in a clone
  this.log.push(clone(newState));
  // now set it
  this.setState(newState);
}

现在所有的状态变化都已经被记录下来,让我们回放它们。为了触发回放,让我们添加一个简单的事件监听器,捕获键盘动作并调用replay()函数。像这样的事件监听器应该放在componentDidMount()生命周期方法中:

componentDidMount() {
  document.addEventListener('keydown', e => {
    if (e.altKey && e.shiftKey && e.keyCode === 82) {
      // ALT+SHIFT+R(eplay)
      this.replay();
    }
  });
}

最后,考虑一下replay()方法。它使用setInterval(),每秒读取一次日志中的下一个对象并将其传递给setState()

replay() {
  if (this.log.length === 1) {
    console.warn('No state changes to replay yet');
    return;
  }
  let idx = -1;
  const interval = setInterval(() => {
    if (++idx === this.log.length - 1) {
      // the end
      clearInterval(interval);
    }
    this.setState(this.log[idx]);
  }, 1000);
}

通过这样,新功能完成了(03.11.table-replay.html 在库中)。尝试一下这个组件,排序、编辑... 然后按下 Alt+Shift+R(或在 Mac 上是 Option-Shift-R)来看看过去是如何展开的。

清理事件处理程序

回放功能需要进行一些清理。当这个组件是页面上唯一的活动内容时,清理并不是必需的;在实际应用中,组件更频繁地被添加和移除。当从 DOM 中移除时,“良好的公民”组件应该在自己之后进行清理。在上面的例子中,有两个需要清理的地方:keydown事件监听器和回放间隔的回调。

如果你不清理 keydown 事件监听器函数,它将在组件消失后仍然留在内存中。而且因为它使用了 this,整个 Excel 实例需要保留在内存中。这实际上是一种内存泄漏。如果出现太多这样的情况,用户可能会耗尽内存,你的应用程序可能会崩溃浏览器标签页。至于间隔,回调函数将在组件消失后继续执行,并引起另一个内存泄漏。回调还会尝试在不存在的组件上调用 setState()(React 会通过警告优雅地处理这种情况)。

你可以在重放仍在进行时从 DOM 中移除组件来测试后一种行为。要从 DOM 中移除组件,你可以简单地替换它(例如,在控制台中运行 第一章 的“Hello world”):

ReactDOM.render(
  React.createElement('h1', null, 'Hello world!'),
  document.getElementById('app'),
);

你还可以在间隔回调中向控制台记录时间戳,以查看它是否持续执行。

const interval = setInterval(() => {
  // ...
  console.log(Date.now());
  // ...
}, 1000);

现在,在重播期间替换组件时,你会看到来自 React 的错误,并且间隔回调的时间戳仍在记录,证明回调仍在运行中(如 图 3-13 所示)。

rur2 0313

图 3-13. 内存泄漏实例

同样地,当组件从 DOM 中移除后,你可以通过按下 Alt+Shift+R 键来测试事件监听器的内存泄漏。

清理解决方案

处理这些内存泄漏非常简单。你需要保留对你想要清理的处理程序和间隔/超时的引用。然后在 componentWillUnmount() 中进行清理。

对于事件处理程序,将其作为类方法而不是内联函数:

keydownHandler(e) {
  if (e.altKey && e.shiftKey && e.keyCode === 82) {
    // ALT+SHIFT+R(eplay)
    this.replay();
  }
}

然后 componentDidMount() 变得更简单:

componentDidMount() {
  document.addEventListener('keydown', this.keydownHandler);
}

对于间隔重播 ID,将其作为类属性而不是局部变量:

this.replayID = setInterval(() => {
  if (++idx === this.log.length - 1) {
    // the end
    clearInterval(this.replayID);
  }
  this.setState(this.log[idx]);
}, 1000);

当然,你需要在构造函数中绑定新方法并添加新属性:

constructor(props) {
  // ...
  this.replayID = null;

  // ...
  this.keydownHandler = this.keydownHandler.bind(this);
}

最后,在componentWillUnmount()函数中进行清理:

componentWillUnmount() {
  document.removeEventListener('keydown', this.keydownHandler);
  clearInterval(this.replayID);
}

现在所有的泄漏问题都已解决(参见书中仓库中的 03.12.table-replay-clean.html)。

你能改进回放吗?

关于实现撤销/重做功能怎么样?比如,当用户使用 Alt+Z 键盘快捷键时,你可以回退到状态日志中的上一步,在 Alt+Shift+Z 上则前进。

另一种实现方式?

是否有其他实现回放/撤销功能的方法,而不需要改变所有你的 setState() 调用?也许可以使用适当的生命周期方法(参见 第二章)?你可以自己尝试一下。

下载表格数据

在所有排序、编辑和搜索之后,用户终于对表格中数据的状态感到满意。如果他们能够下载这些数据,即他们所有努力的结果,以便以后使用,那将是很好的。

幸运的是,在 React 中没有比这更简单的事情了。你所需要做的就是获取当前的 this.state.data 并返回它——例如以 JSON 或逗号分隔值(CSV)的格式。

当用户点击“导出 CSV”后,下载名为 data.csv 的文件(请查看浏览器窗口左下角),然后在 Numbers(Mac 上)或 Microsoft Excel(PC 或 Mac 上)中打开,结果见 图 3-14。

rur2 0314

图 3-14. 通过 CSV 导出表格数据到 Numbers

首先要做的是将新选项添加到工具栏(搜索按钮所在位置)。让我们使用一些 HTML 魔法来强制 <a> 链接触发文件下载,因此新的“按钮”必须是通过一些 CSS 伪装为按钮的链接:

<div className="toolbar">
  <button onClick={this.toggleSearch}>
    {this.state.search ? 'Hide search' : 'Show search'}
  </button>
  <a href="data.json" onClick={this.downloadJSON}>
    Export JSON
  </a>
  <a href="data.csv" onClick={this.downloadCSV}>
    Export CSV
  </a>
</div>

正如你所见,你需要 downloadJSON()downloadCSV() 方法。这些方法有一些重复逻辑,因此可以通过一个带有 format 参数的单一 download() 函数来处理。download() 方法的签名可能如下所示:

download(format, ev) {
  // TODO: implement me
}

在构造函数中,你可以像这样绑定该方法两次:

this.downloadJSON = this.download.bind(this, 'json');
this.downloadCSV = this.download.bind(this, 'csv');

所有的 React 工作都已完成。现在来看 download() 函数。导出为 JSON 很简单,但 CSV 需要更多工作。基本上,它只是遍历所有行和每行中的所有单元格,生成一个长字符串。完成后,该函数通过 download 属性和 href Blob(由 window.URL 创建)启动下载:

download(format, ev) {
  const data = clone(this.state.data).map(row => {
    row.pop(); // drop the last column, the recordId
    return row;
  });
  const contents =
    format === 'json'
      ? JSON.stringify(data, null, '  ')
      : data.reduce((result, row) => {
          return (
            result +
            row.reduce((rowcontent, cellcontent, idx) => {
              const cell = cellcontent.replace(/"/g, '""');
              const delimiter = idx < row.length - 1 ? ',' : '';
              return `${rowcontent}"${cellcontent}"${delimiter}`;
            }, '') +
            '\n'
          );
        }, '');

  const URL = window.URL || window.webkitURL;
  const blob = new Blob([contents], {type: 'text/' + format});
  ev.target.href = URL.createObjectURL(blob);
  ev.target.download = 'data.' + format;
}

完整的代码位于仓库中的 03.13.table-download.html

获取数据

在整章中,Excel 组件在同一文件中具有对 data 的访问权。但是如果数据存在于服务器上并需要获取呢?有多种解决方案,你将在本书的后面部分看到更多,但让我们先尝试其中一个最简单的解决方案——在 componentDidMount() 中获取数据。

假设 Excel 组件是以空的 initialData 属性创建的:

ReactDOM.render(
  <Excel headers={headers} initialData={[]} />,
  document.getElementById('app'),
);

组件可以优雅地呈现中间状态,让用户知道数据即将到来。在 render() 方法中,如果数据不存在,可以根据条件呈现不同的表体:

{this.state.data.length === 0 ? (
  <tbody>
    <tr>
      <td colSpan={this.props.headers.length}>
        Loading data...
      </td>
    </tr>
  </tbody>
) : (
  <tbody onDoubleClick={this.showEditor}>
    {/* ... same as before ...*/}
  </tbody>
)}

当等待数据时,用户会看到加载指示器(如 图 3-15 所示),在这种情况下是一个简单的文本,当然你也可以使用动画。

rur2 0315

图 3-15. 等待数据获取

现在让我们获取数据。使用 Fetch API,向服务器发出请求,一旦响应到达,使用新数据更新状态。同时,需要负责添加记录 ID,之前由构造函数处理。更新后的 componentDidMount() 可以如下所示:

componentDidMount() {
  document.addEventListener('keydown', this.keydownHandler);
  fetch('https://www.phpied.com/files/reactbook/table-data.json')
    .then((response) => response.json())
    .then((initialData) => {
      const data = clone(initialData).map((row, idx) => {
        row.push(idx);
        return row;
      });
      this.setState({data});
    });
}

完整的代码位于仓库中的 03.14.table-fetch.html

第四章:功能性 Excel

记得函数组件吗?在第 2 章的某个时候,当 state 出现时,函数组件退出了讨论。现在是时候把它们带回来了。

快速复习:函数 vs 类组件

在其最简单的形式下,类组件只需要一个render()方法。这是您构建 UI 的地方,可以选择使用this.propsthis.state

class Widget extends React.Component {
  render() {
    let ui;
    // fun with this.props and this.state
    return <div>{ui}</div>;
  }
}

在函数组件中,整个组件 就是 函数,UI 是函数返回的内容。当组件构建时,props 将传递给函数:

function Widget(props) {
    let ui;
    // fun with props but where's the state?
    return <div>{ui}</div>;
}

函数组件的实用性在 React v16.8 之后结束了:您只能用它们来创建不维护状态(stateless)的组件。但是随着 v16.8 中 hooks 的增加,现在可以在任何地方使用函数组件。在本章的其余部分中,您将看到如何将 第 3 章 的 Excel 组件实现为函数组件。

渲染数据

第一步是渲染传递给组件的数据(图 4-1)。使用组件的开发人员不需要知道它是类组件还是函数组件。initialDataheaders props 看起来一样。即使是 propTypes 定义也是相同的。

function Excel(props) {
  // implement me...
}

Excel.propTypes = {
  headers: PropTypes.arrayOf(PropTypes.string),
  initialData: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
};

const headers = ['Book', 'Author', 'Language', 'Published', 'Sales'];

const data = [
  [
    'A Tale of Two Cities', 'Charles Dickens', // ...
  ],
  // ...
];

ReactDOM.render(
  <Excel headers={headers} initialData={data} />,
  document.getElementById('app'),
);

实现函数组件的主体主要是复制粘贴类组件的 render() 方法的主体部分:

function Excel({headers, initialData}) {
  return (
    <table>
      <thead>
        <tr>
          {headers.map((title, idx) => (
            <th key={idx}>{title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {initialData.map((row, idx) => (
          <tr key={idx}>
            {row.map((cell, idx) => (
              <td key={idx}>{cell}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

在上面的代码中,您可以看到,可以使用解构语法function Excel({headers, initialData}){}代替function Excel(props){},以节省稍后使用props.headersprops.initialData的输入。

rur2 0401

图 4-1. 在函数组件中渲染表格(见04.01.fn.table.html

状态钩子

要在函数组件中维护 state,您需要 hooks。什么是 hook?它是一个以use*开头的函数,允许您使用各种 React 功能,如管理状态和组件生命周期的工具。您还可以创建自己的 hooks。本章结束时,您将学习如何使用几个内置 hooks 并编写自己的 hooks。

让我们从状态钩子开始。它是一个名为useState()的函数,作为React对象的属性提供(React.useState())。它接受一个值,即状态变量(要管理的数据片段)的初始值,并返回包含两个元素(元组)的数组。第一个元素是状态变量,第二个是用于修改此变量的函数。让我们看一个例子。

在类组件中,在 constructor() 中定义初始值的方法如下:

this.state = {
  data: initialData;
};

稍后,当您想要更改data状态时,可以改为执行以下操作:

this.setState({
  data: newData,
});

在函数组件中,您既定义初始状态,又获得更新器函数:

const [data, setData] = React.useState(initialData);
注意

注意数组解构语法,其中您将useState()返回的数组的两个元素分配给两个变量:datasetData。这是获取两个返回值的更短更干净的方法,与之相反:

const stateArray = React.useState(initialData);
const data = stateArray[0];
const setData = stateArray[1];

对于渲染,您现在可以使用变量data。当您想要更新此变量时,请使用:

setData(newData);

重写使用状态钩子的组件现在可以看起来像这样:

function Excel({headers, initialData}) {
  const [data, setData] = React.useState(initialData);

  return (
    <table>
      <thead>
        <tr>
          {headers.map((title, idx) => (
            <th key={idx}>{title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((row, idx) => (
          <tr key={idx}>
            {row.map((cell, idx) => (
              <td key={idx}>{cell}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

即使这个示例(见04.02.fn.table-state.html)没有使用setData(),您仍然可以看到它如何使用data状态。让我们继续排序表格,这时您将需要改变状态的手段。

对表进行排序

在类组件中,所有各种状态的碎片都放在this.state对象中,这是一个通常包含不相关信息片段的大杂烩。使用状态钩子,您仍然可以做同样的事情,但也可以决定将状态片段保留在不同的变量中。当涉及到对表格进行排序时,表中包含的data是一个信息片段,而辅助的特定于排序的信息是另一个信息片段。换句话说,您可以使用状态钩子任意次数。

function Excel({headers, initialData}) {
  const [data, setData] = React.useState(initialData);
  const [sorting, setSorting] = React.useState({
    column: null,
    descending: false,
  });

  // ....
}

data是您在表格中显示的内容;sorting对象是一个独立的问题。它涉及如何排序(升序或降序)以及按哪一列(标题、作者等)。

现在排序的函数内联在Excel函数内部:

function Excel({headers, initialData}) {

  // ..

  function sort(e) {
    // implement me
  }

  return (
    <table>
      {/* ... */}
    </table>
  );
}

sort()函数确定按哪列(使用其索引)排序以及排序是否降序:

const column = e.target.cellIndex;
const descending = sorting.column === column && !sorting.descending;

然后,它克隆data数组,因为直接修改状态仍然是个坏主意:

const dataCopy = clone(data);
注意

提醒一下,clone()函数仍然是深度复制的快速脏 JSON 编码/解码方式:

function clone(o) {
  return JSON.parse(JSON.stringify(o));
}

实际排序与以前相同:

dataCopy.sort((a, b) => {
  if (a[column] === b[column]) {
    return 0;
  }
  return descending
    ? a[column] < b[column]
      ? 1
      : -1
    : a[column] > b[column]
      ? 1
      : -1;
});

最后,sort()函数需要用新值更新这两个状态片段:

setData(dataCopy);
setSorting({column, descending});

至于排序的事务,就是这样。剩下的只是更新 UI(Excel()函数的返回值),以反映用于排序的列,并处理标题的任何点击:

<thead onClick={sort}>
  <tr>
    {headers.map((title, idx) => {
      if (sorting.column === idx) {
        title += sorting.descending ? ' \u2191' : ' \u2193';
      }
      return <th key={idx}>{title}</th>;
    })}
  </tr>
</thead>

您可以在图 4-2 中看到带有排序箭头的结果。

您可能已经注意到,使用状态钩子的另一个好处是,不需要像在类组件的构造函数中那样绑定任何回调函数。没有this,没有constructor()。一个函数就足以定义一个组件。

rur2 0402

图 4-2. 数据排序(见04.03.fn.table-sort.html

编辑数据

正如您从第三章记得的那样,编辑功能包括以下步骤:

  1. 您双击表格单元格,它会变成文本输入表单。

  2. 您在文本输入表单中输入文字。

  3. 完成后,按 Enter 提交表单。

为了跟踪此过程,请添加一个edit状态对象。当没有编辑时为null;否则,它存储正在编辑的单元格的行和列索引。

const [edit, setEdit] = useState(null);

在 UI 中,你需要处理双击事件 (onDoubleClick={showEditor}),如果用户正在编辑,则显示表单;否则,只显示数据。当用户按下 Enter 键时,你会捕获提交事件 (onSubmit={save})。

<tbody onDoubleClick={showEditor}>
  {data.map((row, rowidx) => (
    <tr key={rowidx} data-row={rowidx}>
      {row.map((cell, columnidx) => {
        if (
          edit &&
          edit.row === rowidx &&
          edit.column === columnidx
        ) {
          cell = (
            <form onSubmit={save}>
              <input type="text" defaultValue={cell} />
            </form>
          );
        }
        return <td key={columnidx}>{cell}</td>;
      })}
    </tr>
  ))}
</tbody>

还有两个短函数需要实现:showEditor()save()

showEditor() 在双击表格主体的单元格时被调用。在这里,你通过 setEdit() 更新 edit 状态,并提供行和列索引,以便渲染知道要用表单替换哪些单元格。

function showEditor(e) {
  setEdit({
    row: parseInt(e.target.parentNode.dataset.row, 10),
    column: e.target.cellIndex,
  });
}

save() 函数捕获表单提交事件,阻止提交,并更新 data 状态为正在编辑的单元格中的新值。它还调用 setEdit() 并传递 null 作为新的编辑状态,表示编辑完成。

function save(e) {
  e.preventDefault();
  const input = e.target.firstChild;
  const dataCopy = clone(data);
  dataCopy[edit.row][edit.column] = input.value;
  setEdit(null);
  setData(dataCopy);
}

有了这些,编辑功能就完成了。请参考书的仓库中的 04.04.fn.table-edit.html 获取完整的代码。

搜索

数据的搜索/过滤在 React 和 hooks 中没有新挑战。你可以尝试自己实现,并在书的仓库中的 04.05.fn.table-search.html 中参考实现。

你需要两个新的状态变量:

  • 布尔值 search 用于表示用户是在过滤还是仅查看数据。

  • data 的拷贝作为 preSearchData,因为现在 data 成为了所有数据的过滤子集。

const [search, setSearch] = useState(false);
const [preSearchData, setPreSearchData] = useState(null);

你需要确保及时更新 preSearchData,因为 data(过滤后的子集)可能在用户编辑时也会更新。作为复习,请参考 第三章。

我们接着实现回放功能,这提供了一个机会去熟悉两个新概念:

  • 使用生命周期 hooks

  • 自编写 hooks

在 Hooks 的世界中的生命周期

第三章 中的回放功能使用了 Excel 类的两个生命周期方法:componentDidMount()componentWillUnmount()

生命周期方法的问题

如果你重新查看 03.14.table-fetch.html 的例子,你可能会注意到每个例子有两个不相关的任务:

componentDidMount() {
  document.addEventListener('keydown', this.keydownHandler);
  fetch('https://www...')
    .then(/*...*/)
    .then((initialData) => {
      /*...*/
      this.setState({data});
    });
}

componentWillUnmount() {
  document.removeEventListener('keydown', this.keydownHandler);
  clearInterval(this.replayID);
}

componentDidMount() 中,你设置了一个 keydown 监听器来启动回放,并从服务器获取数据。在 componentWillUnmount() 中,你移除了 keydown 监听器,并清除了一个 setInterval() 的 ID。这展示了在类组件中使用生命周期方法时遇到的两个问题(这些问题在使用 hooks 时得到解决):

不相关的任务被一起实现了。

例如,在一个地方进行数据获取和设置事件监听器。这导致生命周期方法变得很长,同时执行着不相关的任务。在简单的组件中,这没问题,但在更大的组件中,你需要依靠代码注释或将代码片段移动到不同的函数中,以便分割不相关的任务,使代码更易读。

相关的任务分散开来。

例如,考虑添加和移除相同的事件侦听器。随着生命周期方法的增长,当您以后阅读代码时,很难一眼看到相同关注点的不同部分,因为它们根本不适合在同一屏幕中。

useEffect()

替代上述两个生命周期方法的内置钩子是 React.use​Ef⁠fect()

注意

单词 “effect” 代表 “副作用”,意思是与主任务无关的工作类型,但在大致相同的时间内发生。任何 React 组件的主要任务是根据状态和属性渲染某些内容。但是在同一函数中(在同一功能中)与几个副作业(例如从服务器获取数据或设置事件侦听器)同时进行渲染可能是必要的。

例如,在 Excel 组件中,设置 keydown 处理程序是渲染表格数据主要任务的副作用。

钩子 useEffect() 接受两个参数:

  • React 在适当的时间调用的回调函数

  • 一个可选的 依赖项 数组

依赖项列表包含在回调函数被调用之前将被检查的变量,并决定是否应该调用回调函数。

  • 如果依赖变量的值未更改,则无需调用回调函数。

  • 如果依赖项列表是空数组,则回调函数只会被调用一次,类似于 componentDidMount()

  • 如果省略依赖项,则回调函数在每次重新渲染时都会被调用。

useEffect(() => {
  // logs only if `data` or `headers` have changed
  console.log(Date.now());
}, [data, headers]);

useEffect(() => {
  // logs once, after initial render, like `componentDidMount()`
  console.log(Date.now());
}, []);

useEffect(() => {
  // called on every re-render
  console.log(Date.now());
}, /* no dependencies here */);

清理副作用

现在您知道如何使用钩子来完成类组件中 componentDidMount() 提供的功能。但是如何处理类似 componentWill​Un⁠mount() 的等效操作呢?为此,您可以使用传递给 useEffect() 的回调函数的返回值:

useEffect(() => {
  // logs once, after initial render, like `componentDidMount()`
  console.log(Date.now());
  return () => {
    // log when the component will be removed form the DOM
    // like `componentDidMount()`
    console.log(Date.now());
  };
}, []);

让我们看一个更完整的例子(04.06.useEffect.html 在存储库中):

function Example() {
  useEffect(() => {
    console.log('Rendering <Example/>', Date.now());
    return () => {
      // log when the component will be removed form the DOM
      // like `componentDidMount()`
      console.log('Removing <Example/>', Date.now());
    };
  }, []);
  return <p>I am an example child component.</p>;
}

function ExampleParent() {
  const [visible, setVisible] = useState(false);
  return (
    <div>
      <button onClick={() => setVisible(!visible)}>
        Hello there, press me {visible ? 'again' : ''}
      </button>
      {visible ? <Example /> : null}
    </div>
  );
}

点击按钮一次会渲染一个子组件,再次点击则会将其移除。正如您在 图 4-3 中所见,useEffect() 的返回值(一个函数)会在组件从 DOM 中移除时被调用。

rur2 0403

图 4-3. 使用 useEffect

请注意,当依赖数组为空时,清理(也称为 teardown)函数在组件从 DOM 中移除时被调用。如果依赖数组中有值,则每当依赖值发生变化时都会调用清理函数。

无麻烦的生命周期

如果再次考虑设置和清除事件侦听器的用例,可以像这样实现:

useEffect(() => {
  function keydownHandler() {
    // do things
  }
  document.addEventListener('keydown', keydownHandler);
  return () => {
    document.removeEventListener('keydown', keydownHandler);
  };
}, []);

上述模式解决了以前提到的基于类的生命周期方法的第二个问题——即在组件周围分散相关任务的问题。在这里,您可以看到如何使用钩子允许您在同一个地方拥有处理函数、其设置和其移除。

至于第一个问题(在同一地方处理无关任务),可以通过多个 useEffect 调用来解决,每个调用专注于一个特定任务。类似于可以拥有单独的状态而不是一个杂包对象,您也可以拥有单独的 useEffect 调用,每个调用专注于一个不同的关注点,而不是需要处理所有事情的单个类方法:

function Example() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // fetch() and then call setData()
  });

  useEffect(() => {
    // event handlers
  });

  return <div>{data}</div>;
}

useLayoutEffect()

总结 useEffect() 的讨论,让我们再考虑另一个名为 useLayoutEffect() 的内置钩子。

注意

只有少数几个内置钩子,所以不必担心要记住一长串新的 API。

useLayoutEffect() 的工作原理类似于 useEffect(),唯一的区别在于它在 React 完成渲染所有 DOM 节点之前调用。一般情况下,除非需要测量页面上的某些内容(比如渲染组件的尺寸或更新后的滚动位置),然后根据这些信息重新渲染,否则应该使用 useEffect()。当这些都不需要时,useEffect() 更好,因为它是异步的,并且也告诉代码阅读者 DOM 变化对组件不重要。

因为 useLayoutEffect() 被更早调用,您可以重新计算和重新渲染,用户只会看到最后一次渲染。否则,他们会先看到初始渲染,然后是第二次渲染。根据布局的复杂程度,用户可能会在两次渲染之间感受到闪烁效果。

下一个示例(repo 中的 04.07.useLayoutEffect.html)渲染了一个具有随机单元格宽度的长表格(只是为了使浏览器更难处理)。然后,在效果钩子中设置表格的宽度。

function Example({layout}) {
  if (layout === null) {
    return null;
  }

  if (layout) {
    useLayoutEffect(() => {
      const table = document.getElementsByTagName('table')[0];
      console.log(table.offsetWidth);
      table.width = '250px';
    }, []);
  } else {
    useEffect(() => {
      const table = document.getElementsByTagName('table')[0];
      console.log(table.offsetWidth);
      table.width = '250px';
    }, []);
  }

  return (
    <table>
      <thead>
        <tr>
          <th>Random</th>
        </tr>
      </thead>
      <tbody>
        {Array.from(Array(10000)).map((_, idx) => (
          <tr key={idx}>
            <td width={Math.random() * 800}>{Math.random()}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function ExampleParent() {
  const [layout, setLayout] = useState(null);
  return (
    <div>
      <button onClick={() => setLayout(false)}>useEffect</button>{' '}
      <button onClick={() => setLayout(true)}>useLayoutEffect</button>{' '}
      <button onClick={() => setLayout(null)}>clear</button>
      <Example layout={layout} />
    </div>
  );
}

根据是否触发 useEffect()useLayoutEffect() 路径,当表格从随机值(大约 600 像素)调整为硬编码的 250 像素时,可能会看到闪烁效果(见图 4-4)。

rur2 0404

图 4-4. 闪烁重新渲染的示意图

注意,无论哪种情况,您都可以获取表格的几何信息(例如 table.offsetWidth),因此如果仅出于信息目的需要此信息并且不打算重新渲染,则使用异步的 useEffect() 会更好。useLayoutEffect() 应该保留用于避免闪烁,例如在需要基于测量的内容执行(重新渲染)时,例如根据所指向的元素大小定位一个花哨工具提示组件。

自定义钩子

让我们回到 Excel,看看如何实现回放功能。对于类组件,需要创建 logSetState() 并将所有 this.setState() 调用替换为 this.logSetState()。对于函数组件,可以将所有 useState() 钩子调用替换为 useLoggedState()。这样做更加方便,因为调用次数较少(每个独立的状态位点)且都位于函数顶部。

// before
function Excel({headers, initialData}) {
  const [data, setData] = useState(initialData);
  const [edit, setEdit] = useState(null);
  // ... etc
}

// after
function Excel({headers, initialData}) {
  const [data, setData] = useLoggedState(initialData, true);
  const [edit, setEdit] = useLoggedState(null);
  // ... etc
}

没有内置的 useLoggedState() 钩子,但没关系。你可以创建自己的自定义钩子。像内置钩子一样,自定义钩子只是以 use*() 开头的函数。这里是一个例子:

function useLoggedState(initialValue, isData) {
  // ...
}

钩子的签名可以是任何你想要的。在这种情况下,有一个额外的 isData 参数。它的目的是帮助区分数据状态与非数据状态。在第三章的类组件示例中,所有状态都是一个单一对象,但这里存在多个状态片段。在回放功能中,主要目标是显示数据变化,然后显示所有辅助信息(排序、降序等)是次要的。由于每秒更新一次回放,逐个观察支持数据变化将不那么有趣;回放将会太慢。因此,我们有一个主要日志(dataLog 数组)和一个辅助日志(auxLog 数组)。此外,包含一个标志指示状态是因用户交互(自动)还是在回放过程中变化,也是很有用的:

let dataLog = [];
let auxLog = [];
let isReplaying = false;

自定义钩子的目标不是干预常规状态更新,因此将此责任委托给原始的 useState。目标是在回放期间记录状态,并提供一个知道如何在回放期间更新此状态的函数的引用。这个函数看起来像这样:

function useLoggedState(initialValue, isData) {
  const [state, setState] = useState(initialValue);

  // fun here...

  return [state, setState];
}

上面的代码正在使用默认的 useState。但现在你有了一个状态片段的引用和更新它的方法。你需要记录下来。让我们在这里利用 useEffect() 钩子的好处:

function useLoggedState(initialValue, isData) {
  const [state, setState] = useState(initialValue);

  useEffect(() => {
    // todo
  }, [state]);

  return [state, setState];
}

此方法确保仅在 state 的值变化时才记录日志。useLoggedState() 函数可能在各种重新渲染过程中被多次调用,但除非涉及感兴趣的状态变化,否则可以忽略这些调用。

useEffect() 的回调函数中:

  • 如果用户正在回放,则不执行任何操作。

  • 将每个数据状态的变化记录到 dataLog 中。

  • 将每个支持数据的变化记录到 auxLog 中,按关联的数据变化索引。

useEffect(() => {
  if (isReplaying) {
    return;
  }
  if (isData) {
    dataLog.push([clone(state), setState]);
  } else {
    const idx = dataLog.length - 1;
    if (!auxLog[idx]) {
      auxLog[idx] = [];
    }
    auxLog[idx].push([state, setState]);
  }
}, [state]);

为什么存在自定义钩子?它们帮助你隔离并整洁打包在组件中使用的逻辑片段,通常在组件之间共享。上面的自定义 useLoggedState() 可以嵌入到任何可以从记录其状态中受益的组件中。此外,自定义钩子可以调用其他钩子,而常规(非钩子和非组件)函数无法做到这一点。

总结回放

现在你有了一个能够记录各种状态变化的自定义钩子,是时候接入回放功能了。

replay() 函数在 React 讨论中并不是一个激动人心的部分,但它设置了一个间隔 ID。当 Excel 在回放过程中从 DOM 中移除时,你需要这个 ID 来清理间隔。在回放过程中,数据变化每秒钟重播一次,而辅助信息则一起刷新:

function replay() {
  isReplaying = true;
  let idx = 0;
  replayID = setInterval(() => {
    const [data, fn] = dataLog[idx];
    fn(data);
    auxLog[idx] &&
      auxLog[idx].forEach((log) => {
        const [data, fn] = log;
        fn(data);
      });
    idx++;
    if (idx > dataLog.length - 1) {
      isReplaying = false;
      clearInterval(replayID);
      return;
    }
  }, 1000);
}

最后一点是设置一个效果挂钩。在Excel渲染后,挂钩负责设置监听器,监视特定组合的键来开始回放展示。这也是在组件销毁后进行清理的地方。

useEffect(() => {
  function keydownHandler(e) {
    if (e.altKey && e.shiftKey && e.keyCode === 82) {
      // ALT+SHIFT+R(eplay)
      replay();
    }
  }
  document.addEventListener('keydown', keydownHandler);
  return () => {
    document.removeEventListener('keydown', keydownHandler);
    clearInterval(replayID);
    dataLog = [];
    auxLog = [];
  };
}, []);

要查看完整的代码,请访问书籍存储库中的04.08.fn.table-replay.html

useReducer

让我们用一个名为useReducer()的内置钩子结束本章。使用减速器是替代useState()的一种选择。不同部分的组件调用变更状态的方式,可以在单个位置处理所有更改。

减速器只是一个 JavaScript 函数,接受两个输入——旧状态和动作——并返回新状态。将动作视为应用程序中发生的事件,例如点击、数据获取或超时。某些变量(新状态、旧状态、动作)可以是任何类型,尽管它们通常是对象。

减速器函数

减速器函数在其最简单形式如下所示:

function myReducer(oldState, action) {
  const newState = {};
  // do something with `oldState` and `action`
  return newState;
}

想象一下,当世界发生某事时,减速器函数负责理清现实。世界是一团混乱,然后发生了一个事件。应该makeSense()的函数调和了混乱和新事件,并将所有复杂性减少为一个良好的状态或秩序

function makeSense(mess, event) {
  const order = {};
  // do something with mess and event
  return order;
}

另一个类比来自烹饪世界。一些酱料和汤也被称为还原物,通过还原过程(浓缩,增强风味)产生。初始状态是一锅水,然后各种操作(沸腾,添加成分,搅拌)通过每个动作改变了锅内内容的状态。

动作

减速器函数可以采取任何内容(字符串、对象),但常见的实现是具有event对象的形式:

  • 一个类型(例如 DOM 世界中的click

  • 可选地,一些payload或关于事件的其他信息

然后“调度”动作。调度动作时,React 将使用当前状态和您的新事件(动作)调用适当的减速器函数。

使用useState时:

const [data, setData] = useState(initialData);

可以用减速器替换:

const [data, dispatch] = useReducer(myReducer, initialData);

data仍然以相同方式用于渲染组件。但是当发生某事时,不再进行少量工作后调用setData(),而是调用useReducer()返回的dispatch()函数。从那里,减速器接管并返回新版本的data。没有其他函数调用来设置新状态;React 使用新的data重新渲染组件。

图 4-5 展示了此过程的图表。

rur2 0405

图 4-5. 组件-调度-动作-减速器流

一个示例减速器

让我们看一个快速、独立的使用 reducer 的例子。假设你有一个随机数据表,还有可以刷新数据或将表的背景和前景颜色更改为随机颜色的按钮(如图 4-6 所示)。

最初,没有数据,并且黑白色是默认值:

const initialState = {data: [], color: 'black', background: 'white'};

reducer 在组件<RandomData>的顶部初始化:

function RandomData() {
  const [state, dispatch] = useReducer(myReducer, initialState);
  // ...
}

在这里,state又回到了一个各种状态片段的抓包对象(但情况并非一定如此)。组件的其余部分按常规操作,根据state进行渲染,有一个区别。之前你可能会有一个按钮的onClick处理程序是一个更新状态的函数,现在所有处理程序只调用dispatch(),发送有关事件的信息:

return (
  <div>
    <div className="toolbar">
      <button onClick={() => dispatch({type: 'newdata'})}>
        Get data
      </button>{' '}
      <button
        onClick={() => dispatch({type: 'recolor', payload: {what: 'color'}})}>
        Recolor text
      </button>{' '}
      <button
        onClick={
          () => dispatch({type: 'recolor', payload: {what: 'background'}})
      }>
        Recolor background
      </button>
    </div>
    <table style={{color, background}}>
      <tbody>
        {data.map((row, idx) => (
          <tr key={idx}>
            {row.map((cell, idx) => (
              <td key={idx}>{cell}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  </div>
);

rur2 0406

图 4-6. <RandomData/>组件(04.09.random-table-reducer.html

每个分派的事件/动作对象都有一个type属性,因此 reducer 函数可以确定需要执行什么操作。可能会有一个指定事件进一步细节的payload,也可能没有。

最后是 reducer。它有许多 if/else 语句(或者如果你喜欢,可以使用 switch 语句),用于检查发送的事件类型。然后根据操作来操作数据,并返回状态的新版本:

function myReducer(oldState, action) {
  const newState = clone(oldState);

  if (action.type === 'recolor') {
    newState[action.payload.what] =
      `rgb(${rand(256)},${rand(256)},${rand(256)})`;
  } else if (action.type === 'newdata') {
    const data = [];
    for (let i = 0; i < 10; i++) {
      data[i] = [];
      for (let j = 0; j < 10; j++) {
        data[i][j] = rand(10000);
      }
    }
    newState.data = data;
  }
  return newState;
}

// couple of helpers
function clone(o) {
  return JSON.parse(JSON.stringify(o));
}
function rand(max) {
  return Math.floor(Math.random() * max);
}

注意如何使用你已经知道的快速脏克隆clone()来克隆旧状态。在使用useState()/setState()时,在许多情况下这并不是严格必要的。你通常可以通过修改现有变量并将其传递给setState()来解决问题。但是在这里,如果你不克隆,仅修改内存中的同一对象,React 将看到旧状态和新状态指向同一个对象,并将跳过渲染,认为什么也没变。你可以自己试试:删除对clone()的调用,观察到重新渲染未发生。

单元测试 Reducers

切换到useReducer()来进行状态管理,可以更轻松地编写单元测试。你不需要设置组件及其属性和状态。你不需要涉及浏览器或找到另一种模拟点击事件的方式。你甚至不需要完全依赖 React。要测试状态逻辑,你只需将旧状态和一个动作传递给 reducer 函数,并检查是否返回了期望的新状态。这是纯 JavaScript:输入两个对象,输出一个对象。单元测试不应比测试典型示例更复杂:

function add(a, b) {
  return a + b;
}

书中稍后会讨论测试的问题,但为了让你尝试一下,一个示例测试可能是这样的:

const initialState = {data: [], color: 'black', background: 'white'};

it('produces a 10x10 array', () => {
  const {data} = myReducer(initialState, {type: 'newdata'});
  expect(data.length).toEqual(10);
  expect(data[0].length).toEqual(10);
});

带有 Reducer 的 Excel 组件

最后一个使用 reducer 的例子,让我们看看如何在Excel组件中从useState()切换到useReducer()

在上一节的示例中,由 reducer 管理的状态再次是一组不相关的数据对象。不必这样做。您可以有多个 reducer 来分离您的关注点。甚至可以混合和匹配useState()useReducer()。让我们尝试用Excel

以前表格中的datauseState()管理:

const [data, setData] = useState(initialData);
// ...
const [edit, setEdit] = useState(null);
const [search, setSearch] = useState(false);

切换到使用useReducer()管理data,同时保持其余部分不变如下所示:

const [data, dispatch] = useReducer(reducer, initialData);
// ...
const [edit, setEdit] = useState(null);
const [search, setSearch] = useState(false);

由于data相同,渲染部分无需更改任何内容。只需在操作处理程序中进行更改。例如,使用filter()进行过滤并调用setData()

function filter(e) {
  const needle = e.target.value.toLowerCase();
  if (!needle) {
    setData(preSearchData);
    return;
  }
  const idx = e.target.dataset.idx;
  const searchdata = preSearchData.filter((row) => {
    return row[idx].toString().toLowerCase().indexOf(needle) > -1;
  });
  setData(searchdata);
}

重写的版本改为分派动作。事件具有“search”类型和一些额外的有效载荷(用户搜索什么以及在哪里?):

function filter(e) {
  const needle = e.target.value;
  const column = e.target.dataset.idx;
  dispatch({
    type: 'search',
    payload: {needle, column},
  });
  setEdit(null);
}

另一个例子是切换搜索字段:

// before
function toggleSearch() {
  if (search) {
    setData(preSearchData);
    setSearch(false);
    setPreSearchData(null);
  } else {
    setPreSearchData(data);
    setSearch(true);
  }
}

// after
function toggleSearch() {
  if (!search) {
    dispatch({type: 'startSearching'});
  } else {
    dispatch({type: 'doneSearching'});
  }
  setSearch(!search);
}

在这里,您可以看到setSearch()dispatch()的混合使用来管理状态。!search切换是 UI 显示或隐藏输入框的标志,而dispatch()用于管理数据。

最后,让我们看看reducer()函数。这是现在发生所有数据过滤和操作的地方。它再次是一系列if/else块,每个块处理不同的操作类型:

let originalData = null;

function reducer(data, action) {
  if (action.type === 'sort') {
    const {column, descending} = action.payload;
    return clone(data).sort((a, b) => {
      if (a[column] === b[column]) {
        return 0;
      }
      return descending
        ? a[column] < b[column]
          ? 1
          : -1
        : a[column] > b[column]
          ? 1
          : -1;
    });
  }
  if (action.type === 'save') {
    data[action.payload.edit.row][action.payload.edit.column] =
      action.payload.value;
    return data;
  }
  if (action.type === 'startSearching') {
    originalData = data;
    return originalData;
  }
  if (action.type === 'doneSearching') {
    return originalData;
  }
  if (action.type === 'search') {
    return originalData.filter((row) => {
      return (
        row[action.payload.column]
          .toString()
          .toLowerCase()
          .indexOf(action.payload.needle.toLowerCase()) > -1
      );
    });
  }
}

第五章:JSX

在前几章中,你已经看到 JSX 的运行效果。你知道它完全是关于编写包含 XML 的 JavaScript 表达式,看起来非常像 HTML。例如:

const hi = <h1>Hello</h1>;

并且你知道你总是可以通过包含更多用花括号括起来的 JavaScript 表达式来“打断” XML 的流程:

const planet = 'Earth';
const hi = <h1>Hello people of <em>{planet}</em>!</h1>;

就算这些表达式恰好是条件、循环或更多的 JSX:

const rock = 3;
const planet = <em>{rock === 3 ? 'Earth' : 'Some other place'}</em>;
const hi = <h1>Hello people of {planet}!</h1>;

在本章中,你将学习更多关于 JSX 的知识,并探索一些可能令你惊喜和/或喜爱的功能。

要查看上述示例的实际效果,请从本书的仓库加载 05.01.hellojsx.html。该文件还说明了如何在同一页上拥有多个 React 应用程序。

几个工具

要尝试并熟悉 JSX 转换,你可以在 https://babeljs.io/repl 上使用实时编辑器(显示在 图 5-1)。确保选中 “Prettify” 选项以获得更好的结果可读性。

rur2 0501

图 5-1. Babel 作为一个实时 JSX 转换工具

如你在 图 5-2 中所见,JSX 转换是轻量且简单的:来自 第一章 的 “Hello world!” 的 JSX 源代码成为了一系列对 React.createElement() 的调用,使用了 React 工作的函数语法。它只是 JavaScript,所以很容易阅读和理解。

rur2 0502

图 5-2. “Hello World” 转换

当你学习 JSX 或者将现有应用程序的标记从 HTML 过渡到 JSX 时,另一个在线工具可能会对你有帮助,即 HTML 到 JSX 编译器(显示在 图 5-3)。

rur2 0503

图 5-3. HTML 到 JSX 工具

现在,让我们来看一些 JSX 的特殊之处。

JSX 中的空白

JSX 中的空白与 HTML 类似但并非完全相同。例如,如果你有以下的 JSX:

function Example1() {
  return (
    <h1>
      {1} plus {2} is   {3}
    </h1>
  );
}

当 React 在浏览器中渲染它时(你可以在浏览器的开发工具中检查生成的 HTML),生成的 HTML 如下所示:

<h1>1 plus 2 is   3</h1>

这实际上是一个具有五个子节点的 h1 DOM 节点,这些子节点是文本元素节点,内容分别是:“1”,“plus”,“2”,“is” 和 “3”,在浏览器中渲染为 “1 plus 2 is 3。” 正如在 HTML 中所预期的那样,多个空格在浏览器中渲染时会变成一个,如 图 5-4 所示。

rur2 0504

图 5-4. 渲染空白(参见仓库中的 05.02.whitespace.html

然而,在下一个例子中:

function Example2() {
  return (
    <h1>
      {1}
      plus
      {2}
      is
      {3}
    </h1>
  );
}

…你最终会得到:

<h1>
  1plus2is3
</h1>

正如你所见,所有的空白都被修剪掉了,所以在浏览器中显示的最终结果是 “1plus2is3。” 你可以随时在需要的地方添加 {' '} 来增加空格,或者将字面字符串转换为表达式并在那里添加空格。换句话说,这些都可以工作:

function Example3() {
  return (
    <h1>
      {/* space expressions */}
      {1}
      {' '}plus{' '}
      {2}
      {' '}is{' '}
      {3}
    </h1>
  );
}
function Example4() {
  return (
    <h1>
      {/* space glued to string expressions */}
      {1}
      {' plus '}
      {2}
      {' is '}
      {3}
    </h1>
  );
}

JSX 中的注释

在前面的例子中,你看到一个新概念悄悄进入了——在 JSX 标记中添加注释。

因为用 {// comment} 作为单行注释不起作用(} 现在被注释掉了),使用单行注释几乎没有好处。您可以保持评论的一致性,并在所有情况下坚持使用多行注释。

<h1>
  {/* multiline comment */}
  {/*
 multi
 line
 comment
 */}
  {
    // single line
  }
  Hello!
</h1>

因为 {// comment} 不起作用(} 现在被注释掉了),使用单行注释几乎没有好处。您可以保持您的评论一致,并在所有情况下坚持使用多行注释。

HTML 实体(HTML Entities)

您可以在 JSX 中像这样使用 HTML 实体:

<h2>
  More info &raquo;
</h2>

此示例生成一个“双右角引号”,如图 5-5 所示。

rur2 0505

图 5-5. JSX 中的 HTML 实体

但是,如果您将实体用作表达式的一部分,将会遇到双重编码问题。在此示例中,HTML 被编码:

<h2>
  {"More info &raquo;"}
</h2>

您可以在图 5-6 中看到结果。

rur2 0506

图 5-6. 双重编码的 HTML 实体

为了防止双重编码,您可以使用 HTML 实体的 Unicode 版本,例如 \u00bb(请参阅https://dev.w3.org/html5/html-author/charref):

<h2>
  {"More info \u00bb"}
</h2>

为方便起见,您可以在模块顶部的某处定义一个常量,以及任何常见的间距。例如:

const RAQUO = ' \u00bb';

然后您可以在需要的任何地方使用该常量,例如:

<h2>
  {"More info" + RAQUO}
</h2>
<h2>
  {"More info"}{RAQUO}
</h2>

反跨站脚本攻击(Anti-XSS)

您可能会想知道为什么您必须绕过许多障碍才能使用 HTML 实体。有一个重要的原因超过了缺点:您需要对抗跨站脚本攻击(XSS)。

React 会转义所有字符串以防止一类 XSS 攻击。因此,当您要求用户提供输入并且他们提供了一个恶意字符串时,React 会保护您。例如,接受用户输入:

const firstname =
  'John<scr'+'ipt src="https://evil/co.js"></scr'+'ipt>';

在某些情况下,您可能会将此写入 DOM 中。例如:

document.write(firstname);

这是一个灾难,因为页面上显示“John”,但<script>标签加载了一个潜在恶意的第三方网站的 JavaScript,很可能由犯罪分子拥有。这会危及您的应用程序,进而危害信任您的用户。

React 在开箱即用的情况下为您提供了保护机制。当您执行以下操作时,React 会转义firstname的内容(如图 5-7 所示):

function Example() {
  const firstname =
    'John<scr' + 'ipt src="https://evil/co.js"></scr' + 'ipt>';
  return <h2>Hello {firstname}!</h2>;
}

rur2 0507

图 5-7. 转义字符串(请参阅05.05.antixss.html在代码库中)

扩展属性(Spread Attributes)

JSX 从 ECMAScript 借用了一项称为扩展运算符的功能,并将其作为定义属性的便利方法采纳。

假设您有一组要传递给<a>组件的属性:

const attr = {
  href: 'https://example.org',
  target: '_blank',
};

您始终可以这样做:

return (
  <a
    href={attr.href}
    target={attr.target}>
    Hello
  </a>
);

但这感觉像是大量的样板代码。通过使用扩展属性,您可以在一行中完成此操作:

return <a {...attr}>Hello</a>;

在上面的示例中(参见仓库中的 05.06.spread.html),您有一个要预先定义的属性对象,也许其中一些是有条件的。展开属性的另一个常见用法是当您从外部获取这个属性对象时——通常是从父组件获取。让我们看看这种情况是如何发生的。

父到子的展开属性

假设您正在构建一个 FancyLink 组件,它在幕后使用常规的 <a>。您希望您的组件接受所有 <a> 所具有的属性(hreftargetrel 等)以及一些不属于 HTML 标准的属性(例如 variant)。人们可以像这样使用您的组件:

<FancyLink
  href="https://example.org"
  rel="canonical"
  target="_blank"
  variant="small">
  Follow me
</FancyLink>

您的组件如何利用展开属性并避免重新定义所有 <a> 的属性?

下面是一种方法,您的应用程序可能仅允许链接有 3 种尺寸,并允许组件的用户通过自定义 variant 属性指定所需的尺寸。您可以借助 switch 语句和 CSS 类来实现大小调整。然后将所有其他属性传递给 <a>

function FancyLink(props) {
  const classes = ['link-core'];
  switch (props.variant) {
    case 'small':
      classes.push('link-small');
      break;
    case 'huge':
      classes.push('link-huge');
      break;
    default:
      classes.push('link-default');
  }

  return (
    <a {...props} className={classes.join(' ')}>
      {props.children}
    </a>
  );
}
注意

您注意到使用了 props.children 吗?这是允许传递任意数量子元素到您的组件中的便捷方式,您可以在组合 UI 时访问这些子元素。对于 FancyLink 组件,以下内容是完全有效的:

<FancyLink>
  <span>Follow me</span>
</FancyLink>

在上述代码片段中,您可以根据 variant 属性的值进行自定义处理,然后将所有属性简单地传递给 <a>。这包括 variant 属性,它将出现在生成的 DOM 中,尽管浏览器对其无用。

您可以更好地避免传递不必要的属性,通过克隆传递给您的 props,并删除浏览器无法忽略的属性。像这样:

function FancyLink(props) {
  const classes = ['link-core'];
  switch (props.variant) {
    // same as before...
  }

  const attribs = Object.assign({}, props); // shallow clone
  delete attribs.variant;

  return (
    <a {...attribs} className={classes.join(' ')}>
      {props.children}
    </a>
  );
}

另一种浅克隆的方法是使用 JavaScript 展开运算符:

const attribs = {...props};

此外,您可以仅克隆您将传递给浏览器的 props,并同时将其他 props 分配给本地变量(从而无需后续删除),这一切可以在一行内完成:

const {variant, ...attribs} = props;

因此,FancyLink 的最终结果可能如下所示(参见仓库中的 05.07.fancylink.html):

function FancyLink(props) {
  const {variant, ...attribs} = props;
  const classes = ['link-core'];
  switch (variant) {
    // same as before...
  }

  return (
    <a {...attribs} className={classes.join(' ')}>
      {props.children}
    </a>
  );
}

在 JSX 中返回多个节点

您总是需要从渲染函数返回单个节点(或数组)。不允许返回两个节点。换句话说,以下内容是错误的:

// Syntax error:
//   Adjacent JSX elements must be wrapped in an enclosing tag
function InvalidExample() {
  return (
    <span>
      Hello
    </span>
    <span>
      World
    </span>
  );
}

一个包装器

修复方法很简单——只需将所有节点包装在另一个组件中,比如 <div>(同时在 "Hello" 和 "World" 之间添加一个空格):

function Example() {
  return (
    <div>
      <span>Hello</span>
      {' '}
      <span>World</span>
    </div>
  );
}

一个片段

为了消除额外的包装器元素的需要,React 的更新版本添加了 fragments,它们是包装器,在组件渲染时不会添加额外的 DOM 节点。

function FragmentExample() {
  return (
    <React.Fragment>
      <span>Hello</span>
      {' '}
      <span>World</span>
    </React.Fragment>
  );
}

此外,可以省略 React.Fragment 部分,这些空元素也可以工作:

function FragmentExample() {
  return (
    <>
      <span>Hello</span>
      {' '}
      <span>World</span>
    </>
  );
}
注意

此时,浏览器版本的 Babel 不支持 <></> 语法,您需要明确写出 React.Frag⁠ment

一个数组

另一个选项是返回一个数组的节点,只要数组中的节点有适当的 key 属性即可。请注意,在数组的每个元素后面都需要逗号。

function ArrayExample() {
  return [
    <span key="a">Hello</span>,
    ' ',
    <span key="b">World</span>,
    '!'
  ];
}

正如您所见,您还可以在数组中插入空格和其他字符串,而这些不需要 key。在某种程度上,这类似于从父组件传递任意数量的子节点并在渲染函数中传播它们:

function ChildrenExample(props) {
  console.log(props.children.length); // 4
  return (
    <div>
      {props.children}
    </div>
  );
}

ReactDOM.render(
  <ChildrenExample>
    <span key="greet">Hello</span>
    {' '}
    <span key="world">World</span>
    !
  </ChildrenExample>,
  document.getElementById('app')
);

JSX 和 HTML 的区别

JSX 应该看起来很熟悉 —— 它就像 HTML,只是更严格,因为它是 XML。它还有额外的好处,可以轻松添加动态值、循环和条件(只需用 {} 包裹它们)。

要开始使用 JSX,您可以始终使用 HTML-to-JSX 编译器,但是越早开始键入您自己的 JSX,效果越好。让我们考虑一下可能会让您感到惊讶的几个 HTML 和 JSX 之间的区别。

一些这些差异在前面的章节中已经提到过,但让我们快速回顾一下它们。

没有班级,为什么?

而不是 classfor 属性(在 ECMAScript 中都是保留字),您需要使用 classNamehtmlFor

// Warning: Invalid DOM property `class`. Did you mean `className`?
// Warning: Invalid DOM property `for`. Did you mean `htmlFor`?
const em = <em class="important" />;
const label = <label for="thatInput" />;

// OK
const em = <em className="important" />;
const label = <label htmlFor="thatInput" />;

样式是一个对象

style 属性接受一个对象值,而不是常规 HTML 中用分号分隔的字符串。CSS 属性的名称使用驼峰命名法,而不是破折号分隔:

// Error: The `style` prop expects a mapping from style properties to values
function InvalidStyle() {
  return <em style="font-size: 2em; line-height: 1.6" />;
}

// OK
function ValidStyle() {
  const styles = {
    fontSize: '2em',
    lineHeight: '1.6',
  };
  return <em style={styles}>Valid style</em>;
}

// inline is also OK
// note the double curly braces:
// one for the dynamic value in JSX, one for the JS object
function InlineStyle() {
  return (
    <em style={{fontSize: '2em', lineHeight: '1.6'}}>Inline style</em>
  );
}

关闭标签

在 HTML 中,有些标签不需要关闭;在 JSX(XML)中,所有标签都需要关闭:

// NO-NO
// no unclosed tags, even though they are fine in HTML
const gimmeabreak = <br>;
const list = <ul><li>item</ul>;
const meta = <meta charset="utf-8">;

// OK
const gimmeabreak = <br />;
const list = <ul><li>item</li></ul>;
const meta = <meta charSet="utf-8" />;

// or
const meta = <meta charSet="utf-8"></meta>;

驼峰命名法属性

您是否注意到前面片段中的 charsetcharSet?JSX 中的所有属性都需要使用驼峰命名法。这是刚开始时常见的混淆来源 —— 您可能会输入 onclick,但直到将其改为 onClick 才注意到没有任何操作:

// Warning: Invalid event handler property `onclick`. Did you mean `onClick`?
const a = <a onclick="reticulateSplines()" />;

// OK
const a = <a onClick={reticulateSplines} />;

例外的规则是所有 data-aria- 前缀的属性,JSX 不要求使用驼峰命名法。

命名空间组件

有时您可能希望有一个返回多个组件的单个对象。这可以用于实现所谓的命名空间,其中库的所有组件具有相同的前缀。例如,Library 对象可以包含 ReaderBook 组件:

const Library = {
  Book({id}) {
    return `Book ${id}`;
  },
  Reader({id}) {
    return `Reader ${id}`;
  },
};

然后可以使用点表示法来引用这些:

<Library.Reader id={456} /> is reading <Library.Book id={123} />

JSX 和表单

在处理表单时,JSX 和 HTML 有一些区别。让我们来看看。

onChange 处理程序

在使用表单元素时,用户与其交互时会更改输入元素的值。在 React 中,您可以通过 onChange 属性订阅此类更改。这比在纯 DOM 中处理各种表单元素更方便。当在 textarea<input type="text"> 字段中键入时,onChange 在用户键入时触发,这比在元素失去焦点时触发更易于处理。这意味着不再需要订阅各种鼠标和键盘事件来监视键入更改。

考虑一个包含文本输入框和两个单选按钮的表单示例。更改处理程序简单地记录更改发生的位置及其元素的新值。正如你所见,你还可以有一个处理整个表单的更改的总体表单处理程序。如果希望在一个中心位置处理所有表单的更改,可以使用此功能。

function changeHandler(which, event) {
  console.log(
    `onChange called on the ${which} with value "${event.target.value}"`,
  );
}

function ExampleForm() {
  return (
    <form onChange={changeHandler.bind(null, 'form')}>
      <label>
        Type here:
        <br />
        <input type="text" onChange={changeHandler.bind(null, 'text input')} />
      </label>
      <div>Make your pick:</div>
      <label>
        <input
          type="radio"
          name="pick"
          value="option1"
          onChange={changeHandler.bind(null, 'radio 1')}
        />
        Option 1
      </label>
      <label>
        <input
          type="radio"
          name="pick"
          value="option2"
          onChange={changeHandler.bind(null, 'radio 2')}
        />
        Option 2
      </label>
    </form>
  );
}

你可以在书籍仓库中的 05.11.forms.onchange.html 示例中实时测试。当你在文本输入框中输入 **x** 时,更改处理程序将调用两次,因为它一次分配给输入框,一次分配给表单。在控制台中,你将看到:

onChange called on the text input with value "x"
onChange called on the form with value "x"

对于单选按钮也是如此。点击“选项 1”会在控制台中记录:

onChange called on the radio 1 with value "option1"
onChange called on the form with value "option1"

value 与 defaultValue

在 HTML 中,如果你有 <input id="i" value="hello" />,然后通过输入“bye”更改值,你会得到:

i.value; // "bye"
i.getAttribute('value'); // "hello"

在 React 中,value 属性(通过事件处理程序中的 event.target.value 访问)始终具有文本输入的最新内容。如果你想指定初始默认值,可以使用 defaultValue 属性。

在下面的片段中,你有一个带有预填充内容“hello”和 onChange 处理程序的 <input> 组件。在“hello”末尾添加“!”后,value 是 “hello!”,而 defaultValue 保持为 “hello”(请参见书籍仓库中的 05.12.forms.value.html):

function changeHandler({target}) {
  console.log('value: ', target.value);
  console.log('defaultValue: ', target.defaultValue);
}

function ExampleForm() {
  return (
    <form>
      <label>
        Type here: <input defaultValue="hello" onChange={changeHandler} />
      </label>
    </form>
  );
}

<textarea> 的值

为了与文本输入保持一致,React 的 <textarea> 版本也接受 defaultValue 属性。它保持 target.value 的最新状态,而 defaultValue 保持原始值不变。如果你按照 HTML 的方式,并使用 textarea 的子元素定义值(不推荐,React 会给出警告),它将被视为 defaultValue

HTML <textarea>(由 W3C 定义)接受一个子元素作为其值的原因是,开发人员可以在输入中使用换行符。但是 React 作为全 JavaScript,不受此限制。当你需要换行时,只需使用 \n

function ExampleTextarea() {
  return (
    <form>
      <label>
        Type here:{' '}
        <textarea
          defaultValue={'hello\nworld'}
          onChange={changeHandler}
        />{' '}
      </label>
    </form>
  );
}

注意,你需要使用 JavaScript 字面量 {'hello\nworld'}。否则,如果使用字面字符串属性值(例如 defaultValue="hello\nworld"),你无法访问 \n 的特殊换行含义。

<select> 的值

当你在 HTML 中使用 <select> 输入时,可以通过 <option selected> 指定预选条目,如下所示:

<!-- old school HTML -->
<select>
  <option value="stay">Should I stay</option>
  <option value="move" selected>or should I go</option>
</select>

在 React 中,你需要指定 <select> 元素的 valuedefaultValue 属性:

// React/JSX
function ExampleSelect() {
  return (
    <form>
      <select defaultValue="move" onChange={changeHandler}>
        <option value="stay">Should I stay</option>
        <option value="move">or should I go</option>
      </select>
    </form>
  );
}
注意

React 会在你混淆并设置 <option>selected 属性时发出警告。

处理多选项类似,只需提供预选值的数组:

function ExampleMultiSelect() {
  return (
    <form>
      <select
        defaultValue={['stay', 'move']}
        multiple={true}
        onChange={selectHandler}>
        <option value="stay">Should I stay</option>
        <option value="move">or should I go</option>
        <option value="trouble">If I stay it will be trouble</option>
      </select>
    </form>
  );
}

注意,在处理多选项时,你无法在更改处理程序中获取 event.target.value。与 HTML 类似,你需要迭代 event.tar⁠get​.selectedOptions。例如,记录所选值到控制台的处理程序可能如下所示:

function selectHandler({target}) {
  console.log(
    Array.from(target.selectedOptions).map((option) => option.value),
  );
}

受控组件与非受控组件

在非 React 世界中,浏览器会维护表单元素的状态,例如文本输入框中的文本。即使你离开页面再回来,状态可能也会恢复。React 支持这种行为,但也允许你接管表单元素状态的控制。

当你让表单元素按照浏览器的意愿行事时,它们被称为不受控组件,因为 React 不控制它们。相反,当你使用 React 接管时,它们就成为受控组件

如何区分它们?当你设置文本输入框、textarea和选择框的value属性,或者单选按钮和复选框的checked属性时,组件就是受控的

当你不设置这些属性时,组件将变为不受控制。你仍然可以使用defaultValue属性(正如本章节的几个示例所示)为表单元素设置默认值。对于单选按钮和复选框,可以使用defaultChecked

让我们通过几个例子澄清这些概念。

不受控组件示例

这是一个不受控文本输入框的示例:

const input = <input type="text" name="firstname" />;

如果要在输入框中预填文本,请使用defaultValue

const input = <input type="text" name="firstname" defaultValue="Jessie" />;

当你想获取用户输入的值时,可以在输入框或整个表单上使用onChange处理程序,正如之前的示例所示。让我们考虑一个更完整的例子。想象一下,你正在创建一个配置文件编辑表单。你的数据是:

const profile = {
  firstname: 'Jessie',
  lastname: 'Pinkman',
  gender: 'male',
  acceptedTOC: false,
};

表单需要两个文本输入框,两个单选按钮和一个复选框:

function UncontrolledForm() {
  return (
    <form onChange={updateProfile}>
      <label>
        First name:{' '}
        <input type="text" name="firstname" defaultValue={profile.firstname} />
      </label>
      <br />
      <label>
        Last name:{' '}
        <input type="text" name="lastname" defaultValue={profile.lastname} />
      </label>
      <br />
      Gender:
      <label>
        <input
          type="radio"
          name="gender"
          defaultChecked={profile.gender === 'male'}
          value="male"
        />
        Male
      </label>
      <label>
        <input
          type="radio"
          name="gender"
          defaultChecked={profile.gender === 'female'}
          value="female"
        />
        Female
      </label>
      <br />
      <label>
        <input
          type="checkbox"
          name="acceptTOC"
          defaultChecked={profile.acceptTOC === true}
        />
        I accept terms and things
      </label>
    </form>
  );
}
注意

单选按钮确实有value属性,但这并不使它们成为受控组件。当设置它们的checked属性时,单选按钮(以及复选框)才会成为受控组件。

updateProfile()事件处理程序应该更新profile对象。这可以非常简单和通用。对于复选框(event.target.type === 'checkbox'),你需要查看target.checked属性。在其他所有情况下,你需要获取target.value

function updateProfile({target}) {
  profile[target.name] =
    target.type === 'checkbox' ? target.checked === true : target.value;
  console.log(profile);
}

图 5-8 展示了在更改性别、接受条款并更新名字后如何更新配置文件(请参见存储库中的05.13.uncontrolled.html)。

为什么要区别对待复选框(查看checked属性),但不是单选按钮?HTML 中的单选按钮很特殊,因为你可以有多个名称相同但值不同的输入,通过名称获取值。如果需要,你仍然可以访问单选按钮的target.checked,但在这种情况下并不是必需的。它总是true,因为在点击元素时会调用onChange回调,在点击单选按钮时总是选中的。

rur2 0508

图 5-8。操作中的不受控组件

使用 onSubmit 处理程序的不受控组件示例

如果你不想对每一次更改做出(过度)反应呢?你想让用户在表单上玩耍,只有在他们提交表单时才担心数据。在这种情况下,你有两个选择:

  • 使用内置的 DOM 集合

  • 使用 React 创建的引用

看看如何使用 DOM 集合(请参阅存储库中的05.14.uncontrolled.onsubmit.html)。表单本质上是相同的,除了onSubmit事件处理程序和一个新的提交按钮:

<form onSubmit={updateProfile}>
  {/* same thing as before ... */}
  <input type="submit" value="Save"/>
</form>

这是新更新的updateProfile()的示例:

function updateProfile(ev) {
  ev.preventDefault();
  const form = ev.target;
  Object.keys(profile).forEach((name) => {
    const element = form[name];
    profile[name] =
      element.type === 'checkbox' ? element.checked : element.value;
  });
}

首先,preventDefault()阻止事件传播并避免浏览器默认的重新加载页面行为。然后只需循环遍历 profile 的字段,并找到具有相同名称的相应表单元素。

DOM 提供了通过各种方式访问表单元素集合的方法之一,其中之一是通过名称。例如:

form.elements.length; // 6
form.elements[0].value; // "Jessie", access by index
form.elements['firstname'].value; // "Jessie", access by name
form.firstname.value; // "Jessie", even shorter

这是updateProfile()在其循环中使用的这种 DOM 表单访问的最后一种变体。

受控组件示例

一旦您分配了value属性(对于text输入、textareaselect)或checked(对于radio输入或checkbox),那么您就需要负责控制该组件。您需要将输入的状态作为组件状态的一部分进行维护。因此,现在整个表单都需要成为有状态的组件。让我们看看如何通过类组件实现:

class ControlledForm extends React.Component {
  constructor() {
    // ...
  }
  updateForm({target}) {
    // ...
  }
  render() {
    return (
      <form>
        {/* ... */}
      </form>
    );
  }
}

假设除了表单本身外没有其他状态需要维护,您可以将profile对象作为组件初始状态的一部分进行克隆。您还需要绑定updateForm()方法:

constructor() {
  super();
  this.state = {...profile};
  this.updateForm = this.updateForm.bind(this);
}

现在表单元素设置value而不是defaultValue,并且所有值都在this.state中维护。此外,现在所有输入都需要有一个onChange处理程序,因为它们现在是受控的。例如,第一个名字输入变成了:

<input
  type="text"
  name="firstname"
  value={this.state.firstname}
  onChange={this.updateForm}
/>

对于除提交按钮之外的其他元素,情况将类似,因为用户不会更改其值。

最后,updateForm()。使用动态属性名称(方括号中的target.name),它可以很简单。它所需做的就是读取表单元素的值并将其分配给状态。

updateForm({target}) {
  this.setState({
    [target.name]:
      target.type === 'checkbox' ? target.checked : target.value,
  });
}

setState()调用之后,将重新呈现表单,并从更新后的状态中读取新的表单元素值(例如,value={this.state.firstname})。

这就是受控组件的全部内容。正如您所见,您需要一些代码来起步。这是坏消息。好消息是,现在您可以从您的状态中更新表单值,这是唯一的真相源泉。您掌控局面。

那么哪种更好:受控还是非受控?这取决于您的用例。实际上并没有“更好”的选择。还要考虑到,在撰写本文时,官方的 React 文档中写道:“在大多数情况下,我们建议使用受控组件来实现表单。”

您可以混合和匹配受控和非受控组件吗?当然可以。在最后两个示例中,“保存”按钮始终是非受控的(<input type="submit" value="Save" />),因为没有东西可供控制;其value不能被用户更改。您始终可以选择混合:控制您需要的组件,其余交给浏览器处理。

第六章:为应用程序开发进行设置

现在您已经了解了 React、JSX 以及类组件和函数组件中的状态管理的许多知识,是时候开始创建和部署一个真实的应用程序了。第七章将开始这个过程,但首先有几个要求需要您处理。

对于任何严肃的开发和部署,除了原型或测试 JSX 外,您需要设置一个构建过程。目标是使用 JSX 和其他现代 JavaScript,而不必等待浏览器实现它们。您需要设置一个后台运行的转换过程,以便在开发时运行。转换过程应该生成尽可能接近最终用户在实时网站上运行的代码(意味着不再进行客户端转换)。此过程还应尽可能不显眼,以便您无需在开发和构建环境之间切换。

JavaScript 社区和生态系统在开发和构建过程中提供了丰富的选择。其中一种最简单、最常见的方法是使用 Create React App(CRA)实用程序(它有很棒的文档),所以让我们选择这个。

Create React App

CRA 是一组 Node.js 脚本及其依赖项,它们帮助您摆脱设置一切的负担。因此,您首先需要安装 Node.js。

Node.js

要安装 Node.js,请访问https://nodejs.org,并获取适合您操作系统的安装程序。按照安装程序的说明操作,就完成了。现在您可以使用命令行 Node 包管理器(npm)实用程序提供的服务。

即使您已经安装了 Node.js,确保您拥有最新版本仍然是一个好主意。

要验证,请在您的终端中输入以下内容:

$ npm --version

如果您没有使用终端(命令提示符)的经验,现在是一个开始的好时机!在 Mac OS X 上,点击 Spotlight 搜索(位于右上角的放大镜图标),输入 Terminal。在 Windows 上,找到开始菜单(右键单击屏幕左下角的 Windows 图标),选择运行,然后输入 powershell

注意

在本书中,您在终端中键入的所有命令都以 $ 作为提示,以区分它们与普通代码。在终端中输入时,忽略 $

Hello CRA

您可以安装 CRA 并在将来的项目中使用它。但这意味着偶尔需要更新它。更方便的方法是使用随 Node.js 一起提供的 npx 实用程序。它允许您执行(因此称为“x”)Node 包脚本。您可以运行 CRA 脚本一次:它会下载并执行最新版本,设置您的应用程序,然后就消失了。下次需要启动另一个项目时,再次运行它,无需担心更新。

要开始,请创建一个临时目录并执行 CRA:

$ mkdir ~/reactbook/test
$ cd ~/reactbook/test
$ npx create-react-app hello

给它一两分钟的时间来完成这个过程,您将会看到成功/欢迎消息:

Success! Created hello at /[...snip...]/reactbook/hello
Inside that directory, you can run several commands:

  npm start
    Starts the development server.

  npm run build
    Bundles the app into static files for production.

  npm test
    Starts the test runner.

  npm run eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you can’t go back!

We suggest that you begin by typing:

  cd hello
  npm start

Happy hacking!

正如屏幕上所示,键入:

$ cd hello
$ npm start

这将打开你的浏览器并指向 http://localhost:3000/,在那里你可以看到一个工作的 React 应用程序(如 图 6-1 所示)。

rur2 0601

图 6-1. 一个新的 React 应用

现在你可以打开 ~/reactbook/test/hello/src/App.js 并做出一个小改变。只要你保存了更改,浏览器就会更新显示新的内容。

构建和部署

假设你对更改满意,并准备好将新应用发布到世界上。转到终端/控制台窗口并按下 Ctrl + C。这会终止进程,进一步的更改将不会自动更新到浏览器中。你已准备好了。键入以下内容:

$ npm run build

这是构建和打包应用程序的过程,准备部署。构建结果位于 /build 文件夹中(如 图 6-2 所示)。

rur2 0602

图 6-2. React 应用程序的新构建

将此文件夹的内容复制到 web 服务器上——即使是一个简单的共享主机也可以——你就可以宣布这个新应用了。当你想做出改变时,重复这个过程:

$ npm start
// work, work, work...
// Ctrl+C
$ npm run build

犯了错误

当你保存一个文件时,其中有错误(也许你忘记关闭一个 JSX 标签),持续的构建会失败,并且你会在控制台和浏览器中得到错误消息(参见 图 6-3 作为示例)。

rur2 0603

图 6-3. 一个错误

这太棒了!你可以立即获得反馈。引用约翰·C·麦克斯韦尔的话:“早失败,频繁失败,但始终向前失败。”这是生活的智慧之言。

package.json 和 node_modules

应用程序根目录中的 package.json 文件包含应用程序的各种配置(CRA 有广泛的文档)。其中一个配置部分涉及依赖项,例如 React 和 React-DOM。这些依赖项安装在应用程序根目录中的 node_modules 文件夹中。

那里的依赖项是用于开发和构建应用程序,而不是用于部署。如果你与朋友、同事或开源社区分享你的应用程序代码,它们不应包括在内。例如,如果你要在 GitHub 上分享此应用程序,不要包括 node_modules。当其他人想要贡献或者你想要为另一个应用程序贡献时,可以在本地安装依赖项。

试一试。删除整个 node_modules 文件夹。然后转到应用的根目录并键入:

$ npm i

i 是“install”的意思。这样,package.json 中列出的所有依赖项(及其依赖项)都会安装到新创建的 node_modules 目录中。

浏览代码

让我们浏览一下 CRA 生成的代码,并注意一些关于应用程序入口点(index.htmlindex.js)以及它如何处理 JavaScript 和 CSS 依赖项的具体信息。

索引

public/index.html中,您将找到老式的 HTML 索引页面,这是浏览器渲染的一切的根源。这是定义<div id="root">的地方,也是 React 将渲染顶层组件及其所有子组件的地方。

文件src/index.js是就 React 而言应用程序的主要入口。请注意顶部部分:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';

JavaScript:现代化

到目前为止书中的示例只使用了简单的组件,并确保ReactReactDOM作为全局变量可用。当您转向具有多个组件的更复杂的应用程序时,您需要一个更好的组织计划。随意散布全局变量是危险的(它们往往会导致命名冲突),并且依赖于全局变量始终存在是不可靠的。

你需要模块。模块将构成应用程序的不同功能片段分割成小的、可管理的文件。一般来说,每个关注点应有一个单独的模块;模块和关注点具有一对一的关系。一些模块可以是单独的 React 组件;有些可能只是与 React 无关的实用程序,比如一个 reducer、一个自定义 hook 或一个处理日期或货币格式化的库。

一个模块的通用模板是:在顶部声明要求,在底部导出,在中间实现“核心”。换句话说,这三个任务:

  1. 需要/导入依赖项

  2. 提供一个函数/类/对象形式的 API

  3. 导出 API

对于一个 React 组件,模板可能如下所示:

import React from 'react';
import MyOtherComponent from './MyOtherComponent';

function MyComponent() {
  return <div>Hello</div>;
}

export default MyComponent;
注意

再次,一个可能有帮助的约定是:一个模块只导出一个 React 组件。

您是否注意到了导入 React 与MyOtherComponent之间的区别:from 'react'from './MyOtherComponent'?后者是一个目录路径——您告诉模块从相对于模块的文件位置拉取依赖项,而前者是从共享位置(node_modules)拉取依赖项。

CSS

src/index.js中,您可以看到 CSS 被视为另一个模块一样处理:

import './index.css';

src/index.css应包含通用样式,例如bodyhtml等,适用于整个页面。

除了应用程序范围的样式之外,您还需要每个组件的特定样式。根据每个 React 组件只有一个 CSS 文件(和一个 JS 文件)的约定,将MyComponent.css中仅与MyComponent.js相关的样式放在这里是个好主意,不要有其他内容。另一个好主意可能是在MyComponent.js中使用的所有类名前缀都以MyComponent-开头。例如:

.MyComponent-table {
  border: 1px solid black;
}

.MyComponent-table-heading {
  border: 1px solid black;
}

虽然有许多其他编写 CSS 的方法,但让我们保持简单和老派:任何在浏览器中运行而无需任何转译的东西。

继续进行

现在你有了一个简单的写作、构建和部署流水线的示例。有了这些经验,现在是时候转向更有趣的话题了:利用现代 JavaScript 提供的许多功能来构建和测试一个真正的应用程序。

此时,你可以删除hello应用程序,也可以保留它来进行探索和尝试想法。

第七章:构建应用程序的组件

现在您已经了解了创建自定义 React 组件(以及使用内置组件)、使用 JSX 定义用户界面,以及使用create-react-app构建和部署结果的所有基础知识,是时候开始构建更完整的应用程序了。

该应用程序称为“Whinepad”,允许用户记笔记和评价他们尝试的葡萄酒。实际上不一定是葡萄酒;它可以是任何他们想要抱怨的东西。它应该完成您对创建、读取、更新和删除(CRUD)应用程序的所有期望。它还应该是一个客户端应用程序,将数据存储在客户端上。目标是学习 React,因此叙述中非 React 部分(例如服务器端存储、CSS 展示)被保持在最低限度。

在构建应用程序时,从小、可重用的组件开始,并将它们组合成整体是一个好主意。这些组件越独立和可重用,越好。本章重点介绍逐个创建组件,下一章将它们组合在一起。

设置

首先,初始化并启动新的 CRA 应用程序:

$ cd ~/reactbook/
$ npx create-react-app whinepad
$ cd whinepad
$ npm start

开始编码

仅仅为了验证一切都按预期工作,打开~/reactbook/whinepad/public/index.html并将文档标题更改为匹配新应用程序:

// before
<title>React App</title>

//after
<title>Whinepad</title>

浏览器自动重新加载,您可以看到标题更改(见图 7-1)。

rur2 0701

图 7-1。一个新应用程序的开端

现在,为了组织目的,让我们将所有 React 组件及其对应的 CSS 放在一个新目录whinepad/src/components内。其他不严格属于组件的代码,例如您可能需要的各种实用程序,可以放在whinepad/src/modules内。根目录src包含 CRA 生成的所有文件。当然您可以更改它们,但任何新代码都放在这两个新目录componentsmodules中:

whinepad/
├──  public/
│   ├── index.html
└── src/
    ├── App.css // CRA-generated
    ├── App.js  // CRA-generated
    ├── ...
    └── components/       // all components live here
        │   ├── Excel.js
        │   ├── Excel.css
        │   ├── ...
        │   └──  ...
        └── modules/      // helper JS modules here
            ├── clone.js
            ├── ...
            └── ...

重构 Excel 组件

让我们开始 Whinepad。这是一个评级应用程序,您可以在其中做笔记。那么,将欢迎屏幕设置为您已经评级的东西的列表,放在一个漂亮的表格中如何?这意味着从第四章重复使用<Excel>组件。

让我们从04.10.fn.table-reducer.html文件中获取Excel的一个版本(正如它在第四章末尾所示),并将其复制到whinepad/src/components/Excel.js

Excel现在可以是一个可重用的独立组件,不知道数据来自哪里以及如何将内容插入 HTML 页面中。它只是一个 React 组件,应用程序的构建块之一。您已经知道一个可用的组件有三个工作:

  • 导入依赖项。

  • 做这项工作。

  • 导出组件。

忽略依赖部分一分钟,现在Excel看起来是这样的:

// dependencies go here

// do the work
function Excel({headers, initialData}) {
  // same as before
}

// export
export default Excel;

回到依赖项。之前,在处理纯 HTML 时,React是一个全局变量,PropTypes也是如此。现在您需要import它们:

import React from 'react';
import PropTypes from 'prop-types';

现在,您可以通过 React.useState() 使用状态钩子。但通常使用 named import 语法分配一些 React 属性更为方便:

import {useState, useReducer} from 'react';

现在您可以使用状态钩子,并使用更短的 useState()

最后,让我们将对象克隆助手移到自己的模块中,因为这不是 Excel 的职责,将其移动将使在任何后续时间更容易用正确的库替换快速而简单的实现。这意味着从 Excel 导入一个新的 clone 模块:

import clone from '../modules/clone.js';

克隆模块的实现位于 modules/ 目录中,这是为模块设计的位置。换句话说,它是一个没有依赖关系的 JavaScript 文件,名为 whinepad/src/modules/clone.js,看起来像这样:

function clone(o) {
  return JSON.parse(JSON.stringify(o));
}

export default clone;
注意

在导入 JavaScript 文件时,可以省略 .js 扩展名。您可以使用:

import clone from '../modules/clone';

而不是:

import clone from '../modules/clone.js';

因此,新的 Excel 看起来如下所示:

import {useState, useReducer} from 'react';
import PropTypes from 'prop-types';
import clone from '../modules/clone';

// do the work
function Excel({headers, initialData}) {
  // same as before
}

// export
export default Excel;

新应用程序的版本 0.0.1

现在您有了一个独立可重用的组件。因此,让我们使用它。由 CRA 生成的 App.js 文件是应用程序的顶级组件,在那里您可以导入 Excel。删除 CRA 生成的代码,并用 Excel 和一些临时数据替换,您可以得到:

import './App.css';
import Excel from './components/Excel';

function App() {
  return (
    <div>
      <Excel
        headers={['Name', 'Year']}
        initialData={[
          ['Charles', '1859'],
          ['Antoine', '1943'],
        ]}
      />
    </div>
  );
}

export default App;

通过这种方式,您可以获得一个工作的应用程序,显示在 图 7-2 中。它相当简单,但仍然可以搜索和编辑数据。

rur2 0702

图 7-2. 一个新应用程序诞生了

CSS

如 第 6 章 中讨论的,让每个组件都有一个 CSS 文件。因此,如果需要的话,Excel.js 组件应该配有一个 Excel.cssExcel.js 中的任何类名都应以 Excel- 为前缀。在来自 第 4 章 的当前实现中,元素使用 HTML 选择器进行样式设置(例如 table th {...}),但在由可重用元素组成的真实应用程序中,样式应该被限定在组件内部,以防止干扰其他组件。

注意

在样式应用程序方面有许多选择。但是,为了本次讨论的目的,让我们专注于 React 部分。一个简单的 CSS 命名约定就可以搞定。

任何“全局”样式都可以放在 CRA 创建的 App.css 中,但这些样式应该限制在一小部分真正通用的样式,例如整个应用程序的字体。 CRA 还生成了一个 index.css,但为了避免混淆哪些全局样式放在哪里,请将其删除。

因此,Excel 渲染的顶级 <div> 变成了:

return (
  <div className="Excel">
    {/* everything else */}
  <div>
);

现在,您可以通过使用 Excel 前缀将样式范围限定为仅适用于此组件:

.Excel table {
  width: 100%;
}

.Excel td {
  /* etc. */
}

.Excel th {
  /* etc. */
}

本地存储

为了尽可能将讨论限制在 React 上,让我们将所有数据保留在浏览器中,不要担心服务器端的部分。但是,不要在应用程序中硬编码数据,而是使用 localStorage。如果存储为空,则一个默认值足以向用户提示应用程序的目的。

数据检索可以在顶层的 App.js 中完成:

const headers = localStorage.getItem('headers');
const data = localStorage.getItem('data');

if (!headers) {
  headers = ['Title', 'Year', 'Rating', 'Comments'];
  data = [['Red whine', '2021', '3', 'meh']];
}

让我们也将 “搜索” 按钮从 Excel 中删除;它应该成为自己组件的一部分,与 Excel 组件分离得更好。

有了这个,你就在通向一个伟大新应用程序的道路上了(见 图 7-3)。

rur2 0703

图 7-3. 一个带有风格的应用程序

组件

现在你知道设置已经起作用了,是时候构建组成应用程序的所有组件了。图 7-4 和 7-5 显示了即将构建的应用程序的截图。

rur2 0704

图 7-4. 要构建的 Whinepad 应用程序

rur2 0705

图 7-5. 编辑项目

重新使用现有的 <Excel> 组件是开始的一种方式;然而,这个组件做得太多了。通过“分而治之”的方式将其分割成小的可重用组件更好。例如,按钮应该是它们自己的组件,这样它们可以在 Excel 表格之外被重复使用。

另外,应用程序还需要一些其他专业组件,如一个显示表情符号而不仅仅是数字的评分小部件,一个对话框组件等等。在开始新组件之前,让我们再添加一个辅助工具——一个组件发现工具。它的目标是:

  • 让你在隔离环境中开发和测试组件。通常,在应用程序中使用组件会导致你将组件与应用程序“结合”起来,降低其可重用性。将组件单独存在迫使你做出更好的关于将其与环境解耦的决策。

  • 让其他团队成员发现和重复使用现有的组件。随着应用程序的增长,团队也在增长。为了最小化两个人同时工作在非常相似的组件上的风险,并促进组件重用(这导致更快的应用程序开发),将所有组件放在一个地方是个好主意,同时附带它们的使用示例。

有一些可用的工具允许组件发现和测试,但让我们不要引入另一个依赖。相反,让我们采取一种轻量级的自助方法。

发现

一个发现工具可以作为一个新组件实现,与应用程序一起存在。

这个任务可以很简单,比如创建一个新组件(src/components/Discovery.js),在这里列出所有你的组件。你甚至可以使用不同的 props 渲染相同的组件,以演示组件的各种用法。例如:

import Excel from './Excel';
// more imports here...

function Discovery() {
  return (
    <div>
      <h2>Excel</h2>
      <Excel
        headers={['Name', 'Year']}
        initialData={[
          ['Charles', '1859'],
          ['Antoine', '1943'],
        ]}
      />
      {/* more components here */}
    </div>
  );
}

export default Discovery;

现在你可以通过在 App.js 中使用 URL 作为条件,加载发现组件而不是真正的应用程序:

const isDiscovery = window.location.pathname.replace(/\//g, '') === 'discovery';

function App() {
  if (isDiscovery) {
    return <Discovery />;
  }
  return (
    <div>
      <Excel headers={headers} initialData={data} />
    </div>
  );
}

现在,如果你加载 http://localhost:3000/discovery 而不是 http://localhost:3000/,你可以看到你已经添加到 <Discovery> 的所有组件。此时只有一个组件,但这个页面很快会变得更多。你的新组件发现工具(见 图 7-6)是开始使用新组件的地方。让我们开始工作,逐个构建它们。

rur2 0706

图 7-6. Whinepad 的组件发现工具

Logo 和一个 Body

从几个简单的组件开始,你可以验证一切是否正常工作,并因快速进展而感到兴奋。以下是每个应用程序都需要的两个新组件:

一个 components/Logo.js 并不需要太多。为了展示可能性,让我们使用箭头函数来定义这个组件:

import logo from './../images/whinepad-logo.svg';

const Logo = () => {
  return <img src={logo} width="300" alt="Whinepad logo" />;
};

export default Logo;

你需要的图像文件可以存储在 src/images/ 中,与 src/components/ 中找到的组件同级。

Body

主体也是一个简单的地方,只需呈现传递给它的子元素:

import './Body.css';

const Body = ({children}) => {
  return <div className="Body">{children}</div>;
};

export default Body;

Body.css 中,你可以像在 JavaScript 文件中一样引用图片:相对于 CSS 文件所在位置。构建过程会确保提取代码中引用的图片,并将它们打包到 build/ 目录下(正如你在上一章节中看到的):

.Body {
  background: url('./../images/back.jpg') no-repeat center center fixed;
  background-size: cover;
  padding: 40px;
}

可发现的

这些确实是非常简单的组件(也许是不必要的,你可能会争论,但应用程序确实会不断增长),它们展示了如何从小拼图开始组装应用程序。既然它们存在,它们应该出现在发现工具中(如 图 7-7 所示):

import Logo from './Logo';
import Header from './Header';
import Body from './Body';

function Discovery() {
  return (
    <div className="Discovery">
      <h2>Logo</h2>
      <div style={{background: '#f6f6f6', display: 'inline-block'}}>
        <Logo />
      </div>

      <h2>Body</h2>
      <Body>I am content inside the body</Body>

      {/* and so on */}

    </div>
  );
}

rur2 0707

图 7-7. 开始构建组件库

<Button> 组件

并非言过其实地概括,每个应用都需要一个按钮。它通常是一个样式良好的原生 HTML <button>,但有时可能需要是一个 <a>,就像在 第三章 中下载按钮所需的那样。新的闪亮 <Button> 是否可以接受一个可选的 href 属性?如果有,它会在底层渲染一个 <a>

符合测试驱动开发(TDD)精神,你可以从定义 <Discovery> 组件的示例使用开始:

import Button from './Button';

// ...

<h2>Buttons</h2>
<p>
  Button with onClick:{' '}
  <Button onClick={() => alert('ouch')}>Click me</Button>
</p>
<p>
  A link: <Button href="https://reactjs.org/">Follow me</Button>
</p>
<p>
  Custom class name:{' '}
  <Button className="Discovery-custom-button">I do nothing</Button>
</p>

(那我们是不是应该称之为发现驱动开发,或者 DDD?)

Button.js

让我们完整地看一下 components/Button.js

import classNames from 'classnames';
import PropTypes from 'prop-types';
import './Button.css';

const Button = (props) =>
  props.href ? (
    <a {...props} className={classNames('Button', props.className)}>
      {props.children}
    </a>
  ) : (
    <button {...props} className={classNames('Button', props.className)} />
  );

Button.propTypes = {
  href: PropTypes.string,
};

export default Button;

这个组件很简短,但有几点需要注意:

  • 它使用了 classnames 模块(后续会有更多内容)。

  • 它使用了函数表达式语法(const Button = () => {} 而不是 function Button() {})。在这个上下文中真的没有理由使用这种语法;你可以选择自己喜欢的语法,但知道这种方式是可能的也是好的。

  • 它使用展开运算符 ...props 作为一种方便的方式来表达:无论传递给 Button 的属性是什么,都将其传递到底层的 HTML 元素。

classnames 包

记住这行吗?

import classNames from 'classnames';

classnames 包为处理 CSS 类名提供了一个有用的函数。它可以帮助组件使用自己的类名,同时也灵活到允许通过父组件传递的类名进行自定义。

将这个包引入你的 CRA 设置中需要运行:

$ cd ~/reactbook/whinepad
$ npm i classnames

注意你的 package.json 已经更新了新的依赖。

使用包的唯一函数:

const cssclasses = classNames('Button', props.className);

这行代码将 Button 类名与创建组件时传递的任何(如果有的话)类名合并在一起(参见 图 7-8)。

rur2 0708

图 7-8. 带有自定义类名的<Button>
注意

你总是可以自己去做,并连接类名,但是classnames是一个小巧的包,更方便地完成这种常见任务。它还允许你有条件地设置类名,这也很方便,例如:

<div className={classNames({
  'mine': true, // unconditional
  'highlighted': this.state.active, // dependent on the
                                    // state...
  'hidden': this.props.hide, // ... or properties
})} />

表单

让我们继续下一个任务,这对于任何数据录入应用程序都是必不可少的:处理表单。作为应用程序开发者,我们很少满足于浏览器内置表单输入的外观和感觉,因此我们倾向于创建我们自己的版本。Whinepad 应用程序当然也不例外。

让我们来看一个通用的<FormInput>组件——可以说是一个工厂。根据它的type属性,这个组件应该将输入的创建委托给更专门的组件,例如,<Suggest>输入、<Rating>输入等。

让我们从较低级别的组件开始。

<Suggest>

网页上常见的高级自动建议(也称为类型提醒)输入很复杂,但我们可以简单一点(就像图 7-9 中一样),利用浏览器已经提供的东西——即<datalist> HTML 元素。

首先要做的事情是——更新发现应用程序:

<h2>Suggest</h2>
<p>
  <Suggest options={['eenie', 'meenie', 'miney', 'mo']} />
</p>

现在我们去实现组件在components/Suggest.js中:

import PropTypes from 'prop-types';

function Suggest({id, defaultValue = '', options=[]}) {

  const randomid = Math.random().toString(16).substring(2);
  return (
    <>
      <input
        id={id}
        list={randomid}
        defaultValue={defaultValue}
      />
      <datalist id={randomid}>
        {options.map((item, idx) => (
          <option value={item} key={idx} />
        ))}
      </datalist>
    </>
  );
}

Suggest.propTypes = {
  defaultValue: PropTypes.string,
  options: PropTypes.arrayOf(PropTypes.string),
};

export default Suggest;

正如前面的代码所示,这个组件并没有什么特别之处;它只是一个围绕着一个带有<datalist><input>的包装器(通过randomid连接)。

rur2 0709

图 7-9. <Suggest>输入的示例

从 JavaScript 语法角度来看,这个例子展示了如何使用解构赋值来为一个变量赋值多个属性,并同时定义默认值:

// before
function Suggest(props) {
  const id = props.id;
  const defaultValue = props.defaultValue || '';
  const options = props.options || [];
  // ...
}

// after
function Suggest({id, defaultValue = '', options=[]}) {}

<Rating> 组件

该应用程序是关于记录你尝试的事物。最懒的记笔记方法就是使用星级评分,例如从 1 到 5 的整数标度,5 分为最高/最好评分。

这个高度可重用的组件可以配置为:

  • 使用任意数量的“星星”。默认是 5,但为什么不试试 11 呢?

  • 保持只读,因为有时你不希望意外点击星星来改变那些重要的评分数据。

在发现工具中测试这个组件(见图 7-10):

<h2>Rating</h2>
<p>
  No initial value: <Rating />
</p>
<p>
  Initial value 4: <Rating defaultValue={4} />
</p>
<p>
  This one goes to 11: <Rating max={11} />
</p>
<p>
  Read-only: <Rating readonly={true} defaultValue={3} />
</p>

rur2 0710

图 7-10. 评分小部件

实施的基本要求包括设置属性、它们的类型和默认值,以及要维护的状态:

import classNames from 'classnames';
import {useState} from 'react';
import PropTypes from 'prop-types';
import './Rating.css';

function Rating({id, defaultValue = 0, max = 5, readonly = false}) {
  const [rating, setRating] = useState(defaultValue);
  const [tempRating, setTempRating] = useState(defaultValue);

  // TODO the rendering goes here...

}

Rating.propTypes = {
  defaultValue: PropTypes.number,
  readonly: PropTypes.bool,
  max: PropTypes.number,
};

export default Rating;

属性是不言自明的:max是星星的数量,readonly使得小部件只读。状态包含rating,即分配的当前星级值,以及tempRating,用户在组件周围移动鼠标但尚未点击提交评分时使用的临时值。

接下来是渲染。它有:

  • 循环生成星星,从 1 到props.max。星星只是表情符号&#128514;。当未应用RatingOn样式时,星星通过 CSS 滤镜变灰(filter: grayscale(0.9);)。

  • 作为真正表单输入的隐藏输入,允许以通用方式获取其值(就像任何旧的<input>一样):

const stars = [];
for (let i = 1; i <= max; i++) {
  stars.push(
    <span
      className={i <= tempRating ? 'RatingOn' : null}
      key={i}
      onClick={() => (readonly ? null : setRating(i))}
      onMouseOver={() => (readonly ? null : setTempRating(i))}>
      &#128514;
    </span>,
  );
}
return (
  <span
    className={classNames({
      Rating: true,
      RatingReadonly: readonly,
    })}
    onMouseOut={() => setTempRating(rating)}>
    {stars}
    <input id={id} type="hidden" value={rating} />
  </span>
);

当用户将鼠标移动到组件上时,tempRating状态会更新,从而改变RatingOn类名。当用户点击时,真正的rating状态也会更新,同时更新隐藏输入。离开组件(鼠标移出时),tempRating会被放弃,变成与rating相同。

这里还可以看到使用classNames函数的条件性 CSS 类名的示例。Rating类始终应用,而RatingReadonly仅在将readonly属性设置为true时应用。

这是处理只读和鼠标悬停行为的相关 CSS 的部分:

.Rating {cursor: pointer;}
.Rating.RatingReadonly {cursor: auto;}
.Rating span {filter: grayscale(0.9);}
.Rating .RatingOn {filter: grayscale(0);}

一个 <FormInput>“工厂”

接下来是一个通用的<FormInput>,根据给定的属性可以生成不同的输入。这将允许您将整个应用程序泛化,从撰写葡萄酒笔记转变为例如通过简单配置管理个人图书馆。稍后详细说明。

在发现应用程序中测试<FormInput>(参见图 7-11):

<h2>Form inputs</h2>
<table className="Discovery-pad">
  <tbody>
    <tr>
      <td>Vanilla input</td>
      <td><FormInput /></td>
    </tr>
    <tr>
      <td>Prefilled</td>
      <td><FormInput defaultValue="with a default" /></td>
    </tr>
    <tr>
      <td>Year</td>
      <td><FormInput type="year" /></td>
    </tr>
    <tr>
      <td>Rating</td>
      <td><FormInput type="rating" defaultValue={4} /></td>
    </tr>
    <tr>
      <td>Suggest</td>
      <td>
        <FormInput
          type="suggest"
          options={['red', 'green', 'blue']}
          defaultValue="green"
        />
      </td>
    </tr>
    <tr>
      <td>Vanilla textarea</td>
      <td><FormInput type="textarea" /></td>
    </tr>
  </tbody>
</table>

rur2 0711

图 7-11. 表单输入

<FormInput>的实现(位于components/FormInput.js中)需要常规的导入、导出和propTypes用于验证的样板文件:

import PropTypes from 'prop-types';
import Rating from './Rating';
import Suggest from './Suggest';

function FormInput({type = 'input', defaultValue = '', options = [], ...rest}) {
  // TODO rendering goes here...
}

FormInput.propTypes = {
  type: PropTypes.oneOf(['textarea', 'input', 'year', 'suggest', 'rating']),
  defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  options: PropTypes.array,
};

export default FormInput;

注意PropTypes.oneOfType([])中的属性类型,它允许组件接受默认值为字符串或数字的任一类型。

渲染部分是一个大的switch语句,它将个别的输入创建委托给更具体的组件,或者回退到内置的 DOM 元素<input><textarea>

switch (type) {
  case 'year':
    return (
      <input
        {...rest}
        type="number"
        defaultValue={
          (defaultValue && parseInt(defaultValue, 10)) ||
          new Date().getFullYear()
        }
      />
    );
  case 'suggest':
    return (
      <Suggest defaultValue={defaultValue} options={options} {...rest} />
    );
  case 'rating':
    return (
      <Rating
        {...rest}
        defaultValue={defaultValue ? parseInt(defaultValue, 10) : 0}
      />
    );
  case 'textarea':
    return <textarea defaultValue={defaultValue} {...rest} />;
  default:
    return <input defaultValue={defaultValue} type="text" {...rest} />;
}

正如您所看到的,这里并没有太多事情;此组件只是一个方便的包装器,允许对表单进行实现无关的定义。

<Form>

现在您有:

  • 自定义输入(例如<Rating>

  • 内置输入(例如<textarea>

  • <FormInput>—一个基于type属性制作输入的工厂

是时候在<Form>中使它们全部协同工作了(见图 7-12)。

rur2 0712

图 7-12. 表单元素

表单组件应该是可重用的,因此关于葡萄酒评级应该没有任何硬编码的内容。(再进一步说,关于葡萄酒的任何内容也不应该被硬编码,这样应用程序可以被重新用于抱怨任何事情。)<Form>组件可以通过一个fields数组进行配置,其中每个字段由以下内容定义:

type

默认是“input”。

id

这是为了稍后可以找到输入。

label

放置在输入框旁边。

options

这些被传递到自动建议输入框。

<Form> 还接受默认值映射,并能够渲染为只读,以便用户无法编辑字段。

从样板开始:

import {forwardRef} from 'react';
import PropTypes from 'prop-types';
import Rating from './Rating';
import FormInput from './FormInput';
import './Form.css';

const Form = forwardRef(({fields, initialData = {}, readonly = false}, ref) => {
  return (
    <form className="Form" ref={ref}>
      {/* more rendering here */}
    </form>
  );
});

Form.propTypes = {
  fields: PropTypes.objectOf(
    PropTypes.shape({
      label: PropTypes.string.isRequired,
      type: PropTypes.oneOf(['textarea', 'input', 'year', 'suggest', 'rating']),
      options: PropTypes.arrayOf(PropTypes.string),
    }),
  ).isRequired,
  initialData: PropTypes.object,
  readonly: PropTypes.bool,
};

export default Form;

在继续之前,让我们回顾一下这段代码中的一些新内容。

类型:shape、objectOf、arrayOf

在 prop types 中注意使用 PropTypes.shape。它让你能够精确地定义期望的 map/object 结构。这比像 PropTypes.object 这样的泛化更加严格,并且可以在其他开发者开始使用你的组件之前捕获更多错误。另外,请注意使用 PropTypes.objectOf。它类似于 arrayOf,让你定义期望包含特定数据类型的数组。这里的 objectOf 意味着组件期望一个 fields prop,它是一个对象。对于 fields 中的每对键值对,值期望是另一个对象,该对象具有 labeltypeoptions 属性,类似于:

<Form
  fields={{
    name: {label: 'Rating', type: 'input'},
    comments: {label: 'Comments', type: 'textarea'},
  }}
/>

总结一下:PropTypes.object 是任何对象,PropTypes.shape 是一个具有预定义键名(属性)的对象,PropTypes.objectOf 是一个具有未知键名但已知值类型的对象。

Refs

那个 ref 是怎么回事?Ref(引用)允许你从 React 中访问底层 DOM 元素。不建议在可以依赖 React 的地方滥用它。然而,在这种情况下,我们希望允许表单外的代码对表单输入进行通用循环和收集表单数据。还有一点要注意的是链式(或父/子级)关系。例如,我们希望 <Discovery> 组件收集表单数据。因此链条看起来像这样:

<Discovery>
  <Form>
    <form>
      <FormInput>
        <input />

Refs 允许 Discovery 以这种方式获取输入的 value

  1. <Discovery> 中使用钩子 useRef() 创建一个 ref 对象。

  2. Ref 被传递给 <Form>,后者通过 forwardRef() 钩子抓取它。

  3. 将这个 ref 转发 给 HTML/DOM <form> 元素。

  4. <Discovery> 现在通过 ref 对象的 .current 属性访问底层表单 DOM 元素。

下面是在探索工具中使用 <Form> 的示例:

const form = useRef();
// ...

<Form
  ref={form}
  fields={{
    rateme: {label: 'Rating', type: 'rating'},
    freetext: {label: 'Greetings'},
  }}
  initialData={{rateme: 4, freetext: 'Hello'}}
/>

现在你可以添加一个按钮,使用 form ref 及其属性 form.current 收集表单中的数据。因为 form.current 给你访问原生表单 DOM 节点的能力,而原生表单包含一个类似数组的输入集合,这意味着你可以将表单转换为数组(使用 Array.from())并遍历该数组。数组中的每个元素是一个原生 DOM 输入元素,你可以使用它们的 value 属性获取输入的值。这也是为什么即使是“高级”表单输入如 Rating 也包含(并更新)一个隐藏的输入元素。

<Button
  onClick={() => {
    const data = {};
    Array.from(form.current).forEach(
      (input) => (data[input.id] = input.value),
    );
    alert(JSON.stringify(data));
  }}>
  Submit
</Button>

点击按钮会显示一个类似 {"rateme":"4","freetext":"Hello"} 的 JSON 字符串的消息。

封装

现在回到 <Form> 的渲染部分。它是对 fields prop 的循环,并通过将每个字段信息传递给 <FormInput>,渲染出初始数据的只读版本或可工作的表单:

<form className="Form" ref={ref}>
  {Object.keys(fields).map((id) => {
    const prefilled = initialData[id];
    const {label, type, options} = fields[id];
    if (readonly) {
      if (!prefilled) {
        return null;
      }
      return (
        <div className="FormRow" key={id}>
          <span className="FormLabel">{label}</span>
          {type === 'rating' ? (
            <Rating
              readonly={true}
              defaultValue={parseInt(prefilled, 10)}
            />
          ) : (
            <div>{prefilled}</div>
          )}
        </div>
      );
    }
    return (
      <div className="FormRow" key={id}>
        <label className="FormLabel" htmlFor={id}>
          {label}
        </label>
        <FormInput
          id={id}
          type={type}
          options={options}
          defaultValue={prefilled}
        />
      </div>
    );
  })}
</form>

你可以看到这相对简单;唯一的复杂性来自于渲染只读评分小部件而不是简单的数值。

<操作>

在数据表格中的每一行旁边应该有操作(见图 7-13),您可以对每一行执行:删除、编辑和查看(当所有信息无法适合一行时)。

rur2 0713

图 7-13. 操作

这里是在发现工具中测试<操作>组件的情况:

<Actions onAction={(type) => alert(type)} />

以及整个组件:

import PropTypes from 'prop-types';
import './Actions.css';

import deleteImage from './../images/close.svg';
import editImage from './../images/edit.svg';

import Button from './Button';

const Actions = ({onAction = () => {}}) => (
  <span className="Actions">
    <Button
      className="ActionsInfo"
      title="More info"
      onClick={() => onAction('info')}>
      View Details
    </Button>
    <Button
      title="Edit"
      onClick={() => onAction('edit')}>
      <img src={editImage} alt="Edit" />
    </Button>
    <Button
      tabIndex="0"
      title="Delete"
      onClick={onAction.bind(null, 'delete')}>
      <img src={deleteImage} alt="Delete" />
    </Button>
  </span>
);

Actions.propTypes = {
  onAction: PropTypes.func,
};

export default Actions;

如您所见,操作是作为按钮实现的。该组件以onAction属性接受回调函数。当用户点击按钮时,将调用回调函数,传递标识哪个按钮被点击的字符串:'info''edit''delete'。这是子组件通知其父组件组件内部变化的简单模式。正如您所见,自定义事件(如onActiononAlienAttack等)就是这么简单。

下一章将全面讨论 React 应用程序中的数据流,但您已经了解了两种在父组件和子组件之间交换数据的方法:回调属性(如onAction)和引用。

对话框

接下来,让我们构建一个通用的对话框组件,用于显示任何类型的消息(而不是alert())或弹出窗口(如图 7-14 所示)。例如,所有添加/编辑表单可以显示在数据表格的模态对话框中。

rur2 0714

图 7-14. 对话框

要在Discovery组件中测试对话框,只需稍微修改状态以管理它们的打开或关闭:

function DialogExample() {
  const [example, setExample] = useState(null);
  return (
    <>
      <p>
        <Button onClick={() => setExample(1)}>Example 1</Button>{' '}
        <Button onClick={() => setExample(2)}>Example 2</Button>
      </p>
      {example === 1 ? (
        <Dialog
          modal
          header="Out-of-the-box example"
          onAction={(type) => {
            alert(type);
            setExample(null);
          }}>
          Hello, dialog!
        </Dialog>
      ) : null}

      {example === 2 ? (
        <Dialog
          header="Not modal, custom dismiss button"
          hasCancel={false}
          confirmLabel="Whatever"
          onAction={(type) => {
            alert(type);
            setExample(null);
          }}>
          Anything goes here, like a <Button>a button</Button> for example
        </Dialog>
      ) : null}
    </>
  );
}

对话框的实现并不需要太复杂,但让我们使其变得有趣,并添加一些不错的功能:

  • 有一个标题字符串的头部来自header属性。

  • 有一个仅仅是传递给<Dialog>的子元素的主体。

  • 底部有确定/取消按钮:让我们称它们为confirmdismiss。有时对话框仅仅是一个信息提示,你只需要一个按钮。属性hasCancel可以定义这一点。如果它是false,则只显示确定按钮。

  • confirm按钮可以通过confirmLabel属性更改标签。dismiss按钮始终显示“取消”。

  • 对话框可以是“模态”的,意味着它占据整个应用程序,直到关闭为止。

  • 一个onAction属性(类似于<操作>组件)可以将用户的操作传递给父组件。

  • 用户可以通过按下 Escape 键或点击对话框外部来关闭对话框。这是一个不错且预期的功能,但有时可能不可取。例如,如果在对话框中大量输入,写出了一些你最好的散文,然后突然按下 Escape 键?一切都丢失了!对话框行为的决定应留给使用该组件的开发人员。Dialog可以仅在通过extendedDismiss属性请求时启用这种扩展行为(按下 Escape 或点击外部)。

import/export/props设置可以如下所示:

import {useEffect} from 'react';
import PropTypes from 'prop-types';
import Button from './Button';
import './Dialog.css';

function Dialog(props) {
  const {
    header,
    modal = false,
    extendedDismiss = true,
    confirmLabel = 'ok',
    onAction = () => {},
    hasCancel = true,
  } = props;

  // rendering here...

}

Dialog.propTypes = {
  header: PropTypes.string.isRequired,
  modal: PropTypes.bool,
  extendedDismiss: PropTypes.bool,
  confirmLabel: PropTypes.string,
  onAction: PropTypes.func,
  hasCancel: PropTypes.bool,
};

export default Dialog;

渲染并不复杂;它是一个条件性的 CSS,当对话框是模态时显示一些条件性按钮:

return (
  <div className={modal ? 'Dialog DialogModal' : 'Dialog'}>
    <div className={modal ? 'DialogModalWrap' : null}>
      <div className="DialogHeader">{header}</div>
      <div className="DialogBody">{props.children}</div>
      <div className="DialogFooter">
        {hasCancel ? (
          <Button className="DialogDismiss" onClick={() => onAction('dismiss')}>
            Cancel
          </Button>
        ) : null}
        <Button onClick={() => onAction(hasCancel ? 'confirm' : 'dismiss')}>
          {confirmLabel}
        </Button>
      </div>
    </div>
  </div>
);

最后,用户可以通过Escape键或点击对话框体外部与其交互的扩展功能是在useEffect()钩子中实现的。这个钩子将仅在对话框渲染时执行一次,并负责设置(和清理)DOM 事件侦听器。如您已经知道的那样,一般的useEffect()模板是:

useEffect(() => {
    // setup
    return () => {
      // cleanup
    };
  },
  [] // dependencies
)

带有此模板,实现可能如下:

useEffect(() => {
  function dismissClick(e) {
    if (e.target.classList.contains('DialogModal')) {
      onAction('dismiss');
    }
  }

  function dismissKey(e) {
    if (e.key === 'Escape') {
      onAction('dismiss');
    }
  }

  if (modal) {
    document.body.classList.add('DialogModalOpen');
    if (extendedDismiss) {
      document.body.addEventListener('click', dismissClick);
      document.addEventListener('keydown', dismissKey);
    }
  }
  return () => {
    document.body.classList.remove('DialogModalOpen');
    document.body.removeEventListener('click', dismissClick);
    document.removeEventListener('keydown', dismissKey);
  };
}, [onAction, modal, extendedDismiss]);

这是一些替代想法:

  • 与单个onAction不同,另一个选项是提供onConfirm(用户单击确定)和onDismiss

  • 包装的div具有条件和非条件类名。组件可能受益于classnames模块,如下所示。

之前:

<div className={modal ? 'Dialog DialogModal' : 'Dialog'}>

之后:

<div className={classNames({
    'Dialog': true,
    'DialogModal': modal,
  })}>

标题

在这一点上,所有最底层的组件都已完成。在着手处理大组件Excel之前,让我们添加一个方便的Header组件,由标志、搜索框和“添加”按钮组成,用于向数据表格添加新记录:

import Logo from './Logo';
import './Header.css';

import Button from './Button';
import FormInput from './FormInput';

function Header({onSearch, onAdd, count = 0}) {
  const placeholder = count > 1 ? `Search ${count} items` : 'Search';
  return (
    <div className="Header">
      <Logo />
      <div>
        <FormInput placeholder={placeholder} id="search" onChange={onSearch} />
      </div>
      <div>
        <Button onClick={onAdd}>
          <b>&#65291;</b> Add whine
        </Button>
      </div>
    </div>
  );
}

export default Header;

如您所见,标题不执行任何搜索或添加数据的操作,但为其父组件提供回调以进行数据管理。

应用配置

最好将 Whinepad 应用程序与特定于葡萄酒的主题分离,并使其成为管理任何类型数据的可重用 CRUD 方法。不应该有硬编码的数据字段。而是,schema对象可以是您想在应用程序中处理的数据类型的描述。

这是一个例子(src/config/schema.js),可以帮助您启动一个面向葡萄酒的应用程序:

import classification from './classification';

const schema = {
  name: {
    label: 'Name',
    show: true,
    samples: ['$2 Chuck', 'Chateau React', 'Vint.js'],
    align: 'left',
  },
  year: {
    label: 'Year',
    type: 'year',
    show: true,
    samples: [2015, 2013, 2021],
  },
  grape: {
    label: 'Grape',
    type: 'suggest',
    options: classification.grapes,
    show: true,
    samples: ['Merlot', 'Bordeaux Blend', 'Zinfandel'],
    align: 'left',
  },
  rating: {
    label: 'Rating',
    type: 'rating',
    show: true,
    samples: [3, 1, 5],
  },
  comments: {
    label: 'Comments',
    type: 'textarea',
    samples: ['Nice for the price', 'XML in my JS, orly??!', 'Lodi? Again!'],
  },
};

export default schema;

这是您可以想象的最简单的 ECMAScript 模块之一,它导出一个变量。它还导入了另一个简单的模块,其中包含一些用于在表单中预填的选项(src/config/classification.js)。只是为了保持schema更短并更易读:

const classification = {
  grapes: [
    'Baco Noir',
    'Barbera',
    'Cabernet Franc',
    'Cabernet Sauvignon',
    // ...
  ],
};

export default classification;

借助schema模块的帮助,您现在可以配置您想在应用程序中管理的数据类型。

<Excel>:新版

现在是应用程序的主要部分,数据表格负责大部分工作:CRUD 操作中的一切,除了创建(C)。

<Discovery>中使用新的<Excel>,以便可以独立于整个应用程序进行测试:

import schema from '../config/schema';

// ...

<h2>Excel</h2>

<Excel
  schema={schema}
  initialData={schema.name.samples.map((_, idx) => {
    const element = {};
    for (let key in schema) {
      element[key] = schema[key].samples[idx];
    }
    return element;
  })}
  onDataChange={(data) => {
    console.log(data);
  }}
/>

如您所见,所有数据配置都来自schema,包括作为initialData属性传递用于测试的三个数据样本。然后是onDataChange回调属性,使得组件的父组件能够管理整体数据并执行诸如将其写入数据库或localStorage的任务。对于发现和测试的目的,console.log()已足够。

图 7-15 到 7-18 展示了Excel在发现工具中的外观和行为。

rur2 0715

图 7-15. Excel组件在Discovery中呈现,样本数据来自schema

rur2 0716

图 7-16. 在 Dialog 中使用 Form 编辑项目

rur2 0717

图 7-17. 查看项目详细信息:相同的 Form 但以只读方式呈现

rur2 0718

图 7-18. 点击删除 Action 时的确认

整体结构

熟悉的结构是顶部的导入,底部的导出以及用于渲染的 Excel 函数。此外,组件管理了一些状态:

  • 数据是否已排序?如何排序?

  • 是否有打开的对话框?里面有什么?

  • 用户是否在表格内联编辑?

  • 数据!

数据状态由 reducer 管理,对于其他一切,使用 useState()。在 Excel 函数内联中,有一些辅助函数用于隔离一些状态处理代码。

import {useState, useReducer, useRef} from 'react';
// more imports...

function reducer(data, action) {/*...*/}

function Excel({schema, initialData, onDataChange, filter}) {
  const [data, dispatch] = useReducer(reducer, initialData);
  const [sorting, setSorting] = useState({
    column: '',
    descending: false,
  });
  const [edit, setEdit] = useState(null);
  const [dialog, setDialog] = useState(null);
  const form = useRef(null);

  function sort(e) {/*...*/}

  function showEditor(e) {/*...*/}

  function save(e) {/*...*/}

  function handleAction(rowidx, type) {/*...*/}

  return (<div className="Excel">{/*...*/}</div>);
}

Excel.propTypes = {
  schema: PropTypes.object,
  initialData: PropTypes.arrayOf(PropTypes.object),
  onDataChange: PropTypes.func,
  filter: PropTypes.string,
};
export default Excel;

渲染

让我们从组件的渲染部分开始。有一个总体的 div 用于帮助样式化,在其中有一个 table 和(可选的)对话框,其内容来自 dialog 状态。这意味着在调用 setDialog()(由 useState() 提供)时,您传递要呈现的对话框内容(例如 setDialog(<Dialog />)):

  return (
    <div className="Excel">
      <table>
        {/* ... */}
      </table>
      {dialog}
    </div>
  );

渲染表头

表头与前几章节类似,不同之处在于表头标签现在来自于作为属性传递给 Excelschema

<thead onClick={sort}>
  <tr>
    {Object.keys(schema).map((key) => {
      let {label, show} = schema[key];
      if (!show) {
        return null;
      }
      if (sorting.column === key) {
        label += sorting.descending ? ' \u2191' : ' \u2193';
      }
      return (
        <th key={key} data-id={key}>
          {label}
        </th>
      );
    })}
    <th className="ExcelNotSortable">Actions</th>
  </tr>
</thead>

sorting 变量来自状态,并影响哪些标题获得排序箭头以及排序方向。整个表头 (<thead>) 有一个 onClick 处理程序,调用 sort() 辅助函数:

function sort(e) {
  const column = e.target.dataset.id;
  if (!column) { // The last "Action" column is not sortable
    return;
  }
  const descending = sorting.column === column && !sorting.descending;
  setSorting({column, descending});
  dispatch({type: 'sort', payload: {column, descending}});
}

渲染表体

表体 (<tbody>) 包含了表行 (<tr>),每行内有表格单元 (<td>)。每行的最后一个单元格保留给 <Actions>。您需要两个循环,一个用于行,另一个用于行内的单元格(列)。

在调整每个单元格的内容之后(您将在下一节中看到),您可以定义 <td>

<tbody onDoubleClick={showEditor}>
  {data.map((row, rowidx) => {

    // TODO: data filtering comes here...

    return (
      <tr key={rowidx} data-row={rowidx}>
        {Object.keys(row).map((cell, columnidx) => {

          const config = schema[cell];
          let content = row[cell];

          // TODO: content tweaks go here...

          return (
            <td
              key={columnidx}
              data-schema={cell}
              className={classNames({
                [`schema-${cell}`]: true,
                ExcelEditable: config.type !== 'rating',
                ExcelDataLeft: config.align === 'left',
                ExcelDataRight: config.align === 'right',
                ExcelDataCenter:
                  config.align !== 'left' && config.align !== 'right',
              })}>
              {content}
            </td>
          );
        })}
        <td>
          <Actions onAction={handleAction.bind(null, rowidx)} />
        </td>
      </tr>
    );
  })}
</tbody>

大部分工作用于定义 CSS 类名。它们根据 schema 条件而定,例如各种数据在单元格中的对齐方式(左边或居中)。

最奇怪的类名定义是 schema-${cell}。这是可选的,但是对于开发者来说,它提供了一个额外的 CSS 类名,用于每种数据类型,以防需要特定的内容。语法可能看起来奇怪,但这是 ECMAScript 的一种定义动态(计算)对象属性名称的方式,使用 [] 与模板字符串结合。

最终,示例单元格的结果 DOM 看起来大致如下:

<td
  data-schema="grape"
  class="schema-grape ExcelEditable ExcelDataLeft">
  Bordeaux Blend
</td>

所有单元格都是可编辑的,除了硬编码的操作和评分,因为您不希望意外点击更改评分。

调整和过滤内容

让我们解决表格渲染中的两个 TODO 注释。首先是内容调整,发生在内部循环中:

const config = schema[cell];
if (!config.show) {
  return null;
}
let content = row[cell];
if (edit && edit.row === rowidx && edit.column === cell) {
  content = (
    <form onSubmit={save}>
      <input type="text" defaultValue={content} />
    </form>
  );
} else if (config.type === 'rating') {
  content = (
    <Rating
      id={cell}
      readonly
      key={content}
      defaultValue={Number(content)}
    />
  );
}

您有一个来自模式的布尔值show配置。当您有太多列要在单个表格中显示时,这将很有帮助。在这种情况下,表格中每个项目的注释可能太长,使得用户很难解析该表格。因此,它不会显示在表格中,但仍可通过“查看详细信息”操作进行查看和通过“编辑”操作进行编辑。

接下来,如果用户双击以内联编辑数据(将表格置于编辑状态),则会显示一个表单。否则,只显示文本内容,除非是评级单元格。显示“星级”评分组件更友好,而不是像其他所有单元格一样简单的文本(例如,“5”或“2”)。

至于第二个TODO,它是用户搜索字符串的结果数据过滤。在前一章节中,每列有单独的输入字段用于过滤。在真实应用程序中,让我们在标题中只有一个单一的搜索输入,并将用户输入的内容传递给数据表。实现是关于遍历每行中的每列,并尝试与作为filter属性传递的搜索字符串匹配。如果没有找到匹配项,则整行将从表中移除。

if (filter) {
  const needle = filter.toLowerCase();
  let match = false;
  const fields = Object.keys(schema);
  for (let f = 0; f < fields.length; f++) {
    if (row[fields[f]].toString().toLowerCase().includes(needle)) {
      match = true;
    }
  }
  if (!match) {
    return null;
  }
}

为什么这个过滤在这里完成,而不是在 reducer 函数中?这是一个个人选择,在某种程度上是由于 reducer 的双调用,React 在“严格”模式下会这样做。

React.Strict 和 Reducers

Excel使用reducer()进行各种数据操作。在每次操作结束时,它会调用传递给组件的onDataChange回调函数。这就是Excel的父级如何被通知数据变化的方式。

function reducer(data, action) {
  // ...
  setTimeout(() => action.payload.onDataChange(data));
  return data;
}

这就是 <Discovery> 中的内容:

<Excel
  schema={schema}
  initialData={/* ... */}
  onDataChange={(data) => {
    console.log(data);
  }}
/>

如果您在打开控制台的情况下测试组件,您会发现每次更改都会有两个相同的条目记录(参见图 7-19 的示例)。

rur2 0719

图 7-19. 在将“$2 Chuck”更改为“$2 Chucks”后的两条控制台消息

这是因为在开发时的严格模式下,React 会将您的 reducer 调用两次。如果您回顾由 CRA 生成的 index.js,整个应用程序都包裹在 <React.StrictMode>中:

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

您可以移除包装器:

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

现在控制台只会有一条日志消息。

这种双调用是 React 帮助您发现 reducer 中的杂质。reducer 必须是纯粹的:对于相同的数据,它应该返回相同的结果。这是一个很棒的(再次强调,仅在开发时)功能,您应该警惕其中的杂质。一旦构建您的应用程序,就不会再有双调用了。

在这种情况下(记录更改),杂质是可以容忍的。但在其他情况下,可能不行。例如,假设您将一个数组传递给 reducer 并在返回数组之前移除了最后一个数组元素。返回的数组在内存中是相同的对象,如果再次将其传递给 reducer,则会再次移除另一个元素。这不是预期的行为。

在下一章中,您将看到一种不同的方式(使用contexts)在父子组件之间进行通信,超出了您在本书中到目前为止看到的回调 props。这将有助于避免双重调用问题。不过,出于教育目的和简单的回调(例如,<Dialog onAction...>),暂时继续使用 props 是可以的。

注意

“超时是怎么回事?”你可能会问。每当有一个不带 0 毫秒实际超时的setTimeout时,很可能存在一些变通方法。这段代码也不例外,它再次涉及到父子通信。我们将在第八章讨论并解决这个问题。

如您所见,我们揭示了在 reducers 和 strict mode 下出现的有趣问题。在未来,您将知道在应用程序中查找 reducers 问题的可能位置。如果发现有什么异常,看起来某些事情发生了两次,一个快速的调试练习就是删除<React.StrictMode>,看看问题是否消失。如果是这样,就是时候再次审视您的 reducers 了。

Excel 的小帮手

现在回到Excel。在这一点上,渲染已经完成。现在是时候看看在初始代码清单中注释掉的几个函数了,即reducer()函数和帮助函数sort()showEditor()save()handleAction()

sort()

实际上,已经讨论过sort()的问题。它是点击表头时的回调函数:

function sort(e) {
  const column = e.target.dataset.id;
  if (!column) {
    return;
  }
  const descending = sorting.column === column && !sorting.descending;
  setSorting({column, descending});
  dispatch({type: 'sort', payload: {column, descending}});
}

总体任务是弄清发生了什么(用户点击了表头,哪一个?),然后更新状态(调用由useState()提供的setSorting()以绘制排序箭头),并dispatch()一个事件以供reducer处理。reducer的任务是执行实际的排序。

showEditor()

另一个短小的辅助函数是showEditor()。当用户双击单元格并更改状态时,会调用它,以显示内联输入字段:

function showEditor(e) {
  const config = e.target.dataset.schema;
  if (!config || config === 'rating') {
    return;
  }
  setEdit({
    row: parseInt(e.target.parentNode.dataset.row, 10),
    column: config,
  });
}

由于此函数适用于表格中任何位置的所有点击事件(<tbody onDoubleClick={showEditor}>),您需要过滤出不希望显示内联表单的情况,即评分(不对项目进行内联评分)和操作列中的任何位置。操作列没有关联的模式配置,所以!config处理了这种情况。对于所有其他单元格,将调用setEdit(),该函数更新标识要编辑哪个单元格的状态。由于这只是渲染层面的变化,所以不涉及到reducer,因此不需要调用dispatch()

save()

接下来是save()辅助函数。当用户完成内联编辑并通过按 Enter 键提交内联表单时调用它(<form onSubmit={save}>)。类似于sort()save()也需要知道发生了什么(提交了什么),然后更新状态(setEdit())并dispatch()一个事件给reducer来更新数据:

function save(e) {
  e.preventDefault();
  const value = e.target.firstChild.value;
  const valueType = schema[e.target.parentNode.dataset.schema].type;
  dispatch({
    type: 'save',
    payload: {
      edit,
      value,
      onDataChange,
      int: valueType === 'year' || valueType === 'rating',
    },
  });
  setEdit(null);
}

弄清valueType有助于reducer在数据中写入整数与字符串,因为所有表单值都作为字符串从 DOM 中获取。

handleAction()

接下来是handleAction()方法。它是最长的,但并不太复杂。它需要处理三种类型的动作:删除、编辑和查看信息。编辑和信息在实现上很接近,因为信息是一个只读表单。我们先从删除开始:

function handleAction(rowidx, type) {
  if (type === 'delete') {
    setDialog(
      <Dialog
        modal
        header="Confirm deletion"
        confirmLabel="Delete"
        onAction={(action) => {
          setDialog(null);
          if (action === 'confirm') {
            dispatch({
              type: 'delete',
              payload: {
                rowidx,
                onDataChange,
              },
            });
          }
        }}>
        {`Are you sure you want to delete "${data[rowidx].name}"?`}
      </Dialog>,
    );
  }

  // TODO: edit and info
}

单击删除动作会弹出一个<Dialog>,显示“确定吗?”通过更新状态并通过setDialog()传递<Dialog>组件作为对话状态。无论答案如何(对话框的onAction),都可以通过传递null对话框来关闭对话框(setDialog(null))。但如果操作是“确认”,则会向减少器发送一个事件。

如果用户的操作是编辑或查看数据行,会创建一个新的<Dialog>,其中包含一个编辑表单。当仅查看数据时,表单是只读的。用户可以关闭对话框,放弃任何更改(这是查看时的唯一选项),或保存更改。保存意味着另一个调度,其中包括对表单的ref,以便于减少器收集表单数据。

const isEdit = type === 'edit';
if (type === 'info' || isEdit) {
  const formPrefill = data[rowidx];
  setDialog(
    <Dialog
      modal
      extendedDismiss={!isEdit}
      header={isEdit ? 'Edit item' : 'Item details'}
      confirmLabel={isEdit ? 'Save' : 'ok'}
      hasCancel={isEdit}
      onAction={(action) => {
        setDialog(null);
        if (isEdit && action === 'confirm') {
          dispatch({
            type: 'saveForm',
            payload: {
              rowidx,
              onDataChange,
              form,
            },
          });
        }
      }}>
      <Form
        ref={form}
        fields={schema}
        initialData={formPrefill}
        readonly={!isEdit}
      />
    </Dialog>,
  );

减少器(reducer)

最后,全能的减少器。它与你已经在第四章末尾看到的内容相似。排序和内联编辑部分基本相同,过滤已经移动到表格的呈现中,并且现在有一种删除行和保存编辑表单的方法:

function reducer(data, action) {
  if (action.type === 'sort') {
    const {column, descending} = action.payload;
    return data.sort((a, b) => {
      if (a[column] === b[column]) {
        return 0;
      }
      return descending
        ? a[column] < b[column]
          ? 1
          : -1
        : a[column] > b[column]
          ? 1
          : -1;
    });
  }
  if (action.type === 'save') {
    const {int, edit} = action.payload;
    data[edit.row][edit.column] = int
      ? parseInt(action.payload.value, 10)
      : action.payload.value;
  }
  if (action.type === 'delete') {
    data = clone(data);
    data.splice(action.payload.rowidx, 1);
  }

  if (action.type === 'saveForm') {
    Array.from(action.payload.form.current).forEach(
      (input) => (data[action.payload.rowidx][input.id] = input.value),
    );
  }

  setTimeout(() => action.payload.onDataChange(data));
  return data;
}

最后两行已经在上面讨论过了。其余内容都与数组操作有关。减少器被调用时,使用当前数据和一些描述发生情况的有效载荷,并根据该信息执行操作。

需要注意的一点是删除操作是唯一执行原始数组克隆的操作。这回到了上面关于减少器双调用的讨论。所有其他操作都可以修改数组,因为它们有一个精确的行/列要修改。或者在排序的情况下,不修改任何数据片段。因此,两次询问“请更新列 1,行 2,值为 2018”的结果每次都一样。然而,所有行只是零索引数组元素。当你有元素 0、1 和 2,删除 1 后,你有 0、1。因此,两次删除id为 1 的操作会删除两个元素。通过在删除之前克隆数组来解决这个问题,从而生成一个新的数组对象。双调用发生在原始的data上,而不是第一次调用返回的data上,因此从 0、1、2 中删除id为 1,再次从 0、1、2 中删除id为 1。这类微小的细节,当它是 React 严格模式与 JavaScript 中对象(数组也是对象)工作方式的结合时,可能会带来麻烦。因此,在修改减少器中的数组和对象时,务必格外小心。

通过这一步,应用程序中的最后一个组件完成了,现在是时候将它们全部放在一起创建一个可工作的应用程序了。

第八章:完成的应用程序

新应用程序的所有组件都已完成并可以在发现工具中进行测试(http://localhost:3000/discovery)。现在是时候将它们集成到一个工作应用程序中(在浏览器中可用作 http://localhost:3000/)。图 8-1 显示了用户首次加载应用程序时的期望结果。从模式示例中获取的默认数据的单行用于向用户展示应用程序的目的。

rur2 0801

图 8-1. 首次加载完成的应用程序

图 8-2 显示了当用户单击 + 添加红葡萄酒按钮时弹出的对话框。

rur2 0802

图 8-2. 添加新记录

图 8-3 显示了用户添加了一行后应用程序的状态。

rur2 0803

图 8-3. 表中的两条记录

由于你已经有了头部、主体、表格组件 Excel 和对话框组件,所以渲染只是简单地组装它们,如下所示:

<div>
  <Header/>
  <Body>
    <Excel/>
    <Dialog>
      <Form/>
    </Dialog>
  </Body>
</div>

主要任务是为这些组件提供正确的属性,并处理它们之间的数据流。让我们创建一个名为 DataFlow 的组件来处理所有这些。DataFlow 应该包含所有数据,并可以将其传递给 <Excel><Header>(后者需要知道搜索字段的占位符的记录数)。当用户在表格中更改数据时,Excel 通过 onDataChange 属性通知父级 DataFlow。当用户在 DataFlow 中使用 Dialog 添加新记录时,通过 onAction 回调将更新后的数据传递给 Excel。图 8-4 将此数据流程展示为图表。

rur2 0804

图 8-4. 数据流

DataFlow还需要传递由头部搜索框中输入的搜索(过滤)字符串。DataFlow从头部的onSearch回调中获取它,并将其作为Excelfilter属性传递,如图 8-5 所示。

rur2 0805

图 8-5. 传递搜索(过滤)字符串

最后,DataFlow还负责更新localStorage,以始终包含最新数据。

更新的 App.js

<App> 组件需要进行一些更新。它导入schema,然后在localStorage中查找数据。如果没有数据,它就从schema中取第一个样本作为初始数据。然后渲染新的组件DataFlow,传递数据和schema

import './App.css';
import Discovery from './components/Discovery';
import DataFlow from './components/DataFlow';
import schema from './config/schema';

const isDiscovery = window.location.pathname.replace(/\//g, '') === 'discovery';

let data = JSON.parse(localStorage.getItem('data'));

// default example data, read from the schema
if (!data) {
  data = [{}];
  Object.keys(schema).forEach((key) => (data[0][key] = schema[key].samples[0]));
}

function App() {
  if (isDiscovery) {
    return <Discovery />;
  }
  return <DataFlow schema={schema} initialData={data} />;
}

export default App;

DataFlow 组件

现在 <DataFlow> 组件的目标已经明确,并且你看到它是如何在 <App> 组件中使用的,让我们看看如何实现它。

总体结构,正如你所期望的那样,涉及导入/导出和属性类型:

import {useState, useReducer, useRef} from 'react';
import PropTypes from 'prop-types';

import Header from './Header';
import Body from './Body';
import Dialog from './Dialog';
import Excel from './Excel';
import Form from './Form';
import clone from '../modules/clone';

function commitToStorage(data) {
  // TODO
}

function reducer(data, action) {
  // TODO
}

function DataFlow({schema, initialData}) {
  // TODO
}

DataFlow.propTypes = {
  schema: PropTypes.object.isRequired,
  initialData: PropTypes.arrayOf(PropTypes.object).isRequired,
};

export default DataFlow;

现在我们来看看这些 TODO 注释。

第一个只是一个一行代码,它接受传递给它的任何内容(最新的 data,这个应用程序的核心)并将其写入 localStorage,以便在用户关闭浏览器选项卡后在下一个会话中使用:

function commitToStorage(data) {
  localStorage.setItem('data', JSON.stringify(data));
}

接下来是 reducer。它只负责两种类型的事件(动作):

save

当用户点击 + ADD WHINE 按钮时,这将在 data 中创建一条新记录。

excelchange

处理来自 Excel 的任何数据变化。此操作不修改数据,只是将其提交到存储并原样返回:

function reducer(data, action) {
  if (action.type === 'save') {
    data = clone(data);
    data.unshift(action.payload.formData);
    commitToStorage(data);
    return data;
  }
  if (action.type === 'excelchange') {
    commitToStorage(action.payload.updatedData);
    return action.payload.updatedData;
  }
}

为什么在添加到数组的 unshift() 中之前需要克隆数据?因为在开发中 reducer 会被调用两次(见 第七章),否则同一条记录会被添加两次。

注意

对于这样一个简单的 reducer,是否真的应该选择 reducer 而不是状态来管理 data?可能不是。事实上,书中的代码库中提供了一种只使用状态的替代实现 DataFlow1.js,代码行数较短。使用 reducer 的潜在好处是,如果预期将来有新的操作,扩展起来更简单。

让我们深入了解定义 DataFlow 组件的函数主体。

数据流体

类似于 Excel 管理其状态的方式,让我们尝试结合 useState()useReducer()。让我们为 data 设计一个 reducer,因为它可能涉及更多操作,其他方面均使用状态。addNew 状态用于切换是否显示添加对话框,filter 用于用户在搜索框中输入的字符串:

function DataFlow({schema, initialData}) {
  const [data, dispatch] = useReducer(reducer, initialData);
  const [addNew, setAddNew] = useState(false);
  const [filter, setFilter] = useState(null);

  const form = useRef(null);

  function saveNew(action) {/* TODO */}

  function onExcelDataChange(updatedData) {/* TODO */}

  function onSearch(e) {/* TODO */}

  return (
    // TODO: render
  );
}

form 引用类似于 第七章 中的 Excel,用于从显示在添加对话框中的表单中获取数据。

接下来,让我们解决渲染 TODO。其任务是组合所有主要组件(<Header><Excel> 等),并传递 data 和回调函数。条件是,如果用户点击添加按钮,还会构造一个 <Dialog>

return (
  <div className="DataFlow">
    <Header
      onAdd={() => setAddNew(true)}
      onSearch={onSearch}
      count={data.length}
    />
    <Body>
      <Excel
        schema={schema}
        initialData={data}
        key={data}
        onDataChange={(updatedData) => onExcelDataChange(updatedData)}
        filter={filter}
      />
      {addNew ? (
        <Dialog
          modal={true}
          header="Add new item"
          confirmLabel="Add"
          onAction={(action) => saveNew(action)}>
          <Form ref={form} fields={schema} />
        </Dialog>
      ) : null}
    </Body>
  </div>
);

另外三个 TODO 注释是关于内联辅助函数的,此时应看起来都不复杂。

onSearch() 从标题中获取搜索字符串并更新 filter 状态,通过重新渲染将其传递给 Excel,在那里用于仅显示匹配的数据记录:

function onSearch(e) {
  setFilter(e.target.value);
}

onExcelDataChange() 是另一个一行代码。这是一个回调函数,接收来自 Excel 的任何数据更新,并分发一个由 reducer 处理的动作:

function onExcelDataChange(updatedData) {
  dispatch({
    type: 'excelchange',
    payload: {updatedData},
  });
}

最后,处理对话框操作的 saveNew() 辅助函数。它无条件关闭对话框(通过设置 addNew 状态),并且如果对话框不仅仅是被解散,它还会从对话框中收集表单数据,并分派适当的 save 动作给 reducer 处理。

function saveNew(action) {
  setAddNew(false);
  if (action === 'dismiss') {
    return;
  }

  const formData = {};
  Array.from(form.current).forEach(
    (input) => (formData[input.id] = input.value),
  );

  dispatch({
    type: 'save',
    payload: {formData},
  });
}

任务完成

现在,应用程序已经完成。您可以构建它,部署到您附近的服务器,并使其对世界可用。

正如你所见,任务是创建所有必要的组件(第七章),尽可能使它们小而通用,并通过渲染顶层组件(HeaderBodyExcel)使它们一起工作,并确保数据在子组件和父组件之间流动。

到目前为止,你已经学习了一种通过 props 和回调传递数据的方法。这是一种有效的方式,但它可能会变得难以维护,主要有两个原因:

  • 子组件可能会嵌套很深,导致传递 props 和回调函数的长而笨拙的链条。

  • 当你将多个回调传递给一个组件(当这个组件中发生很多事情时),定义所有这些回调很快就会失去其优雅性。

在早期的 React 应用程序中,props 和回调函数是组件之间通信的原始方式,对许多情况仍然有效。随着 React 的发展,开发人员开始考虑如何解决由此带来的复杂性。其中一种流行的方法是使用全局数据存储,然后为组件提供读写数据的 API。

考虑这个例子(早期了解我们如何使用 React 构建应用程序的方法):一个深度嵌套的子组件使用回调进行通信:

// index.js
let data = [];
function dataChange(newData) {
  data = newData
}
<App data={data} onDataChange={dataChange} />

// <App> in app.js
<Body data={props.data} onDataChange={props.onDataChange} />

// <Body>
<Table
  data={props.data}
  onDataChange={props.onDataChange}
  onSorting={/* ... */}
  onPaging={/* ... */}
/>

// In <Table>
props.data.forEach((row) => {/* render */});
// later in <Table>
props.onDataChange(newData);

现在,通过某种形式的Storage模块,你可以做以下事情:

// index.js
<App />

// <App> in app.js
<Body />

// <Body>
<Table />

// In <Table>
const data = Storage.get('data');
data.forEach((row) => {/* render */});
// later in <Table>
Storage.set('data', newData);

你可以同意第二个选项看起来更干净、更简洁。

最初,这种全局存储的想法被称为Flux,并且在开源世界中出现了许多实现。其中一种实现,一个叫做Redux的库,赢得了大量开发者的青睐。另一种实现是这本书的第一版的一部分。今天,同样的想法已经成为 React 核心的一部分,被实现为context

让我们看看 Whinepad 应用程序如何转向其第二个版本,并摆脱回调函数,改用上下文。

Whinepad v2

要启动 v2 版本,你需要一个whinepad目录的副本,不包括node_modules/目录(其中存储了所有通过 npm 下载的依赖项)和package-lock.json文件:

$ cd ~/reactbook/whinepad
$ rm -rf node_modules/
$ rm package-lock.json

这两个是安装应用程序时的附带产物,所以当你分发应用程序(例如,在 GitHub 上与他人分享或者只是将其放入源代码控制中)时,你不需要它们。

复制whinepad(v1)版本,准备好进入 v2:

$ cd ~/reactbook/
$ cp -r whinepad whinepad2

在新位置安装依赖项:

$ cd ~/reactbook/whinepad2
$ npm i

启动 CRA 进行开发:

$ npm start .

现在,让我们重写这个应用程序,使其使用上下文。

上下文

第一步是创建一个上下文。最好在一个单独的模块中完成,以便它可以在组件之间共享。而且由于很可能会有多个上下文,你可以将它们存储在一个独立的目录中,与/components/modules同级。

$ cd ~/reactbook/whinepad2/src
$ mkdir contexts
$ touch contexts/DataContext.js

DataContext.js中没有太多事情发生,只是调用了创建上下文的函数:

import React from 'react';

const DataContext = React.createContext();

export default DataContext;

调用createContext()接受一个默认值。它的目的主要是用于测试、文档和类型安全性。让我们提供默认值:

const DataContext = React.createContext({
  data: [],
  updateData: () => {},
});

您可以在上下文中存储任何值,但一个常见的模式是使用一个对象,该对象有两个属性:一个数据片段和一个可以更新数据的函数。

下一步

现在已经创建了一个上下文,下一步是在组件中需要的地方使用这个上下文。data被用在ExcelHeader中,所以这两个组件需要更新。同时在DataFlow中传递数据,并且那里的变动最为显著。

但首先,需要对App.js进行更新和简化。在 v1 中,这是在其中确定初始(或默认)数据并将其作为 props 传递给<DataFlow>的地方。在 v2 中,让我们将所有数据管理都发生在<DataFlow>中。更新后的App.js看起来有点简单:

import './App.css';
import Discovery from './components/Discovery';
import DataFlow from './components/DataFlow';

const isDiscovery = window.location.pathname.replace(/\//g, '') === 'discovery';

function App() {
  if (isDiscovery) {
    return <Discovery />;
  }
  return <DataFlow />;
}

export default App;

DataFlow的工作是在应用程序加载时确定初始数据,在上下文中更新该数据,并确保子组件<Excel><Header>可以从上下文获取数据。子组件也应能够更新数据。正如您即将看到的那样,这实际上并不复杂,但首先,我们来谈谈 v2 中数据流程与 v1 有何不同。

循环数据

在 v1 中(还可以参见图 8-4),Excel在其状态中管理着data。这是构建可以在任何应用程序中任何地方使用的独立组件的一个很好的方法。但是父组件DataFlow也在其状态中保持数据,因为数据需要在HeaderExcel之间共享。因此存在两个“事实来源”,需要进行同步。通过将data属性从DataFlow传递到Excel,并使用onDataChange回调从子组件Excel到父组件DataFlow进行通信来完成此操作。这创建了一个数据的循环流动,可能导致无限渲染循环。Excel中的data发生变化,因此重新渲染。DataFlow通过onDataChange接收到新数据并更新其状态,这导致了DataFlow的重新渲染,从而导致了Excel的新渲染(它是一个子组件)。

React 通过拒绝在渲染阶段更新状态来防止这种情况发生。这就是在Excel中调用onDataChange回调时需要setTimeout黑科技的原因:

function reducer(data, action) {
  // ...
  setTimeout(() => action.payload.onDataChange(data));
  return data;
}

这样做效果很好。超时允许 React 在再次更新状态之前完成渲染。这个黑科技是为了拥有一个完全独立的Excel来管理自己的数据而付出的代价。

让我们在 v2 中进行更改,使用DataFlow中的一个单一事实来源(data)。这避免了黑科技,但带来的缺点是Excel现在需要其他人来管理数据。这并不难,但这是一个变化,并且需要测试区域<Discovery>更为深入参与。

提供上下文

看看由React.createContext()创建的DataContext上下文如何使用。实现这一功能的重要工作发生在DataFlow中,所以让我们来看看它的 v2 版本。

DataFlow.js中要求上下文:

import schema from '../config/schema';
import DataContext from '../contexts/DataContext';

弄清楚世界的初始状态,可以从存储或schema样本中进行,可以在模块的顶部完成,甚至不需要在DataFlow函数的体内完成:

let initialData = JSON.parse(localStorage.getItem('data'));

// default example data, read from the schema
if (!initialData) {
  initialData = [{}];
  Object.keys(schema).forEach(
    (key) => (initialData[0][key] = schema[key].samples[0]),
  );
}

data像以前一样保存在状态中:

function DataFlow() {
  const [data, setData] = useState(initialData);
  // ...
}

data将成为上下文的一部分。上下文还需要一个函数来更新数据。此函数在DataFlow中内联定义:

function updateData(newData) {
  newData = clone(newData);
  commitToStorage(newData);
  setData(newData);
}

更新数据的三个步骤是:

  1. 克隆数据,以便始终是不可变的。

  2. 将其保存到localStorage,以便下次加载应用程序时使用。

  3. 更新状态。

利用dataupdateData,最后一步是将需要上下文的子项(ExcelHeader)包裹在provider组件中:

<DataContext.Provider value={{data, updateData}}>
  <Header onSearch={onSearch} />
  <Body>
    <Excel filter={filter} />
  </Body>
</DataContext.Provider>

由于调用了createContext()创建了DataContext,因此提供者组件<DataContext.Provider>可用:

const DataContext = React.createContext({
  data: [],
  updateData: () => {},
});

提供者必须设置一个value属性,这可以是任何东西。这里的值是常见的模式:“数据加上改变数据的方式。”

现在,任何<DataContext.Provider>的子组件,如<Excel><Header>,都可以成为上下文值的consumer。消费的方式可以是通过<DataContext.Consumer>组件或通过useContext()钩子。

在查看消费上下文之前,以下是新DataFlow.js的完整列表。有关 Whinepad v2 的完整代码,请参阅书本存储库中的whinepad2目录。

import {useState} from 'react';

import Header from './Header';
import Body from './Body';
import Excel from './Excel';
import schema from '../config/schema';
import DataContext from '../contexts/DataContext';
import clone from '../modules/clone';

let initialData = JSON.parse(localStorage.getItem('data'));

// default example data, read from the schema
if (!initialData) {
  initialData = [{}];
  Object.keys(schema).forEach(
    (key) => (initialData[0][key] = schema[key].samples[0]),
  );
}

function commitToStorage(data) {
  localStorage.setItem('data', JSON.stringify(data));
}

function DataFlow() {
  const [data, setData] = useState(initialData);
  const [filter, setFilter] = useState(null);

  function updateData(newData) {
    newData = clone(newData);
    commitToStorage(newData);
    setData(newData);
  }

  function onSearch(e) {
    const s = e.target.value;
    setFilter(s);
  }

  return (
    <div className="DataFlow">
      <DataContext.Provider value={{data, updateData}}>
        <Header onSearch={onSearch} />
        <Body>
          <Excel filter={filter} />
        </Body>
      </DataContext.Provider>
    </div>
  );
}

export default DataFlow;

如您所见,filter仍然作为一个属性传递给<Excel>。即使您使用了上下文,传递属性仍然是一种选择。在许多情况下,组件需要进行通信时,这可能是首选方法。

使用上下文

如果您查看本章前面的DataFlow的 v1 版本,您可能会注意到在 v2 中,reducer()已经消失了。Reducer 的任务是处理来自Excel的数据更改并从头部添加新记录。这些任务现在可以在各自的子项中执行。Excel可以处理任何更改,然后使用提供的updateData()更新上下文。Header可以处理添加新记录,并使用相同的函数更新上下文中的数据。让我们看看如何做到这一点。

标题中的上下文

新标题将负责更多的 UI,即在Dialog中的Form来添加新记录,因此导入的列表稍长。请注意,还导入了新的DataContext

import Logo from './Logo';
import './Header.css';
import {useContext, useState, useRef} from 'react';

import Button from './Button';
import FormInput from './FormInput';
import Dialog from './Dialog';
import Form from './Form';
import schema from '../config/schema';

import DataContext from '../contexts/DataContext';

function Header({onSearch}) {
 // TODO
}

export default Header;

渲染标题所需的数据包括:

  • 来自上下文的data

  • addNew标志是否显示添加对话框(用户点击添加按钮时)

function Header({onSearch}) {
  const {data, updateData} = useContext(DataContext);
  const [addNew, setAddNew] = useState(false);

  const form = useRef(null);

  const count = data.length;
  const placeholder = count > 1 ? `Search ${count} items` : 'Search';

  function saveNew(action) {
    // TODO
  }

  function onAdd() {
    // TODO
  }

  // TODO: render
}

addNew状态直接从DataFlow的 v1 版本复制。新代码是使用DataContext的消费。可以看到,通过使用useContext()钩子,您可以访问由<DataContext.Provider>传递的value属性。它是一个对象,具有data属性和updateData()函数。

在 v1 中,count属性传递给<Header>。现在,头部可以访问所有数据并从中获取计数(data.length)。现在,所有用于渲染的组件都已经准备好了,是时候开始工作了:

function Header({onSearch}) {

  // ....

  return (
    <div>
      <div className="Header">
        <Logo />
        <div>
          <FormInput
            placeholder={placeholder}
            id="search"
            onChange={onSearch}
          />
        </div>
        <div>
          <Button onClick={onAdd}>
            <b>&#65291;</b> Add whine
          </Button>
        </div>
      </div>
      {addNew ? (
        <Dialog
          modal={true}
          header="Add new item"
          confirmLabel="Add"
          onAction={(action) => saveNew(action)}>
          <Form ref={form} fields={schema} />
        </Dialog>
      ) : null}
    </div>
  );
}

与上一个版本的主要区别在于,现在在头部实现了Dialog和它包含的Form

最后需要查看的两个功能函数是onAdd()saveNew()。第一个函数仅更新addNew状态:

function onAdd() {
  setAddNew(true);
}

saveNew()的工作是从表单中收集新记录并添加到data中。然后关键时刻来了:使用更新后的data调用updateData()。这个函数来自于<DataContext.Provider>,在DataFlow中定义为:

function updateData(newData) {
  newData = clone(newData);
  commitToStorage(newData);
  setData(newData);
}

这里发生的是父组件DataFlow接收到新数据并更新状态(通过setData()),这导致 React 重新渲染。这意味着ExcelHeader会重新渲染,但这次使用最新的数据。因此,新记录会出现在Excel表中,头部的搜索框中也会准确显示记录数量。

这里是Header.js文件的完整内容:

import Logo from './Logo';
import './Header.css';
import {useContext, useState, useRef} from 'react';

import Button from './Button';
import FormInput from './FormInput';
import Dialog from './Dialog';
import Form from './Form';
import schema from '../config/schema';

import DataContext from '../contexts/DataContext';

function Header({onSearch}) {
  const {data, updateData} = useContext(DataContext);
  const count = data.length;

  const [addNew, setAddNew] = useState(false);

  const form = useRef(null);

  function saveNew(action) {
    setAddNew(false);
    if (action === 'dismiss') {
      return;
    }

    const formData = {};
    Array.from(form.current).forEach(
      (input) => (formData[input.id] = input.value),
    );
    data.unshift(formData);
    updateData(data);
  }

  function onAdd() {
    setAddNew(true);
  }

  const placeholder = count > 1 ? `Search ${count} items` : 'Search';
  return (
    <div>
      <div className="Header">
        <Logo />
        <div>
          <FormInput
            placeholder={placeholder}
            id="search"
            onChange={onSearch}
          />
        </div>
        <div>
          <Button onClick={onAdd}>
            <b>&#65291;</b> Add whine
          </Button>
        </div>
      </div>
      {addNew ? (
        <Dialog
          modal={true}
          header="Add new item"
          confirmLabel="Add"
          onAction={(action) => saveNew(action)}>
          <Form ref={form} fields={schema} />
        </Dialog>
      ) : null}
    </div>
  );
}

export default Header;

数据表中的上下文

在 v2 完全运行之前的最后一件事是更新Excel,这样它就不再维护自己的状态,而是使用来自<DataContext.Provider>的数据。不需要渲染更改,只需管理数据。

由于Excel中不再需要data状态,因此不再需要reducer()。然而,所有数据操作发生在一个中心位置的想法太吸引人了,所以我们将reducer()简单重命名为dataMangler()

这是之前的状态:

function reducer(data, action) {
  if (action.type === 'sort') {
    const {column, descending} = action.payload;
    // ...
  }
  // ...
}

这是变更后的结果:

function dataMangler(data, action, payload) {
  if (action === 'sort') {
    const {column, descending} = payload;
    // ...
  }
  // ...
}

正如你所见,dataMangler()不需要遵循 reducer API,因此action现在可以是一个字符串,而 payload 可以作为函数的一个单独参数。这只是少打点字,也希望避免任何混淆:dataMangler()不是一个 reducer,只是一个方便的辅助函数。

dataMangler()的完整代码如下:

function dataMangler(data, action, payload) {
  if (action === 'sort') {
    const {column, descending} = payload;
    return data.sort((a, b) => {
      if (a[column] === b[column]) {
        return 0;
      }
      return descending
        ? a[column] < b[column]
          ? 1
          : -1
        : a[column] > b[column]
          ? 1
          : -1;
    });
  }
  if (action === 'save') {
    const {int, edit} = payload;
    data[edit.row][edit.column] = int
      ? parseInt(payload.value, 10)
      : payload.value;
  }
  if (action === 'delete') {
    data = clone(data);
    data.splice(payload.rowidx, 1);
  }
  if (action === 'saveForm') {
    Array.from(payload.form.current).forEach(
      (input) => (data[payload.rowidx][input.id] = input.value),
    );
  }
  return data;
}

注意函数末尾不再有setTimeout(() => action.payload.onDataChange(data)。不再需要onDataChange属性,也不再需要setTimeout的 hack。

当使用 reducer 时,只返回data就足以重新渲染Excel。现在你需要来自 provider 的updateData(),这样父组件DataFlow就可以负责重新渲染。此外,不再有自动调用 reducer 的dispatch()。现在所有的dispatch()调用点都有两个任务:调用dataMangler(),然后将其返回值传递给updateData()

这是变更前的状态:

dispatch({type: 'sort', payload: {column, descending}});

这是更新后的版本:

const newData = dataMangler(data, 'sort', {column, descending});
updateData(newData);

或者,一个简单的一行代码:

updateData(dataMangler(data, 'sort', {column, descending}));

替换 4 个dispatch()调用点,Whinepad 的 v2 版本就完成并且运行正常了。要获取完整的代码清单,请参阅书籍的代码存储库。

更新发现

此时对ExcelHeader的更改也影响了发现工具。虽然技术上没有问题,但有些混乱。例如,数据表为空,搜索输入框不显示计数。要充分利用Discovery,您需要设置环境,其中ExcelHeader生存。在这里,“环境”意味着在示例周围包装一个<DataConsumer.Provider>

这是代码转换之前的情况(内联示例和样本数据来自模式并作为属性传递):

<h2>Excel</h2>
<Excel
  schema={schema}
  initialData={schema.name.samples.map((_, idx) => {
    const element = {};
    for (let key in schema) {
      element[key] = schema[key].samples[idx];
    }
    return element;
  })}
  onDataChange={(data) => {
    console.log(data);
  }}
/>

转换后如下(变成一个全新的示例组件):

<h2>Excel</h2>
<ExcelExample />

示例组件也从schema获取样本数据,并用于维护状态。一个更简单的updateData()被创建,并作为上下文提供程序的一部分传递:

function ExcelExample() {
  const initialData = schema.name.samples.map((_, idx) => {
    const element = {};
    for (let key in schema) {
      element[key] = schema[key].samples[idx];
    }
    return element;
  });
  const [data, setData] = useState(initialData);
  function updateData(newData) {
    setData(newData);
  }
  return (
    <DataContext.Provider value={{data, updateData}}>
      <Excel />
    </DataContext.Provider>
  );
}

现在,Excel示例在发现工具中完全可操作。如果不进行此更新,当Excel尝试使用上下文时,它会得到createContext()中定义的默认dataupdateData()

// In DataContext.js
const DataContext = React.createContext({
  data: [],
  updateData: () => {},
});

// In Excel.js
const {data, updateData} = useContext(DataContext);

// `data` is now an empty array and `updateData` is a no-op function

更新<Discovery>中的<Header>示例可以更简单,因为您知道Header只关心data.length计数。

这是转换之前:

<h2>Header</h2>
<Header
  onSearch={(e) => console.log(e)}
  onAdd={() => alert('add')}
  count={3}
/>

现在是 v2 的展示:

<h2>Header</h2>
<DataContext.Provider value={{data: [1, 2, 3]}}>
  <Header onSearch={(e) => console.log(e)} />
</DataContext.Provider>

现在,将Header包装在提供程序中会导致上下文中使用value而不是来自createContext()的默认值。因此,如果测试标题中的“添加”按钮,您将收到错误,因为updateData()不存在。要修复错误并使按钮可测试,一个空操作的updateData()就足够了:

<h2>Header</h2>
<DataContext.Provider value={{data: [1, 2, 3], updateData: () => {}}}>
  <Header onSearch={(e) => console.log(e)} />
</DataContext.Provider>

现在,您还可以使用 Whinepad 的 v2 版本以及用于单独操作组件的发现区域。

路由

是时候通过实现一项额外的功能——可书签 URL——来结束这一章节和这本书,并在此过程中了解多个上下文和useCallback()钩子。

诸如 Whinepad 之类的单页面应用程序(SPA)不会刷新页面,因此应用程序的不同状态的 URL 无需更改。但是,当它们这样做时,这很好,因为这使用户可以共享链接并已将应用程序置于某个状态。例如,向同事发送类似https://whinepad.com/filter/merlot这样的 URL 比发送“转到https://whinepad.com/并在顶部搜索框中输入merlot”这样的说明更友好。

应用程序能够从 URL 中重新创建状态通常被称为路由,而有许多第三方库可以以某种方式为您提供路由。但让我们再次采用自助方式,并提出一个定制解决方案。

让我们提供 4 种类型的可书签化 URL:

  • /filter/merlot 用于标记搜索“merlot”

  • /add 以打开添加记录的“添加”对话框

  • /info/1 用于显示 ID 为 1 的记录的信息(不可编辑)对话框

  • /edit/1 以获取可编辑版本

第一个 URL 的处理应位于 DataFlow,因为这是传递过滤器的位置;第二个在 Header 中;最后两个在 Excel 中。由于各个组件都需要知道 URL,似乎需要一个新的上下文。

路由上下文

新上下文位于 contexts/RouteContext.js 中:

import React from 'react';

const RouteContext = React.createContext({
  route: {
    add: false,
    edit: null,
    info: null,
    filter: null,
  },
  updateRoute: () => {},
});

export default RouteContext;

再次看到一个熟悉的模式——上下文包括一个数据片段(route)和更新它的方法(updateRoute)。

如前所述,将上下文默认值替换为工作值的工作由父组件 DataFlow 执行。它需要新上下文,并尝试从 URL (window.location.pathname) 中读取路由信息:

// ...
import RouteContext from '../contexts/RouteContext';
//...

// read state from the URL "route"
const route = {};
function resetRoute() {
  route.add = false;
  route.edit = null;
  route.info = null;
  route.filter = null;
}
resetRoute();
const path = window.location.pathname.replace(/\//, '');
if (path) {
  const [action, id] = path.split('/');
  if (action === 'add') {
    route.add = true;
  } else if (action === 'edit' && id !== undefined) {
    route.edit = parseInt(id, 10);
  } else if (action === 'info' && id !== undefined) {
    route.info = parseInt(id, 10);
  } else if (action === 'filter' && id !== undefined) {
    route.filter = id;
  }
}

// ...

function DataFlow() {
  // ...
}

现在,如果应用加载的 URL 是 /filter/merlot,那么 route 将变成:

{
  add: false,
  edit: null,
  info: null,
  filter: 'merlot',
};

如果应用加载的是 /edit/1 这个 URL,route 将变成:

{
  add: false,
  edit: 1,
  info: null,
  filter: null,
};

DataFlow 也需要定义一个更新路由的函数:

function DataFlow() {

  // ...

  function updateRoute(action = '', id = '') {
    resetRoute();
    if (action) {
      route[action] = action === 'add' ? true : id;
    }
    id = id !== '' ? '/' + id : '';
    window.history.replaceState(null, null, `/${action}${id}`);
  }

  // ...
}

History API 中,使用 replaceState()pushState() 的一个替代方法,它不会创建历史记录条目(用于与浏览器的后退按钮一起使用)。在这种情况下,这是首选,因为 URL 将频繁更新,并有可能污染历史记录堆栈。例如,当用户输入“merlot”时,会产生六个历史记录条目(/filter/m, /filter/me, /filter/mer 等),使得后退按钮无法使用。

使用过滤器 URL

下一步是将新上下文的任何消费者包装在提供程序组件中(在此情况下为 <RouteContext.Provider>)。但是,出于过滤目的,目前还不是必需,因为所有过滤都发生在 DataFlow 中。

要使用新的路由功能,只需要进行两个更改。一个是在 onSearch 回调中,每当用户在搜索框中键入时调用。

之前:

function onSearch(e) {
  const s = e.target.value;
  setFilter(s);
}

之后:

function onSearch(e) {
  const s = e.target.value;
  setFilter(s);
  if (s) {
    updateRoute('filter', s);
  } else {
    updateRoute();
  }
}

现在当用户在搜索框中键入“m”时,URL 将变为 /filter/m。当用户删除搜索字符串时,URL 将返回到 /

更新 URL 只完成了一半工作。另一半是在应用加载时预填充搜索框 在加载时进行搜索。进行搜索意味着确保正确的 filter 属性传递给 Excel。幸运的是,这很简单。

之前:

function DataFlow() {
  const [filter, setFilter] = useState(null);
  // ...
}

之后:

function DataFlow() {
  const [filter, setFilter] = useState(route.filter);
  // ...
}

这已经足够了。现在,每当 DataFlow 渲染时,它都传递 <Excel filter=​{fil⁠ter}>,其中过滤器值来自 route。因此,Excel 只显示匹配的行。如果 route 对象中没有过滤器,则 filter 属性为 nullExcel 将显示所有内容。

若要预填充搜索框(位于 Header 中),需要将头部包裹在路由上下文提供程序中。这发生在 DataFlow 渲染时。

之前:

function DataFlow() {
  // ...
  return (
    <div className="DataFlow">
      <DataContext.Provider value={{data, updateData}}>
        <Header onSearch={onSearch} />
        <Body>
          <Excel filter={filter} />
        </Body>
      </DataContext.Provider>
    </div>
  );
}

之后:

function DataFlow() {
  // ...
  return (
    <div className="DataFlow">
      <DataContext.Provider value={{data, updateData}}>
        <RouteContext.Provider value={{route, updateRoute}}>
          <Header onSearch={onSearch} />
          <Body>
            <Excel filter={filter} />
          </Body>
        </RouteContext.Provider>
      </DataContext.Provider>
    </div>
  );
}

如您所见,可以拥有任意数量的上下文提供程序包装器。它们可以像上面看到的那样嵌套,或者可以仅在需要时包装不同的组件。

在头部使用路由上下文

Header 组件可以通过 RouteContext 获得路由访问权限。

之前:

// ...
import DataContext from '../contexts/DataContext';

function Header({onSearch}) {
  const {data, updateData} = useContext(DataContext);
  const [addNew, setAddNew] = useState(false);
  // ...

之后:

// ...
import DataContext from '../contexts/DataContext';
import RouteContext from '../contexts/RouteContext';

function Header({onSearch}) {
  const {data, updateData} = useContext(DataContext);
  const {route, updateRoute} = useContext(RouteContext);
  const [addNew, setAddNew] = useState(route.add);

注意将 route.add 作为 addNew 状态的默认值传递给组件使 /add URL 自动工作。将 addNew 设置为 true 会导致组件的渲染部分显示一个 Dialog

确保搜索框具有来自路由的预填充值也是一行代码。

Before:

<FormInput
  placeholder={placeholder}
  id="search"
  onChange={onSearch}
/>

After:

<FormInput
  placeholder={placeholder}
  id="search"
  onChange={onSearch}
  defaultValue={route.filter}
/>

头部需要做的另一件事是在用户执行适当的操作时更新路由上下文。当用户点击“添加”按钮时,URL 应更改为 /add。这通过从上下文调用 updateRoute() 来完成。

Before:

function onAdd() {
  setAddNew(true);
}

After:

function onAdd() {
  setAddNew(true);
  updateRoute('add');
}

当用户关闭对话框(或提交表单并使对话框消失)时,/add 应从 URL 中移除。

Before:

function saveNew(action) {
  setAddNew(false);
  // ...
}

After:

function saveNew(action) {
  setAddNew(false);
  updateRoute();
  // ...
}

在数据表格中使用路由上下文

Excel 中使用路由上下文看起来很熟悉:

// ...
import RouteContext from '../contexts/RouteContext';

function Excel({filter}) {
  const {route, updateRoute} = useContext(RouteContext);
  // ...
}

在这个组件中,handleAction() 辅助函数发挥了重要作用(参见 第七章)。它负责打开和关闭对话框以及对话框内容。只要调用它时传递正确的参数,此辅助程序就可以用于路由的目的。

借助 useEffect() 的帮助,当数据表格渲染并且结果是打开 /edit/[ID]/info/[ID] 的对话框时,可以调用此辅助程序。以下代码展示了如何实现这一点:

useEffect(() => {
  if (route.edit !== null && route.edit < data.length) {
    handleAction(route.edit, 'edit');
  } else if (route.info !== null && route.info < data.length) {
    handleAction(route.info, 'info');
  }
}, [route, handleAction, data]);

这里的routehandleActiondata是该效果的依赖项,因此不会经常调用。快速检查data.length可以防止打开具有超出范围的 ID 的对话框(例如,当只有 3 条记录时,无法编辑 ID 5)。然后当 URL 是 /info/2 时调用 handleAction(),例如 handleAction(2, 'info')

因此,handleAction() 负责读取路由信息并创建正确的对话框。但它还负责在用户操作时更新 URL。这一部分很简单。

Closing the dialog before:

setDialog(null);

And after:

setDialog(null);
updateRoute(); // clean up the URL

打开对话框之前:

const isEdit = type === 'edit';
if (type === 'info' || isEdit) {
  const formPrefill = data[rowidx];
  setDialog(
    <Dialog ...
// ...

And after:

const isEdit = type === 'edit';
if (type === 'info' || isEdit) {
  const formPrefill = data[rowidx];
  updateRoute(type, rowidx); // makes the URL e.g., /edit/3
  setDialog(
    <Dialog ...
// ...

这样,功能就完成了;只剩最后一步了。

useCallback()

设置 useEffect 时,handleAction() 被作为一个依赖项传递:

useEffect(() => {
  if (route.edit !== null && route.edit < data.length) {
    handleAction(route.edit, 'edit');
  } else if (route.info !== null && route.info < data.length) {
    handleAction(route.info, 'info');
  }
}, [route, handleAction, data]);

但由于 handleAction() 是在 Excel() 内部的内联函数,这意味着每次调用 Excel() 进行重新渲染时都会创建一个新的 handleAction()。而 useEffect() 则会看到更新的依赖项。这是不高效的。尽管函数执行的内容相同,但每次更改函数依赖项都是没有意义的。

React 提供了 useCallback() 钩子来帮助处理这种情况。它会使用其依赖项记忆化回调函数。因此,如果在 Excel 重新渲染时创建了一个新的 handleAction(),但其依赖项没有更改,则 useEffect() 不需要查看新的依赖项。旧的记忆化的 handleAction 应该可以解决问题。

使用 useCallback()handleAction 包装起来应该看起来有些熟悉,就像 useEffect() 一样,模式是:第一个参数是一个函数,第二个是一个依赖项数组。

Before:

function handleAction(rowidx, type) {
  // ...
}

After:

const handleAction = useCallback(
  (rowidx, type) => {
    // ...
  },
  [data, updateData, updateRoute],
);

dataupdateDataupdateRoute 这些依赖是 handleAction 正常工作所需的唯一外部信息。因此,如果这些信息在重新渲染之间没有改变,旧的记忆化 handleAction 就足够了。以下是经过所有路由更改后的完整和最终版本的 handleAction()

const handleAction = useCallback(
  (rowidx, type) => {
    if (type === 'delete') {
      setDialog(
        <Dialog
          modal
          header="Confirm deletion"
          confirmLabel="Delete"
          onAction={(action) => {
            setDialog(null);
            if (action === 'confirm') {
              updateData(
                dataMangler(data, 'delete', {
                  rowidx,
                  updateData,
                }),
              );
            }
          }}>
          {`Are you sure you want to delete "${data[rowidx].name}"?`}
        </Dialog>,
      );
    }
    const isEdit = type === 'edit';
    if (type === 'info' || isEdit) {
      const formPrefill = data[rowidx];
      updateRoute(type, rowidx);
      setDialog(
        <Dialog
          modal
          extendedDismiss={!isEdit}
          header={isEdit ? 'Edit item' : 'Item details'}
          confirmLabel={isEdit ? 'Save' : 'ok'}
          hasCancel={isEdit}
          onAction={(action) => {
            setDialog(null);
            updateRoute();
            if (isEdit && action === 'confirm') {
              updateData(
                dataMangler(data, 'saveForm', {
                  rowidx,
                  form,
                  updateData,
                }),
              );
            }
          }}>
          <Form
            ref={form}
            fields={schema}
            initialData={formPrefill}
            readonly={!isEdit}
          />
        </Dialog>,
      );
    }
  },
  [data, updateData, updateRoute],
);

结束

亲爱的读者,我很高兴你读到这里。希望你现在是一个更自信的程序员,知道如何启动一个新的 React 项目或加入现有项目并将其推向未来。

编程书籍就像是时间的快照。技术在变化和演进,而书籍始终如一。我尽力专注于常青内容,让演进自然发生。但我希望在新版出版之前,尝试通过 PDF 附件给本书增加新内容。如果你希望了解新内容,请加入邮件列表

posted @ 2025-11-18 09:36  绝不原创的飞龙  阅读(15)  评论(0)    收藏  举报