React-秘籍-全-

React 秘籍(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书包含了我们在多年构建 React 应用程序中发现有用的一系列代码。就像你在厨房中使用的食谱一样,我们设计它们作为你自己代码的起点或灵感。你应该根据自己的情况调整它们,并用更适合你需求的成分(比如示例服务器)替换掉它们。这些配方涵盖了从一般的 Web 开发提示到你可以概括为库的较大代码片段。

大部分的配方都是用 Create React App 构建的,因为这是现在大多数 React 项目的常见起点。转换每个配方以在 Preact 或 Gatsby 中使用应该很简单。

为了保持代码的简洁,我们通常使用钩子和函数而不是类组件。我们还使用 Prettier 工具在整个过程中应用标准的代码格式化。除了更窄的缩进和行长度外,我们使用了 Prettier 的默认选项,以便将代码整齐地打印到页面上。你应该调整代码格式以符合你的首选标准。

在创建这些配方时我们使用了许多库:

Tool/library 描述 版本
Apollo Client GraphQL 客户端 3.3.19
axios HTTP 库 0.21.1
chai 单元测试支持库 4.3.0
chromedriver 浏览器自动化工具 88.0.0
Create React App 生成 React 应用的工具 4.0.3
Cypress 自动化测试系统 7.3.0
Cypress Axe 自动化无障碍测试 0.12.2
Gatsby 生成 React 应用的工具 3.4.1
GraphQL API 查询语言 15.5.0
jsx-a11y 用于无障碍的 ESLint 插件 6.4.1
Material-UI 组件库 4.11.4
Node JavaScript 运行时 v12.20.0
npm Node 包管理器 6.14.8
nvm 运行多个 Node 环境的工具 0.33.2
nwb 生成 React 应用的工具 0.25.x
Next.js 生成 React 应用的工具 10.2.0
Preact 轻量级类 React 框架 10.3.2
Preact Custom Elements 创建自定义元素的库 4.2.1
preset-create-react-app Storybook 插件 3.1.7
Rails Web 开发框架 6.0.3.7
Razzle 生成 React 应用的工具 4.0.4
React Web 框架 17.0.2
React Media 在 React 代码中使用媒体查询 1.10.0
React Router (DOM) 管理 React 路由的库 5.2.0
React Testing Library 用于 React 的单元测试库 11.1.0
react-animations React CSS 动画库 1.0.0
React Focus Lock 捕获键盘焦点的库 2.5.0
react-md-editor Markdown 编辑器 3.3.6
React-Redux Redux 的 React 支持库 7.2.2
Redux 状态管理库 4.0.5
Redux-Persist 存储 Redux 状态的库 6.0.0
Ruby Rails 使用的语言 2.7.0p0
selenium-webdriver 浏览器测试框架 4.0.0-beta.1
Storybook 组件库系统 6.2.9
TweenOne React 动画库 2.7.3
Typescript JavaScript 的类型安全扩展 4.1.2
Webpacker 用于向 Rails 应用程序添加 React 的工具 4.3.0
Workbox 用于创建服务工作者的库 5.1.3
Yarn 另一个 Node 包管理器 1.22.10

本书使用的约定

本书使用以下排版约定:

Italic

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

Constant width

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

Constant width bold

显示用户应按原样输入的命令或其他文本。

Constant width italic

显示应由用户提供值或由上下文确定值的文本。

此元素表示提示或建议。

此元素表示一般注意事项。

此元素指示警告或注意事项。

使用代码示例

可以下载补充材料(代码示例、练习等)https://github.com/dogriffiths/ReactCookbook-source

如果您有技术问题或使用代码示例的问题,请发送电子邮件至bookquestions@oreilly.com

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

我们感谢,但通常不要求署名。署名通常包括标题、作者、出版社和 ISBN。例如:“React Cookbook by David Griffiths and Dawn Griffiths (O’Reilly)。版权所有 2021 年 Dawn Griffiths 和 David Griffiths,978-1-492-08584-3。”

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

O’Reilly 在线学习

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

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O'Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O'Reilly 和 200 多个其他出版商的大量文本和视频。有关更多信息,请访问http://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/react-cb

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

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

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

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

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

致谢

我们要感谢我们非常耐心的编辑 Corbin Collins 在过去一年里对写作过程中的帮助和建议。他的冷静和幽默在写作过程中起到了稳定作用。我们还要感谢 Amanda Quinn,O'Reilly Media 的高级内容获取编辑,委托出版这本书,以及 O'Reilly 的制作团队 Danny Elfanbaum,为实现实体和电子版本所做的贡献。

特别感谢 Sam Warner 和 Mark Hobson 对本书内容进行的非常严格的审查。

我们也感谢那些为支持 React 生态系统的许多开源库工作的开发人员。我们对他们所有人都心怀感激,特别是他们对错误报告或求助的迅速响应。

如果你觉得这些配方有用,主要是因为这些人的工作。如果你发现代码或文本中有错误,那完全是我们的责任。

第一章:创建应用程序

React 是一个非常灵活的开发框架。开发者使用它来创建大型 JavaScript 重度单页应用(SPA),或者创建非常小的插件。你可以在 Rails 应用中嵌入代码,也可以生成内容丰富的网站。

在本章中,我们将探讨创建 React 应用程序的各种方式。我们还将介绍一些可能想要添加到开发周期中的更有价值的工具。现在很少有人从头开始创建他们的 JavaScript 项目。这样做是一个繁琐的过程,涉及大量琢磨和配置。好消息是,你可以使用一个工具来几乎在每种情况下生成你需要的代码。

让我们快速浏览一下开始 React 之旅的多种方式,从最常用的一种开始:create-react-app

生成一个简单的应用程序

问题

从头开始创建和配置 React 项目是具有挑战性的。不仅需要做出众多设计选择(包括要包含哪些库、使用哪些工具、启用哪些语言功能),而且手动创建的应用程序由于其特性而彼此不同。项目的特殊性增加了新开发者达到生产力所需时间。

解决方案

create-react-app 是一个用于构建符合标准结构且具有良好默认选项的 SPA 的工具。生成的项目使用 React Scripts 库来构建、测试和运行代码。项目有一个标准的 Webpack 配置和一组启用的标准语言功能。

任何曾在一个 create-react-app 应用程序上工作过的开发者都能立刻适应其他任何一个。他们理解项目结构并知道可以使用哪些语言功能。这是一个简单易用的工具,包含了典型应用程序所需的所有功能:从 Babel 配置和文件加载器到测试库和开发服务器。

如果你是 React 的新手,或者需要用最少的麻烦创建一个通用的 SPA,那么你应该考虑使用 create-react-app 来创建你的应用程序。

你可以选择在计算机上全局安装 create-react-app 命令,但这现在已经不被推荐。相反,你应该通过 npx 调用 create-react-app 来创建新项目。使用 npx 确保你使用的是 create-react-app 的最新版本:

$ npx create-react-app my-app

这个命令创建一个名为 my-app 的新项目目录。默认情况下,该应用程序使用 JavaScript。如果你想要使用 TypeScript 作为开发语言,create-react-app 也提供了这个选项:

$ npx create-react-app --template typescript my-app

create-react-app 是由 Facebook 开发的,因此如果你已安装 yarn 包管理器,那么你的新项目将默认使用 yarn 并不足为奇。要使用 npm,你可以指定 --use-npm 标志,或者进入目录并删除 yarn.lock 文件,然后使用 npm 重新运行安装。

$ cd my-app
$ rm yarn.lock
$ npm install

要启动你的应用程序,请运行 start 脚本:

$ npm start # or yarn start

此命令在端口 3000 上启动服务器,并在主页打开浏览器,如图 1-1 所示。

图 1-1. 生成的首页

服务器将你的应用程序作为一个单独的大 JavaScript 包交付。代码将其所有组件挂载在public/index.html中的这个<div/>内:

<div id="root"></div>

生成组件的代码始于src/index.js文件(如果你使用 TypeScript,则为src/index.tsx):

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import reportWebVitals from './reportWebVitals'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()

此文件几乎只是渲染一个名为<App/>的单个组件,它从同一目录中的App.js(或App.tsx)导入:

import logo from './logo.svg'
import './App.css'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  )
}

export default App

如果你在start状态下编辑此文件,浏览器中的页面将自动更新。

当你准备将代码部署到生产环境时,你需要生成一组静态文件,这些文件可以部署到标准的 Web 服务器上。要做到这一点,请运行build脚本:

$ npm run build

build脚本创建一个build目录,然后发布一组静态文件(参见图 1-2)。

图 1-2. 构建目录中生成的内容

构建从public/目录复制了许多这些文件。应用程序的代码被转译为浏览器兼容的 JavaScript,并存储在static/js目录中的一个或多个文件中。应用程序使用的样式表被拼接在一起,并存储在static/css中。其中的几个文件添加了哈希 ID,以便在部署应用程序时,浏览器下载最新代码,而不是某个旧版本的缓存。

讨论

create-react-app不仅是一个生成新应用程序的工具,还是一个平台,可用于保持你的 React 应用程序与最新工具和库保持更新。你可以像更新其他库一样更新react-scripts库:通过更改版本号并重新运行npm install。你无需管理一长串的 Babel 插件或 postcss 库,也无需维护复杂的webpack.config.js文件。react-scripts库为你管理它们。

当然,配置仍然在那里,但是埋藏在react-scripts目录的深处。在那里,你会找到webpack.config.js文件,其中包含你的应用程序将使用的所有 Babel 配置和文件加载器。因为它是一个库,你可以像更新任何其他依赖项一样更新 React Scripts。

然而,如果你后来决定自行管理所有内容,完全可以这样做。如果你将应用程序弹出,那么所有内容将重新回到你的控制下:

$ npm run eject

然而,这是一次性的变更。一旦你弹出了应用程序,就无法再回去了。在弹出应用程序之前,请仔细考虑。你可能会发现你需要的配置已经可用。例如,开发者经常会弹出应用程序以切换到使用 TypeScript。现在,--template typescript选项消除了这种需要。

另一个常见的弹出的原因是代理 Web 服务。React 应用程序经常需要连接到某个独立的 API 后端。开发人员过去通过配置 Webpack 在本地开发服务器上代理远程服务器来完成此操作。现在您可以通过在 package.json 文件中设置代理来避免这样做:

"proxy": "http://myapiserver",

如果您的代码现在访问了服务器在本地找不到的 URL(/api/thing),react-scripts 会自动将这些请求代理到 http://myapiserver/api/thing

如果可能的话,避免弹出您的应用程序。查看 create-react-app 文档 看看是否可以通过其他方式进行更改。

您可以从 GitHub 站点下载此示例的源代码,支持 JavaScriptTypeScript

用 Gatsby 构建内容丰富的应用程序

问题

内容丰富 的网站,如博客和在线商店,需要有效地提供大量复杂的内容。像 create-react-app 这样的工具并不适合这种类型的网站,因为它会将所有内容打包为一个大的 JavaScript 捆绑包,浏览器必须在显示任何内容之前下载。

解决方案

如果您正在构建内容丰富的网站,请考虑使用 Gatsby。

Gatsby 专注于以最高效的方式加载、转换和提供内容。它可以生成网页的静态版本,这意味着 Gatsby 网站的响应时间通常明显慢于使用 create-react-app 构建的网站。

Gatsby 拥有许多插件,可以高效地从静态本地数据、GraphQL 数据源和第三方内容管理系统(如 WordPress)加载和转换数据。

您可以全局安装 gatsby,但也可以通过 npx 命令运行它:

$ npx gatsby new my-app

gatsby new 命令在名为 my-app 的子目录中创建一个新项目。第一次运行此命令时,它会询问要使用哪个包管理器:yarn 还是 npm

要启动您的应用程序,请进入新目录并以开发模式运行它:

$ cd my-app
$ npm run develop

此后,您可以打开您的应用程序,网址为 http://localhost:8000,如 图 1-3 所示。

图 1-3. Gatsby 页面位于 http://localhost:8000

Gatsby 项目具有简单的结构,如 图 1-4 所示。

图 1-4. Gatsby 目录结构

应用程序的核心位于 src 目录下。Gatsby 应用程序中的每个页面都有其自己的 React 组件。这是默认应用程序的首页,位于 index.js 中:

import * as React from "react"
import { Link } from "gatsby"
import { StaticImage } from "gatsby-plugin-image"

import Layout from "../components/layout"
import Seo from "../components/seo"

const IndexPage = () => (
  <Layout>
    <Seo title="Home" />
    <h1>Hi people</h1>
    <p>Welcome to your new Gatsby site.</p>
    <p>Now go build something great.</p>
    <StaticImage
      src="../images/gatsby-astronaut.png"
      width={300}
      quality={95}
      formats={["AUTO", "WEBP", "AVIF"]}
      alt="A Gatsby astronaut"
      style={{ marginBottom: `1.45rem` }}
    />
    <p>
      <Link to="/page-2/">Go to page 2</Link> <br />
      <Link to="/using-typescript/">Go to "Using TypeScript"</Link>
    </p>
  </Layout>
)

export default IndexPage

不需要为页面创建路由。每个页面组件都会自动分配一个路由。例如,位于 src/pages/using-typescript.tsx 的页面会自动在 using-typescript 处可用^(1)。这种方法有多个优点。首先,如果有多个页面,您无需手动管理它们的路由。其次,这意味着 Gatsby 可以更快地交付。要了解原因,请看如何为 Gatsby 应用程序生成生产构建。

如果停止 Gatsby 开发服务器^(2),您可以使用以下命令生成生产构建:

$ npm run build

此命令运行 gatsby build 命令,创建一个 public 目录。而 public 目录则包含了 Gatsby 的真正魔力。对于每个页面,您会找到两个文件。首先是生成的 JavaScript 文件:

1389 06:48 component---src-pages-using-typescript-tsx-93b78cfadc08d7d203c6.js

在这里,您可以看到 using-typescript.tsx 的代码仅有 1,389 字节长,与核心框架一起,这些 JavaScript 刚好足够构建页面。这不是在 create-react-app 项目中找到的包含所有内容的脚本。

其次,每个页面都有一个子目录,其中包含一个生成的 HTML 文件。例如,对于 using-typescript.tsx,文件名为 public/using-typescript/index.html,包含了网页的静态生成版本。它包含了 using-typescript.tsx 组件本应动态渲染的 HTML。在网页末尾,它加载页面的 JavaScript 版本来生成任何动态内容。

这种文件结构意味着 Gatsby 页面的加载速度与静态页面一样快。使用捆绑的 react-helmet 库,您还可以生成关于您站点的 <meta/> 标签,具有关于您站点的额外功能。这两个功能对搜索引擎优化(SEO)非常有利。

讨论

如何将内容导入到您的 Gatsby 应用程序中?您可以使用无头内容管理系统、GraphQL 服务、静态数据源或其他方式。幸运的是,Gatsby 有许多插件可以让您连接数据源到您的应用程序,然后将内容从其他格式(如 Markdown)转换为 HTML。

您可以在 Gatsby 网站 上找到完整的插件集。

大多数情况下,您在创建项目时选择需要的插件。为了让您更快上手,Gatsby 还支持 启动模板。模板提供了初始的应用程序结构和配置。我们之前构建的应用程序使用了默认的起始模板,非常简单。应用程序根目录下的 gatsby-config.js 文件配置了应用程序使用的插件。

但是有大量预配置的 Gatsby starters 可用于构建连接到各种数据源的应用程序,具有用于 SEO、样式、离线缓存、渐进式 Web 应用程序(PWA)等的预配置选项。无论您构建什么类型的内容丰富应用程序,都会有一个接近您需求的 starter。

关于Gatsby starters,以及关于最有用的工具和命令的cheat sheet,Gatsby 网站上有更多信息。

您可以从GitHub 网站下载此配方的源代码。

使用 Razzle 构建 Universal Apps

问题

有时当您开始构建应用程序时,很难确定哪些重要的架构决策。您应该创建 SPA 吗?如果性能至关重要,应该使用服务器端 r 吗?您需要决定您的部署平台将是什么,以及您是否将在 JavaScript 或 TypeScript 中编写代码。

许多工具要求您尽早回答这些问题。如果您后来改变主意,修改构建和部署应用程序的方式可能会很复杂。

解决方案

如果您希望推迟有关如何构建和部署应用程序的决策,您应该考虑使用Razzle

Razzle 是用于构建Universal applications的工具:可以在服务器上执行其 JavaScript。或者在客户端上。或者两者兼而有之。

Razzle 使用插件架构,允许您改变构建应用程序的方式。它甚至可以让您改变构建代码的想法,无论是在 React、Preact 还是完全不同的框架如 Elm 或 Vue。

使用create-razzle-app命令可以创建一个 Razzle 应用程序:^(3)

$ npx create-razzle-app my-app

此命令在my-app子目录中创建一个新的 Razzle 项目。您可以使用start脚本启动开发服务器:

$ cd my-app
$ npm run start

start脚本将动态构建客户端代码和服务器代码,然后在端口 3000 上运行服务器,如图 1-5 所示。

图 1-5. Razzle 首页位于 http://localhost:3000

当您希望部署应用程序的生产版本时,可以运行build脚本:

$ npm run build

不像create-react-app,这不仅会构建客户端代码,还会构建一个 Node 服务器。 Razzle 生成的代码位于build子目录下。服务器代码将在运行时继续为客户端生成静态代码。您可以通过运行build/server.js文件使用start:prod脚本启动生产服务器:

$ npm run start:prod

您可以将生产服务器部署到任何支持 Node 的地方。

服务器和客户端都可以运行相同的代码,这使得它变得Universal。但它是如何做到的呢?

客户端和服务器有不同的入口点。服务器在src/server.js中运行代码;浏览器在src/client.js中运行代码。server.jsclient.js都使用src/App.js渲染相同的应用程序。

如果要将您的应用程序作为 SPA 运行,请删除src/index.jssrc/server.js文件。然后在public文件夹中创建一个包含<div/>并带有 ID rootindex.html文件,并使用以下命令重新构建应用程序:

$ node_modules/.bin/razzle build --type=spa

要每次将您的应用程序构建为 SPA,请在package.jsonstartbuild脚本中添加--type=spa

您将生成一个完整的 SPA,位于build/public/目录下,可以部署到任何 Web 服务器上。

讨论

Razzle 非常适应,因为它是从一组高度可配置的插件构建的。每个插件都是一个高阶函数,接收一个 Webpack 配置并返回一个修改过的版本。一个插件可能会转译 TypeScript 代码,另一个插件可能会捆绑 React 库。

如果要将应用程序切换到 Vue,只需替换您使用的插件即可。

您可以在Razzle 网站上找到可用插件的列表。

您可以从GitHub 网站下载此配方的源代码。

使用 Next.js 管理服务器和客户端代码

问题

React 会生成客户端代码,即使在服务器上也是如此。但是,有时您可能希望将相对较少的应用程序编程接口(API)代码作为同一 React 应用程序的一部分管理。

解决方案

Next.js 是一个用于生成 React 应用程序和服务器代码的工具。API 端点和客户端页面使用默认路由约定,使它们比您自行管理时更容易构建和部署。您可以在网站上找到关于 Next.js 的详细信息。

您可以使用以下命令创建一个 Next.js 应用程序:

$ npx create-next-app my-app

如果已安装yarn,将使用它作为包管理器。您可以使用--user-npm标志强制使用npm包管理器:

$ npx create-next-app --use-npm my-app

这将在my-app子目录中创建一个 Next.js 应用程序。要启动该应用程序,请运行dev脚本(参见图 1-6):

$ cd my-app
$ npm run dev

图 1-6. 一个运行在 http://localhost:3000 的 Next.js 页面

Next.js 允许您创建页面,无需管理任何路由配置。例如,如果您向pages文件夹添加一个组件脚本,它将立即通过服务器可用。例如,默认应用程序的pages/index.js组件生成主页。

这种方法类似于 Gatsby 的方法^(4),但在 Next.js 中进一步包含了服务器端代码。

Next.js 应用通常包含一些 API 服务器代码,这在 React 应用中是不常见的,React 应用通常是与服务器代码分开构建的。但如果你查看 pages/api 目录,你会找到一个名为 hello.js 的示例服务器端点:

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction

export default (req, res) => {
  res.status(200).json({ name: 'John Doe' })
}

将此路由挂载到 api/hello 端点是自动完成的。

Next.js 将你的代码转译成一个名为 .next 的隐藏目录,然后可以部署到诸如 Next.js 自家平台 Vercel 的服务上。

如果你愿意,你可以通过以下命令生成应用的静态构建版本:

$ node_modules/.bin/next export

export 命令将会在一个名为 out 的目录中构建你的客户端代码。该命令会将每个页面转换为静态渲染的 HTML 文件,这样在浏览器中加载速度会很快。页面末尾会加载 JavaScript 版本以生成任何动态内容。

如果你创建了一个 Next.js 应用的导出版本,它将不会包含任何服务器端 API。

Next.js 提供了一系列数据获取选项,允许你从静态内容或通过 无头内容管理系统 (CMS) 源 获取数据。

讨论

Next.js 在很多方面与 Gatsby 类似。它的重点是交付速度,配置量很小。它可能对几乎没有服务器代码的团队最为有益。

你可以从 GitHub 站点 下载本示例的源代码。

使用 Preact 创建一个微型应用

问题

React 应用可能会很大。创建一个简单的 React 应用并将其转译成数百千字节大小的 JavaScript 捆绑包是非常容易的。你可能希望构建一个具有类似 React 功能但体积小得多的应用。

解决方案

如果你需要 React 的功能但不想付出 React 规模的 JavaScript 捆绑包代价,可以考虑使用 Preact。

Preact 不是 React。它是一个独立的库,旨在尽可能接近 React 但体积要小得多。

React 框架如此庞大的原因在于它的工作方式。React 组件不会直接在浏览器的文档对象模型 (DOM) 中生成元素。相反,它们在 虚拟 DOM 中构建元素,然后在频繁的间隔内更新实际 DOM。这样做可以让基本的 DOM 渲染速度很快,因为只有在实际发生变化时才需要更新实际 DOM。但这也有一个缺点。React 的虚拟 DOM 需要大量代码来保持更新。它需要管理一个完整的合成事件模型,与浏览器中的事件模型相似。因此,React 框架体积庞大,并且下载时间可能较长。

解决方法之一是使用 SSR 等技术,但配置 SSR 可能会比较复杂。^(5) 有时候,你只想下载少量代码。这就是为什么 Preact 存在的原因。

尽管 Preact 库与 React 类似,但体积小巧。在撰写本文时,主要的 Preact 库大小约为 4KB,足够小,以至于能够在网页中添加类似 React 的功能,几乎不需要比编写原生 JavaScript 多的代码。

Preact 允许您选择如何使用它:作为包含在网页中的小型 JavaScript 库(无需工具方法)或作为完整的 JavaScript 应用程序。

无需工具方法非常基础。核心 Preact 库不支持 JSX,您将无法使用现代 JavaScript。以下是使用原始 Preact 库的示例网页:

<html>
    <head>
        <title>No Tools!</title>
        <script src="https://unpkg.com/preact?umd"></script>
    </head>
    <body>
        <h1>No Tools Preact App!</h1>
        <div id="root"></div>
        <script>
         var h = window.preact.h;
         var render = window.preact.render;

         var mount = document.getElementById('root');

         render(
             h('button',
               {
                   onClick: function() {
                       render(h('div', null, 'Hello'), mount);
                   }
               },
               'Click!'),
             mount
         );
        </script>
    </body>
</html>

此应用程序将在 ID 为root<div/>中挂载自身,在那里它将显示一个按钮。当您单击按钮时,它将用字符串"Hello"替换根div的内容,这是 Preact 应用程序可以达到的基本操作。

你很少会以这种方式编写应用程序。实际上,您将创建一个简单的构建链,至少支持现代 JavaScript。

Preact 支持整个 JavaScript 应用程序的范围。在另一极端,您可以使用preact-cli创建完整的 Preact 应用程序。

preact-cli是一个用于创建 Preact 项目的工具,类似于create-react-app。您可以使用以下命令创建 Preact 应用程序:

$ npx preact-cli create default my-app

这个命令使用默认模板。其他模板可用于创建项目,例如,使用 Material 组件或 TypeScript。有关更多信息,请参见Preact GitHub 页面

此命令将在my-app子目录中创建您的新 Preact 应用程序。要启动它,请运行dev脚本:

$ cd my-app
$ npm run dev

服务器将在端口 8080 上运行,如图 1-7 所示。

图 1-7. 来自 Preact 的页面

服务器生成一个网页,该网页从src/index.js中的代码生成一个 JavaScript 捆绑包。

现在,您拥有一个类似于全尺寸的 React 应用程序。例如,Home组件(src/routes/home/index.js)中的代码看起来非常像 React,并支持完整的 JSX:

import { h } from 'preact';
import style from './style.css';

const Home = () => (
    <div class={style.home}>
        <h1>Home</h1>
        <p>This is the Home component.</p>
    </div>
);

export default Home;

与标准 React 组件的唯一显著区别是,从preact库导入了名为h的函数,而不是从react库导入React

Preact 代码中的 JSX 将转换为对h函数的一系列调用,这就是为什么需要导入它的原因。出于同样的原因,之前版本的create-react-app在版本 17 之前也需要导入react对象。从版本 17 开始,create-react-app切换到使用JSX 转换,不再需要每次导入react。未来版本的 Preact 可能会做出类似的更改。

不过,应用程序的大小已经增加到略高于 300KB。这相当大,但我们仍处于开发模式。要看到 Preact 的真正强大之处,请按 Ctrl-C 停止开发服务器,然后运行 build 脚本:

$ npm run build

此命令将在 build 目录中生成应用程序的静态版本。首先,这样做有助于创建首页的静态副本,渲染速度快。其次,它会删除应用程序中的所有未使用代码并将所有内容缩小。如果您在标准 Web 服务器上提供此构建版本的应用程序,则在打开时,浏览器只会传输大约 50–60KB 的内容。

讨论

Preact 是一个非常出色的项目。尽管它的工作方式与 React 完全不同,但在几乎相同的功能强大的同时,它的体积却只有一小部分。您可以用它来处理从最低级的内联代码到完整的单页面应用程序的任何事情,因此,如果代码大小对您的项目至关重要,那么考虑使用它是非常值得的。

您可以在 Preact 网站 上了解更多关于 Preact 的信息。

您可以从 GitHub 站点下载 无工具示例更大的 Preact 示例 的源代码。

如果想让 Preact 看起来更像 React,请查看 preact-compat 库。

最后,如果想要一个与 Preact 类似的项目,请查看 InfernoJS

使用 nwb 构建库

问题

大型组织通常同时开发多个 React 应用程序。如果您是咨询公司,可能会为多个组织创建应用程序。如果您是软件公司,可能会创建需要相同外观和感觉的各种应用程序,因此可能希望构建共享组件以在多个应用程序中使用。

创建组件项目时,需要创建目录结构,选择一组工具,选择一组语言特性,并创建能够将您的组件捆绑成可部署格式的构建链。这个过程可能与手动创建整个 React 应用程序的项目一样繁琐。

解决方案

您可以使用 nwb 工具包创建完整的 React 应用程序或单个 React 组件。它还可以为在 Preact 和 InfernoJS 项目中使用的组件创建组件,但我们在这里专注于 React 组件。

要创建一个新的 React 组件项目,首先需要全局安装 nwb 工具:

$ npm install -g nwb

使用 nwb 命令可以创建一个新项目:

$ nwb new react-component my-component

如果不只是创建单个组件,而是要创建一个完整的 nwb 应用程序,可以在该命令中用 react-apppreact-appinferno-app 替换 react-component,以在给定的框架中创建一个应用程序。如果想创建一个没有框架的基本 JavaScript 项目,则可以使用 vanilla-app

当您运行此命令时,它会询问您关于要构建的库类型的几个问题。例如,它会问您是否要构建 ECMAScript 模块:

Creating a react-component project...
? Do you want to create an ES modules build? (Y/n)

此选项允许您构建包含 export 语句的版本,Webpack 可以使用它来决定是否需要将组件包含在客户端应用程序中。还会询问您是否要创建通用模块定义(UMD):

? Do you want to create a UMD build? (y/N)

如果您希望在网页中的 <script/> 中包含组件,则这非常有用。对于我们的示例,我们不会创建 UMD 构建。

在问题回答完毕后,该工具将在 my-component 子目录中创建一个 nwb 组件项目。该项目附带一个简单的包装器应用程序,您可以使用 start 脚本启动它:

$ cd my-component
$ npm run start

演示应用程序在端口 3000 上运行,如 图 1-8 所示。

图 1-8. 一个 nwb 组件

应用程序将包含一个在 src/index.js 中定义的单个组件:

import React, { Component } from 'react'

export default class extends Component {
  render() {
    return (
      <div>
        <h2>Welcome to React components</h2>
      </div>
    )
  }
}

现在您可以像构建任何 React 项目一样构建组件。当您准备好创建可发布版本时,请输入:

$ npm run build

构建的组件将位于 lib/index.js,您可以将其部署到存储库中供其他项目使用。

讨论

要了解有关创建 nwb 组件的更多详细信息,请参阅 nwb 开发组件和库指南

您可以从 GitHub 网站 下载此示例的源代码。

使用 Webpacker 将 React 添加到 Rails 中

问题

Rails 框架是在交互式 JavaScript 应用程序变得流行之前创建的。Rails 应用程序遵循更传统的 Web 应用程序开发模型,即在服务器上生成 HTML 页面,以响应浏览器请求。但有时,您可能希望在 Rails 应用程序中包含更多交互式元素。

解决方案

使用 Webpacker 库可以将 React 应用程序插入由 Rails 生成的网页中。要查看其工作原理,首先让我们生成一个包含 Webpacker 的 Rails 应用程序:

$ rails new my-app --webpack=react

此命令将在名为 my-app 的目录中创建一个预配置为运行 Webpacker 服务器的 Rails 应用程序。在我们启动应用程序之前,让我们进入其中并生成一个示例页面/控制器:

$ cd my-app
$ rails generate controller Example index

此代码将在 app/views/example/index.html.erb 中生成此模板页面:

<h1>Example#index</h1>
<p>Find me in app/views/example/index.html.erb</p>

接下来,我们需要创建一个小的 React 应用程序,可以将其插入到此页面中。Rails 将 Webpacker 应用程序作为 packs 插入:即 Rails 内的小 JavaScript 捆绑包。我们将在 app/javascript/packs/counter.js 中创建一个新的 pack,其中包含一个简单的计数器组件:

import React, { useState } from 'react'
import ReactDOM from 'react-dom'

const Counter = (props) => {
  const [count, setCount] = useState(0)
  return (
    <div className="Counter">
      You have clicked the button {count} times.
      <button onClick={() => setCount((c) => c + 1)}>Click!</button>
    </div>
  )
}

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <Counter />,
    document.body.appendChild(document.createElement('div'))
  )
})

每当用户点击按钮时,此应用程序会更新计数器。

现在,我们可以通过向模板页面添加一行代码来将 pack 插入到网页中:

<h1>Example#index</h1>
<p>Find me in app/views/example/index.html.erb</p>
<%= javascript_pack_tag 'counter' %>

最后,我们可以在端口 3000 上运行 Rails 服务器:

$ rails server

在撰写本文时,启动服务器时需要安装 yarn 包管理器。你可以使用 npm install -g yarn 全局安装 yarn

你将在 Figure 1-9 中看到 http://localhost:3000/example/index.html 页面。

图 1-9. 一个嵌入在 http://localhost:3000/example/index.html 中的 React 应用程序

讨论

幕后,正如你可能已经猜到的那样,Webpacker 使用 Webpack 的副本转换应用程序,你可以使用 app/config/webpacker.yml 配置文件对其进行配置。

Webpacker 与 Rails 代码一起使用,而不是替代它。如果你的 Rails 应用程序需要少量的额外交互性,你应该考虑使用它。

你可以在 Webpacker GitHub 站点 上找到关于 Webpacker 的更多信息。

你可以从 GitHub 站点 下载本示例的源代码。

使用 Preact 创建自定义元素

问题

有时在现有内容中添加 React 代码是有挑战性的情况。例如,在某些 CMS 配置中,用户不允许将额外的 JavaScript 插入到页面的主体中。在这些情况下,有一个标准化的方式可以安全地将 JavaScript 应用程序插入到页面中将会很有帮助。

解决方案

自定义元素是创建新 HTML 元素的标准方法,您可以在网页上使用它们。实际上,它们通过使更多的标签可用来扩展 HTML 语言。

这个示例介绍了如何使用像 Preact 这样的轻量级框架创建自定义元素,并将其发布到第三方服务器上。

让我们从创建一个新的 Preact 应用程序开始。该应用程序将提供我们将能够在其他地方使用的自定义元素:^(6)

$ preact create default my-element

现在我们将进入应用程序目录,并向项目添加 preact-custom-element 库:

$ cd my-element
$ npm install preact-custom-element

preact-custom-element 库将允许我们在浏览器中注册一个新的自定义 HTML 元素。

接下来,我们需要修改 Preact 项目的 src/index.js 文件,以便注册一个新的自定义元素,我们将其命名为 components/Converter/index.js

import register from 'preact-custom-element'
import Converter from './components/Converter'

register(Converter, 'x-converter', ['currency'])

register 方法告诉浏览器,我们要创建一个名为 <x-converter/> 的新自定义 HTML 元素,该元素具有一个名为 currency 的属性,我们将在 src/components/Converter/index.js 中定义它:

import { h } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import 'style/index.css'

const rates = { gbp: 0.81, eur: 0.92, jpy: 106.64 }

export default ({ currency = 'gbp' }) => {
  const [curr, setCurr] = useState(currency)
  const [amount, setAmount] = useState(0)

  useEffect(() => {
    setCurr(currency)
  }, [currency])

  return (
    <div className="Converter">
      <p>
        <label htmlFor="currency">Currency: </label>
        <select
          name="currency"
          value={curr}
          onChange={(evt) => setCurr(evt.target.value)}
        >
          {Object.keys(rates).map((r) => (
            <option value={r}>{r}</option>
          ))}
        </select>
      </p>
      <p className="Converter-amount">
        <label htmlFor="amount">Amount: </label>
        <input
          name="amount"
          size={8}
          type="number"
          value={amount}
          onInput={(evt) => setAmount(parseFloat(evt.target.value))}
        />
      </p>
      <p>
        Cost:
        {((amount || 0) / rates[curr]).toLocaleString('en-US', {
          style: 'currency',
          currency: 'USD',
        })}
      </p>
    </div>
  )
}

要符合自定义元素规范,我们必须为我们的元素选择一个以小写字母开头、不包含任何大写字母并包含连字符的名称。^(7) 这种约定确保该名称不会与任何标准元素名称冲突。

我们的 Converter 组件是一个货币转换器,在我们的示例中使用了一组固定的汇率。如果现在启动我们的 Preact 服务器:

$ npm run dev

自定义元素的 JavaScript 将在 http://localhost:8080/bundle.js 上可用。

要使用这个新的自定义元素,让我们在某个地方创建一个静态网页,并使用以下 HTML:

<html>
    <head>
        <script src="https://unpkg.com/babel-polyfill/dist/polyfill.min.js">
        </script>
        <script src="https://unpkg.com/@webcomponents/webcomponentsjs">
        </script>
        <!-- Replace this with the address of your custom element -->
        <script type="text/javascript" src="http://localhost:8080/bundle.js">
        </script>
    </head>
    <body>
        <h1>Custom Web Element</h1>
        <div style="float: right; clear: both">
            <!-- This tag will insert the Preact app -->
            <x-converter currency="jpy"/>
        </div>
        <p>This page contains an example custom element called
            <code>&lt;x-converter/&gt;</code>,
            which is being served from a different location</p>
    </body>
</html>

此网页包含在 <head/> 元素的最终 <script/> 中嵌入的自定义元素的定义。为了确保自定义元素在新旧浏览器中都可用,我们还从 unpkg.com 包含了一些 shims。

现在我们已经在网页中包含了自定义元素代码,我们可以像将其作为标准 HTML 的一部分一样插入 <x-converter/> 标签。在我们的示例中,我们还向底层 Preact 组件传递了一个 currency 属性。

无论我们在 HTML 中如何定义它们,自定义元素属性都以小写名称传递给底层组件。

我们可以通过一个独立于 Preact 服务器的 Web 服务器运行此页面。图 1-10 显示了新的自定义元素。

图 1-10. 嵌入静态页面中的自定义元素

讨论

自定义元素不需要与使用它的网页位于同一服务器上,这意味着我们可以使用自定义元素为任何网页发布小部件。因此,您可能希望检查任何传入请求的 Referer 标头,以防止未经授权的使用。

我们的示例是从 Preact 的开发服务器提供自定义元素。对于生产发布,您可能希望创建组件的静态构建,这可能会显著减小体积。^(8)

您可以从 GitHub 网站 下载此示例的源代码。

使用 Storybook 进行组件开发

问题

React 组件是 React 应用程序的稳定构建材料。如果我们仔细编写它们,就可以在其他 React 应用程序中重用这些组件。但是当你构建一个组件时,需要检查它在所有情况下的工作情况。例如,在异步应用程序中,React 可能会使用未定义的属性渲染组件。组件仍然会正确渲染吗?会显示错误吗?

但是,如果您正在构建复杂应用程序的组件,可能很难创建组件需要处理的所有情况。

另外,如果您的团队中有专门的用户体验(UX)开发人员,如果他们必须浏览整个应用程序才能查看正在开发的单个组件,那将浪费很多时间。

如果有一种方法可以独立显示组件并传递示例属性集,那将会很有帮助。

解决方案

Storybook 是一种用于显示各种状态下组件库的工具。您可以将其描述为组件的画廊,但这可能有点贬低它。实际上,Storybook 是一个用于组件开发的工具。

如何将 Storybook 添加到项目中?让我们从使用 create-react-app 创建一个 React 应用程序开始:

$ npx create-react-app my-app
$ cd my-app

现在我们可以将 Storybook 添加到项目中:

$ npx sb init

我们可以使用 yarnnpm 启动 Storybook 服务器:

$ npm run storybook

Storybook 在 9000 端口上运行一个单独的服务器,如您在 Figure 1-11 中所见。使用 Storybook 时,无需运行实际的 React 应用程序。

Figure 1-11. Storybook 的欢迎页面

Storybook 将用示例属性呈现的单个组件称为 story。Storybook 的默认安装在应用程序的 src/stories 目录中生成示例故事。例如,src/stories/Button.stories.js

import React from 'react';

import { Button } from './Button';

export default {
  title: 'Example/Button',
  component: Button,
  argTypes: {
    backgroundColor: { control: 'color' },
  },
};

const Template = (args) => <Button {...args} />;

export const Primary = Template.bind({});
Primary.args = {
  primary: true,
  label: 'Button',
};

export const Secondary = Template.bind({});
Secondary.args = {
  label: 'Button',
};

export const Large = Template.bind({});
Large.args = {
  size: 'large',
  label: 'Button',
};

export const Small = Template.bind({});
Small.args = {
  size: 'small',
  label: 'Button',
};

Storybook 监视源文件夹中命名为 *.stories.js 的文件,并不关心它们的位置,因此您可以自由地在喜欢的位置创建它们。一个典型的模式是将故事放在与其展示的组件相邻的文件夹中。因此,如果您将文件夹复制到不同的应用程序中,可以将故事作为活动文档包含在内。

Figure 1-12 显示了在 Storybook 中 Button.stories.js 的外观。

Figure 1-12. 一个示例故事

讨论

尽管其看起来简单,但 Storybook 是一个高效的开发工具。它允许您一次专注于一个组件。就像一种视觉单元测试,它使您能够在一系列可能的场景中尝试组件,以检查其行为是否适当。

Storybook 还拥有大量额外的 插件

插件允许您:

  • 检查无障碍问题(addon-a11y

  • 添加用于设置属性的交互式控件(Knobs

  • 为每个故事包含内联文档(Docs

  • 记录 HTML 的快照以测试更改的影响(Storyshots

还可以做更多事情。

欲了解更多关于 Storybook 的信息,请访问 该网站

您可以从 GitHub 站点 下载此示例的源代码。

使用 Cypress 在浏览器中测试您的代码

问题

大多数 React 项目都包含一个测试库。最常见的可能是 @testing-library/react,它与 create-react-app 捆绑在一起,或者是 Preact 使用的 Enzyme。

但是没有什么比在真实浏览器中测试代码更好,因为那会带来额外的复杂性。传统上,浏览器测试可能会不稳定,并且需要频繁维护,因为您需要每次升级浏览器时升级浏览器驱动程序(如 ChromeDriver)。

加上在后端服务器上生成测试数据的问题,基于浏览器的测试可能会复杂设置和管理。

解决方案

Cypress 测试框架 避免了传统浏览器测试的许多缺点。它在浏览器中运行,但避免了使用外部的 Web 驱动程序工具。相反,它通过网络端口直接与浏览器(如 Chrome 或 Electron)通信,然后注入 JavaScript 来运行大部分测试代码。

让我们创建一个 create-react-app 应用程序,看看它是如何工作的:

$ npx create-react-app --use-npm my-app

现在让我们进入应用程序目录并安装 Cypress:

$ cd my-app
$ npm install cypress --save-dev

在运行 Cypress 之前,我们需要配置它,以便它知道如何找到我们的应用程序。我们可以通过在应用程序目录中创建一个cypress.json文件,并告诉它我们应用程序的统一资源定位器(URL)来实现这一点:

{
  "baseUrl": "http://localhost:3000/"
}

一旦我们启动了主应用程序:

$ npm start

然后我们可以打开 Cypress:

$ npx cypress open

第一次运行 Cypress 时,它会安装所有需要的依赖项。现在我们将在cypress/integration目录下创建一个名为screenshot.js的测试,它将打开首页并截图:

describe('screenshot', () => {
    it('should be able to take a screenshot', () => {
        cy.visit('/');
        cy.screenshot('frontpage');
    });
});

您会注意到我们以 Jest 格式编写了测试。保存测试后,它将显示在主 Cypress 窗口中,如图 1-13 所示。

图 1-13. Cypress 窗口

如果双击测试,Cypress 将在浏览器中运行它。应用程序的首页将打开,并且测试将保存一个截图到cypress/screenshots/screenshot.js/frontpage.png

讨论

这里是您可以在 Cypress 中执行的一些示例命令:

命令 描述
cy.contains('Fred') 查找包含Fred的元素
cy.get('.Norman').click() 单击具有类Norman的元素
cy.get('input').type('Hi!') 在输入框中键入"Hi!"
cy.get('h1').scrollIntoView() 滚动<h1/>到视图中

这些只是与网页交互的一些命令。但是 Cypress 还有另一个绝招。Cypress 还可以修改浏览器内部的代码以更改时间(cy.clock())、cookies(cy.setCookie())、本地存储(cy.clearLocalStorage())和最令人印象深刻的是伪造请求和响应到 API 服务器。

它通过修改内置在浏览器中的网络功能来实现这一点,使得这段代码:

cy.route("/api/server?*", [{some: 'Data'}])

将拦截任何以/api/server?开头的服务器端点的请求,并返回 JSON 数组[{some: 'Data'}]

模拟网络响应可以彻底改变团队开发应用程序的方式,因为它将前端开发与后端解耦。浏览器测试可以指定它们所需的数据,而无需创建真实的服务器和数据库。

要了解更多关于 Cypress 的信息,请访问文档站点

您可以从GitHub 网站下载此配方的源代码。

^(1) 是的,这意味着 Gatsby 内置了对 TypeScript 的支持。

^(2) 大多数操作系统按 Ctrl-C 即可完成此操作。

^(3) 名称有意与create-react-app相似。Razzle 的维护者 Jared Palmer 将create-react-app列为 Razzle 的灵感之一。

^(4) 参见“使用 Gatsby 构建内容丰富的应用程序”。

^(5) 查看配方 1.2 和 1.3。

^(6) 欲了解如何创建 Preact 应用程序的更多信息,请参阅 “使用 Preact 创建微型应用”。

^(7) 参阅 WHATWG 规范 了解自定义元素和命名约定的详细信息。

^(8) 进一步了解如何缩小 Preact 的下载量,请参阅 “使用 Preact 创建微型应用”。

第二章:路由

本章将探讨使用 React 路由和react-router-dom库的技巧。

react-router-dom 使用声明式路由,这意味着你将路由看作是任何其他 React 组件。与按钮、文本字段和文本块不同,React 路由没有视觉外观。但在大多数其他方面,它们与按钮和文本块类似。路由存在于组件的虚拟 DOM 树中。它们监听当前浏览器位置的变化,并允许您打开和关闭界面的部分。它们使得单页面应用程序看起来像是多页面应用程序。

如果使用得当,它们可以使您的应用程序感觉像任何其他网站。用户可以将您的应用程序的部分添加到书签中,就像他们可能会将维基百科的页面添加到书签中一样。他们可以在浏览器历史记录中前进和后退,并且您的界面将正常工作。如果您对 React 不熟悉,深入了解路由的强大功能是非常值得的。

创建具有响应式路由的界面

问题

大多数应用程序在移动设备和笔记本电脑上都有用户使用,这意味着您可能希望您的 React 应用程序在所有屏幕尺寸上都能良好运行。使您的应用程序响应式涉及相对简单的 CSS 更改,以调整文本和屏幕布局的大小,并且更深入的更改可以使移动设备和桌面用户在浏览站点时有非常不同的体验。

我们的示例应用程序显示了一组人员的姓名和地址。在图 2-1 中,您可以看到应用程序在桌面机器上运行。

图 2-1. 应用程序的桌面视图

但是这种布局在移动设备上效果不佳,可能只能显示人员列表或者一个人的详细信息,但不能同时显示。

在 React 中,我们能做些什么来为移动设备和桌面用户提供定制的导航体验,而不需要创建完全不同的应用程序版本?

解决方案

我们将使用响应式路由。响应式路由会根据用户显示器的大小而变化。我们现有的应用程序使用单一路由显示一个人员的信息:/people/:id

当您导航到这个路由时,浏览器将显示图 2-1 中的页面。您可以看到人员列表显示在左侧。页面突出显示所选人员并在右侧显示其详细信息。

我们将修改我们的应用程序以处理在/people路由上的附加路由。然后我们将使路由具有响应能力,以便用户在不同设备上看到不同的内容:

路由 移动设备 桌面
/people 显示人员列表 重定向到people:someId
people:id 显示:id 的详细信息 显示人员列表和:id 的详细信息

我们需要哪些要素来实现这一点?首先,如果我们的应用程序尚未安装,我们需要安装react-router-dom

$ npm install react-router-dom

react-router-dom 库允许我们将浏览器当前位置与应用程序的状态协调一致。接下来,我们将安装 react-media 库,这样我们就可以创建响应式的 React 组件,以响应显示屏尺寸的变化:

$ npm install react-media

现在我们将创建一个响应式的 PeopleContainer 组件,它将管理我们想要创建的路由。在小屏幕上,我们的组件将显示人员列表或单个人员的详细信息中的一个。在大屏幕上,它将显示左侧人员列表和右侧单个人员详细信息的组合视图。

PeopleContainer 将使用 react-media 中的 Media 组件。Media 组件的作用类似于 CSS 的 @media 规则:它允许您生成适用于指定屏幕尺寸范围的输出。Media 组件接受一个 queries 属性,允许您指定一组屏幕尺寸。我们将定义一个单独的屏幕尺寸—small,它将用作移动和桌面屏幕之间的断点:

<Media queries={{
        small: "(max-width: 700px)"
    }}>
  ...
</Media>

Media 组件接受一个子组件,期望它是一个函数。这个函数会传入一个 size 对象,用于获取当前屏幕尺寸信息。在我们的例子中,size 对象会包含一个 small 属性,我们可以用它来决定显示哪些其他组件:

<Media queries={{
        small: "(max-width: 700px)"
    }}>
  {
    size => size.small ? [SMALL SCREEN COMPONENTS] : [BIG SCREEN COMPONENTS]
  }
</Media>

在查看返回的代码细节之前,我们需要看一下如何在应用程序中挂载 PeopleContainer。下面的代码将是我们的主要 App 组件:

import { BrowserRouter, Link, Route, Switch } from 'react-router-dom'
import PeopleContainer from './PeopleContainer'

function App() {
  return (
    <BrowserRouter>
      <Switch>
        <Route path="/people">
          <PeopleContainer />
        </Route>
        <Link to="/people">People</Link>
      </Switch>
    </BrowserRouter>
  )
}

export default App

我们正在使用来自 react-router-domBrowserRouter,它将我们的代码与浏览器的 HTML5 历史记录 API 连接起来。我们需要将所有路由包装在 Router 中,以便它们可以访问浏览器的当前地址。

BrowserRouter 内部,我们有一个 SwitchSwitch 会查看内部的组件,寻找与当前位置匹配的 Route。这里有一个单独的 Route,匹配路径以 /people 开头。如果条件成立,我们展示 PeopleContainer。如果没有匹配的路由,我们会继续查看 Switch 的末尾,并渲染一个指向 /people 路径的 Link。所以,当有人访问应用程序的首页时,他们只会看到一个指向 People 页面的链接。

代码将匹配以指定 path 开头的路由,除非指定了 exact 属性,在这种情况下,只有整个路径匹配时才会显示路由。

因此,如果我们在 PeopleContainer 内部,我们已经在以 /people/… 开头的路径上。如果在小屏幕上,我们需要显示人员列表或显示单个人员的详细信息,但不能同时显示。我们可以使用 Switch 来实现这一点:

<Media queries={{
        small: "(max-width: 700px)"
    }}>
  {
    size => size.small ? [SMALL SCREEN COMPONENTS]
        <Switch>
          <Route path='/people/:id'>
            <Person/>
          </Route>
          <PeopleList/>
        </Switch>
        : [BIG SCREEN COMPONENTS]
  }
</Media>

在小设备上,Media组件将使用一个值调用其子函数,该值表示size.smalltrue。我们的代码将渲染一个Switch,如果当前路径包含一个id,则显示Person组件。否则,Switch将无法匹配该Route,而是渲染一个PeopleList

即使我们还没有为大屏幕编写代码,如果我们现在在移动设备上运行此代码并点击首页的People链接,我们将导航至people,这可能会导致应用程序渲染PeopleList组件。PeopleList组件显示一组以/people/id形式的路径链接的人员。^(1) 当有人从列表中选择一个人时,我们的组件将重新渲染,这次PeopleContainer将显示单个人员的详细信息(参见图 2-2)。

图 2-2. 在移动视图中:左侧的人员列表链接到右侧的个人详情

目前为止一切顺利。现在我们需要确保我们的应用程序仍然适用于大屏幕。我们需要在PeopleContainer中生成响应式路由,以便当size.smallfalse时,如果当前路由形式为/people/id,我们可以在左侧显示PeopleList组件,在右侧显示Person组件:

<div style={{display: 'flex'}}>
  <PeopleList/>
  <Person/>
</div>

不幸的是,这并不能处理当前路径为/people的情况。我们需要另一个Switch,要么显示单个人员的详细信息,要么将重定向到列表中第一个人员的/people/first-person-id

<div style={{display: 'flex'}}>
    <PeopleList/>
    <Switch>
        <Route path='/people/:id'>
            <Person/>
        </Route>
        <Redirect to={`/people/${people[0].id}`}/>
    </Switch>
</div>

Redirect组件并不执行实际的浏览器重定向。它只是更新当前路径为/people/first-person-id,这会导致PeopleContainer重新渲染。这类似于在 JavaScript 中调用history.push(),但它不会在浏览器历史记录中添加额外页面。如果一个人导航至/people,浏览器将简单地将其位置更改为/people/first-person-id

如果我们现在在笔记本电脑或更大的平板电脑上转到/people,我们将看到人员列表旁边的第一个人员的详细信息(图 2-3)。

图 2-3. 在大屏幕上查看 http://localhost:3000/people 时看到的内容

这是我们的PeopleContainer的最终版本:

import Media from 'react-media'
import { Redirect, Route, Switch } from 'react-router-dom'
import Person from './Person'
import PeopleList from './PeopleList'
import people from './people'

const PeopleContainer = () => {
  return (
    <Media
      queries={{
        small: '(max-width: 700px)',
      }}
    >
      {(size) =>
        size.small ? (
          <Switch>
            <Route path="/people/:id">
              <Person />
            </Route>
            <PeopleList />
          </Switch>
        ) : (
          <div style={{ display: 'flex' }}>
            <PeopleList />
            <Switch>
              <Route path="/people/:id">
                <Person />
              </Route>
              <Redirect to={`/people/${people[0].id}`} />
            </Switch>
          </div>
        )
      }
    </Media>
  )
}

export default PeopleContainer

讨论

在组件内部声明式路由可能在初次接触时显得奇怪。假设您以前使用过集中式路由模型。在这种情况下,声明式路由可能一开始看起来很混乱,因为它们将应用程序的连接分布在多个组件中,而不是在单个文件中。而不是创建干净的组件,这些组件对外界一无所知,突然之间您需要给出应用程序中使用的路径的详细知识,这可能会使它们不太便携。

然而,响应式路由展示了声明式路由的真正威力。如果您担心您的组件对应用程序中的路径了解过多,请考虑将路径字符串提取到一个共享文件中。这样,您既可以根据当前路径修改组件行为,又可以有一个集中的路径配置。

您可以从GitHub 网站下载此示例的源代码。

将状态移入路由

问题

使用显示它的路由来管理组件的内部状态通常很有帮助。例如,这是一个显示两个信息选项卡的 React 组件:一个用于/people,一个用于/offices

import { useState } from 'react'
import People from './People'
import Offices from './Offices'

import './About.css'

const OldAbout = () => {
  const [tabId, setTabId] = useState('people')

  return (
    <div className="About">
      <div className="About-tabs">
        <div
          onClick={() => setTabId('people')}
          className={
            tabId === 'people' ? 'About-tab active' : 'About-tab'
          }
        >
          People
        </div>
        <div
          onClick={() => setTabId('offices')}
          className={
            tabId === 'offices' ? 'About-tab active' : 'About-tab'
          }
        >
          Offices
        </div>
      </div>
      {tabId === 'people' && <People />}
      {tabId === 'offices' && <Offices />}
    </div>
  )
}

export default OldAbout

当用户点击选项卡时,内部的tabId变量会更新,并显示PeopleOffices组件(参见图 2-4)。

图 2-4。默认情况下,OldAbout 组件显示人员的详细信息

问题在哪里?组件是有效的,但如果我们选择 Offices 选项卡然后刷新页面,组件将重置为 People 选项卡。同样地,当页面在 Offices 选项卡上时,我们无法将其作为书签保存。我们无法在应用程序的其他位置创建直接导航到 Offices 的链接。辅助功能硬件可能不会注意到选项卡作为超链接的工作方式,因为它们没有以那种方式呈现。

解决方案

我们将从组件中移动tabId状态到当前浏览器位置。因此,不再在/about路径渲染组件,然后使用onClick事件来改变内部状态,而是将路由设置为/about/people/about/offices,显示其中一个选项卡。选项卡选择将在浏览器刷新时保留。我们可以将页面上的特定选项卡书签保存或创建链接到特定选项卡。并且我们将选项卡实际作为超链接,这样键盘或屏幕阅读器导航的人就会认识到它们。

我们需要哪些成分?只需一个:react-router-dom

$ npm install react-router-dom

react-router-dom将允许我们将当前浏览器的 URL 与我们在屏幕上呈现的组件同步。

我们现有的应用程序已经使用react-router-dom在路径/oldabout显示OldAbout组件,如您可以从App.js文件的这段代码片段中看到的那样:

<Switch>
    <Route path="/oldabout">
        <OldAbout/>
    </Route>
    <p>Choose an option</p>
</Switch>

您可以在GitHub 存储库中查看此文件的完整代码。

我们将创建一个名为AboutOldAbout组件的新版本,并将其挂载到自己的路由上:

<Switch>
    <Route path="/oldabout">
        <OldAbout/>
    </Route>
    <Route path="/about/:tabId?">
        <About/>
    </Route>
    <p>Choose an option</p>
</Switch>

此添加允许我们在示例应用程序中打开两个版本的代码。

我们的新版本看起来几乎与旧组件相同。我们将tabId从组件中提取出来,并移入当前路径。

Route 的路径设置为 /about/:tabId? 意味着 /about/about/offices/about/people 都将装载我们的组件。? 表示 tabId 参数是可选的。

现在我们已经完成了第一部分:我们已经将组件的状态放入了显示它的路径中。现在我们需要更新组件,使其与路由交互,而不是与内部状态变量交互。

OldAbout 组件中,我们在每个选项卡上都有 onClick 监听器:

<div onClick={() => setTabId("people")}
     className={tabId === "people" ? "About-tab active" : "About-tab"}
>
    People
</div>
<div onClick={() => setTabId("offices")}
     className={tabId === "offices" ? "About-tab active" : "About-tab"}
>
    Offices
</div>

我们将把这些转换为 Link 组件,转到 /about/people/about/offices。事实上,我们将它们转换为 NavLink 组件。NavLink 就像一个链接,但是它有能力在其链接的位置是当前位置时设置额外的类名。这意味着我们不需要原始代码中的 className 逻辑:

<NavLink to="/about/people"
         className="About-tab"
         activeClassName="active">
    People
</NavLink>
<NavLink to="/about/offices"
         className="About-tab"
         activeClassName="active">
    Offices
</NavLink>

我们不再设置 tabId 变量的值。我们改为在路径中跳转到新位置,并带有新的 tabId 值。

但是我们该如何读取 tabId 的值呢?OldAbout 代码像这样显示当前选项卡内容:

{tabId === "people" && <People/>}
{tabId === "offices" && <Offices/>}

这段代码可以替换为一个 Switch 和几个 Route 组件:

<Switch>
    <Route path='/about/people'>
        <People/>
    </Route>
    <Route path='/about/offices'>
        <Offices/>
    </Route>
</Switch>

我们现在几乎完成了。只剩下一步:如果路径是 /about 且不包含 tabId,我们该如何决定。

OldAbout 在首次创建状态时为 tabId 设置了默认值:

const [tabId, setTabId] = useState("people")

我们可以通过在 Switch 结尾添加一个 Redirect 来实现相同的效果。Switch 会按顺序处理其子组件,直到找到匹配的 Route。如果当前路径没有匹配的 Route,它将达到 Redirect,这将把地址更改为 /about/people。这将导致 About 组件重新渲染,并且默认选择 People 标签:

<Switch>
    <Route path='/about/people'>
        <People/>
    </Route>
    <Route path='/about/offices'>
        <Offices/>
    </Route>
    <Redirect to='/about/people'/>
</Switch>

您可以通过为 Redirect 提供一个 from 属性来使其在当前路径上有条件地执行。在这种情况下,我们可以将 from 设置为 /about,这样只有匹配 /about 的路由才会被重定向到 /about/people

这是我们完成的 About 组件:

import { NavLink, Redirect, Route, Switch } from 'react-router-dom'
import './About.css'
import People from './People'
import Offices from './Offices'

const About = () => (
  <div className="About">
    <div className="About-tabs">
      <NavLink
        to="/about/people"
        className="About-tab"
        activeClassName="active"
      >
        People
      </NavLink>
      <NavLink
        to="/about/offices"
        className="About-tab"
        activeClassName="active"
      >
        Offices
      </NavLink>
    </div>
    <Switch>
      <Route path="/about/people">
        <People />
      </Route>
      <Route path="/about/offices">
        <Offices />
      </Route>
      <Redirect to="/about/people" />
    </Switch>
  </div>
)

export default About

我们不再需要内部的 tabId 变量,现在我们有一个纯声明的组件(参见图 2-5)。

图 2-5. 使用新组件访问 http://localhost/about/offices

讨论

将状态从组件移至地址栏可能会简化您的代码,但这只是一个幸运的副作用。真正的价值在于,您的应用程序开始表现得不像应用程序,而更像一个网站。我们可以书签页,并且浏览器的“后退”和“前进”按钮能正常工作。在路由中管理更多状态不是抽象的设计决策;这是使您的应用程序对用户来说不那么令人惊讶的一种方式。

您可以从 GitHub 站点 下载此示例的源代码。

用 MemoryRouter 进行单元测试

问题

我们在 React 应用程序中使用路由,以便更好地利用浏览器的功能。我们可以收藏页面,创建应用程序的深链接,并在历史记录中前进和后退。

然而,一旦我们使用路由,我们使组件依赖于其外部的某些东西:浏览器位置。这可能看起来不是太大的问题,但它确实有后果。

假设我们想要对一个路由感知的组件进行单元测试。例如,让我们为我们在“将状态移到路由”中构建的About组件创建一个单元测试:

describe('About component', () => {
  it('should show people', () => {
    render(<About />)
    expect(screen.getByText('Kip Russel')).toBeInTheDocument()
  })
})

这个单元测试渲染组件,然后检查它是否能在输出中找到“Kip Russel”这个名字。当我们运行这个测试时,我们得到以下错误:

console.error node_modules/jsdom/lib/jsdom/virtual-console.js:29
    Error: Uncaught [Error: Invariant failed: You should not use <NavLink>
        outside a <Router>]

错误发生是因为NavLink找不到组件树中更高级别的Router。这意味着我们需要在测试之前将组件包装在Router中。

另外,我们可能希望编写一个单元测试,检查About组件在特定路由上挂载时的工作情况。即使我们提供一个Router组件,我们如何伪造一个特定的路由?

这不仅仅是单元测试的问题。如果我们使用像 Storybook 这样的库工具,我们可能希望展示一个组件在特定路径上挂载时的示例。

我们需要像实际浏览器路由器那样的东西,但允许我们指定其行为。

解决方案

react-router-dom库提供了这样一个路由器:MemoryRouterMemoryRouter在外界看来与BrowserRouter一样。不同之处在于,虽然BrowserRouter是底层浏览器历史记录 API 的接口,但MemoryRouter没有这样的依赖。它可以跟踪当前位置,并可以在历史记录中前进和后退,但它通过简单的内存结构实现了这一点。

让我们再看看那个失败的单元测试。不只是渲染About组件,让我们把它包装在MemoryRouter中:

describe('About component', () => {
  it('should show people', () => {
    render(
      <MemoryRouter>
        <About />
      </MemoryRouter>
    )

    expect(screen.getByText('Kip Russel')).toBeInTheDocument()
  })
})

现在,当我们运行测试时,它可以工作了。这是因为MemoryRouter将一个模拟版本的 API 注入到上下文中。这使得它对所有子组件都可用。About组件现在可以呈现LinkRoute,因为历史记录可用。

但是MemoryRouter有一个额外的优势。因为它伪造了浏览器的历史记录 API,它可以使用initialEntries属性提供完全虚假的历史记录。initialEntries属性应该设置为历史记录条目的数组。如果传递一个单值数组,它将被解释为当前位置。这使您能够编写单元测试,检查组件在给定路由上挂载时的行为:

describe('About component', () => {
  it('should show offices if in route', () => {
    render(
      <MemoryRouter initialEntries={[{ pathname: '/about/offices' }]}>
        <About />
      </MemoryRouter>
    )

    expect(screen.getByText('South Dakota')).toBeInTheDocument()
  })
})

我们可以在 Storybook 中使用真实的BrowserRouter,因为我们在真实的浏览器中,但MemoryRouter也允许我们伪造当前位置,就像我们在ToAboutOffices Storybook 故事中所做的那样(见图 2-6)。

图 2-6. 使用 MemoryRouter,我们可以模拟 /about/offices 路由。

讨论

路由器允许您将希望去哪里的详细信息与如何到达目的地分开。在这个示例中,我们看到了这种分离的一个优势:我们可以创建一个虚拟的浏览器位置来检查不同路由上组件的行为。这种分离允许您更改应用程序跟随链接的方式,而不会导致中断。如果将单页应用程序转换为服务器端渲染应用程序,可以将 BrowserRouter 替换为 StaticRouter。用于调用浏览器历史 API 的链接将成为导致浏览器进行本机页面加载的本机超链接。路由器是分离策略(您想做什么)和机制(您将如何做到)优势的一个很好的例子。

您可以从 GitHub 网站 下载此配方的源代码。

使用 Prompt 进行页面退出确认

问题

有时,如果用户正在编辑某些内容,需要询问用户是否确认离开页面。这个看似简单的任务可能会很复杂,因为它依赖于识别用户何时点击“返回”按钮,然后找到拦截历史记录移动并可能取消其行为的方法(见图 2-7)。

图 2-7. 离开前询问确认

如果应用程序中有多个页面需要相同的功能,该如何简单地创建此功能以覆盖任何需要它的组件?

解决方案

react-router-dom 库包含一个名为 Prompt 的组件,用于要求用户确认是否离开页面。

此处我们唯一需要的配料是 react-router-dom 库本身:

npm install react-router-dom

假设我们有一个名为 Important 的组件,安装在 /important,允许用户编辑一段文本:

import React, { useEffect, useState } from 'react'

const Important = () => {
  const initialValue = 'Initial value'

  const [data, setData] = useState(initialValue)
  const [dirty, setDirty] = useState(false)

  useEffect(() => {
    if (data !== initialValue) {
      setDirty(true)
    }
  }, [data, initialValue])

  return (
    <div className="Important">
      <textarea
        onChange={(evt) => setData(evt.target.value)}
        cols={40}
        rows={12}
      >
        {data}
      </textarea>
      <br />
      <button onClick={() => setDirty(false)} disabled={!dirty}>
        Save
      </button>
    </div>
  )
}

export default Important

Important 已经在跟踪 textarea 中的文本是否与原始值不同。如果文本不同,dirty 的值为 true。当 dirtytrue 时,我们如何要求用户确认他们是否希望离开页面,如果他们点击“返回”按钮?

我们添加了一个 Prompt 组件:

return (
  <div className="Important">
    <textarea
      onChange={(evt) => setData(evt.target.value)}
      cols={40}
      rows={12}
    >
      {data}
    </textarea>
    <br />
    <button onClick={() => setDirty(false)} disabled={!dirty}>
      Save
    </button>
    <Prompt
      when={dirty}
      message={() => 'Do you really want to leave?'}
    />
  </div>
)

如果用户编辑文本,然后点击“返回”按钮,将显示 Prompt(见图 2-8)。

图 2-8. Prompt 要求用户确认他们希望离开

添加确认信息很容易,但默认的提示界面是一个简单的 JavaScript 对话框。我们希望能够自行决定用户确认离开页面的方式。

为了演示我们如何做到这一点,让我们将 Material-UI 组件库添加到应用程序中:

$ npm install '@material-ui/core'

Material-UI 库是 Google Material Design 标准的 React 实现。我们将使用它作为一个示例,说明如何用更定制化的内容替换标准的 Prompt 界面。

Prompt组件不会呈现任何 UI。相反,Prompt组件请求当前的Router显示确认对话框。默认情况下,BrowserRouter显示默认的 JavaScript 对话框,但可以用自己的代码替换它。

BrowserRouter添加到组件树中后,我们可以传递一个名为getUserConfirmation的属性:

<div className="App">
    <BrowserRouter
        getUserConfirmation={(message, callback) => {
          // Custom code goes here
        }}
    >
        <Switch>
            <Route path='/important'>
                <Important/>
            </Route>
        </Switch>
    </BrowserRouter>
</div>

getUserConfirmation属性是一个接受两个参数的函数:要显示的消息和一个回调函数。

当用户单击“返回”按钮时,Prompt组件将运行getUserCon⁠firmation,然后等待回调函数被调用,参数为truefalse

回调函数以异步方式返回用户的响应。Prompt组件会等待我们询问用户想要做什么。这使我们能够创建一个自定义界面。

让我们创建一个名为Alert的自定义 Material-UI 对话框。我们将使用这个对话框代替默认的 JavaScript 模态框:

import Button from '@material-ui/core/Button'
import Dialog from '@material-ui/core/Dialog'
import DialogActions from '@material-ui/core/DialogActions'
import DialogContent from '@material-ui/core/DialogContent'
import DialogContentText from '@material-ui/core/DialogContentText'
import DialogTitle from '@material-ui/core/DialogTitle'

const Alert = ({ open, title, message, onOK, onCancel }) => {
  return (
    <Dialog
      open={open}
      onClose={onCancel}
      aria-labelledby="alert-dialog-title"
      aria-describedby="alert-dialog-description"
    >
      <DialogTitle id="alert-dialog-title">{title}</DialogTitle>
      <DialogContent>
        <DialogContentText id="alert-dialog-description">
          {message}
        </DialogContentText>
      </DialogContent>
      <DialogActions>
        <Button onClick={onCancel} color="primary">
          Cancel
        </Button>
        <Button onClick={onOK} color="primary" autoFocus>
          OK
        </Button>
      </DialogActions>
    </Dialog>
  )
}

export default Alert

当然,我们没有必要显示对话框。我们可以显示倒计时器或者 Snackbar 消息,或者自动保存用户的更改。但我们将显示一个自定义的Alert对话框。

我们将如何在界面中使用Alert组件?首先,我们需要创建自己的getUserConfirmation函数。我们将存储消息和回调函数,然后设置一个布尔值,表示我们要打开Alert对话框:

const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmMessage, setConfirmMessage] = useState()
const [confirmCallback, setConfirmCallback] = useState()

return (
  <div className="App">
    <BrowserRouter
      getUserConfirmation={(message, callback) => {
        setConfirmMessage(message)
        // Use this setter form because callback is a function
        setConfirmCallback(() => callback)
        setConfirmOpen(true)
      }}
    >
  .....

值得注意的是,当我们存储回调函数时,我们使用setConfirmCallback(() => callback)而不是简单地写setConfirmCallback(callback)。这是因为useState钩子返回的设置器将执行传递给它们的任何函数,而不是仅仅存储它们。

然后,我们可以使用confirmMessageconfirmCallbackconfirmOpen的值来渲染界面中的Alert

这是完整的App.js文件:

import { useState } from 'react'
import './App.css'
import { BrowserRouter, Link, Route, Switch } from 'react-router-dom'
import Important from './Important'
import Alert from './Alert'

function App() {
  const [confirmOpen, setConfirmOpen] = useState(false)
  const [confirmMessage, setConfirmMessage] = useState()
  const [confirmCallback, setConfirmCallback] = useState()

  return (
    <div className="App">
      <BrowserRouter
        getUserConfirmation={(message, callback) => {
          setConfirmMessage(message)
          // Use this setter form because callback is a function
          setConfirmCallback(() => callback)
          setConfirmOpen(true)
        }}
      >
        <Alert
          open={confirmOpen}
          title="Leave page?"
          message={confirmMessage}
          onOK={() => {
            confirmCallback(true)
            setConfirmOpen(false)
          }}
          onCancel={() => {
            confirmCallback(false)
            setConfirmOpen(false)
          }}
        />
        <Switch>
          <Route path="/important">
            <Important />
          </Route>
          <div>
            <h1>Home page</h1>
            <Link to="/important">Go to important page</Link>
          </div>
        </Switch>
      </BrowserRouter>
    </div>
  )
}

export default App

现在,当用户退出编辑时,他们会看到自定义对话框,如图 2-9 所示。

图 2-9. 当用户单击“返回”按钮时,自定义警报框将出现

讨论

在这个示例中,我们使用组件库重新实现了Prompt模态框,但你不必仅仅将一个对话框替换为另一个对话框。如果有人离开页面,你完全可以做其他的事情:比如将工作进度保存在某个地方,以便稍后返回。getUserConfirmation函数的异步特性允许这种灵活性。这是react-router-dom如何抽象出横切关注点的另一个例子。

您可以从GitHub 网站下载此示例的源代码。

使用 React Transition Group 创建过渡效果

问题

原生和桌面应用程序通常使用动画来在视觉上连接不同的元素。如果您点击列表中的一个项目,它会展开以显示详细信息。左右滑动可以用来指示用户接受或拒绝一个选项。

因此,动画通常用于指示位置变化。它们放大细节。它们带你去列表中的下一个人。我们用匹配的动画反映了 URL 的变化。

但是当我们从一个位置移动到另一个位置时,如何创建动画呢?

解决方案

对于这个示例,我们将需要react-router-dom库和react-transition-group库:

$ npm install react-router-dom
$ npm install react-transition-group

我们将对我们先前使用的About组件进行动画化处理。^(4) About组件有两个称为 People 和 Offices 的选项卡,它们分别显示在/about/people/about/offices路由上。

当有人点击其中一个选项卡时,我们将淡出旧选项卡的内容,然后淡入新选项卡的内容。虽然我们使用了淡出效果,但我们也可以使用更复杂的动画,比如将选项卡内容向左或向右滑动。^(5) 然而,简单的淡入淡出动画将更清楚地展示其工作原理。

About组件内部,选项卡内容由PeopleOffices组件在不同的路由内呈现:

import { NavLink, Redirect, Route, Switch } from 'react-router-dom'
import './About.css'
import People from './People'
import Offices from './Offices'

const About = () => (
  <div className="About">
    <div className="About-tabs">
      <NavLink
        to="/about/people"
        className="About-tab"
        activeClassName="active"
      >
        People
      </NavLink>
      <NavLink
        to="/about/offices"
        className="About-tab"
        activeClassName="active"
      >
        Offices
      </NavLink>
    </div>
    <Switch>
      <Route path="/about/people">
        <People />
      </Route>
      <Route path="/about/offices">
        <Offices />
      </Route>
      <Redirect to="/about/people" />
    </Switch>
  </div>
)

export default About

我们需要为Switch组件内部的组件添加动画效果。为此,我们需要两样东西:

  • 有些事情需要跟踪位置何时发生了变化

  • 有些事情需要在选项卡内容发生变化时进行动画处理

我们如何知道位置何时发生了变化?我们可以从react-router-domuseLocation钩子中获取当前位置:

const location = useLocation()

现在进入更复杂的任务:动画本身。接下来是一系列相当复杂的事件,但花时间理解它是值得的。

当我们从一个组件动画到另一个组件时,我们需要保持页面上的两个组件。随着Offices组件的淡出,People组件淡入。^(6) 我们可以通过将这两个组件放在转换组中来实现这一点。转换组是一组组件,其中一些出现,而其他一些消失。

我们可以通过将我们的动画包裹在TransitionGroup组件中来创建一个过渡组。我们还需要一个CSSTransition组件来协调 CSS 动画的细节。

我们更新的代码将Switch同时包裹在TransitionGroupCSSTransition中:

import {
  NavLink,
  Redirect,
  Route,
  Switch,
  useLocation,
} from 'react-router-dom'
import People from './People'
import Offices from './Offices'
import {
  CSSTransition,
  TransitionGroup,
} from 'react-transition-group'

import './About.css'
import './fade.css'

const About = () => {
  const location = useLocation()

  return (
    <div className="About">
      <div className="About-tabs">
        <NavLink
          to="/about/people"
          className="About-tab"
          activeClassName="active"
        >
          People
        </NavLink>
        <NavLink
          to="/about/offices"
          className="About-tab"
          activeClassName="active"
        >
          Offices
        </NavLink>
      </div>
      <TransitionGroup className="About-tabContent">
        <CSSTransition
          key={location.key}
          classNames="fade"
          timeout={500}
        >
          <Switch location={location}>
            <Route path="/about/people">
              <People />
            </Route>
            <Route path="/about/offices">
              <Offices />
            </Route>
            <Redirect to="/about/people" />
          </Switch>
        </CSSTransition>
      </TransitionGroup>
    </div>
  )
}

export default About

注意我们将 location.key 传递给 CSSTransition 组的 key,并将 location 传递给 Switch 组件。location.key 是当前位置的哈希值。将 location.key 传递给过渡组件将使 CSSTransition 在动画完成之前保持在虚拟 DOM 中。当用户点击其中一个标签时,位置会发生变化,这将刷新 About 组件。TransitionGroup 将在组件树中保留现有的 CSSTransition 直到其超时结束:即 500 毫秒。但现在它还会有第二个 CSSTransition 组件。

每个 CSSTransition 组件都将保持其子组件的活动状态(见图 2-10)。

图 2-10. 过渡组件在虚拟 DOM 中保留了旧组件和新组件

我们需要将 location 值传递给 Switch 组件:我们需要旧标签页的 Switch,并且我们需要新标签页的 Switch 来持续渲染它们的路由。

现在,让我们来看动画本身。CSSTransition 组件接受一个名为 classNames 的属性,我们将其设置为值 fade。请注意,classNames 是复数形式,以区分它与标准的 className 属性。

CSSTransition 将使用 classNames 生成四个不同的类名:

  • fade-enter

  • fade-enter-active

  • fade-exit

  • fade-exit-active

fade-enter 类用于即将开始动画进入视图的组件。fade-enter-active 类应用于实际动画中的组件。fade-exitfade-exit-active 用于即将开始或正在消失动画中的组件。

CSSTransition 组件将这些类名添加到其直接子元素。如果我们从 Offices 标签页到 People 标签页进行动画处理,那么旧的 CSSTransition 将在 People HTML 中添加 fade-enter-active 类,并在 Offices HTML 中添加 fade-exit-active 类。

剩下的事情就是定义 CSS 动画本身:

.fade-enter {
    opacity: 0;
}
.fade-enter-active {
    opacity: 1;
    transition: opacity 250ms ease-in;
}
.fade-exit {
    opacity: 1;
}
.fade-exit-active {
    opacity: 0;
    transition: opacity 250ms ease-in;
}

fade-enter- 类使用 CSS 过渡效果将组件的不透明度从 0 变为 1. fade-exit- 类将组件的不透明度从 1 变为 0. 通常建议将动画类定义放在单独的 CSS 文件中。这样,我们可以在其他动画中重用它们。

动画完成。当用户点击标签时,他们会看到内容从旧数据向新数据交叉淡出(见图 2-11)。

图 2-11. 标签页内容从 Offices 淡出到 People

讨论

当动画使用不当时,它们可能非常恼人。每个添加的动画都应该有其意图。如果您只是因为认为它会很吸引人而添加动画,那么几乎可以肯定用户会不喜欢它。通常,在添加动画之前最好先问几个问题:

  • 此动画是否会澄清两个路由之间的关系?您是放大以查看更多细节,还是横向移动以查看相关项目?

  • 动画的时长应该多长?超过半秒可能太长了。

  • 性能会受到什么影响?如果浏览器将工作交给 GPU 处理,CSS 过渡通常影响很小。但在旧浏览器和移动设备上会发生什么?

您可以从 GitHub 网站 下载此示例的源代码。

创建安全路由

问题

大多数应用程序需要阻止访问特定路由,直到某人登录。但如何保护某些路由而不是其他路由?是否可以将安全机制与用于登录和退出登录的用户界面元素分离?而且如何在不编写大量代码的情况下实现?

解决方案

让我们看看在 React 应用程序中实现基于路由的安全性的一种方法。此应用程序包含一个主页(/),一个没有安全性的公共页面(/public),以及两个私密页面(/private1/private2)需要我们保护:

import React from 'react'
import './App.css'
import { BrowserRouter, Route, Switch } from 'react-router-dom'
import Public from './Public'
import Private1 from './Private1'
import Private2 from './Private2'
import Home from './Home'

function App() {
  return (
    <div className="App">
      <BrowserRouter>
        <Switch>
          <Route exact path="/">
            <Home />
          </Route>
          <Route path="/private1">
            <Private1 />
          </Route>
          <Route path="/private2">
            <Private2 />
          </Route>
          <Route exact path="/public">
            <Public />
          </Route>
        </Switch>
      </BrowserRouter>
    </div>
  )
}

export default App

我们将使用上下文构建安全系统。上下文是组件可以存储数据并使其可用于子组件的地方。BrowserRouter 使用上下文将路由信息传递给其内部的 Route 组件。

我们将创建一个名为 SecurityContext 的自定义上下文:

import React from 'react'

const SecurityContext = React.createContext({})

export default SecurityContext

上下文的默认值是一个空对象。我们需要一些东西将登录和注销函数添加到上下文中。我们将通过创建一个 SecurityProvider 来实现:

import { useState } from 'react'
import SecurityContext from './SecurityContext'

const SecurityProvider = (props) => {
  const [loggedIn, setLoggedIn] = useState(false)

  return (
    <SecurityContext.Provider
      value={{
        login: (username, password) => {
          // Note to engineering team:
          // Maybe make this more secure...
          if (username === 'fred' && password === 'password') {
            setLoggedIn(true)
          }
        },
        logout: () => setLoggedIn(false),
        loggedIn,
      }}
    >
      {props.children}
    </SecurityContext.Provider>
  )
}

export default SecurityProvider

在实际系统中,代码可能会有很大不同。您可能会创建一个组件,该组件使用 web 服务或第三方安全系统进行登录和注销。但在我们的示例中,SecurityProvider 使用简单的 loggedIn 布尔值来跟踪我们是否已登录。SecurityProvider 将三件事放入上下文中:

  • 一个用于登录的函数(login

  • 一个用于注销的函数(logout

  • 一个布尔值,表示我们是否已登录或退出(loggedIn

这三件事将对放置在 SecurityProvider 组件内的任何组件可用。为了允许 SecurityProvider 内的任何组件访问这些函数,我们将添加一个名为 useSecurity 的自定义钩子:

import SecurityContext from './SecurityContext'
import { useContext } from 'react'

const useSecurity = () => useContext(SecurityContext)

export default useSecurity

现在我们有了 SecurityProvider,我们需要使用它来保护一部分路由。我们将创建另一个名为 SecureRoute 的组件:

import Login from './Login'
import { Route } from 'react-router-dom'
import useSecurity from './useSecurity'

const SecureRoute = (props) => {
  const { loggedIn } = useSecurity()

  return (
    <Route {...props}>{loggedIn ? props.children : <Login />}</Route>
  )
}

export default SecureRoute

SecureRoute 组件从 SecurityContext 中获取当前的 loggedIn 状态(使用 useSecurity 钩子),如果用户已登录,则呈现路由内容。如果用户未登录,则显示登录表单。^(7)

LoginForm 调用 login 函数,如果成功,则重新渲染 SecureRoute,然后显示安全数据。

如何使用所有这些新组件?这里是App.js文件的更新版本:

import './App.css'
import { BrowserRouter, Route, Switch } from 'react-router-dom'
import Public from './Public'
import Private1 from './Private1'
import Private2 from './Private2'
import Home from './Home'
import SecurityProvider from './SecurityProvider'
import SecureRoute from './SecureRoute'

function App() {
  return (
    <div className="App">
      <BrowserRouter>
        <SecurityProvider>
          <Switch>
            <Route exact path="/">
              <Home />
            </Route>
            <SecureRoute path="/private1">
              <Private1 />
            </SecureRoute>
            <SecureRoute path="/private2">
              <Private2 />
            </SecureRoute>
            <Route exact path="/public">
              <Public />
            </Route>
          </Switch>
        </SecurityProvider>
      </BrowserRouter>
    </div>
  )
}

export default App

SecurityProvider包裹了我们整个路由系统,使得login()logout()loggedIn对每个SecureRoute都可用。

您可以在图 2-12 中看到应用程序正在运行。

图 2-12. 主页链接到其他页面

如果我们点击公共页面链接,页面会显示(见图 2-13)。

图 2-13. 公共页面在未登录时可用

但如果我们点击私密页面 1,将呈现登录屏幕(见图 2-14)。

图 2-14. 您需要先登录才能查看私密页面 1

如果您使用用户名fred和密码password登录,然后您将看到私密内容(见图 2-15)。

图 2-15. 登录后的私密页面 1 的内容

讨论

真正的安全性只能由安全的后端服务提供。然而,安全路由可以防止用户误入无法从服务器读取数据的页面。

更好的SecurityProvider实现将委托给某些第三方 OAuth 工具或其他安全服务。但通过将SecurityProvider与安全 UI(LoginLogout)及主应用程序分离,您可以随时间修改安全机制而不需大量更改应用程序中的代码。

如果您想看看组件在用户登录和退出时的行为,您可以在单元测试中创建SecurityProvider的模拟版本。

您可以从GitHub 网站下载此配方的源代码。

^(1) 我们在这里不会展示PeopleList的代码,但它在GitHub上是可用的。

^(2) 在这个例子中,我们使用了 React 测试库。

^(3) 请参阅“使用 Storybook 进行组件开发”。

^(4) 请参阅配方 2.2 和 2.3。

^(5) 这是第三方选项卡组件的常见特性。动画增强了用户的印象,他们在选项卡之间左右移动。

^(6) 该代码使用相对定位在淡出期间将两个组件放置在同一位置。

^(7) 我们将在此省略Login组件的内容,但代码可在 GitHub 存储库中找到。

第三章:管理状态

当我们在 React 中管理状态时,我们必须存储数据,但我们也记录数据依赖关系。依赖关系是 React 工作的本质。它们允许 React 在必要时高效地更新页面。

管理数据依赖关系,是 React 状态管理的关键。你将在本章中看到,我们使用的大多数工具和技术都是为了确保我们高效地管理依赖关系。

下面食谱中的一个关键概念是数据reducerReducer只是一个接收单个对象或数组的函数,然后返回一个修改后的副本。这个简单的概念是 React 中大部分状态管理的基础。我们将看看 React 如何本地使用reducer函数,以及如何使用 Redux 库来全局管理数据和reducer

我们还将看看选择器函数。这些函数允许我们深入到reducer返回的状态中。选择器帮助我们忽略不相关的数据,并显著提高代码的性能。

在这个过程中,我们将看到一些简单的方法来检查你是否在线,如何管理表单数据,以及其他各种提示和技巧,以确保你的应用程序正常运行。

使用 Reducers 管理复杂状态

问题

许多 React 组件很简单。它们只是渲染一个 HTML 部分,可能显示几个属性。

然而,有些组件可能更加复杂。它们可能需要管理多个内部状态。例如,考虑你可以在图 3-1 中看到的简单数字游戏。

图 3-1 简单数字拼图

该组件显示一系列数字方块,排列成网格,其中有一个空格。如果用户点击空格旁边的方块,则可以移动它。通过这种方式,用户可以重新排列方块,直到它们按照从 1 到 8 的正确顺序排列。

这个组件渲染了少量 HTML,但它将需要一些相当复杂的逻辑和数据。它将记录各个方块的位置。它需要知道用户是否可以移动给定的方块。它需要知道如何移动方块。它需要知道游戏是否完成。它还需要执行其他操作,比如通过洗牌方块来重置游戏。

完全可以在组件内部编写所有这些代码,但测试起来会更困难。你可以使用 React 测试库,但这可能过于复杂,因为大部分代码与渲染 HTML 无关。

解决方案

如果你有一个具有复杂内部状态或需要以复杂方式操纵其状态的组件,请考虑使用reducer

一个reducer是一个接受两个参数的函数:

  • 一个表示给定状态的对象或数组

  • 描述如何修改状态的动作

函数返回一个我们传递给它的状态的新副本。

动作参数可以是任何你想要的,但通常它是一个带有字符串type属性和一个包含额外信息的payload对象。您可以将type视为命令名称,将payload视为命令的参数。

例如,如果我们将我们的瓦片位置从 0(左上角)到 8(右下角)编号,我们可以告诉减速器移动左上角的瓦片:

{type: 'move', payload: 0}

我们需要一个完全定义游戏内部状态的对象或数组。我们可以使用一个简单的字符串数组:

['1', '2', '3', null, '5', '6', '7', '8', '4']

那将表示瓦片布局如下:

1 2 3
5 6
7 8 4

然而,一个稍微更灵活的方法是为我们的状态使用对象,并给它一个包含当前瓦片布局的items属性:

{
    items: ['1', '2', '3', null, '5', '6', '7', '8', '4']
}

为什么要这样做?因为这将允许我们的减速器返回其他状态值,比如游戏是否已完成:

{
    items: ['1', '2', '3', '4', '5', '6', '7', '8', null],
    complete: true
}

我们已经决定了一个动作(move),并且知道状态将如何被构造,这意味着我们已经做了足够的设计来创建一个测试:

import reducer from './reducer'

describe('reducer', () => {
  it('should be able to move 1 down if gap below', () => {
    let state = {
      items: ['1', '2', '3', null, '5', '6', '7', '8', '4'],
    }

    state = reducer(state, { type: 'move', payload: 0 })

    expect(state.items).toEqual([
      null,
      '2',
      '3',
      '1',
      '5',
      '6',
      '7',
      '8',
      '4',
    ])
  })

  it('should say when it is complete', () => {
    let state = {
      items: ['1', '2', '3', '4', '5', '6', '7', null, '8'],
    }

    state = reducer(state, { type: 'move', payload: 8 })

    expect(state.complete).toBe(true)

    state = reducer(state, { type: 'move', payload: 5 })

    expect(state.complete).toBe(false)
  })
})

在我们的第一个测试场景中,我们在一个状态中传入瓦片的位置。然后我们检查减速器是否返回了新状态的瓦片。

在我们的第二个测试中,我们执行两次瓦片移动,然后查找complete属性来告诉我们游戏是否结束了。

好的,我们已经推迟看减速器代码的时间足够长了:

function trySwap(newItems, position, t) {
  if (newItems[t] === null) {
    const temp = newItems[position]
    newItems[position] = newItems[t]
    newItems[t] = temp
  }
}

function arraysEqual(a, b) {
  for (let i = 0; i < a.length; i++) {
    if (a[i] !== b[i]) {
      return false
    }
  }
  return true
}

const CORRECT = ['1', '2', '3', '4', '5', '6', '7', '8', null]

function reducer(state, action) {
  switch (action.type) {
    case 'move': {
      const position = action.payload
      const newItems = [...state.items]
      const col = position % 3

      if (position < 6) {
        trySwap(newItems, position, position + 3)
      }
      if (position > 2) {
        trySwap(newItems, position, position - 3)
      }
      if (col < 2) {
        trySwap(newItems, position, position + 1)
      }
      if (col > 0) {
        trySwap(newItems, position, position - 1)
      }

      return {
        ...state,
        items: newItems,
        complete: arraysEqual(newItems, CORRECT),
      }
    }
    default: {
      throw new Error('Unknown action: ' + action.type)
    }
  }
}

export default reducer

我们的减速器当前只识别一个动作:move。我们的GitHub 存储库中的代码还包括shufflereset的动作。该存储库还有一个更详尽的测试集,我们用它来创建前面的代码。

但是没有这段代码包含任何 React 组件。这是纯 JavaScript,因此可以在与外界隔离的环境中创建和测试。

要小心在减速器中生成一个新对象来表示新状态。这样做可以确保每个新状态都完全独立于之前的状态。

现在是时候将我们的减速器与 React 组件进行连接,使用useReducer钩子:

import { useReducer } from 'react'
import reducer from './reducer'

import './Puzzle.css'

const Puzzle = () => {
  const [state, dispatch] = useReducer(reducer, {
    items: ['4', '1', '2', '7', '6', '3', null, '5', '8'],
  })

  return (
    <div className="Puzzle">
      <div className="Puzzle-squares">
        {state.items.map((s, i) => (
          <div
            className={`Puzzle-square ${
              s ? '' : 'Puzzle-square-empty'
            }`}
            key={`square-${i}`}
            onClick={() => dispatch({ type: 'move', payload: i })}
          >
            {s}
          </div>
        ))}
      </div>
      <div className="Puzzle-controls">
        <button
          className="Puzzle-shuffle"
          onClick={() => dispatch({ type: 'shuffle' })}
        >
          Shuffle
        </button>
        <button
          className="Puzzle-reset"
          onClick={() => dispatch({ type: 'reset' })}
        >
          Reset
        </button>
      </div>
      {state.complete && (
        <div className="Puzzle-complete">Complete!</div>
      )}
    </div>
  )
}

export default Puzzle

即使我们的拼图组件正在做一些相当复杂的事情,实际的 React 代码也相对简短。

useReducer接受一个减速器函数和一个初始状态,并返回一个两元素数组:

  • 数组中的第一个元素是来自减速器的当前状态。

  • 数组的第二个元素是一个dispatch函数,允许我们向减速器发送动作。

我们通过遍历state.items数组中的字符串来显示瓦片。

如果有人点击位置为i的瓦片,我们向减速器发送move命令:

onClick={() => dispatch({type: 'move', payload: i})}

React 组件不知道如何移动瓦片。它甚至不知道是否可以移动瓦片。组件将动作发送到减速器。

如果move动作移动了一个瓦片,组件将自动重新渲染具有新位置瓦片的组件。如果游戏完成,组件将通过state.complete的值知道:

state.complete && <div className='Puzzle-complete'>Complete!</div>

我们还添加了两个按钮来运行 shufflereset 操作,这些之前遗漏了但在GitHub 仓库中有说明。

现在我们已经创建了组件,让我们试试它。当我们首次加载组件时,我们会看到它处于初始状态,如图 3-2 所示。

图 3-2. 游戏的初始状态

如果我们点击标记为 7 的瓷砖,则其将移动到空白处(见图 3-3)。

图 3-3. 移动瓷砖 7 后

如果点击“洗牌”按钮,则 reducer 会随机重新排列瓷砖,如图 3-4 所示。

图 3-4. 洗牌按钮将瓷砖移动到随机位置

点击“重置”按钮后,拼图将变为完成状态,并显示“完成!”文本(见图 3-5)。

图 3-5. 重置按钮将瓷砖移动到其正确位置

我们将所有复杂性都隐藏在 reducer 函数内部,这样我们可以对其进行测试,而组件则简单易维护。

讨论

Reducers 是管理复杂性的一种方式。通常会在以下情况下使用 reducer:

  • 您需要管理大量的内部状态。

  • 您需要复杂的逻辑来管理组件的内部状态。

如果上述任一情况正确,则 reducer 可以显著简化您的代码管理。

但是,要注意不要在非常小的组件中使用 reducers。如果您的组件具有简单的状态和少量逻辑,可能不需要引入 reducer 的额外复杂性。

有时,即使存在复杂状态,也可以采用其他方法。例如,如果在表单中捕获和验证数据,创建一个验证表单组件可能更好(见“创建和验证表单”)。

您需要确保您的 reducer 没有任何副作用。避免例如更新服务器的网络调用。如果 reducer 有副作用,那么可能会导致其破坏。React 在开发模式下有时会对您的 reducer 进行额外调用,以确保没有副作用。如果您使用 reducer 并注意到 React 在渲染组件时调用了两次您的代码,则意味着 React 正在检查不良行为。

在满足所有这些条件的情况下,reducers 是应对复杂性的极好工具。它们是 Redux 等库的核心部分,可以轻松重用和组合,简化组件,并显著简化 React 代码的测试。

您可以从GitHub 网站下载此示例的源代码。

创建撤销功能

问题

JavaScript 丰富框架如 React 的承诺之一是 Web 应用可以与桌面应用程序密切相似。桌面应用程序的一个常见功能是撤消操作。React 应用程序中的某些本机组件自动支持撤消功能。如果您在文本区域中编辑文本,然后按下 Cmd/Ctrl-Z,它将撤消您的编辑。但是如何将撤消扩展到自定义组件?如何在没有大量代码的情况下跟踪状态变化?

解决方案

如果一个 reducer 函数管理您组件中的状态,则可以使用 undo-reducer 实现一个相当通用的撤消功能。

考虑来自“使用 reducer 管理复杂状态”的Puzzle示例中的此代码片段:

const [state, dispatch] = useReducer(reducer, {
  items: ['4', '1', '2', '7', '6', '3', null, '5', '8'],
})

此代码使用一个称为reducer的 reducer 函数和一个初始状态来管理数字拼图游戏中的图块(见图 3-6)。

图 3-6. 一个简单的数字拼图游戏

如果用户单击 Shuffle 按钮,则组件通过将shuffle操作发送到 reducer 来更新图块状态:

<button className='Puzzle-shuffle'
        onClick={() => dispatch({type: 'shuffle'})}>Shuffle</button>

(有关 reducer 的详细信息及何时应使用它们,请参见“使用 reducer 管理复杂状态”。)

我们将创建一个名为useUndoReducer的新钩子,它可以替代useReducer

const [state, dispatch] = useUndoReducer(reducer, {
  items: ['4', '1', '2', '7', '6', '3', null, '5', '8'],
})

useUndoReducer钩子将神奇地赋予我们的组件回到过去的能力:

<button
  className="Puzzle-undo"
  onClick={() => dispatch({ type: 'undo' })}
>
  Undo
</button>

如果我们在组件中添加此按钮,则可以撤消用户执行的最后一个操作,如图 3-7](#ch03_image_7)所示。

图 3-7. (1) 游戏进行中;(2) 进行移动;(3) 单击撤消以撤消移动

但是我们如何执行这种魔法呢?虽然useUndoReducer相对容易使用,但理解起来有些困难。但值得这样做,这样您就可以根据需要调整该方法。

我们可以利用所有 reducer 都以相同方式工作的事实:

  • 行为定义了你想做的事情。

  • reducer 在每个操作后返回一个新状态。

  • 调用 reducer 时不允许产生副作用。

此外,reducer 只是接受状态对象和操作对象的简单 JavaScript 函数。

因为 reducer 以如此明确定义的方式工作,我们可以创建一个新的 reducer(一个 undo-reducer),它包裹在另一个 reducer 函数周围。我们的 undo-reducer 将作为中介工作。它将大多数操作传递给底层 reducer,同时保留所有先前状态的历史记录。如果有人想要撤消一个操作,它将从其历史记录中找到最后一个状态,然后返回该状态而不调用底层 reducer。

我们将从创建一个接受一个 reducer 并返回另一个的高阶函数开始:

import lodash from 'lodash'

const undo = (reducer) => (state, action) => {
  let {
    undoHistory = [],
    undoActions = [],
    ...innerState
  } = lodash.cloneDeep(state)
  switch (action.type) {
    case 'undo': {
      if (undoActions.length > 0) {
        undoActions.pop()
        innerState = undoHistory.pop()
      }
      break
    }

    case 'redo': {
      if (undoActions.length > 0) {
        undoHistory = [...undoHistory, { ...innerState }]
        undoActions = [
          ...undoActions,
          undoActions[undoActions.length - 1],
        ]
        innerState = reducer(
          innerState,
          undoActions[undoActions.length - 1]
        )
      }
      break
    }

    default: {
      undoHistory = [...undoHistory, { ...innerState }]
      undoActions = [...undoActions, action]
      innerState = reducer(innerState, action)
    }
  }
  return { ...innerState, undoHistory, undoActions }
}

export default undo

这个 reducer 函数相当复杂,所以值得花些时间来理解它的作用。

它创建一个 reducer 函数,用于跟踪我们传递给它的动作和状态。假设我们的游戏组件发送一个动作来打乱游戏中的方块。我们的 reducer 首先检查动作是否是 undoredo 类型。它不是。因此,它将 shuffle 动作传递给管理游戏中方块的底层 reducer(参见 图 3-8)。

图 3-8. undo-reducer 将大多数动作传递给底层 reducer

当它将 shuffle 动作传递给底层 reducer 时,undo 代码通过将它们添加到 undoHistoryundoActions 中来跟踪现有状态和 shuffle 动作。然后返回底层游戏 reducer 的状态以及 undoHistoryundoActions

如果我们的拼图组件发送了一个 undo 动作,则 undo-reducer 从 undoHistory 中返回先前的状态,完全绕过游戏自身的 reducer 函数(参见 图 3-9)。

图 3-9. 对于撤销操作,undo-reducer 返回最新的历史状态

现在让我们看看 useUndoReducer 钩子本身:

import { useReducer } from 'react'
import undo from './undo'

const useUndoReducer = (reducer, initialState) =>
  useReducer(undo(reducer), initialState)

export default useUndoReducer

这个 useUndoReducer 钩子是一段简洁的代码。它只是对内置的 useReducer 钩子的调用,但是不直接传递 reducer,而是传递 undo(reducer)。结果是您的组件使用您提供的 reducer 的增强版本:一个可以撤销和重做动作的版本。

这是我们更新后的 Puzzle 组件(查看 “使用 Reducers 管理复杂状态” 获取原始版本):

import reducer from './reducer'
import useUndoReducer from './useUndoReducer'

import './Puzzle.css'

const Puzzle = () => {
  const [state, dispatch] = useUndoReducer(reducer, {
    items: ['4', '1', '2', '7', '6', '3', null, '5', '8'],
  })

  return (
    <div className="Puzzle">
      <div className="Puzzle-squares">
        {state.items.map((s, i) => (
          <div
            className={`Puzzle-square ${
              s ? '' : 'Puzzle-square-empty'
            }`}
            key={`square-${i}`}
            onClick={() => dispatch({ type: 'move', payload: i })}
          >
            {s}
          </div>
        ))}
      </div>
      <div className="Puzzle-controls">
        <button
          className="Puzzle-shuffle"
          onClick={() => dispatch({ type: 'shuffle' })}
        >
          Shuffle
        </button>
        <button
          className="Puzzle-reset"
          onClick={() => dispatch({ type: 'reset' })}
        >
          Reset
        </button>
      </div>
      <div className="Puzzle-controls">
        <button
          className="Puzzle-undo"
          onClick={() => dispatch({ type: 'undo' })}
        >
          Undo
        </button>
        <button
          className="Puzzle-redo"
          onClick={() => dispatch({ type: 'redo' })}
        >
          Redo
        </button>
      </div>
      {state.complete && (
        <div className="Puzzle-complete">Complete!</div>
      )}
    </div>
  )
}

export default Puzzle

唯一的更改是我们使用 useUndoReducer 替代了 useReducer,并且我们添加了一对按钮来调用 “undo” 和 “redo” 动作。

现在,如果加载组件并进行一些更改,您可以逐个撤销更改,如 图 3-10 所示。

图 3-10. 使用 useUndoReducer,您现在可以发送 undoredo 动作

讨论

此处显示的 undo-reducer 将与接受和返回状态对象的 reducer 一起工作。如果您的 reducer 使用数组管理状态,则必须修改 undo 函数。

因为它保留了所有先前状态的历史记录,所以如果你的状态数据庞大或者在可能进行大量更改的情况下使用它时,可能需要避免使用它。否则,你可能希望限制历史记录的最大大小。

另外,请记住它在内存中维护其历史记录。如果用户重新加载整个页面,则历史记录将消失。每当全局状态发生变化时,通过将其持久化到本地存储可以解决此问题。

您可以从 GitHub 网站 下载此示例的源代码。

创建和验证表单

问题

大多数 React 应用程序在某种程度上使用表单,并且大多数应用程序对创建表单采取临时方法。如果团队正在构建您的应用程序,您可能会发现一些开发人员单独管理各个字段的状态变量。其他人则选择将表单状态记录在单一值对象中,这样更简单地将其传入和传出表单,但每个字段更新起来可能有些棘手。字段验证通常会导致混乱的代码,一些表单在提交时进行验证,而另一些表单在用户键入时动态验证。有些表单可能在首次加载时显示验证消息,而其他表单可能仅在用户触摸字段后才会显示消息。

设计上的这些变化可能会导致用户体验差和编写代码时方法不一致。在我们与 React 团队合作的经验中,表单和表单验证是开发人员常见的绊脚石。

解决方案

为了对表单开发应用一些一致性,我们将创建一个 SimpleForm 组件,该组件将包裹一个或多个 InputField 组件。这是使用 SimpleFormInputField 的示例:

import { useEffect, useState } from 'react'
import './App.css'
import SimpleForm from './SimpleForm'
import InputField from './InputField'

const FormExample0 = ({ onSubmit, onChange, initialValue = {} }) => {
  const [formFields, setFormFields] = useState(initialValue)

  const [valid, setValid] = useState(true)
  const [errors, setErrors] = useState({})

  useEffect(() => {
    if (onChange) {
      onChange(formFields, valid, errors)
    }
  }, [onChange, formFields, valid, errors])

  return (
    <div className="TheForm">
      <h1>Single field</h1>

      <SimpleForm
        value={formFields}
        onChange={setFormFields}
        onValid={(v, errs) => {
          setValid(v)
          setErrors(errs)
        }}
      >
        <InputField
          name="field1"
          onValidate={(v) =>
            !v || v.length < 3 ? 'Too short!' : null
          }
        />

        <button
          onClick={() => onSubmit && onSubmit(formFields)}
          disabled={!valid}
        >
          Submit!
        </button>
      </SimpleForm>
    </div>
  )
}

export default FormExample0

我们在单个对象 formFields 中跟踪表单的状态。每当我们在表单中更改字段时,该字段将在 SimpleForm 上调用 onChange。使用 onValidate 方法验证 field1 字段,并且每当验证状态发生变化时,该字段将在 SimpleForm 上调用 onValid 方法。只有在用户与字段交互时才会进行验证,使其变为 dirty

您可以在 图 3-11 中看到表单的运行情况。

无需跟踪单个字段值。表单值对象记录具有从字段名称派生的属性的单个字段值。InputField 处理何时运行验证的详细信息:它将更新表单值并决定何时显示错误。

图 3-11. 带有字段验证的简单表单

图 3-12 展示了一个稍微复杂的例子,使用了带有几个字段的 SimpleForm

图 3-12. 更复杂的表单

要创建 SimpleFormInputField 组件,我们必须首先查看它们如何彼此通信。InputField 组件需要告诉 SimpleForm 其值何时发生变化以及新值是否有效。它将使用上下文来实现这一点。

上下文 是存储范围。当组件在上下文中存储值时,该值对其子组件可见。SimpleForm 将创建一个名为 FormCon⁠text 的上下文,并用它来存储一组回调函数,任何子组件都可以使用这些函数与表单通信:

import { createContext } from 'react'

const FormContext = createContext({})

export default FormContext

要了解 SimpleForm 的工作原理,让我们从一个简化版本开始,该版本仅跟踪其子组件的值,暂时不用担心验证:

import React, { useCallback, useEffect, useState } from 'react'

import './SimpleForm.css'
import FormContext from './FormContext'

function updateWith(oldValue, field, value) {
  const newValue = { ...oldValue }
  newValue[field] = value
  return newValue
}

const SimpleForm = ({ children, value, onChange, onValid }) => {
  const [values, setValues] = useState(value || {})

  useEffect(() => {
    setValues(value || {})
  }, [value])

  useEffect(() => {
    if (onChange) {
      onChange(values)
    }
  }, [onChange, values])

  let setValue = useCallback(
    (field, v) => setValues((vs) => updateWith(vs, field, v)),
    [setValues]
  )
  let getValue = useCallback((field) => values[field], [values])
  let form = {
    setValue: setValue,
    value: getValue,
  }

  return (
    <div className="SimpleForm-container">
      <FormContext.Provider value={form}>
        {children}
      </FormContext.Provider>
    </div>
  )
}

export default SimpleForm

最终版本的SimpleForm将增加额外的代码来跟踪验证和错误,但这个简化的表单更易于理解。

表单将在values对象中跟踪所有字段值。表单创建了两个名为getValuesetValue的回调函数,并将它们放入上下文(作为form对象),子组件将在其中找到它们。我们通过在子组件周围包装<FormContext.Provider>来将form放入上下文中。

请注意,我们已经在useCallback中包装了getValuesetValue回调,这样可以防止组件每次渲染SimpleForm时创建这些函数的新版本。

每当子组件调用form.value()函数时,它将收到指定字段的当前值。如果子组件调用form.setValue(),它将更新该值。

现在让我们看一个简化版的InputField组件,再次删除任何验证代码,以便更容易理解:

import React, { useContext } from 'react'
import FormContext from './FormContext'

import './InputField.css'

const InputField = (props) => {
  const form = useContext(FormContext)

  if (!form.value) {
    return 'InputField should be wrapped in a form'
  }

  const { name, label, ...otherProps } = props

  const value = form.value(name)

  return (
    <div className="InputField">
      <label htmlFor={name}>{label || name}:</label>
      <input
        id={name}
        value={value || ''}
        onChange={(event) => {
          form.setValue(name, event.target.value)
        }}
        {...otherProps}
      />{' '}
      {}
    </div>
  )
}

export default InputField

InputFieldFormContext中提取form对象。如果找不到form对象,它知道我们没有将其包装在SimpleForm组件中。然后,InputField渲染一个input字段,并将其值设置为form.value(name)返回的任何内容。如果用户更改字段的值,InputField组件将新值发送到form.setValue(name, event.target.value)

如果您需要除了input之外的表单字段,可以将其包装在类似此处所示的InputField组件中。

验证代码与前面类似。与表单在values状态中跟踪当前值的方式相同,它还需要跟踪哪些字段是脏的,哪些是无效的。然后需要传递setDirtyisDirtysetInvalid的回调函数。这些回调函数在子字段运行其onValidate代码时使用。

这是包含验证的SimpleForm组件的最终版本:

import { useCallback, useEffect, useState } from 'react'
import FormContext from './FormContext'
import './SimpleForm.css'

const SimpleForm = ({ children, value, onChange, onValid }) => {
  const [values, setValues] = useState(value || {})
  const [dirtyFields, setDirtyFields] = useState({})
  const [invalidFields, setInvalidFields] = useState({})

  useEffect(() => {
    setValues(value || {})
  }, [value])

  useEffect(() => {
    if (onChange) {
      onChange(values)
    }
  }, [onChange, values])

  useEffect(() => {
    if (onValid) {
      onValid(
        Object.keys(invalidFields).every((i) => !invalidFields[i]),
        invalidFields
      )
    }
  }, [onValid, invalidFields])

  const setValue = useCallback(
    (field, v) => setValues((vs) => ({ ...vs, [field]: v })),
    [setValues]
  )
  const getValue = useCallback((field) => values[field], [values])
  const setDirty = useCallback(
    (field) => setDirtyFields((df) => ({ ...df, [field]: true })),
    [setDirtyFields]
  )
  const getDirty = useCallback(
    (field) => Object.keys(dirtyFields).includes(field),
    [dirtyFields]
  )
  const setInvalid = useCallback(
    (field, error) => {
      setInvalidFields((i) => ({
        ...i,
        [field]: error ? error : undefined,
      }))
    },
    [setInvalidFields]
  )
  const form = {
    setValue: setValue,
    value: getValue,

    setDirty: setDirty,
    isDirty: getDirty,

    setInvalid: setInvalid,
  }

  return (
    <div className="SimpleForm-container">
      <FormContext.Provider value={form}>
        {children}
      </FormContext.Provider>
    </div>
  )
}

export default SimpleForm

这是InputField组件的最终版本。请注意,一旦它失去焦点或其值发生变化,该字段被标记为dirty

import { useContext, useEffect, useState } from 'react'
import FormContext from './FormContext'

import './InputField.css'

const splitCamelCase = (s) =>
  s
    .replace(/([a-z0-9])([A-Z0-9])/g, '$1 $2')
    .replace(/^([a-z])/, (x) => x.toUpperCase())

const InputField = (props) => {
  const form = useContext(FormContext)

  const [error, setError] = useState('')

  const { onValidate, name, label, ...otherProps } = props

  let value = form.value && form.value(name)

  useEffect(() => {
    if (onValidate) {
      setError(onValidate(value))
    }
  }, [onValidate, value])

  const setInvalid = form.setInvalid

  useEffect(() => {
    if (setInvalid) {
      setInvalid(name, error)
    }
  }, [setInvalid, name, error])

  if (!form.value) {
    return 'InputField should be wrapped in a form'
  }

  return (
    <div className="InputField">
      <label htmlFor={name}>{label || splitCamelCase(name)}:</label>
      <input
        id={name}
        onBlur={() => form.setDirty(name)}
        value={value || ''}
        onChange={(event) => {
          form.setDirty(name)
          form.setValue(name, event.target.value)
        }}
        {...otherProps}
      />{' '}
      {
        <div className="InputField-error">
          {form.isDirty(name) && error ? error : <>&nbsp;</>}
        </div>
      }
    </div>
  )
}

export default InputField

讨论

您可以使用此方案创建许多简单的表单,并可以扩展它以与任何 React 组件一起使用。例如,如果您正在使用第三方日历或日期选择器,只需将其包装在类似InputField的组件中,即可在SimpleForm内部使用它。

此方案不支持表单内嵌或表单数组。可以修改SimpleForm组件,使其像InputField一样,以便在一个表单内放置另一个表单。

您可以从GitHub 网站下载此方案的源代码。

使用时钟测量时间

问题

有时,React 应用程序需要根据一天中的时间做出响应。它可能只需要显示当前时间,或者可能需要定期轮询服务器,或者在白天变成黑夜时更改其界面。但是,如何使您的代码在时间变化时重新渲染?如何避免过度渲染组件?而且如何在不过度复杂化代码的情况下完成所有这些?

解决方案

我们将创建一个 useClock 钩子。useClock 钩子将使我们能够访问当前日期和时间的格式化版本,并在时间变化时自动更新界面。以下是代码示例,并且 图 3-13 显示其运行中:

import { useEffect, useState } from 'react'
import useClock from './useClock'
import ClockFace from './ClockFace'

import './Ticker.css'

const SimpleTicker = () => {
  const [isTick, setTick] = useState(false)

  const time = useClock('HH:mm:ss')

  useEffect(() => {
    setTick((t) => !t)
  }, [time])

  return (
    <div className="Ticker">
      <div className="Ticker-clock">
        <h1>Time {isTick ? 'Tick!' : 'Tock!'}</h1>
        {time}
        <br />
        <ClockFace time={time} />
      </div>
    </div>
  )
}

export default SimpleTicker

图 3-13. SimpleTicker 每三秒

time 变量包含格式为 HH:mm:ss 的当前时间。当时间变化时,isTick 状态的值在真和假之间切换,然后用于显示 Tick!Tock!。我们显示当前时间,然后还显示带有 ClockFace 组件的时间。

除了接受日期和时间格式外,useClock 还可以接受一个指定更新之间毫秒数的数字(见 图 3-14):

import { useEffect, useState } from 'react'
import useClock from './useClock'

import './Ticker.css'

const IntervalTicker = () => {
  const [isTick3, setTick3] = useState(false)

  const tickThreeSeconds = useClock(3000)

  useEffect(() => {
    setTick3((t) => !t)
  }, [tickThreeSeconds])

  return (
    <div className="Ticker">
      <div className="Ticker-clock">
        <h1>{isTick3 ? '3 Second Tick!' : '3 Second Tock!'}</h1>
        {tickThreeSeconds}
      </div>
    </div>
  )
}

export default IntervalTicker

图 3-14. IntervalTicker 每三秒重新渲染组件

如果您希望定期执行某些任务(例如轮询网络服务),这个版本会更加实用。

要轮询网络服务,请考虑使用具有 “将网络调用转换为钩子” 的时钟。如果将时钟的当前值作为依赖项传递给执行网络调用的钩子,则每次时钟变化时都会重复网络调用。

如果向 useClock 传递一个数值参数,它将返回一个 ISO 格式的时间字符串,例如 2021-06-11T14:50:34.706

为了构建这个钩子,我们将使用一个名为 Moment.js 的第三方库来处理日期和时间格式化。如果您更愿意使用另一个库,如 Day.js,转换应该是直接的:

$ npm install moment

这是 useClock 的代码:

import { useEffect, useState } from 'react'
import moment from 'moment'

const useClock = (formatOrInterval) => {
  const format =
    typeof formatOrInterval === 'string'
      ? formatOrInterval
      : 'YYYY-MM-DDTHH:mm:ss.SSS'
  const interval =
    typeof formatOrInterval === 'number' ? formatOrInterval : 500
  const [response, setResponse] = useState(
    moment(new Date()).format(format)
  )

  useEffect(() => {
    const newTimer = setInterval(() => {
      setResponse(moment(new Date()).format(format))
    }, interval)

    return () => clearInterval(newTimer)
  }, [format, interval])

  return response
}

export default useClock

我们从传递给钩子的 formatOrInterval 参数派生日期时间的 format 和所需的 tick interval。然后我们使用 setInterval 创建一个定时器。每 interval 毫秒,此定时器将设置 response 值为新的时间字符串。当我们将 response 字符串设置为新时间时,依赖于 useClock 的任何组件都将重新渲染。

我们需要确保取消任何不再使用的定时器。我们可以利用 useEffect 钩子的一个特性来实现这一点。如果在 useEffect 代码的末尾返回一个函数,那么该函数将在下次 useEffect 需要运行时调用。因此,我们可以在创建新定时器之前清除旧定时器。

如果我们向 useClock 传递新的格式或间隔,它将取消旧的定时器,并使用新的定时器响应。

讨论

此示例展示了如何使用钩子来简单解决问题。React 代码(名字就是提示)会对依赖项更改做出反应。不要想着“如何每秒运行这段代码?”useClock钩子允许您编写依赖于当前时间的代码,并隐藏了创建定时器、更新状态和清除定时器的所有复杂细节。

如果在组件中多次使用useClock钩子,则时间更改可能导致多次渲染。例如,如果您有两个时钟,分别以 12 小时制(04:45)和 24 小时制(16:45)格式化当前时间,则当分钟更改时,您的组件将渲染两次。每分钟额外渲染一次不太可能对性能产生太大影响。

您还可以在其他钩子内部使用useClock钩子。如果您创建一个useMessages钩子来从服务器检索消息,您可以在其中调用useClock以定期轮询服务器。

您可以从GitHub 网站下载此示例的源代码。

监控在线状态

问题

假设有人在其手机上使用您的应用程序,然后他们进入没有数据连接的地铁。如何检查网络连接已经断开?如何以 React 友好的方式更新您的界面,告知用户存在问题或禁用一些需要网络访问的功能?

解决方案

我们将创建一个名为useOnline的钩子,用于告诉我们是否连接到网络。我们需要的代码在浏览器失去或重新获得与网络的连接时运行。幸运的是,有称为onlineoffline的窗口/全局事件正是做这些事情的。当触发onlineoffline事件时,当前网络状态将由navigator.onLine给出,其值将设置为truefalse

import { useEffect, useState } from 'react'

const useOnline = () => {
  const [online, setOnline] = useState(navigator.onLine)

  useEffect(() => {
    if (window.addEventListener) {
      window.addEventListener('online', () => setOnline(true), false)
      window.addEventListener(
        'offline',
        () => setOnline(false),
        false
      )
    } else {
      document.body.ononline = () => setOnline(true)
      document.body.onoffline = () => setOnline(false)
    }
  }, [])

  return online
}

export default useOnline

此钩子在online变量中管理其连接状态。当首次运行该钩子时(请注意空依赖数组),我们会注册浏览器的在线/离线事件监听器。当任一事件发生时,我们可以将online的值设置为truefalse。如果这是当前值的更改,则使用此钩子的任何组件都将重新渲染。

下面是钩子实际运行的示例:

import useOnline from './useOnline'
import './App.css'

function App() {
  const online = useOnline()

  return (
    <div className="App">
      <h1>Network Checker</h1>
      <span>
        You are now....
        {online ? (
          <div className="App-indicator-online">ONLINE</div>
        ) : (
          <div className="App-indicator-offline">OFFLINE</div>
        )}
      </span>
    </div>
  )
}

export default App

如果运行应用程序,页面当前将显示为在线状态。如果断开/重新连接网络,则消息将切换到离线,然后再切换到在线(参见图 3-15)。

图 3-15. 当网络关闭和重新打开时,代码将重新渲染

讨论

重要的是要注意,此钩子检查的是浏览器连接到网络的情况,而不是连接到更广泛的 Internet 或您的服务器。如果要检查服务器是否正在运行并可用,您需要编写额外的代码。

您可以从GitHub 网站下载此示例的源代码。

使用 Redux 管理全局状态

问题

在本章的其他示例中,我们已经看到您可以使用称为 reducer 的纯 JavaScript 函数来管理复杂的组件状态。Reducers 简化组件并使业务逻辑更易于测试。

但是如果您有一些数据,比如购物篮,需要在所有地方访问怎么办?

解决方案

我们将使用 Redux 库来管理全局应用程序状态。Redux 使用相同的 reducers,我们可以提供给 React 的 useReducer 函数,但它们用于管理整个应用程序的单个状态对象。此外,Redux 还有许多扩展,用于解决常见的编程问题,以便更快地开发和管理您的应用程序。

首先,我们需要安装 Redux 库:

$ npm install redux

我们还将安装 React Redux 库,这将使 Redux 与 React 结合使用更加简单:

$ npm install react-redux

我们将使用 Redux 构建一个包含购物篮的应用程序(参见 图 3-16)。

图 3-16. 当客户购买产品时,应用程序将其添加到购物篮中

如果客户点击“购买”按钮,应用程序将产品添加到购物篮中。如果他们再次点击“购买”按钮,则更新购物篮中的数量。购物篮将出现在应用程序的多个位置,因此非常适合迁移到 Redux。这是我们将用来管理购物篮的 reducer 函数:

const reducer = (state = {}, action = {}) => {
  switch (action.type) {
    case 'buy': {
      const basket = state.basket ? [...state.basket] : []
      const existing = basket.findIndex(
        (item) => item.productId === action.payload.productId
      )
      if (existing !== -1) {
        basket[existing].quantity = basket[existing].quantity + 1
      } else {
        basket.push({ quantity: 1, ...action.payload })
      }
      return {
        ...state,
        basket,
      }
    }
    case 'clearBasket': {
      return {
        ...state,
        basket: [],
      }
    }
    default:
      return { ...state }
  }
}

export default reducer

这里我们创建了一个单一的 reducer。当您的应用程序规模扩大时,您可能希望将 reducer 拆分为更小的 reducers,并使用 Redux 的 combineReduc⁠ers 函数 将它们组合起来。

reducer 函数响应 buyclearBasket 操作。buy 操作将添加新项目到购物篮,或者如果已有匹配的 productId,则更新现有项目的数量。clearBasket 操作将将购物篮设置为空数组。

现在我们有了一个 reducer 函数,我们将使用它来创建一个 Redux store。这个 store 将成为我们共享应用程序状态的中央存储库。要创建一个 store,在一些顶层组件(如 App.js)中添加以下两行代码:

import { createStore } from 'redux'
import reducer from './reducer'

const store = createStore(reducer)

商店需要全局可用,为此,我们需要将其注入到可能需要它的组件的上下文中。React Redux 库提供了一个用于在组件上下文中注入 store 的组件,称为 Provider

<Provider store={store}>
  All the components inside here can access the store
</Provider>

这是示例应用程序中的 reducer.js 组件,您可以在本书的 GitHub 仓库 中找到它:

const reducer = (state = {}, action = {}) => {
  switch (action.type) {
    case 'buy': {
      const basket = state.basket ? [...state.basket] : []
      const existing = basket.findIndex(
        (item) => item.productId === action.payload.productId
      )
      if (existing !== -1) {
        basket[existing].quantity = basket[existing].quantity + 1
      } else {
        basket.push({ quantity: 1, ...action.payload })
      }
      return {
        ...state,
        basket,
      }
    }
    case 'clearBasket': {
      return {
        ...state,
        basket: [],
      }
    }
    default:
      return { ...state }
  }
}

export default reducer

现在我们的组件可以访问商店了,那么我们如何使用它呢?React Redux 允许您通过 hooks 访问商店。如果您想要读取全局状态的内容,可以使用 useSelector

const basket = useSelector((state) => state.basket)

useSelector钩子接受一个函数来提取中央状态的一部分。选择器非常高效,只有在您关注的状态部分发生变化时,您的组件才会重新渲染。

如果您需要将动作提交到中央存储,可以使用useDispatch钩子:

const dispatch = useDispatch()

这将返回一个dispatch函数,您可以使用它发送动作到存储中:

dispatch({ type: 'clearBasket' })

这些钩子通过从当前上下文中提取存储来工作。如果您忘记向应用程序添加Provider或尝试在Provider上下文之外运行useSelectoruseDispatch,则会收到错误,如图 3-17 所示。

图 3-17. 如果您忘记包含Provider,您将会收到此错误

完成的Basket组件读取并清除应用程序范围内的购物篮:

import { useDispatch, useSelector } from 'react-redux'

import './Basket.css'

const Basket = () => {
  const basket = useSelector((state) => state.basket)
  const dispatch = useDispatch()

  return (
    <div className="Basket">
      <h2>Basket</h2>
      {basket && basket.length ? (
        <>
          {basket.map((item) => (
            <div className="Basket-item">
              <div className="Basket-itemName">{item.name}</div>
              <div className="Basket-itemProductId">
                {item.productId}
              </div>
              <div className="Basket-itemPricing">
                <div className="Basket-itemQuantity">
                  {item.quantity}
                </div>
                <div className="Basket-itemPrice">{item.price}</div>
              </div>
            </div>
          ))}
          <button onClick={() => dispatch({ type: 'clearBasket' })}>
            Clear
          </button>
        </>
      ) : (
        'Empty'
      )}
    </div>
  )
}

export default Basket

为了演示一些向购物篮添加商品的代码,这里是一个Boots组件,允许客户购买一系列产品:

import { useDispatch } from 'react-redux'

import './Boots.css'

const products = [
  {
    productId: 'BE8290004',
    name: 'Ski boots',
    description: 'Mondo 26.5\. White.',
    price: 698.62,
  },
  {
    productId: 'PC6310098',
    name: 'Snowboard boots',
    description: 'Mondo 27.5\. Blue.',
    price: 825.59,
  },
  {
    productId: 'RR5430103',
    name: 'Mountaineering boots',
    description: 'Mondo 27.3\. Brown.',
    price: 634.98,
  },
]

const Boots = () => {
  const dispatch = useDispatch()

  return (
    <div className="Boots">
      <h1>Boots</h1>

      <dl className="Boots-products">
        {products.map((product) => (
          <>
            <dt>{product.name}</dt>
            <dd>
              <p>{product.description}</p>
              <p>${product.price}</p>
              <button
                onClick={() =>
                  dispatch({ type: 'buy', payload: product })
                }
              >
                Add to basket
              </button>
            </dd>
          </>
        ))}
      </dl>
    </div>
  )
}

export default Boots

这两个组件可能出现在组件树中非常不同的位置,但它们共享同一个 Redux 存储。一旦客户将产品添加到购物篮中,Basket组件将自动更新变化(参见图 3-18)。

图 3-18. Redux-React 钩子确保当用户购买产品时,Basket将被重新渲染

讨论

开发人员通常将 Redux 库与 React 框架一起使用。长期以来,几乎每个 React 应用程序默认都包含 Redux。我们认为 Redux 经常被过度使用或不当使用。我们曾经看到一些项目甚至禁止使用本地状态,而是使用 Redux 来处理所有状态。我们认为这种做法是错误的。Redux 旨在用于集中管理应用程序状态,而不是简单的组件状态。如果您存储的数据仅关系到一个组件或其子组件,您可能不应将其存储在 Redux 中。

然而,如果您的应用程序管理一些全局应用程序状态,那么 Redux 仍然是首选的工具。

您可以从GitHub 网站下载本教程的源代码。

使用 Redux Persist 解决页面重新加载问题

问题

Redux 是管理应用程序状态的绝佳方式。然而,它确实存在一个小问题:当您重新加载页面时,整个状态会消失(参见图 3-19)。

图 3-19. Redux 状态(左)在页面重新加载时会丢失(右)

状态会消失是因为 Redux 将其状态保存在内存中。我们如何防止状态消失呢?

解决方案

我们将使用 Redux Persist 库将 Redux 状态的副本保存在本地存储中。要安装 Redux Persist,请输入以下命令:

$ npm install redux-persist

我们需要做的第一件事是创建一个持久化的 reducer,围绕我们现有的 reducer 包装起来:

import storage from 'redux-persist/lib/storage'

const persistConfig = {
  key: 'root',
  storage,
}

const persistedReducer = persistReducer(persistConfig, reducer)

storage 指定了我们将持久化 Redux 状态的位置:默认情况下将在 localStorage 中。persistConfig 表明我们希望将我们的状态保存在名为 persist:rootlocalStorage 项中。当 Redux 状态发生变化时,persistedReducer 将通过 localStorage.setItem('persist:root', ...) 写入副本。现在我们需要使用 persistedReducer 创建我们的 Redux store:

const store = createStore(persistedReducer)

我们需要在访问 Redux store 的代码和 Redux store 之间插入 Redux Persist 代码。我们使用一个名为 PersistGate 的组件来实现这一点:

import { PersistGate } from 'redux-persist/integration/react'
import { persistStore } from 'redux-persist'

const persistor = persistStore(store)
...
<Provider store={store}>
  <PersistGate loading={<div>Loading...</div>} persistor={persistor}>
    Components live in here
  </PersistGate>
</Provider>

PersistGate 必须位于 Redux Provider 内部,并且位于将使用 Redux 的组件之外。PersistGate 将监视 Redux 状态丢失的情况,并从 localStorage 中重新加载它。当 Redux 正在重新加载数据时,可能需要一点时间,如果您想显示 UI 稍微忙碌的状态,可以将一个 loading 组件传递给 PersistGate:例如,一个动画旋转器。当 Redux 重新加载时,加载组件将显示在其子组件的位置。如果您不想要加载组件,可以将其设置为 null

这是示例应用程序中修改后的 App.js 的最终版本:

import { BrowserRouter, Route, Switch } from 'react-router-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'

import Menu from './Menu'
import Home from './Home'
import Boots from './Boots'
import Basket from './Basket'

import './App.css'
import reducer from './reducer'

import { persistStore, persistReducer } from 'redux-persist'
import { PersistGate } from 'redux-persist/integration/react'
import storage from 'redux-persist/lib/storage'

const persistConfig = {
  key: 'root',
  storage,
}

const persistedReducer = persistReducer(persistConfig, reducer)

const store = createStore(persistedReducer)

const persistor = persistStore(store)

function App() {
  return (
    <div className="App">
      <Provider store={store}>
        <PersistGate
          loading={<div>Loading...</div>}
          persistor={persistor}
        >
          <BrowserRouter>
            <Menu />
            <Switch>
              <Route exact path="/">
                <Home />
              </Route>
              <Route path="/boots">
                <Boots />
              </Route>
            </Switch>
            <Basket />
          </BrowserRouter>
        </PersistGate>
      </Provider>
    </div>
  )
}

export default App

现在,当用户重新加载页面时,Redux 状态会保留,如 图 3-20 所示。

图 3-20. 重新加载前的 Redux 状态(顶部)和重新加载后(底部)

讨论

Redux Persist 库是通过页面重新加载简单地持久化 Redux 状态的方法。如果您有大量的 Redux 数据,需要小心不要超出 localStorage 的限制,这个限制因浏览器而异,但通常约为 10 MB。但是,如果您的 Redux 数据量如此之大,您应该考虑将其中一些数据转移到服务器上。

您可以从 GitHub 站点 下载本配方的源代码。

使用 Reselect 计算派生状态

问题

当您将应用程序状态提取到类似 Redux 这样的工具中的外部对象时,通常需要在显示之前对数据进行某种方式的处理。例如,图 3-21 展示了本章中几个配方中使用过的应用程序。

图 3-21. 如何计算购物篮的总费用和税金的最佳方法?

如果我们想要计算购物篮中物品的总费用,然后计算要支付的销售税,该怎么办?我们可以创建一个 JavaScript 函数,读取购物篮中的物品并计算这两个值,但是每次购物篮重新渲染时,该函数都需要重新计算这些值。有没有一种方法可以从更新状态时仅计算派生值呢?

解决方案

Redux 开发者专门创建了一个库,名为 reselect,用于高效地从状态对象中派生值。

reselect 库创建选择器函数。选择器函数 接受一个参数——状态对象,并返回一个处理过的版本。

我们已经在 “使用 Redux 管理全局状态” 中看到了一个选择器。我们用它来从中央 Redux 状态返回当前的购物篮:

const basket = useSelector((state) => state.basket)

state => state.basket 是一个选择器函数;它从状态对象中派生出一些值。reselect 库创建高效的选择器函数,如果它们依赖的状态没有改变,可以缓存它们的结果。

要安装 reselect,请输入以下命令:

$ npm install reselect

让我们从创建一个选择器函数开始,它将执行以下操作:

  • 计算购物篮中所有物品的总数

  • 计算所有商品的总成本

我们将这个函数称为 summarizer。在详细介绍如何编写它之前,我们将首先编写一个测试,展示它需要做什么:

it('should be able to handle multiple products', () => {
  const actual = summarizer({
    basket: [
      { productId: '1234', quantity: 2, price: 1.23 },
      { productId: '5678', quantity: 1, price: 1.5 },
    ],
  })
  expect(actual).toEqual({ itemCount: 3, cost: 3.96 })
})

因此,如果我们给它一个状态对象,它将添加数量和成本,并返回一个包含 itemCountcost 的对象。

我们可以像这样使用 Reselect 库创建一个名为 summarizer 的选择器函数:

import { createSelector } from 'reselect'

const summarizer = createSelector(
  (state) => state.basket || [],
  (basket) => ({
    itemCount: basket.reduce((i, j) => i + j.quantity, 0),
    cost: basket.reduce((i, j) => i + j.quantity * j.price, 0),
  })
)

export default summarizer

createSelector 函数创建一个基于其他选择器函数的选择器函数。传递给它的每个参数(除了最后一个参数)都应该是选择器函数。我们只传递了一个:

(state) => state.basket || []

这段代码从状态中提取了购物篮。

传递给 createSelector 的最后一个参数(combiner)是一个函数,根据前面选择器的结果派生出一个新值:

(basket) => ({
  itemCount: basket.reduce((i, j) => i + j.quantity, 0),
  cost: basket.reduce((i, j) => i + j.quantity * j.price, 0),
})

basket 值是通过第一个选择器处理状态得到的结果。

到底有谁会以这种方式创建函数?这难道不比仅仅手动创建一个 JavaScript 函数复杂得多,而不需要将所有这些函数传递给函数?

答案是效率。选择器只会在需要时重新计算它们的值。状态对象可能很复杂,可能有数十个属性。但是我们只关注 basket 属性的内容,如果其他任何内容发生变化,我们都不希望重新计算成本。

reselect 的作用是确定它返回的值何时可能已经发生了变化。假设我们调用它一次,它会像这样计算 itemCountvalue

{itemCount: 3, cost: 3.96}

然后用户运行一堆命令,更新个人偏好设置,向某人发布消息,将几件物品添加到他们的愿望清单中,等等。

每个事件可能会更新全局应用程序状态。但是下次运行 summarizer 函数时,它将返回之前生成的缓存值:

{itemCount: 3, cost: 3.96}

为什么?因为它知道这个值仅仅依赖于全局状态中的 basket 值。如果那没有改变,它就不需要重新计算返回值。

因为 reselect 允许我们从其他选择器函数构建选择器函数,我们可以构建另一个名为 taxer 的选择器,来计算购物篮的销售税:

import { createSelector } from 'reselect'
import summarizer from './summarizer'

const taxer = createSelector(
  summarizer,
  (summary) => summary.cost * 0.07
)

export default taxer

taxer选择器使用summarizer函数返回的值。它获取summarizer结果的cost,并将其乘以 7%。如果篮子的总结总额不变,则taxer函数将不需要更新其结果。

现在我们有了summarizertaxer选择器,我们可以像使用任何其他选择器函数一样在组件内部使用它们:

import { useDispatch, useSelector } from 'react-redux'

import './Basket.css'
import summarizer from './summarizer'
import taxer from './taxer'

const Basket = () => {
  const basket = useSelector((state) => state.basket)
  const { itemCount, cost } = useSelector(summarizer)
  const tax = useSelector(taxer)
  const dispatch = useDispatch()

  return (
    <div className="Basket">
      <h2>Basket</h2>
      {basket && basket.length ? (
        <>
          {basket.map((item) => (
            <div className="Basket-item">
              <div className="Basket-itemName">{item.name}</div>
              <div className="Basket-itemProductId">
                {item.productId}
              </div>
              <div className="Basket-itemPricing">
                <div className="Basket-itemQuantity">
                  {item.quantity}
                </div>
                <div className="Basket-itemPrice">{item.price}</div>
              </div>
            </div>
          ))}
          <p>{itemCount} items</p>
          <p>Total: ${cost.toFixed(2)}</p>
          <p>Sales tax: ${tax.toFixed(2)}</p>
          <button onClick={() => dispatch({ type: 'clearBasket' })}>
            Clear
          </button>
        </>
      ) : (
        'Empty'
      )}
    </div>
  )
}

export default Basket

当我们现在运行代码时,在购物篮底部看到一个摘要,每当我们购买新产品时它都会更新(见图 3-22)。

图 3-22. 选择器重新计算总成本和销售税,仅在篮子变化时。

讨论

第一次遇到选择器函数时,它们可能会显得复杂和难以理解。但是花时间理解它们是值得的。它们与 Redux 无关。你也可以将它们与非 Redux reducer 一起使用。因为它们除了reselect库本身外没有任何依赖,所以很容易进行单元测试。我们在本章的代码中包含了示例测试。

你可以从GitHub 网站下载此配方的源代码。

第四章:交互设计

在本章中,我们将探讨一些解决典型界面问题的配方。如何处理错误?如何帮助人们使用您的系统?如何在不编写大量混乱代码的情况下创建复杂的输入序列?

这是一些我们一次又一次发现有用的技巧集合。在本章末尾,我们将探讨向应用程序添加动画的各种方法。我们尽可能采取低技术的方法,理想情况下,我们包含的配方将以最少的麻烦为界面设计增添意义。

构建一个集中式错误处理程序

问题

很难准确定义什么使得良好的软件变得优秀。但大多数优秀的软件共同点之一是它们如何响应错误和异常。当人们运行您的代码时,总会出现异常的、意外的情况:网络可能会消失,服务器可能会崩溃,存储可能会损坏。重要的是考虑在这些情况发生时应该如何处理。

几乎肯定会失败的一个方法是忽视错误条件的发生并隐藏发生了什么错误的细节。无论何时何地,您都需要留下一串证据,以便您可以防止再次发生该错误。

当我们编写服务器代码时,我们可能会记录错误详细信息并返回一个适当的响应消息。但是,如果我们编写客户端代码,我们需要一个处理本地错误的计划。我们可以选择向用户显示崩溃的详细信息,并要求他们提交错误报告。我们也可以使用像Sentry.io这样的第三方服务远程记录详细信息。

无论我们的代码做什么,它应该是一致的。但是在 React 应用程序中如何一致地处理异常?

解决方案

在这个配方中,我们将看一种创建集中式错误处理程序的方式。明确一点:这段代码不会自动捕获所有异常。它仍然需要明确添加到 JavaScript 的catch块中。它也不能替代从其他方面可以恢复的任何错误处理。如果一个订单失败是因为服务器正在进行维护,最好是告诉用户稍后再试。

但是这种技术有助于捕捉我们之前未计划的任何错误。

作为一个通则,当出现问题时,有三件事情你应该告诉用户:

  • 发生了什么

  • 为什么会发生

  • 他们应该如何应对

在我们展示的示例中,我们将通过显示对话框来处理错误,该对话框显示 JavaScript Error 对象的详细信息,并要求用户将内容发送到系统支持的邮箱。我们希望有一个简单的错误处理函数,当出现错误时我们可以调用它:

setVisibleError('Cannot do that thing', errorObject)

如果我们希望使函数在整个应用程序中都能够轻松使用,则通常的方法是使用上下文。上下文是一种我们可以在一组 React 组件周围包装的范围。我们放入该上下文的任何内容都可用于所有子组件。我们将使用我们的上下文来存储错误处理程序函数,当出现错误时我们可以运行它。

我们将称我们的上下文为ErrorHandlerContext

import React from 'react'

const ErrorHandlerContext = React.createContext(() => {})

export default ErrorHandlerContext

为了让上下文在一组组件中可用,让我们创建一个ErrorHandlerProvider组件,该组件将创建一个上下文实例,并使其对我们传递给它的任何子组件可用:

import ErrorHandlerContext from './ErrorHandlerContext'

let setError = () => {}

const ErrorHandlerProvider = (props) => {
  if (props.callback) {
    setError = props.callback
  }

  return (
    <ErrorHandlerContext.Provider value={setError}>
      {props.children}
    </ErrorHandlerContext.Provider>
  )
}

export default ErrorHandlerProvider

现在我们需要一些代码来说明当调用错误处理程序时应该做什么。在我们的情况下,我们需要一些代码来响应错误报告,并显示包含所有错误详情的对话框。如果您希望以不同方式处理错误,这就是您需要修改的代码:

import { useCallback, useState } from 'react'
import ErrorHandlerProvider from './ErrorHandlerProvider'
import ErrorDialog from './ErrorDialog'

const ErrorContainer = (props) => {
  const [error, setError] = useState()
  const [errorTitle, setErrorTitle] = useState()
  const [action, setAction] = useState()

  if (error) {
    console.error(
      'An error has been thrown',
      errorTitle,
      JSON.stringify(error)
    )
  }

  const callback = useCallback((title, err, action) => {
    console.error('ERROR RAISED ')
    console.error('Error title: ', title)
    console.error('Error content', JSON.stringify(err))
    setError(err)
    setErrorTitle(title)
    setAction(action)
  }, [])
  return (
    <ErrorHandlerProvider callback={callback}>
      {props.children}

      {error && (
        <ErrorDialog
          title={errorTitle}
          onClose={() => {
            setError(null)
            setErrorTitle('')
          }}
          action={action}
          error={error}
        />
      )}
    </ErrorHandlerProvider>
  )
}

export default ErrorContainer

ErrorContainer使用ErrorDialog显示详细信息。我们不会在此处详细介绍ErrorDialog的代码,因为这是您最有可能用自己实现替换的代码。^(1)

我们需要将应用程序的大部分内容包装在ErrorContainer中。ErrorContainer内的任何组件都可以调用错误处理程序:

import './App.css'
import ErrorContainer from './ErrorContainer'
import ClockIn from './ClockIn'

function App() {
  return (
    <div className="App">
      <ErrorContainer>
        <ClockIn />
      </ErrorContainer>
    </div>
  )
}

export default App

组件如何使用错误处理程序?我们将创建一个名为useErrorHandler()的自定义钩子,它将从上下文中获取错误处理程序函数并返回它:

import ErrorHandlerContext from './ErrorHandlerContext'
import { useContext } from 'react'

const useErrorHandler = () => useContext(ErrorHandlerContext)

export default useErrorHandler

这是一组相当复杂的代码,但现在我们来使用错误处理程序;这非常简单。此示例代码在用户单击按钮时进行网络请求。如果网络请求失败,则将错误的详细信息传递给错误处理程序:

import useErrorHandler from './useErrorHandler'
import axios from 'axios'

const ClockIn = () => {
  const setVisibleError = useErrorHandler()

  const doClockIn = async () => {
    try {
      await axios.put('/clockTime')
    } catch (err) {
      setVisibleError('Unable to record work start time', err)
    }
  }

  return (
    <>
      <h1>Click Button to Record Start Time</h1>
      <button onClick={doClockIn}>Start work</button>
    </>
  )
}

export default ClockIn

你可以在图 4-1 中看到应用程序的外观。

图 4-1. 记时应用程序

当你点击按钮时,由于服务器代码不存在,网络请求失败。图 4-2 显示了出现的错误对话框。注意,它显示了出错的原因、原因以及用户应该如何处理。

图 4-2. 当网络请求抛出异常时,我们将其传递给错误处理程序

讨论

在我们多年来创建的所有示例中,这个示例节省了最多的时间。在开发过程中,代码经常会出现问题,如果失败的唯一证据是隐藏在 JavaScript 控制台中的堆栈跟踪,您很可能会错过它。

当某个基础设施(网络、网关、服务器、数据库)出现故障时,这段小代码可以节省大量时间用于追踪故障原因。

你可以从GitHub 网站下载此示例的源代码。

创建一个交互式帮助指南

问题

蒂姆·伯纳斯-李(Tim Berners-Lee)特意设计了具有极少特性的 Web。它有一个简单的协议(HTTP),最初有一个简单的标记语言(HTML)。缺乏复杂性意味着网站的新用户立即知道如何使用它们。如果您看到类似超链接的东西,您可以单击它并转到另一页。

但是,富 JavaScript 应用程序改变了一切。现在的 Web 应用程序不再是超链接网页的集合。相反,它们类似于旧桌面应用程序;它们更强大且功能丰富,但缺点是现在使用起来更加复杂。

如何在您的应用程序中构建交互式指南?

解决方案

我们将构建一个简单的帮助系统,可以覆盖到现有应用程序上。当用户打开帮助时,他们将看到一系列弹出式说明,描述如何使用页面上可见的各种功能,如图 4-3 所示。

图 4-3. 当用户询问时显示一系列帮助消息

我们希望能够轻松维护并且只为可见组件提供帮助。这听起来是一项相当大的任务,所以让我们首先构建一个能够显示弹出帮助消息的组件:

import { Popper } from '@material-ui/core'
import './HelpBubble.css'

const HelpBubble = (props) => {
  const element = props.forElement
    ? document.querySelector(props.forElement)
    : null

  return element ? (
    <Popper
      className="HelpBubble-container"
      open={props.open}
      anchorEl={element}
      placement={props.placement || 'bottom-start'}
    >
      <div className="HelpBubble-close" onClick={props.onClose}>
        Close [X]
      </div>
      {props.content}
      <div className="HelpBubble-controls">
        {props.previousLabel ? (
          <div
            className="HelpBubble-control HelpBubble-previous"
            onClick={props.onPrevious}
          >
            &lt; {props.previousLabel}
          </div>
        ) : (
          <div>&nbsp;</div>
        )}
        {props.nextLabel ? (
          <div
            className="HelpBubble-control HelpBubble-next"
            onClick={props.onNext}
          >
            {props.nextLabel} &gt;
          </div>
        ) : (
          <div>&nbsp;</div>
        )}
      </div>
    </Popper>
  ) : null
}

export default HelpBubble

我们正在使用@material-ui库中的Popper组件。Popper组件可以在页面上的其他组件旁边锚定。我们的HelpBubble采用一个forElement字符串,该字符串将代表一个 CSS 选择器,如.class-name#some-id。我们将使用选择器将屏幕上的事物与弹出消息关联起来。

现在我们有了一个弹出消息组件,我们需要一个协调一系列HelpBubbles的东西。我们将其称为HelpSequence

import { useEffect, useState } from 'react'

import HelpBubble from './HelpBubble'

function isVisible(e) {
  return !!(
    e.offsetWidth ||
    e.offsetHeight ||
    e.getClientRects().length
  )
}

const HelpSequence = (props) => {
  const [position, setPosition] = useState(0)
  const [sequence, setSequence] = useState()

  useEffect(() => {
    if (props.sequence) {
      const filter = props.sequence.filter((i) => {
        if (!i.forElement) {
          return false
        }
        const element = document.querySelector(i.forElement)
        if (!element) {
          return false
        }
        return isVisible(element)
      })
      setSequence(filter)
    } else {
      setSequence(null)
    }
  }, [props.sequence, props.open])

  const data = sequence && sequence[position]

  useEffect(() => {
    setPosition(0)
  }, [props.open])

  const onNext = () =>
    setPosition((p) => {
      if (p === sequence.length - 1) {
        props.onClose && props.onClose()
      }
      return p + 1
    })

  const onPrevious = () =>
    setPosition((p) => {
      if (p === 0) {
        props.onClose && props.onClose()
      }
      return p - 1
    })

  return (
    <div className="HelpSequence-container">
      {data && (
        <HelpBubble
          open={props.open}
          forElement={data.forElement}
          placement={data.placement}
          onClose={props.onClose}
          previousLabel={position > 0 && 'Previous'}
          nextLabel={
            position < sequence.length - 1 ? 'Next' : 'Finish'
          }
          onPrevious={onPrevious}
          onNext={onNext}
          content={data.text}
        />
      )}
    </div>
  )
}

export default HelpSequence

HelpSequence接受一个像这样的 JavaScript 对象数组:

[
    {forElement: "p",
        text: "This is some introductory text telling you how to start"},
    {forElement: ".App-link", text: "This will show you how to use React"},
    {forElement: ".App-nowhere", text: "This help text will never appear"},
]

并将其转换为动态序列的HelpBubbles。仅当它可以找到与forElement选择器匹配的元素时,它才会显示HelpBubble。然后,它将HelpBubble放置在元素旁边并显示帮助文本。

让我们向由create-react-app生成的默认App.js代码添加一个HelpSequence

import { useState } from 'react'
import logo from './logo.svg'
import HelpSequence from './HelpSequence'
import './App.css'

function App() {
  const [showHelp, setShowHelp] = useState(false)

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
      <button onClick={() => setShowHelp(true)}>Show help</button>
      <HelpSequence
        sequence={[
          {
            forElement: 'p',
            text: 'This is some introductory text telling you how to start',
          },
          {
            forElement: '.App-link',
            text: 'This will show you how to use React',
          },
          {
            forElement: '.App-nowhere',
            text: 'This help text will never appear',
          },
        ]}
        open={showHelp}
        onClose={() => setShowHelp(false)}
      />
    </div>
  )
}

export default App

起初,我们除了一个帮助按钮之外什么也看不到(见图 4-4)。

图 4-4. 应用程序在首次加载时

当用户点击帮助按钮时,第一个帮助主题将显示,如图 4-5 所示。

图 4-5. 当用户点击帮助按钮时,帮助气泡将出现在第一个匹配项上。

图 4-6 显示了当用户点击“下一个”时帮助移动到下一个元素。用户可以继续从一个项目移动到另一个项目,直到没有更多匹配的可见元素。

图 4-6. 最后一个元素有一个完成按钮

讨论

向应用程序添加交互式帮助使用户界面可发现。开发人员花费大量时间向应用程序添加功能,而用户可能永远不知道它存在,仅仅是因为他们不知道它的存在。

此示例中的实现将帮助显示为简单的纯文本。您可以考虑使用 Markdown,因为这将允许更丰富的体验,帮助主题可以包含链接到其他更详细的帮助页面。^(2)

帮助主题自动限制为页面上可见的元素。您可以选择为每个页面创建一个单独的帮助序列,或者创建一个单一的大帮助序列,它将自动适应用户当前界面的视图。

最后,像这样的帮助系统非常适合存储在无头 CMS 中,这样可以动态更新帮助内容,而无需每次创建新的部署。

您可以从GitHub 网站下载此示例的源代码。

用于复杂交互的 Reducer。

问题

应用程序经常需要用户按照一系列操作。他们可能正在完成向导中的步骤,或者他们可能需要登录并确认某些危险操作(参见图 4-7)。

图 4-7. 这个删除过程需要登录然后确认删除。

用户不仅需要执行一系列步骤,这些步骤可能是有条件的。如果用户最近已经登录过,他们可能不需要再次登录。他们可能希望在序列的中途取消操作。

如果您在组件内建模复杂的序列,很快您的应用程序可能就会充满混乱的代码。

解决方案。

我们将使用一个 Reducer 来管理一系列复杂的操作。我们在第三章介绍了用于管理状态的 Reducer。Reducer是一个接受状态对象和动作的函数。Reducer 使用动作来决定如何更改状态,并且它不能有副作用。

因为 Reducer 没有用户界面代码,它们非常适合管理复杂的相互关联的状态片段,而不用担心视觉外观。它们特别适合单元测试。

例如,假设我们实现了在本示例开头提到的删除序列。我们可以通过经典的测试驱动风格开始,编写一个单元测试:

import deletionReducer from './deletionReducer'

describe('deletionReducer', () => {
  it('should show the login dialog if we are not logged in', () => {
    const actual = deletionReducer({}, { type: 'START_DELETION' })
    expect(actual.showLogin).toBe(true)
    expect(actual.message).toBe('')
    expect(actual.deleteButtonDisabled).toBe(true)
    expect(actual.loginError).toBe('')
    expect(actual.showConfirmation).toBe(false)
  })
})

我们的减少函数将被称为deletionReducer。我们向它传递一个空对象({})和一个指示我们要开始删除过程的动作({type: 'START_DELETION'})。然后我们说我们期望状态的新版本具有showLogin值为trueshowConfirmation值为false,等等。

然后,我们可以实现一个 Reducer 的代码来做到这一点:

function deletionReducer(state, action) {
  switch (action.type) {
    case 'START_DELETION':
      return {
        ...state,
        showLogin: true,
        message: '',
        deleteButtonDisabled: true,
        loginError: '',
        showConfirmation: false,
      }
    default:
      return null // Or anything
  }
}

起初,我们只是将状态属性设置为通过测试的值。随着我们添加更多的测试,我们的减速器在处理更多情况时得到改进。

最终,我们得到了类似这样的东西:^(3)

function deletionReducer(state, action) {
  switch (action.type) {
    case 'START_DELETION':
      return {
        ...state,
        showLogin: !state.loggedIn,
        message: '',
        deleteButtonDisabled: true,
        loginError: '',
        showConfirmation: !!state.loggedIn,
      }
    case 'CANCEL_DELETION':
      return {
        ...state,
        showLogin: false,
        showConfirmation: false,
        showResult: false,
        message: 'Deletion canceled',
        deleteButtonDisabled: false,
      }
    case 'LOGIN':
      const passwordCorrect = action.payload === 'swordfish'
      return {
        ...state,
        showLogin: !passwordCorrect,
        showConfirmation: passwordCorrect,
        loginError: passwordCorrect ? '' : 'Invalid password',
        loggedIn: true,
      }
    case 'CONFIRM_DELETION':
      return {
        ...state,
        showConfirmation: false,
        showResult: true,
        message: 'Widget deleted',
      }
    case 'FINISH':
      return {
        ...state,
        showLogin: false,
        showConfirmation: false,
        showResult: false,
        deleteButtonDisabled: false,
      }
    default:
      throw new Error('Unknown action: ' + action.type)
  }
}

export default deletionReducer

尽管此代码复杂,但如果您首先创建测试,可以快速编写它。

现在我们有了减速器,可以在我们的应用程序中使用它:

import { useReducer, useState } from 'react'
import './App.css'
import deletionReducer from './deletionReducer'

function App() {
  const [state, dispatch] = useReducer(deletionReducer, {})
  const [password, setPassword] = useState()

  return (
    <div className="App">
      <button
        onClick={() => {
          dispatch({ type: 'START_DELETION' })
        }}
        disabled={state.deleteButtonDisabled}
      >
        Delete Widget!
      </button>
      <div className="App-message">{state.message}</div>
      {state.showLogin && (
        <div className="App-dialog">
          <p>Enter your password</p>
          <input
            type="password"
            value={password}
            onChange={(evt) => setPassword(evt.target.value)}
          />
          <button
            onClick={() =>
              dispatch({ type: 'LOGIN', payload: password })
            }
          >
            Login
          </button>
          <button
            onClick={() => dispatch({ type: 'CANCEL_DELETION' })}
          >
            Cancel
          </button>
          <div className="App-error">{state.loginError}</div>
        </div>
      )}
      {state.showConfirmation && (
        <div className="App-dialog">
          <p>Are you sure you want to delete the widget?</p>
          <button
            onClick={() =>
              dispatch({
                type: 'CONFIRM_DELETION',
              })
            }
          >
            Yes
          </button>
          <button
            onClick={() =>
              dispatch({
                type: 'CANCEL_DELETION',
              })
            }
          >
            No
          </button>
        </div>
      )}
      {state.showResult && (
        <div className="App-dialog">
          <p>The widget was deleted</p>
          <button
            onClick={() =>
              dispatch({
                type: 'FINISH',
              })
            }
          >
            Done
          </button>
        </div>
      )}
    </div>
  )
}

export default App

这段代码的大部分只是为序列中的每个对话框创建用户界面。在这个组件中几乎没有逻辑。它只是按照减速器告诉它做的事情。它将用户带过快乐路径,登录并确认删除(参见图 4-8)。

图 4-8. 最终结果

但是图 4-9 显示它也处理了所有的边缘情况,比如无效密码和取消。

图 4-9. 缩小处理的边缘情况均由减速器处理

讨论

有时减速器会使您的代码变得复杂;如果您的状态片段很少并且它们之间的交互也很少,您可能不需要一个减速器。但是,如果您发现自己在绘制流程图或状态图来描述一系列用户交互的顺序,那就是您可能需要减速器的信号。

您可以从GitHub 网站下载此示例的源代码。

添加键盘交互

问题

电源用户喜欢使用键盘进行频繁使用的操作。React 组件可以响应键盘事件,但仅当它们(或它们的子组件)具有焦点时。如果您希望组件在文档级别响应事件,该怎么办?

解决方案

我们将创建一个键监听器钩子,以监听document级别的keydown事件。但是,可以轻松修改以便在 DOM 中监听任何其他 JavaScript 事件。这是钩子:

import { useEffect } from 'react'

const useKeyListener = (callback) => {
  useEffect(() => {
    const listener = (e) => {
      e = e || window.event
      const tagName = e.target.localName || e.target.tagName
      // Only accept key-events that originated at the body level
      // to avoid key-strokes in e.g. text-fields being included
      if (tagName.toUpperCase() === 'BODY') {
        callback(e)
      }
    }
    document.addEventListener('keydown', listener, true)
    return () => {
      document.removeEventListener('keydown', listener, true)
    }
  }, [callback])
}

export default useKeyListener

该钩子接受一个回调函数并在document对象上注册它以监听keydown事件。在useEffect结束时,它返回一个函数,该函数将注销回调。如果我们传入的回调函数发生变化,我们将首先注销旧函数,然后注册新函数。

我们如何使用这个钩子?这里有一个例子。看看你是否注意到我们必须处理的小编码瑕疵:

import { useCallback, useState } from 'react'
import './App.css'
import useKeyListener from './useKeyListener'

const RIGHT_ARROW = 39
const LEFT_ARROW = 37
const ESCAPE = 27

function App() {
  const [angle, setAngle] = useState(0)
  const [lastKey, setLastKey] = useState('')

  let onKeyDown = useCallback(
    (evt) => {
      if (evt.keyCode === LEFT_ARROW) {
        setAngle((c) => Math.max(-360, c - 10))
        setLastKey('Left')
      } else if (evt.keyCode === RIGHT_ARROW) {
        setAngle((c) => Math.min(360, c + 10))
        setLastKey('Right')
      } else if (evt.keyCode === ESCAPE) {
        setAngle(0)
        setLastKey('Escape')
      }
    },
    [setAngle]
  )
  useKeyListener(onKeyDown)

  return (
    <div className="App">
      <p>
        Angle: {angle} Last key: {lastKey}
      </p>
      <svg
        width="400px"
        height="400px"
        title="arrow"
        fill="none"
        strokeWidth="10"
        stroke="black"
        style={{
          transform: `rotate(${angle}deg)`,
        }}
      >
        <polyline points="100,200 200,0 300,200" />
        <polyline points="200,0 200,400" />
      </svg>
    </div>
  )
}

export default App

这段代码监听用户按左/右光标键。我们的onKeyDown函数说明了这些按键点击发生时应该发生什么,但请注意,我们已经将其包装在useCallback中。如果我们没有这样做,浏览器将在每次渲染App组件时重新创建onKeyDown函数。新函数将与旧的onKeyDown函数执行相同的操作,但它会存在于内存中的不同位置,而useKeyListener将不断注销和重新注册它。

如果您忘记在useCallback中包装回调函数,可能会导致大量的渲染调用,从而减慢应用程序的速度。

通过使用useCallback,我们可以确保只在setAngle发生变化时创建该函数。

如果您运行该应用程序,您将在屏幕上看到一个箭头。如果您按左/右箭头键(参见图 4-10),您可以旋转图像。如果您按 Escape 键,则可以将其重置为垂直状态。

图 4-10. 按下左/右/Escape 键会导致箭头旋转

讨论

我们在useKeyListener函数中小心地只监听源自body级别的事件。如果用户在文本字段中点击箭头键,浏览器不会将这些事件发送到你的代码。

你可以从GitHub 网站下载这个示例的源代码。

使用 Markdown 进行富内容处理

问题

如果你的应用允许用户提供大块文本内容,那么让这些内容包含格式化文本、链接等将会很有帮助。然而,允许用户传递原始的 HTML 等内容可能会导致安全漏洞,并给开发人员带来无法预料的痛苦。

如何让用户发布富文本内容而不损害应用程序的安全性?

解决方案

Markdown 是一种安全地允许用户向您的应用程序发布富内容的绝佳方式。要了解如何在您的应用程序中使用 Markdown,请考虑这个简单的应用程序,它允许用户在列表中发布带有时间戳的一系列消息:

import { useState } from 'react'
import './Forum.css'

const Forum = () => {
  const [text, setText] = useState('')
  const [messages, setMessages] = useState([])

  return (
    <section className="Forum">
      <textarea
        cols={80}
        rows={20}
        value={text}
        onChange={(evt) => setText(evt.target.value)}
      />
      <button
        onClick={() => {
          setMessages((msgs) => [
            {
              body: text,
              timestamp: new Date().toISOString(),
            },
            ...msgs,
          ])
          setText('')
        }}
      >
        Post
      </button>
      {messages.map((msg) => {
        return (
          <dl>
            <dt>{msg.timestamp}</dt>
            <dd>{msg.body}</dd>
          </dl>
        )
      })}
    </section>
  )
}

export default Forum

当您运行该应用程序时(参见图 4-11),您会看到一个大文本区域。当您发布纯文本消息时,应用程序会保留空格和换行符。

图 4-11. 用户在文本区域输入文本,然后将其作为纯文本消息发布

如果你的应用程序包含文本区域,考虑允许用户输入 Markdown 内容是值得的。

有许多 Markdown 库可供选择,但大多数都是react-markdown的包装器或语法高亮器,如PrismJSCodeMirror

我们将查看一个名为react-md-editor的库,它为react-markdown添加了额外的功能,并允许您显示和编辑 Markdown。我们将从安装该库开始:

$ npm install @uiw/react-md-editor

现在我们将将我们的纯文本区域转换为 Markdown 编辑器,并将发布的消息从 Markdown 转换为 HTML:

import { useState } from 'react'
import MDEditor from '@uiw/react-md-editor'

const MarkdownForum = () => {
  const [text, setText] = useState('')
  const [messages, setMessages] = useState([])

  return (
    <section className="Forum">
      <MDEditor height={300} value={text} onChange={setText} />
      <button
        onClick={() => {
          setMessages((msgs) => [
            {
              body: text,
              timestamp: new Date().toISOString(),
            },
            ...msgs,
          ])
          setText('')
        }}
      >
        Post
      </button>
      {messages.map((msg) => {
        return (
          <dl>
            <dt>{msg.timestamp}</dt>
            <dd>
              <MDEditor.Markdown source={msg.body} />
            </dd>
          </dl>
        )
      })}
    </section>
  )
}

export default MarkdownForum

将纯文本转换为 Markdown 是一个小改变,却有很大的回报。正如您在图 4-12 中所看到的,用户可以对消息应用丰富的格式,并选择在发布之前在全屏模式下进行编辑。

图 4-12. Markdown 编辑器在您输入时显示预览,并允许您全屏工作

讨论

将 Markdown 添加到应用程序中非常快速,并且可以通过最小的努力来改善用户体验。有关 Markdown 的更多详情,请参阅约翰·格鲁伯的原始指南

你可以从GitHub 网站下载这个示例的源代码。

使用 CSS 类进行动画处理

问题

你想给你的应用程序添加一点简单的动画效果,但又不想通过安装第三方库来增加应用程序的大小。

解决方案

在 React 应用程序中,大多数你可能需要的动画可能不需要第三方动画库。这是因为现在 CSS 动画使得浏览器具有用极少代码实现 CSS 属性动画的能力。它只需很少的代码,并且由于图形硬件生成动画非常流畅。GPU 动画消耗的能量更少,更适合移动设备。

如果你想在你的 React 应用程序中添加动画,先从 CSS 动画开始,然后再看其他地方。

CSS 动画如何工作?它使用了一个叫做 transition 的 CSS 属性。假设我们想创建一个可扩展的信息面板。当用户点击按钮时,面板会平滑地打开。当他们再次点击时,它会平滑地关闭,如图 Figure 4-13 所示。

图 4-13. 简单的 CSS 动画会平滑地展开和收缩面板

我们可以使用 CSS 的 transition 属性来创建这种效果:

.InfoPanel-details {
    height: 350px;
    transition: height 0.5s;
}

此 CSS 指定了 heighttransition 属性。这个组合意味着“无论当前的高度如何,在下半秒内动画到我的首选高度。”

当元素的 height 改变时(例如当额外的 CSS 规则变为有效时),动画将会发生。例如,如果我们有一个额外的 CSS 类名,其高度不同,transition 属性将在元素切换到不同类时动画高度变化:

.InfoPanel-details {
    height: 350px;
    transition: height 0.5s;
}
.InfoPanel-details.InfoPanel-details-closed {
    height: 0;
}

这个类名结构是块元素修饰符(BEM)命名的一个例子。 是组件(InfoPanel),元素 是块内的东西(details),修饰符 描述了元素的当前状态(closed)。BEM 规范减少了代码中名称冲突的可能性。

如果一个 InfoPanel-details 元素突然获得了额外的 .InfoPanel-details-closed 类,height 将从 350px 变为 0,并且 transition 属性将平滑地收缩该元素。相反,如果组件失去 .InfoPanel-details-closed 类,则元素将再次展开。

这意味着我们可以将繁重的工作推迟到 CSS 上,而我们在 React 代码中所需做的只是向元素添加或移除类:

import { useState } from 'react'

import './InfoPanel.css'

const InfoPanel = ({ title, children }) => {
  const [open, setOpen] = useState(false)

  return (
    <section className="InfoPanel">
      <h1>
        {title}
        <button onClick={() => setOpen((v) => !v)}>
          {open ? '^' : 'v'}
        </button>
      </h1>
      <div
        className={`InfoPanel-details ${
          open ? '' : 'InfoPanel-details-closed'
        }`}
      >
        {children}
      </div>
    </section>
  )
}

export default InfoPanel

讨论

我们经常看到许多项目打包第三方组件库,以使用一些小部件来展开或收缩其内容。正如你所见,这样的动画非常容易包含。

你可以从GitHub 网站下载这个配方的源代码。

使用 React 动画

问题

CSS 动画非常简单,并且适用于大多数你可能需要的动画效果。

然而,它们要求您理解各种 CSS 属性及其动画效果。如果您想通过快速扩展并变成透明来说明删除项目,您该如何做?

诸如 Animate.css 这样的库包含大量预设的 CSS 动画,但它们通常需要更高级的 CSS 动画概念,如关键帧,并且不特别适合 React。我们如何将 CSS 库动画添加到 React 应用程序中?

解决方案

React Animations 库是 Animate.css 库的 React 包装器。它会有效地向您的组件添加动画样式,而不会产生不必要的渲染或显著增加生成的 DOM 的大小。

它能如此高效地工作,因为 React Animations 与 CSS-in-JS 库配合使用。CSS-in-JS 是一种直接在 JavaScript 代码中编写样式信息的技术。React 将允许您将样式属性添加为 React 组件,但是 CSS-in-JS 更加高效,动态创建页面头部的共享样式元素。

有几个 CSS-in-JS 库可供选择,但在本示例中,我们将使用一个称为 Radium 的库。

让我们从安装 Radium 和 React Animations 开始:

$ npm install radium
$ npm install react-animations

我们的示例应用(图 4-14)每次向集合添加图像项时都会运行一个动画。

图 4-14. 单击“添加”按钮将从 picsum.photos 加载新图像

同样地,当用户点击图像时,会显示一个淡出动画,然后从列表中移除图像,如 图 4-15 所示。^(4)

图 4-15. 如果单击第五张图像,它将从列表中淡出并消失

我们将从 Radium 中导入一些动画和辅助代码:

import { pulse, zoomOut, shake, merge } from 'react-animations'
import Radium, { StyleRoot } from 'radium'

const styles = {
  created: {
    animation: 'x 0.5s',
    animationName: Radium.keyframes(pulse, 'pulse'),
  },
  deleted: {
    animation: 'x 0.5s',
    animationName: Radium.keyframes(merge(zoomOut, shake), 'zoomOut'),
  },
}

从 React Animations 中获取 pulsezoomOutshake 动画。当我们添加图像时,我们将使用 pulse 动画。当我们移除图像时,我们将使用 zoomOutshake合并动画。我们可以使用 React Animations 的 merge 函数组合动画。

styles 生成运行这些半秒动画所需的所有 CSS 样式。对 Radium.keyframes() 的调用为我们处理所有动画细节。

我们必须知道动画何时完全结束。如果我们在删除动画完成之前删除图像,就不会有图像可以动画化。

我们可以通过向要动画化的任何元素传递 onAnimationEnd 回调来跟踪 CSS 动画。对于我们图像集合中的每个项目,我们将跟踪三件事:

  • 它表示的图像的 URL

  • 一个布尔值,在“created”动画运行时为真

  • 一个布尔值,在“deleted”动画运行时为真

这是将图像动画化进出集合的示例代码:

import { useState } from 'react'
import { pulse, zoomOut, shake, merge } from 'react-animations'
import Radium, { StyleRoot } from 'radium'

import './App.css'

const styles = {
  created: {
    animation: 'x 0.5s',
    animationName: Radium.keyframes(pulse, 'pulse'),
  },
  deleted: {
    animation: 'x 0.5s',
    animationName: Radium.keyframes(merge(zoomOut, shake), 'zoomOut'),
  },
}

function getStyleForItem(item) {
  return item.deleting
    ? styles.deleted
    : item.creating
    ? styles.created
    : null
}

function App() {
  const [data, setData] = useState([])

  let deleteItem = (i) =>
    setData((d) => {
      const result = [...d]
      result[i].deleting = true
      return result
    })
  let createItem = () => {
    setData((d) => [
      ...d,
      {
        url: `https://picsum.photos/id/${d.length * 3}/200`,
        creating: true,
      },
    ])
  }
  let completeAnimation = (d, i) => {
    if (d.deleting) {
      setData((d) => {
        const result = [...d]
        result.splice(i, 1)
        return result
      })
    } else if (d.creating) {
      setData((d) => {
        const result = [...d]
        result[i].creating = false
        return result
      })
    }
  }
  return (
    <div className="App">
      <StyleRoot>
        <p>
          Images from&nbsp;
          <a href="https://picsum.photos/">Lorem Picsum</a>
        </p>
        <button onClick={createItem}>Add</button>
        {data.map((d, i) => (
          <div
            style={getStyleForItem(d)}
            onAnimationEnd={() => completeAnimation(d, i)}
          >
            <img
              id={`image${i}`}
              src={d.url}
              width={200}
              height={200}
              alt="Random"
              title="Click to delete"
              onClick={() => deleteItem(i)}
            />
          </div>
        ))}
      </StyleRoot>
    </div>
  )
}

export default App

讨论

在选择要使用的动画时,我们首先应该问:这个动画意味着什么?

所有的动画都应该有意义。它可以展示某种存在(创建或删除)。它可能指示状态的变化(启用或禁用)。它可能放大以显示细节或缩小以揭示更广泛的背景。或者它可能说明限制或边界(在长列表末尾的弹簧回弹动画)或允许用户表达偏好(向左或向右滑动)。

动画应该也要简短。大多数动画可能在半秒内完成,这样用户可以体验动画的含义,而无需过于关注其外观。

动画永远不应该仅仅是吸引人

您可以从GitHub 站点下载此配方的源代码。

使用 TweenOne 动画信息图表

问题

CSS 动画流畅而高效。浏览器可能会将 CSS 动画延迟到合成阶段的图形硬件上,这意味着动画不仅以机器码速度运行,而且机器码本身不在 CPU 上运行。

然而,将 CSS 动画运行在图形硬件上的缺点是,在动画期间,您的应用程序代码不会知道发生了什么。您可以跟踪动画何时开始,结束或重复(onAnimationStartonAni⁠ma⁠tionEndonAnimationIteration),但其中发生的一切是个谜。

如果你正在制作信息图表动画,可能希望在条形图的柱子增长或缩小时动画化数字。或者,如果你正在编写一个跟踪骑行者的应用程序,你可能希望在自行车穿越地形时显示当前的海拔高度。

但是,您如何创建可以在发生时侦听的动画?

解决方案

TweenOne 库使用 JavaScript 创建动画,这意味着您可以帧-by-帧地跟踪它们发生的过程。

让我们从安装 TweenOne 库开始:

$ npm install rc-tween-one

TweenOne 与 CSS 一起工作,但它不使用 CSS 动画。相反,它生成 CSS 转换,并且每秒更新多次。

你需要将想要动画化的对象包裹在一个<TweenOne/>元素中。例如,假设我们想要在 SVG 中动画化一个rect

<TweenOne component='g' animation={...details here}>
    <rect width="2" height="6" x="3" y="-3" fill="white"/>
</TweenOne>

TweenOne接受一个元素名称和一个描述要执行的动画的对象。我们将很快看到这个动画对象的样子。

TweenOne 将使用元素名称(本例中为g)生成包裹在动画对象周围的包装器。此包装器将具有一个样式属性,其中包括一组 CSS 转换,用于将内容移动和旋转到某个位置。

因此,在我们的例子中,动画的某个时刻,DOM 可能看起来像这样:

<g style="transform: translate(881.555px, 489.614px) rotate(136.174deg);">
  <rect width="2" height="6" x="3" y="-3" fill="white"/>
</g>

尽管您可以创建类似于 CSS 动画的效果,但 TweenOne 库的工作方式不同。TweenOne 库不会将动画交给硬件处理,而是使用 JavaScript 逐帧创建动画,这有两个后果。首先,它会使用更多的 CPU 资源(不好),其次,我们可以跟踪动画的进行(好)。

如果我们向 TweenOne 传递一个 onUpdate 回调,我们将收到有关每一帧动画的信息:

<TweenOne component='g' animation={...details here} onUpdate={info=>{...}}>
    <rect width="2" height="6" x="3" y="-3" fill="white"/>
</TweenOne>

传递给 onUpdateinfo 对象有一个 ratio 值,介于 0 到 1 之间,表示 TweenOne 元素在动画中的位置比例。我们可以使用 ratio 来动画化与图形关联的文本。

例如,如果我们构建一个动画仪表板,显示赛道上的车辆,我们可以使用 onUpdate 显示每辆车的速度和距离随着动画的进行。

我们将在 SVG 中创建此示例的可视化内容。首先,让我们创建一个包含 SVG 路径的字符串,代表赛道:

export default 'm 723.72379,404.71306 ...  -8.30851,-3.00521 z'

这只是我们将使用的实际路径的大大简化版本。我们可以像这样从 track.js 导入路径字符串:

import path from './track'

要在 React 组件内显示轨道,我们可以渲染一个 svg 元素:

<svg height="600" width="1000" viewBox="0 0 1000 600"
     style={{backgroundColor: 'black'}}>
  <path stroke='#444' strokeWidth={10}
        fill='none' d={path}/>
</svg>

我们可以为车辆添加一对矩形——一个红色用于车身,一个白色用于挡风玻璃:

<svg height="600" width="1000" viewBox="0 0 1000 600"
     style={{backgroundColor: 'black'}}>
  <path stroke='#444' strokeWidth={10}
        fill='none' d={path}/>
  <rect width={24} height={16} x={-12} y={-8} fill='red'/>
  <rect width={2} height={6} x={3} y={-3} fill='white'/>
</svg>

图 4-16 显示了顶部左侧带有车辆的轨道。

图 4-16. 左上角带有微小车辆的静态图像

但是,我们如何使车辆在赛道周围动起来呢?TweenOne 让这变得很容易,因为它包含一个插件,用于生成遵循 SVG 路径字符串的动画。

import PathPlugin from 'rc-tween-one/lib/plugin/PathPlugin'

TweenOne.plugins.push(PathPlugin)

我们已经配置了 TweenOne 用于 SVG 路径动画。这意味着我们可以看看如何描述 TweenOne 的动画。我们用一个简单的 JavaScript 对象来做到这一点:

import path from './track'

const followAnimation = {
  path: { x: path, y: path, rotate: path },
  repeat: -1,
}

使用这个对象告诉 TweenOne 两件事:首先,我们告诉它生成跟随我们从 track.js 导入的 path 字符串的平移和旋转。其次,我们通过将 repeat 计数设置为-1,表示我们希望动画无限循环。

我们可以将其作为我们车辆动画的基础:

<svg height="600" width="1000" viewBox="0 0 1000 600"
     style={{backgroundColor: 'black'}}>
  <path stroke='#444' strokeWidth={10}
        fill='none' d={path}/>
  <TweenOne component='g' animation={{...followAnimation, duration: 16000}}>
    <rect width={24} height={16} x={-12} y={-8} fill='red'/>
    <rect width={2} height={6} x={3} y={-3} fill='white'/>
  </TweenOne>
</svg>

注意,我们使用展开运算符提供了额外的动画参数:duration。值为 16000 表示我们希望动画持续 16 秒。

我们可以添加第二辆车,并使用 onUpdate 回调方法为每辆车创建一个非常基本的虚拟遥测统计信息。以下是完成的代码:

import { useState } from 'react'
import TweenOne from 'rc-tween-one'
import Details from './Details'
import path from './track'
import PathPlugin from 'rc-tween-one/lib/plugin/PathPlugin'
import grid from './grid.svg'

import './App.css'

TweenOne.plugins.push(PathPlugin)

const followAnimation = {
  path: { x: path, y: path, rotate: path },
  repeat: -1,
}

function App() {
  const [redTelemetry, setRedTelemetry] = useState({
    dist: 0,
    speed: 0,
    lap: 0,
  })
  const [blueTelemetry, setBlueTelemetry] = useState({
    dist: 0,
    speed: 0,
    lap: 0,
  })

  const trackVehicle = (info, telemetry) => ({
    dist: info.ratio,
    speed: info.ratio - telemetry.dist,
    lap:
      info.ratio < telemetry.dist ? telemetry.lap + 1 : telemetry.lap,
  })

  return (
    <div className="App">
      <h1>Nürburgring</h1>
      <Details
        redTelemetry={redTelemetry}
        blueTelemetry={blueTelemetry}
      />
      <svg
        height="600"
        width="1000"
        viewBox="0 0 1000 600"
        style={{ backgroundColor: 'black' }}
      >
        <image href={grid} width={1000} height={600} />
        <path stroke="#444" strokeWidth={10} fill="none" d={path} />
        <path
          stroke="#c0c0c0"
          strokeWidth={2}
          strokeDasharray="3 4"
          fill="none"
          d={path}
        />

        <TweenOne
          component="g"
          animation={{
            ...followAnimation,
            duration: 16000,
            onUpdate: (info) =>
              setRedTelemetry((telemetry) =>
                trackVehicle(info, telemetry)
              ),
          }}
        >
          <rect width={24} height={16} x={-12} y={-8} fill="red" />
          <rect width={2} height={6} x={3} y={-3} fill="white" />
        </TweenOne>

        <TweenOne
          component="g"
          animation={{
            ...followAnimation,
            delay: 3000,
            duration: 15500,
            onUpdate: (info) =>
              setBlueTelemetry((telemetry) =>
                trackVehicle(info, telemetry)
              ),
          }}
        >
          <rect width={24} height={16} x={-12} y={-8} fill="blue" />
          <rect width={2} height={6} x={3} y={-3} fill="white" />
        </TweenOne>
      </svg>
    </div>
  )
}

export default App

图 4-17 显示了动画。车辆沿着赛道的路径移动,并旋转以面向行驶方向。

图 4-17. 从当前动画状态生成的遥测的最终动画

讨论

CSS 动画是大多数 UI 动画应该使用的技术。然而,在信息图表的情况下,通常需要同步文本和图形。TweenOne 可以实现这一点,但会增加 CPU 使用率的成本。

你可以从GitHub 网站下载此配方的源代码。

^(1) 你可以在GitHub 仓库上下载此配方的所有源代码。

^(2) 详细了解如何在您的应用程序中使用 Markdown,请参阅“使用 Markdown 创建丰富内容”。

^(3) 参见GitHub 仓库以获取我们用于驱动此代码的测试。

^(4) 纸质书籍是美好的事物,但要完全体验动画效果,请在GitHub上查看完整的代码。

第五章:连接到服务

与 Angular 等框架不同,React 并不包含应用程序可能需要的一切。特别是,它不提供一种标准方法将数据从网络服务获取到您的应用程序中。这种自由很好,因为它意味着 React 应用程序可以使用最新的技术。不利之处在于,刚开始使用 React 的开发人员可能会被单独留给自己解决的困境。

在本章中,我们将看到一些将网络服务附加到您的应用程序的方法。我们将通过每个示例看到一些共同的主题,并尝试将网络代码与使用它的组件分开。这样,当新的网络服务技术出现时,我们可以无需大量更改代码就进行切换。

将网络调用转换为 Hooks

问题

组件化开发的优势之一是将代码分解为小的可管理的块,每个块执行一个明确可识别的操作。在某些方面,最好的组件是您可以在大屏幕上看到而无需滚动的组件。React 的一个伟大特性是,它在许多方面随着时间的推移变得更简单了。React hooks 和远离基于类的组件已经消除了样板代码并减少了代码量。

但是,扩展组件大小的一种方法是填充网络代码。如果您的目标是创建简单的代码,您应该尝试从组件中剥离网络代码。组件将变得更小,网络代码将更具重复使用性。

但我们应该如何拆分出网络代码呢?

解决方案

在这个示例中,我们将看到一种将您的网络请求移动到 React hooks 中的方法,以跟踪网络请求是否仍在进行中,或者是否发生了阻止其成功的错误。

在我们查看细节之前,我们需要考虑在进行异步网络请求时对我们来说什么是重要的。有三件事我们需要跟踪:

  • 请求返回的数据

  • 请求是否仍在从服务器加载数据

  • 在运行请求时可能发生的任何错误

您将会在本章的每个示例中看到这三个要素出现。无论我们是使用fetchaxios命令,通过 Redux 中间件,还是通过类似GraphQL的 API 查询层进行请求,我们的组件始终会关注数据、加载状态和错误。

例如,让我们构建一个简单的留言板,其中包含几个论坛。每个论坛上的留言包含一个author字段和一个text字段。图 5-1 显示了示例应用程序的屏幕截图,您可以从 GitHub 站点下载。

图 5-1. 这些按钮选择NASANot NASA论坛

页面顶部的按钮选择“NASA”或“Not NASA”论坛。一个小的 Node 服务器为我们的示例应用程序提供后端,预先向 NASA 论坛中预填充了一些消息。下载源代码后,您可以通过运行应用程序主目录中的server.js脚本来运行后端服务器:

$ node ./server.js

后端服务器运行在http://localhost:5000。我们可以以通常的方式启动 React 应用程序:

$ npm run start

React 应用程序将在端口 3000 上运行。

在开发模式下,我们通过 React 服务器代理所有后端请求。如果您使用的是create-react-app,可以通过在package.json中添加proxy属性并将其设置为http://localhost:5000来实现这一点。React 服务器将 API 调用传递给我们的server.js后端。例如,http://localhost:3000/messages/nasa(返回 NASA 论坛的消息数组)将被代理到http://localhost:5000/messages/nasa

我们将使用简单的fetch命令进行网络请求以读取消息:

const response = await fetch(`/messages/${forum}`)
if (!response.ok) {
  const text = await response.text()
  throw new Error(`Unable to read messages for ${forum}: ${text}`)
}
const body = await response.json()

在这里,forum值将包含论坛的字符串 ID。fetch命令是异步的并返回一个 promise,所以我们将await它。然后我们可以检查调用是否因为任何错误的 HTTP 状态而失败,如果是,我们将抛出一个错误。我们将从响应中提取 JSON 对象并将其存储在body变量中。如果响应体不是正确格式的 JSON 对象,我们也将抛出一个错误。

在此调用中,我们需要跟踪三件事:数据、加载状态和任何错误。我们将把整个过程封装在一个自定义钩子中,所以让我们有三个状态称为dataloadingerror

const useMessages = (forum) => {
  const [data, setData] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()
  ....
  return { data, loading, error }
}

我们将论坛名称作为参数传递给useMessages钩子,该钩子将返回一个包含dataloadingerror状态的对象。我们可以在使用该钩子的任何组件中使用对象解构来提取和重命名这些值,例如:

const {
  data: messages,
  loading: messagesLoading,
  error: messagesError,
} = useMessages('nasa')

在扩展运算符中重命名变量有助于避免命名冲突。例如,如果您想从多个论坛读取消息,可以第二次调用useMessages钩子并选择与第二个钩子响应不同的变量名。

让我们回到useMessages钩子。网络请求取决于我们传入的forum值,因此我们需要确保在useEffect中运行fetch请求:

useEffect(() => {
  setError(null)
  if (forum) {
    ....
  } else {
    setData([])
    setLoading(false)
  }
}, [forum])

目前我们省略了实际请求的代码。useEffect中的代码将在第一次调用钩子时运行。如果客户端组件重新渲染并传入相同的forum值,useEffect将不会运行,因为[forum]依赖项没有变化。只有当forum值改变时,它才会再次运行。

现在让我们看看如何将fetch请求插入到这个钩子中:

import { useEffect, useState } from 'react'

const useMessages = (forum) => {
  const [data, setData] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()

  useEffect(() => {
    let didCancel = false
    setError(null)
    if (forum) {
      ;(async () => {
        try {
          setLoading(true)
          const response = await fetch(`/messages/${forum}`)
          if (!response.ok) {
            const text = await response.text()
            throw new Error(
              `Unable to read messages for ${forum}: ${text}`
            )
          }
          const body = await response.json()
          if (!didCancel) {
            setData(body)
          }
        } catch (err) {
          setError(err)
        } finally {
          setLoading(false)
        }
      })()
    } else {
      setData([])
      setLoading(false)
    }
    return () => {
      didCancel = true
    }
  }, [forum])

  return { data, loading, error }
}

export default useMessages

因为我们使用await正确处理承诺,所以我们需要在一个相当难看的(async () => {...})调用中包装代码。在其中,我们能够为dataloadingerror设置值,因为请求运行、完成和(可能)失败。所有这些将在钩子调用完成后异步发生。当dataloadingerror状态变化时,钩子将导致组件使用新值重新渲染。

在钩子中使用异步代码的后果是,在网络响应接收之前,钩子将返回。这意味着在先前的网络响应接收之前,有可能再次调用钩子。为了避免网络响应按错误的顺序解析,示例代码使用didCancel变量跟踪当前请求是否被后续请求覆盖。这个变量将控制钩子是否返回钩子中的数据。它不会取消网络请求本身。要取消网络请求,请参阅“使用令牌取消网络请求”。

让我们看一看示例应用程序中的App.js,看看如何使用这个钩子:

import './App.css'
import { useState } from 'react'
import useMessages from './useMessages'

function App() {
  const [forum, setForum] = useState('nasa')
  const {
    data: messages,
    loading: messagesLoading,
    error: messagesError,
  } = useMessages(forum)

  return (
    <div className="App">
      <button onClick={() => setForum('nasa')}>NASA</button>
      <button onClick={() => setForum('notNasa')}>Not NASA</button>
      {messagesError ? (
        <div className="error">
          Something went wrong:
          <div className="error-contents">
            {messagesError.message}
          </div>
        </div>
      ) : messagesLoading ? (
        <div className="loading">Loading...</div>
      ) : messages && messages.length ? (
        <dl>
          {messages.map((m) => (
            <>
              <dt>{m.author}</dt>
              <dd>{m.text}</dd>
            </>
          ))}
        </dl>
      ) : (
        'No messages'
      )}
    </div>
  )
}

export default App

我们的示例应用程序在你点击 NASA 或非 NASA 按钮时改变加载的论坛。示例服务器返回“Not NASA”论坛的 404 状态,导致屏幕上出现错误。在图 5-2 中,我们可以看到示例应用程序显示加载状态,来自 NASA 论坛的消息,以及尝试从缺失的“Not NASA”论坛加载数据时的错误。

图 5-2. 应用程序显示加载、消息和错误

如果服务器抛出错误,useMessages 钩子也会处理,如图 5-3 所示。

图 5-3. 组件可以显示来自服务器的错误

讨论

当你创建一个应用程序时,很容易花费时间构建假设一切正常工作的功能。但是值得投资时间来处理错误,并努力显示数据仍在加载的状态。你的应用将更加愉快使用,并且你将更容易追踪到慢服务和错误。

您还可以考虑将此示例与“构建集中式错误处理程序”结合使用,这将使用户更容易描述发生的情况。

您可以从GitHub 网站下载此示例的源代码。

使用状态计数器自动刷新

问题

网络服务经常需要相互交互。例如,在我们之前的示例中使用的论坛应用程序中。如果我们添加一个表单来发布新消息,我们希望每次有人发布消息时,消息列表都会自动更新。

在这个应用程序的先前版本中,我们创建了一个名为useMessages的自定义钩子,其中包含读取论坛消息所需的所有代码。

我们将向应用程序添加一个表单,用于将新消息发布到服务器:

const {
  data: messages,
  loading: messagesLoading,
  error: messagesError,
} = useMessages('nasa')
const [text, setText] = useState()
const [author, setAuthor] = useState()
const [createMessageError, setCreateMessageError] = useState()
// Other code here...
<input
  type="text"
  value={author}
  placeholder="Author"
  onChange={(evt) => setAuthor(evt.target.value)}
/>
<textarea
  value={text}
  placeholder="Message"
  onChange={(evt) => setText(evt.target.value)}
/>
<button
  onClick={async () => {
    try {
      await [code to post message here]
      setText('')
      setAuthor('')
    } catch (err) {
      setCreateMessageError(err)
    }
  }}
>
  Post
</button>

这是问题所在:当您发布新消息时,除非手动刷新页面(参见图 5-4),否则不会出现在列表中。

图 5-4. 发布消息不会刷新消息列表

如何在每次发布新消息时自动重新加载来自服务器的消息?

解决方案

我们将通过使用状态计数器来触发数据刷新。状态计数器只是一个递增的数字。当前计数器的值无关紧要;重要的是每次重新加载数据时更改它:

const [stateVersion, setStateVersion] = useState(0)

您可以将状态计数器视为表示服务器数据视图的版本。当我们做一些可能改变服务器状态的操作时,我们会更新状态计数器以反映这些变化:

// code to post a new message here
setStateVersion((v) => v + 1)

注意,我们正在使用函数增加stateVersion的值,而不是使用setStateVersion(stateVersion + 1)。如果新值取决于旧值,您应始终使用函数来更新状态值。这是因为 React 异步设置状态。如果我们快速连续两次运行setStateVersion(stateVersion + 1),则stateVersion的值在两次调用之间可能不会更改,我们会错过一个增量。

读取当前消息集的代码被包装在useEffect中,我们可以通过使其依赖于stateVersion值来强制重新运行它:

useEffect(() => {
  setError(null)
  if (forum) {
    // Code to read /messages/:forum
  } else {
    setData([])
    setLoading(false)
  }
}, [forum, stateVersion])

如果forum变量的值更改或者stateVersion更改,它将自动重新加载消息(参见图 5-5)。

图 5-5. 发布新消息会导致消息列表重新加载

这是我们的方法。现在我们需要看看代码放在哪里。这是组件的上一个版本,只读取消息:

import './App.css'
import { useState } from 'react'
import useMessages from './useMessages'

function App() {
  const [forum, setForum] = useState('nasa')
  const {
    data: messages,
    loading: messagesLoading,
    error: messagesError,
  } = useMessages(forum)

  return (
    <div className="App">
      <button onClick={() => setForum('nasa')}>NASA</button>
      <button onClick={() => setForum('notNasa')}>Not NASA</button>
      {messagesError ? (
        <div className="error">
          Something went wrong:
          <div className="error-contents">
            {messagesError.message}
          </div>
        </div>
      ) : messagesLoading ? (
        <div className="loading">Loading...</div>
      ) : messages && messages.length ? (
        <dl>
          {messages.map((m) => (
            <>
              <dt>{m.author}</dt>
              <dd>{m.text}</dd>
            </>
          ))}
        </dl>
      ) : (
        'No messages'
      )}
    </div>
  )
}

export default App

我们将把新的表单添加到这个组件中。我们还可以在组件内部包括网络代码和状态计数器代码。但是,这会将发布代码放在组件中,并将读取代码放在useMessages钩子中。将所有网络代码集中在钩子中更好。这样不仅组件更清洁,而且网络代码更可重用。

这是我们将用于useForum钩子新版本的代码,我们将其重命名为useMessages:^(1)

import { useCallback, useEffect, useState } from 'react'

const useForum = (forum) => {
  const [data, setData] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()
  const [creating, setCreating] = useState(false)
  const [stateVersion, setStateVersion] = useState(0)

  const create = useCallback(
    async (message) => {
      try {
        setCreating(true)
        const response = await fetch(`/messages/${forum}`, {
          method: 'POST',
          body: JSON.stringify(message),
          headers: {
            'Content-type': 'application/json; charset=UTF-8',
          },
        })
        if (!response.ok) {
          const text = await response.text()
          throw new Error(
            `Unable to create a ${forum} message: ${text}`
          )
        }
        setStateVersion((v) => v + 1)
      } finally {
        setCreating(false)
      }
    },
    [forum]
  )

  useEffect(() => {
    let didCancel = false
    setError(null)
    if (forum) {
      ;(async () => {
        try {
          setLoading(true)
          const response = await fetch(`/messages/${forum}`)
          if (!response.ok) {
            const text = await response.text()
            throw new Error(
              `Unable to read messages for ${forum}: ${text}`
            )
          }
          const body = await response.json()
          if (!didCancel) {
            setData(body)
          }
        } catch (err) {
          setError(err)
        } finally {
          setLoading(false)
        }
      })()
    } else {
      setData([])
      setLoading(false)
    }
    return () => {
      didCancel = true
    }
  }, [forum, stateVersion])

  return { data, loading, error, create, creating }
}

export default useForum

我们现在在useForum钩子内构造一个create函数,然后将其与其他各种状态一起返回到组件中。请注意,我们将create函数包装在useCallback中,这意味着除非我们需要为不同的forum值创建数据,否则我们不会创建函数的新版本。

在 hooks 和组件内创建函数时要小心。如果创建了一个新的函数对象,即使这个函数与之前的版本做的事情相同,React 通常会触发重新渲染。

当我们调用create函数时,它会向论坛发布一条新消息,然后更新stateVersion值,这将自动导致 hook 从服务器重新读取消息。请注意,我们还有一个creating值,在网络代码发送消息到服务器时为true。我们可以使用creating值来禁用 POST 按钮。

然而,在create中我们不会跟踪任何错误。为什么不呢?毕竟,当我们从服务器读取数据时我们会这样做。这是因为当您在服务器上更改数据时,通常希望对异常处理有更多的控制,而不像在仅仅读取数据时那样。在示例应用程序中,当向服务器发送消息时,我们会清空消息表单。如果有错误发生,我们希望保留消息表单中的文本。

现在让我们来看一下调用 hook 的代码:

import './App.css'
import { useState } from 'react'
import useForum from './useForum'

function App() {
  const {
    data: messages,
    loading: messagesLoading,
    error: messagesError,
    create: createMessage,
    creating: creatingMessage,
  } = useForum('nasa')
  const [text, setText] = useState()
  const [author, setAuthor] = useState()
  const [createMessageError, setCreateMessageError] = useState()

  return (
    <div className="App">
      <input
        type="text"
        value={author}
        placeholder="Author"
        onChange={(evt) => setAuthor(evt.target.value)}
      />
      <textarea
        value={text}
        placeholder="Message"
        onChange={(evt) => setText(evt.target.value)}
      />
      <button
        onClick={async () => {
          try {
            await createMessage({ author, text })
            setText('')
            setAuthor('')
          } catch (err) {
            setCreateMessageError(err)
          }
        }}
        disabled={creatingMessage}
      >
        Post
      </button>
      {createMessageError ? (
        <div className="error">
          Unable to create message
          <div className="error-contents">
            {createMessageError.message}
          </div>
        </div>
      ) : null}
      {messagesError ? (
        <div className="error">
          Something went wrong:
          <div className="error-contents">
            {messagesError.message}
          </div>
        </div>
      ) : messagesLoading ? (
        <div className="loading">Loading...</div>
      ) : messages && messages.length ? (
        <dl>
          {messages.map((m) => (
            <>
              <dt>{m.author}</dt>
              <dd>{m.text}</dd>
            </>
          ))}
        </dl>
      ) : (
        'No messages'
      )}
    </div>
  )
}

export default App

我们读取和写入消息的详细信息隐藏在useForum hook 内部。我们使用对象解构将create函数赋值给createMessage变量。如果我们调用createMessage,它不仅会发布消息,还会自动从论坛重新读取消息并更新屏幕(见图 5-6)。

图 5-6. 发布新消息并自动重新加载

我们的 hook 不再只是从服务器读取数据的方式。它正在变成管理论坛本身的服务

讨论

如果您打算在一个组件中向服务器发送数据,然后在不同的组件中读取数据,请注意使用此方法时要小心。独立的 hook 实例将有独立的状态计数器,从一个组件中发布数据将不会自动在另一个组件中重新读取数据。如果您想要将发布和读取数据的代码拆分到不同的组件中,请在某个共同的父组件中调用自定义 hook,将数据传递给子组件,并将需要它们的发布函数传递给子组件。

如果您想要使您的代码定期轮询网络服务,那么请考虑创建一个时钟,并使您的网络代码依赖于当前时钟值,就像前面的代码依赖于状态计数器一样。^(2)

您可以从GitHub 网站下载此配方的源代码。

取消网络请求的令牌

问题

考虑一个有 bug 的应用程序,可以搜索城市。当用户在搜索框中开始输入名称时,会出现一列匹配的城市。当用户输入“C... H... I... G...”时,匹配的城市会出现在结果表中。但是,过了一会儿,会出现一个更长的城市列表,其中包括错误的结果,比如威奇托福尔斯(见图 5-7)。

图 5-7. 初始搜索正常工作,然后显示错误的城市

问题在于应用程序每次用户输入字符时都会发送新的网络请求。但并不是所有的网络请求所花费的时间都相同。在这个例子中,你可以看到,“CHI”搜索花费的时间比“CHIG”搜索长了几秒钟。这意味着“CHI”的结果在“CHIG”的结果之后返回。

如何防止一系列异步网络调用返回顺序混乱?

解决方案

如果您正在向网络服务器进行多次 GET 调用,您可以在发送新请求之前取消旧调用,这意味着您永远不会因为只有一个网络请求在服务端调用时,不会收到结果返回顺序不一致的问题。

对于此示例,我们将使用 Axios 网络库。这意味着我们必须安装它:

$ npm install axios

Axios 库是原生fetch函数的封装,允许您使用令牌取消网络请求。Axios 的实现基于 ECMA 的可取消的 Promise 提案

让我们首先看看我们的问题代码。网络代码被包装在一个自定义钩子内部:^(3)

import { useEffect, useState } from 'react'
import axios from 'axios'

const useSearch = (terms) => {
  const [data, setData] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()

  useEffect(() => {
    setError(null)
    if (terms) {
      ;(async () => {
        try {
          setLoading(true)
          const response = await axios.get('/search', {
            params: { terms },
          })
          setData(response.data)
        } catch (err) {
          setError(err)
        } finally {
          setLoading(false)
        }
      })()
    } else {
      setData([])
      setLoading(false)
    }
  }, [terms])

  return { data, loading, error }
}

export default useSearch

terms参数包含搜索字符串。问题出现在代码发送了一个网络请求到/search,搜索字符串为"CHI"

在这期间,我们使用字符串"CHIG"再次发起了另一个调用。之前的请求花费了更长的时间,这导致了错误。

我们将通过使用 Axios 取消令牌来避免这个问题。如果我们给请求附加一个令牌,我们稍后可以使用这个令牌来取消请求。浏览器将终止请求,我们将不再接收到响应。

要使用令牌,我们首先需要为其创建一个源:

const source = axios.CancelToken.source()

source就像是网络请求的遥控器。一旦网络请求与源关联,我们可以告诉源取消它。我们使用source.token将源与请求关联:

const response = await axios.get('/search', {
  params: { terms },
  cancelToken: source.token,
})

Axios 会记住哪个令牌附加在哪个网络请求上。如果我们想要取消请求,我们可以调用这个:

source.cancel('axios request canceled')

我们需要确保仅在发出新请求时取消请求。幸运的是,我们的网络调用位于一个useEffect内部,它有一个很方便的特性。如果我们返回一个取消当前请求的函数,这个函数会在useEffect再次运行之前被执行。因此,如果我们返回一个取消当前网络请求的函数,每次运行新请求时就会自动取消旧的网络请求。^(4) 这是定制钩子的更新版本:

import { useEffect, useState } from 'react'
import axios from 'axios'

const useCancelableSearch = (terms) => {
  const [data, setData] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()

  useEffect(() => {
    setError(null)
    if (terms) {
      const source = axios.CancelToken.source()
      ;(async () => {
        try {
          setLoading(true)
          const response = await axios.get('/search', {
            params: { terms },
            cancelToken: source.token,
          })
          setData(response.data)
        } catch (err) {
          setError(err)
        } finally {
          setLoading(false)
        }
      })()

      return () => {
        source.cancel('axios request cancelled')
      }
    } else {
      setData([])
      setLoading(false)
    }
  }, [terms])

  return { data, loading, error }
}

export default useCancelableSearch

讨论

只有在访问幂等服务时才应该使用这种方法。实际上,这意味着您应该仅对您只关心最新结果的GET请求使用它。

您可以从GitHub 网站下载此示例的源代码。

使用 Redux 中间件进行网络调用

问题

Redux 是一个允许您集中管理应用程序状态的库。^(5)当您想要更改应用程序状态时,您通过分派命令(称为actions)来执行,这些命令由 JavaScript 函数(称为reducers)捕获并处理。 Redux 因为提供了一种将状态管理逻辑与组件代码分离的方式,因此在 React 开发人员中很受欢迎。 Redux 异步执行操作但严格按顺序执行。 因此,您可以在 Redux 中创建大型复杂的应用程序,这些应用程序既高效又稳定。

如果我们能利用 Redux 的强大功能来编排所有网络请求,那将是很好的。我们可以分派类似于“去读取最新搜索结果”的操作,Redux 可以进行网络请求,然后更新中心状态。

然而,为了确保 Redux 代码稳定,减少函数必须符合几个非常严格的标准之一是没有任何减速器函数可以具有副作用。这意味着你永远不应该在减速器内部进行网络请求。

但是,如果我们不能在减速器函数内部进行网络请求,那么我们如何配置 Redux 来代替我们与网络进行通信呢?

解决方案

在 React Redux 应用程序中,组件发布(dispatch)操作,并且减速器通过更新中心状态来响应操作(请参阅图 5-8)。

图 5-8。 使用 Redux 减速器更新中心状态

如果我们想要创建具有副作用的操作,我们将不得不使用 Redux 中间件。 中间件在 Redux 将它们发送到减速器之前接收操作,并且中间件可以转换操作、取消操作或创建新操作。 最重要的是,Redux 中间件代码允许具有副作用。 这意味着如果组件分派一个说“去搜索这个字符串”的操作,我们可以编写一个接收该操作的中间件,生成一个网络调用,然后将响应转换为新的“存储这些搜索结果”的操作。 您可以在图 5-9 中看到 Redux 中间件的工作原理。

图 5-9。 中间件可以进行网络调用

让我们创建一个中间件,拦截类型为"SEARCH"的操作,并使用它来生成一个网络服务。

当我们从网络获取结果返回时,我们将创建一个类型为"SEARCH_RESULTS"的新操作,然后可以将搜索结果存储在中央 Redux 状态中。我们的操作对象将看起来像这样:

{
  "type": "SEARCH",
  "payload": "Some search text"
}

这是我们将用来拦截SEARCH操作的axiosMiddleware.js代码:

import axios from 'axios'

const axiosMiddleware = (store) => (next) => (action) => {
  if (action.type === 'SEARCH') {
    const terms = action.payload
    if (terms) {
      ;(async () => {
        try {
          store.dispatch({
            type: 'SEARCH_RESULTS',
            payload: {
              loading: true,
              data: null,
              error: null,
            },
          })
          const response = await axios.get('/search', {
            params: { terms },
          })
          store.dispatch({
            type: 'SEARCH_RESULTS',
            payload: {
              loading: false,
              error: null,
              data: response.data,
            },
          })
        } catch (err) {
          store.dispatch({
            type: 'SEARCH_RESULTS',
            payload: {
              loading: false,
              error: err,
              data: null,
            },
          })
        }
      })()
    }
  }
  return next(action)
}
export default axiosMiddleware

Redux 中间件的函数签名可能会让人感到困惑。 您可以将其视为接收存储、操作和称为next的另一个函数的函数,该函数可以将操作转发到 Redux 的其余部分。

在上述代码中,我们检查操作是否为SEARCH类型。如果是,我们将进行网络调用。如果不是,则运行next(action),这会将其传递给其他对其感兴趣的代码。

当我们启动网络调用、接收数据或捕获任何错误时,我们可以生成一个新的SEARCH_RESULTS操作:

store.dispatch({
  type: 'SEARCH_RESULTS',
  payload: {
    loading: ...,
    error: ...,
    data: ...
  },
})

我们的新操作的有效载荷如下:

  • 一个名为loading的布尔标志,当网络请求正在运行时为true

  • 包含来自服务器响应的data对象

  • 包含发生的任何错误详细信息的error对象^(6)

然后,我们可以创建一个减速器,将SEARCH_RESULTS存储在中心状态中:

const reducer = (state, action) => {
  if (action.type === 'SEARCH_RESULTS') {
    return {
      ...state,
      searchResults: { ...action.payload },
    }
  }
  return { ...state }
}

export default reducer

当我们创建 Redux 存储时,还需要使用 Redux applyMiddleware函数注册我们的中间件。在示例代码中,我们在App.js文件中执行此操作:

import { Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux'
import './App.css'

import reducer from './reducer'
import Search from './Search'
import axiosMiddleware from './axiosMiddleware'

const store = createStore(reducer, applyMiddleware(axiosMiddleware))

function App() {
  return (
    <div className="App">
      <Provider store={store}>
        <Search />
      </Provider>
    </div>
  )
}

export default App

最后,我们可以在一个Search组件中将所有内容连接起来,该组件将调度一个搜索请求,然后通过 Redux 选择器读取结果:

import './App.css'
import { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'

const Search = () => {
  const [terms, setTerms] = useState()
  const {
    data: results,
    error,
    loading,
  } = useSelector((state) => state.searchResults || {})
  const dispatch = useDispatch()

  return (
    <div className="App">
      <input
        placeholder="Search..."
        type="text"
        value={terms}
        onChange={(e) => {
          setTerms(e.target.value)
          dispatch({
            type: 'SEARCH',
            payload: e.target.value,
          })
        }}
      />
      {error ? (
        <p>Error: {error.message}</p>
      ) : loading ? (
        <p>Loading...</p>
      ) : results && results.length ? (
        <table>
          <thead>
            <tr>
              <th>City</th>
              <th>State</th>
            </tr>
          </thead>
          {results.map((r) => (
            <tr>
              <td>{r.name}</td>
              <td>{r.state}</td>
            </tr>
          ))}
        </table>
      ) : (
        <p>No results</p>
      )}
    </div>
  )
}
export default Search

您可以查看运行中的演示应用程序图 5-10。

图 5-10. 数据加载、加载完成或出错时的应用程序

讨论

Redux 减速器总是严格按照调度顺序处理操作。对于由中间件生成的网络请求则不然。如果您快速连续进行许多网络请求,可能会发现响应以不同的顺序返回。如果这可能导致错误,请考虑使用取消令牌。^(7)

您还可以考虑将所有 Redux useDispatch()/useSelector()代码从组件中移出,并将其放入自定义挂钩中,这样可以通过将服务层与组件代码分开来获得更灵活的架构。

您可以从GitHub 网站下载此配方的源代码。

连接到 GraphQL

问题

GraphQL 是创建 API 的绝佳方式。如果您已经使用 REST 服务一段时间,那么 GraphQL 的某些功能看起来会有些奇怪(甚至可能是异端的),但是在几个 GraphQL 项目上工作后,我们强烈建议您考虑在下一个开发项目中使用它。

当人们提到 GraphQL 时,他们可能指的是几件事情。他们可能指的是由 GraphQL 基金会管理和维护的 GraphQL 语言。GraphQL 允许您指定 API 并创建查询,以访问和变异存储在这些 API 背后的数据。他们可能是指 GraphQL 服务器,它将多个低级数据访问方法组合成一个丰富的 Web 服务。或者他们可能正在讨论 GraphQL 客户端,它允许您仅使用极少的代码快速创建新的客户端请求,并在网络上传输您所需的数据。

但是,如何将 GraphQL 集成到您的 React 应用程序中?

解决方案

在我们看如何从 React 中使用 GraphQL 之前,我们将先创建一个小的 GraphQL 服务器。我们需要的第一件事是一个 GraphQL schema。该模式是我们的 GraphQL 服务器将提供的数据和服务的正式定义。

这是我们将使用的schema.graphql模式。这是我们在本章中之前使用的论坛消息示例的 GraphQL 规范:

type Query {
    messages: [Message]
}

type Message {
    id: ID!
    author: String!
    text: String!
}

type Mutation {
    addMessage(
        author: String!
        text: String!
    ): Message
}

该模式定义了一个名为messages的单一query(用于读取数据的方法),它返回一个Message对象数组。每个Message具有一个id,一个名为author的非空字符串和一个名为text的非空字符串。我们还有一个名为addMessage的单一mutation(用于更改数据的方法),它将基于author字符串和text字符串存储消息。

在创建样本服务器之前,我们将安装一些库:

$ npm install apollo-server
$ npm install graphql
$ npm install require-text

apollo-server是一个创建 GraphQL 服务器的框架。require-text库允许我们读取schema.graphql文件。这是server.js,我们的示例服务器:

const { ApolloServer } = require('apollo-server')
const requireText = require('require-text')

const typeDefs = requireText('./schema.graphql', require)

const messages = [
  {
    id: 0,
    author: 'SC',
    text: 'Rolls complete and a pitch is program. One BRAVO.',
  },
  {
    id: 1,
    author: 'PAO',
    text: 'One BRAVO is an abort control model. Altitude is 2 miles.',
  },
  {
    id: 2,
    author: 'CAPCOM',
    text: 'All is well at Houston. You are good at 1 minute.',
  },
]

const resolvers = {
  Query: {
    messages: () => messages,
  },
  Mutation: {
    addMessage: (parent, message) => {
      const item = { id: messages.length + 1, ...message }
      messages.push(item)
      return item
    },
  },
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
})

server.listen({ port: 5000 }).then(({ url }) => {
  console.log(Launched at ${url}!)
})

服务器将消息存储在一个数组中,并预先填充了一些消息。您可以使用以下命令启动服务器:

$ node ./server.js

此命令将在 5000 端口启动服务器。如果您打开浏览器到http://localhost:5000,您将看到 GraphQL Playground 客户端。Playground 客户端是一个工具,允许您在将其添加到代码之前交互式地尝试查询和变更(见图 5-11)。

图 5-11. GraphQL Playground 应该在 http://localhost:5000 运行

现在我们可以开始查看 React 客户端代码了。我们将安装 Apollo 客户端:

$ npm install @apollo/client

GraphQL 支持GETPOST请求,但 Apollo 客户端将查询和变更发送到 GraphQL 服务器作为POST请求,这样可以避免任何跨域问题,也意味着你可以连接到第三方 GraphQL 服务器而无需代理。因此,这意味着 GraphQL 客户端必须处理自己的缓存,所以当我们在App.js中配置客户端时,我们需要提供一个缓存和服务器地址:

import './App.css'
import {
  ApolloClient,
  ApolloProvider,
  InMemoryCache,
} from '@apollo/client'
import Forum from './Forum'

const client = new ApolloClient({
  uri: 'http://localhost:5000',
  cache: new InMemoryCache(),
})

function App() {
  return (
    <div className="App">
      <ApolloProvider client={client}>
        <Forum />
      </ApolloProvider>
    </div>
  )
}

export default App

ApolloProvider使客户端对任何子组件都可用。如果忘记添加ApolloProvider,您会发现您的所有 GraphQL 客户端代码将失败。

我们将从Forum组件内部调用 GraphQL。我们将执行两个操作:

  • 一个称为Messagesquery,读取所有消息

  • 一个称为AddMessagemutation,将发布一个新消息

查询和变更是用 GraphQL 语言编写的。这是Messages查询:

query Messages {
  messages {
    author text
  }
}

此查询意味着我们想要读取所有消息,但只想返回authortext字符串。因为我们没有请求消息的id,GraphQL 服务器将不会返回它。这是 GraphQL 的灵活性的一部分:您在查询时指定您想要的内容,而不是为每种变化制定特定的 API 调用。

AddMessage 变更略微复杂,因为它需要参数化,以便我们每次调用时可以指定 authortext 值:

mutation AddMessage(
  $author: String!
  $text: String!
) {
  addMessage(
    author: $author
    text: $text
  ) {
    author
    text
  }
}

我们将使用 Apollo GraphQL 客户端提供的 useQueryuseMutation 钩子。useQuery 钩子返回一个带有 dataloadingerror 属性的对象。^(8) useMutation 钩子返回一个包含两个值的数组:一个函数和表示结果的对象。

在 “使用状态计数器自动刷新” 中,我们看到了如何在某些变更后自动重新加载数据。幸运的是,Apollo 客户端提供了现成的解决方案。当你调用一个变更时,你可以指定一个数组,包含了在变更成功时应该重新运行的其他查询:

await addMessage({
  variables: { author, text },
  refetchQueries: ['Messages'],
})

'Messages' 字符串指的是 GraphQL 查询的名称,这意味着我们可以对 GraphQL 服务运行多个查询,并指定哪些查询在变更后可能需要刷新。

最后,这是完整的 Forum 组件:

import { gql, useMutation, useQuery } from '@apollo/client'
import { useState } from 'react'

const MESSAGES = gql`
  query Messages {
    messages {
      author
      text
    }
  }
`

const ADD_MESSAGE = gql`
  mutation AddMessage($author: String!, $text: String!) {
    addMessage(author: $author, text: $text) {
      author
      text
    }
  }
`

const Forum = () => {
  const {
    loading: messagesLoading,
    error: messagesError,
    data,
  } = useQuery(MESSAGES)
  const [addMessage] = useMutation(ADD_MESSAGE)
  const [text, setText] = useState()
  const [author, setAuthor] = useState()

  const messages = data && data.messages

  return (
    <div className="App">
      <input
        type="text"
        value={author}
        placeholder="Author"
        onChange={(evt) => setAuthor(evt.target.value)}
      />
      <textarea
        value={text}
        placeholder="Message"
        onChange={(evt) => setText(evt.target.value)}
      />
      <button
        onClick={async () => {
          try {
            await addMessage({
              variables: { author, text },
              refetchQueries: ['Messages'],
            })
            setText('')
            setAuthor('')
          } catch (err) {}
        }}
      >
        Post
      </button>
      {messagesError ? (
        <div className="error">
          Something went wrong:
          <div className="error-contents">
            {messagesError.message}
          </div>
        </div>
      ) : messagesLoading ? (
        <div className="loading">Loading...</div>
      ) : messages && messages.length ? (
        <dl>
          {messages.map((m) => (
            <>
              <dt>{m.author}</dt>
              <dd>{m.text}</dd>
            </>
          ))}
        </dl>
      ) : (
        'No messages'
      )}
    </div>
  )
}
export default Forum

当您运行应用程序并发布新消息时,消息列表将自动更新,并将新消息添加到末尾,如图 Figure 5-12 所示。

图 5-12. 我们发布消息后,它将出现在列表中

讨论

如果你的团队分为前端和后端开发人员,GraphQL 将特别有用。与 REST 不同,GraphQL 系统不需要后端开发人员手工制定客户端的每个 API 调用。相反,后端团队可以提供一个坚固和一致的 API 结构,让前端团队决定 如何 使用它。

如果您正在使用 GraphQL 创建 React 应用程序,您可能考虑将所有 useQueryuseMutation 调用提取到自定义钩子中。^(9) 这样一来,您将创建一个更灵活的架构,使组件不那么依赖于服务层的细节。

您可以从 GitHub 站点 下载此示例的源代码。

使用防抖请求减少网络负载

问题

在开发系统中工作时,很容易忽略性能问题。这可能是件好事,因为比起快速执行错误操作,正确执行代码更为重要。

但是,当您的应用程序部署到其首个实际环境(例如用于用户验收测试的环境)时,性能将变得更加重要。与 React 相关的动态界面通常会产生大量的网络调用,而这些调用的成本只有在服务器必须处理大量并发客户端时才会显现。

在本章中,我们多次使用了一个示例搜索应用程序。在搜索应用程序中,用户可以按名称或州查找城市。搜索是实时进行的——当他们输入时。如果打开开发者工具并查看网络请求(参见图 5-13),你会发现它为每个键入的字符生成网络请求。

图 5-13. 演示搜索应用程序为每个字符运行网络请求。

大多数这些网络请求几乎没有价值。平均打字员可能每半秒钟按一次键,如果他们看着键盘,他们可能甚至看不到每个搜索的结果。在发送给服务器的七个请求中,他们可能只会阅读最后一个的结果。这意味着服务器做了七倍于所需的工作。

我们能做些什么来避免发送这么多浪费的请求?

解决方案

我们将对搜索调用的网络请求进行防抖。防抖意味着我们将延迟发送网络请求一个非常短的时间,比如半秒钟。如果在等待时有另一个请求进来,我们将忘记第一个请求,然后创建另一个延迟请求。通过这种方式,我们推迟发送任何请求,直到半秒钟内没有收到新请求。

要了解如何做到这一点,请查看我们的示例搜索钩子 useSearch.js

import { useEffect, useState } from 'react'
import axios from 'axios'

const useSearch = (terms) => {
  const [data, setData] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()

  useEffect(() => {
    let didCancel = false
    setError(null)
    if (terms) {
      ;(async () => {
        try {
          setLoading(true)
          const response = await axios.get('/search', {
            params: { terms },
          })
          if (!didCancel) {
            setData(response.data)
          }
        } catch (err) {
          setError(err)
        } finally {
          setLoading(false)
        }
      })()
    } else {
      setData([])
      setLoading(false)
    }
    return () => {
      didCancel = true
    }
  }, [terms])

  return { data, loading, error }
}
export default useSearch

发送网络请求的代码位于(async ()....)()代码块内。我们需要延迟此代码,直到我们有半秒钟的空闲时间。

JavaScript 函数 setTimeout 将在延迟后运行代码。这将是我们实现防抖特性的关键:

const newTimer = setTimeout(SOMEFUNCTION, 500)

我们可以使用 newTimer 值来清除超时,如果我们做得足够快,这可能意味着我们的函数永远不会被调用。要了解如何使用此功能来防抖网络请求,请查看 useDebouncedSearch.js,这是 useSearch.js 的防抖版本:

import { useEffect, useState } from 'react'
import axios from 'axios'

const useDebouncedSearch = (terms) => {
  const [data, setData] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()

  useEffect(() => {
    setError(null)
    if (terms) {
      const newTimer = setTimeout(() => {
        ;(async () => {
          try {
            setLoading(true)
            const response = await axios.get('/search', {
              params: { terms },
            })
            setData(response.data)
          } catch (err) {
            setError(err)
          } finally {
            setLoading(false)
          }
        })()
      }, 500)
      return () => clearTimeout(newTimer)
    } else {
      setData([])
      setLoading(false)
    }
  }, [terms])

  return { data, loading, error }
}

export default useDebouncedSearch

我们将网络代码传递给 setTimeout 函数,然后返回以下内容:

() => clearTimeout(newTimer)

如果从 useEffect 返回一个函数,这段代码将在下次 useEffect 触发之前调用,这意味着如果用户快速输入,我们将继续推迟网络请求。只有当用户停止输入半秒钟时,代码才会提交一个网络请求。

useSearch 钩子的原始版本为每个字符都运行一个网络请求。使用钩子的防抖版本后,以平均速度打字将仅导致单个网络请求(参见图 5-14)。

图 5-14. 防抖搜索钩子将发送更少的请求。

讨论

防抖动请求将减少您的网络流量和服务器负载。重要的是要记住,防抖动可以减少不必要的网络请求数量,但不能避免网络响应以不同顺序返回的问题。有关如何避免响应顺序问题的详细信息,请参阅“使用令牌取消网络请求”(#ch05-03)。

你可以从GitHub 网站下载此示例的源代码。

^(1) 我们正在重命名它,因为它不再仅仅是读取消息列表的方式,而是整个论坛。我们最终可以添加删除、编辑或标记消息的功能。

^(2) 查看“使用时钟测量时间”章节(ch03.xhtml#ch03-04)。

^(3) 将此代码与使用fetch的“将网络调用转换为 Hooks”(ch05-01)进行比较。

^(4) 如果上一个网络请求已完成,则取消它将不会产生任何效果。

^(5) 第一次使用时可能会感到相当困惑。查看第三章以获取更多 Redux 示例。

^(6) 为简化问题,我们只是存储整个对象。实际上,您会希望确保错误只包含可序列化的文本。

^(7) 参见“使用令牌取消网络请求”(#ch05-03)。

^(8) 这是异步服务的标准值集合。我们在本章的其他示例中也使用过它们。

^(9) 就像我们在“使用状态计数器自动刷新”中处理 HTTP 网络调用一样。

第六章:组件库

如果您正在构建任何大小的应用程序,则可能需要一个组件库。本机 HTML 支持的数据类型有些受限,并且实现可能因浏览器而异。例如,日期输入字段在 Chrome、Firefox 和 Edge 浏览器上看起来非常不同。

组件库允许您为应用程序创建一致的外观。在切换桌面和移动客户端时,它们通常能很好地适应。最重要的是,组件库通常会提升您的应用程序的可用性。它们要么是根据经过深入测试的设计标准生成的(如 Material Design),要么是多年来开发的。任何粗糙的地方通常都已经被修复了。

请注意:并不存在完美的组件库。它们各有优势和劣势,您需要选择一个最符合您需求的库。如果您有一个庞大的用户体验团队和一套健全的设计标准,您可能希望选择一个允许大量微调以适应公司主题的库。一个例子是 Material-UI,它允许您对其组件进行相当大的修改。如果您的用户体验团队很小或根本没有用户体验团队,您可能会考虑像 Semantic UI 这样的库,它简洁而功能齐全,并可以快速启动您的项目。

无论您选择哪个库,始终要记住用户体验中最重要的不是应用程序的外观,而是其行为。用户很快会忽略您添加到界面的任何花哨图形,但他们永远不会忘记(或原谅)每次使用时使他们感到烦恼的界面的某些部分。

使用 Material Design 与 Material-UI

问题

现在许多应用程序既可在 Web 上使用,也可作为移动设备上的原生应用程序使用。谷歌创建 Material Design 旨在在所有平台上提供无缝体验。如果您的用户也使用安卓手机或任何由谷歌创建的产品,Material Design 对他们来说会很熟悉。Material Design 只是一个规范,有几种可用的实现。其中一种是 React 的 Material-UI 库。但是,使用 Material-UI 的步骤是什么,如何安装呢?

解决方案

让我们从安装核心 Material-UI 库开始:

$ npm install @material-ui/core

核心库包含主要组件,但遗漏了一个显著的功能:标准字体。为了使 Material-UI 在原生移动应用程序中感觉相同,您还应安装谷歌的 Roboto 字体:

$ npm install fontsource-roboto

Material Design 还规定了一组大量的标准图标。这些图标为标准任务(如编辑任务、创建新项目、共享内容等)提供了共同的视觉语言。为了使用这些图标的高质量版本,您还应安装 Material-UI 图标库:

$ npm install @material-ui/icons

现在我们已经准备好使用 Material-UI 了,我们可以做些什么呢?我们不能在这里详细查看所有可用的组件,但我们将看一些更流行的功能^(1)。

我们将从 Material-UI 内部样式的基础开始。为了确保 Material-UI 组件在不同的浏览器中看起来相同,它们包括了一个CssBaseline组件,它将规范应用程序的基本样式。它将删除边距并应用标准的背景颜色。您应该在应用程序的开头附近添加一个CssBaseline组件。例如,如果您使用的是create-react-app,您可能应该将其添加到您的App.js中:

import CssBaseline from '@material-ui/core/CssBaseline'
...

function App() {
  // ...

  return (
    <div className="App">
      <CssBaseline />
      ...
    </div>
  )
}

export default App

接下来,我们将看一下 Material Design 的AppBarToolbar组件。这些提供了大多数 Material Design 应用程序中看到的标准标题,也是其他功能(如汉堡菜单和抽屉面板)将出现的地方。

我们将在屏幕顶部放置一个AppBar,并在其中放置一个Toolbar。这将让我们看看 Material-UI 内部如何处理排版:

<div className="App">
    <CssBaseline/>
    <AppBar position='relative'>
        <Toolbar>
            <Typography component='h1' variant='h6' color='inherit' noWrap>
                Material-UI Gallery
            </Typography>
        </Toolbar>
    </AppBar>
    <main>
      {/* Main content goes here...*/}
    </main>
</div>

虽然您可以在 Material-UI 应用程序中插入普通文本内容,但通常最好在Typography内显示它。Typography组件将确保文本符合 Material Design 标准。我们还可以使用它在适当的标记元素内显示文本。在这种情况下,我们将在Toolbar中将文本显示为h1元素。这就是Typography component属性指定的:应用于包装文本的 HTML 元素。然而,我们也可以告诉 Material-UI 将文本样式设定为h6标题。这将使其稍小,作为页面标题时不会过于突出。

接下来,让我们看一下 Material-UI 如何处理输出的样式。它使用主题。主题是定义 CSS 样式层次结构的 JavaScript 对象。您可以集中定义主题,这使您可以控制应用程序的整体外观。

主题是可扩展的。我们将导入一个名为makeStyles的函数,它将允许我们创建默认主题的修改版本:

import { makeStyles } from '@material-ui/core/styles'

我们将使我们的示例应用程序显示一个图像库,因此我们将希望为图库项、描述等创建样式。我们可以使用makeStyles为这些不同的屏幕元素创建样式:

const useStyles = makeStyles((theme) => ({
  galleryGrid: {
    paddingTop: theme.spacing(4),
  },
  galleryItemDescription: {
    overflow: 'hidden',
    textOverflow: 'ellipsis',
    whiteSpace: 'nowrap',
  },
}))

在这个简化的示例中,我们扩展了基础主题以包含galleryGridgalleryItemDescription类的样式。我们可以直接添加 CSS 属性,或者(在galleryGridpaddingTop的情况下)我们可以引用当前主题中的某个值:在这种情况下是theme.spacing(4)。因此,我们可以将样式的某些部分推迟到一个集中的主题中,稍后可以更改它。

makeStyles返回的useStyles是一个钩子,它将生成一组 CSS 类,然后返回它们的名称,以便我们可以在组件内部引用它们。

例如,我们希望使用ContainerGrid组件显示图像网格。^(2) 我们可以像这样从主题中为它们附加样式:

const classes = useStyles()

return (
  <div className="App">
    ...
    <main>
      <Container className={classes.galleryGrid}>
        <Grid container spacing="4">
          <Grid item>...</Grid>
          <Grid item>...</Grid>
          ...
        </Grid>
      </Container>
    </main>
  </div>
)

每个Grid组件都是一个容器或一个项目。我们将在每个项目中显示一个图库图像。

在 Material Design 中,我们将重要的项目显示在卡片内。卡片是一个矩形面板,看起来略高于背景浮动。如果你曾经使用过 Google Play Store,你会看到卡片用于显示应用程序、音乐曲目或其他你想要下载的内容。我们将在每个Grid项目中放置一个卡片,并用它来显示预览、文本描述和一个按钮,该按钮可以显示图像的更详细版本。你可以在示例应用程序中查看这些卡片,如图 6-1 所示。

图 6-1。卡片位于网格项目内,而这些网格项目则位于容器内

Material-UI 还对对话窗口有着广泛的支持。这里是一个自定义对话框的例子:

import Dialog from '@material-ui/core/Dialog'
import DialogTitle from '@material-ui/core/DialogTitle'
import Typography from '@material-ui/core/Typography'
import DialogContent from '@material-ui/core/DialogContent'
import DialogActions from '@material-ui/core/DialogActions'
import Button from '@material-ui/core/Button'
import CloseIcon from '@material-ui/icons/Close'

const MyDialog = ({ onClose, open, title, children }) => {
  return (
    <Dialog open={open} onClose={onClose}>
      <DialogTitle>
        <Typography
          component="h1"
          variant="h5"
          color="inherit"
          noWrap
        >
          {title}
        </Typography>
      </DialogTitle>
      <DialogContent>{children}</DialogContent>
      <DialogActions>
        <Button
          variant="outlined"
          startIcon={<CloseIcon />}
          onClick={onClose}
        >
          Close
        </Button>
      </DialogActions>
    </Dialog>
  )
}

export default MyDialog

注意,我们从之前安装的 Material-UI 图标库中导入了一个 SVG 图标。DialogTitle出现在对话框顶部。DialogActions是出现在对话框底部的按钮。你可以在DialogContent中定义对话框的主体内容。

这里是App.js的完整代码:

import './App.css'
import CssBaseline from '@material-ui/core/CssBaseline'
import AppBar from '@material-ui/core/AppBar'
import { Toolbar } from '@material-ui/core'
import Container from '@material-ui/core/Container'
import Grid from '@material-ui/core/Grid'
import Card from '@material-ui/core/Card'
import CardMedia from '@material-ui/core/CardMedia'
import CardContent from '@material-ui/core/CardContent'
import CardActions from '@material-ui/core/CardActions'
import Typography from '@material-ui/core/Typography'
import { makeStyles } from '@material-ui/core/styles'
import { useState } from 'react'
import MyDialog from './MyDialog'
import ImageSearchIcon from '@material-ui/icons/ImageSearch'

import gallery from './gallery.json'
import IconButton from '@material-ui/core/IconButton'

const useStyles = makeStyles((theme) => ({
  galleryGrid: {
    paddingTop: theme.spacing(4),
  },
  galleryItem: {
    height: '100%',
    display: 'flex',
    flexDirection: 'column',
    // maxWidth: '200px'
  },
  galleryImage: {
    paddingTop: '54%',
  },
  galleryItemDescription: {
    overflow: 'hidden',
    textOverflow: 'ellipsis',
    whiteSpace: 'nowrap',
  },
}))

function App() {
  const [showDetails, setShowDetails] = useState(false)
  const [selectedImage, setSelectedImage] = useState()
  const classes = useStyles()

  return (
    <div className="App">
      <CssBaseline />
      <AppBar position="relative">
        <Toolbar>
          <Typography
            component="h1"
            variant="h6"
            color="inherit"
            noWrap
          >
            Material-UI Gallery
          </Typography>
        </Toolbar>
      </AppBar>
      <main>
        <Container className={classes.galleryGrid}>
          <Grid container spacing="4">
            {gallery.map((item, i) => {
              return (
                <Grid item key={`photo-${i}`} xs={12} sm={3} lg={2}>
                  <Card className={classes.galleryItem}>
                    <CardMedia
                      image={item.image}
                      className={classes.galleryImage}
                      title="A photo"
                    />
                    <CardContent>
                      <Typography
                        gutterBottom
                        variant="h6"
                        component="h2"
                      >
                        Image
                      </Typography>
                      <Typography
                        className={classes.galleryItemDescription}
                      >
                        {item.description}
                      </Typography>
                    </CardContent>
                    <CardActions>
                      <IconButton
                        aria-label="delete"
                        onClick={() => {
                          setSelectedImage(item)
                          setShowDetails(true)
                        }}
                        color="primary"
                      >
                        <ImageSearchIcon />
                      </IconButton>
                    </CardActions>
                  </Card>
                </Grid>
              )
            })}
          </Grid>
        </Container>
      </main>
      <MyDialog
        open={showDetails}
        title="Details"
        onClose={() => setShowDetails(false)}
      >
        <img
          src={selectedImage && selectedImage.image}
          alt="From PicSum"
        />
        <Typography>
          {selectedImage && selectedImage.description}
        </Typography>
      </MyDialog>
    </div>
  )
}

export default App

讨论

Material-UI 是一个非常好用的库,也是当前与 React 一起使用最广泛的库之一。使用你的应用程序的用户几乎肯定在其他地方也用过它,这将增加你的应用程序的可用性。在开始在你的应用程序中使用 Material-UI 之前,值得花一些时间了解Material Design 原则。这样,你将创建一个不仅外观吸引人,而且易于使用和可访问的应用程序。

你可以从GitHub 网站下载此示例的源代码。

使用 React Bootstrap 创建一个简单的 UI

问题

过去 10 年中最流行的 CSS 库可能是 Twitter 的 Bootstrap 库。如果你正在创建一个新应用程序,且没有太多时间去定制 UI,只是想要一个易于使用且对大多数用户来说很熟悉的东西,那 Bootstrap 也是一个不错的选择。

但是 Bootstrap 出现在像 React 这样的框架之前。Bootstrap 包含了用于包含少量手工客户端代码的网页的 CSS 资源和一组 JavaScript 库。基础的 Bootstrap 库与像 React 这样的框架不兼容。

当你创建一个 React 应用程序时,你如何使用 Bootstrap?

解决方案

有几个 Bootstrap 库的端口可以与 React 一起使用。在这个示例中,我们将看看 React Bootstrap。React Bootstrap 与标准的 Bootstrap CSS 库一起工作,但它扩展了 Bootstrap JavaScript,使其更加适合 React 使用。

让我们首先安装 React Bootstrap 组件和 Bootstrap JavaScript 库:

$ npm install react-bootstrap bootstrap

React Bootstrap 库本身不包含任何 CSS 样式。你需要自己包含一份副本。最常见的方法是在你的 HTML 文件中从内容分发网络(CDN)下载它。例如,如果你使用 create-react-app,你应该在你的 public/index.html 文件中包含类似以下的内容:

<link
  rel="stylesheet"
  href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
  integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKG"
  crossorigin="anonymous"
/>

你应该使用可用的最新稳定版本的 Bootstrap 替换它。你需要手动管理 Bootstrap 的版本;当你升级 JavaScript 库时,它不会自动更新。

Bootstrap 是一个很好的通用库,但它对表单的支持特别强大。良好的表单布局可能需要时间并且可能很繁琐。Bootstrap 为你处理了所有艰难的工作,允许你专注于表单的功能。例如,React Bootstrap 的 Form 组件包含几乎你创建表单所需的一切。Form.Control 组件默认会生成一个 inputForm.Label 会生成一个 labelForm.Group 会将它们关联起来并适当地布局:

<Form.Group controlId="startupName">
    <Form.Label>Startup name</Form.Label>
    <Form.Control placeholder="No names ending in ...ly, please"/>
</Form.Group>

表单字段通常显示在一行上并占用可用宽度。如果你想要一行上显示多个字段,那么你可以使用 Form.Row

<Form.Row>
    <Form.Group as={Col} controlId="startupName">
        <Form.Label>Startup name</Form.Label>
        <Form.Control placeholder="No names ending in ...ly, please"/>
    </Form.Group>
    <Form.Group as={Col} controlId="market">
        <Form.Label>Market</Form.Label>
        <Form.Control placeholder="e.g. seniors on Tik-Tok"/>
    </Form.Group>
</Form.Row>

Col 组件确保标签和字段的大小适当。如果你想要一个不是 input 的表单字段,你可以使用 as 属性:

<Form.Control as="select" defaultValue="Choose...">
    <option>Progressive web application</option>
    <option>Conservative web application</option>
    <option>Android native</option>
    <option>iOS native</option>
    <option>New Jersey native</option>
    <option>VT220</option>
</Form.Control>

这将生成一个 Bootstrap 样式的 select 元素。

将所有内容放在一起,就得到了你可以在图 6-2 中看到的表单:

import Form from 'react-bootstrap/Form'
import Col from 'react-bootstrap/Col'
import Button from 'react-bootstrap/Button'
import Alert from 'react-bootstrap/Alert'
import { useState } from 'react'
import './App.css'

function App() {
  const [submitted, setSubmitted] = useState(false)

  return (
    <div className="App">
      <h1>VC Funding Registration</h1>
      <Form>
        <Form.Row>
          <Form.Group as={Col} controlId="startupName">
            <Form.Label>Startup name</Form.Label>
            <Form.Control placeholder="No names ending in ...ly, please" />
          </Form.Group>
          <Form.Group as={Col} controlId="market">
            <Form.Label>Market</Form.Label>
            <Form.Control placeholder="e.g. seniors on Tik-Tok" />
          </Form.Group>
          <Form.Group as={Col} controlId="appType">
            <Form.Label>Type of application</Form.Label>
            <Form.Control as="select" defaultValue="Choose...">
              <option>Progressive web application</option>
              <option>Conservative web application</option>
              <option>Android native</option>
              <option>iOS native</option>
              <option>New Jersey native</option>
              <option>VT220</option>
            </Form.Control>
          </Form.Group>
        </Form.Row>

        <Form.Row>
          <Form.Group as={Col} controlId="description">
            <Form.Label>Description</Form.Label>
            <Form.Control as="textarea" />
          </Form.Group>
        </Form.Row>

        <Form.Group id="technologiesUsed">
          <Form.Label>
            Technologies used (check at least 3)
          </Form.Label>
          <Form.Control as="select" multiple>
            <option>Blockchain</option>
            <option>Machine learning</option>
            <option>Quantum computing</option>
            <option>Autonomous vehicles</option>
            <option>For-loops</option>
          </Form.Control>
        </Form.Group>

        <Button variant="primary" onClick={() => setSubmitted(true)}>
          Submit
        </Button>
      </Form>
      <Alert
        show={submitted}
        variant="success"
        onClose={() => setSubmitted(false)}
        dismissible
      >
        <Alert.Heading>We'll be in touch!</Alert.Heading>
        <p>One of our partners will be in touch shortly.</p>
      </Alert>
    </div>
  )
}

export default App

图 6-2. 一个 React 引导表单和警告框

讨论

Bootstrap 是一个比 Material Design 更早的 UI 工具包,但仍然有市场认为它更合适。如果你正在构建一个需要更像传统网站的应用程序,那么 Bootstrap 将赋予它更传统的感觉。如果你想构建更像跨平台应用程序的东西,你应该考虑 Material-UI。^(3)

你可以从GitHub 网站下载这个示例的源代码。

使用 React Window 查看数据集

问题

一些应用程序需要显示看似无穷的数据量。如果你正在开发像 Twitter 这样的应用程序,你不希望下载用户时间轴上的所有推文,因为这可能需要几个小时、几天或几个月的时间。解决方案是窗口化数据。当你窗口化一个项目列表时,你只保留当前显示的项目在内存中。当你向上或向下滚动时,应用程序会下载当前视图所需的数据。

但是创建这种窗口逻辑是相当复杂的。它不仅涉及对当前可见内容的精确跟踪,^(4) 而且如果未能有效缓存窗口数据,可能会很快遇到内存问题。

在 React 应用程序中如何实现窗口代码?

解决方案

React Window 库是一组用于需要滚动大量数据的应用程序的组件。我们将看看如何创建一个大型、固定大小的列表。^(5)

首先,我们需要创建一个组件,用于显示单个项目的详细信息。在我们的示例应用程序中,我们将创建一组 10,000 个日期字符串。我们将使用一个名为 DateRow 的组件来渲染每个日期,这将是我们的项目渲染器。React Window 通过仅渲染当前视口中可见的项目来工作。当用户向上或向下滚动列表时,它会在项目进入视图时创建新项目,并在项目消失时将其移除。

当 React Window 调用一个项目渲染器时,它会传递两个属性:一个项目编号(从 0 开始),以及一个样式对象。

这是我们的 DateRow 项渲染器:

import moment from 'moment'

const DateRow = ({ index, style }) => (
  <div className={`aDate ${index % 2 && 'aDate-odd'}`} style={style}>
    {moment().add(index, 'd').format('dddd, MMMM Do YYYY')}
  </div>
)

export default DateRow

此组件会计算未来 index 天的日期。在一个更实际的应用程序中,此组件可能会从后端服务器下载一个数据项。

要生成列表本身,我们将使用 FixedSizeList。我们需要给列表一个固定的宽度和高度。React Window 通过列表的高度和每个项目的高度来计算可见项目的数量,使用 itemSize 属性的值。如果 height 是 400,itemHeight 是 40,那么列表只需要显示 10 或 11 个 DateRow 组件(见 Figure 6-3)。

这是代码的最终版本。请注意,FixedSizeList 不包括 DateRow 组件的实例。这是因为它想要使用 DateRow 函数动态创建多个项目,当我们滚动列表时。因此,它使用 {DateRow} 函数本身,而不是使用 <DateRow/>

import { FixedSizeList } from 'react-window'
import DateRow from './DateRow'
import './App.css'

function App() {
  return (
    <div className="App">
      <FixedSizeList
        height={400}
        itemCount={10000}
        itemSize={40}
        width={300}
      >
        {DateRow}
      </FixedSizeList>
    </div>
  )
}

export default App

图 6-3. 列表仅包含可见项目

还有一个需要注意的细节是,由于项目会动态添加到列表中并从列表中移除,因此在 CSS 中使用 nth-child 选择器时需要小心:

.aDate:nth-child(even) { /* This won't work */
    background-color: #eee;
}

相反,您需要动态检查项目的当前索引,并使用一些模数-2 数学来检查它是否为奇数,就像我们在示例中所做的那样:

<div className={`aDate ${index % 2 && 'aDate-odd'}`} ...>

讨论

React Window 是一个专注于组件库,但如果您需要展示大量数据集,则非常有价值。在列表中显示的数据的下载和缓存仍由您负责,但与 React Window 执行的窗口处理相比,这是一个相对简单的任务。

您可以从 GitHub 网站 下载此示例的源代码。

使用 Material-UI 创建响应式对话框

问题

如果您使用一个组件库,很有可能会在某个时候显示对话框窗口。对话框允许您添加 UI 细节,而不让用户感觉他们正在切换到另一页。它们非常适合内容创建或快速显示有关项目的详细信息。

然而,对话框在移动设备上的表现不佳。移动设备有一个小的显示屏,并且对话框通常会浪费大量空间来显示背景页面的边缘。

如何创建响应式对话框,在桌面机器上像浮动窗口一样运行,但在移动设备上看起来像单独的全屏页面?

解决方案

Material-UI 库包含一个高阶函数,可以判断您是否在移动设备上,并将对话框显示为全屏窗口:

import { withMobileDialog } from '@material-ui/core'
...

const ResponsiveDialog = withMobileDialog()(
  ({ fullScreen }) => {
    // Return some component using the fullScreen (true/false) property
  }
)

withMobileDialog 使其包装的任何组件都有一个额外的属性称为 fullScreen,它设置为 truefalseDialog 组件可以使用此属性来更改其行为。如果像这样传递 fullScreenDialog

import { withMobileDialog } from '@material-ui/core'
import Dialog from '@material-ui/core/Dialog'
import DialogTitle from '@material-ui/core/DialogTitle'
import Typography from '@material-ui/core/Typography'
import DialogContent from '@material-ui/core/DialogContent'
import DialogActions from '@material-ui/core/DialogActions'
import Button from '@material-ui/core/Button'
import CloseIcon from '@material-ui/icons/Close'

const ResponsiveDialog = withMobileDialog()(
  ({ onClose, open, title, fullScreen, children }) => {
    return (
      <Dialog open={open} fullScreen={fullScreen} onClose={onClose}>
        <DialogTitle>
          <Typography
            component="h1"
            variant="h5"
            color="inherit"
            noWrap
          >
            {title}
          </Typography>
        </DialogTitle>
        <DialogContent>{children}</DialogContent>
        <DialogActions>
          <Button
            variant="outlined"
            startIcon={<CloseIcon />}
            onClick={onClose}
          >
            Close
          </Button>
        </DialogActions>
      </Dialog>
    )
  }
)

export default ResponsiveDialog

当在移动设备或桌面设备上运行时,对话框将更改其行为。

假设我们修改了在 “将网络调用转换为钩子” 中创建的应用程序。在我们原始的应用程序中,当用户点击画廊中的图像时会出现对话框。在移动设备上,对话框如 图 6-4 所示。

图 6-4. 在移动设备上,默认情况下,对话框周围有空间

如果您用 ResponsiveDialog 替换此对话框,在大屏幕上看起来是一样的。但在小屏幕上,对话框将填满显示屏,就像您在 图 6-5 中看到的那样。这不仅为对话框内容提供了更多空间,还简化了移动用户的体验。它不再像弹出窗口一样工作,而更像是一个独立的页面。

图 6-5. 在移动设备上,响应式对话框填满了屏幕

讨论

关于如何处理响应式界面的更多想法,请参见 “创建具有响应路由的界面”。

您可以从 GitHub 站点 下载此示例的源代码。

使用 React Admin 构建管理控制台

问题

开发人员通常会花费很长时间来创建和维护终端用户应用程序,以至于一个重要的任务经常被忽视:管理控制台。客户不使用管理控制台;它们被后台工作人员和管理员用来查看当前数据集,并调查和解决应用程序中的数据问题。一些数据存储系统如 Firebase 内置了相当先进的管理控制台。但对于大多数后端服务来说并非如此。相反,开发人员通常不得不直接访问位于多层云基础设施后面的数据库来解决数据问题。

如何为几乎任何 React 应用程序创建管理控制台?

解决方案

我们将研究 React Admin,尽管本章是关于组件库,但 React Admin 包含的内容远不止组件。它是一个应用程序框架,可以轻松构建界面,允许管理员查看和维护应用程序中的数据。

不同的应用程序将使用不同的网络服务层。它们可能使用 REST、GraphQL 或其他许多系统之一。但在大多数情况下,数据是作为在服务器上保存的一组资源进行访问的。React Admin 已经准备好大部分组件,用于创建一个管理员应用程序,使您能够浏览每个资源。它允许您创建、维护和搜索数据。它还可以将数据导出到外部应用程序。

为了展示react-admin的工作原理,我们将为我们在第五章中创建的消息板应用程序创建一个管理控制台(参见图 6-6)。

图 6-6。原始留言板应用程序

应用程序的后端是一个简单的 GraphQL 服务器。GraphQL 服务器具有相对简单的模式,类似于这样定义消息的模式语言:

type Message {
    id: ID!
    author: String!
    text: String!
}

每条消息都有一个唯一的id。字符串记录消息的文本和作者的姓名。

用户只能对数据做一种类型的更改:他们可以添加一条消息。他们可以运行一种类型的查询:他们可以读取所有消息。

要创建一个react-admin应用程序,首先需要创建一个新的 React 应用程序,然后安装react-admin库:

$ npm install react-admin

这个库的主要组件称为Admin。这将构成我们整个应用程序的框架:

<Admin dataProvider={...}>
  ...UI for separate resources goes here...
</Admin>

Admin组件需要一个数据提供程序。数据提供程序是一个适配器,将应用程序连接到后端服务。我们的后端服务使用 GraphQL,因此我们需要一个 GraphQL 数据提供程序:

$ npm install graphql
$ npm install ra-data-graphql-simple

大多数后端服务都有可用的数据提供程序。有关详细信息,请参阅React Admin 网站。我们需要在使用数据提供程序之前初始化我们的数据提供程序。GraphQL 配置了一个异步的buildGraphQLProvider函数,因此在使用它之前我们需要确保它已经准备就绪:

import { Admin } from 'react-admin'
import buildGraphQLProvider from 'ra-data-graphql-simple'
import { useEffect, useState } from 'react'

function App() {
  const [dataProvider, setDataProvider] = useState()

  useEffect(() => {
    let didCancel = false
    ;(async () => {
      const dp = await buildGraphQLProvider({
        clientOptions: { uri: 'http://localhost:5000' },
      })
      if (!didCancel) {
        setDataProvider(() => dp)
      }
    })()
    return () => {
      didCancel = true
    }
  }, [])

  return (
    <div className="App">
      {dataProvider && (
        <Admin dataProvider={dataProvider}>
          ...resource UI here...
        </Admin>
      )}
    </div>
  )
}

export default App

数据提供程序连接到运行在 5000 端口上的 GraphQL 服务器。^(6) 数据提供程序将首先下载应用程序的模式,这将告诉它有哪些资源(在我们的情况下只有一个资源Messages)可用以及可以在这些资源上执行哪些操作。

如果现在尝试运行应用程序,它将不会执行任何操作。这是因为即使它知道服务器上有一个Messages资源,它也不知道我们想对其执行任何操作。所以,让我们将Messages资源添加到应用程序中。

如果我们希望应用程序列出服务器上的所有消息,我们需要创建一个名为ListMessages的简单组件。这将使用react-admin中的一些现成组件来构建其界面:

const ListMessages = (props) => {
  return (
    <List {...props}>
      <Datagrid>
        <TextField source="id" />
        <TextField source="author" />
        <TextField source="text" />
      </Datagrid>
    </List>
  )
}

这将创建一个包含消息idauthortext列的表。现在,我们可以通过将Resource传递给Admin组件来告知管理员系统有关新组件的信息:

<Admin dataProvider={dataProvider}>
    <Resource name="Message" list={ListMessages}/>
</Admin>

Admin组件将查看新的Resource,联系服务器以读取消息,然后使用ListMessages组件呈现它们(见图 6-7)。

图 6-7. 显示来自服务器的消息

屏幕更新似乎像魔术一样工作,但这是因为服务器必须遵循某些约定,以便 GraphQL 适配器知道调用哪个服务。在这种情况下,它将找到一个名为allMessages的查询,该查询返回消息:

type Query {
    Message(id: ID!): Message
    allMessages(page: Int, perPage: Int,
        sortField: String, sortOrder: String,
        filter: MessageFilter): [Message]
}

因此,您可能需要更改后端 API 以满足数据提供者的要求。然而,您添加的服务可能会在主应用程序中非常有用。

allMessages查询允许管理员界面从服务器分页获取数据。它可以接受一个名为filter的属性,用于搜索数据。示例模式中的MessageFilter将允许管理员控制台查找包含authortext字符串的消息。它还允许管理员控制台发送通用搜索字符串(q),用于查找包含任何字段中字符串的消息。

下面是MessageFilter对象的 GraphQL 模式定义。您需要为应用程序中的每个资源创建类似的内容:

input MessageFilter {
    q: String
    author: String
    text: String
}

为了在前端实现过滤和搜索功能,我们首先需要在一个名为MessageFilter的 React 组件中创建一些过滤字段。这与架构中的MessageFilter有很大区别,尽管您会注意到它包含匹配字段:

const MessageFilter = (props) => (
  <Filter {...props}>
    <TextInput label="Author" source="author" />
    <TextInput label="Text" source="text" />
    <TextInput label="Search" source="q" alwaysOn />
  </Filter>
)

现在,我们可以将MessageFilter添加到ListMessages组件中,突然间,我们发现可以在管理员控制台中进行分页、搜索和过滤消息(见图 6-8):

const ListMessages = (props) => {
  return (
    <List {...props} filters={<MessageFilter />}>
      <Datagrid>
        <TextField source="id" />
        <TextField source="author" />
        <TextField source="text" />
      </Datagrid>
    </List>
  )
}

图 6-8. 按作者或文本过滤消息表

我们还可以通过添加CreateMessage组件来实现创建新消息的能力:

const CreateMessage = (props) => {
  return (
    <Create title="Create a Message" {...props}>
      <SimpleForm>
        <TextInput source="author" />
        <TextInput multiline source="text" />
      </SimpleForm>
    </Create>
  )
}

然后将CreateMessage组件添加到Resource中(见图 6-9):

<Resource name="Message" list={ListMessages} create={CreateMessage}/>

图 6-9. 在控制台上创建消息

GraphQL 数据提供程序将通过将CreateMessage表单的内容传递给名为CreateMessage的变化来创建消息:

type Mutation {
    createMessage(
        author: String!
        text: String!
    ): Message
}

同样地,您可以添加更新或删除消息的功能。如果您的模式复杂并且包含子资源,react-admin可以在表格内显示子项。它还可以处理不同的显示类型。它可以显示图像和链接。有可用的组件可以在日历或图表上显示资源(参见图 6-10 来自在线演示应用程序的示例)。^(7) 管理控制台还可以与现有的安全系统配合使用。

图 6-10. 在线演示中的不同视图类型

讨论

虽然您必须对后端服务进行一些额外的更改,以使react-admin适合您的应用,但这些额外的服务极有可能对您的主要应用程序也有帮助。即使它们没有,react-admin提供的构建模块也很可能大大减少创建后台系统所需的开发时间。

你可以从GitHub 站点下载此配方的源代码。

没有设计师?使用 Semantic UI

问题

设计良好的样式可以为应用程序增添很多视觉吸引力。但糟糕的样式可能会使一个好的应用程序看起来廉价和业余。许多开发者在设计方面的感知有限。^(8) 在您几乎没有或没有专业设计帮助的情况下,一个简单、清晰的 UI 组件库可以让您专注于应用程序的功能,而不必花费无数小时调整按钮和边框的位置。

经过试验的框架如 Bootstrap 可以为大多数应用程序提供一个良好的、无光泽的基础。^(9) 但即使是它们通常也需要大量关注视觉外观。如果您希望专注于应用程序的功能并希望获得清晰的功能视觉外观,那么 Semantic UI 库是一个不错的选择。

但 Semantic UI 库很老,来自 jQuery 统治的时代。在撰写本文时,它已经超过两年没有更新了。如果您想在 React 中使用像 Semantic UI 这样可靠和成熟的库,该怎么办?

解决方案

Semantic UI React 库是一个包装器,使 Semantic UI 库可用于 React 用户。

顾名思义,Semantic UI 侧重于界面的含义。您可以通过 CSS 管理其视觉外观,而不是组件。相反,Semantic UI 组件侧重于功能性。例如,当您创建一个表单时,您说要包括哪些字段,而不是关于它们布局的任何内容。这导致了干净、一致的外观,几乎不需要任何视觉调整。

要开始使用,让我们安装 Semantic 库及其样式支持:

$ npm install semantic-ui-react semantic-ui-css

此外,我们还需要在应用程序的index.js文件中包含对样式表的引用:

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import reportWebVitals from './reportWebVitals'
import 'semantic-ui-css/semantic.min.css'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()

我们将重新创建我们的消息发布应用程序。我们需要一个包含作者姓名文本字段和发布消息文本区域的表单。语义化组件旨在尽可能与简单的 HTML 元素相似。因此,如果我们构建一个表单,我们将导入FormInputTextAreaButton来发布消息:

import { Button, Form, Input, TextArea } from 'semantic-ui-react'
import './App.css'
import { useState } from 'react'

function App() {
  const [author, setAuthor] = useState('')
  const [text, setText] = useState('')
  const [messages, setMessages] = useState([])

  return (
    <div className="App">
      <Form>
        <Form.Field>
          <label htmlFor="author">Author</label>
          <Input
            value={author}
            id="author"
            onChange={(evt) => setAuthor(evt.target.value)}
          />
        </Form.Field>
        <Form.Field>
          <label htmlFor="text">Message</label>
          <TextArea
            value={text}
            id="text"
            onChange={(evt) => setText(evt.target.value)}
          />
        </Form.Field>
        <Button
          basic
          onClick={() => {
            setMessages((m) => [
              {
                icon: 'pencil',
                date: new Date().toString(),
                summary: author,
                extraText: text,
              },
              ...m,
            ])
            setAuthor('')
            setText('')
          }}
        >
          Post
        </Button>
      </Form>
    </div>
  )
}

export default App

这段代码应该感觉很熟悉。Form组件确实有一个Field助手,可以更轻松地组合标签和字段,但除此之外,代码看起来与基本的 HTML 表单类似。

在示例应用程序中,我们通过将消息添加到名为messages的数组中来“发布”消息。您可能已经注意到,我们是按照特定的对象结构将消息添加到数组中的:

setMessages((m) => [
  {
    icon: 'pencil',
    date: new Date().toString(),
    summary: author,
    extraText: text,
  },
  ...m,
])

我们并非出于偶然选择了这些属性。尽管语义化中的大多数组件都很简单,但也有一些更复杂的组件,这些组件旨在支持一些常见的用例。一个例子是Feed组件。Feed组件用于呈现社交消息流,例如您可能在 Twitter 或 Instagram 上看到的消息流。它将呈现一系列干净的消息,带有日期戳、标题、图标等等。包括Feed组件的最终代码如下所示:

import {
  Button,
  Form,
  Input,
  TextArea,
  Feed,
} from 'semantic-ui-react'
import './App.css'
import { useState } from 'react'

function App() {
  const [author, setAuthor] = useState('')
  const [text, setText] = useState('')
  const [messages, setMessages] = useState([])

  return (
    <div className="App">
      <Form>
        <Form.Field>
          <label htmlFor="author">Author</label>
          <Input
            value={author}
            id="author"
            onChange={(evt) => setAuthor(evt.target.value)}
          />
        </Form.Field>
        <Form.Field>
          <label htmlFor="text">Message</label>
          <TextArea
            value={text}
            id="text"
            onChange={(evt) => setText(evt.target.value)}
          />
        </Form.Field>
        <Button
          basic
          onClick={() => {
            setMessages((m) => [
              {
                icon: 'pencil',
                date: new Date().toString(),
                summary: author,
                extraText: text,
              },
              ...m,
            ])
            setAuthor('')
            setText('')
          }}
        >
          Post
        </Button>
      </Form>
      <Feed events={messages} />
    </div>
  )
}

export default App

当您运行应用程序时,界面干净简洁(请参阅图 6-11)。

图 6-11. Semantic UI 界面实例

讨论

Semantic UI 是一个老旧的库。但这并不是坏事。它经过了实战考验的界面既清晰又功能强大,是在没有视觉设计师支持的情况下快速启动和测试产品市场的最佳方式之一。如果您正在创建精益创业公司,并希望快速组合一些内容来测试产品是否有市场,它特别有用。^(10)

您可以从GitHub 站点下载此示例的源代码。

^(1) 有关整个组件集的详细信息,请参阅Material-UI 网站

^(2) 有关这些组件的更多信息,请参阅Material-UI 网站

^(3) 查看更多信息,请参阅“将网络调用转换为 Hooks”。

^(4) 包括处理视口大小变化时发生的所有棘手边缘情况。

^(5) 您可以使用该库来处理可变大小和固定大小的列表和网格。详细信息请参阅文档

^(6) 您可以在本章的源代码中找到服务器。您可以通过输入node ./server.js来运行服务器。

^(7) 其中一些仅在您订阅企业版时才可用。

^(8) 包括至少一位作者...

^(9) 参见《使用 React Bootstrap 创建简单 UI》,了解如何在应用程序中使用 Bootstrap 的指导。

^(10) 更多详细信息,请参阅《精益创业》(Eric Ries 著,Crown Business 出版)。

第七章:安全

在本章中,我们将探讨多种保护应用程序的方法。我们将看看将你的应用程序与标准安全系统集成的常见模式。我们将看看如何审计你的代码以解决几种常见的安全缺陷。在本章的几个示例中,我们将使用 WebAuthn API 将应用程序集成到安全设备(如指纹传感器和物理令牌)中。WebAuthn 是一种令人兴奋且未充分利用的技术,可以提高应用程序的安全性并增强用户体验。

安全请求,而不是路由。

问题

“创建安全路由” 展示了如何使用 React Router 创建安全路由。这意味着如果用户尝试访问应用程序中特定的路径,你可以强制他们在查看该页面内容之前提交登录表单。

当你首次构建应用程序时,安全路由方法是一个很好的、相当通用的方法。然而,有些应用程序并不容易落实到这种静态的安全模型中。有些页面将是安全的,有些将是不安全的。但在许多应用程序中,更容易保护数据服务而不是页面。重要的是你查看的不是哪个页面,而是你正在查看的数据。

所有这些复杂性通常在 API 层面定义起来很直接。但这种复杂性不应该在前端客户端的逻辑中重复。因此,将某些路由标记为安全的,而其他路由标记为不安全的这种简单方法是不够好的。

解决方案

如果仅将路由定义为安全或不安全不足以满足客户端的安全性要求,你可能需要考虑通过使用后端服务器接收到的安全响应来控制对你的应用程序的访问。

使用这种方法,你首先假设用户可以在你的应用中随处访问。你不需要担心安全路由和不安全路由。你只有路由。如果用户访问包含私人数据的路径,API 服务器会返回一个错误,通常是 HTTP 状态码 401(未授权)。当发生错误时,安全系统会将用户重定向到一个登录表单。

使用这种方法,API 服务器驱动的是什么是私有的、什么是公共的策略。如果安全策略发生变化,你只需要修改 API 服务器上的代码,而不需要更改客户端代码。

让我们再次看看原始安全路由示例的代码。在我们的应用程序中,我们注入了一个 SecurityProvider,它控制其所有子组件的安全性。在示例应用程序中,我们在 App.js 文件中实现这一点:

import './App.css'
import { BrowserRouter, Route, Switch } from 'react-router-dom'
import Public from './Public'
import Private1 from './Private1'
import Private2 from './Private2'
import Home from './Home'
import SecurityProvider from './SecurityProvider'
import SecureRoute from './SecureRoute'

function App() {
  return (
    <div className="App">
      <BrowserRouter>
        <SecurityProvider>
          <Switch>
            <Route exact path="/">
              <Home />
            </Route>
            <SecureRoute path="/private1">
              <Private1 />
            </SecureRoute>
            <SecureRoute path="/private2">
              <Private2 />
            </SecureRoute>
            <Route exact path="/public">
              <Public />
            </Route>
          </Switch>
        </SecurityProvider>
      </BrowserRouter>
    </div>
  )
}

export default App

你可以看到应用程序具有简单的 RoutesSecuredRoutes。如果未经认证的用户尝试访问安全路由,则会被重定向到登录表单,正如你在 图 7-1 中所看到的。

图 7-1. 当你首次访问一个安全路由时,会看到一个登录表单。

一旦他们登录(参见 Figure 7-2),他们可以访问安全内容。

图 7-2. 一旦您登录,安全路由就可见

如果我们想基于后端 API 的安全性来设计我们的安全性,我们将从所有SecuredRoutes替换为简单的Routes开始。应用程序只有在 API 服务器告诉它哪些数据是私有的和公共的时候才知道。对于本食谱中的示例应用程序,我们将在应用程序上有两个页面,这两个页面包含公开和私有数据的混合。交易页面将从服务器读取安全数据。优惠页面将从服务器读取不安全数据。以下是我们App.js文件的新版本:

import './App.css'
import { BrowserRouter, Route, Switch } from 'react-router-dom'
import Transactions from './Transactions'
import Offers from './Offers'
import Home from './Home'
import SecurityProvider from './SecurityProvider'

function App() {
  return (
    <div className="App">
      <BrowserRouter>
        <SecurityProvider>
          <Switch>
            <Route exact path="/">
              <Home />
            </Route>
            <Route exact path="/transactions">
              <Transactions />
            </Route>
            <Route exact path="/offers">
              <Offers />
            </Route>
          </Switch>
        </SecurityProvider>
      </BrowserRouter>
    </div>
  )
}

export default App

我们还需要对我们的SecurityProvider进行更改。在 API 安全模型中,客户端首先假设所有数据都是公开的,这与安全路由方法相反,后者假设您在登录前没有访问权限。

这意味着我们的新SecurityProvider必须将其初始登录状态默认为true

import { useState } from 'react'
import SecurityContext from './SecurityContext'
import Login from './Login'
import axios from 'axios'

const SecurityProvider = (props) => {
  const [loggedIn, setLoggedIn] = useState(true)

  return (
    <SecurityContext.Provider
      value={{
        login: async (username, password) => {
          await axios.post('/api/login', { username, password })
          setLoggedIn(true)
        },
        logout: async () => {
          await axios.post('/api/logout')
          return setLoggedIn(false)
        },
        onFailure() {
          return setLoggedIn(false)
        },
        loggedIn,
      }}
    >
      {loggedIn ? props.children : <Login />}
    </SecurityContext.Provider>
  )
}

export default SecurityProvider

我们还进行了几项其他更改:

  • 确定用户是否应该看到Login表单的代码现在位于SecurityProvider中。这段代码曾经存在于SecuredRoute组件内部,但现在我们将其集中显示。

  • 我们用对后端服务调用/api/login/api/logout取代了虚拟的用户名/密码检查。您应该根据您的系统应用程序替换这些内容。

  • SecurityProvider现在提供了一个名为onFailure的新函数,它简单地将人员标记为已登出。

当您调用此函数时,它会强制用户登录。如果我们不再使用安全路由,那么在什么时候进行安全检查?我们会在 API 调用本身中执行它们。

在真实的应用程序中,您可能需要添加处理无效登录尝试的代码。为了保持代码简洁,我们在这里省略了任何特殊处理。登录失败将简单地使您留在登录表单中,而没有任何错误消息。

让我们来看看我们新的src/Transactions.js定义的交易页面。该组件读取交易数据并在屏幕上显示:

import useTransactions from './useTransactions'

const Transactions = () => {
  const { data: transactions } = useTransactions()

  return (
    <div>
      <h1>Transactions</h1>
      <main>
        <table>
          <thead>
            <tr>
              <th>Date</th>
              <th>Amount</th>
              <th>Description</th>
            </tr>
          </thead>
          <tbody>
            {transactions &&
              transactions.map((trx) => (
                <tr>
                  <td>{trx.date}</td>
                  <td>{trx.amount}</td>
                  <td>{trx.description}</td>
                </tr>
              ))}
          </tbody>
        </table>
      </main>
    </div>
  )
}

export default Transactions

useTransactions钩子包含了从服务器读取数据的网络代码。在这个钩子内部,我们需要添加对来自服务器的 401(未经授权)响应的检查:

import { useEffect, useState } from 'react'
import axios from 'axios'
import useSecurity from './useSecurity'

const useTransactions = () => {
  const security = useSecurity()
  const [transactions, setTransactions] = useState([])

  useEffect(() => {
    ;(async () => {
      try {
        const result = await axios.get('/api/transactions')
        setTransactions(result.data)
      } catch (err) {
        const status = err.response && err.response.status
        if (status === 401) {
          security.onFailure()
        }
        // Handle other exceptions here (consider a shared
        // error handler -- see elsewhere in the book)
      }
    })()
  }, [])

  return { data: transactions }
}

export default useTransactions

在示例应用中,我们使用axios库联系服务器。axios处理 HTTP 错误,如401(未经授权的 HTTP 状态),将其作为异常处理。这使得处理意外响应的代码更加清晰。如果您使用不同的 API 标准,如 GraphQL,您可以通过检查 GraphQL 返回的错误对象的内容以类似的方式处理安全错误。

如果从服务器返回未授权响应,useTransac⁠tions 钩子会调用 SecurityPro⁠vider 中的 onFailure 函数。

我们将以相同方式构建“优惠”页面。src/Offers.js 组件将格式化来自服务器的 offers 数据:

import useOffers from './useOffers'

const Offers = () => {
  const { data: offers } = useOffers()

  return (
    <div>
      <h1>Offers</h1>
      <main>
        <ul>
          {offers &&
            offers.map((offer) => <li className="offer">{offer}</li>)}
        </ul>
      </main>
    </div>
  )
}

export default Offers

读取数据的代码位于 src/useOffers.js 钩子中:

import { useEffect, useState } from 'react'
import axios from 'axios'
import useSecurity from './useSecurity'

const useOffers = () => {
  const security = useSecurity()
  const [offers, setOffers] = useState([])

  useEffect(() => {
    ;(async () => {
      try {
        const result = await axios.get('/api/offers')
        setOffers(result.data)
      } catch (err) {
        const status = err.response && err.response.status
        if (status === 401) {
          security.onFailure()
        }
        // Handle other exceptions here (consider a shared
        // error handler -- see elsewhere in the book)
      }
    })()
  }, [])

  return { data: offers }
}

export default useOffers

即使 /api/offers 端点未受保护,我们仍然有代码来检查安全错误。 API 安全方法的一个后果是,您必须把所有端点都视为安全的,以防它们未来变得安全。

让我们尝试我们的示例应用程序。我们将从打开首页开始(见 图 7-3)。

图 7-3. 应用程序的首页

如果我们点击“优惠”链接,我们可以看到从服务器读取的优惠内容(见 图 7-4)。这些数据未受保护,应用程序不要求我们登录。

图 7-4. 如果我们点击“优惠”链接,我们可以看到内容

如果现在我们返回首页并点击“交易”链接,应用程序会要求我们登录(见 图 7-5)。交易页面尝试从服务器下载交易数据,结果是 401(未授权)响应。代码捕获此异常并调用 SecurityProvider 中的 onFailure 函数,然后显示登录表单(见 图 7-5)。

图 7-5. 如果我们尝试访问“交易”页面,系统会要求我们登录

如果我们登录,应用程序将我们的用户名和密码发送到服务器。假设这不会导致错误,SecurityProvider 将隐藏登录表单,重新渲染“交易”页面,并且现在可以读取数据,因为我们已登录(见 图 7-6)。

图 7-6. 一旦我们登录,我们可以看到“交易”页面

讨论

我们的示例应用现在没有任何内容表明哪些 API 是受保护的,哪些是不受保护的。服务器现在处理所有这些工作。 API 端点完全负责应用程序的安全性。

使用这种方法,您应该对所有 API 调用应用相同的安全处理。将 API 调用提取到自定义钩子中的一个好处是,这些钩子可以共享安全代码。钩子可以调用其他钩子,一个常见的方法是创建行为通用的 GETPOST 调用的钩子。^(1) 通用的 GET 钩子不仅可以处理访问失败,还可以包括请求取消、防抖(见第 5.3 和 5.6 章节),以及共享的错误处理(“构建集中式错误处理程序”)^(“ch04.xhtml#ch04-01”)。

采用安全 API 方法的另一个优点是,在某些情况下完全可以禁用安全性。例如,在开发过程中,可以取消开发人员需要配置身份提供者的需求。还可以选择在不同的部署中使用不同的安全配置。

最后,对于像 Cypress 这样的自动化测试系统,它可以模拟网络响应,您可以将应用程序功能的测试与非功能性安全性测试分开。建议使用额外的仅服务器安全性测试,以确保服务器本身的安全性。

你可以从GitHub 站点下载此配方的源代码。

使用物理令牌进行认证

问题

用户名和密码并不总是足够安全;它们可能被窃取或猜测。因此,一些用户可能只使用提供额外安全性的应用程序。

越来越多的系统现在提供双因素认证。双因素系统要求用户首先用表单登录,然后提供额外的信息。额外的信息可能是通过短信文本消息发送给他们的代码。或者可能是他们手机上的应用程序生成的一次性密码。或者,可能是最安全的,它可能涉及使用物理硬件设备,比如一个YubiKey,在需要时连接到计算机并按下。

这些物理令牌使用公钥密码学,为特定应用程序生成公钥并使用私钥加密字符串。应用程序可以向设备发送随机的“挑战”字符串,使用私钥生成签名。然后,应用程序可以使用公钥检查字符串是否被正确签名。

但是你如何将它们与你的 React 应用程序集成?

解决方案

Web Authentication(也称为WebAuthn)是一个广泛支持的^(2) W3C 标准,允许浏览器与物理设备(如 YubiKey)通信。

Web 认证中有两种流程。第一种称为attestation。在 attestation 期间,用户将安全设备注册到应用程序中。在assertion期间,用户可以验证其身份以登录系统。

首先,让我们看看 attestation。在这个流程中,用户会注册一个物理设备到他们的帐户。这意味着用户在 attestation 期间应始终保持登录状态。

此配方的代码包括一个虚拟的 Node 服务器,可以从应用程序中的server目录运行:

$ cd server
$ npm install
$ npm run start

Attestation 有三个步骤:

  1. 服务器生成 attestation 请求,说明可以接受的设备类型。

  2. 用户连接设备并激活它,可能通过按下设备上的按钮。

  3. 设备生成响应,其中包括公钥,然后将其返回到服务器,在那里可以存储在用户的帐户中。

我们可以通过检查 window.PublicKeyCredential 的存在来判断浏览器是否支持 WebAuthn。如果存在,则可以继续。

/startRegister 处有一个端点,该端点将在服务器上创建认证请求。因此,我们将首先调用该端点。

import axios from 'axios'
...
// Ask to start registering a physical token for the current user
const response = await axios.post('/startRegister')

这就是认证请求的外观:

{
    "rpName": "Physical Token Server",
    "rpID": "localhost",
    "userID": "1234",
    "userName": "freda",
    "excludeCredentials": [
        {"id": "existingKey1", "type": "public-key"}
    ],
    "authenticatorSelection": {
        "userVerification": "discouraged"
    },
    "extensions": {
        "credProps": true
    }
}

一些属性以 rp... 开头,表示依赖方。依赖方是生成请求的应用程序。

rpName 是一个描述应用程序的自由格式文本字符串。应将 rpId 设置为当前域名。在这里,它是 localhost,因为我们正在运行开发服务器。userID 是一个唯一标识用户的字符串。userName 是用户的名称。

excludeCredentials 是一个有趣的属性。用户可能会将多个设备记录到其帐户中。该值列出已记录的设备,以避免用户注册相同的设备两次。如果尝试多次注册同一设备,则浏览器将立即抛出异常,指出设备已在其他地方注册。

authenticatorSelection 允许您设置各种选项,用于在用户激活其设备时需要执行的操作。在这里,我们将 userVerification 设置为 false,以防止用户在激活其设备时执行任何额外的步骤(如输入 PIN 码)。因此,当要求用户插入其设备时,用户将其插入 USB 插槽并按下按钮,无需执行其他操作。

credProps 扩展请求设备返回附加的凭证属性,这可能对服务器有所帮助。

一旦服务器生成了认证请求,我们需要要求用户连接其安全设备。我们使用一个名为的浏览器函数进行此操作:

navigator.credentials.create()

create 函数接受一个认证请求对象。不幸的是,对象内的数据需要采用各种低级二进制形式,如字节数组。我们可以通过从 GitHub 安装名为 webauthn-json 的库来显著简化我们的工作,该库允许您使用 JSON 指定请求:

$ npm install "@github/webauthn-json"

然后,我们可以将 WebAuthn 请求的内容传递给 GitHub 版本的 create 函数:

import { create } from '@github/webauthn-json'
import axios from 'axios'
...
// Ask to start registering a physical token for the current user
const response = await axios.post('/startRegister')
// Pass the WebAuthn config to webauthn-json 'create' function
const attestation = await create({ publicKey: response.data })

这是浏览器要求用户插入并激活其安全设备的时刻(见 Figure 7-7)。

图 7-7. 当调用 create 时,浏览器请求令牌

create 函数返回一个 attestation 对象,你可以将其视为设备的注册信息。服务器可以使用 attestation 对象在用户登录时验证其身份。我们需要将 attestation 对象记录在用户的账户上。我们将通过将其发布到示例服务器上的 /register 端点来实现:

import { create } from '@github/webauthn-json'
import axios from 'axios'
...
// Ask to start registering a physical token for the current user
const response = await axios.post('/startRegister')
// Pass the WebAuthn config to webauthn-json 'create' function
const attestation = await create({ publicKey: response.data })
// Send the details of the physical YubiKey to be stored against the user
const attestationResponse = await axios.post('/register', {
  attestation,
})

这就是我们为用户注册新设备的概述。但是我们在代码中放置它在哪里呢?

示例应用程序有一个账户页面(参见 图 7-8),我们将在其中添加一个按钮来注册新密钥。

图 7-8. 我们将在账户页面上添加一个按钮来注册新设备

这是已经就位的注册代码:

import { useState } from 'react'
import Logout from './Logout'
import axios from 'axios'
import { create } from '@github/webauthn-json'

const Private2 = () => {
  const [busy, setBusy] = useState(false)
  const [message, setMessage] = useState()

  return (
    <div className="Private2">
      <h1>Account page</h1>

      {window.PublicKeyCredential && (
        <>
          <p>Register new hardware key</p>
          <button
            onClick={async () => {
              setBusy(true)
              try {
                const response = await axios.post('/startRegister')
                setMessage('Send response')
                const attestation = await create({
                  publicKey: response.data,
                })
                setMessage('Create attestation')
                const attestationResponse = await axios.post(
                  '/register',
                  {
                    attestation,
                  }
                )
                setMessage('registered!')
                if (
                  attestationResponse.data &&
                  attestationResponse.data.verified
                ) {
                  alert('New key registered')
                }
              } catch (err) {
                setMessage('' + err)
              } finally {
                setBusy(false)
              }
            }}
            disabled={busy}
          >
            Register
          </button>
        </>
      )}
      <div className="Account-message">{message}</div>

      <Logout />
    </div>
  )
}

export default Private2

如果我们在账户页面上点击注册按钮,浏览器会要求我们连接安全设备(参见 图 7-9)。一旦完成,应用程序将设备的凭据发送到服务器,然后告诉我们已在我们的账户上记录了一个新设备(参见 图 7-10)。

图 7-9. 当您选择注册新设备时,系统要求您激活它

图 7-10. 当注册新设备时,我们会收到通知

我们需要考虑的下一个流程是断言。当用户登录时,断言发生。

步骤与证明类似:

  1. 应用程序请求服务器创建一个断言请求。

  2. 用户通过激活其安全设备将该请求转换为断言对象。

  3. 服务器将根据其存储的凭据检查断言以证明该人是他们自称的人。

让我们从创建断言请求的第一阶段开始。这是断言请求的外观:

{
    "allowCredentials": [
        {"id": "existingTokenID", "type": "public-key"}
    ],
    "attestation": "direct",
    "extensions": {
        "credProps": true,
    },
    "rpID": "localhost",
    "timeout": 60000,
    "challenge": "someRandomString"
}

allowCredentials 属性是一个注册设备数组,将被接受。浏览器将使用此数组来检查用户是否连接了正确的设备。

断言请求还包括一个 challenge 字符串:设备将需要使用其私钥创建一个签名。服务器将使用公钥来检查此签名,以确保我们使用了正确的设备。

timeout 指定了用户需要证明身份的时间长度。

当您使用指定的用户 ID 调用 /startVerify 端点时,示例服务器将生成一个断言请求:

import axios from 'axios'
...
// Ask for a challenge to verify user userID
const response = await axios.post('/startVerify', { userID })

然后,我们可以将断言请求传递给 get webauthn-json 函数,该函数将要求用户通过连接一个可接受的设备来验证其身份(参见 图 7-11):

import { get } from '@github/webauthn-json'
import axios from 'axios'
...
const response = await axios.post('/startVerify', { userID })
const assertion = await get({ publicKey: response.data })

图 7-11. get 函数要求用户连接设备

get 函数返回一个断言对象,其中包含用于挑战字符串的签名,发送回服务器的 /verify 端点以检查签名。对该调用的响应将告诉我们用户是否正确验证了其身份:

import { get } from '@github/webauthn-json'
import axios from 'axios'
...
const response = await axios.post('/startVerify', { userID })
const assertion = await get({ publicKey: response.data })
const resp2 = await axios.post('/verify', { userID, assertion })
if (resp2.data && resp2.data.verified) {
  // User is verified
}

我们在应用程序中把这段代码放在哪里?

该示例应用基于安全路由配方。^(3) 它包含一个 SecurityProvider,用于管理其所有子组件的安全性。SecurityProvider 提供一个 login 函数,在用户提交登录表单时调用该函数并传递用户名和密码。我们将在这里放置验证代码:

import { useState } from 'react'
import SecurityContext from './SecurityContext'
import { get } from '@github/webauthn-json'
import axios from 'axios'

const SecurityProvider = (props) => {
  const [loggedIn, setLoggedIn] = useState(false)

  return (
    <SecurityContext.Provider
      value={{
        login: async (username, password) => {
          const response = await axios.post('/login', {
            username,
            password,
          })
          const { data } = response
          if (data.twoFactorNeeded) {
            const userID = data.userID
            const response = await axios.post('/startVerify', {
              userID,
            })
            const assertion = await get({ publicKey: response.data })
            const resp2 = await axios.post('/verify', {
              userID,
              assertion,
            })
            if (resp2.data && resp2.data.verified) {
              setLoggedIn(true)
            }
          } else {
            setLoggedIn(true)
          }
        },
        logout: async () => {
          await axios.post('/logout')
          setLoggedIn(false)
        },
        loggedIn,
      }}
    >
      {props.children}
    </SecurityContext.Provider>
  )
}
export default SecurityProvider

我们首先将用户名和密码发送到 /login 终点。如果用户注册了安全设备,那么 /login 的响应将设置 twoFactorNeeded 属性为 true。我们可以使用用户的 ID 调用 /startVerify 终点,并使用生成的断言请求要求用户激活他们的设备。我们可以将断言发送回服务器。如果一切顺利,我们将 loggedIn 设置为 true,用户将能看到页面。

让我们看看它是如何工作的。假设我们已经注册了设备到我们的账户。我们打开应用程序并点击账户页面(见图 7-12)。

图 7-12. 应用程序打开时,点击账户链接

账户页面是安全的,所以我们被要求输入用户名和密码(见图 7-13)。在示例应用程序中,您可以将 freda 作为用户名和 mypassword 作为密码输入。

图 7-13. 登录表单出现

一旦我们输入了用户名和密码,浏览器会要求我们连接安全设备(见图 7-14)。

图 7-14. 浏览器要求用户激活他们的安全设备

如果他们连接他们的设备并激活它,用户可以看到安全页面(见图 7-15)。

图 7-15. 用户验证身份后,账户页面可见

讨论

正如你可能已经注意到的,WebAuthn 是一个相当复杂的 API。它使用相当晦涩的术语(attestation 用于 registrationassertion 用于 verification),并使用一些低级数据类型,幸运的是 GitHub 的 webauthn-json 让我们可以避免这些。

复杂性存在于服务器端。可下载源代码中的示例服务器使用一个名为 SimpleWebAuthn 的库来处理大部分的加密 事务。如果你计划在应用程序的服务器端使用 SimpleWebAuthn,请注意还有一个客户端的 SimpleWebAuthn 库与之配套。在示例客户端源代码中,我们避免使用它,以避免使我们的代码过于依赖 SimpleWebAuthn。

如果您实施双因素认证,您需要考虑如果用户丢失了他们的安全设备该怎么办。从技术上讲,您只需删除注册在其名下的设备即可重新启用他们的帐户。但最好要非常小心。针对双因素认证的典型攻击是致电服务台,假装是丢失令牌的用户。

相反,您需要创建一个足够严格的流程,以检查任何要求重置帐户的人的身份。

您可以从GitHub 网站下载此教程的源代码。

启用 HTTPS

问题

在生产环境中通常使用 HTTPS,但在开发过程中使用 HTTPS 可能会有所帮助。某些网络服务只能在通过 HTTPS 保护的页面中工作。WebAuthn 只能通过 HTTPS 远程工作。^(4) 如果您的应用程序使用带有 HTTPS 代理服务器,可能会在您的代码中引入许多错误和其他问题。

在生产服务器上启用 HTTPS 现在相对简单,^(5) 但是如何在开发服务器上启用 HTTPS 呢?

解决方案

如果您使用create-react-app创建了您的应用程序,您可以通过以下方式启用 HTTPS:

  • 生成自签名 SSL 证书

  • 将证书注册到您的开发服务器

要生成自签名证书,我们需要了解 HTTPS 的一些工作原理。

HTTPS 只是通过加密的安全套接字层(SSL)连接隧道传输的 HTTP。当浏览器连接到 HTTPS 地址时,它会打开到服务器上安全套接字的连接。^(6) 服务器必须提供来自浏览器信任的组织的证书。如果浏览器接受证书,它将向服务器上的安全套接字发送加密数据,然后服务器会将其解密并转发给 HTTP 服务器。

设置 HTTPS 服务器的主要困难在于获取一个 Web 浏览器将信任的证书。浏览器维护一组根证书。这些是由大型、可信任的组织发布的证书。当 HTTPS 服务器向浏览器呈现证书时,该证书必须由浏览器的一个根证书签名。

如果我们想生成一个 SSL 证书,我们首先需要创建一个根证书,并告诉浏览器信任它。然后我们必须为我们的开发服务器生成一个由根证书签名的证书。

如果听起来很复杂,那是因为确实如此。

让我们首先创建一个根证书。为此,您需要在您的机器上安装一个名为 OpenSSL 的工具。

我们将使用openssl命令创建一个密钥文件。它会要求您输入一个密码,您需要输入两次:

$ openssl genrsa -des3 -out mykey.key 2048
Generating RSA private key, 2048 bit long modulus
.......................................................+++
.................................+++
e is 65537 (0x10001)
Enter pass phrase for mykey.key:
Verifying - Enter pass phrase for mykey.key:
$

mykey.key 文件现在包含一个私钥,用于加密数据。我们可以使用这个密钥文件来创建一个证书文件。证书文件包含关于组织的信息以及一个截止日期,之后它将不再有效。

您可以使用以下命令创建一个证书:

$ openssl req -x509 -new -nodes -key mykey.key -sha256 -days 2048 -out mypem.pem
Enter pass phrase for mykey.key:
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) []:US
State or Province Name (full name) []:Massachusetts
Locality Name (eg, city) []:Cambridge
Organization Name (eg, company) []:O'Reilly Media
Organizational Unit Name (eg, section) []:Harmless scribes
Common Name (eg, fully qualified host name) []:Local
Email Address []:me@example.com
$

在这里,我们正在创建一个有效期为 2,048 天的证书。系统会要求你输入的密码是你创建 mykey.key 文件时设置的密码。组织细节无关紧要,因为你只会在本地机器上使用它。

证书存储在名为 mypem.pem 的文件中,我们需要将这个文件安装为我们机器上的根证书。^(7) 有几种方法可以在您的机器上安装根证书。^(8) 您可以使用根证书来签署网站证书,这是我们接下来要做的事情。

我们将创建一个本地密钥文件,并使用以下命令创建一个证书签名请求(CSR)文件:

$ openssl req -new -sha256 -nodes -out myprivate.csr -newkey rsa:2048 \
-keyout myprivate.key \
-subj "/C=US/ST=Massachusetts/L=Cambridge/O=O'Reilly \
Media/OU=Harmless scribes/CN=Local/emailAddress=me@example.com"
Generating a 2048 bit RSA private key
....................+++
..+++
writing new private key to 'myprivate.key'
-----
$

接下来,创建一个名为 extfile.txt 的文件,其中包含以下内容:

authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature,nonRepudiation,keyEncipherment,dataEncipherment
subjectAltName=DNS:localhost

现在我们可以运行一个命令来为我们的应用程序生成一个 SSL 证书:

$ openssl x509 -req -in myprivate.csr -CA mypem.pem -CAkey mykey.key \
-CAcreateserial -out \
myprivate.crt -days 500 -sha256 -extfile ./extfile.txt
Signature ok
subject=/C=US/ST=Massachusetts/L=Cambridge/O=O'Reilly
Media/OU=Harmless scribes/CN=Local/
emailAddress=me@example.com
Getting CA Private Key
Enter pass phrase for mykey.key:
$

记住,密码是你第一次创建 mykey.key 文件时设置的密码。

经过所有这些步骤的结果是,我们有两个文件可以用来保护我们的开发服务器:

  • myprivate.crt 文件是由根证书签名的证书,这个文件向浏览器保证我们的应用程序是可信的。

  • myprivate.key 文件将用于加密开发服务器与浏览器之间的连接。

如果你是使用 create-react-app 创建你的应用程序,你可以通过将以下内容放入应用程序目录中的 .env 文件来启用 HTTPS:

HTTPS=true
SSL_CRT_FILE=myprivate.crt
SSL_KEY_FILE=myprivate.key

如果重新启动服务器,你应该能够通过 https://localhost:3000 访问你的应用程序,而不是 http://localhost:3000

讨论

自签名证书是创建起来非常复杂的东西,但在某些情况下是必需的。然而,即使在开发环境中不需要运行 HTTPS,理解 HTTPS 是什么、它是如何工作的以及为什么应该信任它仍然是值得的。

你可以从 GitHub 网站 下载这个示例的源代码。

使用指纹进行身份验证

问题

“使用物理令牌进行身份验证” 讨论了如何使用物理令牌,如 YubiKeys,进行双因素身份验证。但物理令牌仍然相对稀少,而且价格相对较高。大多数人已经拥有手机、平板等移动设备。其中许多设备都内置了指纹传感器。但如何让一个 React 应用程序使用指纹传感器进行双因素身份验证呢?

解决方案

我们可以使用指纹传感器作为 WebAuthn 认证令牌。它们以同样的方式连接到 API,尽管需要进行几个配置更改。

此配方基于“使用物理令牌进行身份验证”,用于使用可移动令牌进行双因素认证。我们在“使用物理令牌进行身份验证”中看到,在 WebAuthn 身份验证中有两个主要流程:

认证

在这个流程中,用户注册设备或令牌到他们的账户。一种方法是在他们的手机上按指纹传感器。

断言

在这个流程中,用户激活设备或令牌,服务器检查它是否与之前注册的设备或令牌匹配。

无论是认证还是断言都有三个阶段:

  1. 服务器生成请求。

  2. 用户使用令牌,生成响应。

  3. 响应发送到服务器。

如果我们想要从使用可移动物理令牌切换到使用设备中内置的指纹传感器,我们只需更改认证请求阶段。认证请求说明浏览器可以为用户注册的令牌类型。对于可移动物理令牌,如 YubiKeys,我们生成了一个类似这样的认证请求:

{
    "rpName": "Physical Token Server",
    "rpID": "localhost",
    "userID": "1234",
    "userName": "freda",
    "excludeCredentials": [
        {"id": "existingKey1", "type": "public-key"}
    ],
    "authenticatorSelection": {
        "userVerification": "discouraged"
    },
    "extensions": {
        "credProps": true,
    },
}

我们需要稍微更改这一点,以允许用户使用指纹传感器:

{
    "rpName": "Physical Token Server",
    "rpID": "localhost",
    "userID": "1234",
    "userName": "freda",
    "excludeCredentials": [
        {"id": "existingKey1", "type": "public-key"}
    ],
    "authenticatorSelection": {
        "authenticatorAttachment": "platform",
        "userVerification": "required"
    },
    "attestation": "direct",
    "extensions": {
        "credProps": true,
    },
}

这两个请求几乎相同。第一个变化在认证器选择上。我们现在想使用platform认证器,因为指纹传感器内置于设备中,不可移动,这意味着我们有效地限制用户在其当前物理设备上使用。相比之下,YubiKey 可以从一台机器断开连接,然后连接到另一台机器。

我们还表示我们希望使用直接认证,这意味着我们不需要任何额外的验证。例如,我们不会要求用户按指纹传感器然后输入 PIN 码。

除了更改初始的认证请求对象之外,所有其他代码保持不变。一旦用户按指纹传感器响应认证请求,它将生成一个公钥,我们可以存储在用户账户中。当用户再次登录并通过按指纹传感器确认其身份时,它将以与 YubiKey 相同的方式签署挑战字符串。

因此,如果你要支持一种类型的认证器,值得允许用户同时使用指纹传感器和可移动令牌。

除非用户有一个也适用于移动设备的可移动令牌,例如使用近场通信(NFC),否则任何用户都不太可能注册可移动令牌和指纹。一旦他们注册了一个指纹,他们将无法登录并注册可移动令牌,反之亦然。

这里是更新后的组件,允许用户注册令牌:

import { useState } from 'react'
import Logout from './Logout'
import axios from 'axios'
import { create } from '@github/webauthn-json'

const Private2 = () => {
  const [busy, setBusy] = useState(false)
  const [message, setMessage] = useState()

  const registerToken = async (startRegistrationEndpoint) => {
    setBusy(true)
    try {
      const response = await axios.post(startRegistrationEndpoint)
      setMessage('Send response')
      const attestation = await create({ publicKey: response.data })
      setMessage('Create attestation')
      const attestationResponse = await axios.post('/register', {
        attestation,
      })
      setMessage('registered!')
      if (
        attestationResponse.data &&
        attestationResponse.data.verified
      ) {
        alert('New key registered')
      }
    } catch (err) {
      setMessage('' + err)
    } finally {
      setBusy(false)
    }
  }
  return (
    <div className="Private2">
      <h1>Account page</h1>

      {window.PublicKeyCredential && (
        <>
          <p>Register new hardware key</p>
          <button
            onClick={() => registerToken('/startRegister')}
            disabled={busy}
          >
            Register Removable Token
          </button>
          <button
            onClick={() => registerToken('/startFingerprint')}
            disabled={busy}
          >
            Register Fingerprint
          </button>
        </>
      )}
      <div className="Account-message">{message}</div>

      <Logout />
    </div>
  )
}

export default Private2

当我们要注册指纹时,我们调用了一个稍微不同的端点。否则,代码的其余部分保持不变。

要尝试它,您需要使用带有指纹传感器的设备。只有在本地主机或使用 HTTPS 的远程服务器上运行应用程序时,我们才能使用 WebAuthn。要从移动设备测试此代码,您需要在开发服务器上配置 HTTPS(参见“启用 HTTPS”),或者您需要配置您的设备将localhost连接代理到您的开发机器(参见“在 Android 设备上测试”)。

要运行示例应用程序,您需要切换到应用程序目录,并使用以下命令启动开发服务器:

$ npm run start

您还需要运行 API 服务器。为此打开一个单独的终端,然后从server子目录运行它:

$ cd server
$ npm run start

开发服务器将在端口 3000 上运行,API 服务器将在端口 5000 上运行。开发服务器将代理 API 请求到 API 服务器。

打开应用程序后,您应单击“账户页面”链接(参见图 7-16)。

图 7-16. 在主页上单击“账户页面”链接

应用程序将要求您登录。输入用户名freda和密码mypassword(参见图 7-17)。这些值在示例服务器中已硬编码。

图 7-17. 在登录表单中输入freda/mypassword

现在您将看到两个按钮,用于为您的账户注册令牌:一个用于可移动令牌,另一个用于指纹(参见图 7-18)。

图 7-18. 有按钮用于注册可移动令牌和指纹

按下按钮注册指纹。您的移动设备将要求您按下指纹传感器。您的指纹传感器将生成一个公钥,该应用程序可以存储在freda账户中。消息框将显示在完成时,如图 7-19 所示。

图 7-19. 应用程序将在令牌注册完成时进行确认

现在注销。再次登录时,在表单中输入fredamypassword。应用程序现在将要求您通过指纹传感器确认身份,然后再次登录。

讨论

内置指纹传感器比可移动令牌(如 YubiKeys)更常见。这两种设备的使用模式有所不同。YubiKeys 可以从一个设备移动到另一个设备,而指纹通常限于单个设备。^(9) 因此,对于希望从多个设备连接的用户来说,可移动令牌具有额外的灵活性。可移动设备的缺点是它们比手机更容易丢失。在大多数情况下,支持这两种设备并让用户决定哪种选项最适合他们是值得的。

您可以从GitHub 网站下载此示例的源代码。

使用确认登录

问题

有时用户可能希望执行更危险或不易撤销的操作。他们可能想要删除数据,移除用户帐户,或者执行会发送电子邮件的操作。如何防止恶意第三方在找到登录但未被关注的机器时执行这些操作?

解决方案

许多系统要求用户在执行敏感操作之前确认其登录凭据。您很可能希望为多个操作执行此操作,因此如果有一种集中进行确认的方法将非常有帮助。

我们将基于“创建安全路由”中的代码来编写此示例。在那个示例中,我们构建了一个SecurityProvider组件,为其子组件提供了loginlogout函数:

import { useState } from 'react'
import SecurityContext from './SecurityContext'

const SecurityProvider = (props) => {
  const [loggedIn, setLoggedIn] = useState(false)

  return (
    <SecurityContext.Provider
      value={{
        login: (username, password) => {
          // Note to engineering team:
          // Maybe make this more secure...
          if (username === 'fred' && password === 'password') {
            setLoggedIn(true)
          }
        },
        logout: () => setLoggedIn(false),
        loggedIn,
      }}
    >
      {props.children}
    </SecurityContext.Provider>
  )
}

export default SecurityProvider

需要使用loginlogout函数的组件可以从useSecurity钩子中访问它们:

const security = useSecurity()
...
// Anywhere that we need to logout...
security.logout()

对于此示例,我们将在SecurityProvider中添加一个额外的函数,允许子组件确认用户已登录。一旦他们提供了用户名和密码,我们允许他们执行危险操作。

我们可以通过创建一个接受包含危险操作的回调函数的函数来完成此操作,在用户确认其登录详细信息后应用程序调用此函数。在SecurityProvider中实现此函数会更容易,但在从组件中调用它时会有一些问题。我们可以返回一个成功/失败标志:

// We WON'T do it like this
confirmLogin((success) => {
    if (success) {
        // Do dangerous thing here
    } else {
        // Handle the user canceling the login
    }
})

这种方法的缺点是,如果您忘记检查success标志的值,即使用户取消了登录表单,代码也将执行危险操作。

或者,我们将不得不传递两个单独的回调函数:一个用于成功,一个用于取消:

// We WON'T do it like this either
confirmLogin(
    () => {
        // Do dangerous thing here
    },
    () => {
        // Handle the user canceling the login
    });

然而,这段代码有点丑陋。

相反,我们将使用一个 promise 来实现该代码,这将使实现变得更加复杂,但将简化调用它的任何代码。

这是SecurityProvider的一个版本,完整包含了新的confirmLogin函数:

import { useRef, useState } from 'react'
import SecurityContext from './SecurityContext'
import LoginForm from './LoginForm'

export default (props) => {
  const [showLogin, setShowLogin] = useState(false)
  const [loggedIn, setLoggedIn] = useState(false)
  const resolver = useRef()
  const rejecter = useRef()

  const onLogin = async (username, password) => {
    // Note to engineering team:
    // Maybe make this more secure...
    if (username === 'fred' && password === 'password') {
      setLoggedIn(true)
    }
  }
  const onConfirmLogin = async (username, password) => {
    // Note to engineering team:
    // Same here...
    return username === 'fred' && password === 'password'
  }

  return (
    <SecurityContext.Provider
      value={{
        login: onLogin,
        confirmLogin: async (callback) => {
          setShowLogin(true)
          return new Promise((res, rej) => {
            resolver.current = res
            rejecter.current = rej
          })
        },
        logout: () => setLoggedIn(false),
        loggedIn,
      }}
    >
      {showLogin ? (
        <LoginForm
          onLogin={async (username, password) => {
            const valid = await onConfirmLogin(username, password)
            if (valid) {
              setShowLogin(false)
              resolver.current()
            }
          }}
          onCancel={() => {
            setShowLogin(false)
            rejecter.current()
          }}
        />
      ) : null}
      {props.children}
    </SecurityContext.Provider>
  )
}

如果用户调用confirmLogin函数,SecurityProvider将显示一个登录表单,允许用户确认其用户名和密码。confirmLo⁠gin函数返回一个承诺,仅当用户正确输入用户名和密码时才会解决。如果用户取消登录表单,则承诺将被拒绝。

我们在这里不显示LoginForm组件的详细信息,但您可以在此配方的可下载源代码中找到它。

在我们的示例代码中,这里检查用户名和密码与静态字符串是否匹配。在您的代码版本中,您将用一些安全服务的调用替换它。

当我们调用confirmLogin时,我们正在将承诺存储在一个ref中。Refs通常指向 DOM 中的元素,但您可以使用它们来存储任何状态片段。与useState不同,refs会立即更新。一般来说,在代码中不应大量使用refs并且我们在这里仅使用它们以便我们可以立即记录承诺,而不必等待useState操作完成。

在实际中如何使用confirmLogin函数?假设我们有一个包含执行某些危险操作按钮的组件:

import { useState } from 'react'
import Logout from './Logout'

const Private1 = () => {
  const [message, setMessage] = useState()

  const doDangerousThing = () => {
    setMessage('DANGEROUS ACTION!')
  }

  return (
    <div className="Private1">
      <h1>Private page 1</h1>

      <button
        onClick={() => {
          doDangerousThing()
        }}
      >
        Do dangerous thing
      </button>

      <p className="message">{message}</p>

      <Logout />
    </div>
  )
}

export default Private1

如果我们希望用户在执行此操作之前确认其登录详细信息,我们可以首先获取由SecurityProvider提供的上下文:

const security = useSecurity()

在执行危险操作的代码中,我们可以awaitconfirmLogin返回的承诺:

const security = useSecurity()
...
await security.confirmLogin()
setMessage('DANGEROUS ACTION!')

在调用confirmLogin后的代码将仅在用户提供正确的用户名和密码时运行。

如果用户取消登录对话框,承诺将被拒绝,我们可以在catch块中处理取消。

这是一个修改后的执行危险代码的组件版本,现在在继续之前确认用户的登录:

import { useState } from 'react'
import Logout from './Logout'
import useSecurity from './useSecurity'

export default () => {
  const security = useSecurity()
  const [message, setMessage] = useState()

  const doDangerousThing = async () => {
    try {
      await security.confirmLogin()
      setMessage('DANGEROUS ACTION!')
    } catch (err) {
      setMessage('DANGEROUS ACTION CANCELLED!')
    }
  }

  return (
    <div className="Private1">
      <h1>Private page 1</h1>

      <button
        onClick={() => {
          doDangerousThing()
        }}
      >
        Do dangerous thing
      </button>

      <p className="message">{message}</p>

      <Logout />
    </div>
  )
}

如果我们尝试这段代码,我们首先需要从应用程序目录运行应用程序:

$ npm run start

当应用程序打开时(参见图 7-20),您将需要点击私人页面 1。

图 7-20. 首先点击私人页面 1 链接

然后应用程序会要求您登录(参见图 7-21)。您应该使用fred/password登录。

图 7-21. 页面受保护,因此您需要登录

如果您现在点击按钮执行危险操作,您将需要确认您的凭据后才能继续(如图 7-22 所示)。

图 7-22. 您必须确认您的登录详细信息才能继续

讨论

此配方将您的确认代码集中在SecurityProvider中,这具有优势:不仅可以减少组件中的代码量,还意味着用户确认可以在自定义钩子内完成。如果您将一组操作抽象成基于钩子的服务,^(10)那么您还可以在该服务中包含确认逻辑。因此,您的组件将完全不知道哪些操作是危险的,哪些不是。

您可以从GitHub 网站下载此配方的源代码。

使用单因素认证

问题

我们已经看到可移动令牌和指纹可以用于双因素认证系统,为用户的账户提供额外的安全性。

但是,您也可以将它们用作简单的登录便利。许多移动应用程序允许用户通过按压指纹传感器而无需输入用户名或密码登录。

如何为 React 应用启用单因素认证?

解决方案

安全令牌,如指纹传感器和 USB 设备(如 YubiKeys),需要记录在服务器上的用户账户上。单因素认证的问题在于,当他们触摸指纹传感器时,我们不知道用户应该是谁。在双因素系统中,他们刚刚在表单中输入了他们的用户名。但在单因素系统中,当我们创建断言请求时,我们需要知道用户应该是谁。^(11)

通过在浏览器中设置包含用户 ID 的 cookie,我们可以避免这个问题,每当启用了令牌的账户的用户登录时。^(12)

当应用程序显示登录表单时,应用程序可以检查 cookie 的存在,然后使用它创建一个断言请求,并要求用户输入安全令牌。如果用户不想使用令牌,他们可以取消请求,简单地使用登录表单。^(13)

用户 ID 通常是机器生成的内部密钥,不包含安全信息。但是,如果您的用户 ID 更容易识别,比如电子邮件地址,您不应该使用这种方法。

我们基于“创建安全路由”中的安全路由代码来编写此配方的代码。我们通过名为SecurityProvider的包装组件管理所有安全性。这为子组件提供了loginlogout功能。我们将添加另一个名为loginWithToken的函数:

import { useState } from 'react'
import SecurityContext from './SecurityContext'
import { get } from '@github/webauthn-json'
import axios from 'axios'

const SecurityProvider = (props) => {
  const [loggedIn, setLoggedIn] = useState(false)

  return (
    <SecurityContext.Provider
      value={{
        login: async (username, password) => {
          const response = await axios.post('/login', {
            username,
            password,
          })
          setLoggedIn(true)
        },
        loginWithToken: async (userID) => {
          const response = await axios.post('/startVerify', {
            userID,
          })
          const assertion = await get({ publicKey: response.data })
          await axios.post('/verify', { userID, assertion })
          setLoggedIn(true)
        },
        logout: async () => {
          await axios.post('/logout')
          setLoggedIn(false)
        },
        loggedIn,
      }}
    >
      {props.children}
    </SecurityContext.Provider>
  )
}
export default SecurityProvider

loginWithToken接受一个用户 ID,然后要求用户用令牌验证其身份:

  1. 在服务器上调用startVerify函数创建一个断言请求

  2. 将请求传递给 WebAuthn,要求用户按压指纹传感器

  3. 将生成的断言传递回名为verify的端点,以检查令牌是否有效

您需要在您的实现中替换 startVerifyverify 端点。

要调用 SecurityProvider 中的 loginWithToken 函数,我们需要从 cookie 中找到当前用户的 ID。我们将通过安装 js-cookie 库来实现这一点:

$ npm install js-cookie

这将允许我们像这样读取 userID cookie:

import Cookies from 'js-cookie'
...
const userIDCookie = Cookies.get('userID')

现在,我们可以在 Login 组件中使用此代码,它将检查 userID cookie。如果存在,它将要求通过令牌登录。否则,它将允许用户使用用户名和密码登录:

import { useEffect, useState } from 'react'
import useSecurity from './useSecurity'
import Cookies from 'js-cookie'

const Login = () => {
  const { login, loginWithToken } = useSecurity()
  const [username, setUsername] = useState()
  const [password, setPassword] = useState()
  const userIDCookie = Cookies.get('userID')

  useEffect(() => {
    ;(async () => {
      if (userIDCookie) {
        loginWithToken(userIDCookie)
      }
    })()
  }, [userIDCookie])

  return (
    <div>
      <h1>Login Page</h1>

      <p>You need to log in.</p>

      <label htmlFor="username">Username:</label>
      <input
        id="username"
        name="username"
        type="text"
        value={username}
        onChange={(evt) => setUsername(evt.target.value)}
      />

      <br />
      <label htmlFor="password">Password:</label>
      <input
        id="password"
        name="password"
        type="password"
        value={password}
        onChange={(evt) => setPassword(evt.target.value)}
      />

      <br />
      <button onClick={() => login(username, password)}>Login</button>
    </div>
  )
}

export default Login

让我们试试示例应用程序。我们必须首先从应用程序目录启动开发服务器:

$ npm run start

然后,在单独的终端中,我们可以启动示例 API 服务器:

$ cd server
$ npm run start

开发服务器运行在端口 3000 上;API 服务器运行在端口 5000 上。

应用程序启动时,点击链接到账户页面(如图 7-23 所示)。

图 7-23. 应用程序打开时,点击链接到账户页面

应用程序要求我们登录(见图 7-24)。使用用户名 freda 和密码 mypassword

图 7-24. 使用 freda/mypassword 登录

账户页面询问我们是否希望使用指纹传感器或物理令牌进行登录(见图 7-25)。您可以注册一个令牌,然后注销。

图 7-25. 选择启用使用物理令牌或指纹登录

下次我们登录时,将立即看到激活令牌的请求(见图 7-26)。

图 7-26. 一旦启用,您可以仅使用令牌登录

如果我们激活令牌,我们将无需提供用户名和密码即可登录。

讨论

值得注意的是,单因素身份验证主要是为了增加便利性而非安全性。指纹传感器特别方便,因为登录实际上只需要移动一个手指。

您应始终提供返回到使用登录表单的能力。这样做不会降低应用程序的安全性,因为狡猾的黑客可能会删除 cookie,并返回使用表单。

您可以从 GitHub 网站 下载此示例的源代码。

在 Android 设备上测试

问题

您可以使用桌面浏览器模拟移动设备的外观进行大多数移动浏览器测试(见图 7-27)。

图 7-27. 您可以使用桌面浏览器进行大多数移动测试

但有时最好在物理移动设备上测试 React 应用程序,这通常不是问题;移动设备可以使用开发机的 IP 地址远程访问 React 应用程序。

然而,在某些情况下,这并不成立:

  • 您的移动设备可能无法连接到与开发机相同的网络。

  • 您可能正在使用需要除 localhost 外域名的 HTTPS 的技术,比如 WebAuthn。

是否可以配置移动设备访问 React 应用,就像它在 localhost 上运行一样,尽管它是在不同的机器上运行?

解决方案

此步骤将探讨如何在基于 Android 的设备上代理网络,以便连接到 localhost 的连接将转到您开发机器上的服务器。

您需要的第一件事是拥有启用了 USB 调试 的 Android 设备。您还需要安装 Android SDK 的一个副本,它将允许您使用一个称为 Android 调试桥 (ADB) 的工具。ADB 在开发机器和 Android 设备之间打开了一个通信通道。

然后,您需要使用 USB 数据线将 Android 设备连接到开发机器,并确保 adb 命令在您的命令路径中可用。^(14) 您随后可以列出连接到您的机器的 Android 设备:

$ adb devices
* daemon not running; starting now at tcp:5037
* daemon started successfully
List of devices attached
25PRIFFEJZWWDFWO        device
$

在这里,您可以看到只连接了一个设备,其设备 ID 为 25PRIFFEJZWWDFWO

您现在可以使用 adb 命令在 Android 设备上配置代理,将所有 HTTP 流量重定向到其内部端口 3000:

$ adb shell settings put global http_proxy localhost:3000

如果有多台 Android 设备连接到您的机器,您需要使用 adb 选项 -s <device-id> 指定其设备 ID。

接下来,您需要告诉 adb 在 Android 设备上运行一个代理服务,该服务将从设备的端口 3000 转发到开发机器的端口 3000:

$ adb reverse tcp:3000 tcp:3000

如果现在在 Android 设备上打开浏览器,并告诉它访问 http://localhost:3000,它将显示在您的开发机器上运行的应用程序,就像它在设备内部运行一样(参见 图 7-28)。

图 7-28. 如果您在移动浏览器中打开 localhost,则将连接到开发机器

使用完应用程序后,您需要在 Android 设备上禁用代理设置。

如果未能在 Android 设备上禁用代理,则将无法访问网络。

您可以通过将代理重置回 :0 来完成此操作:

$ adb shell settings put global http_proxy :0

讨论

这个步骤第一次使用时需要大量工作,因为它涉及在开发机器上安装整个 Android SDK。但之后,连接和断开真实的 Android 设备到您的机器将变得非常简单。

使用 ESlint 进行安全检查

问题

JavaScript 中经常出现的一些常见编码问题会导致安全威胁。您可以决定创建一组编码标准,以避免这些错误。但是,您需要经常审查这些标准,以使它们与技术的最新变化保持同步,并且还需要引入缓慢和昂贵的代码审查过程。

是否有一种方法可以检查代码中的不良安全实践,而不会减慢您的开发过程?

解决方案

引入安全审查的一种方法是尝试自动化。一个可以帮助您做到这一点的工具是eslint。如果您使用类似create-react-app这样的工具创建了您的应用程序,您可能已经安装了eslint。事实上,create-react-app每次重新启动开发服务器时都会运行eslint。如果您曾在终端中看到代码问题被突出显示,那输出就来自于eslint

Compiled with warnings.

src/App.js
 Line 5:9:  'x' is assigned a value but never used  no-unused-vars

Search for the keywords to learn more about each warning.
To ignore, add // eslint-disable-next-line to the line before.

如果您尚未安装eslint,可以通过npm安装它:

$ npm install --save-dev eslint

安装完成后,您可以像这样初始化它:

$ node_modules/.bin/eslint --init
- How would you like to use ESLint? · problems
- What type of modules does your project use? · esm
- Which framework does your project use? · react
- Does your project use TypeScript? · No / Yes
- Where does your code run? · browser
- What format do you want your config file to be in? · JavaScript
Local ESLint installation not found.
The config that you've selected requires the following dependencies:

eslint-plugin-react@latest eslint@latest
- Would you like to install them now with npm? · No / Yes
$

记住:如果您使用create-react-app,您不需要初始化eslint;它已经为您做好了。

在此时,您可以选择编写自己的eslint规则集,以检查任何安全实践的违规。然而,安装一个已经为您编写好一套安全规则的eslint插件要简单得多。

例如,让我们安装由Slyk创建和管理的eslint-plugin-react-security包:

$ npm install --save-dev eslint-plugin-react-security

安装完成后,我们可以通过编辑package.json中的eslintConfig部分(如果您使用create-react-app),或者在您的应用目录中的eslintrc文件中启用此插件。

您应该将它从这个变成这个:

"eslintConfig": {
  "extends": [
    "react-app",
    "react-app/jest"
  ]
},

to this:

"eslintConfig": {
  "extends": [
    "react-app",
    "react-app/jest"
  ],
  "plugins": [
    "react-security"
  ],
  "rules": {
    "react-security/no-javascript-urls": "warn",
    "react-security/no-dangerously-set-innerhtml": "warn",
    "react-security/no-find-dom-node": "warn",
    "react-security/no-refs": "warn"
  }
},

这一更改将启用来自 React 安全插件的四个规则。

为了检查它们是否有效,让我们向应用程序添加一些代码,违反no-dangerously-set-innerhtml规则:

import logo from './logo.svg'
import './App.css'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <div
          dangerouslySetInnerHTML={{
            __html: '<p>This is a bad idea</p>',
          }}
        />
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  )
}

export default App

如果您手动安装了eslint,现在可以使用以下命令扫描此文件:

$ node_modules/.bin/eslint src/App.js

如果您使用create-react-app,只需重新启动服务器即可确保重新加载eslint配置:

Compiled with warnings.

src/App.js
 Line 12:16:  dangrouslySetInnerHTML prop usage detected
 react-security/no-dangerously-set-innerhtml

Search for the keywords to learn more about each warning.
To ignore, add // eslint-disable-next-line to the line before.

讨论

如果您有一个开发团队,您可能还希望使用 Git 的pre-commit钩子来运行eslint检查,以防止开发人员提交失败的代码。Git 钩子将更快地向开发人员反馈,并阻止他们让所有人的构建失败。

如果您想通过package.json文件配置预提交钩子,请考虑安装Husky code hooks

自动化安全检查的另一个好处是您可以将它们添加到您的构建和部署管道中。如果您在管道的开始运行检查,您可以立即拒绝提交并通知开发人员。

您可以从GitHub 站点下载此配方的源代码。

使登录表单在浏览器中友好

问题

许多安全解决方案依赖于用户名/密码表单,但在创建它们时很容易陷入几个可用性陷阱。在某些设备上,自动大写和自动更正可能会破坏用户名和密码,以帮助为目的。一些浏览器会尝试自动填充用户名字段,但通常不清楚它们使用了什么规则,因此自动完成在某些站点上有效,但在其他站点上则无效。

在构建登录表单时,应遵循哪些实践,以使其与浏览器配合而不是相互对抗?

解决方案

几个 HTML 属性可以显著提高登录表单的可用性。

首先,可以禁用用户名字段的自动更正可能会有所帮助。在移动设备上经常会应用自动更正来弥补键盘小和不可避免的拼写错误。但是在输入用户名时,自动更正没有什么用处。您可以使用autoCorrect属性禁用自动更正:

<input autoCorrect="off"/>

接下来,如果您的用户名是电子邮件地址,请考虑将type设置为email,这可能会在移动设备上启动专用于电子邮件的键盘。某些浏览器甚至可能在自动完成窗口或电子邮件专用键盘的标题中显示最近的电子邮件地址:

<input type="email"/>

您还可以考虑将j_username用作用户名字段的idname。为什么?因为基于 Java 的应用程序通常有名为j_username的字段,因此用户可能以前已经提供了j_username值。这增加了浏览器在自动完成窗口中提供电子邮件地址的可能性:

<input id="j_username" name="j_username"/>

您可以明确指定一个字段表示用户名字段,这样很可能会触发浏览器的自动完成响应:

<input autoComplete="username"/>

现在,关于密码,该怎么办呢?

首先,始终将类型设置为password

<input type="password"/>

永远不要试图以其他方式复制密码字段的视觉外观,例如通过自定义 CSS 样式。这样做将阻止浏览器对密码字段应用标准安全功能,例如在其中禁用复制功能。此外,如果不将类型设置为password,浏览器将不会提供将值存储在其密码管理器中的选项。

密码字段有两种类型:用于当前密码(登录时)和用于新密码(注册或更改密码时)。

为什么这很重要?因为 HTML 中的autoComplete属性可以告诉浏览器您打算如何使用密码字段。

如果是登录表单,您将希望说密码是current-password

<input type="password" autoComplete="current-password"/>

如果是注册或更改密码表单,您应将其设置为new-password

<input type="password" autoComplete="new-password"/>

这个值将促使浏览器在登录表单中自动填充存储的密码。它还会触发任何内置或第三方密码生成工具。

最后,避免使用向导式登录界面(参见图 7-29,来自华盛顿邮报的示例)。

浏览器不太可能将单个用户名字段识别为登录表单,因此不太可能为您提供完整的详细信息。

图 7-29。多步表单可能会阻止浏览器使用自动完成。

讨论

autocomplete属性对于几种表单字段类型有许多其他不常用的值,包括地址详细信息、电话号码和信用卡号。有关更多信息,请参见Mozilla 开发网站

^(1) 或者,在 GraphQL 的情况下,访问器和变异器。

^(2) 与 Internet Explorer 显著不同。

^(3) 参见“创建安全路由”。

^(4) 在 Android 设备上通过代理手机到开发机器可以解决此问题。参见“在 Android 设备上进行测试”。

^(5) 查看Let’s Encrypt 网站

^(6) 默认情况下,这将在端口 443 上进行。

^(7) .pem扩展名代表 Privacy-Enhanced Mail。PEM 格式最初设计用于电子邮件,但现在用作通用证书存储格式。

^(8) 要详细的指南,请参阅来自 BounCA 的此教程

^(9) 唯一的例外情况是用户连接了外部指纹传感器。

^(10) 有关此类服务的示例,请参见“使用状态计数器自动刷新”中的useForum钩子。

^(11) 当浏览器要求用户扫描他们的指纹或激活他们的令牌时,需要断言请求。它包含所有可接受设备的列表,因此对于特定用户是唯一的。

^(12) 这种方法的一个后果是用户将仅在注册令牌的浏览器上进行单因素认证。如果他们使用不同的浏览器或最近清除了他们的 Cookies,他们将不得不回到使用登录表单。

^(13) 这假定您使用的是 JavaScript 可读取的 Cookie。也可以使用 HTTP-only Cookie,只有服务器(或服务工作者)才能读取。如果使用 HTTP-only Cookie,则需要服务器上的代码来检查用户是否应提供令牌。

^(14) 您需要找到安装在您计算机上的 Android SDK。您可以在安装的子目录中找到adb命令。

第八章:测试

在本章中,我们将探讨测试 React 应用程序的各种技术。总的来说,我们发现对于应该具有的确切测试组合,过于具体的建议并不是一个好主意。一个好的指导原则是遵循以下两条规则:

  • 除非你有一个失败的测试,否则不要编写代码。

  • 如果一次性运行测试通过,请将其删除。

这两条规则将帮助你构建能够工作的代码,同时避免创建提供较少价值的冗余测试。

我们发现,在项目初期,编写基于浏览器的测试更容易一些。这些测试往往是高级别的,并有助于捕捉应用程序的主要业务需求。稍后,当应用程序的架构开始出现和稳定下来时,编写单元测试变得更容易一些。它们编写速度更快,运行速度更快,一旦你的代码结构稳定,你就不需要不断更新它们了。

有时候值得放宽对测试的定义。当你在处理主要价值在于视觉的布局代码时,你可能会认为 Storybook 故事是一种“测试”。断言是通过你的眼睛完成的,你在创建时查看组件。当然,这种类型的测试不会自动检测到回归失败,但我们在一篇食谱中提出了一种技术,可以将这些视觉检查转化为实际的自动化测试。

如果你在编写代码之前编写测试,你会发现测试是设计工具。它们将成为你希望应用程序如何工作的可执行示例。

相反,如果你在编写代码之后编写测试,它们将只是一种工件。这些代码片段你必须机械地创建,因为它们感觉像是专业开发者应该写的东西。

我们在本章中关注四种工具:React Testing Library、Storybook、Selenium 库和 Cypress。

React Testing Library 是创建非常详细的单元测试的一个很好的方式。

Storybook 是一个我们之前介绍过的展示工具。我们将其包括在本章中是因为一个展示是一组代码示例,这也是测试的一种形式。您将找到使用 Storybook 作为您测试/开发过程的一部分的方法。

Selenium 是用于在真实浏览器中测试应用程序的最成熟的库之一。

最后,让我们快速了解目前我们最喜欢的测试工具:Cypress。Cypress 类似于 Selenium,因为它在浏览器内运行。但它包含了许多额外的功能,比如测试重放、生成测试运行视频以及更简单的编程模型。如果你只想使用本章的一个工具,那就选择 Cypress 吧。

使用 React Testing Library

问题

有许多方法可以测试 React 应用程序。在项目的早期阶段,当您仍在定义应用程序的基本目的和功能时,您可能会选择以某种非常高级的形式创建测试,例如Cucumber tests。如果您正在查看系统的某个隔离部分(例如创建和维护数据项),您可能希望使用类似 Cypress 的工具创建功能测试。

但是,如果您深入到创建单个组件的详细信息中,那么您可能希望创建单元测试。单元测试之所以被称为单元测试,是因为它们试图测试作为独立单元的单个代码片段。尽管是否单元测试是正确的术语来测试组件(通常包含子组件,因此不是隔离的)仍有争议,但通常用于测试可以在浏览器之外测试的组件的名称。

那么,如何对 React 组件进行单元测试呢?历史上存在几种方法。早期的单元测试依赖将组件渲染为 HTML 字符串,这需要最小的测试基础设施,但存在多个缺点:

  • 处理组件状态变化时的重新渲染。

  • 对必须从字符串中解析的 HTML 元素进行断言。

  • 要测试 UI 交互,你需要模拟事件模型。

不久之后,开发人员创建了库来解决这些问题的每一个细节。

然而,通过这种方式创建的测试缺乏浏览器中测试的真实性。虚拟文档对象模型(DOM)与浏览器 DOM 之间的交互细微差别被忽略了。通常,为了减少测试的复杂性,子组件未被渲染。

结果是,React 应用程序通常只有少数单元测试。开发人员会重构其代码,将复杂逻辑移至易于测试的 JavaScript 函数中。开发人员必须使用真实浏览器测试更复杂的任何内容,导致测试速度变慢。由于速度慢,开发人员可能会被 discouraged 不测试太多场景。

那么如何可以在不启动整个应用程序和在真实浏览器中运行测试的情况下,现实地对 React 组件进行单元测试呢?

解决方案

Kent C. Dodds 的测试库试图避免先前单元测试库存在的问题,通过提供 DOM 的独立实现来实现此目标。因此,测试可以将 React 组件渲染到虚拟 DOM 中,然后与 Testing Library 的 DOM 同步,并创建一个行为类似于真实浏览器的 HTML 元素树。

您可以像在浏览器中一样检查元素。它们具有相同的属性和属性。您甚至可以将按键传递给 input 字段,并使它们像浏览器中的字段一样工作。

如果您使用 create-react-app 创建应用程序,则应已安装 Testing Library。如果没有,您可以从命令行安装它:

$ npm install --save-dev "@testing-library/react"
$ npm install --save-dev "@testing-library/jest-dom"
$ npm install --save-dev "@testing-library/user-event"

这三个库将允许我们对组件进行单元测试。

Testing Library 允许我们使用@testing-library/jest-dom中的 DOM 实现来渲染组件。User Event 库(@testing-library/user-event)简化了与生成的 DOM 元素交互的过程。这个 User Event 库允许我们点击按钮和在组件的字段中输入内容。

为了展示如何对组件进行单元测试,我们需要一个要测试的应用程序。我们将在本章的大部分内容中使用相同的应用程序。应用程序打开时,会要求用户进行简单的计算。应用程序将告诉用户的答案是否正确(参见图 8-1)。

图 8-1. 在测试中的应用程序

应用程序的主要组件称为App。我们可以通过编写一个名为App.test.js的新文件为这个组件创建一个单元测试:

describe('App', () => {
  it('should tell you when you win', () => {
    // Given we've rendered the app
    // When we enter the correct answer
    // Then we are told that we've won
  })
})

上述代码是一个 Jest 测试,测试App组件在我们输入正确答案时是否告诉我们已经赢了。我们已经为测试结构放置了占位符注释。

我们将通过将组件导入并将其传递给 Testing Library 的render函数来开始渲染App组件:

import { render } from '@testing-library/react'
import App from './App'

describe('App', () => {
  it('should tell you when you win', () => {
    // Given we've rendered the app
    render(<App />)

    // When we enter the correct answer
    // Then we are told that we've won
  })
})

注意,我们将实际的JSX传递给render函数,这意味着我们可以在需要时测试组件在传递不同属性集时的行为。

在测试的下一部分中,我们需要输入正确的答案。为此,我们必须首先知道正确的答案是什么。这个谜题总是随机生成的乘法,所以我们可以从页面上捕捉数字,然后将乘积输入到Guess字段中。^(1)

我们将需要查看App组件生成的元素。render函数返回一个包含元素和一组用于过滤它们的函数的对象。与使用此返回值不同,我们将使用 Testing Library 的screen对象。

你可以把screen对象看作是浏览器窗口的内容。它允许我们在页面内查找元素,以便我们可以与它们交互。例如,如果我们想要找到标记为Guess的输入字段,我们可以这样做:

const input = screen.getByLabelText(/guess:/i)

screen对象中的过滤方法通常以以下方式开头:

getBy...

如果你知道 DOM 只包含匹配元素的单个实例

queryBy...

如果你知道匹配的元素数为零或一个

getAllBy...

如果你知道匹配的元素数至少为一个(返回一个数组)

queryAllBy...

要查找零个或多个元素(返回一个数组)

如果这些方法发现的元素数量与期望的不符,它们将抛出异常。还有findBy...findAllBy...方法,它们是getBy...getAllBy...的异步版本,返回 Promise。

对于每一种这样的过滤方法类型,你可以搜索以下内容:

函数名称结尾 描述
...ByLabelText 根据标签查找字段
...ByPlaceHolderText 查找具有占位符文本的字段
...ByText 匹配文本内容
...ByDisplayValue 根据值查找
...ByAltText 匹配 alt 属性
...ByTitle 匹配标题属性
...ByRole 根据 ARIA 角色查找
...ByTestId 根据 data-testid 属性查找

在页面中有近 50 种查找元素的方法。然而,你可能已经注意到 none 一词没有使用 CSS 选择器来追踪元素,这是有意的。Testing Library 限制了在 DOM 中查找元素的方式数量。例如,它不允许你通过类名查找元素,以减少测试的脆弱性。类名经常用于样式美化,并且经常会发生变化。

仍然可以通过使用 render 方法返回的 container 来使用选择器追踪元素:

const { container } = render(<App />)
const theInput = container.querySelector('#guess')

但是这种方法被认为是不良实践。如果你使用 Testing Library,最好遵循标准方法,根据内容或角色查找元素。

这种方法在筛选函数中有一个小的让步:...ByTestId 函数。如果你没有实际的方法通过内容查找元素,你可以随时向相关标签添加 data-testid 属性。这在我们当前正在编写的测试中非常有用,因为我们需要找到页面上显示的两个随机生成的数字。这些数字的内容是不知道的(图 8-2)。

图 8-2。我们不能通过内容找到数字,因为我们不知道它们是什么

所以,我们对代码进行了小修改,并添加了测试 ID:

<div className="Question-detail">
  <div data-testid="number1" className="number1">
    {pair && pair[0]}
  </div>
  &times;
  <div data-testid="number2" className="number2">
    {pair && pair[1]}
  </div>
  ?
</div>

这意味着我们可以开始实现测试的下一部分:

import { render, screen } from '@testing-library/react'
import App from './App'

describe('App', () => {
  it('should tell you when you win', () => {
    // Given we've rendered the app
    render(<App />)

    // When we enter the correct answer
    const number1 = screen.getByTestId('number1').textContent
    const number2 = screen.getByTestId('number2').textContent
    const input = screen.getByLabelText(/guess:/i)
    const submitButton = screen.getByText('Submit')
    // Err...

    // Then we are told that we've won
  })
})

我们已经有每个数字的文本,也有 input 元素。现在我们需要在字段中输入正确的数字,然后提交答案。我们将使用 @testing-library/user-event 库完成这个操作。User Event 库简化了为 HTML 元素生成 JavaScript 事件的过程。通常会看到 User Event 库被导入为别名 user,这是因为可以将对 User Event 库的调用视为用户所执行的操作:

import { render, screen } from '@testing-library/react'
import user from '@testing-library/user-event'
import App from './App'

describe('App', () => {
  it('should tell you when you win', () => {
    // Given we've rendered the app
    render(<App />)

    // When we enter the correct answer
    const number1 = screen.getByTestId('number1').textContent
    const number2 = screen.getByTestId('number2').textContent
    const input = screen.getByLabelText(/guess:/i)
    const submitButton = screen.getByText('Submit')
    user.type(input, '' + parseFloat(number1) * parseFloat(number2))
    user.click(submitButton)

    // Then we are told that we've won
  })
})

最后,我们需要断言我们已经获胜了。我们可以通过查找包含单词 won 的某个元素来简单地编写这个:^(2)

// Then we are told that we've won
screen.getByText(/won/i)

如果找不到精确匹配的元素,getByText 断言将失败。

如果你对测试中某一时刻的当前 HTML 状态感到不确定,请尝试在代码中添加 screen.getByTestId('NONEXISTENT')。抛出的异常将显示当前的 HTML。

然而,如果您的应用程序运行缓慢,测试很可能会失败。这是因为get...query...函数查看 DOM 的现有状态。如果结果需要几秒钟才能出现,断言将失败。因此,将一些断言设置为异步是个好主意。这会使代码变得稍微复杂,但在运行速度慢的代码上运行时,测试会更加稳定。

find...方法是get...方法的异步版本,Testing Library 的waitFor函数将允许您在一段时间内重新运行代码。通过结合这两个函数,我们可以创建我们测试的最后一部分:

import { render, screen, waitFor } from '@testing-library/react'
import user from '@testing-library/user-event'
import App from './App'

describe('App', () => {
  it('should tell you when you win', async () => {
    // Given we've rendered the app
    render(<App />)

    // When we enter the correct answer
    const number1 = screen.getByTestId('number1').textContent
    const number2 = screen.getByTestId('number2').textContent
    const input = screen.getByLabelText(/guess:/i)
    const submitButton = screen.getByText('Submit')
    user.type(input, '' + parseFloat(number1) * parseFloat(number2))
    user.click(submitButton)

    // Then we are told that we've won
    await waitFor(() => screen.findByText(/won/i), { timeout: 4000 })
  })
})

单元测试应该快速运行,但如果由于某些原因您的测试需要超过五秒钟的时间,您将需要将第二个timeout值以毫秒传递给it函数。

讨论

在与不同团队合作时,我们发现在项目的早期阶段,开发人员会为每个组件编写单元测试。但随着时间的推移,他们会减少甚至删除太需要维护的单元测试。

部分原因是单元测试比浏览器测试更抽象。它们正在执行与浏览器测试相同类型的操作,但是这些操作是不可见的。当它们与组件交互时,您不会看见它们。

第二个原因是团队经常将测试视为项目中的可交付工件。如果单元测试未覆盖代码的某个百分比,团队甚至可能会有构建失败的情况。

如果开发人员在编写代码之前编写测试,这些问题通常会消失。如果您逐行编写测试,您将更好地掌握 HTML 的当前状态。如果您停止将测试视为开发工件,并开始将其视为设计代码的工具,它们将不再是耗时的负担,而是使您工作更轻松的工具。

写代码时的重要一点是从一个失败的测试开始。在项目的早期阶段,这可能是一个失败的浏览器测试。随着项目的成熟和架构的稳定,您应该创建越来越多的单元测试。

您可以从GitHub 网站下载此配方的源代码。

使用 Storybook 进行渲染测试

问题

测试只是您可以执行的简单示例。因此,测试与组件库系统(例如 Storybook)有很多共同之处。测试和图库都是在特定情况下运行的组件示例。而测试会用代码做出断言,开发人员会通过查看示例并检查其是否符合预期来对库示例进行断言。在图库和测试中,异常将非常明显。

存在一些差异。测试可以自动与组件交互;画廊组件需要人工按按钮和输入文本。开发人员可以用单一命令运行测试;画廊必须一次查看一个示例。画廊组件是视觉化的,易于理解;而测试则是抽象的,不那么有趣。

有没有办法将像 Storybook 这样的画廊与自动化测试结合起来,以兼得两者的优点?

解决方案

我们将看看如何在测试中重用您的 Storybook 故事。您可以使用以下命令将 Storybook 安装到您的应用中:

$ npx sb init

本章的示例应用是一个简单的数学游戏,用户需要计算乘法问题的答案(参见图 8-3)。

图 8-3. 示例应用

游戏中的一个组件称为 Question,它显示一个随机生成的乘法问题(参见图 8-4)。

图 8-4. 问题组件

我们可以不用太担心这个组件的测试。只需通过创建一些 Storybook 故事来构建它。我们将编写一个新的 Question.stories.js 文件:

import Question from './Question'

const Info = {
  title: 'Question',
}

export default Info

export const Basic = () => <Question />

然后,我们将创建一个初始版本的组件,在 Storybook 中查看并且满意:

import { useEffect, useState } from 'react'
import './Question.css'

const RANGE = 10

function rand() {
  return Math.floor(Math.random() * RANGE + 1)
}

const Question = ({ refreshTime }) => {
  const [pair, setPair] = useState()

  const refresh = () => {
    setPair((pair) => {
      return [rand(), rand()]
    })
  }

  useEffect(refresh, [refreshTime])

  return (
    <div className="Question">
      <div className="Question-detail">
        <div data-testid="number1" className="number1">
          {pair && pair[0]}
        </div>
        &times;
        <div data-testid="number2" className="number2">
          {pair && pair[1]}
        </div>
        ?
      </div>
      <button onClick={refresh}>Refresh</button>
    </div>
  )
}

export default Question

如果用户点击刷新按钮或者父组件传入新的 refreshTime 值,该组件会显示一个随机生成的问题。

在 Storybook 中显示组件,看起来它工作正常。我们可以点击刷新按钮,它就会刷新。因此,在这一点上,我们开始在主应用中使用这个组件。过了一段时间,我们添加了一些额外的功能,但它们都不是视觉上的改变,所以我们不再查看它的 Storybook 故事。毕竟,它看起来还是一样的。对吧?

这是一个修改后的组件版本,我们将其连接到应用的其余部分之后:

import { useEffect, useState } from 'react'
import './Question.css'

const RANGE = 10

function rand() {
  return Math.floor(Math.random() * RANGE + 1)
}

const Question = ({ onAnswer, refreshTime }) => {
  const [pair, setPair] = useState()
  const result = pair && pair[0] * pair[1]

  useEffect(() => {
    onAnswer(result)
  }, [onAnswer, result])

  const refresh = () => {
    setPair((pair) => {
      return [rand(), rand()]
    })
  }

  useEffect(refresh, [refreshTime])

  return (
    <div className="Question">
      <div className="Question-detail">
        <div data-testid="number1" className="number1">
          {pair && pair[0]}
        </div>
        &times;
        <div data-testid="number2" className="number2">
          {pair && pair[1]}
        </div>
        ?
      </div>
      <button onClick={refresh}>Refresh</button>
    </div>
  )
}

export default Question

这个版本只比之前略长一点点。我们添加了一个 onAnswer 回调函数,每次应用生成新问题时,它都会向父组件返回正确答案。

这个新组件在应用中表现良好,但接着发生了一件奇怪的事情。下次有人查看 Storybook 时,他们会注意到一个错误,如图 8-5 所示。

图 8-5. 查看组件新版本时发生错误

发生了什么?我们在代码中添加了一个隐含的假设,即父组件将始终向组件传递一个 onAnswer 回调函数。因为 Storybook 故事渲染 Basic 故事时没有 onAnswer,所以我们遇到了错误:

<Question/>

这重要吗?对于像这样的简单组件来说,可能不重要。毕竟,应用本身仍在工作。但是,未能处理丢失的属性,例如这里缺少的回调或更常见的缺少数据,是 React 中错误的最典型原因之一。

应用程序经常使用来自网络的数据生成 React 属性,这意味着您传递给组件的初始属性通常会是 null 或 undefined。通常建议要么使用类型安全的语言,如 TypeScript,以避免这些问题,要么编写测试以检查您的组件是否能够处理丢失的属性。

我们创建了这个组件而没有任何测试,但我们确实用 Storybook 的故事创建了它,并且该故事确实捕捉到了问题。那么有没有办法编写一个测试来自动检查 Storybook 是否可以渲染所有故事?

我们将在名为Question.test.js的文件中为此组件创建一个测试。

考虑为每个组件创建一个文件夹。而不是在src目录中简单地放置一个名为Question.js的文件,创建一个名为src/Question的文件夹,在其中放置Question.jsQuestion.stories.jsQuestion.test.js。然后,如果添加一个src/Question/index.js文件,它默认导出Question组件,那么您的其余代码将不受影响,并且可以减少其他开发人员需要处理的文件数量。^(3)

然后在测试文件中,我们可以创建一个 Jest 测试,加载每个故事,然后将它们传递给 Testing Library 的render函数:^(4)

import { render } from '@testing-library/react'
import Question from './Question'

const stories = require('./Question.stories')

describe('Question', () => {
  it('should render all storybook stories without error', () => {
    for (let story in stories) {
      if (story !== 'default') {
        let C = stories[story]
        render(<C />)
      }
    }
  })
})

如果您的故事使用装饰器来提供诸如路由器或样式之类的内容,则此技术将无法自动获取它们。您应该在测试中的render方法中添加它们。

运行此测试时,您将会收到一个失败:

onAnswer is not a function
TypeError: onAnswer is not a function

我们可以通过在调用之前检查是否有回调来修复错误:

useEffect(() => {
  // We need to check to avoid an error
  if (onAnswer && result) {
    onAnswer(result)
  }
}, [onAnswer, result])

这种技术允许您以最小的工作量为组件创建一些基本测试。值得为组件创建一个不包含任何属性的故事。然后,在添加新属性之前,创建一个使用它的故事,并考虑您期望组件如何行为。

即使测试只执行每个故事的简单渲染,也没有理由您不能导入单个故事并创建一个使用该故事的测试:

import { render, screen } from '@testing-library/react'
import user from '@testing-library/user-event'
import Question from './Question'
import { Basic, WithDisabled } from './Question.stories'
...
it('should disable the button when asked', () => {
  render(<WithDisabled />)
  const refreshButton = screen.getByRole('button')
  expect(refreshButton.disabled).toEqual(true)
})

讨论

Storybook 渲染测试将基础单元测试引入到您的应用程序中,它可以发现大量的回归错误。它还帮助您将测试视为示例,这些示例有助于您设计代码,而不是必须为了让团队领导满意而创建的编码工件。为故事创建渲染测试还有助于如果您的团队对单元测试还不熟悉的话。通过创建视觉示例,它可以避免非视觉测试感觉抽象所带来的问题。它还可以让开发人员养成为系统中的每个组件编写测试文件的习惯。当您需要对组件进行微小更改时,添加一个小的单元测试函数就会变得更加容易。

你可以从GitHub 网站下载这个配方的源代码。

使用 Cypress 在没有服务器的情况下进行测试

问题

高质量代码的主要特征之一是其对错误的响应方式。Peter Deutsch 的分布式计算的八个谬论中的第一个是:“网络是可靠的”。不仅网络可靠,连接到它的服务器或数据库也不可靠。在某些时候,您的应用程序将不得不处理某些网络故障。可能是手机失去连接,服务器宕机,数据库崩溃,或者其他人已经删除了您尝试更新的数据。无论原因是什么,您都需要决定在发生严重问题时应用程序应该做什么。

在测试环境中模拟网络问题可能是具有挑战性的。如果您编写的代码使服务器进入某种错误状态,这可能会对其他测试或连接到服务器的用户造成问题。

您如何为网络故障情况创建自动化测试?

解决方案

对于这个配方,我们将使用 Cypress。我们在第一章中提到了 Cypress 测试系统。这是一个真正卓越的测试系统,在许多开发项目中迅速成为我们的首选工具。

要将 Cypress 安装到您的项目中,请输入以下命令:

$ npm install --save-dev cypress

Cypress 通过自动化 Web 浏览器来工作。从这个意义上说,它类似于其他系统如 Selenium。不过,Cypress 的区别在于它不需要你安装单独的驱动程序,它可以远程控制浏览器,并将自己注入到浏览器的 JavaScript 引擎中。

因此,Cypress 可以积极地用虚拟版本替换 JavaScript 基础设施的核心部分,以便控制它们。例如,Cypress 可以替换用于向服务器发出网络调用的 JavaScript fetch 函数。^(5) 因此,Cypress 测试可以伪造网络服务器的行为,并允许客户端开发人员从服务器上人工制作响应。

我们将使用本章其他配方中使用的示例游戏应用程序。每当用户回答问题时,我们将添加一个网络调用来存储结果。在 Cypress 中,我们可以通过伪造响应而无需创建实际的服务器代码来实现这一点。

为了展示其工作原理,我们首先将创建一个模拟服务器正确响应的测试。然后我们将创建一个模拟服务器失败的测试。

安装 Cypress 后,在 cypress/integration/ 目录下创建一个名为 0001-basic-game-functions.js 的文件:^(6)

describe('Basic game functions', () => {
  it('should notify the server if I lose', () => {
    // Given I started the application
    // When I enter an incorrect answer
    // Then the server will be told that I have lost
  })
})

我们已为需要编写的每个步骤放置了占位符注释。

Cypress 中的每个命令和断言都以 cy 开头。如果我们想在浏览器中打开位置为 http://localhost:3000 的页面,可以使用以下命令:

describe('Basic game functions', () => {
  it('should notify the server if I lose', () => {
    // Given I started the application
    cy.visit('http://localhost:3000')

    // When I enter an incorrect answer
    // Then the server will be told that I have lost
  })
})

要运行测试,我们可以输入:

$ npx cypress run

那个命令将运行所有测试而不显示浏览器。^(7) 我们还可以输入以下内容:

$ npx cypress open

此命令将打开 Cypress 应用程序窗口(如您在 图 8-6 中所见)。如果我们双击测试文件,测试将在浏览器中打开(如您在 图 8-7 中所见)。

图 8-6. 当您输入**npx cypress open**时,测试将显示在 Cypress 窗口中。

图 8-7. 在浏览器中运行的 Cypress 测试

示例应用程序要求用户计算两个随机数的乘积(参见 图 8-8)。这些数字的范围是 1–10,因此,如果我们输入 101,我们可以确定答案是错误的。

Cypress 不允许直接从屏幕捕获文本内容。因此,我们无法简单地读取两个数字的值并将其存储在变量中,因为 Cypress 命令不会立即在浏览器中执行操作。相反,当您运行命令时,Cypress 将其添加到一系列指令中,并在测试结束时执行它们。这种方法可能有些奇怪,但这些可链式操作的指令允许 Cypress 处理大多数由异步接口引起的问题。^(8) 不利之处在于,没有命令能够返回页面内容,因为命令运行时页面尚不存在。

我们将在本章其他地方看到如何在测试场景中去除随机性,并使此测试具有确定性,这将消除从页面获取数据的需求。

图 8-8. 应用程序要求用户计算两个随机数的乘积

我们可以使用 cy.get 命令通过 CSS 选择器找到输入字段。我们还可以使用 cy.contains 命令找到提交按钮:

describe('Basic game functions', () => {
  it('should notify the server if I lose', () => {
    // Given I started the application
    cy.visit('http://localhost:3000')

    // When I enter an incorrect answer
    cy.get('input').type('101')
    cy.contains('Submit').click()

    // Then the server will be told that I have lost
  })
})

现在我们只需测试应用程序是否将游戏结果传递给服务器。

我们将使用 cy.intercept() 命令来做到这一点。cy.intercept() 命令将改变应用程序中网络请求的行为,以便我们可以为特定请求伪造响应。如果结果将被 POST 到端点 /api/result,我们可以生成如下的伪造响应:

cy.intercept('POST', '/api/result', {
  statusCode: 200,
  body: '',
})

一旦这个命令生效,对 /api/result 的网络请求将收到伪造的响应。这意味着我们需要在进行网络请求之前运行该命令。我们将在测试开始时执行它:

describe('Basic game functions', () => {
  it('should notify the server if I lose', () => {
    // Given I started the application
    cy.intercept('POST', '/api/result', {
      statusCode: 200,
      body: '',
    })
    cy.visit('http://localhost:3000')

    // When I enter an incorrect answer
    cy.get('input').type('101')
    cy.contains('Submit').click()

    // Then the server will be told that I have lost
  })
})

现在我们已经指定了网络响应。但是如何断言应用程序已经进行了网络调用,并且如何知道它已经将正确的数据发送到了 /api/result 端点呢?

我们需要为网络请求指定一个 别名。这样可以让我们稍后在测试中引用该请求:^(9)

cy.intercept('POST', '/api/result', {
  statusCode: 200,
  body: '',
}).as('postResult')

然后我们可以在测试结束时进行断言,等待网络调用完成,并检查发送到请求体中的数据内容:

describe('Basic game functions', () => {
  it('should notify the server if I lose', () => {
    // Given I started the application
    cy.intercept('POST', '/api/result', {
      statusCode: 200,
      body: '',
    }).as('postResult')
    cy.visit('http://localhost:3000')

    // When I enter an incorrect answer
    cy.get('input').type('101')
    cy.contains('Submit').click()

    // Then the server will be told that I have lost
    cy.wait('@postResult').then((xhr) => {
      expect(xhr.request.body.guess).equal(101)
      expect(xhr.request.body.result).equal('LOSE')
    })
  })
})

此断言检查请求体的两个属性是否具有预期值。

如果我们现在运行测试,它将通过(如您在 图 8-9 中所见)。

图 8-9. 完成的测试通过

现在我们已经为成功情况创建了一个测试,我们可以为失败情况编写测试。如果网络调用失败,应用程序应在屏幕上显示一条消息。在这个测试中,我们实际上并不关心发送到服务器的详细信息,但我们仍然需要等待网络请求完成,然后检查错误消息的存在:

it('should display a message if I cannot post the result', () => {
  // Given I started the application
  cy.intercept('POST', '/api/result', {
    statusCode: 500,
    body: { message: 'Bad thing happened!' },
  }).as('postResult')
  cy.visit('http://localhost:3000')

  // When I enter an answer
  cy.get('input').type('16')
  cy.contains('We are unable to save the result').should('not.exist')
  cy.contains('Submit').click()

  // Then I will see an error message
  cy.wait('@postResult')
  cy.contains('We are unable to save the result')
})

注意,在进行网络调用之前,我们检查错误消息 不存在,以确保网络调用 导致 错误。

除了生成存根响应和状态码外,cy.intercept 还可以执行其他技巧,如减慢响应时间、限制网络速度或从测试函数生成响应。更多详细信息,请参阅 cy.intercept 文档

讨论

Cypress 测试可以改变开发团队的工作方式,特别是在模拟网络调用方面。团队通常以不同的节奏开发 API 和前端代码。此外,一些团队有专门从事前端或服务器代码的开发人员。Cypress 在这些情况下很有帮助,因为它允许前端开发人员编写针对尚不存在的端点的代码。Cypress 还可以模拟所有病理性失败情况。

网络性能可能会引入间歇性的错误。开发环境使用本地服务器,数据很少或没有,这意味着开发时的 API 性能比生产环境好得多。编写假设数据立即可用的代码很简单,但是在生产环境中,数据可能需要一两秒才能到达,这种代码就会出问题。

因此,值得至少为每个 API 调用运行一次测试,其中响应时间延迟约一秒钟:

cy.intercept('GET', '/api/widgets', {
  statusCode: 200,
  body: [{ id: 1, name: 'Flange' }],
  delay: 1000,
}).as('getWidgets')

模拟缓慢的网络响应通常会暴露出许多可能会潜入代码中的异步错误。

几乎同样重要的是,创建人工缓慢的网络响应将让您了解每个 API 调用对性能的总体影响。

您可以从GitHub 站点下载此配方的源代码。

使用 Cypress 进行离线测试

问题

此配方使用了由Etienne Bruines发明的自定义 Cypress 命令。

应用程序需要处理与网络断开连接的情况。我们在其他地方已经看到如何创建钩子以检测当前是否离线。^(10) 但是我们如何测试离线行为呢?

解决方案

我们可以使用 Cypress 模拟离线工作。Cypress 测试可以注入修改浏览器测试的内部行为的代码。因此,我们应该能够修改网络代码以模拟离线条件。

对于此配方,您需要在应用程序中安装 Cypress。如果尚未安装 Cypress,则可以在应用程序目录中运行以下命令进行安装:

$ npm install --save-dev cypress

您可以然后在 cypress/integration 目录中添加一个 0002-offline-working.js 文件:

describe('Offline working', () => {
  it(
    'should tell us when we are offline',
    { browser: '!firefox' },
    () => {
      // Given we have started the application
      // When the application is offline
      // Then we will see a warning
      // When the application is back online
      // Then we will not see a warning
    }
  )
})

我们将在 Firefox 上忽略此测试。离线模拟代码依赖于 Chrome DevTools 远程调试协议,而该协议目前在 Firefox 浏览器中不可用。

我们已经将测试结构标记为一系列注释。所有 Cypress 命令都以 cy 开头,因此我们可以像这样打开应用程序:

describe('Offline working', () => {
  it(
    'should tell us when we are offline',
    { browser: '!firefox' },
    () => {
      // Given we have started the application
      cy.visit('http://localhost:3000')

      // When the application is offline
      // Then we will see a warning
      // When the application is back online
      // Then we will not see a warning
    }
  )
})

如何强制浏览器模拟离线工作呢?

我们可以这样做,因为 Cypress 设计为可扩展的。我们可以添加一个自定义 Cypress 命令,允许我们切换到离线状态然后再切换回在线状态:

cy.network({ offline: true })
cy.network({ offline: false })

要添加自定义命令,请打开 cypress/support/commands.js 文件,并添加以下代码:

Cypress.Commands.add('network', (options = {}) => {
  Cypress.automation('remote:debugger:protocol', {
    command: 'Network.enable',
  })

  Cypress.automation('remote:debugger:protocol', {
    command: 'Network.emulateNetworkConditions',
    params: {
      offline: options.offline,
      latency: 0,
      downloadThroughput: 0,
      uploadThroughput: 0,
      connectionType: 'none',
    },
  })
})

此命令使用 DevTools 中的远程调试协议来模拟离线网络条件。保存此文件后,您可以实现其余的测试:

describe('Offline working', () => {
  it(
    'should tell us when we are offline',
    { browser: '!firefox' },
    () => {
      // Given we have started the application
      cy.visit('http://localhost:3000')
      cy.contains(/you are currently offline/i).should('not.exist')

      // When the application is offline
      cy.network({ offline: true })

      // Then we will see a warning
      cy.contains(/you are currently offline/i).should('be.visible')

      // When the application is back online
      cy.network({ offline: false })

      // Then we will not see a warning
      cy.contains(/you are currently offline/i).should('not.exist')
    }
  )
})

如果现在在 Electron 中运行测试,它将通过(见 Figure 8-10)。

图 8-10. 您可以通过单击左侧面板查看在线/离线测试的每个阶段

讨论

应该可以创建类似的命令来模拟各种网络条件和速度。

有关网络命令工作原理的更多信息,请参阅Cypress.io 的此篇博文

您可以从GitHub 站点下载此配方的源代码。

使用 Selenium 在浏览器中进行测试

问题

没有什么比在真实浏览器中运行代码更好,编写自动化基于浏览器的测试的最常见方法是使用Web 驱动程序。你可以通过向网络端口发送命令来控制大多数浏览器。不同的浏览器有不同的命令,而 Web 驱动程序是一个命令行工具,它简化了控制浏览器的过程。

但是,我们如何为使用 Web 驱动程序的 React 应用程序编写测试呢?

解决方案

我们将使用 Selenium 库。Selenium 是一个为各种 Web 驱动程序提供一致 API 的框架,这意味着你可以为 Firefox 编写测试,同样的代码应该在 Chrome、Safari 和 Edge 中同样有效。^(11)

我们将使用本章中的所有示例应用程序相同的示例应用程序。这是一个询问用户简单乘法问题答案的游戏。

Selenium 库支持多种不同的编程语言,如 Python、Java 和 C#。我们将使用 JavaScript 版本:Selenium WebDriver。

我们将从安装 Selenium 开始:

$ npm install --save-dev selenium-webdriver

我们还需要安装至少一个 Web 驱动程序。你可以全局安装 Web 驱动程序,但更可管理的方式是将它们安装在你的应用程序中。我们可以为 Firefox 安装像geckodriver这样的驱动程序,但现在我们将为 Chrome 安装chromedriver

$ npm install --save-dev chromedriver

我们现在可以开始创建一个测试。将 Selenium 测试包含在应用程序的src文件夹内是很有用的,因为这样可以更轻松地使用 IDE 手动运行测试。因此,我们将创建一个名为src/selenium的文件夹,并在其中添加一个名为0001-basic-game-functions.spec.js的文件:^(12)

describe('Basic game functions', () => {
  it('should tell me if I won', () => {
    // Given I have started the application
    // When I enter the correct answer
    // Then I will be told that I have won
  })
})

我们在注释中概述了测试。

尽管将 Selenium 测试包含在src树中很方便,但这可能导致像 Jest 这样的工具将其视为单元测试来运行,如果你在后台持续运行单元测试,这就是个问题。例如,如果你使用create-react-app创建应用程序并保持npm run test命令运行,你会发现每次保存 Selenium 测试时浏览器突然出现在屏幕上。为了避免这种情况,请采用某种命名约定来区分 Selenium 和单元测试。如果你将所有的 Selenium 测试命名为**.spec.js,你可以通过修改测试脚本来避免它们,设置为react-scripts test ‘..test.js’

Selenium 使用 Web 驱动程序来自动化 Web 浏览器。我们可以在每个测试开始时创建一个驱动程序的实例:

import { Builder } from 'selenium-webdriver'
let driver

describe('Basic game functions', () => {
  beforeEach(() => {
    driver = new Builder().forBrowser('chrome').build()
  })

  afterEach(() => {
    driver.quit()
  })

  it('should tell me if I won', () => {
    // Given I have started the application
    // When I enter the correct answer
    // Then I will be told that I have won
  })
})

在这个示例中,我们正在创建一个 Chrome 驱动程序。

通过为每个测试创建一个驱动程序,我们还将为每个测试创建一个全新的浏览器实例,确保没有浏览器状态在测试之间传递。 如果我们在测试之间不保留状态,那么我们可以以任何顺序运行测试。 我们在共享服务器状态上没有这样的保证。 例如,如果您的测试依赖于数据库数据,您应确保每个测试在启动时正确初始化服务器。

要让 Selenium 创建驱动程序的实例,我们还应该明确require驱动程序:

import { Builder } from 'selenium-webdriver'
require('chromedriver')

let driver

describe('Basic game functions', () => {
  beforeEach(() => {
    driver = new Builder().forBrowser('chrome').build()
  })

  afterEach(() => {
    driver.quit()
  })

  it('should tell me if I won', () => {
    // Given I have started the application
    // When I enter the correct answer
    // Then I will be told that I have won
  })
})

现在我们可以开始填写测试。 Selenium 的 JavaScript 版本是高度异步的。 几乎所有命令都返回承诺,这意味着它非常高效,但也很容易引入测试错误。

让我们通过打开应用程序来开始我们的测试:

import { Builder } from 'selenium-webdriver'
require('chromedriver')

let driver

describe('Basic game functions', async () => {
  beforeEach(() => {
    driver = new Builder().forBrowser('chrome').build()
  })

  afterEach(() => {
    driver.quit()
  })

  it('should tell me if I won', () => {
    // Given I have started the application
    await driver.get('http://localhost:3000')
    // When I enter the correct answer
    // Then I will be told that I have won
  }, 60000)
})

driver.get命令告诉浏览器打开给定的 URL。为了使其工作,我们还必须进行其他两个更改。首先,我们必须使用async标记测试函数,这将允许我们await通过driver.get返回的承诺。

其次,我们向测试添加了一个 60,000 毫秒的超时值,覆盖了 Jest 测试的隐式五秒限制。如果您不增加默认超时时间,则会发现您的测试在浏览器启动之前失败。我们在这里将其设置为 60,000 毫秒,以确保测试在任何机器上都能正常工作。您应根据您的预期硬件调整此值。

要输入正确的值到游戏中,我们需要读取问题中显示的两个数字(如图 8-11 所示)。

图 8-11. 游戏要求用户计算一个随机乘积

我们可以使用名为findElement的命令找到页面上的两个数字以及inputsubmit按钮:

const number1 = await driver.findElement(By.css('.number1')).getText()
const number2 = await driver.findElement(By.css('.number2')).getText()
const input = await driver.findElement(By.css('input'))
const submit = await driver.findElement(
  By.xpath("//button[text()='Submit']")
)

如果您从页面中读取一组元素,并且不关心按严格顺序解析它们,您可以使用Promise.all函数将它们组合成一个单一的承诺,然后等待它们:

const [number1, number2, input, submit] = await Promise.all([
  driver.findElement(By.css('.number1')).getText(),
  driver.findElement(By.css('.number2')).getText(),
  driver.findElement(By.css('input')),
  driver.findElement(By.xpath("//button[text()='Submit']")),
])

在示例应用程序中,这种优化几乎不会节省时间,但如果页面以不确定的顺序渲染不同的组件,那么结合承诺可能会提高测试性能。

这意味着我们现在可以完成我们测试的下一部分:

import { Builder, By } from 'selenium-webdriver'
require('chromedriver')

let driver

describe('Basic game functions', async () => {
  beforeEach(() => {
    driver = new Builder().forBrowser('chrome').build()
  })

  afterEach(() => {
    driver.quit()
  })

  it('should tell me if I won', () => {
    // Given I have started the application
    await driver.get('http://localhost:3000')
    // When I enter the correct answer
    const [number1, number2, input, submit] = await Promise.all([
      driver.findElement(By.css('.number1')).getText(),
      driver.findElement(By.css('.number2')).getText(),
      driver.findElement(By.css('input')),
      driver.findElement(By.xpath("//button[text()='Submit']")),
    ])
    await input.sendKeys('' + number1 * number2)
    await submit.click()
    // Then I will be told that I have won
  }, 60000)
})

注意,我们没有将sendKeysclick返回的承诺合并,因为我们在意测试在提交之前将答案输入到输入字段中

最后,我们希望断言屏幕上出现一个你赢了!的消息(参见图 8-12)。

图 8-12. 应用程序告诉用户他们得到了正确的答案

现在我们可以这样写我们的断言:

const resultText = await driver
  .findElement(By.css('.Result'))
  .getText()
expect(resultText).toMatch(/won/i)

这段代码几乎肯定会成功,因为在用户提交答案后结果会迅速显示。React 应用程序通常会显示动态结果,特别是如果它们依赖于网络数据。如果我们修改应用程序代码以模拟结果出现前的两秒延迟^(13),我们的测试将产生以下错误:

no such element: Unable to locate element: {"method":"css selector",
 "selector":".Result"}
 (Session info: chrome=88.0.4324.192)
NoSuchElementError: no such element: Unable to locate element: {
 "method":"css selector","selector":".Result"}
 (Session info: chrome=88.0.4324.192)

我们可以通过等待元素出现在屏幕上,然后等待文本匹配预期结果来避免这个问题。我们可以使用driver.wait函数来完成这两件事情:

await driver.wait(until.elementLocated(By.css('.Result')))
const resultElement = driver.findElement(By.css('.Result'))
await driver.wait(until.elementTextMatches(resultElement, /won/i))

这给了我们测试的最终版本:

import { Builder, By } from 'selenium-webdriver'
require('chromedriver')

let driver

describe('Basic game functions', async () => {
  beforeEach(() => {
    driver = new Builder().forBrowser('chrome').build()
  })

  afterEach(() => {
    driver.quit()
  })

  it('should tell me if I won', () => {
    // Given I have started the application
    await driver.get('http://localhost:3000')
    // When I enter the correct answer
    const [number1, number2, input, submit] = await Promise.all([
      driver.findElement(By.css('.number1')).getText(),
      driver.findElement(By.css('.number2')).getText(),
      driver.findElement(By.css('input')),
      driver.findElement(By.xpath("//button[text()='Submit']")),
    ])
    await input.sendKeys('' + number1 * number2)
    await submit.click()
    // Then I will be told that I have won
    await driver.wait(until.elementLocated(By.css('.Result')))
    const resultElement = driver.findElement(By.css('.Result'))
    await driver.wait(until.elementTextMatches(resultElement, /won/i))
  }, 60000)
})

讨论

根据我们的经验,Web 驱动程序测试是 Web 应用程序的自动化测试中最流行的形式——流行,即经常使用的。它们不可避免地依赖于匹配的浏览器和 Web 驱动程序版本,并且因时序问题而偶尔会失败。这些时序问题通常在单页面应用程序中更为常见,因为它们可以异步更新内容。

尽管可以通过在代码中小心添加时序延迟和重试来避免这些问题,但这可能会使您的测试对环境变化敏感,例如在不同的测试服务器上运行应用程序。如果您经常遇到间歇性失败的问题,另一个选择是将更多的测试移至像 Cypress 这样的系统,它通常更 容忍 时序失败。

您可以从 GitHub 站点 下载此配方的源代码。

使用 ImageMagick 测试跨浏览器视觉

问题

应用程序在不同浏览器上查看时可能会有很大差异。甚至在同一浏览器但不同操作系统上查看时,应用程序的外观也可能不同。一个例子是 Chrome,在 Mac 上查看时往往隐藏滚动条,但在 Windows 上显示它们。值得庆幸的是,像 Internet Explorer 这样的旧浏览器终于在逐渐消失,但即使是现代浏览器也可能以微妙不同的方式应用 CSS,从而根本改变页面的外观。

在多种浏览器和平台上手动持续检查应用程序可能会耗费大量时间。

我们可以做什么来自动化这个兼容性过程?

解决方案

在这个配方中,我们将结合三种工具来检查不同浏览器和平台上的视觉一致性:

Storybook

这将为我们提供一个所有相关配置中所有组件的基本画廊,我们需要检查这些组件。

Selenium

这将允许我们捕获 Storybook 中所有组件的视觉外观。Selenium Grid 还将允许我们远程连接到不同操作系统上的浏览器,以进行操作系统之间的比较。

ImageMagick

具体来说,我们将使用 ImageMagick 的compare工具生成两个屏幕截图之间的视觉差异,并提供两幅图像有多远的数值度量。

我们将从安装 Storybook 开始。您可以使用以下命令在您的应用程序中执行此操作:

$ npx sb init

然后,您需要为您感兴趣跟踪的每个组件和配置创建stories。您可以从本书的其他章节或Storybook 教程中了解如何操作。

接下来,我们将需要 Selenium 来自动化截取屏幕截图。您可以使用以下命令安装 Selenium:

$ npm install --save-dev selenium-webdriver

您还需要安装相关的 Web 驱动程序。例如,要自动化 Firefox 和 Chrome,您需要以下内容:

$ npm install --save-dev geckodriver
$ npm install --save-dev chromedriver

最后,您需要安装 ImageMagick,一组命令行图像处理工具。有关如何安装 ImageMagick 的详细信息,请参阅ImageMagick 下载页面

我们将使用本章之前用过的同一个示例游戏应用程序。您可以在 Storybook 中查看应用程序中显示的组件,如图 8-13 所示。

图 8-13. Storybook 中显示的应用程序组件

您可以通过输入以下内容来运行您的应用程序上的 Storybook 服务器:

$ npm run storybook

接下来,我们将创建一个测试,这个测试将只是一个脚本,用于捕获 Storybook 中每个组件的屏幕截图。在名为src/selenium的文件夹中,创建一个名为shots.spec.js的脚本:^(14)

import { Builder, By, until } from 'selenium-webdriver'

require('chromedriver')
let fs = require('fs')

describe('shots', () => {
  it('should take screenshots of storybook components', async () => {
    const browserEnv = process.env.SELENIUM_BROWSER || 'chrome'
    const url = process.env.START_URL || 'http://localhost:6006'
    const driver = new Builder().forBrowser('chrome').build()
    driver.manage().window().setRect({
      width: 1200,
      height: 900,
      x: 0,
      y: 0,
    })

    const outputDir = './screenshots/' + browserEnv
    fs.mkdirSync(outputDir, { recursive: true })

    await driver.get(url)

    await driver.wait(
      until.elementLocated(By.className('sidebar-item')),
      60000
    )
    let elements = await driver.findElements(
      By.css('button.sidebar-item')
    )
    for (let e of elements) {
      const expanded = await e.getAttribute('aria-expanded')
      if (expanded !== 'true') {
        await e.click()
      }
    }
    let links = await driver.findElements(By.css('a.sidebar-item'))
    for (let link of links) {
      await link.click()
      const s = await link.getAttribute('id')
      let encodedString = await driver
        .findElement(By.css('#storybook-preview-wrapper'))
        .takeScreenshot()
      await fs.writeFileSync(
        `${outputDir}/${s}.png`,
        encodedString,
        'base64'
      )
    }

    driver.quit()
  }, 60000)
})

该脚本打开一个浏览器到 Storybook 服务器,打开每个组件,并对每个 story 进行截图,然后将其存储在screenshots子目录中。

我们可以使用不同的测试系统来截取每个组件的屏幕截图,比如 Cypress。使用 Selenium 的优势在于我们可以在远程机器上打开浏览器会话。

默认情况下,shots.spec.js测试将使用 Chrome 浏览器在http://localhost:6006地址上的 Storybook 进行屏幕截图。假设我们在 Mac 上运行shots测试。如果我们有一个在同一网络上的 Windows 机器,我们可以安装 Selenium Grid 服务器,这是一个代理服务器,允许远程机器启动 Web 驱动程序会话。

如果 Windows 机器的地址是192.168.1.16,我们可以在运行shots.spec.js测试之前在命令行中设置这个环境变量:

$ export SELENIUM_REMOTE_URL=http://192.168.1.16:4444/wd/hub

因为 Windows 机器将访问位于 Mac 上的 Storybook 服务器,例如,其 IP 地址为192.168.1.14,我们还需要在命令行中为其设置一个环境变量:

$ export START_URL=http://192.168.1.14:6006

我们还可以选择 Windows 机器要使用的浏览器:^(15)

$ export SELENIUM_BROWSER=firefox

如果我们创建一个脚本在package.json中运行shots.spec.js

 "scripts": {
  ...
  "testShots": "CI=true react-scripts test --detectOpenHandles \
 'selenium/shots.spec.js'"
  }

我们可以运行测试并捕获每个组件的屏幕截图:

$ npm run testShots

测试将使用我们创建的环境变量联系远程机器上的 Selenium Grid 服务器。它将要求 Selenium Grid 打开一个 Firefox 浏览器到我们本地的 Storybook 服务器。然后它将通过网络发送每个组件的屏幕截图,在测试中将它们存储在名为 screenshots/firefox 的文件夹中。

运行完 Firefox 后,我们可以继续运行 Chrome:

$ export SELENIUM_BROWSER=chrome
$ npm run testShots

测试将把 Chrome 的屏幕截图写入 screenshots/chrome 文件夹。

这种技术的更全面实现还会记录操作系统和客户端类型(例如,屏幕大小)。

现在我们需要检查 Chrome 和 Firefox 屏幕截图之间的视觉差异,这就是 ImageMagick 发挥作用的地方。ImageMagick 中的 compare 命令可以生成一个突出显示两个其他图像之间视觉差异的图像。例如,考虑 图 8-14 中的 Firefox 和 Chrome 两个屏幕截图。

图 8-14. Chrome 和 Firefox 中的同一组件

这两个图像看起来是相同的。如果我们从应用程序目录中输入以下命令:

$ compare -fuzz 15% screenshots/firefox/question--basic.png \
 screenshots/chrome/question--basic.png difference.png

我们将生成一个新的图像,显示两个屏幕截图之间的差异,你可以在 图 8-15 中看到。

图 8-15. 显示两个屏幕截图差异的生成图像

生成的图像显示两个图像之间超过 15% 的视觉差异的像素。你可以看到这些屏幕截图几乎是相同的。

这很好,但仍需要一个人看图片并评估差异是否显著。我们还能做些什么?

compare 命令还能显示两个图像之间差异的数值度量:

$ compare -metric AE -fuzz 15% screenshots/firefox/question--basic.png
 screenshots/chrome/question--basic.png difference.png
6774

6774 是两个图像视觉差异的数值度量(基于绝对误差计数,或 AE)。举例来说,考虑 图 8-16 中展示的当 Answer 组件被赋予 disabled 属性时的两个屏幕截图。

图 8-16. Chrome 和 Firefox 渲染的禁用表单

比较这两个图像会返回一个更大的数值:

$ compare -metric AE -fuzz 15% screenshots/firefox/answer--with-disabled.png
 screenshots/chrome/answer--with-disabled.png difference3.png
28713

实际上,生成的图像(见 图 8-17)清楚地显示了差异所在:禁用的输入字段。

图 8-17. Chrome 和 Firefox 表单之间的视觉差异

图 8-18 展示了在两个浏览器之间展示不同字体样式的组件的类似显著差异(21,131),这是由一些特定于 Mozilla 的 CSS 属性引起的。

图 8-18. Chrome 和 Firefox 中文本样式不同的组件

实际上,可以编写一个 shell 脚本,通过每个图像并生成一个小的网页报告,显示其视觉差异和相关指标:

#!/bin/bash
mkdir -p screenshots/diff
export HTML=screenshots/compare.html
echo '<body><ul>' > $HTML
for file in screenshots/chrome/*.png
do
 FROM=$file
 TO=$(echo $file | sed 's/chrome/firefox/')
 DIFF=$(echo $file | sed 's/chrome/diff/')
 echo "FROM $FROM TO $TO"
 ls -l $FROM
 ls -l $TO
 METRIC=$(compare -metric AE -fuzz 15% $FROM $TO $DIFF 2>&1)
 echo "<li>$FROM $METRIC<br/><img src=../$DIFF/></li>" >> $HTML
done
echo "</li></body>" >> $HTML

此脚本创建了screenshots/compare.html报告,可以在图 8-19 中看到。

图 8-19. 生成的比较报告示例

讨论

为了节省空间,我们仅展示了这种技术的简单实现。可以创建一个排名报告,按从大到小的顺序显示视觉差异。这样的报告将突出显示不同平台之间最显著的视觉差异。

您还可以使用自动化视觉测试来防止回归。您需要避免由于微小变化(如抗锯齿)引起的误报。持续集成作业可以设置一些图像之间的视觉阈值,并在任何组件超出该阈值时失败。

您可以从GitHub 站点下载此技巧的源代码。

给移动浏览器添加控制台

问题

这个技巧与本章其他技巧略有不同,因为它不是关于自动化测试,而是关于手动测试——特别是在移动设备上手动测试代码。

如果你在移动设备上测试应用程序,可能会遇到在桌面环境中不存在的错误。通常,如果出现错误,可以在 JavaScript 控制台中添加调试消息。但移动浏览器通常没有可见的 JavaScript 控制台。如果你使用的是移动版 Chrome,你可以尝试通过桌面版 Chrome 远程调试。但如果你在其他浏览器中发现问题怎么办?或者如果你根本不想设置远程调试会话怎么办?

是否有办法从移动浏览器内部访问 JavaScript 控制台和其他开发工具?

解决方案

我们将使用一个名为Eruda的软件。

Eruda 是一个轻量级的开发工具面板实现,允许您查看 JavaScript 控制台、页面结构以及一系列其他插件和扩展

要启用 Eruda,您需要在应用程序的head部分安装一小段相对基础的 JavaScript。您可以从内容分发网络下载 Eruda。但是,因为它可能相当大,应仅在浏览器的使用者表示希望访问时才启用它。

一种方法是仅当 URL 中出现eruda=true时才启用 Eruda。以下是可以插入到页面容器中的示例脚本:^(16)

<script>
    (function () {
        var src = '//cdn.jsdelivr.net/npm/eruda';
        if (!/eruda=true/.test(window.location)
            && localStorage.getItem('active-eruda') != 'true') return;
        document.write('<scr' + 'ipt src="' + src
            + '"></scr' + 'ipt>');
        document.write('<scr' + 'ipt>');
        document.write('window.addEventListener(' +
            '"load", ' +
            'function () {' +
            '  var container=document.createElement("div"); ' +
            '  document.body.appendChild(container);' +
            '  eruda.init({' +
            '    container: container,' +
            '    tool: ["console", "elements"]' +
            '  });' +
            '})');
        document.write('</scr' + 'ipt>');
    })();
</script>

如果你现在将你的应用程序打开为 http://ipaddress/?eruda=truehttp://ipaddress/#eruda=true,你会注意到界面上出现了一个额外的按钮,如图 8-20 所示。

图 8-20. 如果在 URL 中添加 ?eruda=true,页面右侧将会出现一个按钮。

如果你在本章中使用示例应用程序,请尝试输入一些游戏答案。^(17) 然后点击 Eruda 按钮。控制台将如图 8-21 所示出现。

图 8-21. 单击按钮打开 Eruda 工具

由于示例应用程序调用的端点缺失,你应该在控制台中找到一些错误和其他记录。控制台还支持很少使用的 console.table 函数,这是一种有用的以表格形式显示对象数组的方式。

元素标签提供了对 DOM 的相当基本的视图(见图 8-22)。

图 8-22. Eruda 元素视图

同时,设置选项卡具有广泛的 JavaScript 功能集,您可以在与网页交互时启用和禁用它们(见图 8-23)。

图 8-23. Eruda 设置视图

讨论

Eruda 是一个令人愉快的工具,提供了一整套功能,开发者几乎不需要做任何工作。除了基本功能外,它还有插件,允许您跟踪性能、设置屏幕刷新率、生成虚假地理位置,甚至在浏览器内部编写和运行 JavaScript。一旦开始使用,您可能会发现它很快成为您手动测试过程的标准部分。

你可以从 GitHub 网站 下载这个示例的源代码。

从测试中去除随机性

问题

在理想的情况下,测试应该始终处于完全人工的环境中。测试是你希望应用程序在明确定义的条件下运行的示例。但是测试通常必须应对不确定性。例如,它们可能在不同的时间运行。本章中我们一直使用的示例应用程序需要处理随机性

我们的示例应用程序是一个游戏,向用户展示一个随机生成的问题,用户必须回答(见图 8-24)。

图 8-24. 游戏要求用户计算一个随机生成的乘法问题

随机性还可能出现在代码内部标识符或随机数据集的生成中。如果你要求一个新的用户名,你的应用程序可能会建议一个随机生成的字符串。

但是随机性为测试带来了问题。这是我们在本章前面实施的一个示例测试:

describe('Basic game functions', () => {
  it('should notify the server if I lose', () => {
    // Given I started the application
    // When I enter an incorrect answer
    // Then the server will be told that I have lost
  })
})

其实有一个很好的理由,为什么那个测试看起来关注用户输入不正确的答案。问题始终要求计算 1 到 10 之间两个数的乘积。因此很容易想到一个不正确的答案:101。它将始终是错误的。但是,如果我们想编写一个测试来展示当用户输入正确答案时会发生什么,我们就会遇到问题。正确的答案取决于随机生成的数据。我们可以编写一些代码来找到屏幕上出现的两个数字,就像本章第一个 Selenium 配方中的示例一样:

const [number1, number2, input, submit] = await Promise.all([
  driver.findElement(By.css('.number1')).getText(),
  driver.findElement(By.css('.number2')).getText(),
  driver.findElement(By.css('input')),
  driver.findElement(By.xpath("//button[text()='Submit']")),
])
await input.sendKeys('' + number1 * number2)
await submit.click()

有时这种方法甚至是不可能的。例如,Cypress 不允许您从页面上捕获数据。如果我们想编写一个 Cypress 测试来输入乘法问题的正确答案,我们将会遇到很大困难。这是因为 Cypress 不允许您从页面上捕获值并将它们传递给测试中的其他步骤。

在测试期间关闭随机性会更好。

但是我们可以吗?

解决方案

我们将看看如何使用 Sinon 库来临时替换 Math.random 函数,以制作一个自己制作的虚假函数。

让我们首先考虑如何在单元测试中做到这一点。我们将为顶级 App 组件创建一个新的测试,检查输入正确值是否会显示我们赢了的消息。

我们将创建一个函数,用于修复 Math.random 的返回值:

const sinon = require('sinon')

function makeRandomAlways(result) {
  if (Math.random.restore) {
    Math.random.restore()
  }
  sinon.stub(Math, 'random').returns(result)
}

该函数通过替换 Math 对象的 random 方法为一个始终返回相同值的存根方法来工作。现在我们可以在测试中使用它。页面上显示的 Question 总是基于以下值生成 1 到 10 之间的随机数:

Math.random() * 10 + 1

如果我们修复 Math.random,让它始终生成值为 0.5,那么“随机”数将始终是 6。这意味着我们可以编写一个单元测试如下:

it('should tell you that you entered the right answer', async () => {
  // Given we've rendered the app
  makeRandomAlways(0.5)
  render(<App />)

  // When we enter the correct answer
  const input = screen.getByLabelText(/guess:/i)
  const submitButton = screen.getByText('Submit')
  user.type(input, '36')
  user.click(submitButton)

  // Then we are told that we've won
  await waitFor(() => screen.findByText(/won/i), { timeout: 4000 })
})

并且这个测试将始终通过,因为应用程序将始终询问,“6 × 6 是多少?”

修复 Math.random 的真正价值在于我们使用显式阻止我们捕获像 Cypress 这样的随机生成值的测试框架,正如我们之前看到的。

Cypress 允许我们通过将它们添加到 cypress/support/commands.js 脚本中来添加自定义命令。如果你编辑该文件并添加以下代码:

Cypress.Commands.add('random', (result) => {
  cy.reload().then((win) => {
    if (win.Math.random.restore) {
      win.Math.random.restore()
    }
    sinon.stub(win.Math, 'random').returns(result)
  })
})

您将创建一个名为 cy.random 的新命令。我们可以使用这个命令来创建一个测试,展示我们在介绍中讨论的获胜情况:^(18)

describe('Basic game functions', () => {
  it('should notify the server if I win', () => {
    // Given I started the application
    cy.intercept('POST', '/api/result', {
      statusCode: 200,
      body: '',
    }).as('postResult')
    cy.visit('http://localhost:3000')
    cy.random(0.5)
    cy.contains('Refresh').click()

    // When I enter the correct answer
    cy.get('input').type('36')
    cy.contains('Submit').click()

    // Then the server will be told that I have won
    cy.wait('@postResult').then((xhr) => {
      assert.deepEqual(xhr.request.body, {
        guess: 36,
        answer: 36,
        result: 'WIN',
      })
    })
  })
})

在调用 cy.random 命令后,如果应用程序在替换 Math.random 函数之前生成了随机数,我们需要点击刷新按钮。

讨论

你永远无法从测试中完全消除所有的随机性。例如,机器的性能可以显著影响组件何时以及多频繁地重新渲染。但尽可能减少不确定性通常是测试中的一件好事。我们能够尽可能地从测试中去除外部依赖,这样做会更好。

接下来的示例中,我们还将探讨如何消除外部依赖。

你可以从GitHub 站点下载这个示例的源代码。

时间旅行

问题

时间可能是引发大量 bug 的根源。如果时间只是一个科学上的测量,那么情况就相对简单。但实际情况并非如此。时间的表示受到国界和当地法律的影响。一些国家有自己的时区,其他国家则有多个时区。一个让人放心的因素是,所有国家的时区偏移都是整小时,除了像印度这样的地方,那里的时间偏移量是从 UTC 开始算的+05:30。

这就是为什么在测试中尝试修复时间很有帮助。但我们该怎么做呢?

解决方案

我们将看看在测试 React 应用程序时如何修复时间。在测试依赖于时间的代码时,需要考虑一些问题。首先,你应该避免在服务器上改变时间。在大多数情况下,最好将服务器设置为 UTC 时间并保持不变。

这意味着,如果你想在浏览器中伪造日期和时间,当浏览器与服务器联系时就会出现问题。这意味着你必须修改服务器的 API 以接受有效日期或者在与服务器隔离时测试依赖于时间的浏览器代码。^(19)

对于这个示例,我们将采用后一种方法:使用 Cypress 测试系统来伪造与服务器的任何连接。

我们将使用与本章其他示例相同的应用程序。这是一个简单的游戏,要求用户计算两个数字的乘积。我们将测试游戏的一个功能,即用户有 30 秒的时间来提供答案。30 秒后,他们将看到一个消息,告诉他们时间已经用完(见图 8-25)。

图 8-25. 如果玩家在 30 秒内没有答复,将会输掉比赛

我们可以尝试编写一个测试,让其暂停 30 秒,但这有两个问题。首先,它会减慢你的测试速度。在测试中加入多个 30 秒的暂停会让测试变得难以忍受。其次,添加暂停并不是测试功能的一种精确方法。如果你试图暂停 30 秒,可能会暂停 30.5 秒然后再查看消息。

要获得精确性,我们需要控制浏览器内的时间。如前一篇章中所示,Cypress 可以将代码注入浏览器,用存根函数替换关键代码片段,以便我们控制。Cypress 内置了一个名为 cy.clock 的命令,允许我们指定当前时间。

让我们看看如何通过创建一个超时功能的测试来使用 cy.clock。这将是我们测试的结构:

describe('Basic game functions', () => {
  it('should say if I timed out', () => {
    // Given I have started a new game
    // When 29 seconds have passed
    // Then I will not see the time-out message
    // When another second has passed
    // Then I will see the time-out message
    // And the game will be over
  })
})

我们可以从打开应用并点击刷新按钮开始:

describe('Basic game functions', () => {
  it('should say if I timed out', () => {
    // Given I have started a new game
    cy.visit('http://localhost:3000')
    cy.contains('Refresh').click()

    // When 29 seconds have passed
    // Then I will not see the time-out message
    // When another second has passed
    // Then I will see the time-out message
    // And the game will be over
  })
})

现在我们需要模拟经过的 29 秒时间。我们可以使用 cy.clockcy.tick 命令来实现这一点。cy.clock 命令允许你指定一个新的日期和时间;或者,如果你不带参数调用 cy.clock,它将把时间和日期设置回 1970 年。cy.tick() 命令允许你向当前日期和时间添加一定数量的毫秒:

describe('Basic game functions', () => {
  it('should say if I timed out', () => {
    // Given I have started a new game
    cy.clock()
    cy.visit('http://localhost:3000')
    cy.contains('Refresh').click()

    // When 29 seconds have passed
    cy.tick(29000)

    // Then I will not see the time-out message
    // When another second has passed
    // Then I will see the time-out message
    // And the game will be over
  })
})

现在我们可以完成测试中的其他步骤。关于我们使用的其他 Cypress 命令的详细信息,请参阅 Cypress 文档

describe('Basic game functions', () => {
  it('should say if I timed out', () => {
    // Given I have started a new game
    cy.clock()
    cy.visit('http://localhost:3000')
    cy.contains('Refresh').click()

    // When 29 seconds have passed
    cy.tick(29000)

    // Then I will not see the time-out message
    cy.contains(/out of time/i).should('not.exist')

    // When another second has passed
    cy.tick(1000)

    // Then I will see the time-out message
    cy.contains(/out of time/i).should('be.visible')

    // And the game will be over
    cy.get('input').should('be.disabled')
    cy.contains('Submit').should('be.disabled')
  })
})

如果我们在 Cypress 中运行测试,它会通过(如你可以在 图 8-26 中看到)。

图 8-26。通过控制时间,我们可以强制测试中的超时发生

这是一个相对简单的基于时间的测试。但如果我们想测试更复杂的东西,比如夏令时(DST),怎么办呢?

DST(夏令时)的错误是大多数开发团队的梦魇。它们会悄无声息地停留在你的代码库中数月,然后突然在春秋交替之际,在清晨的早些时候出现。

DST 发生的时间取决于你所在的时区。对客户端代码来说,这是一个特别棘手的问题,因为 JavaScript 的日期处理不了时区。它们当然可以处理偏移量;例如,你可以在像 Chrome 这样的浏览器中创建一个设置为格林威治标准时间之前五小时的 Date 对象:^(20)

new Date('2021-03-14 01:59:30 GMT-0500')

但 JavaScript 的日期都隐式地使用浏览器的时区。当你创建一个带有时区名称的日期时,JavaScript 引擎会将其简单地转换为浏览器的时区。

浏览器的时区在打开浏览器时就已固定。没有办法说“让我们从现在开始假装我们在纽约”。

如果开发人员为 DST 创建测试,这些测试可能仅在开发者的时区下工作。如果在设置为 UTC 的集成服务器上运行,则可能导致测试失败。

然而,有一个解决这个问题的方法。在 Linux 和 Mac 计算机上(但不适用于 Windows),你可以在启动浏览器时通过设置名为 TZ 的环境变量来指定时区。如果我们启动 Cypress 时设置了 TZ 变量,Cypress 启动的任何浏览器都将继承它,这意味着虽然我们不能为单个测试设置时区,但可以为整个测试运行设置时区。

首先,让我们使用设置为纽约时区的 Cypress 启动:

$ TZ='America/New_York' npx cypress open

示例应用程序有一个按钮,允许您查看当前时间(参见图 8-27)。

图 8-27. 屏幕上显示当前时间

我们可以创建一个测试,检查页面上的时间是否正确处理了夏令时的更改。这是我们将创建的测试:

describe('Timing', () => {
  it('should tell us the current time', () => {
    cy.clock(new Date('2021-03-14 01:59:30').getTime())
    cy.visit('http://localhost:3000')
    cy.contains('Show time').click()
    cy.contains('2021-03-14T01:59:30.000').should('be.visible')
    cy.tick(30000)
    cy.contains('2021-03-14T03:00:00.000').should('be.visible')
  })
})

在这个测试中,我们向 cy.clock 传递了一个显式的日期。我们需要通过调用 getTime 将其转换为毫秒,因为 cy.clock 仅接受数值时间。然后我们检查初始时间,30 秒后,我们检查时间是否已经从上午 2 点转变为上午 3 点(如图 8-28 所示)。

图 8-28. 30 秒后,时间从 01:59 正确地变为 03:00

讨论

如果您需要创建依赖于当前时区的测试,请考虑将它们放入子文件夹中,以便单独运行。如果您想要将日期格式化为不同的时区,可以使用 toLocaleString 日期方法:

new Date().toLocaleString('en-US', { timeZone: 'Asia/Tokyo' })

您可以从GitHub 网站下载此示例的源代码。

^(1) 在本章的其他示例中,您将看到可以动态地从测试中去除随机性,并且在不捕获页面上的问题的情况下修复正确的答案。

^(2) 注意,许多测试使用正则表达式进行文本比较,这允许像这个示例一样进行不区分大小写的子字符串匹配。正则表达式可以有效地防止测试经常性地中断。

^(3) 查看GitHub 仓库中的源代码,了解我们如何在示例应用程序中组织代码。

^(4) 如果您尚未安装 Testing Library,请参阅“使用 React Testing Library”。

^(5) 直接或间接地通过 Axios 等库。

^(6) 无论你如何称呼这个文件,但我们遵循的惯例是在高级测试中加上故事编号。这样做可以减少测试合并冲突的可能性,并且更容易跟踪单个更改的意图。

^(7) 这将加快测试的运行速度,并为每个测试记录一个视频,这在您的测试运行在集成服务器上时非常有帮助。

^(8) Cypress 命令在许多方面类似于 promises,尽管它们不是 promises。您可以把每一个看作是一个“类 promise”。

^(9) cy.intercept 命令不能简单地返回对伪造网络请求的引用,因为 Cypress 命令具有链式特性。

^(10) 参见“监控在线状态”。

^(11) 这并不意味着测试将在每个浏览器上都能工作,只是它们将在每个浏览器上运行。

^(12) 我们遵循的约定是在测试前面加上其关联的故事编号。Selenium 不需要这样做。

^(13) 下载本章的源代码,您将找到执行此操作的代码,网址为GitHub

^(14) 您可以将此脚本放在任何地方,但这是我们在 GitHub 网站上示例代码中使用的位置。

^(15) 远程机器必须安装适当的浏览器和 Web 驱动程序才能使其正常工作。

^(16) 对于 create-react-app 应用程序,应将此添加到 public/index.html 文件中。

^(17) 本书的源代码存储库中提供了这段代码的内容,网址为source code repository

^(18) 您可以在“使用 Cypress 在没有服务器的情况下进行测试”中了解更多关于此测试的信息。

^(19) 也就是说,允许浏览器对服务器说“让我们假装是四月十四日星期四”。

^(20) 通常情况下,Firefox 不会接受这种格式。

第九章:可访问性

写这一章节很有挑战性,因为除了戴眼镜和隐形眼镜外,我们两个都不需要使用特殊的无障碍设备或软件。我们在本章中尝试汇集了一些工具和技术,希望能帮助您找到代码中一些更为明显的无障碍问题。

我们将讨论如何使用地标和 ARIA 角色,这将为您的页面增加意义和结构,否则这些只能通过视觉分组来实现。然后我们提供了几个操作步骤,展示如何对应用进行手动和自动化审核,通过静态分析查找代码中的问题,并通过自动化浏览器查找运行时错误。

然后我们将深入讨论创建自定义对话框涉及的一些更为技术性的问题(提示:尝试使用来自库的预构建对话框),最后,我们构建一个简单的屏幕阅读器。

欲深入了解无障碍性,请务必查看 Web Content Accessibility Guidelines (WCAG),提供了三个符合级别:A、AA 和 AAA。AAA 是最高的符合级别。

如果您正在编写专业软件,理想情况下,您会发现这些操作步骤很有帮助。但没有什么可以取代那些每天都要与不可访问软件问题共存的人的经验。无障碍软件就是好软件。它扩展了您的市场,并迫使您更深入地考虑设计问题。我们建议至少对您的代码运行一次可访问性审核。您可以联系类似 AbilityNet 这样的组织,或者无论您身在何处,只需搜索 无障碍软件测试,您就会发现这是追踪代码问题的最有效方式。

使用地标

问题

让我们考虑 图 9-1 中的应用程序。这是一个用于创建和管理任务的简单应用程序。

图 9-1. 任务 应用示例

如果有人能看到应用程序,他们将轻松区分主要内容(任务)和页面边缘的所有其他内容:链接到其他页面、标题、版权等等。

让我们看看此应用程序的主要 App 组件的代码:

const App = () => {
  ...
  return (
    <>
      <h1>Manage Tasks</h1>
      <a href='/contacts'>Contacts</a>&nbsp;|&nbsp;
      <a href='/events'>Events</a>&nbsp;|&nbsp;
      Tasks&nbsp;|&nbsp;
      <a href='/notes'>Notes</a>&nbsp;|&nbsp;
      <a href='/time'>TimeRec</a>&nbsp;|&nbsp;
      <a href='/diary'>Diary</a>&nbsp;|&nbsp;
      <a href='/expenses'>Expenses</a>&nbsp;|&nbsp;
      <a href='/invoices'>Invoices</a>
      <button className='addButton'
          onClick={() => setFormOpen(true)}>+</button>
      <TaskContexts .../>
      &#169;2029, Amalgamated Consultants Corp. All Rights Reserved.
      <TaskForm .../>
      <ModalQuestion ...>
        Are you sure you want to delete this task?
      </ModalQuestion>
    </>
  )
}

如果依赖设备朗读页面,理解页面结构可能会很困难。导航链接在哪里?页面的主要内容又在哪里?人眼进行的分析(见 图 9-2)如果无法评估界面的空间分组,则难以复制。

那么,我们该如何解决这个问题?我们可以用什么来替代视觉分组,使页面的结构更易于理解?

图 9-2. 视觉观众可以迅速识别页面的各个部分

解决方案

我们将在我们的代码中引入 地标。地标是我们可以使用的 HTML 元素,用于结构上地组织我们的界面,以反映它们在视觉上的组织方式。地标在设计页面时也很有帮助,因为它们迫使您考虑各种类型页面内容的功能。

让我们从 header 开始突出显示。页面的这一部分标识页面的主题。通常我们会使用 h1 标题来表示,但我们也可能包括常用工具,或者一个标志。我们可以使用 header 标签来标识页眉:

<header>
    <h1>Manage Tasks</h1>
</header>

我们页面上应该始终有一个 h1 标题,并且应该使用较低级别的标题来结构化页面的其余内容,而不跳过任何级别。例如,您不应该在两个 h1 标题之间有一个 h3 标题而没有 h2 标题。标题是屏幕阅读器用户的便捷导航设备,包括允许用户在标题之间向前跳转和向后跳转的功能。

接下来,我们需要考虑 导航。导航可以采用多种形式。它可能是一系列链接的列表(如此处),或者可能是一系列菜单或侧边栏。导航是一个允许您访问网站主要部分的组件块。您页面上几乎肯定会有其他不属于导航的链接。

我们可以使用 nav 地标来标识页面的导航:

<nav>
    <a href='/contacts'>Contacts</a>&nbsp;|&nbsp;
    <a href='/events'>Events</a>&nbsp;|&nbsp;
    Tasks&nbsp;|&nbsp;
    <a href='/notes'>Notes</a>&nbsp;|&nbsp;
    <a href='/time'>TimeRec</a>&nbsp;|&nbsp;
    <a href='/diary'>Diary</a>&nbsp;|&nbsp;
    <a href='/expenses'>Expenses</a>&nbsp;|&nbsp;
    <a href='/invoices'>Invoices</a>
</nav>

页面的关键部分是内容。在我们的任务应用程序中,内容就是任务的集合。主要内容是用户主要想要阅读和与页面交互的内容。偶尔,主要内容还可能包括工具,比如任务应用程序中的浮动“添加”按钮,但这些不一定要在主要内容中,并且我们可以将它们移动到页眉的某个位置。

我们可以使用 main 标签将页面的主要内容分组在一起:

<main>
    <button className='addButton'
            onClick={() => setFormOpen(true)}>+</button>
    <TaskContexts contexts={contexts}
                  tasks={tasks}
                  onDelete={setTaskToRemove}
                  onEdit={task => {
                      setEditTask(task)
                      setFormOpen(true)
                  }}
    />
</main>

最后,我们有网页的 元数据:关于数据的数据。在任务应用程序中,页面底部的版权声明就是元数据的一个例子。通常会将元数据放在页面底部的一个组中,因此它被分组在 footer 标签中:

<footer>
    &#169;2029, Amalgamated Consultants Corp. All Rights Reserved.
</footer>

还有几件事情留在我们原始的 App 组件中:

<TaskForm .../>
<ModalQuestion ...>
    Are you sure you want to delete this task?
</ModalQuestion>

TaskForm 是一个模态对话框,当用户想要创建或编辑任务时出现(见 Figure 9-3)。

Figure 9-3. TaskForm 是一个模态对话框,显示在其他内容上方。

ModalQuestion 是一个确认框,如果用户尝试删除任务时会出现(见 Figure 9-4)。

Figure 9-4. 模态问题框询问用户确认删除任务。

这两个组件只会在需要时显示。在页面处于正常状态时,模态框不会出现在页面结构中,因此不必包含在地标中。我们将在本章的其他地方看到处理动态内容(如模态框)的其他方法,这将使它们对您的受众更具可访问性。

这是我们的App组件的最终形式:

const App = () => {
  ....
  return (
    <>
      <header>
        <h1>Manage Tasks</h1>
      </header>
      <nav>
        <a href='/contacts'>Contacts</a>&nbsp;|&nbsp;
        <a href='/events'>Events</a>&nbsp;|&nbsp;
        Tasks&nbsp;|&nbsp;
        <a href='/notes'>Notes</a>&nbsp;|&nbsp;
        <a href='/time'>TimeRec</a>&nbsp;|&nbsp;
        <a href='/diary'>Diary</a>&nbsp;|&nbsp;
        <a href='/expenses'>Expenses</a>&nbsp;|&nbsp;
        <a href='/invoices'>Invoices</a>
      </nav>
      <main>
      <button className='addButton'
          onClick={() => setFormOpen(true)}>+</button>
        <TaskContexts .../>
      </main>
      <footer>
        &#169;2029, Amalgamated Consultants Corp. All Rights Reserved.
      </footer>
      <TaskForm .../>
      <ModalQuestion ...>
        Are you sure you want to delete this task?
      </ModalQuestion>
    </>
  )
}

讨论

地标是 HTML5 的一部分,因此在浏览器中原生支持。这意味着您可以开始使用它们,而无需添加特殊的工具或支持库。

您会发现一些自动辅助工具可能会抱怨由 React 应用程序渲染的地标。标准指南指出,网页正文中的所有内容都应该在一个地标内。但大多数 React 应用程序将它们的内容(包括任何地标)渲染在一个单独的div中,这立即违反了规则。

忽略这个问题可能是安全的。只要地标存在且它们在同一级别,它们被包装在额外的div中就不会有问题。

您可以从[GitHub site]下载此配方的源代码。

应用角色、替代文本和标题

问题

应用程序中常见的组件可能表现得像按钮,即使它们并不是按钮。同样,您可能有类似对话框的组件,而实际上并不是对话框。或者您可能有结构类似于列表但不使用olul标签的数据集合。

如果您可以看到组件的视觉样式,创建行为像标准 UI 元素的组件就不是问题。如果某些东西对用户来说看起来像一个按钮,他们会将其视为按钮,而不管其实现方式如何。

但是,如果有人看不到组件的视觉样式,就会出现问题。相反,您需要为无法看到的人描述组件的目的。

解决方案

我们将讨论在应用程序中使用角色角色描述了组件的含义:它告诉用户它的目的是什么。角色是网页语义的一部分,因此类似于我们在“使用地标”中讨论的语义地标。

这里列出了一些您可以应用于渲染 HTML 的典型角色:

Role name Purpose
alert 告诉用户发生了某事。
article 大块的文本内容,比如新闻报道。
button 可以点击以执行某些操作的内容。
checkbox 用户可选择的真/假值。
comment 类似于用户提交的评论或反应。
complementary 补充信息,可能在侧边栏中。
contentinfo 版权声明、作者姓名、出版日期等。
dialog 浮在其他内容之上的东西。通常是模态框。
feed 在博客中常见,是文章的列表。
figure 插图。
list 一组顺序排列的事物。
listitem 列表中的每个事物。
搜索 搜索字段。
菜单 一系列选项,通常用于导航。
菜单项 菜单中的项。

您可以将角色应用于具有role属性的元素。让我们考虑本章示例应用程序中的Task组件。Task组件将每个任务渲染为一个小面板,并带有一个删除按钮:

import DeleteIcon from './delete-24px.svg'
import './Task.css'

const Task = ({ task, onDelete, onEdit }) => {
  return (
    <div className="Task">
        <div className="Task-contents"
          ...
        >
        <div className="Task-details">
          <div className="Task-title">{task.title}</div>
          <div className="Task-description">{task.description}</div>
        </div>
        <div className="Task-controls">
          <img
            src={DeleteIcon}
            width={24}
            height={24}
            title="Delete"
            onClick={(evt) => {
              evt.stopPropagation()
              onDelete()
            }}
            alt="Delete icon"
          />
        </div>
      </div>
    </div>
  )
}

我们在页面上根据描述人们执行任务的上下文将任务分组在一起。例如,您可能会在“Phone”标题下分组一系列任务(见图 9-5)。

图 9-5. 每个组包含任务列表

因此,任务似乎与listitem角色相匹配。它们是出现在有序集合内部的事物。因此,我们可以将该角色添加到第一个div中:

return <div role='listitem' className='Task'>
    <div className='Task-details'>
        ....

如果我们就此止步,我们会遇到问题。角色有规则。你不能将listitem角色应用于组件,除非它出现在具有list角色的某物内部。因此,如果我们要将我们的Task组件标记为listitems,我们还需要给TaskList父元素一个list角色:

import Task from '../Task'
import './TaskList.css'

function TaskList({ tasks, onDelete, onEdit }) {
  return (
    <div role="list" className="TaskList">
      {tasks.map((t) => (
        <Task
          key={t.id}
          task={t}
          onDelete={() => onDelete(t)}
          onEdit={() => onEdit(t)}
        />
      ))}
    </div>
  )
}

export default TaskList

使用listlistitem角色是完全有效的。但是实践中,如果我们有行为像列表的 HTML,最好改变标记并使用真正的ulli标签。从可访问性的角度来看,可能没有什么区别。但是总是避免在 HTML 中填充无尽的div标签是一种好习惯。通常情况下,如果可以使用真正的 HTML 标签而不是角色,那可能是最好的选择。

让我们从TaskList中移除list角色,并创建一个真正的ul

import Task from '../Task'
import './TaskList.css'

function TaskList({ tasks, onDelete, onEdit }) {
  return (
    <ul className="TaskList">
      {tasks.map((t) => (
        <Task
          key={t.id}
          task={t}
          onDelete={() => onDelete(t)}
          onEdit={() => onEdit(t)}
        />
      ))}
    </ul>
  )
}

export default TaskList

然后我们可以在Task中用li标签替换listitem角色:

import './Task.css'

const Task = ({ task, onDelete, onEdit }) => {
  return (
    <li className="Task">
      <div
        className="Task-contents"
        ...
      >
        <div className="Task-details">...</div>
        <div className="Task-controls">...</div>
      </div>
    </li>
  )
}

export default Task

使用li标签意味着我们需要进行一些 CSS 样式更改来移除列表项目符号,但是对于未来的任何开发人员(可能包括你自己)来说,代码将更易于阅读。

接下来,让我们来看看示例应用程序的导航部分。它有一系列链接,你几乎可以把它们看作是选项菜单:

<nav>
    <a href='/contacts'>Contacts</a>&nbsp;|&nbsp;
    <a href='/events'>Events</a>&nbsp;|&nbsp;
    Tasks&nbsp;|&nbsp;
    <a href='/notes'>Notes</a>&nbsp;|&nbsp;
    <a href='/time'>TimeRec</a>&nbsp;|&nbsp;
    <a href='/diary'>Diary</a>&nbsp;|&nbsp;
    <a href='/expenses'>Expenses</a>&nbsp;|&nbsp;
    <a href='/invoices'>Invoices</a>
</nav>

所以,在这里应用menumenuitem角色吗?答案几乎肯定是

菜单和菜单项具有预期行为。用户到达菜单时,可能期望如果选择它,则弹出菜单。一旦菜单可见,他们可能会使用箭头键浏览选项,而不是用 Tab 键移动。^(1)

现在让我们来看看我们示例应用程序中的+按钮,它允许用户通过显示弹出式任务表单来创建新任务(见图 9-6)。

图 9-6. 用户点击+按钮后会出现一个新任务表单

这是按钮的代码:

<button className='addButton'
    onClick={() => setFormOpen(true)}>+</button>

我们是否需要应用button角色?不需要。该元素已经是一个按钮。但我们可以提供关于用户点击按钮后可以期待发生什么的一些额外信息。将弹出窗口显式显示在 HTML 中,使用aria-haspopup属性:

<button aria-haspopup='dialog' className='addButton'
    onClick={() => setFormOpen(true)}>+</button>

aria-haspopup属性的值必须与作为结果显示的组件的角色相匹配。在这种情况下,我们将显示一个对话框。您还可以将aria-haspopup属性设置为值true。但是屏幕阅读器将其解释为menu,因为通常与弹出窗口相关的组件用于打开菜单。

因为我们将aria-haspopup设置为dialog,所以我们还需要确保显示的TaskForm具有dialog角色。这是当前TaskForm的代码:

const TaskForm = ({ task, contexts, onCreate, onClose, open }) => {
  ...

  return <Modal open={open} onCancel={close}>
    <form>
      ....
    </form>
    <ModalFooter>
      <button onClick={...}>Cancel</button>
      <button onClick={...}>Save</button>
    </ModalFooter>
  </Modal>
}

我们将TaskForm包装在Modal组件中,如下所示:

import './Modal.css'

function Modal({ open, onCancel, children }) {
  if (!open) {
    return null
  }

  return <div className='Modal'
        ...
  >
    <div className='Modal-dialog'
       ...
    >
      {children}
    </div>
  </div>
}

export default Modal

这个Modal组件分为两部分:

  • 一个外部的Modal包装器,用于遮蔽页面的其他内容,是一个半透明的层

  • 一个内部Modal-dialog div,显示的内容看起来像一个窗口

因为Modal类可重用,可能会用于除对话框(例如警报)之外的其他内容,因此我们将为Modal类添加一个额外的title属性,该属性将应用于Modal-dialogtitle将使屏幕阅读器用户清楚地了解对话框的目的。

这给我们带来了更新后的Modal组件:

import './Modal.css'

function Modal({ open, onCancel, children, role, title }) {
  if (!open) {
    return null
  }

  return <div role='presentation' className='Modal'
        ...
  >
    <div className='Modal-dialog'
       role={role} title={title}
       ...
    >
      {children}
    </div>
  </div>
}

export default Modal

这是我们更新后的TaskForm组件:

const TaskForm = ({ task, contexts, onCreate, onClose, open }) => {
  ...

  return <Modal title='Create or edit a task'
          role='dialog'
          open={open} onCancel={close}>
    <form>
      ....
    </form>
    <ModalFooter>
      <button onClick={...}>Cancel</button>
      <button onClick={...}>Save</button>
    </ModalFooter>
  </Modal>
}

最后,让我们考虑每个Task旁边出现的删除按钮,看起来像一个小垃圾桶:

<img src={DeleteIcon}
   width={24}
   height={24}
   alt='Delete icon'
   aria-haspopup='dialog'
   role='button'
   title='Delete'
   onClick={evt => {
     evt.stopPropagation()
     evt.preventDefault()
     onDelete()
   }}
/>

垃圾桶图标作为按钮工作,因此我们赋予它该角色。垃圾桶已经具有aria-haspopup,因为对话框会要求用户确认删除。

但就像列表和列表项的情况一样,将按钮实现为按钮通常更好。我们可以将此组件重写为包装图像的button

<button
  onClick={evt => {
    evt.stopPropagation()
    evt.preventDefault()
    onDelete()
  }}
  title='Delete'
  aria-haspopup='dialog'
>
  <img src={DeleteIcon}
     width={24}
     height={24}
     alt='Delete icon'
  />
</button>

这不仅对开发人员更清晰,而且也是可自动使用的。

讨论

角色在某些方面与地标重叠。有可用的地标角色,如mainheader。但它们有两种不同的用途。地标是如其名,用于突出显示网页的重要部分。角色则描述接口某些部分的预期行为。在这两种情况下,地标和角色都旨在为网页提供额外的含义。

如果您的界面包含行为类似标准 HTML 元素的组件,例如列表,通常最好样式化标准 HTML 标记,而不是使用自定义代码重新创建元素。

您可以从GitHub 网站下载此示例的源代码。

使用 ESlint 检查辅助功能

问题

如果你不需要使用任何可访问性设备,识别可访问性问题可能会很具有挑战性。^(2)在开发的热情中,还很容易出现破坏你之前测试过的代码可访问性的回归问题。

当你创建代码时,你需要一种快速而简便地找出可访问性问题的方式。你需要一个可以在你输入代码时持续监控并立即标记问题的流程,这样你还记得你做了什么。

解决方案

我们将看看如何配置eslint工具以查找代码中更明显的可访问性问题。

eslint是一个对你的代码进行静态分析的工具。它会找出未使用的变量,在useEffect调用中缺少的依赖等问题。如果你用create-react-app创建了你的应用程序,可能已经在你的应用上持续运行eslint。开发服务器将在每次代码需要重新编译时重新运行eslint,任何eslint错误都将出现在服务器窗口中。

如果你还没有安装eslint,你可以使用以下命令进行安装:

$ npm install --save-dev eslint

或者你可以使用它的yarn等效方式。eslint可以通过插件进行扩展。插件是一组规则,eslint将在保存静态代码时应用这些规则。有一个专门用来检查可访问性问题的插件。它被称为 jsx-a11y,你可以使用以下命令安装它:

$ npm install --save-dev eslint-plugin-jsx-a11y

如果你想手动运行eslint,你可以将一个脚本添加到你的package.json文件中:^(3)

"scripts": {
  ....
  "lint": "eslint src"
},

在我们可以使用 jsx-a11y 插件之前,我们需要对其进行配置。我们可以通过更新package.json中的eslintConfig部分来实现这一点:

"eslintConfig": {
  "extends": [
    "react-app"
    "react-app/jest",
    "plugin:jsx-a11y/recommended"
  ],
  "plugins": [
    "jsx-a11y"
  ],
  "rules": {}
}

这个配置将告诉eslint使用新的插件,并启用一组推荐的可访问性规则。

如果你选择,你也可以通过向rules部分添加额外配置来配置每个规则的工作方式。我们现在将通过禁用其中一个规则来实现这一点:

"eslintConfig": {
  "extends": [
    "react-app"
    "react-app/jest",
    "plugin:jsx-a11y/recommended"
  ],
  "plugins": [
    "jsx-a11y"
  ],
  "rules": {
    "jsx-a11y/no-onchange": "off"
  }
}

禁用规则可能看起来不是一个好主意,但有一个特定的理由你可能想要禁用no-onchange规则。

jsx-a11y 开发人员创建了no-onchange规则,因为旧版浏览器存在问题,它们以不同的方式实现了onchange。一些浏览器在用户每次在输入字段中键入字符时都会生成一个onChange事件。其他浏览器仅在用户离开字段时生成事件。这些不同的行为给使用可访问性工具的人们带来了大量问题。

解决方案是将所有的onChange处理程序替换为onBlur处理程序,这意味着所有浏览器在用户离开字段时会触发字段更改事件。

但是现在这条规则已经完全过时,并且在插件中已被弃用。如果您尝试将 React 代码中所有的 onChange 处理程序替换为 onBlur 处理程序,将会显著改变应用程序的工作方式。您还将远离 React 用于跟踪表单字段状态的标准方法:使用 onChange

因此,在这 情况下,禁用规则是一个好主意。

现在我们可以运行 eslint,并启用我们的可访问性规则:

$ npm run lint

在应用程序的早期版本中,eslint 发现了一些错误:

$ npm run lint
> app@0.1.0 lint app
> eslint src
app/src/Task/Task.js
 6:9  error  Visible, non-interactive elements with click handlers
 must have at least one keyboard listener
 jsx-a11y/click-events-have-key-events
 6:9  error  Static HTML elements with event handlers require a role
 jsx-a11y/no-static-element-interactions
✖ 2 problems (2 errors, 0 warnings)

要查看这些错误的原因,让我们看一下 Task.js 的源代码:

<li className="Task">
  <div className="Task-contents" onClick={onEdit}>
    ....
  </div>
</li>

Task 组件在一个小卡片面板内显示任务的详细信息(参见 图 9-7)。

图 9-7. 应用程序在单独的面板中显示任务,每个面板都有一个删除按钮

如果用户点击一个任务,他们将打开一个表单,允许他们编辑任务的细节。完成此操作的代码是 Task-contents div 上的 onClick 处理程序。

要了解为什么 eslint 不高兴,让我们先看看这个错误:

6:9  error  Static HTML elements with event handlers require a role
 jsx-a11y/no-static-element-interactions

div 这样的元素是 静态 的。它们没有内置的交互行为。默认情况下,它们只是布局其他内容的东西。eslint 不高兴是因为 onClick 处理程序表明这个特定的 div 实际上被用作 活动 组件。如果有人使用辅助功能设备,我们需要告诉他们此组件的目的。eslint 希望我们通过给 div 分配一个 role 来做到这一点。^(4)

我们将为这个 div 元素设置 button 角色,以指示用户通过点击来使用该组件。当我们点击一个任务时,将显示一个弹出的编辑窗口,因此我们还会给 div 元素添加 aria-haspopup 属性,告知用户点击任务将打开一个对话框:

<li className='Task'>
        <div className='Task-contents'
             role='button'
             aria-haspopup='dialog'
             onClick={onEdit}
        >
    ....
</div>
</li>

将元素转换为原生 button 标签通常比使用 button 角色更好。但在这种情况下,div 元素包裹了一块相当大的 HTML 文本块,因此提供角色而不是处理将灰色按钮看起来像卡片的样式后果更有意义。

如果我们再次运行 eslint,我们仍然有两个错误。但其中一个是新的:

$ npm run lint
> app@0.1.0 lint app
> eslint src
app/src/Task/Task.js
 6:9  error  Visible, non-interactive elements with click handlers
 must have at least one keyboard listener
 jsx-a11y/click-events-have-key-events
 6:9  error  Elements with the 'button' interactive role must be tabbable
 jsx-a11y/interactive-supports-focus
✖ 2 problems (2 errors, 0 warnings)

我们说任务的行为像一个按钮。但是:角色有规则。如果我们希望某物被视为按钮,它必须表现得像按钮一样。按钮可以做的一件事是可以被 切换 到。它们需要能够从键盘接收焦点。我们可以通过添加 tabIndex 属性来实现这一点:

<li className='Task'>
        <div className='Task-contents'
             role='button'
             tabIndex={0}
             onClick={onEdit}
        >
    ....
</div>
</li>

tabIndex 设置为 0 意味着我们的任务将成为页面的标签顺序的一部分。

tabIndex可以有几个值:-1 表示只能通过程序设置焦点;0 表示它是一个普通的可标记组件。如果一个元素的可标记值大于 0,这意味着焦点系统应该给它更高的优先级。通常应避免使用大于 0 的值,因为它们可能会引起可访问性问题。^(5)

如果我们再次运行eslint,我们只有一个错误:

$ npm run lint
> app@0.1.0 lint app
> eslint src
app/src/Task/Task.js
 6:9  error  Visible, non-interactive elements with click handlers
 must have at least one keyboard listener
 jsx-a11y/click-events-have-key-events
1 problems (1 errors, 0 warnings)

这个错误意味着我们有一个onClick事件来定义鼠标点击任务时发生的操作,但是我们没有代码来响应键盘。如果有人无法使用鼠标,他们将无法编辑任务。

因此,我们需要添加某种键盘事件处理程序。我们将添加代码以在用户按 Enter 键或空格键时调用编辑事件:

<li className="Task">
  <div
    className="Task-contents"
    role="button"
    tabIndex={0}
    onClick={onEdit}
    onKeyDown={(evt) => {
      if (evt.key === 'Enter' || evt.key === ' ') {
        evt.preventDefault()
        onEdit()
      }
    }}
  >
    ....
  </div>
</li>

添加键盘处理程序将修复剩余的错误。

jsx-a11y 中的每个规则都有一个关联的GitHub 页面,详细说明代码可能违反规则的原因以及如何修复它。

讨论

jsx-a11y 可能是eslint中最有用的插件之一。通常,lint 规则会检查良好的编程实践并可以找到一些编码问题。但是 jsx-a11y 插件可以真正改变应用程序的设计。

确保您的应用程序允许键盘导航对于使用辅助工具的人来说很重要,但对于经常使用您的应用程序的人来说也很有用。如果有人长时间使用应用程序,他们通常会更喜欢使用键盘而不是鼠标,因为键盘需要更少的移动并且更精确。

我们还看过如何设置tabIndex来给元素添加键盘焦点。一些浏览器,特别是 Firefox,会提供微妙的指示器来显示当前具有键盘焦点的元素。如果您希望清楚地向用户显示当前焦点在哪里,请考虑在您的应用程序中添加一些顶级 CSS:

:focus-visible {
    outline: 2px solid blue;
}

这个样式规则将为任何具有键盘焦点的组件添加一个可识别的轮廓。一些用户更有可能选择键盘导航,一旦他们看到它是可用的。

您可以从GitHub 站点下载此示例的源代码。

在运行时使用 Axe DevTools

问题

静态代码分析工具,如eslint,可以用于发现许多可访问性问题。但是静态分析是有限的。它通常会忽略运行时发生的错误。

代码可能以动态方式行为,静态分析工具无法预测。我们需要在 Web 浏览器中运行应用程序时检查其可访问性。

解决方案

我们将安装 axe DevTools 插件。这个插件在FirefoxChrome中都可用。

安装完成后,您将在浏览器开发者控制台中多一个选项卡(见图 9-8)。

图 9-8. 开发者控制台中的 axe DevTools。

为了看到它的工作原理,让我们在本章中始终使用的示例任务应用程序中搞乱一些代码。

应用程序包含一个弹出式TaskForm组件。此组件已被赋予dialog角色,但我们可以修改它以具有一些无效值:

const TaskForm = ({ task, contexts, onCreate, onClose, open }) => {
  ...
  return (
    <Modal
      title="Create or edit a task"
      role="fish"
      open={open}
      onCancel={close}
    >
      <form>...</form>
      <ModalFooter>...</ModalFooter>
    </Modal>
  )
}

如果您打开http://localhost:3000并单击按钮以创建任务,您将看到任务表单(参见图 9-9)。

图 9-9. 按下+按钮后会出现新的任务表单。

如果现在在浏览器中打开开发者工具窗口,切换到 axe DevTools 选项卡,并在页面上运行审核,您将看到两个错误(参见图 9-10)。

图 9-10. 在模态框中设置无效值会导致两个错误。

有两个错误,首先,对话框不包含有效的role。其次,模态框不再具有dialog角色,这意味着它不再作为页面中的重要地标。某些角色,如dialog,标记元素作为页面中重要的地标元素。应用程序的每个部分必须出现在一个地标内。

如果您重置代码并刷新 DevTools 的审计,则错误将消失。

您可以想象,一些未来的静态代码分析可能包括扫描检查所有代码,以检查无效的role值。^(6) 然而,DevTools 也可以检查其他更微妙的问题。

在示例应用程序中,编辑App.css文件,并添加一些代码以更改主标题的颜色:

h1 {
    color: #9e9e9e;
}

图 9-11. 改变一级标题的颜色的结果。

结果似乎并不太严重(参见图 9-11),但是这确实导致 DevTools 显示此错误:

Elements must have sufficient color contrast

Fix the following:
Element has insufficient color contrast of 2.67 (foreground color: #9e9e9e,
background color: #ffffff, font size: 24.0pt (32px), font weight: bold).
Expected contrast ratio of 3:1

Chrome 浏览器在开发者控制台内部相对容易修复对比度错误。如果您检查h1标题,检查元素的color样式,然后点击小颜色面板,您将看到在图 9-12 中报告的对比度问题。

图 9-12. 点击颜色属性中的小灰色方块查看对比度。

如果您现在打开对比度部分,您可以调整颜色以满足 AA 和 AAA 的无障碍标准(参见图 9-13)。

图 9-13. 打开对比度比率以调整颜色以满足无障碍标准。

Chrome 建议将颜色从#949494改为#767676。对于大多数人来说,这种差异并不是很明显,但对于对比度较低的用户来说,阅读将显著更容易(参见图 9-14)。

图 9-14. 改变对比度以满足 AAA 标准的结果。

有时,如果 Chrome 无法识别特定的背景颜色,它将不显示对比信息。您可以通过临时为您检查的元素分配backgroundColor来避免此问题。

讨论

axe DevTools 扩展程序易于使用,并且可以找到许多静态分析工具无法找到的问题。

它确实依赖于开发人员手动检查错误,但我们将在下一章中看到,有方法可以自动化基于浏览器的可访问性测试。

您可以从GitHub 网站下载此示例代码。

使用 Cypress Axe 自动化浏览器测试

问题

前一个示例清楚地表明,一些可访问性问题只会在真实的 Web 浏览器中的运行时才会出现,因此无法通过静态分析找到。

如果我们依赖于手动浏览器测试,很可能会出现回归问题。更好的做法是自动化像 axe DevTools 这样的工具允许我们在浏览器内执行的手动检查。

解决方案

我们将研究如何使用 Cypress 测试框架的插件cypress-axe自动化浏览器可访问性测试。cypress-axe插件使用与 axe DevTools 相同的axe-core库。但因为我们可以在浏览器级别的测试中使用cypress-axe,所以我们可以自动化审核过程,以便集成服务器可以立即发现回归错误。

我们需要在我们的应用程序中安装 Cypress 和axe-core库:

$ npm install --save-dev cypress axe-core

然后,我们可以安装cypress-axe扩展程序:

$ npm install --save-dev cypress-axe

如果这是您第一次安装 Cypress,您需要运行 Cypress 应用程序,它将创建适当的目录和初始代码,供您用作测试的基础。您可以使用以下命令启动 Cypress:

$ npx cypress open

我们需要配置cypress-axe插件。编辑cypress/support/index.js文件,并添加这一行代码:

import 'cypress-axe'

我们还需要添加一些钩子,允许我们在测试运行期间记录错误。我们可以通过编辑cypress/plugins/index.js文件并添加以下代码来实现这一点:

module.exports = (on, config) => {
  on('task', {
    log(message) {
      console.log(message)
      return null
    },
    table(message) {
      console.table(message)
      return null
    },
  })
}

然后,您可以删除cypress/integration目录下的所有示例测试,并创建一个名为cypress/integration/accessibility.js的新文件:^(7)

function terminalLog(violations) {
  cy.task(
    'log',
    `${violations.length} accessibility violation${
      violations.length === 1 ? '' : 's'
    } ${violations.length === 1 ? 'was' : 'were'} detected`
  )
  const violationData = violations.map(
    ({ id, impact, description, nodes }) => ({
      id,
      impact,
      description,
      nodes: nodes.length,
    })
  )

  cy.task('table', violationData)
  console.table(violationData)
}

describe('can be used', () => {
  it('should be accessible when starting', () => {
    cy.visit('/')
    cy.injectAxe()
    cy.checkA11y(null, null, terminalLog)
  })
})

这基于来自cypress-axe存储库的示例代码

测试位于describe函数内。terminalLog函数用于报告错误。

测试具有以下结构:

  1. 打开页面在/

  2. axe-core库注入页面中

  3. 运行页面的审核

做大部分工作的axe-core库是其他工具(如 axe DevTools 浏览器扩展)所使用的相同库。axe-core库将检查当前 DOM 并根据其规则集检查它。然后,它将报告它发现的任何失败。

cypress-axe插件将 axe-core 库注入浏览器,并使用checkA11y命令运行审核。它将问题发送到terminalLog函数。

如果您在 Cypress 中双击accessibility.js运行此测试,它将通过(参见图 9-15)。

因此,让我们创建一个问题。让我们添加第二个测试:

it('should be accessible when creating a task', () => {
  cy.visit('/')
  cy.injectAxe()
  cy.contains('+').click()
  cy.checkA11y(null, null, terminalLog)
})

图 9-15. 通过可访问性测试的代码

测试打开应用程序,单击+按钮以打开创建任务的表单,然后执行审核。

在当前形式下,应用程序也将通过此测试。因此,让我们修改示例应用程序中的TaskForm,使其具有无效的role值:

const TaskForm = ({ task, contexts, onCreate, onClose, open }) => {
  ...
  return (
    <Modal
      title="Create or edit a task"
      role="hatstand"
      open={open}
      onCancel={close}
    >
      <form>...</form>
      <ModalFooter>...</ModalFooter>
    </Modal>
  )
}

如果重新运行测试,它现在将失败。您需要在打开 JavaScript 控制台时运行测试(参见图 9-16),以在控制台表格中看到失败。

图 9-16. 如果测试期间打开控制台,您将找到失败的详细信息

讨论

有关可访问性审核和 cypress-axe 测试的出色介绍,请参见马西·萨顿(Marcy Sutton)的演讲,她在 ReactJS Girls Conference 上介绍了该插件,并且自那以后我们一直在使用它。

您可以从GitHub 网站下载此示例的源代码。

添加跳过按钮

问题

页面通常在开始时有大量内容。可能有导航链接、快速操作菜单、社交媒体账号链接、搜索字段等。如果您可以使用鼠标并查看页面,这不会成为问题。您可能会在心理上过滤它们,并开始使用页面的主要内容。

但是,如果您使用屏幕阅读器,则可能需要听取您访问的每个页面上这些初始元素的详细信息。现代屏幕阅读器技术通常允许用户自动浏览部分和标题,但仍可能需要一些时间才能找到重要内容的位置。

因此,许多网站包含隐藏的链接和按钮,通常包含文本如“跳转到内容”,使键盘用户能够快速到达页面的关键部分。

一个例子是 YouTube。如果打开 YouTube 然后按 Tab 键几次,您会看到一个按钮出现(参见图 9-17),如果按空格键,它将将键盘焦点移动到主内容。

图 9-17. 如果按 Tab 键三次,YouTube 将显示跳过按钮

如何创建一个仅在 Tab 到达时才出现的按钮?

解决方案

此示例包含一个可重用的SkipButton组件,我们可以将其几乎添加到任何页面而不会破坏设计或布局。

它需要具有几个特性:

  • 它需要隐藏,除非我们 Tab 进入。我们不只想要一个透明按钮,以防用户在意外点击屏幕的这部分时按到它。

  • 它需要浮动在页面内容的上方,这样我们就不需要在布局中为它留出空间。

  • 它需要作为一个可访问的按钮工作。这意味着它必须被屏幕阅读器识别,并且表现出按钮的行为。如果我们在它获得焦点时按下 Enter 键或空格键,我们希望它能起作用。

  • 它需要在我们使用它之后消失。

我们在这个过程中会添加一些其他要求,但这应该能让我们开始。

让我们首先创建一个名为SkipButton的新组件。我们将使其返回一个单一的div,并允许它包含任何传递给它的子元素:

const SkipButton = (props) => {
  const { className, children, ...others } = props

  return (
    <div className={`SkipButton ${className || ''}`} {...others}>
      {children}
    </div>
  )
}

该组件还将接受一个类名和任何父组件可能想要传递的其他属性。

我们希望屏幕阅读器将其视为一个实际的button。我们可以通过用button替换div来实现这一点,但我们将保持它为div,以便样式更容易应用。然而,我们会给它一个rolebutton,并且——因为角色有规则——我们还会给它一个tabIndex值为0。这是我们无论如何都需要做的事情,因为我们希望用户能够通过 Tab 键访问它:

const SkipButton = (props) => {
  const { className, children, ...others } = props

  return (
    <div
      className={`SkipButton ${className || ''}`}
      role="button"
      tabIndex={0}
      {...others}
    >
      {children}
    </div>
  )
}

当点击按钮时,我们希望它执行某些操作。或者更确切地说,我们希望在用户按下 Enter 键或空格键时执行某些操作。因此,我们将允许它接受一个名为onClick的属性,然后将其附加到一个事件处理程序上,该处理程序将在用户按下 Enter 键或空格键时触发:

const SkipButton = (props) => {
  const { className, children, onClick, ...others } = props

  return (
    <div
      className={`SkipButton ${className || ''}`}
      role="button"
      tabIndex={0}
      {...others}
      onKeyDown={(evt) => {
        if (evt.key === 'Enter' || evt.key === ' ') {
          evt.preventDefault()
          onClick(evt)
        }
      }}
    >
      {children}
    </div>
  )
}

当然,我们可以将这个属性命名为onKeyDown,但是按钮通常有onClick,在使用时更容易记住。

最后,我们将在组件中做的一件事情是:允许它接受一个引用,在我们在下一个示例中重用组件时会很有用。

你不能像传递大多数其他属性那样传递引用。React 渲染器使用引用来跟踪在 DOM 中生成的元素。

如果我们希望一个组件接受一个引用对象,我们需要将所有内容包裹在调用 React 的forwardRef函数中。forwardRef函数返回你的组件的包装版本,从父组件中提取引用并显式地传递给它包装的组件。这听起来有点复杂,但实际上就是这样:

import { forwardRef } from 'react'
import './SkipButton.css'

const SkipButton = forwardRef((props, ref) => {
  const { className, children, onClick, ...others } = props

  return (
    <div
      className={`SkipButton ${className || ''}`}
      role="button"
      tabIndex={0}
      ref={ref}
      {...others}
      onKeyDown={(evt) => {
        if (evt.key === 'Enter' || evt.key === ' ') {
          evt.preventDefault()
          onClick(evt)
        }
      }}
    >
      {children}
    </div>
  )
})

这就是我们完成的SkipButton,包含一些样式信息的导入。它只是一个按钮。其余的就是在SkipButton.css文件中进行样式设置。

我们希望按钮浮动在页面其他内容的上方,因此我们将z-index设置为非常高的值:

.SkipButton {
    z-index: 10000;
}

我们希望在用户按下 Tab 键进入按钮之前隐藏该按钮。我们可以尝试将其设置为透明,但这会带来两个问题。首先,它可能会挡住某些可点击的内容。除非我们还将pointer-events设置为none,否则会阻挡点击事件。其次,如果按钮虽然透明但仍然在屏幕上显示,对于屏幕阅读器来说可能会被视为额外的屏幕杂乱,因为如果屏幕阅读器将屏幕空间转换为点字,用户可能会在其他文本中听到“跳转到内容”。

因此,我们将按钮放在屏幕之外,直到需要它为止:

.SkipButton {
    z-index: 10000;
    position: absolute;
    left: -1000px;
    top: -1000px;
}

那么,当有人按 Tab 键进入按钮时会发生什么?我们可以设置仅在按钮获得焦点时应用的样式:

.SkipButton {
    z-index: 10000;
    position: absolute;
    left: -1000px;
    top: -1000px;
}

.SkipButton:focus {
    top: auto;
    left: auto;
}

此外,我们可以添加一些纯视觉样式。重要的是要记住,并非每个使用此按钮的人都会使用屏幕阅读器。有些人会选择使用键盘导航,因为他们无法使用鼠标,或者因为他们觉得键盘导航更快:

.SkipButton {
    z-index: 10000;
    position: absolute;
    left: -1000px;
    top: -1000px;
    font-size: 12px;
    line-height: 16px;
    display: inline-block;
    color: black;
    font-family: sans-serif;
    background-color: #ffff88;
    padding: 8px;
    margin-left: 8px;

}

.SkipButton:focus {
    top: auto;
    left: auto;
}

现在,我们可以将SkipButton插入到页面的开头某处。在用户按下 Tab 键进入之前,它不会可见,但位置很重要。我们希望它在页面的前两到三个 Tab 处。我们将其添加到header部分:

<header>
    <SkipButton onClick={() => document.querySelector('.addButton').focus()}>
        Skip to content
    </SkipButton>
    <h1>Manage Tasks</h1>
</header>

在这里,我们只是使用 document.querySelector 来找到将接收焦点的元素。您可以选择引用要跳转到的元素,或者导航到一个位置。在实践中,我们发现使用简单的 document.querySelector 是最直接的方法。它允许您轻松引用可能不在当前组件中的元素。并且不依赖于导航到页面内锚点,如果应用程序更改其路由方法可能会导致断裂。

如果您在浏览器中打开示例应用程序,然后按 Tab 键,您将看到SkipButton(见图 9-18)。

图 9-18. 如果按下 Tab 键,跳过按钮将出现在主标题上方。

讨论

SkipButton放在页面开头的前三个 Tab 处是个好主意,如果每个页面中所需的Tab数量相同,用户很快就能学会如何跳转到每个页面的关键部分。我们发现,喜欢使用键盘更加高效的人群也很喜欢SkipButton

您可以为每个页面创建一个标准的SkipButton,还可以将焦点移动到页面main部分的第一个可标签化项上。^(8)

您可以从GitHub 网站下载此示例的源代码。

添加跳过区域

问题

在上一个示例中,我们看到跳过按钮对于用户想要快速跳过页面开头的所有标题和导航,直接进入主要内容是很有帮助的。

然而,即使在主内容中,有时候用户跳过一些组件集合会更有帮助。考虑本章中我们一直在使用的示例任务应用程序。用户可以在不同组中创建相当多的任务(见图 9-19)。

图 9-19. 该示例应用程序显示了一组任务,分成了几个组

如果他们想要进入 Shopping 任务,他们可能需要跳过其他 14 个任务。每个任务都有两个焦点:任务本身和任务的删除按钮。这意味着即使进入页面内容后,仍然需要跳过 28 个焦点。

我们能做些什么来使用户更容易跳过一系列组件?

解决方案

我们将使用之前创建的 SkipButton 组件来创建跳过区域。

如果我们向前切换到页面主内容的某个部分,比如办公任务,我们希望出现一个按钮,允许用户完全跳过办公任务(见图 9-20)。

图 9-20. 我们希望在向前切换到一个组时出现一个跳过按钮

相反,如果他们向后切换到办公室部分,我们希望出现一个按钮,允许他们在办公室任务之前跳过(见图 9-21)。

我们只希望在进入一个区域时显示这些按钮,而在离开时不显示。这意味着当我们向前切换标签时只显示“跳过办公室”按钮,并且在向后切换标签时只显示“在办公室之前跳过”按钮。

图 9-21. 当我们向后切换到一个组时,也应该出现一个跳过按钮

在看具体实现之前,让我们看看如何在进入实现细节的血腥细节之前使用跳过区域。我们的任务应用程序使用 TasksContexts 组件渲染一系列任务组:

import TaskList from '../TaskList'
import './TaskContexts.css'

function TaskContexts({ contexts, tasks, onDelete, onEdit }) {
  return contexts.map((c) => {
    const tasksForContext = tasks.filter((t) => t.context === c.value)
    if (tasksForContext.length === 0) {
      return <div className="TaskContexts-context">&nbsp;</div>
    }
    return (
      <div key={c.value} className="TaskContexts-context">
        <h2>{c.name}</h2>
        <TaskList
          tasks={tasksForContext}
          onDelete={onDelete}
          onEdit={onEdit}
        />
      </div>
    )
  })
}

export default TaskContexts

每个“上下文”(如购物、办公、研究等任务组)都有一个标题和一组任务。我们希望用户能够跳过每个任务组。我们将每个任务组包装在一个名为 Skip 的新组件中,如下所示:

import TaskList from '../TaskList'
import Skip from '../Skip'
import './TaskContexts.css'

function TaskContexts({ contexts, tasks, onDelete, onEdit }) {
  return contexts.map((c) => {
    const tasksForContext = tasks.filter((t) => t.context === c.value)
    if (tasksForContext.length === 0) {
      return <div className="TaskContexts-context">&nbsp;</div>
    }
    return (
      <div key={c.value} className="TaskContexts-context">
        <Skip name={c.name}>
          <h2>{c.name}</h2>
          <TaskList
            tasks={tasksForContext}
            onDelete={onDelete}
            onEdit={onEdit}
          />
        </Skip>
      </div>
    )
  })
}

export default TaskContexts

如果我们把一些任务包装在我们(尚不存在的)Skip 组件中,用户进入任务组时将会看到 SkipButtons 神奇地出现和消失。

我们只需要传递一个名称给 Skip 组件,它将用于“跳过…”和“在…之前跳过…”文本中。

现在,要创建 Skip 组件,让我们从一个简单的组件开始,它渲染两个 SkipButtons 和它所接收到的任何子组件:

import { useRef } from 'react'
import SkipButton from '../SkipButton'
import './Skip.css'

const Skip = ({ children, name }) => {
  const startButton = useRef()
  const endButton = useRef()

  return (
    <div className="Skip">
      <SkipButton ref={startButton}>Skip {name}</SkipButton>
      {children}
      <SkipButton ref={endButton}>Skip before {name}</SkipButton>
    </div>
  )
}

我们创建了两个引用,用于跟踪每个按钮。当用户点击 startButton 时,焦点将跳转到 endButton,反之亦然:

import { useRef, useState } from 'react'
import SkipButton from '../SkipButton'
import './Skip.css'

const Skip = ({ children, name }) => {
  const startButton = useRef()
  const endButton = useRef()

  const skipAfter = () => {
    if (endButton.current) {
      endButton.current.focus()
    }
  }
  const skipBefore = () => {
    if (startButton.current) {
      startButton.current.focus()
    }
  }

  return (
    <div className="Skip">
      <SkipButton ref={startButton} onClick={skipAfter}>
        Skip {name}
      </SkipButton>
      {children}
      <SkipButton ref={endButton} onClick={skipBefore}>
        Skip before {name}
      </SkipButton>
    </div>
  )
}

如果我们运行此代码,当我们进入一组任务时,我们会看到SkipButton,并且当我们点击 Enter 键时,焦点将移动到任务列表末尾的SkipButton

然而,我们不是要跳到endButton,而是想要聚焦于endButton后面的内容。就像我们希望跳到列表末尾的按钮,然后立即按 Tab 键到达下一个内容。如果我们创建一个函数来执行 Tab 操作,我们就可以做到这一点:^(9)

const focusableSelector = 'a[href], ..., *[contenteditable]'

function focusNextElement() {
  var focusables = document.querySelectorAll(focusableSelector)
  var current = document.querySelectorAll(':focus')
  var nextIndex = 0
  if (current.length === 1) {
    var currentIndex = Array.prototype.indexOf.call(
      focusables,
      current[0]
    )
    if (currentIndex + 1 < focusables.length) {
      nextIndex = currentIndex + 1
    }
  }

  focusables[nextIndex].focus()
}

此代码查找 DOM 中所有可以使用 Tab 键导航到的元素。然后它在列表中搜索,直到找到当前具有焦点的元素,然后将焦点设置为下一个元素。

我们可以编写一个类似的函数称为focusPreviousElement,用于程序化地执行反向 Tab 操作。然后我们可以添加我们的Skip组件:

import { useRef, useState } from 'react'
import {
  focusNextElement,
  focusPreviousElement,
} from './focusNextElement'
import SkipButton from '../SkipButton'
import './Skip.css'

const Skip = ({ children, name }) => {
  const startButton = useRef()
  const endButton = useRef()

  const skipAfter = () => {
    if (endButton.current) {
      endButton.current.focus()
      focusNextElement()
    }
  }
  const skipBefore = () => {
    if (startButton.current) {
      startButton.current.focus()
      focusPreviousElement()
    }
  }

  return (
    <div className="Skip">
      <SkipButton ref={startButton} onClick={skipAfter}>
        Skip {name}
      </SkipButton>
      {children}
      <SkipButton ref={endButton} onClick={skipBefore}>
        Skip before {name}
      </SkipButton>
    </div>
  )
}

当我们进入一组任务(例如办公室)时,我们会看到一个SkipButton,它让我们可以跳过整个组,继续到接下来的内容。

我们只需再添加一个功能。我们只希望在进入跳过区域时显示SkipButton,而不是在离开时显示。我们可以通过保持一个名为inside的状态变量来实现这一点,该变量跟踪当前组件内外焦点的状态:

import { useRef, useState } from 'react'
import {
  focusNextElement,
  focusPreviousElement,
} from './focusNextElement'
import SkipButton from '../SkipButton'
import './Skip.css'

const Skip = ({ children, name }) => {
  const startButton = useRef()
  const endButton = useRef()
  const [inside, setInside] = useState(false)

  const skipAfter = () => {
    if (endButton.current) {
      endButton.current.focus()
      focusNextElement()
    }
  }
  const skipBefore = () => {
    if (startButton.current) {
      startButton.current.focus()
      focusPreviousElement()
    }
  }

  return (
    <div
      className="Skip"
      onFocus={(evt) => {
        if (
          evt.target !== startButton.current &&
          evt.target !== endButton.current
        ) {
          setInside(true)
        }
      }}
      onBlur={(evt) => {
        if (
          evt.target !== startButton.current &&
          evt.target !== endButton.current
        ) {
          setInside(false)
        }
      }}
    >
      <SkipButton
        ref={startButton}
        tabIndex={inside ? -1 : 0}
        onClick={skipAfter}
      >
        Skip {name}
      </SkipButton>
      {children}
      <SkipButton
        ref={endButton}
        tabIndex={inside ? -1 : 0}
        onClick={skipBefore}
      >
        Skip before {name}
      </SkipButton>
    </div>
  )
}

我们的跳过区域现在已经完成。如果用户通过 Tab 键进入任务组,将会出现一个SkipButton。他们可以使用该按钮跳过该组,并继续进行下一步操作。

讨论

在过多应用跳过区域时,您需要谨慎。最好的用法是用于跳过用户否则需要通过 Tab 键浏览的许多组件。

您可以采取其他方法。例如,如果您的页面包含一系列标题和副标题,您可以考虑添加SkipButton,使用户可以跳转到下一个标题(如果他们向前 Tab)或上一个标题(如果他们向后 Tab)。

一些用户将使用辅助功能软件,使其能够跳过组和组件的部分,而无需在应用程序中添加任何额外的代码。在这些情况下,SkipButton不会显示在页面上,并且用户将完全忽略它们。

您可以从GitHub 网站下载此示例的源代码。

捕捉模态中的范围

问题

React 应用程序经常显示弹出窗口。例如,在本章中使用的示例任务应用程序在单击任务时显示弹出对话框。该对话框允许用户编辑任务的详细信息(见图 9-22)。

图 9-22. 用户单击任务后会出现编辑对话框

这些弹出窗口通常是模态的,这意味着我们要么与它们交互,要么在返回应用程序的其余部分之前将其关闭。但是,自定义模态对话框可能会存在一个问题:焦点可能会从中逃逸。

让我们看一下示例应用程序中的任务表单。早期版本的代码存在这个泄漏焦点的问题。如果用户点击一个任务,他们将看到任务表单,第一个字段将立即获得焦点。但如果用户按回 Tab 键,则焦点将移动到背景中的其他项目中(见图 9-23)。

图 9-23。按回 Tab 键将焦点移出对话框,转到Charts任务。

如果您能看到焦点已经转移到了哪里,这是一个稍微奇怪的功能。但对于使用可访问性软件的人来说,这可能是一个重大的困惑源,他们可能完全不知道模态对话框仍然在屏幕上。如果有人可以看到屏幕但无法使用鼠标,则体验可能会更加奇怪。用户可能能够聚焦到被对话框隐藏的组件上。

我们需要一种方法来限制焦点在一组组件内,以便用户不能意外地移动到本应不可及的组件中。

解决方案

我们将安装 React Focus Lock 库,它将限制焦点在一小部分组件内。您可以使用以下命令安装它:

$ npm install react-focus-lock

React Focus Lock 库通过在ReactFocusLock中包装一组组件来工作,它将监视焦点,并等待焦点移动到其外部。如果发生这种情况,它将立即将焦点移回内部。

在我们示例应用中的模态框是使用Modal组件创建的:

import './Modal.css'

function Modal({ open, onCancel, children, role, title }) {
  if (!open) {
    return null
  }

  return (
    <div role="presentation" className="Modal" ...>
      <div className="Modal-dialog" role={role} title={title} ...>
        {children}
      </div>
    </div>
  )
}

我们将模态框的整个内容作为子组件传递。我们可以使用 React Focus Lock 库通过将它们包装在ReactFocusLock中来将焦点限制在这些子组件内部:

import ReactFocusLock from 'react-focus-lock'
import './Modal.css'

function Modal({ open, onCancel, children, role, title }) {
  if (!open) {
    return null
  }

  return (
    <div role="presentation" className="Modal" ...>
      <div className="Modal-dialog" role={role} title={title} ...>
        <ReactFocusLock>{children}</ReactFocusLock>
      </div>
    </div>
  )
}

现在,如果用户打开TaskForm并开始按 Tab 键,他们将在对话框内循环浏览按钮和字段。如果他们在最后一个按钮后按 Tab 键,则将移动到第一个字段,反之亦然。

该库的工作原理是创建一个隐藏的按钮,其中tabIndex设置为 1,违反了 axe-core 的 tabindex 规则,该规则指出 tabindex 不应大于 0。如果这造成问题,您可以禁用 tabindex 规则。例如,在 cypress-axe 中,在执行页面审核之前,您可以运行cy.configureAxe({rules: [{ id: 'tabindex', enabled: false }]})

讨论

我们的示例应用程序使用自定义模式对话框,从而展示了为何这通常是一个不好的做法。如果您使用来自 Material UI 等库的对话框和其他组件,通常会免费获得许多可访问性功能。此外,库通常会在 React 应用程序的“root” div之外创建浮动元素。然后,它们将整个“root” divaria-hidden属性设置为true,有效地隐藏了屏幕阅读器和其他可访问性软件的整个应用程序余下的部分。

要查看一个无障碍模态的优秀示例,请看来自 ReactJS 团队的React Modal

您可以从GitHub 网站下载此示例的源代码。

使用 Speech API 创建页面阅读器

问题

您可以使用许多工具来检查无障碍性,但是了解特定需求人员如何使用您的应用程序是很困难的。这就是为什么创建无障碍应用程序的最佳方法是让必须使用辅助设备的人士参与建立和测试您的代码。

对于我们其他人来说,通过辅助软件体验使用应用程序的“感觉”仍然是有帮助的。但是存在问题。盲文阅读器依赖用户阅读盲文的能力。能够读取您应用程序内容的软件是一个不错的选择,但大多数屏幕阅读器价格昂贵。Mac 自带名为 VoiceOver 的内置屏幕阅读器,具有许多功能,可以让您在屏幕上跳转。但并非每个人都使用 Mac。

Chrome 有一个名为 ChromeVox 的扩展,效果很好,但只适用于 Chrome,并且似乎不再积极开发。

除了所有这些问题外,屏幕阅读器还会想要告诉您有关一切的信息。您可能希望使用屏幕阅读器查看应用程序的某些部分是什么样子,但当您切换回 IDE 或其他浏览器标签中的一些参考材料时,它将继续朗读给您。

尽管存在所有这些问题,尝试体验应用程序的音频版本仍然值得一试。至少,它会让您对我们大多数人在编写可供人们使用的软件方面表现得多么糟糕有所了解。

我们可以做些什么来尝试使用屏幕阅读器测试我们的应用程序?

解决方案

我们将创建一个简单的屏幕阅读器——非常、非常简单的屏幕阅读器。它不会达到专业质量,但它会通过键盘和音频反馈来提供使用应用程序的一些体验。它还将在我们的本地 React 应用程序上运行,并且不会影响我们机器上的其他页面或桌面应用程序。它被称为 TalkToMe。^(10)

我们将在本章中始终使用的示例任务应用程序中添加少量代码。我们不希望屏幕阅读器代码包含在我们代码的生产版本中,因此我们将首先在主源文件夹中添加一个名为talkToMe.js的文件:

function talkToMe() {
  if (
    process.env.NODE_ENV !== 'production' &&
    sessionStorage.getItem('talkToMe') === 'true'
  ) {
    ...
  }
}

通过检查NODE_ENV值,我们可以将代码限制在开发环境中运行。我们还检查了名为talkToMe的会话存储变量。只有当此变量存在且具有值"true"时,我们才会运行屏幕阅读器。

我们需要代码来读取具有焦点的当前元素的详细信息。焦点事件不会冒泡,这意味着我们不能简单地将onFocus事件处理程序附加到高级元素并开始跟踪焦点。

然而,我们可以侦听focusin事件。我们可以将focusin侦听器附加到document对象上,每当用户移动到新组件时,它将被调用:

function talkToMe() {
  if (
    process.env.NODE_ENV !== 'production' &&
    sessionStorage.getItem('talkToMe') === 'true'
  ) {
    document.addEventListener('focusin', (evt) => {
      if (sessionStorage.getItem('talkToMe') === 'true') {
        ....
      }
    })
  }
}

注意,我们对talkToMe项目进行了额外的检查,以防用户在使用应用程序时关闭了它。

我们需要一些方法来描述当前聚焦的元素。此函数将基于其名称、角色等提供当前元素的粗略描述:

function getDescription(element) {
  const nodeName = element.nodeName.toUpperCase()
  const role = element.role
    ? element.role
    : nodeName === 'BUTTON'
    ? 'button'
    : nodeName === 'INPUT' || nodeName === 'TEXTAREA'
    ? 'text field ' + element.value
    : nodeName === 'SELECT'
    ? 'select field ' + element.value
    : element.getAttribute('role') || 'group'
  const title = element.title || element.textContent
  const extraInstructions =
    nodeName === 'INPUT' || nodeName === 'TEXTAREA'
      ? 'You are currently in a text field. To enter text, type.'
      : ''
  return role + '. ' + title + '. ' + extraInstructions
}

现在我们可以获取当前聚焦元素的描述:

function talkToMe() {
  if (
    process.env.NODE_ENV !== 'production' &&
    sessionStorage.getItem('talkToMe') === 'true'
  ) {
    document.addEventListener('focusin', (evt) => {
      if (sessionStorage.getItem('talkToMe') === 'true') {
        const description = getDescription(evt.target)
        ....
      }
    })
  }
}

现在我们需要将描述的文本转换为语音。为此,我们可以使用现在大多数浏览器都包含的 Web Speech API。语音合成器接受一个称为utterance的对象:

window.speechSynthesis.speak(
  new SpeechSynthesisUtterance(description)
)

在开始朗读文本片段之前,我们首先需要检查是否已经在处理其他内容。如果是这样,我们将取消旧的朗读并开始新的朗读,这将允许用户在听到足够的信息后快速跳转到组件:

if (window.speechSynthesis.speaking) {
  window.speechSynthesis.cancel()
}
window.speechSynthesis.speak(
  new SpeechSynthesisUtterance(description)
)

这给了我们最终版本的talkToMe

function talkToMe() {
  if (
    process.env.NODE_ENV !== 'production' &&
    sessionStorage.getItem('talkToMe') === 'true'
  ) {
    document.addEventListener('focusin', (evt) => {
      if (sessionStorage.getItem('talkToMe') === 'true') {
        const description = getDescription(evt.target)
        if (window.speechSynthesis.speaking) {
          window.speechSynthesis.cancel()
        }
        window.speechSynthesis.speak(
          new SpeechSynthesisUtterance(description)
        )
      }
    })
  }
}

现在我们可以通过在应用程序的index.js文件顶部调用它来添加talkToMe

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import reportWebVitals from './reportWebVitals'
import talkToMe from './talkToMe'

talkToMe()

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()

如果现在在浏览器中打开应用程序,打开开发者控制台,并创建一个名为talkToMe的新会话存储变量,将其设置为字符串“true”,则在按 Tab 键切换元素时,您应该听到元素的描述。

讨论

talkToMe屏幕阅读器只不过是一个玩具,但它将帮助您在代码中创建简明的标题和其他元数据,强调“前置加载”信息的重要性。用户越早能决定某个元素不是他们正在寻找的,他们就越快可以继续。它还将清楚地显示出您的应用程序中哪些部分难以导航,并允许您在不查看屏幕的情况下尝试应用程序。

您可以从GitHub 网站下载此示例的源代码。

^(1) 有关围绕菜单和菜单项问题的有趣讨论,请参见 Adrian Roselli 的这篇文章

^(2) 在撰写本章时,我们自己发现了这一点。因此,在示例应用程序中,我们无疑错过了许多无障碍问题。

^(3) 如果您想在预提交 Git 钩子或集成服务器上检查代码,这将特别有用。

^(4) 有关角色及其用途的详细信息,请参见“应用角色、替代文本和标题”。

^(5) 参见“在模态框中捕获范围”中涉及大于零值的问题。

^(6) 当你阅读本书时,这样的规则可能已经存在。

^(7) 您可以根据需要为此文件命名,只要它具有 .js 扩展名并且位于集成目录内。

^(8) 有关 main 部分的更多信息,请参阅 “使用地标”。

^(9) 这是基于用户 RadekStackOverflow 上回答的问题的答案。

^(10) 感谢 Terry Tibbs 在编写此工具时提供的帮助。

第十章:性能

我们中的一位计算机科学讲师曾在一堂课上开头说过:“你永远不应该、绝对不应该试图优化你的代码。但当你确实需要优化你的代码时,这是你应该如何做的方法。”

正如 Donald Knuth 曾经说过的那样,过早优化是万恶之源。你应该先让你的代码工作起来。然后让你的代码易于维护。只有在有问题时,你才应该考虑让你的代码快速运行。慢速但工作正常的代码永远比快速但不可用的代码好。

话虽如此,性能可能成为重要问题的时候。如果你的应用加载时间超过几秒,可能会失去永不返回的用户。在低功率设备上,慢速可能导致无法使用。本章将采用我们称之为本质主义的性能方法。你应该很少调整你的代码,但当你需要时,你应该调整正确的代码。我们将看看各种工具和技术,让你能够追踪和测量性能瓶颈,以便在必要时应用性能修复,确保它们应用在正确的地方,并且有一些方法来衡量它们带来的改变。

所有性能修复都会带来成本。如果使客户端代码更快,可能会消耗更多内存或更多服务器时间。你几乎总是需要添加更多的代码和更多的复杂性。

本章中的示例按照我们建议的处理性能问题的顺序排列。我们从浏览器中的高级测量开始,探讨如何客观地识别性能瓶颈。如果找到了瓶颈,我们将向您展示如何使用 React 内置的Profiler组件来跟踪导致问题的各个组件。然后,我们会深入探讨更低级别和更精确的性能测量方法,精确到亚毫秒级别。

只有在您能够精确测量性能之后,才能考虑提高代码的速度。

我们接下来将向您展示一些改善应用性能的简单方法。有些方法很简单,比如将代码拆分成更小的包或组合异步网络调用。其他方法则更为复杂,比如在服务器上预渲染页面。

总结一下:本章更多关注的是性能测量而不是性能调优。因为你永远不应该、绝对不应该优化你的代码,但当你确实需要时,你应该从测量开始。

使用浏览器性能工具

问题

值得一提的是,在确信存在问题之前,最好延迟性能调优。从某种意义上说,只有当用户注意到你的应用程序性能不佳时,你才真正面临问题。但如果等到用户注意到了,可能为时已晚。因此,有必要设定一些客观的度量标准,用以判断何时需要对应用程序进行调优,而不只是寻找可能运行更快的代码。几乎总能使代码运行更快,许多开发人员花费了大量时间调优代码,却没有对用户体验产生显著影响。

最好有一个工具,可以专注于在哪些地方可能需要优化你的代码。

解决方案

检查性能的最佳方法是使用浏览器。最终,用户的体验是唯一重要的。因此,我们将查看各种内置于浏览器的工具,这些工具将提供客观的度量,并找出代码潜在的瓶颈。

我们将首先看一下 Chrome 中名为 Lighthouse 的内置工具。

谷歌为 Firefox 生产了一个名为 Google Lighthouse 的附加组件。虽然它效果很好,但它只是 Google Page Speed 服务的前端,因此只能用于公开的 Web 页面。但是,你可以在 Chrome 上的任何页面上使用 Lighthouse 扩展。

Lighthouse 扩展是检查应用程序基本适用性的好方法。除了检查性能外,Lighthouse 还会审查网页的可访问性以及是否遵循最佳网络实践。它还将检查您的页面是否为搜索引擎机器人优化,并查看您的 Web 应用程序是否符合被视为渐进式 Web 应用程序的标准(见图 10-1)。

图 10-1. Lighthouse 检查的指标

你可以以两种方式运行 Lighthouse 审计:在命令行上或在浏览器中。

如果你想在命令行上运行审计,你首先需要安装 Lighthouse 命令:

$ npm install -g lighthouse

然后,你可以使用 lighthouse 命令来运行审核:

$ lighthouse http://localhost:3000

Lighthouse 的命令行版本只是 Google Chrome 浏览器的自动化脚本。它的优势在于生成审核的 HTML 报告,适合在持续集成服务器上使用。

你也可以在 Google Chrome 中交互地使用 Lighthouse。最好在隐身窗口中进行此操作,这样可以减少其他扩展程序和存储与 Lighthouse 审计的干扰。一旦启动了 Chrome 并打开了你的应用程序,进入开发者工具,然后切换到 Lighthouse 标签(见图 10-2)。

图 10-2. 带有 Chrome 开发工具的 Lighthouse 标签页

然后点击生成审核按钮。 Lighthouse 将刷新您的页面多次并执行一系列审核。性能审核将集中在六个不同的指标上(见图 10-3)。

图 10-3. Lighthouse 性能审核测量的六个 Web 核心指标

这些指标被称为Web 核心指标。Web 核心指标是您在应用程序运行中跟踪性能的指标。

首次内容绘制(FCP)是浏览器开始渲染内容所需的时间。FCP 将显著影响用户对性能的感知。在 FCP 之前,用户将只看到空白屏幕,如果这种情况持续时间过长,用户可能会关闭浏览器并转向其他地方。

Lighthouse 测量 FCP 所需时间,然后将其与 Google 全球记录的性能统计数据进行比较。如果您的应用程序在全球 FCP 的前 25%中,它将标记为绿色。目前,绿色评级意味着第一个内容在两秒内呈现。如果在前 75%内,它将给您一个橙色等级,这意味着您的页面开始在四秒内呈现。Lighthouse 将为其他情况给出红色评分。

速度指数(SI)衡量页面视觉稳定化所需的时间。它通过录制视频并检查帧之间的差异来进行视觉检查。

Lighthouse 将 SI 指标与全球网站性能进行比较。如果 SI 小于 4.3 秒,则您在全球网页的前 25%中,Lighthouse 将给出绿色评级。如果小于 5.8 秒,则您将在前 75%中,并获得橙色评级。其他情况下都会被评为红色。

最大内容绘制(LCP)发生在浏览器视口完全加载时。其他内容可能仍在视野之外加载,但 LCP 是用户感觉到页面可见的时候。要获得绿色评级,LCP 需要在 2.5 秒内完成。对于橙色评级,它需要少于 4 秒。其他情况下都会被评为红色。服务器端渲染可以显著提高 LCP 评级。

交互时间(TTI)是在您可以使用鼠标和键盘与页面交互之前所需的时间。在 React 中,这发生在第一个完整渲染之后,当 React 附加了事件处理程序时。您希望这个时间少于 3.8 秒以获得绿色评级。如果您可以获得 7.3 秒或更少的 TTI,您将获得橙色评级。其他情况下都会被评为红色。您可以通过延迟加载第三方 JavaScript 或代码分割来改善 TTI。^(1)

总阻塞时间(TBT)是在 FCP 和 TTI 之间发生的所有阻塞任务的总和。阻塞任务是任何超过 50 毫秒的任务。这大约是电影中显示一帧所需的时间,超过 50 毫秒的任何事情开始变得显著。如果阻塞任务过多,浏览器将开始感觉像是冻结。因此,TBT 的等级涵盖了较短的时间段。如果 TBT 小于 300 毫秒,Lighthouse 将评估您的页面为绿色。600 毫秒以内为橙色,其他情况为红色。高 TBT 分数会使用户感觉浏览器超负荷运行。通常通过减少 JavaScript 代码运行或减少 DOM 扫描次数来改善 TBT。最有效的技术可能是代码拆分。

累积布局偏移(CLS)衡量的是您网页的跳动性或视觉稳定性。如果您的应用在页面加载期间插入额外内容并移动其他内容,这将开始影响 CLS 指标。CLS 是页面在加载期间移动的比例。

Lighthouse 报告中未包括的首次输入延迟(FID)指标是用户向页面发送事件(例如点击按钮)和 JavaScript 处理程序接收事件之间所需的时间。您希望 FID 不超过 300 毫秒。FID 与 TBT 密切相关,因为阻塞事件通常由事件处理程序创建。

除了提供页面主要指标的审计外,Lighthouse 报告还将包括修复任何发现问题的建议。

Lighthouse 是检查性能问题的绝佳起点。它并非详尽检查,但会突出显示您可能没有注意到的问题。

许多因素(带宽、内存、CPU 等)都会影响 Lighthouse 的审核结果,因此您可以预期结果会因运行而异。像WebPageTestGTmetrix这样的在线服务可以从世界各地的多个位置对您的应用程序进行审核,这将为您提供比针对http://localhost:3000运行的 Lighthouse 审核更真实的应用程序速度视图。

虽然 Lighthouse 擅长突出性能问题的存在,但在找出问题的根本原因方面不太有帮助。可能是网页的代码过大或速度太慢。也可能是服务器响应迟缓。甚至可能是资源问题,如内存不足或缓存大小过大。

要找出瓶颈存在的原因,我们接下来可以访问浏览器本身的性能工具。

如果您正在使用 Firefox 或 Chrome,您可以通过在隐身窗口中打开您的页面,然后转到开发工具中的性能选项卡(见图 10-4)来访问性能控制台。

图 10-4. 浏览器开发工具中的性能选项卡

性能选项卡就像浏览器的引擎管理系统。在那里,您可以跟踪内存使用情况、任何 CPU 阻塞程序、DOM 中的元素数量等等。要收集统计信息,您需要单击工具栏上的记录按钮,然后与页面进行交互几秒钟,然后停止录制。性能系统将追踪您选择的所有内容。例如,在图 10-5 的示例中,您可以看到当用户单击按钮时发生了阻塞操作(请参阅早期的 TBT),浏览器在事件处理程序返回之前阻塞了 60.92 毫秒。

图 10-5. 放大以调查长时间运行的任务

性能选项卡为您提供了在性能调优时可能需要的所有统计数据。它可能包含的细节远远超出您可能需要的范围。因此,您可能希望安装 React 开发者工具,它们适用于ChromeFirefox

安装 React 开发者工具后,您可能会发现它们默认情况下无法在隐身模式中运行。启用它们是值得的(参见 Chrome 的图 10-6 和 Firefox 的图 10-7)。

图 10-6. 在 Chrome 的隐身模式中启用 React Dev 工具

图 10-7. 在 Firefox 的私密模式中启用 React Dev 工具

与浏览器的性能工具类似,React 开发者工具需要您通过单击开发者面板左上角的记录按钮来记录性能会话(参见图 10-8)。

图 10-8. Chrome DevTools 中的 React Profiler 选项卡

记录会话后,性能统计数据将显示,并与呈现网页的 React 组件相关联。如果某个组件显示时间过长,您可以将鼠标悬停在性能结果上,并在页面上看到其高亮显示(请参见图 10-9)。

React 开发者工具通常是识别性能问题根本原因的最佳交互工具。但是,如同往常一样,如果用户或像 Lighthouse 这样的更高级别工具发现存在性能瓶颈,您应该考虑调优性能。

图 10-9. 如果您在火焰图中悬停在一个组件上,它将在页面上被高亮显示

讨论

如果您在性能方面采取了“本质主义”的方法,您应该始终从浏览器开始,无论是使用应用程序还是使用我们在此处讨论的内置工具或扩展。

使用 Profiler 跟踪渲染

问题

浏览器工具提供了丰富的性能详细信息,它们应该始终是您寻找底层性能问题原因的首选位置。

一旦您确定了问题,获取应用程序的某个部分的更详细的性能统计信息可能会有所帮助。在进行更改前后收集实际的性能数据是提升性能的唯一途径。使用浏览器扩展可能会很难做到这一点,因为它们会向您提供关于所有内容的大量信息。

我们如何获取我们正在调优的应用程序部分的性能统计信息?

解决方案

我们将使用 React 的Profiler组件。您可以将Profiler组件包装在您要调优的应用程序的任何部分周围。每当 React 渲染它时,它将记录性能统计信息,并向您显示几个重要的信息:

统计 目的
阶段 是安装还是更新引起了渲染
实际持续时间 如果不应用内部缓存,渲染完成所需的时间
基础持续时间 使用缓存进行渲染所花费的时间
开始时间 页面加载后的毫秒数
提交时间 当渲染结果进入浏览器的 DOM 时
交互 我们当前正在跟踪的任何事件处理程序

要了解Profiler组件的工作原理,让我们开始检查您可以在图 10-10 中看到的示例应用程序。

图 10-10. 示例日历应用程序

这是App组件的代码:

import { useState } from 'react'
import { unstable_trace as trace } from 'scheduler/tracing'
import './App.css'

function App({ onRender }) {
  const [year, setYear] = useState(2023)

  return (
    <div className="App">
      <h1>Year: {year}</h1>
      <button onClick={() => setYear((y) => y - 1)}>Previous</button>
      <button onClick={() => setYear((y) => y + 1)}>Next</button>
      <br />
      <YearCalendar year={year} onRender={onRender} />
    </div>
  )
}

export default App

应用程序显示两个按钮:一个用于向前移动一年,另一个用于向后移动。

我们可以首先将按钮和日历组件包装在Profiler组件中:

import { useState, Profiler } from 'react'
import { unstable_trace as trace } from 'scheduler/tracing'
import './App.css'

function App({ onRender }) {
  const [year, setYear] = useState(2023)

  return (
    <div className="App">
      <h1>Year: {year}</h1>
      <Profiler id="app" onRender={() => {}}>
        <button onClick={() => setYear((y) => y - 1)}>
          Previous
        </button>
        <button onClick={() => setYear((y) => y + 1)}>Next</button>
        <br />
        <YearCalendar year={year} onRender={onRender} />
      </Profiler>
    </div>
  )
}

export default App

Profiler接受一个id和一个回调函数onRender。每次Profiler被渲染时,它都会将统计信息发送回onRender函数。因此,让我们更详细地填写onRender函数的细节:

import { useState, Profiler } from 'react'
import { unstable_trace as trace } from 'scheduler/tracing'
import './App.css'

let renders = []
let tracker = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime,
  interactions
) => {
  renders.push({
    id,
    phase,
    actualDuration,
    baseDuration,
    startTime,
    commitTime,
    interactions: JSON.stringify(Array.from(interactions)),
  })
}

function App({ onRender }) {
  const [year, setYear] = useState(2023)

  return (
    <div>
      ....
      <Profiler id="app" onRender={tracker}>
        ....
      </Profiler>
      <button onClick={() => console.table(renders)}>Stats</button>
    </div>
  )
}

tracker函数将在名为renders的数组中记录Profiler的每个结果。我们还在界面上添加了一个按钮,每当我们点击它时,它将以表格格式在控制台中显示渲染结果。

如果我们重新加载页面并点击几次“前进”和“后退”按钮,然后点击“统计”按钮,我们将在控制台上看到概要统计信息(见图 10-11)。

数据以表格格式呈现,这使得阅读起来稍微容易一些。这还意味着我们可以按任何列排序。我们还可以复制整个表格并将其粘贴到电子表格中进行更多分析。

图 10-11. JavaScript 控制台中显示的渲染统计信息

您会注意到交互列始终是一个空数组。这是因为我们目前没有追踪任何事件处理程序或其他代码片段。如果我们想要查看在渲染过程中当前正在运行的事件处理程序,我们可以导入一个跟踪函数并将其包装在我们想要追踪的任何代码周围。例如,这是我们如何开始跟踪用户点击“上一页”按钮的方式:

import { unstable_trace as trace } from 'scheduler/tracing'
...
<button
  onClick={() => {
    trace('previous button click', performance.now(), () => {
      setYear((y) => y - 1)
    })
  }}
/>

trace 函数接受一个标签、一个时间戳和一个包含它正在追踪的代码的回调函数。时间戳可以是一个日期,但通常最好使用从 performance.now() 返回的毫秒数。

如果我们重新加载网页,点击几次“下一页”,然后点击几次“上一页”,我们将开始看到这些交互出现在结果表格中(见图 10-12)。

图 10-12. 在结果表格中显示跟踪的交互作为 JSON 字符串

我们对输出进行字符串化,因为 trace 将交互存储为 JavaScript 集合,这些集合通常在控制台中不正确显示。尽管交互数据在表格中看起来被截断,但您仍然可以复制结果。以下是单个跟踪交互返回的数据示例:

[
    {
        "__count":1,
        "id":1,
        "name":"previous button click",
        "timestamp":4447.909999988042
    }
]

讨论

Profiler 组件从 React 版本 16.4.3 开始存在。trace 函数仍处于实验阶段,但它具有巨大的功能。虽然在我们的示例中仅使用了一个简单的事件处理程序,但它还可以为更大的代码块(例如网络请求)提供真实的时间信息。React 容器组件在渲染期间通常会有许多正在进行中的网络请求,trace 函数使您能够查看在特定慢渲染时发生了什么。它还能让您大致了解多少次渲染是由一整套不同的网络流程链导致的。

您可以从GitHub 网站下载此示例的源代码。

创建性能分析单元测试

问题

React 的 Profiler 是一个强大的工具。它让您可以访问 React 开发者工具中提供的相同性能分析信息。它的优势在于您可以专注于正在尝试优化的代码。

然而,它仍然依赖于您与网页的交互。在进行代码更改之前和之后,您会想测试性能。但是如何确保您在之前和之后测量的时间是相同的?如果进行手动测试,如何保证每次执行相同的一组操作?

解决方案

本文将介绍如何创建调用 Profiler 代码的单元测试。自动化测试将允许我们创建可重复运行的性能测试,以验证我们所做的任何优化是否真正对性能产生影响。

在单元测试中,我们可以在 Web 浏览器之外渲染 React 组件,因为 Testing Library 提供了一个无头 DOM 的实现。

要了解如何使用 Profiler,我们将再次查看示例日历应用程序(参见 图 10-13)。

图 10-13. 示例日历应用程序

我们可以将 Profiler 组件添加到 App 组件的主要代码中,然后允许任何其他代码传递一个 onRender 方法,该方法可用于跟踪渲染性能:

import { useState, Profiler } from 'react'
import YearCalendar from './YearCalendar'
import { unstable_trace as trace } from 'scheduler/tracing'
import './App.css'

function App({ onRender }) {
  const [year, setYear] = useState(2023)

  return (
    <div className="App">
      <h1>Year: {year}</h1>
      <Profiler id="app" onRender={onRender || (() => {})}>
        <button
          onClick={() => {
            trace('previous button click', performance.now(), () => {
              setYear((y) => y - 1)
            })
          }}
        >
          Previous
        </button>
        <button onClick={() => setYear((y) => y + 1)}>Next</button>
        <br />
        <YearCalendar year={year} onRender={onRender} />
      </Profiler>
    </div>
  )
}

export default App

我们还可以将 onRender 函数传递给子组件,以跟踪它们的渲染性能。在上述代码中,我们将 onRender 传递给 YearCalendar,它可以在其自己的 Profiler 组件中使用它,或者将其传递到组件树的更深层次。

通过创建提供程序组件,可以避免将 onRender 传递给子组件并将其注入到当前上下文中。我们这里没有这样做是为了保持代码的简单性。但书中其他地方有各种提供程序的例子。例如,参见 “安全请求,而不是路由” 中的 SecurityProvider

Profiler 组件必须具有 id 属性和 onRender 属性。当应用程序正常运行时,App 组件不会传递 onRender 属性,因此我们需要提供一个默认函数:

<Profiler id='app' onRender={onRender || (() => {})}>

Profiler 组件相对轻量,通常不会减慢应用程序的性能。如果忘记从代码中移除 Profiler,也没有关系。Profiler 仅在开发模式下运行。创建生产版本时,将从代码中删除它。

现在我们可以开始构建单元测试:

import { render, screen } from '@testing-library/react'
import user from '@testing-library/user-event'
import App from './App'

let renders = []
let tracker = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime,
  interactions
) => {
  renders.push({
    id,
    phase,
    actualDuration,
    baseDuration,
    startTime,
    commitTime,
    interactions: JSON.stringify(Array.from(interactions)),
  })
}

let startTime = 0

describe('App', () => {
  beforeEach(() => {
    renders = []
    startTime = performance.now()
  })
  afterEach(() => {
    console.log('Time taken: ', performance.now() - startTime)
    console.table(renders)
  })
  it('should move between years', async () => {
    render(<App onRender={tracker} />)
    user.click(screen.getByRole('button', { name: /previous/i }))
    user.click(screen.getByRole('button', { name: /previous/i }))
    user.click(screen.getByRole('button', { name: /previous/i }))
    user.click(screen.getByRole('button', { name: /next/i }))
    user.click(screen.getByRole('button', { name: /next/i }))
    user.click(screen.getByRole('button', { name: /next/i }))
  }, 30000)
})

测试时间超过五秒钟可能会违反 Jest 的超时限制。避免此限制的最简单方法是在 it 函数调用中添加超时参数,就像我们在这里做的那样,将超时设置为 30,000 毫秒。您需要根据测试的复杂性调整此值。

运行此测试时,在控制台中会捕获大量数据(参见 图 10-14)。

图 10-14. 单元测试将捕获大量的渲染信息

值得注意的是,该测试是可重复的。每次执行时都会执行相同的操作。我们发现单元测试通常比在浏览器中运行的代码更一致。前一个测试的重复运行总时间为 2,100 毫秒 +/- 20 毫秒。这个变化小于 1%。它们每次还产生了确切的 2,653 个分析分数。

在浏览器中进行手动测试,很难获得可重复的结果。

在这里的示例中,我们只是显示捕获的结果。在实际性能情况下,您可能希望以某种方式处理结果,以找到特定组件的平均渲染时间,例如。然后,当您开始调整组件时,可以更有信心地确保任何性能提升都是由于实际性能变化而不是浏览器行为的变化。

讨论

即使我们在 Jest 单元测试中编写了这段性能测试代码,它并不像常规功能测试那样是一个测试;我们没有执行任何断言。断言仍然可能有所帮助,^(2)但编写断言某些操作比给定时间快的性能测试并不好。性能测试高度依赖于环境。如果您在开发环境上编写了一个断言某些事情将花费少于三秒的测试,那么如果它在集成服务器上花费了九秒,您不应感到惊讶。

如果您确实想要自动跟踪性能,您可以考虑添加回归检查。回归检查将在某个中央存储库中记录一组性能统计信息,并记录生成这些统计信息的环境的 ID。您可以检查将来在相同环境中运行的速度是否显著慢于在相同环境中历史运行的速度。

总体而言,报告性能结果比断言所需的性能更为重要。

您可以从GitHub 网站下载此配方的源代码。

精确测量时间

问题

一旦您需要优化非常底层的 JavaScript 代码,您应该使用什么来测量性能?例如,您可以使用Date()函数在某些 JavaScript 代码的开头和结尾创建时间戳:

const beforeDate = new Date()
for (let i = 0; i < 1000; i++) {}
const afterDate = new Date()
console.log(
  '1,000 loops took',
  afterDate.getTime() - beforeDate.getTime()
)

我们可以将每个日期转换为毫秒,这样我们就可以看到从一个日期减去另一个日期需要多长时间。

这是一种常见的技术,console对象被赋予了称为timetimeEnd的新方法,以缩短代码:

console.time('1,000 loops')
for (let i = 0; i < 1000; i++) {}
console.timeEnd('1,000 loops')

time函数接受一个标签参数,如果我们使用相同的标签调用timeEnd,它会在控制台上显示结果。让我们运行代码:

1,000 loops: 0ms

这是一个问题。React 应用程序很少包含长时间运行的函数,因此您通常只需要优化浏览器多次调用它们的小段 JavaScript 代码。例如,您可能希望优化在屏幕上渲染动画的游戏代码。由于短代码片段可能运行时间少于一毫秒,因此很难测量它们的性能。我们不能使用Date对象来测量性能,因为它们只能精确到毫秒,即使机器的内部时钟要精确得多。

我们需要一些可以用来测量少于一毫秒时间的东西。

解决方案

我们将使用performance.now()。此函数调用返回以毫秒分数表示的高分辨率时间戳。例如,如果您打开 Chrome 控制台并键入performance.now(),您将看到类似于这样的内容:

> performance.now()
< 10131.62500000908

与 JavaScript 日期中的时间测量方式不同。JavaScript 日期从 1970 年 1 月 1 日开始测量时间。相反,performance.now()从当前网页加载时开始计时。^(3)

如果您尝试在 Firefox 中运行performance.now(),会发生一件有趣的事情:

> performance.now()
< 4194

默认情况下,Firefox 仅返回performance.now()的整数毫秒部分,有效地删除了使用它的大部分优势。Firefox 四舍五入到整数毫秒,这是出于安全考虑。从技术上讲,如果 JavaScript 可以精确地计时微小的代码量,这可以为浏览器提供签名。

您可以通过打开about:config,搜索名为privacy.reduceTimerPrecision的属性,并将其设置为false来在 Firefox 中启用高分辨率时间。如果您这样做,将开始获得高分辨率时间:

performance.now()
151405.8

如果您希望避免第三方使用它来跟踪您,请确保禁用此属性。

要回到我们的示例代码,我们可以测量执行类似于这样的循环所花费的时间:

const before0 = performance.now()
for (let i = 0; i < 1000; i++) {}
const after0 = performance.now()
console.log('1,000 loops took', after0 - before0)
const before1 = performance.now()
for (let i = 0; i < 100000; i++) {}
const after1 = performance.now()
console.log('100,000 loops took', after1 - before1)

当我们运行此代码时,我们看到以下结果:

1,000 loops took 0.03576700000007804
100,000 loops took 1.6972319999999854

这些答案更为精确,并提供有关 JavaScript 底层性能的更多信息。在这种情况下,我们可以看到向循环添加更多迭代并不会线性扩展,这表明 JavaScript 引擎在意识到每个循环迭代相同时开始即时优化代码。

讨论

performance.now()相比于 JavaScript 日期具有几个优点。除了额外的精度外,它不受时钟更改的影响,这在决定添加一些性能监控到长时间运行的代码时非常有用。它还会在页面加载时从零开始计时,这对于优化页面加载时间非常有用。

在使用performance.now()时需要注意一点:谨慎使用它来构建某些高级定时功能。例如,我们曾经创建了一个简单的 JavaScript 生成器函数,使使用performance.now()变得更容易:

function* timekeeper() {
  let now = 0
  while (true) yield -now + (now = performance.now())
}

创建此函数是为了避免需要计算开始和结束时间之间差异的需求。而不是写成这样:

const before0 = performance.now()
for (let i = 0; i < 1000; i++) {}
const after0 = performance.now()
console.log('1,000 loops took', after0 - before0)
const before1 = performance.now()
for (let i = 0; i < 100000; i++) {}
const after1 = performance.now()
console.log('100,000 loops took', after1 - before1)

相反,我们可以这样编写:

const t = timekeeper()
t.next()
for (let i = 0; i < 1000; i++) {}
console.log('1,000 loops took', t.next().value)
for (let i = 0; i < 100000; i++) {}
console.log('100,000 loops took', t.next().value)

没有必要使用所有那些丑陋的beforeafter变量。每次调用t.next().value后时间将重置为零,不再需要计算。

问题在于,将performance.now()调用包装在另一个函数内会显著增加测量时间,从而破坏performance.now()的精度:

1,000 loops took 0.05978800000002593
100,000 loops took 19.585223999999926

在这种情况下,即使运行 100,000 次循环仅需 1.69 毫秒,该函数也会报告超过 19 毫秒的时间。

如果希望准确,请勿将对performance.now()的调用隐藏在另一个函数中。

您可以从GitHub 网站下载此配方的源代码。

使用代码拆分来缩小您的应用程序

问题

SPA 的性能损耗之一是需要下载和运行的 JavaScript 代码量。 JavaScript 不仅需要时间来渲染,而且所需的网络带宽量可能会显著减慢连接到移动网络的设备上的应用程序速度。

让我们考虑我们在 第二章 中使用的 同步路由 应用程序(参见 图 10-15)。

图 10-15. 同步路由应用程序

示例应用程序很小,但包含一些相当大的 JavaScript 捆绑包:

$ ls -l build/static/js
total 1336
-rw-r--r--  1 davidg  admin  161800 12:07 2.4db4d779.chunk.js
-rw-r--r--  1 davidg  admin    1290 12:07 2.4db4d779.chunk.js.LICENSE.txt
-rw-r--r--  1 davidg  admin  461100 12:07 2.4db4d779.chunk.js.map
-rw-r--r--  1 davidg  admin    4206 12:07 3.307a63d5.chunk.js
-rw-r--r--  1 davidg  admin    9268 12:07 3.307a63d5.chunk.js.map
-rw-r--r--  1 davidg  admin    3082 12:07 main.e8a3e1cb.chunk.js
-rw-r--r--  1 davidg  admin    6001 12:07 main.e8a3e1cb.chunk.js.map
-rw-r--r--  1 davidg  admin    2348 12:07 runtime-main.67df5f2e.js
-rw-r--r--  1 davidg  admin   12467 12:07 runtime-main.67df5f2e.js.map
$

最大的 (2.4db4d779.chunk.js) 包含主要的 React 框架代码,而应用程序特定的代码仅限于小的 main.e8a3e1cb.chunk.js 文件。 这意味着该应用程序几乎是一个 React 应用程序可以达到的最小尺寸。 大多数 React 应用程序通常会更大:通常总计 1 Mb 的大小,这对于使用慢速连接的用户来说将是一个显著问题。

那么,在 React 中,我们可以做些什么来减少 JavaScript 捆绑包的大小呢?

解决方案

我们将使用 代码拆分,它涉及将应用程序的主要代码拆分为几个较小的捆绑包。 然后,浏览器将 延迟加载 这些捆绑包。 只有当需要其中一个包含的组件时,才会加载特定的捆绑包。

我们为本示例应用程序使用的应用程序肯定不需要代码拆分。 与所有性能更改一样,只有在拆分代码确实显著改善了 Web 性能时,才应该尝试拆分代码。 我们将在这个应用程序中拆分代码,因为这样做更容易看到它的工作原理。

我们使用 lazy 函数在 React 中拆分代码:

import { lazy } from 'react'

lazy 函数接受一个工厂函数,调用时将导入一个组件。 lazy 函数返回一个占位符组件,在浏览器渲染它之前什么也不做。 占位符组件将运行工厂函数并动态加载包含实际组件的任何捆绑包。

要了解这是如何工作的,请考虑我们示例应用程序中的这个组件:

import { NavLink, Redirect, Route, Switch } from 'react-router-dom'
import People from './People'
import Offices from './Offices'
import './About.css'

const About = () => (
  <div className="About">
    <div className="About-tabs">
      <NavLink
        to="/about/people"
        className="About-tab"
        activeClassName="active"
      >
        People
      </NavLink>
      <NavLink
        to="/about/offices"
        className="About-tab"
        activeClassName="active"
      >
        Offices
      </NavLink>
    </div>
    <Switch>
      <Route path="/about/people">
        <People />
      </Route>
      <Route path="/about/offices">
        <Offices />
      </Route>
      <Redirect to="/about/people" />
    </Switch>
  </div>
)

export default About

当用户访问特定路由时,浏览器将仅渲染 PeopleOffices 组件。 如果应用程序当前位于路径 /about/people,则 Offices 组件将不会渲染,这意味着我们可以推迟加载 Offices 组件直至稍后。 我们可以通过 lazy 函数实现这一点。

我们将 Offices 组件的导入替换为调用 lazy

//import Offices from "./Offices"
const Offices = lazy(() => import('./Offices'))

现在存储在 Offices 变量中的对象将在应用程序的其余部分中显示为另一个组件。 它是一个惰性占位符。 在内部,它包含对工厂函数的引用,在浏览器渲染时将调用该函数。

如果尝试刷新网页,将会看到一个错误(参见 图 10-16)。

图 10-16. 如果忘记添加 Suspense 组件,您将收到延迟加载错误

占位符在返回之前不会等待实际组件加载。相反,它会在等待实际组件加载时替换一些其他 HTML。

我们可以通过 Suspense 容器设置这个“加载”界面:

import { lazy, Suspense } from 'react'
import { NavLink, Redirect, Route, Switch } from 'react-router-dom'
import People from './People'
// import Offices from './Offices'
import './About.css'

const Offices = lazy(() => import('./Offices'))

const About = () => (
  <div className="About">
    <div className="About-tabs">
      <NavLink
        to="/about/people"
        className="About-tab"
        activeClassName="active"
      >
        People
      </NavLink>
      <NavLink
        to="/about/offices"
        className="About-tab"
        activeClassName="active"
      >
        Offices
      </NavLink>
    </div>
    <Suspense fallback={<div>Loading...</div>}>
      <Switch>
        <Route path="/about/people">
          <People />
        </Route>
        <Route path="/about/offices">
          <Offices />
        </Route>
        <Redirect to="/about/people" />
      </Switch>
    </Suspense>
  </div>
)

export default About

lazy 占位符将检查其上下文,找到由 Suspense 提供的 fallback 组件,并在等待额外的 JavaScript 包加载时在页面上显示它。

我们在这里使用了一个简单的“加载中…”消息,但你完全可以显示一些假的替代界面,以给人一种新组件已加载的假象。YouTube 在其首页也使用了同样的技术。当 YouTube 加载内容时,它会显示一组块和矩形,代替即将加载的视频(参见图 10-17)。视频通常需要两到三秒才能加载完毕,但这种技术让用户感觉它们几乎瞬间加载完成。

图 10-17. YouTube 在加载推荐内容时呈现一个假的首页

在我们的应用程序中,如果现在刷新页面,你应该看到应用程序恢复正常,就像图 10-18 所示。

图 10-18. 添加 Suspense 组件可以消除错误

在幕后,Webpack 开发服务器将 Offices 代码拆分为一个单独的 JavaScript 捆绑包。

当生成构建时,Webpack 还会拆分捆绑包。它将使用 tree shaking 来确定哪些组件可以安全地出现在哪些 JavaScript 捆绑包中。

Tree shaking 是一个递归分析代码文件之间导入关系的过程,从某个初始文件(如 index.js)开始。这允许 Webpack 避免将从未被任何其他代码导入的代码添加到捆绑包中。React.lazy 的调用不会被 tree shaking 进程跟踪,因此延迟加载的代码不会包含在初始 JavaScript 捆绑包中。相反,Webpack 将为每个延迟加载的文件运行单独的 tree shaking 进程,这将导致生产应用程序中大量的小代码包。

如果我们生成一个新的构建,然后查看生成的 JavaScript 代码,现在会看到一些额外的文件:

$ yarn build
...Builds code
$ ls -l build/static/js
total 1352
-rw-r--r--  1 davidg  admin     628 12:09 0.a30b3768.chunk.js
-rw-r--r--  1 davidg  admin     599 12:09 0.a30b3768.chunk.js.map
-rw-r--r--  1 davidg  admin  161801 12:09 3.f7664178.chunk.js
-rw-r--r--  1 davidg  admin    1290 12:09 3.f7664178.chunk.js.LICENSE.txt
-rw-r--r--  1 davidg  admin  461100 12:09 3.f7664178.chunk.js.map
-rw-r--r--  1 davidg  admin    4206 12:09 4.a74be2bf.chunk.js
-rw-r--r--  1 davidg  admin    9268 12:09 4.a74be2bf.chunk.js.map
-rw-r--r--  1 davidg  admin    3095 12:09 main.e4de2e45.chunk.js
-rw-r--r--  1 davidg  admin    6089 12:09 main.e4de2e45.chunk.js.map
-rw-r--r--  1 davidg  admin    2361 12:09 runtime-main.9df06006.js
-rw-r--r--  1 davidg  admin   12496 12:09 runtime-main.9df06006.js.map

由于这是一个非常小的应用程序,这不太可能影响性能,但无论如何让我们检查一下。

使用 Chrome 的 Lighthouse 工具可以最方便地检查加载性能。你可以在图 10-19 中看到该应用程序原始版本的性能。

图 10-19. 应用程序在不进行代码拆分时的性能

如果我们添加一些懒加载,性能会有所提升,主要是因为完成 FCP 所需的时间(参见图 10-20)。

图 10-20. 应用程序在进行代码拆分后的性能

这并不是性能上的巨大提升,但它确实表明,即使在小型应用程序中,您也可以从延迟加载中获益。

讨论

所有优化都有一个代价,但代码拆分需要最少的实现工作量,并且我们发现这是我们最常用的方法。它通常会改善 FCP 和 TTI 的 Web-vitals 指标。您应该避免过于激进地使用它,因为框架需要下载和评估每个脚本。但对于大多数相当大的应用程序,您将从拆分代码中获得一些即时的好处。

最好在路由级别进行代码拆分。路由控制哪些组件可见,因此是将需要现在加载的代码与需要稍后加载的代码分开的好地方。这也意味着,如果有人在您的应用程序中书签一个位置,当他们返回时,他们只会下载该位置所需的代码。

您可以从 GitHub 站点 下载此示例的源代码。

合并网络 promises

问题

许多 React 应用程序进行异步网络调用,许多应用程序的不活跃结果来自于等待这些异步请求的响应。在这些调用期间,应用程序可能做的事情很少,因此应用程序并不忙碌;它只是在等待。

随着时间的推移,客户端应用程序变得更加复杂,而服务器 API 变得更加简单。在 无服务器 应用程序的情况下,服务器 API 如此通用,无需自定义代码,这导致客户端代码发起的 API 调用数量增加了^(4)。

让我们看一个例子。我们有一个应用程序从后端 API 读取几个人的详细信息。服务器有一个端点,如果浏览器发送 GET 请求到 /people/1234,将返回 id 为 1234 的人的详细信息。开发人员编写了一个 hook 来进行这些请求:

import { useEffect, useState } from 'react'
import { get } from './fakeios'

const usePeopleSlow = (...ids) => {
  const [people, setPeople] = useState([])

  useEffect(() => {
    let didCancel = false
    ;(async () => {
      const result = []
      for (let i = 0; i < ids.length; i++) {
        const id = ids[i]
        result.push(await get('/people/' + id))
      }
      if (!didCancel) {
        setPeople(result)
      }
    })()
    return () => {
      didCancel = true
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [...ids])

  return people
}

export default usePeopleSlow

钩子的调用方式如下:

const peopleSlow = usePeopleSlow(1, 2, 3, 4)

该代码为每个 ID 调用服务器。它在存储结果到数组之前等待每个响应完成。如果 API 端点需要 5 秒来响应,那么 usePeopleSlow 钩子将需要 20 秒才能返回所有数据。

我们能做些什么来加快速度吗?

解决方案

我们将组合异步 promises,以便可以同时进行多个 API 请求。

大多数异步请求库通过返回 promises 来工作。如果你等待一个 promise,它会返回响应的载荷。但在前面的示例 usePeopleSlow 代码中,这些 promises 是按顺序等待的:

const result = []
for (let i = 0; i < ids.length; i++) {
  const id = ids[i]
  result.push(await get('/people/' + id))
}

在收到第一个人的响应之前,第二个人的请求甚至都没有发送,这就是为什么在读取四个人的详细信息时,5 秒延迟会导致 20 秒的响应时间。

另一种方法是我们可以这样做。我们可以发送请求而不等待,并同时将它们全部发送出去。然后我们需要等待所有的响应,当我们收到最后一个响应时,我们可以从钩子中返回数据。

您可以使用名为Promise.all的 JavaScript 函数进行并行请求。

Promise.all函数不仅接受一组 Promises,还将它们组合成单个 Promise。这意味着我们可以像这样组合多个get()调用:

const [res1, res2, res3] = await Promise.all(
  get('/people/1'),
  get('/people/2'),
  get('/people/3')
)

Promise.all不仅组合 Promises,还组合结果。如果您等待一个由Promise.all返回的 Promise 数组,您将收到一个包含每个 Promise 的数组。

现在我们可以编写usePeopleSlow钩子的新版本,使用Promise.all

import { useEffect, useState } from 'react'
import { get } from './fakeios'

const usePeopleFast = (...ids) => {
  const [people, setPeople] = useState([])

  useEffect(() => {
    let didCancel = false
    ;(async () => {
      const result = await Promise.all(
        ids.map((id) => get('/people/' + id))
      )
      if (!didCancel) {
        setPeople(result)
      }
    })()
    return () => {
      didCancel = true
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [...ids])

  return people
}

export default usePeopleFast

这段代码的关键在于这三行:

const result = await Promise.all(
  ids.map((id) => get('/people/' + id))
)

通过将id映射为网络请求返回的一组 Promise 数组,我们可以等待Promise.all的结果,并接收所有响应的数组。

如果您计时两个钩子,那么usePeopleFast将在五秒多的时间内读取四个人的详细信息。事实上,我们已经在一个请求的时间内完成了五次请求。在示例应用程序中,这些是代码两个版本之间的比较时间:

版本 耗时(毫秒)
usePeopleSlow 5000.99999998929
usePeopleFast 20011.224999994738

讨论

如果您有多个独立的异步请求,这种方法将显著提高性能。如果您进行了许多并行请求,那么浏览器、网络卡或服务器可能会开始排队响应。但是,它仍然会比一系列独立的响应更快地生成响应。

如果您发送并行请求,将加重服务器的负载,但这不太可能成为一个巨大的问题。首先,正如我们刚刚注意到的那样,服务器在繁忙时通常会排队请求。其次,服务器仍将执行相同数量的工作。您所做的只是将该工作集中到一个较短的时间段内。

你可以从GitHub 网站下载此示例的源代码。

使用服务器端渲染

问题

单页应用(SPA)非常擅长使网站像桌面应用程序一样丰富多彩。如果您使用像谷歌文档这样的应用程序,体验几乎与使用桌面文字处理器无异。

但是所有的事情都是有代价的。单页应用的一个主要性能问题之一是,浏览器必须在构建界面之前下载大量的 JavaScript 代码包。如果您使用像create-react-app这样的工具创建 React 应用程序,则 HTML 正文中唯一的东西是一个名为root的空的DIV

<div id="root"></div>

直到 JavaScript 引擎下载代码、运行并更新 DOM 之前,浏览器将看到的只是空的DIV

即使我们通过代码拆分减少包大小并且浏览器已经缓存了 JavaScript,读取代码并设置界面仍可能需要几秒钟。

从 JavaScript 构建整个界面意味着 SPA 通常会遇到两个主要问题。首先,最重要的是,用户体验可能会下降,特别是对于大型 React 应用程序。其次,您的应用程序在搜索引擎优化(SEO)方面将表现不佳。搜索引擎机器人在扫描站点时通常不会等待 JavaScript 渲染界面。它们将下载页面的基本 HTML 并索引其内容。对于许多业务应用程序来说,这可能并不重要。但是,如果您正在构建比如购物网站,您可能希望尽可能多地索引页面以捕获流量。

因此,如果在 HTML 加载时不显示空的DIV,而是在浏览器下载并运行应用程序的 JavaScript 之前包含页面的初始 HTML,这将是有帮助的。

解决方案

我们将探讨如何使用服务器端渲染来将 React 页面的空的DIV替换为预渲染的 HTML 版本。这是因为 React 与 Web 页面的 DOM 交互方式。

当您在 React 中渲染组件时,您并不直接更新 DOM。相反,当您运行类似以下代码的一段代码时:

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

render 方法会更新虚拟 DOM,然后我们会定期将其与页面上的实际 HTML 元素同步。React 会以高效的方式进行此操作,因此它只会更新在实际 DOM 中与虚拟 DOM 中不匹配的元素。

服务器端渲染的工作方式是不将内容渲染到 React 虚拟 DOM,而是渲染到一个字符串。当浏览器向服务器发送 HTML 页面请求时,我们将把 React 内容的一个版本渲染为一个字符串,然后将该字符串插入到 HTML 中,然后返回给浏览器。这意味着浏览器在开始下载应用程序的 JavaScript 之前会立即渲染页面的 HTML。我们的服务器端代码将类似于以下内容:

let indexHTML = <contents of index.html>
const app = <render App to string>
indexHTML = indexHTML.replace(
  '<div id="root"></div>',
  `<div id="app">${app}</div>`
)
res.contentType('text/html')
res.status(200)
return res.send(indexHTML)

让我们首先使用 create-react-app 创建一个应用程序,以便更详细地了解其工作原理。

有许多 React 工具和框架支持服务器端渲染,但 create-react-app 不是这些工具之一。因此,查看如何将 create-react-app 应用程序转换为支持 SSR 的应用程序,将帮助我们理解在 React 中启用 SSR 所需的所有步骤:

$ npx create-react-app ssrapp

我们将构建一个用于托管 SSR 代码的服务器。让我们首先创建一个服务器代码的文件夹:

$ mkdir server

我们将使用 Express 构建服务器。我们的服务器代码将渲染应用程序的组件。

在加载 React 组件时,我们将需要一些额外的库。在主应用程序目录中(而不是server子目录),安装以下内容:

$ npm install --save-dev ignore-styles url-loader @babel/register

create-react-app工具生成的代码使用了许多现代 JavaScript 特性,这些特性不是开箱即用的,因此我们在服务器代码中需要做的第一件事就是为服务器启用这些 JavaScript 特性,以便运行我们的 React 组件。在新的server文件夹中创建一个名为index.js的文件,并将以下内容放入其中:

require('ignore-styles')
require('url-loader')
require('file-loader')
require('regenerator-runtime/runtime')
require('@babel/register')({
  ignore: [/(node_modules)/],
  presets: [
    '@babel/preset-env',
    [
      '@babel/preset-react',
      {
        runtime: 'automatic',
      },
    ],
  ],
  plugins: [],
})
require('./ssr')

此文件将配置我们将在服务器代码中使用的语言特性。我们加载了preset-react Babel 插件,它会自动安装在每个create-react-app应用程序中。在脚本的末尾,我们加载了一个名为ssr.js的文件,我们将在其中放置我们的主服务器代码。

创建server/ssr.js文件,并将以下代码添加到其中:

import express from 'express'
import fs from 'fs'
import path from 'path'

const server = express()

server.get(
  /.(js|css|map|ico|svg|png)$/,
  express.static(path.resolve(__dirname, '../build'))
)

server.use('*', async (req, res) => {
  let indexHTML = fs.readFileSync(
    path.resolve(__dirname, '../build/index.html'),
    {
      encoding: 'utf8',
    }
  )

  res.contentType('text/html')
  res.status(200)

  return res.send(indexHTML)
})

server.listen(8000, () => {
  console.log(`Launched at http://localhost:8000!`)
})

我们的自定义服务器将类似于create-react-app提供的开发服务器。它使用以下行创建 Web 服务器:

const server = express()

如果服务器收到对 JavaScript、样式表或图像文件的请求,它将在build目录中查找文件。build目录是create-react-app生成的可部署版本的应用程序所在地:

server.get(
  /.(js|css|map|ico|svg|png)$/,
  express.static(path.resolve(__dirname, '../build'))
)

如果我们收到对任何其他内容的请求,我们将返回build/index.html文件的内容:

server.use('*', async (req, res) => {
  ...
})

最后,我们在 8000 端口上启动服务器:

server.listen(8000, () => {
  console.log(`Launched at http://localhost:8000!`)
})

在我们运行此服务器之前,我们需要构建应用程序。我们可以使用以下命令来完成这个过程:

$ yarn run build

构建应用程序将生成所有静态文件,并存放在build目录中,这些文件是我们服务器所需的。现在我们可以启动服务器本身:

$ node server
Launched at http://localhost:8000!

如果我们在http://localhost:8000处打开浏览器,我们将看到我们的 React 应用程序(见 Figure 10-21)。

图 10-21. 我们新服务器上运行的应用程序

到目前为止,一切顺利。但实际上我们并没有进行任何服务器端渲染。为此,我们需要加载一些 React 代码来加载和渲染App组件:

import express from 'express'
import fs from 'fs'
import path from 'path'
import { renderToString } from 'react-dom/server'
import App from '../src/App'

const server = express()

server.get(
  /.(js|css|map|ico|svg|png)$/,
  express.static(path.resolve(__dirname, '../build'))
)

server.use('*', async (req, res) => {
  let indexHTML = fs.readFileSync(
    path.resolve(__dirname, '../build/index.html'),
    {
      encoding: 'utf8',
    }
  )

  const app = renderToString(<App />)

  indexHTML = indexHTML.replace(
    '<div id="root"></div>',
    `<div id="app">${app}</div>`
  )

  res.contentType('text/html')
  res.status(200)

  return res.send(indexHTML)
})

server.listen(8000, () => {
  console.log(`Launched at http://localhost:8000!`)
})

这段新代码使用了 React 的 SSR 库react-dom/server中的renderToString函数。renderToString函数会像你期望的那样工作。它不会将App组件呈现为虚拟 DOM,而是将其呈现为字符串。我们可以用从App组件生成的 HTML 替换index.html内容中的空DIV。如果重新启动服务器并重新加载 Web 浏览器,您会发现应用程序仍然可以工作。大部分时间(见 Figure 10-22)。

图 10-22. React 应用程序显示损坏的 SVG 图像

而不是看到旋转的 React 标志,我们看到了一个损坏的图像符号。如果查看服务器返回的生成 HTML,我们可以看到发生了什么:

<div id="app">
  <div class="App" data-reactroot="">
    <header class="App-header">
      <img src="[object Object]" class="App-logo" alt="logo"/>
      <p>Edit <code>src/App.js</code> and save to reload.</p>
      <a class="App-link" href="https://reactjs.org"
         target="_blank" rel="noopener noreferrer">
        Learn React
      </a>
    </header>
  </div>
</div>

img元素出现了一些奇怪的问题。它没有呈现 SVG 图像,而是尝试加载 URL“[object Object]”。这里发生了什么?

在 React 代码中,我们像这样加载标志:

import logo from './logo.svg'
...
<img src={logo} className="App-logo" alt="logo" />

此代码依赖于create-react-app的一些 Webpack 配置。当您通过开发服务器访问应用程序时,Webpack 将使用一个称为svgr的库,将 SVG 文件的任何导入替换为包含原始 SVG 内容的生成的 React 组件。svgr允许像导入.js文件一样导入 SVG 图像。

但是,在我们手动构建的服务器上,我们没有这样的 Webpack 配置。我们可以通过将logo.svg文件复制到public文件夹中,然后更改App组件中的代码来避免配置 Webpack 的麻烦:

// import logo from './logo.svg'
import './App.css'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src="/logo.svg" className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  )
}

export default App

如果我们现在重新构建应用程序并重新启动服务器:

$ yarn build
$ node server

SSR 应用程序将正确显示应用程序(参见图 10-23)。

图 10-23. 应用程序现在正确显示 SVG 图像

实际上,还有最后一步需要实现。在src/index.js文件中,我们像这样渲染应用程序的单页版本:

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

请记住,即使通过 SSR 服务器访问我们的应用程序,此代码仍将运行。浏览器将下载预渲染版本的网页,然后再下载 SPA 的 JavaScript。当 SPA 代码运行时,它将执行index.js中的前述代码。浏览器仍然需要加载和运行 JavaScript 才能使界面交互。当不需要时,ReactDOM.render方法可能会替换所有我们的预渲染 HTML。因此,如果我们将调用ReactDOM.render替换为ReactDOM.hydrate,那么只有在 DOM 中的 HTML 与虚拟 DOM 中的 HTML 不同时,才会替换 HTML。对于我们的服务器端渲染页面,静态网页的内容和虚拟 DOM 的内容应该是相同的。结果是,hydrate不会更新页面上的元素;它只会附加一组事件监听器以使页面交互。

因此,我们现在有了一个服务器端渲染的应用程序。但这是否使页面加载速度更快了?

测试页面加载时间的最简单方法是在 Chrome 中运行 Lighthouse 性能审计。请记住,Lighthouse 执行网页的基本审计,检查性能、可访问性和一些其他功能。它将给我们一个指标,我们可以用来比较应用程序的两个版本。

当我们在开发笔记本上尝试时,通过访问create-react-app捆绑的普通 React 开发服务器,我们获得了 91 分的性能评分和首次内容绘制(FCP)时间为 1.2 秒(参见图 10-24)。

图 10-24. 应用程序的基本性能,不包括服务器端渲染

这并不是一个糟糕的性能评分。但我们运行的是一个小型 React 应用程序。

当我们测试应用程序的 SSR 版本时会发生什么?毕竟,服务器仍然需要花费一些时间来渲染 React 代码。它会运行得更快吗?您可以在图 10-25 中查看我们的测试结果。

总体分数已提高到 100 分中的 99 分。FCP 时间降至 0.6 秒:是原始版本的一半。此外,如果您在浏览器中加载应用程序的原始 SPA 版本并不断点击刷新按钮,您会看到页面在渲染网页之前经常会闪白屏。闪白屏是因为下载的 HTML 只是一个空的DIV,浏览器在 JavaScript 渲染应用程序之前将其显示为白色页面。

图 10-25. 应用程序的服务器端渲染性能

将其与应用程序的 SSR 版本进行比较。如果您在 SSR 版本上不断点击刷新按钮,您唯一会注意到的是徽标的旋转会不断重置。您几乎看不到任何闪烁。

即使服务器仍然会发生渲染过程,但生成 HTML 字符串所需的时间比渲染相同一组 DOM 元素所需的时间少。

讨论

在本教程中,我们已经介绍了如何为应用程序设置基本的服务器端渲染的基础知识。对于您的应用程序,具体细节可能会因应用程序使用的附加库而有所不同。

大多数 React 应用程序都使用某种形式的路由,例如。如果您使用react-router,那么您需要在服务器端添加一些额外的代码来处理浏览器请求的路径不同而需要渲染不同组件的情况。例如,我们可以像这样使用react-router中的StaticRouter

import { StaticRouter } from 'react-router-dom'
...
const app = renderToString(
  <StaticRouter location={req.originalUrl} context={{}}>
    <App />
  </StaticRouter>
)

StaticRouter为单个特定路由渲染其子组件。在这种情况下,我们使用浏览器请求的originalURL路由。如果浏览器请求/person/1234StaticRouter将为此路由渲染App组件。请注意,我们还可以使用StaticRouter传递任何其他上下文以供应用程序其余部分使用。

如果您在应用程序中使用了React.lazy进行代码拆分,您需要注意这在服务器端是无法工作的。幸运的是,有一个解决方法。Loadable Components库与React.lazy完成相同的工作,但也可以在服务器端运行。因此,Loadable Components 为您提供了服务器端渲染的所有优势以及代码拆分的所有好处。

就像所有优化一样,服务器端渲染是有代价的。它将需要在您的代码中增加复杂性,并且还将增加服务器的额外负载。您可以将 SPA 部署为任何 Web 服务器上的静态代码。而对于服务器端渲染的代码,则不是这样。它将需要一个 JavaScript 服务器,并可能增加您的云托管成本。

此外,如果您从一开始就知道要为应用程序使用服务器端渲染,那么您应该考虑使用工具如 Razzle 或 Next.js,并从第一天开始构建服务器端渲染。

最后,还有替代的 SSR 方法可以提升网页性能,而无需服务器端渲染。考虑使用 Gatsby。 Gatsby 可以在构建时预渲染页面,带来了许多 SSR 的优点,而无需服务器端代码。

您可以从GitHub 站点下载此配方的源代码。

使用 Web Vitals

问题

拥有运行良好且可读性高的代码比高度调整的代码更为重要。正如我们所看到的,调整总是伴随着相关的成本。

但是如果存在显著的性能问题,必须尽快意识到并尽快修复。互联网的很多依赖于经过的交易。如果人们访问您的网站,但它不立即响应,他们可能会离开并永远不会回来。

开发人员通常使用内嵌在代码中的信标跟踪服务器性能。如果存在性能问题,这些信标可以生成警报,开发人员可以在影响大量用户之前修复问题。

但是我们如何在客户端代码中嵌入跟踪信标呢?

解决方案

我们将看看如何跟踪Web vitals。我们在“使用浏览器性能工具”中提到了 Web vitals。它们是衡量您应用程序最重要的性能特征的一小组指标,例如累计布局转移(CLS),它衡量的是应用程序首次加载时的跳动程度。

诸如 Lighthouse Chrome 扩展程序之类的多种工具用于跟踪 Web 关键性能指标。 Web vitals 这个名字旨在提醒您关注关键指标,如心率和血压,因为它们告诉您需要解决的潜在问题。

如果您使用create-react-app创建应用程序,可能已经嵌入了自动跟踪应用程序 Web vitals 的代码。如果查看src/index.js文件,您将看到在末尾调用报告 Web vitals 的代码:

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import reportWebVitals from './reportWebVitals'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

reportWebVitals()

reportWebVitals函数可以接受一个回调函数,该函数在应用程序运行时用于跟踪各种度量指标。例如,如果将console.log传递给它:

reportWebVitals(console.log)

然后,您将在 JavaScript 控制台中看到度量指标以一系列 JSON 对象的形式出现(参见图 10-26)。

图 10-26. JavaScript 控制台中的 Web vitals

这不是跟踪 Web Vital 的预期方式。更好的选择是将数据发送回某些后端存储。例如,您可以选择将它们POST到发送 API 端点:

reportWebVitals((vital) => {
  fetch('/trackVitals', {
    body: JSON.stringify(vital),
    method: 'POST',
    keepalive: true,
  })
})

许多浏览器都有内置功能,用于记录关键测量。如果用户离开页面,浏览器将取消正常的网络请求,例如通过调用fetch发出的请求。考虑到页面加载时最重要的 Web Vital,如果失去这些指标将是一种遗憾。因此,在可用时应考虑使用navigator.sendBeacon函数:

reportWebVitals((vital) => {
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/trackVitals', JSON.stringify(vital))
  } else {
    fetch('/trackVitals', {
      body: JSON.stringify(vital),
      method: 'POST',
      keepalive: true,
    })
  }
})

如果用户打开页面后立即离开,navigator.sendBeacon将允许完成其POST请求再终止。

讨论

有商业跟踪服务可用于记录 Web Vital,例如sentry.io。如果已安装性能监控系统,则可能还能够通过 Web Vital 来提供额外的性能监控。

最后,考虑像在create-react-app site中描述的那样使用 Google Analytics 跟踪 Web Vital 指标。

^(1) 请参阅“使用代码分割来缩小您的应用程序”。

^(2) 例如,在执行某些操作之前,先检查组件是否处于特定状态。

^(3) 如果在 Node 中运行,performance.now()将从当前进程的启动时间开始计时。

^(4) 这在 GraphQL 服务的情况下有一个例外。在 GraphQL 中,客户端可以向后端发出复杂查询,标准化的查询解析器将在服务器上将低级查询的结果“拼接”在一起。GraphQL 可以生成更快的网络响应,而无需调整客户端。

第十一章:渐进式 Web 应用程序

渐进式 Web 应用程序(PWAs)是尝试像本地安装的应用程序一样行为的 Web 应用程序。它们可以在离线状态下工作,集成本机通知系统,并具有运行长时间后台进程的能力,即使您离开网站后仍可继续。它们之所以被称为“渐进式”,是因为如果当前浏览器中某些功能不可用,它们会平滑降级其功能。

本章几乎完全集中在 PWAs 的一个特性上:service workers。偶尔会遇到“渐进式 Web 应用程序”这个术语,用来描述任何 JavaScript 丰富的浏览器应用程序。事实上,除非该应用程序使用 service workers,否则它不算是 PWA。

Service workers 实际上是应用程序的本地安装服务器。后端服务器是软件分发机制和实时数据服务的提供者,但是实际控制权在于 service worker,因为它提供对网络的访问。它可以选择从自己的本地缓存满足网络请求。如果网络不可用,它可以选择用本地占位符替换网络资源。它甚至可以在离线状态下排队数据更新,并在网络连接恢复时与后端服务器同步。

由于它是写作过程中最令人愉悦的一章,因此这是一个很好的最终章主题。Service workers 是现代浏览器中最迷人的功能之一。希望您会喜欢。

使用 Workbox 创建 Service Workers

问题

PWAs 即使在离线状态下也能工作。它们可以缓存所需的任何内容或代码,并且缓存会在用户刷新页面后保留。它们可以独立于在浏览器中运行的代码运行后台操作。

PWAs 之所以能够做到这一点,是因为 service workers。Service workers 是一种 Web worker。Web worker 是在与 Web 页面运行的 JavaScript 分离的单独线程中运行的 JavaScript 片段。Service workers 是特殊的 Web worker,可以拦截 Web 页面与服务器之间的网络流量,从而对注册它们的页面具有极大的控制权。您可以将 service worker 理解为一种本地代理服务,即使在断开网络连接时也可用。

Service workers 最常用于本地缓存内容。浏览器会缓存大部分它们看到的内容,但是 service worker 可以更加积极地进行缓存。例如,在浏览器中进行强制刷新通常会强制从网络重新加载资产。但是无论用户使用多少次强制刷新功能,它都不会影响 service workers。

您可以在 图 11-1 中看到一个 service worker 在运行中的情况。

图 11-1。一个 service worker 将拦截所有网络请求

在这种情况下,服务工作线程将在第一次下载文件时进行缓存。如果页面多次请求 logo.svg 文件,服务工作线程将从其私有缓存中返回它,而不是从网络中获取。

服务工作线程如何缓存数据以及它如何决定是否从其缓存或网络返回数据称为 策略。在本章中,我们将查看各种标准策略。

服务工作线程以单独的 JavaScript 文件形式存储在服务器上,并且浏览器将从 URL 下载并安装它们。没有什么可以阻止您手工制作一个服务工作线程并将其存储在应用程序的公共文件夹中,但是手工编写服务工作线程存在几个问题。

首先,服务工作线程非常难以创建。它们不仅可以包含复杂的代码,还有复杂的生命周期。编写一个加载失败或者缓存错误文件的服务工作线程非常容易。更糟糕的是,有可能编写一个会使您的应用程序与网络隔离的服务工作线程。

其次,您可以使用服务工作线程来预缓存应用程序代码。对于 React 应用程序来说,这是一个非常棒的功能。服务工作线程可以从本地缓存中一瞬间返回几百千字节的 JavaScript,而不是从网络下载,这意味着您的应用程序可以几乎立即启动,即使在网络连接不佳的低功耗设备上也是如此。

但是代码缓存也有其自身的问题。假设我们有一个包含以下生成的 JavaScript 文件的 React 应用程序:

$ ls build/static/js/
2.d106afb5.chunk.js             2.d106afb5.chunk.js.map
3.9e79b289.chunk.js.map         main.095e14c4.chunk.js.map
runtime-main.b175c5d9.js.map    2.d106afb5.chunk.js.LICENSE.txt
3.9e79b289.chunk.js             main.095e14c4.chunk.js
runtime-main.b175c5d9.js
$

如果我们想要预缓存这些文件,服务工作线程将需要知道这些文件的名称。这是因为它将在后台下载这些文件,甚至在浏览器请求它们之前。因此,如果您手动创建服务工作线程,您将需要包括每个文件的名称以进行预缓存。

但是,如果您对源代码进行小改动然后重新构建应用程序会发生什么?

$ yarn run build
$ ls build/static/js/
2.d106afb5.chunk.js             2.d106afb5.chunk.js.map
3.9e79b289.chunk.js.map         main.f5b66cc7.chunk.js.map
runtime-main.b175c5d9.js.map    2.d106afb5.chunk.js.LICENSE.txt
3.9e79b289.chunk.js             main.f5b66cc7.chunk.js
runtime-main.b175c5d9.js
$

文件名 会改变,这意味着现在您必须使用最新生成的文件名更新服务工作线程脚本。

如何创建稳定的服务工作线程,始终与最新的应用程序代码同步?

解决方案

我们将使用来自 Google 的一组工具称为 Workbox。Workbox 工具允许您生成与最新应用程序文件同步的服务工作线程。

Workbox 包含一组标准策略来处理常见服务工作线程用例的详细信息。如果您想要预缓存您的应用程序,可以通过一行代码将其集成到 Workbox 中。

要了解如何使用 Workbox,请考虑您可以在图 11-2 中看到的应用程序。

图 11-2. 我们的示例应用程序有两个页面

这是一个基于create-react-app生成的简单的双页面应用程序。我们将构建一个服务工作者,预缓存所有应用程序的代码和文件。

我们将从 Workbox 安装几个库:

$ yarn add workbox-core
$ yarn add workbox-precaching
$ yarn add workbox-routing

当构建服务工作者时,您将看到每个库的作用。

在我们的应用程序中,我们将创建一个名为service-worker.js的新文件作为服务工作者。我们可以将这个文件放在与应用程序其余代码相同的目录中:

import { clientsClaim } from 'workbox-core'
import { precacheAndRoute } from 'workbox-precaching'

clientsClaim()

precacheAndRoute(self.__WB_MANIFEST)

如果我们手动创建服务工作者,我们必须将其创建在用于存储其他静态内容的相同目录中。例如,在create-react-app应用程序中,我们必须将其创建在public目录中。

我们的服务工作者将预缓存所有应用程序代码。这意味着它将自动缓存作为应用程序一部分的任何 CSS、JavaScript、HTML 和图像。

服务工作者从workbox-core调用clientsClaim函数,这将使服务工作者成为其范围内所有客户端的控制器。客户端是一个网页,范围是与服务工作者相同路径的任何网页 URL。Workbox 将在https://host/service-worker.js生成我们的服务工作者,这意味着服务工作者将成为所有以https://host/开头的页面的控制器。

函数precacheAndRoute将处理预缓存过程的所有细节。它将创建和管理本地缓存,并拦截应用程序文件的网络请求,从本地缓存而不是网络加载它们。

只有通过 HTTPS 加载,服务工作者才能正常运行。大多数浏览器会为从localhost加载的站点做出例外。出于安全原因,浏览器不会在私密标签页中运行服务工作者。

当我们创建了服务工作者后,我们需要从主应用程序代码中注册它。注册是一个复杂的过程,但好消息是几乎总是相同的。一旦您为一个应用程序编写了注册代码,您可以无需更改地复制到另一个应用程序中。此外,如果您使用cra-template-pwa模板构建应用程序,它会为您生成注册代码。^(1)

了解注册过程的细节仍然很重要;这将帮助您理解部署应用程序后发生的任何看似奇怪的行为背后的生命周期。这将使您更容易理解注册过程。

在应用程序的主源目录中创建一个名为registerWorker.js的新文件:

const register = (pathToWorker, onInstall, onUpdate, onError) => {
  // We will write this code shortly
}

const registerWorker = () => {
  register(
    '/service-worker.js',
    (reg) => console.info('Service worker installed', reg),
    (reg) => console.info('Service worker updated', reg),
    (err) => console.error('Service worker failed', err)
  )
}

export default registerWorker

现在先将register函数保持为空。

我们将在应用程序中的index.js文件中调用registerWorker函数:

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import registerWorker from './registerWorker'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

registerWorker()

函数registerWorker将使用生成的服务工作者的路径调用register函数:service-worker.js

现在我们可以开始编写register函数:

const register = (pathToWorker, onInstall, onUpdate, onError) => {
  if (
    process.env.NODE_ENV === 'production' &&
    'serviceWorker' in navigator
  ) {
    const publicUrl = new URL(
      process.env.PUBLIC_URL,
      window.location.href
    )
    if (publicUrl.origin !== window.location.origin) {
      return
    }

    // Do the loading and registering here
  }
}

我们将检查我们是否处于生产模式,并且浏览器是否支持运行服务工作线程。渐进网页应用程序中的渐进意味着我们应该始终检查功能是否可用才使用。几乎所有浏览器(除了著名的 Internet Explorer)都支持服务工作线程,但是如果浏览器不支持,我们可以完全跳过服务工作线程。这意味着应用程序将失去离线工作的能力,但除此之外,应用程序应该仍然可以运行。

我们还添加了额外的检查以确保我们运行在应用程序指定的PUBLIC URL上,这将避免在从内容分发网络加载代码时出现的跨域问题。^(2)

现在我们可以下载并注册服务工作线程:

const register = (pathToWorker, onInstall, onUpdate, onError) => {
  if (
    process.env.NODE_ENV === 'production' &&
    'serviceWorker' in navigator
  ) {
    const publicUrl = new URL(
      process.env.PUBLIC_URL,
      window.location.href
    )
    if (publicUrl.origin !== window.location.origin) {
      return
    }

    window.addEventListener('load', async () => {
      try {
        const registration = await navigator.serviceWorker.register(
          process.env.PUBLIC_URL + pathToWorker
        )

        // Code to check progress goes here
      } catch (err) {
        if (onError) {
          onError(err)
        }
      }
    })
  }
}

一旦我们知道网页已加载,我们可以使用navigator.serviceWorker.register函数注册服务工作线程,并传递服务工作线程的完整 URL:https://host/service-worker.js

它返回一个注册对象,可用于跟踪和管理服务工作线程。例如,您可以使用注册对象查找服务工作线程何时更新或安装:

const register = (pathToWorker, onInstall, onUpdate, onError) => {
  if (
    process.env.NODE_ENV === 'production' &&
    'serviceWorker' in navigator
  ) {
    const publicUrl = new URL(
      process.env.PUBLIC_URL,
      window.location.href
    )
    if (publicUrl.origin !== window.location.origin) {
      return
    }

    window.addEventListener('load', async () => {
      try {
        const registration = await navigator.serviceWorker.register(
          process.env.PUBLIC_URL + pathToWorker
        )

        registration.onupdatefound = () => {
          const worker = registration.installing
          if (worker) {
            worker.onstatechange = () => {
              if (worker.state === 'installed') {
                if (navigator.serviceWorker.controller) {
                  if (onUpdate) {
                    onUpdate(registration)
                  }
                } else {
                  if (onInstall) {
                    onInstall(registration)
                  }
                }
              }
            }
          }
        }
      } catch (err) {
        if (onError) {
          onError(err)
        }
      }
    })
  }
}

onupdatefound 处理程序在浏览器开始安装服务工作线程时运行。一旦浏览器安装了服务工作线程,我们可以检查 navigator.serviceWorker.controller 来查看之前的服务工作线程是否仍在运行。如果没有,则知道这是一个全新的安装,而不是更新。

有关服务工作线程最令人困惑的事情之一是它们的更新方式。如果旧的服务工作线程已经控制了一个页面,浏览器将将新的服务工作线程置于等待状态,这意味着它将绝对什么也不做,直到旧的服务工作线程停止。服务工作线程停止的时机是当用户关闭所有它控制的页面时。因此,如果更新了您的服务工作线程,您将不会运行新的代码,直到您再次打开页面,然后关闭并再次打开页面。

这个过程对于手动测试新服务工作线程功能的任何人来说可能会有些混乱。

在构建应用程序之前,我们需要配置构建工具以将我们的service-worker.js源文件转换为紧凑编写的服务工作线程脚本。

如果您使用 Webpack 构建应用程序,您应该安装 Workbox Webpack 插件:

$ yarn install -D workbox-webpack-plugin

如果您使用create-react-app创建应用程序,无需安装 Workbox Webpack 插件或配置其使用,因为它已经包含并配置了插件。

然后,您可以将以下内容添加到您的webpack.config.js配置中:

const { InjectManifest } = require('workbox-webpack-plugin')

module.exports = {
  ....
  plugins: [
    ....
    new InjectManifest({
      swSrc: './src/service-worker.js',
    }),
  ],
}

此配置将告诉 Webpack 从 src/service-worker.js 文件生成服务工作线程。它还将在构建的应用程序中生成一个名为 asset-manifest.json 的文件,该文件将列出所有应用程序文件。服务工作线程在预缓存应用程序时将使用 asset-manifest.json 中的信息。

现在构建应用程序:

$ yarn run build

在您的 build 目录中,您将看到一个生成的 service-worker.js 文件和 asset-manifest.json 文件:

asset-manifest.json  logo192.png        service-worker.js.map
favicon.ico          manifest.json      static
index.html           robots.txt
logo512.png          service-worker.js

asset-manifest.json 文件将包含类似以下内容:

{
  "files": {
    "main.css": "/static/css/main.8c8b27cf.chunk.css",
    "main.js": "/static/js/main.f5b66cc7.chunk.js",
    "main.js.map": "/static/js/main.f5b66cc7.chunk.js.map",
    "runtime-main.js": "/static/js/runtime-main.b175c5d9.js",
    "runtime-main.js.map": "/static/js/runtime-main.b175c5d9.js.map",
    "static/js/2.d106afb5.chunk.js": "/static/js/2.d106afb5.chunk.js",
    "static/js/2.d106afb5.chunk.js.map": "/static/js/2.d106afb5.chunk.js.map",
    "static/js/3.9e79b289.chunk.js": "/static/js/3.9e79b289.chunk.js",
    "static/js/3.9e79b289.chunk.js.map": "/static/js/3.9e79b289.chunk.js.map",
    "index.html": "/index.html",
    "service-worker.js": "/service-worker.js",
    "service-worker.js.map": "/service-worker.js.map",
    "static/css/main.8c8b27cf.chunk.css.map":
        "/static/css/main.8c8b27cf.chunk.css.map",
    "static/js/2.d106afb5.chunk.js.LICENSE.txt":
        "/static/js/2.d106afb5.chunk.js.LICENSE.txt",
    "static/media/logo.6ce24c58.svg": "/static/media/logo.6ce24c58.svg"
  },
  "entrypoints": [
    "static/js/runtime-main.b175c5d9.js",
    "static/js/2.d106afb5.chunk.js",
    "static/css/main.8c8b27cf.chunk.css",
    "static/js/main.f5b66cc7.chunk.js"
  ]
}

现在可以运行应用程序了。您不能只使用以下命令启动开发服务器:

$ yarn run start

这只会以开发模式运行应用程序,并且服务工作线程不会启动。您需要在 build 目录上运行服务器。最简单的方法是全局安装 serve 包,然后针对 build 目录运行它:

$ npm install -s serve
$ serve -s build/
 ┌──────────────────────────────────────────────────┐
 │                                                  │
 │   Serving!                                       │
 │                                                  │
 │   - Local:            http://localhost:5000      │
 │   - On Your Network:  http://192.168.1.14:5000   │
 │                                                  │
 │   Copied local address to clipboard!             │
 │                                                  │
 └──────────────────────────────────────────────────┘

-s 选项用于运行单页面应用程序。如果服务器找不到匹配的文件,它将返回 build/index.html 文件。

现在您可以在 http://localhost:5000 打开浏览器。应用程序将显示出来,如果您打开开发者工具并切换到应用程序选项卡,在服务工作线程下,您应该可以看到 service-worker.js 脚本正在运行(见 图 11-3)。

图 11-3. 应用程序中安装并运行的服务工作线程

服务工作线程将所有应用程序文件下载到本地缓存中,因此下次加载页面时,文件将来自本地缓存而不是服务器。如果您切换到开发者工具中的 网络 选项卡然后重新加载页面,您可以看到这一过程(见 图 11-4)。服务工作线程将提供每个网络响应,除了那些超出其范围的响应。属于站点级而不是页面级别的文件,如 favicon 图标,仍将以通常的方式下载。

图 11-4. 刷新后,文件将使用服务工作线程下载

服务工作线程从本地缓存返回文件。如果您使用 Chrome,您可以在应用程序选项卡中看到缓存。对于 Firefox,您将在存储选项卡中找到它(见 图 11-5)。

图 11-5. 缓存将文件存储在本地

缓存不包含 所有 应用程序文件的副本,只包含应用程序请求的文件。这样,它将避免下载不需要的文件,并且仅在浏览器或应用程序代码请求它们后将文件下载到缓存中。

因此,第一次加载应用程序时,缓存可能是空的。这取决于服务工作线程何时变为活动状态。如果页面在服务工作线程变为活动状态之前加载,服务工作线程不会拦截网络请求并缓存响应。因此,在看到缓存出现之前,您可能需要刷新页面。

要证明文件确实来自服务工作线程,您可以停止服务器并刷新网页。即使服务器不再存在,页面也应该正常加载(见 图 11-6)。

图 11-6. 即使没有运行服务器,您也可以刷新页面。

现在,您应该将 React 应用程序视为本地应用程序,而不是网络应用程序。它是通过服务工作线程提供的,而不是通过后端服务器。甚至可以让您导航到第二页(见 图 11-7)。

图 11-7. 即使服务器离线,您仍然可以在页面之间导航。

使用代码分割可能会干扰某些离线功能。例如,如果用于显示示例应用程序中第二页的代码存储在一个未初始化加载的单独 JavaScript 文件中,浏览器将不会从本地缓存返回它。只有当浏览器在在线状态下访问该页面时,它才会可用。

当我们查看第二页时,我们可以检查服务工作线程的当前问题。确保服务器 运行,并导航到第二页。它应该正常加载。然后重新加载页面。而不是看到第二页,您将从浏览器得到一个错误页面(见 图 11-8)。

图 11-8. 当服务器离线时,第二页不会重新加载。

我们可以在离线状态下重新加载应用程序的首页,那为什么不能对第二页做到呢?因为这是一个单页面应用程序(SPA)。当我们导航到第二页时,浏览器不会从服务器加载一个新的网页;相反,它使用历史 API 更新地址栏中的 URL,然后修改 DOM 来显示第二页。

但是,当您重新加载页面时,浏览器将向服务器发出新的请求以获取 http://localhost:5000/page2。当服务器运行时,它将为所有页面请求返回 index.html 的内容,而 React 路由器将渲染组件,看起来像第二页。

当服务器不再在线时,这个过程就会失败。服务工作线程将无法使用缓存数据响应 http://localhost:5000/page2 的请求。对于 page2,缓存中没有任何内容。因此,它会将请求转发到不再运行的服务器,这就是为什么会出现错误页面的原因。

我们可以通过向 service-worker.js 添加更多代码来解决这个问题:^(3)

import { clientsClaim } from 'workbox-core'
import {
  createHandlerBoundToURL,
  precacheAndRoute,
} from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'

clientsClaim()

precacheAndRoute(self.__WB_MANIFEST)

const fileExtensionRegexp = new RegExp('/[^/?]+\.[^/]+$')
registerRoute(({ request, url }) => {
  if (request.mode !== 'navigate') {
    return false
  }
  if (url.pathname.startsWith('/_')) {
    return false
  }
  if (url.pathname.match(fileExtensionRegexp)) {
    return false
  }
  return true
}, createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'))

我们现在正在使用workbox-routing注册显式路由。路由决定服务工作线程如何处理一组路径的请求。我们使用前面示例代码中的过滤器函数和处理程序注册了一个新路由。过滤器函数是registerRoute调用中传递的第一个值。如果该路由处理给定请求,则返回 true。在前面的代码中,过滤器函数将处理任何导航到新网页的请求。因此,如果您在http://localhost:5000/http://localhost:5000/page2打开浏览器,此路由将返回相同的缓存index.html副本。

函数createHandlerBoundToURL将创建一个处理程序,将这些请求视为对http://localhost:5000/index.html的请求,这意味着如果我们在第 2 页重新加载应用程序,服务工作线程应该以与在首页上加载 HTML 相同的方式加载它。

让我们试试这个。在保存更改到service-worker.js之后,重新构建应用程序:

$ yarn run build

现在确保本地服务器正在运行:

$ serve -s build/

打开浏览器访问http://localhost:5000,您应该看到应用程序。如果检查开发者工具,您会发现它已加载了新版本的服务工作线程,但旧版本的服务工作线程仍在运行(见图 11-9)。

图 11-9. 工具中显示了新旧服务工作线程。

先前版本的服务工作线程仍然控制应用程序。浏览器已安装了新的服务工作线程,但处于等待状态。直到旧的服务工作线程消失,它才会接管,如果您关闭标签页然后重新打开它(见图 11-10)。

图 11-10. 重新打开应用程序以激活新工作线程。

如果您现在停止本地服务器并导航到第 2 页,应该能够重新加载而无任何问题(见图 11-11)。

图 11-11. 一旦注册了路由处理程序,您可以重新加载第 2 页。

讨论

在这个配方中,我们深入探讨了如何创建、注册和使用服务工作线程。在接下来的配方中,您将看到,在首次构建应用程序时,可以自动生成大部分这些代码。但是,深入了解服务工作线程的混乱细节仍然是值得的。这有助于理解工作线程的生命周期:浏览器如何安装服务工作线程以及它如何变为活动状态。

我们发现服务工作线程可能会让手动测试代码的人感到困惑。如果浏览器仍在运行旧版本的服务工作线程,则可能仍在运行旧版本的应用程序。这种混淆可能导致测试报告失败,因为旧 bug 可能仍然存在。一旦理解了新服务工作线程如何加载以及旧服务工作线程如何消失,您就可以快速诊断问题。

自动化浏览器测试不会受过时的服务工作线程影响,测试开始时通常处于干净状态,没有缓存或正在运行的服务工作线程。

带有服务工作线程的渐进式网络应用程序是本地应用程序和远程应用程序的混合体。服务器成为安装在本地的应用程序的分发服务器。当应用程序更新时,它将在浏览器中安装一个新版本,但通常要等到浏览器重新打开才能使用新应用程序。

现在我们已经详细介绍了服务工作线程,可以看看如何将它们快速添加到新应用程序中。

您可以从GitHub 网站下载此示例的源代码。

使用 Create React App 构建 PWA

问题

在您的应用程序中运行服务工作线程之前,您需要两件事情。首先,您需要一个服务工作线程,“使用 Workbox 创建服务工作线程”介绍了 Workbox 库如何帮助简化服务工作线程的创建和管理。其次,您需要一段代码,在您的应用程序中注册服务工作线程。虽然创建复杂,但您可以通过少量更改将注册代码复制到新应用程序中。

然而,随着服务工作线程使用模式的发展,避免创建自己的注册代码将是有帮助的。我们可以如何做到这一点?

解决方案

我们将看看如何在 create-react-app 中使用模板来构建一个包含服务工作线程的应用程序。

即使您不打算使用 create-react-app,使用它生成应用程序然后在项目中重用服务工作线程代码可能是值得的。

我们在第一章简要介绍了如何在生成 TypeScript 项目时使用应用程序模板,该模板是create-react-app在生成新应用程序时使用的样板代码。

如果我们想创建一个渐进式网络应用程序,我们可以通过输入以下内容来实现:

$ npx create-react-app appname --template cra-template-pwa

如果您想创建一个 TypeScript 应用程序,请使用 cra-template-pwa-typescript 替换 cra-template-pwa

如果我们这样做,它将在名为appname的新文件夹中生成一个 React 应用程序。该应用程序看起来几乎与任何其他 CRA 应用程序相同,但它将安装几个 Workbox 库。它将添加两个额外的源文件。在src目录中,您将找到一个名为service-worker.js的示例脚本:

import { clientsClaim } from 'workbox-core'
import { ExpirationPlugin } from 'workbox-expiration'
import {
  precacheAndRoute,
  createHandlerBoundToURL,
} from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { StaleWhileRevalidate } from 'workbox-strategies'

clientsClaim()

precacheAndRoute(self.__WB_MANIFEST)

const fileExtensionRegexp = new RegExp('/[^/?]+\.[^/]+$')
registerRoute(({ request, url }) => {
  if (request.mode !== 'navigate') {
    return false
  }

  if (url.pathname.startsWith('/_')) {
    return false
  }

  if (url.pathname.match(fileExtensionRegexp)) {
    return false
  }

  return true
}, createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'))

registerRoute(
  ({ url }) =>
    url.origin === self.location.origin &&
    url.pathname.endsWith('.png'),
  new StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [new ExpirationPlugin({ maxEntries: 50 })],
  })
)

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting()
  }
})

服务工作者类似于我们在“使用 Workbox 创建服务工作者”中创建的服务工作者。

您还会在 src 目录中找到一个名为 serviceWorkerRegistration.js 的新文件。这个文件非常长,所以我们不会在这里包含其内容。但它与我们在“使用 Workbox 创建服务工作者”中编写的 registerWorker.js 脚本具有相同的目的。它将服务工作者注册为应用程序的控制器。即使您不打算在应用程序中使用 create-react-appserviceWorkerRegistration.js 文件也非常有价值。它有几个前一篇配方中的注册代码没有的附加功能。例如,假设您正在运行在 localhost 上。如果它们看起来属于不同应用程序,它将注销任何服务工作者,这在您同时在几个 React 应用程序上工作时非常有用。

尽管服务工作者和注册代码在您的新应用程序中为您创建了,但它们实际上并没有配置好。在 index.js 文件中,您会发现该应用程序实际上会注销任何服务工作者:

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorkerRegistration from './serviceWorkerRegistration'
import reportWebVitals from './reportWebVitals'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

serviceWorkerRegistration.unregister()

reportWebVitals()

如果您想要启用 service-worker.js 脚本,您需要将 serviceWor⁠kerRegistration.unregister 更改为 serviceWorkerRegistration.register

register 函数允许您将回调传递到注册过程中,以便您可以跟踪服务工作者安装的当前状态。为此,请传递一个带有 onInstallonUpdate 函数的对象:

serviceWorkerRegistration.register({
  onInstall: (registration) => {
    console.log('Service worker installed')
  },
  onUpdate: (registration) => {
    console.log('Service worker updated')
  },
})

如果您想要延迟某些处理直到浏览器安装了服务工作者,或者如果您想在新的服务工作者更新到先前版本时运行代码,则回调非常有用。如果调用 onUpdate,则您将知道您的新服务工作者正在等待旧服务工作者消失。

讨论

“使用 Workbox 创建服务工作者” 帮助您了解服务工作者的操作方式。当您最终构建一个真实的应用程序时,模板代码将更加精致和功能丰富。

您可以从 GitHub 站点下载此配方的源代码。

缓存第三方资源

问题

现代应用程序中使用的许多资源来自第三方服务器:支付库、字体、图片等等。第三方资源可能会消耗大量带宽,并且随着时间的推移可能会增长。如果它们来自慢速服务器,那么它们可能会以您无法控制的方式减慢您的应用程序速度。^(4)

是否可以使用服务工作者缓存第三方资源?

解决方案

由于服务工作者只允许控制相同 URL 路径内的页面,所以它们的范围有限。这就是为什么服务工作者通常位于应用程序的根目录的原因;它允许它们控制每一页。

但对于它们允许访问的 URL 并无限制。它们可以与页面或代码可以访问的任何端点通信。这意味着你可以开始缓存来自第三方服务器的资源。

您在 图 11-12 中看到的应用程序使用了从 Google Fonts 下载的字体。

图 11-12. 使用谷歌字体的应用程序——非常漂亮!

在页面头部添加了以下两行代码来添加字体:

<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Fascinate&display=swap"
      rel="stylesheet">

第一个 link 导入了网络字体,第二个导入了相关样式表。

要在应用程序中缓存这些内容,我们首先需要注册一个服务工作者。示例应用程序是使用 cra-template-pwa 模板创建的,因此我们需要在 index.js 文件中调用 register 函数:

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorkerRegistration from './serviceWorkerRegistration'
import reportWebVitals from './reportWebVitals'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

serviceWorkerRegistration.register()

reportWebVitals()

现在我们将在 service-worker.js 脚本中添加一些路由,该脚本包含应用程序的服务工作者。服务工作者使用 Workbox 库。

我们需要缓存样式表和可下载字体。

我们在 “使用 Workbox 创建服务工作者” 中看到,我们可以预缓存应用程序代码,这是一个非常常见的需求,Workbox 让你只需一行代码即可完成:

precacheAndRoute(self.__WB_MANIFEST)

这个命令将创建一个路由,本地缓存任何应用程序代码。如果要缓存第三方资源,我们需要做更多工作。让我们创建一个路由来缓存样式表:

registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com'
  // TODO Add handler
)

当我们调用 registerRoute 时,我们需要传递一个过滤函数和一个处理程序。过滤函数接收一个请求对象,并返回 true 如果处理程序应该处理该请求。处理程序是一个函数,决定如何满足请求。它可能查找本地缓存,将请求传递给网络,或者两者结合。

处理程序是相当复杂的构建函数,但通常遵循某些标准的 策略,比如在从网络下载文件之前检查缓存。Workbox 有一些函数提供了几种策略的实现。

当我们下载样式表时,我们将使用 stale-while-revalidate 策略,这意味着当浏览器想要下载 Google 样式表时,我们会发送一个样式表请求,并检查本地缓存是否已经有样式表文件的副本。如果没有,我们将等待样式表网络请求返回。如果你频繁请求资源但不关心是否有最新版本,这种策略非常有用。我们更喜欢使用缓存版本的样式表,因为速度更快。但我们也会始终从网络请求新版本的样式表。我们将缓存从 Google 返回的任何内容,因此即使这次没有获取到样式表的最新版本,下次加载时我们也会得到。

这是我们为 stale-while-revalidate 策略创建处理程序的方法:

registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com',
  new StaleWhileRevalidate({
    cacheName: 'stylesheets',
  })
)

StaleWhileRevalidate函数将返回一个处理程序函数,该处理程序将在名为stylesheets的缓存中缓存样式表。

当加载第三方请求时,您可能会发现您的请求可能因为跨域资源共享(CORS)错误而失败。即使第三方资源带有有效的 CORS 头,由于GET请求来自 JavaScript 代码而不是页面的 HTML,也可能发生此错误。您可以通过在使用资源的 HTML 元素上将crossorigin设置为anonymous来解决此问题,例如正在下载样式表的link引用。

当下载 Google 字体时,我们可以应用相同的策略。但是字体文件可能很大,stale-while-revalidate策略将始终下载资源的最新版本,即使仅仅是为了更新本地缓存。

相反,我们将使用缓存优先策略。在缓存优先策略中,我们首先检查资源的缓存,如果存在,则使用缓存。如果在本地找不到资源,我们将发送网络请求。这是处理大资源的有益策略。它确实有一个缺点:只有当缓存中不存在资源时,你才能下载资源的新版本。这意味着你可能永远无法下载任何更新版本。

因此,通常我们会配置缓存优先策略,只缓存一段时间内的资源。如果处理程序在本地缓存中找到资源但它太旧,它将从网络请求资源,然后缓存更新版本。

无论我们缓存什么,我们都将使用直到缓存超时。因此,如果第三方服务器出现临时问题并且我们收到500状态,^(5)我们不希望缓存响应。因此,在决定是否缓存响应之前,我们还需要检查状态。

以下代码显示了我们如何注册路由以缓存 Google 字体:

registerRoute(
  ({ url }) => url.origin === 'https://fonts.gstatic.com',
  new CacheFirst({
    cacheName: 'fonts',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxAgeSeconds: 60 * 60 * 24 * 7,
        maxEntries: 5,
      }),
    ],
  })
)

此代码将在名为fonts的本地缓存中缓存最多五个字体文件。缓存副本将在一周后超时,我们只会在状态为2000时缓存响应。状态0表示请求存在跨域问题,在这种情况下,我们会缓存响应。CORS 错误不会因为代码更改而消失,如果我们缓存错误,我们将避免发送注定失败的未来请求。

讨论

第三方资源缓存可以显著改善应用程序的性能,但更重要的是,在应用程序离线时,它将使资源可用。如果应用程序无法读取诸如字体文件之类的外观文件并不太重要,但是,如果您使用第三方代码生成付款表单,即使用户设备暂时断开网络,保持操作也会很有帮助。

您可以从GitHub 网站下载此配方的源代码。

自动重新加载工作线程

问题

服务工作线程更新的方式可能会让使用或测试应用程序的任何人感到困惑。如果我们对服务工作线程进行更改,应用程序将下载新版本并将其状态设置为已安装(参见 图 11-13)。

图 11-13. 更新后的工作线程已安装,但旧版本仍在运行

只有当用户关闭标签页然后重新打开时,旧的服务工作线程才会消失。旧的工作线程消失,新的工作线程可以停止等待并开始运行(参见 图 11-14)。

图 11-14. 只有在关闭并重新打开应用程序时,新的工作线程才会开始运行

如果服务工作线程正在缓存应用程序的代码,那么如果服务工作线程没有启动,它将不会从服务器下载最新的代码。您可能会发现您正在使用整个客户端应用程序的旧版本。为了运行新的应用程序,您需要重新加载页面(以安装新的工作线程),然后关闭并重新打开标签页(删除旧的工作线程并启动新的工作线程)。

测试人员很快会习惯这种稍微奇怪的顺序,但对真正的用户来说却不是这样。事实上,新代码只有在下次但一个时间可用时才会更新,通常不是一个大问题。如果您对代码进行了重大更改,例如更新 API,可能 会有问题。^(6)

在某些情况下,您希望立即使用新代码。有没有一种方法可以清除旧的服务工作线程并升级到应用程序的新版本?

解决方案

有两件事情我们需要做来切换到一个新的服务工作线程:

如果您使用 create-react-app 创建应用程序,或者使用 cra-template-pwa 模板的代码,^(7) 那么您将会注册您的服务工作线程,使用 serviceWorkerRegistration.register 函数。例如,您可能在应用程序的 index.js 文件中有以下代码:

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorkerRegistration from './serviceWorkerRegistration'
import reportWebVitals from './reportWebVitals'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

serviceWorkerRegistration.register()

reportWebVitals()

即使您已经编写了自己的注册代码,您可能也有类似的内容。

serviceWorkerRegistration.register 函数允许您传递一对回调函数,这些函数将告知您服务工作线程何时已被安装或更新:

serviceWorkerRegistration.register({
  onInstall: (registration) => {},
  onUpdate: (registration) => {},
})

回调函数接收到一个 registration 对象:这是浏览器刚刚安装或更新的服务工作线程的包装器。

当服务工作线程下载后,会被安装。但是如果已经有一个现存的服务工作线程在运行,新的服务工作线程将等待旧的服务工作线程消失。如果服务工作线程在等待,onUpdate 函数将会被调用。

我们希望在每次调用 onUpdate 函数时自动删除旧的服务工作线程。这将允许新的服务工作线程开始运行。

服务工作线程是一种特殊形式的网络工作者。网络工作者是在与网页中运行的 JavaScript 分离的线程中运行的 JavaScript 片段。您可以通过向它们发送异步消息来与所有网络工作者进行通信。服务工作线程可以拦截网络请求,因为浏览器将网络请求转换为消息。

因此,我们可以通过向服务工作线程发送消息来要求其运行任意代码片段。我们可以通过给服务工作线程添加消息事件侦听器来使其响应消息:

self.addEventListener('message', (event) => {
  // handle messages here
})

self 变量包含服务工作线程的全局范围。这就像 window 对于页面代码一样。

页面代码可以向新服务工作线程发送消息,告诉它我们希望它停止等待并替换旧的服务工作线程:

serviceWorkerRegistration.register({
  onUpdate: (registration) => {
    registration.waiting.postMessage({ type: 'SKIP_WAITING' })
  },
})

registration.waiting 是对服务工作线程的引用,registration.wait⁠ing.postMessage 将向其发送消息。

当浏览器安装新版本的服务工作线程但旧的服务工作线程仍在运行时,应用程序代码将向新的服务工作线程发送 SKIP_WAITING 消息。

服务工作线程有一个名为 skipWaiting 的内置函数,它将终止旧的服务工作线程并允许新的服务工作线程接管。因此,当服务工作线程收到 SKIP_WAITING 消息时,我们可以在服务工作线程中调用 skipWaiting

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting()
  }
})

如果应用程序现在已更新,新的服务工作线程将立即取代旧的服务工作线程。

只剩下一步:我们需要重新加载页面,以便通过新的服务工作线程下载新的应用程序代码。这意味着应用程序中的更新版本的 index.js 文件看起来像这样:

import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import * as serviceWorkerRegistration from './serviceWorkerRegistration'
import reportWebVitals from './reportWebVitals'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

serviceWorkerRegistration.register({
  onUpdate: (registration) => {
    registration.waiting.postMessage({ type: 'SKIP_WAITING' })
    window.location.reload()
  },
})

reportWebVitals()

一旦安装了这个新版本的代码,每次应用程序更改时应用程序将自动更新自身。与旧的服务工作线程并排等待新服务工作线程的耐心版本不同,您将只看到新加载的版本(见图 11-15)。

图 11-15. 新的服务工作线程现在将立即取代旧版本

讨论

通过添加页面重新加载,当新代码正在下载时,您会发现页面会“闪烁”。如果您的应用程序很大,这可能会让用户感到不适,因此您可能会选择在重新加载之前询问用户是否要升级到应用程序的新版本。Gmail 在有重大更新可用时会这样做。

您可以从GitHub 网站下载此配方的源代码。

添加通知

问题

服务工作线程以及一般的网络工作者的一个优点是,它们不会因用户离开页面而停止运行。如果服务工作线程执行了一个耗时的操作,只要浏览器本身仍在运行,它将继续在后台运行。这意味着您可以离开页面或关闭选项卡,并确保您的工作者将有时间完成。

但是,如果用户想知道后台任务何时完成,怎么办?服务工作者没有任何可视化界面。它们可能控制网页,但不能更新它们。网页和服务工作者之间唯一的通信方式是发送消息。

鉴于服务工作者没有可视化界面,它们如何告诉我们发生了重要事件?

解决方案

我们将从服务工作者创建通知。我们的示例应用程序(参见图 11-16)将在您点击按钮时启动一个长时间运行的过程,大约需要 20 秒。

图 11-16. 当您点击按钮时,示例应用程序会启动一个较慢的进程。

用户将不得不授予发送完成通知的权限(参见图 11-17)。如果他们拒绝了权限,后台任务仍将运行,但完成时不会看到任何内容。

图 11-17. 您将需要授予接收通知的权限

通知的声誉很差。通常情况下,当网站想用信息轰炸您时,您会看到它们。一般来说,如果您使用通知,最好在用户明白为什么需要时再请求权限。避免在页面加载时就请求发送通知的权限,因为用户不会知道为什么要发送通知。

然后服务工作者将运行一些代码,暂停 20 秒钟,然后显示通知(参见图 11-18)。

图 11-18. 当任务完成时显示的通知

让我们开始看代码。在App组件中,我们将添加一个按钮来运行后台,但确保仅在浏览器支持服务工作者时可见:

function App() {
  const startTask = () => {
    // Start task here
  }
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        {'serviceWorker' in navigator && (
          <button onClick={startTask}>Do slow thing</button>
        )}
      </header>
    </div>
  )
}

当用户点击按钮时,它们将调用startTask函数。我们可以在那里请求权限来显示通知:

const startTask = () => {
  Notification.requestPermission((permission) => {
    navigator.serviceWorker.ready.then(() => {
      const notifyMe = permission === 'granted'
      // Then run task
    })
  })
}

如果用户授予权限,则permission字符串将具有granted值,这将将notifyMe变量设置为true。我们可以在服务工作者中运行任务,并告诉它在完成时是否允许发送通知。

我们不能直接与服务工作者交流。相反,我们必须发布消息,因为服务工作者在与网页代码分离的单独线程中运行。

我们可以从navigator.serviceWorker.controller获取当前控制页面的服务工作者。因此,我们可以像这样向服务工作者发送消息:

const startTask = () => {
  Notification.requestPermission((permission) => {
    navigator.serviceWorker.ready.then(() => {
      const notifyMe = permission === 'granted'
      navigator.serviceWorker.controller.postMessage({
        type: 'DO_SLOW_THING',
        notifyMe,
      })
    })
  })
}

在示例应用程序中,我们的服务在service-worker.js中。它可以通过添加message事件处理程序来接收消息:

self.addEventListener('message', (event) => {
  ...
  if (event.data && event.data.type === 'DO_SLOW_THING') {
    // Code for slow task here
  }
})

在服务工作者中,self指的是全局作用域对象。它相当于网页代码中的window。让我们模拟一个慢任务,通过调用setTimeout等待 20 秒钟,然后向控制台发送消息:^(8)

self.addEventListener('message', (event) => {
  ...
  if (event.data && event.data.type === 'DO_SLOW_THING') {
    setTimeout(() => {
      console.log('Slow thing finished!')
      // TODO: Send notification here
    }, 20000)
  }
})

现在剩下的事情就是显示通知。我们可以使用服务工作线程的 registration 对象来实现,该对象具有一个 showNotification 方法:

self.addEventListener('message', (event) => {
  ...
  if (event.data && event.data.type === 'DO_SLOW_THING') {
    setTimeout(() => {
      console.log('Slow thing finished!')
      if (event.data.notifyMe) {
        self.registration.showNotification('Slow thing finished!', {
          body: 'Now get on with your life',
          icon: '/logo512.png',
          vibrate: [100, 100, 100, 200, 200, 200, 100, 100, 100],
          // tag: 'some-id-if-you-do-not-want-duplicates'
        })
      }
    }, 20000)
  }
})

注意,在尝试显示通知之前,我们会检查 event.data.notifyMe;这是我们在网页代码中添加的变量。

通知需要一个 title 和一个 options 对象。选项允许你修改通知的行为。在本例中,我们给它一些主体文本和一个图标,并设置了一个振动序列。如果用户的设备支持,他们应该在通知出现时感受到一组 dot-dot-dot-dash-dash-dash-dot-dot-dot 的振动。

在示例代码中,还有一个 tag 选项,我们已经将其注释掉了。我们可以使用 tag 来唯一标识一个通知,防止用户多次接收相同的通知。如果你省略它,每次调用 showNotification 都会产生一个新的通知。

要试验代码,你首先需要构建应用程序,因为服务工作线程只在生产模式下运行:

$ yarn run build

你接下来需要在生成的 build 目录上运行一个服务器。你可以通过安装 serve 模块来实现,并执行以下命令:

$ serve -s build

如果你在 http://localhost:5000 打开应用并点击按钮,慢速处理将开始。然后你可以转到另一页或关闭标签页,慢速任务将继续运行。只有当你关闭浏览器时,它才会停止。

20 秒后,你应该会看到一个类似于 图 11-19 的通知出现。

图 11-19. Mac 上显示的通知

很诱人从移动设备访问你的服务器以检查通知中的振动是否工作。请注意,服务工作线程仅在访问 localhost 或使用 HTTPS 时启用。如果你想通过 HTTPS 测试应用程序,请参见 “启用 HTTPS” 以在服务器上启用它。

考虑到通知可能在你关闭页面后出现,如果你给用户一个简单的方式回到你的应用程序将会很有帮助。你可以通过在服务工作线程中添加一个通知点击处理程序来实现这一点。如果服务工作线程创建了一个通知并且用户点击了它,浏览器将向服务工作线程发送一个 notificationclick 事件。你可以像这样创建一个处理程序:

self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  // TODO Go back to the application
})

你可以通过调用 event.notification.close 来关闭通知。但是如何将用户发送回 React 应用程序呢?

服务工作线程是零个或多个浏览器标签页的控制器,这些标签页被称为其 clients。这些标签页的网络请求被服务工作线程拦截。你可以使用 self.clients 来获取客户端列表。这个对象有一个叫做 openWindow 的实用函数,可以用来在浏览器中打开一个新的标签页:

self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  if (self.clients.openWindow) {
    self.clients.openWindow('/')
  }
})

如果用户现在点击通知,则浏览器将返回到 React 应用程序的首页。

但我们可以做得更好一点。如果用户已切换到不同的标签页但 React 应用程序仍然打开,我们可以将焦点切回到正确的标签页。

为此,我们需要获取服务工作线程控制的每个打开标签的数组。然后我们可以查看是否有任何匹配的正确路径。如果找到一个,我们可以将焦点切换到该标签:

self.addEventListener('notificationclick', (event) => {
  event.notification.close()

  event.waitUntil(
    self.clients
      .matchAll({
        type: 'window',
      })
      .then((clientList) => {
        const returnPath = '/'

        const tab = clientList.find((t) => {
          return t.url === self.location.origin + returnPath
        })
        if (tab && 'focus' in tab) {
          tab.focus()
        } else if (self.clients.openWindow) {
          self.clients.openWindow(returnPath)
        }
      })
  )
})

如果我们点击通知,我们将切换回一个打开的标签页,而不是总是创建新的标签页(见 图 11-20)。

图 11-20. 如果仍然打开,则通知可以切换回我们的应用程序

讨论

通知是让用户了解重要事件的好方法。关键在于澄清他们为什么应该同意接收通知,然后只有在发生重要事件时才发送通知。

您可以从 GitHub 站点 下载此示例的源代码。

利用后台同步进行离线更改

问题

想象有人在网络连接不可用的地方(例如,在地铁上)使用应用程序。应用程序代码的预缓存意味着可以无需网络连接打开应用程序。用户还可以在页面之间移动,一切都应该正常显示。

但是如果他们做了一些会向服务器发送数据的事情怎么办?如果他们试图发布消息怎么办?

解决方案

后台同步 是一种在服务器不可用时排队网络请求并在以后自动重新发送它们的方式。

我们的示例应用程序将在用户点击按钮时向后端服务器发送一些数据(参见 图 11-21)。

图 11-21. 当用户点击按钮时,示例应用程序会向服务器发送数据

要启动应用程序,您首先需要使用此命令构建它:

$ yarn run build

示例项目在 server/index.js 中包含此服务器:

const express = require('express')
const app = express()

app.use(express.json())
app.use(express.static('build'))

app.post('/endpoint', (request, response) => {
  console.log('Server received data', request.body)
  response.send('OK')
})

app.listen(8000, () => console.log('Launched on port 8000!'))

服务器将从 build 目录中提供内容,该目录中发布了生成的代码。它还显示从发送到 http://localhost:8000/endpoint 的任何 POST 请求接收到的数据。

您可以使用此命令启动服务器:

$ node server

如果现在在浏览器中打开应用程序,地址为 http://localhost:8000,并在首页点击按钮几次,你将看到数据出现在服务器窗口中:

$ node server
Launched on port 8000!
Server received data { timeIs: '2021-05-09T18:59:37.280Z' }
Server received data { timeIs: '2021-05-09T18:59:37.720Z' }
Server received data { timeIs: '2021-05-09T18:59:38.064Z' }
Server received data { timeIs: '2021-05-09T18:59:38.352Z' }

这是发送数据到服务器的应用程序代码。它使用 fetch 函数在按下按钮时 POST 当前时间:

import React from 'react'
import logo from './logo.svg'
import './App.css'

function App() {
  const sendData = () => {
    const options = {
      method: 'POST',
      body: JSON.stringify({ timeIs: new Date() }),
      headers: {
        'Content-Type': 'application/json',
      },
    }
    fetch('/endpoint', options)
  }
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <button onClick={sendData}>Send data to server</button>
      </header>
    </div>
  )
}

export default App

如果现在停止服务器,点击网页上的按钮将生成一系列失败的网络请求,如 图 11-22 所示。

图 11-22. 如果无法联系服务器,则网络请求将失败

停止服务器会模拟用户暂时失去网络联系后尝试从应用程序发送数据的情况。

我们可以通过使用 service worker 来解决这个问题。Service worker 可以拦截渐进式 web 应用程序中网页发出的网络请求。在本章的其他示例中,我们已经使用 service worker 通过返回本地缓存版本的文件来处理网络故障。现在我们需要处理数据的反向传输:从浏览器到服务器。

我们需要缓存我们尝试发送到服务器的 POST 请求,并在再次与服务器联系时重新发送它们。

为此,我们将使用 workbox-background-sync 库。后台同步 是一种 API,用于在我们无法联系服务器的情况下将网络请求重定向到队列中。这是一个复杂的 API,并非所有浏览器都支持。

workbox-background-sync 库使得 API 的使用变得更加简单,并且它还能够在像 Firefox 这样不原生支持后台同步的浏览器上运行。

示例应用程序的 service worker 在 service-worker.js 文件中。我们可以通过添加以下代码来添加后台同步功能:

import { NetworkOnly } from 'workbox-strategies'
import { BackgroundSyncPlugin } from 'workbox-background-sync'

// Other service worker code here....

registerRoute(
  //endpoint/,
  new NetworkOnly({
    plugins: [
      new BackgroundSyncPlugin('endPointQueue1', {
        maxRetentionTime: 24 * 60,
      }),
    ],
  }),
  'POST'
)

这段代码将在 service worker 中注册一个新的 路由,用于处理特定 URL 的网络请求。在这种情况下,我们正在创建一个路由来处理所有对 http://localhost:8000/endpoint 的请求。我们使用正则表达式来匹配路径。然后,我们使用 Network Only 策略,这意味着浏览器会将所有请求发送到 service worker,并且所有响应都将来自网络。但我们配置该策略以使用后台同步插件。路由的第三个参数表示它只对 POST 请求到 endpoint 感兴趣。

当应用程序向 http://localhost:8000/endpoint 发送 POST 请求时,service worker 会拦截它。Service worker 将请求转发到服务器,如果成功,将返回响应给网页。如果服务器不可用,service worker 将向网页返回网络错误,然后将网络请求添加到名为 endPointQueue1 的重试队列中。

Workbox 在浏览器内的索引数据库中存储队列。将 maxRe⁠tentionTime 设置为 24 * 60 将请求存储在数据库中最多一天。

workbox-background-sync 库将在认为服务器可能已经可用时重新发送队列中的请求,例如,如果网络连接重新上线。重试也会每隔几分钟进行一次。

如果您重新启动服务器然后等待大约五分钟,您应该会看到失败的网络请求出现在服务器中:

$ node server
Launched on port 8000!
Server received data { timeIs: '2021-05-09T21:26:11.068Z' }
Server received data { timeIs: '2021-05-09T21:02:44.647Z' }
Server received data { timeIs: '2021-05-09T21:02:45.647Z' }

如果您打开开发者工具中的应用程序选项卡,选择服务工作者,然后向workbox-background-sync:endPointQueue1发送同步消息(如图 11-23 所示),则可以强制 Chrome 立即重新发送请求。

图 11-23. 在 Chrome 中强制进行同步

讨论

后台同步是一个非常强大的功能,但在启用之前需要仔细考虑。客户端代码发送请求的顺序不一定是服务器处理它们的顺序。

如果您创建简单的一组资源并使用POST请求,确切的顺序可能并不重要。例如,如果您从在线书店购买书籍,购买它们的顺序并不重要。

但是,如果您创建依赖资源或对同一资源应用多个更新⁠^(10),那么您需要小心。例如,如果您先将信用卡号修改为1111 1111 1111 1111,然后再修改为2222 2222 2222 2222,更新的顺序将完全改变最终结果。

您可以从GitHub 站点下载此配方的源代码。

添加自定义安装 UI

问题

PWAs 在许多方面的行为方式类似于本地安装的应用程序。您可以在桌面机器或移动设备上安装它们。许多浏览器允许您在当前设备上创建快捷方式,以在单独的窗口中启动您的应用程序。如果您在桌面机器上,您可以将快捷方式添加到码头或启动菜单中。如果您在移动设备上,您可以将应用程序添加到主屏幕上。

但是,许多用户忽略了他们可以安装 PWA 的事实,这一情况在浏览器中使用低调界面来指示可安装性时并没有得到改善(参见图 11-24)。

图 11-24. PWA 安装在地址栏中的一个小按钮中

浏览器这样做是为了最大化您网站的屏幕空间。然而,如果您认为本地安装对您的用户有帮助,您可以选择添加自定义安装 UI。但是如何做到呢?

解决方案

一些浏览器^(11)会在检测到您的应用程序是一个完整的 PWA 时生成 JavaScript beforeinstallprompt事件。^(12)

您可以捕获此事件并使用它来显示您的自定义安装 UI。

创建一个名为MyInstaller.js的组件,并添加以下代码:

import React, { useEffect, useState } from 'react'

const MyInstaller = ({ children }) => {
  const [installEvent, setInstallEvent] = useState()

  useEffect(() => {
    window.addEventListener('beforeinstallprompt', (event) => {
      event.preventDefault()
      setInstallEvent(event)
    })
  }, [])

  return (
    <>
      {installEvent && (
        <button
          onClick={async () => {
            installEvent.prompt()
            await installEvent.userChoice
            setInstallEvent(null)
          }}
        >
          Install this app!
        </button>
      )}
      {children}
    </>
  )
}

export default MyInstaller

此组件将捕获onbeforeinstallprompt事件,并将其存储在installEvent变量中。然后,它利用事件的存在来显示自定义用户界面。在此处的代码中,它在屏幕上显示一个简单的按钮。然后,您可以将此组件插入到您的应用程序中,例如:

function App() {
  return (
    <div className="App">
      <MyInstaller>
        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <p>
            Edit <code>src/App.js</code> and save to reload.
          </p>
          <a
            className="App-link"
            href="https://reactjs.org"
            target="_blank"
            rel="noopener noreferrer"
          >
            Learn React
          </a>
        </header>
      </MyInstaller>
    </div>
  )
}

如果现在构建并运行应用程序:

$ yarn run build
$ serve -s build

您将在首页顶部看到安装按钮(参见 图 11-25)。如果您像这样使用开发服务器运行应用程序,则不会看到安装按钮:

$ yarn run start

图 11-25. 自定义安装按钮出现在页面顶部

这是因为应用程序只有在有服务工作线程运行时才能被视为 PWA。服务工作线程只会在生产代码中运行。

如果单击安装按钮,MyInstaller 组件将运行 installEvent.prompt 方法。这将显示通常的安装对话框(参见 图 11-26)。

图 11-26. 单击自定义安装按钮后,将出现安装提示。

如果您的设备已安装该应用程序,则浏览器不会触发onbeforeinstallprompt事件。

如果用户选择安装该应用程序,将启动一个单独的应用程序窗口。^(13) 如果他们使用桌面设备,可能会出现一个查找器或资源管理器窗口,其中包含一个可添加到您的设备上的停靠栏或启动菜单的应用程序启动图标(参见 图 11-27)。在移动设备上,该图标将出现在主屏幕上。

图 11-27. 浏览器将为该应用程序创建一个启动图标

讨论

本地安装是一个对经常运行您的应用程序的用户非常有用的功能。根据我们的经验,许多用户并不知道某些网站可以安装,因此添加一个自定义界面是个好主意。但是,如果您认为用户可能只是偶然访客,最好避免触发页面加载时自动显示安装实例。这样做可能会让用户感到不适,从而阻止他们返回您的网站。

您可以从GitHub 网站下载此示例的源代码。

提供离线响应

问题

您不希望在应用程序中缓存所有第三方资源;这会占用太多空间。这意味着有时您的代码将无法加载所需的所有资源。例如,您可以看到在早期章节中创建的应用程序,该应用程序显示了从第三方图像站点获取的一系列图像(参见 图 11-28)。

图 11-28. 应用程序显示来自 http://picsum.photos 的图像

您可以使用服务工作线程将此应用程序的所有代码缓存以便离线工作。您可能不希望缓存第三方图像,因为这样会导致太多的缓存。这意味着如果断开网络连接,应用程序仍将打开,但没有图像(参见 图 11-29)。

图 11-29. 如果您处于离线状态,图片将无法加载。

有助于用本地提供的替代图像替换丢失的图像。这样,当用户处于离线状态时,他们仍然可以看到占位图像。

这是一个特定情况的普遍问题:当一个大型外部文件不可用时,您可能希望有占位文件。您可能想用一些临时替代品替换视频文件、音频文件,甚至完整的网页。

解决方案

为了解决这个问题,我们将结合使用几种服务工作者技术,以返回一个本地替代品和一个缓存文件。

假设我们想要用在 图 11-30 中显示的替代图像来替换所有加载失败的图像。

图 11-30. 用于替换加载失败的图像

我们需要做的第一件事是确保图像文件在本地缓存中可用。我们将把图像添加到应用程序使用的静态文件中,但我们不能依赖于替代图像被自动缓存。预缓存将存储我们从服务器下载的任何文件。在网络离线时,我们将需要占位图像,因此我们必须使用 缓存预热 明确地将图像加载到本地缓存中。

在服务工作者中,我们将在服务工作者安装时运行一些代码。我们可以通过添加一个 install 事件处理程序来实现这一点:

self.addEventListener('install', (event) => {
  // Cache image here
})

我们可以显式地打开一个名为 fallback 的本地缓存,然后从网络中添加文件:

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('fallback').then((cache) => {
      cache.add('/comingSoon.png')
    })
  )
})

如果您希望在应用程序安装时缓存文件,这种技术将会很有帮助,特别是对于那些虽然不会被应用程序立即加载,但在离线时仍然需要的文件。

现在我们已经存储了替代图像,当真正的图像不可用时,我们需要返回它。我们需要添加在网络请求失败时运行的代码。我们可以使用 catch 处理程序 来实现这一点。当 Workbox 策略失败时,将执行 catch 处理程序:^(14)

setCatchHandler(({ event }) => {
  if (event.request.destination === 'image') {
    return caches.match('/comingSoon.png')
  }
  return Response.error()
})

catch 处理程序接收失败的请求对象。我们可以检查请求的 URL,但最好检查请求的 destination。目的地是将消耗文件的事物,并在选择文件的占位符时非常有帮助。如果目的地是 image,则请求发生是因为浏览器尝试加载 img 元素。以下是一些其他请求目的地的示例:

目的地 生成者
“” JavaScript 网络请求
“音频” 加载一个
“document” 导航到一个网页
“嵌入” 加载一个
“font” 在 CSS 中加载字体
“frame” 加载一个
“iframe” 加载一个
posted @ 2025-11-18 09:36  绝不原创的飞龙  阅读(16)  评论(0)    收藏  举报