React-和-D3-js-集成教程-全-

React 和 D3.js 集成教程(全)

原文:Integrating D3.js with React

协议:CC BY-NC-SA 4.0

一、设置我们的技术栈

集成交互式数据可视化(又名数据即)组件可以帮助你更好地讲述你的故事。React 已经设置为能够动画可缩放矢量图形(SVG)、HTML 和 Canvas 这不是什么新鲜事。多年来,我们已经有能力用 HTML 和纯 JavaScript 制作 SVG、Canvas 和 HTML 动画。React 具有 HTML、CSS 和 JavaScript 功能,可以很好地与其他库配合使用,帮助创建图表和动画视图。

为什么要使用数据可视化?

React 相对于其他 web 平台的最大优势是它使用了虚拟 DOM (VDOM ),从而提高了性能。我们可以利用 React 所提供的以及其他第三方库,如 D3 库和数据管理,来处理我们的数据,不仅构建引人注目的图表组件,还可以提高性能。同时。有时,我们希望取得控制权,让我们的组件控制元素,而不是做出 React。

添加其他库和技术,比如反冲、Material-UI、TypeScript、单元测试、时髦的层叠样式表(SCSS)等等,将需要更多的知识,但这是我们所得到的小小代价。

在这本书里,我会给你一些工具来学习如何用 React 创建数据可视化,我们会从大量的 D3 库以及 React 世界里的其他标准库那里得到帮助。

这第一章作为一个介绍。我们将回顾我们将在本书中使用的工具来创建动画和图表,以及设置我们的第一个“Hello World”D3/React/TypeScript 项目。此外,我们将看看我们能做些什么来确保质量,研究单元测试、林挺和格式化。

我们开始吧。

React

React(也称为 ReactJS)是一个 JavaScript 库,由脸书( https://github.com/facebook/react )开发,用于创建 Web 用户界面。

React 是由乔丹·沃克发明的,他当时正在做脸书的广告。它与其他 web 框架和库如 jQuery、Angular、Vue.js、Svelte 等竞争。

在 2017 年 9 月发布的上一版本 React 16.x 中,React 团队在消除 bug 的同时,增加了更多的工具和开发支持。React 的最新版本(撰写本文时)是 17,发布于 2020 年 10 月。

Note

React 17 是一个“垫脚石”版本,该版本主要专注于使 React 更容易升级到未来版本,以及增加与浏览器的更好兼容性。React 团队的支持表明该库势头强劲,不会很快消失。

为什么要 React?

你知道吗?

  • React 是开发人员的最爱。事实上,根据一项 Stack Overflow 调查( https://insights.stackoverflow.com/survey/2020 ),React 是最受欢迎的 web 框架并且已经连续两年了。

  • 对 React 开发人员的需求激增;据 Indeed.com(https://www.indeed.com/q-React-jobs.html)统计,React 开放的开发者岗位接近 56000 个。

  • React 库很轻(大约 100KB)并且很快。

  • React 很容易上手使用。

对优势和局限性做出 React

正如我提到的,当 React 与 jQuery、Angular 和 Vue.js 等其他 web 框架相比时,最大的优势是 React 使用了 VDOM,可以提高性能。这里还有几个优点:

  • React 可以用作单页应用(SPA ),如 Create-React-App (CRA ),或者用于服务器端渲染(SSR ),如 Gatsby.js 和 Next.js

  • React 可以遵循单向数据流以及数据绑定。

  • React 的 JSX 产生了更好的代码可读性。

  • React 可以很容易地与其他框架集成。

React 有一些限制,如下所示:

  • React 本身只是一个 UI 库,而不是 Angular 那样的成熟框架。

  • 开发人员可以决定添加哪些库以及遵循哪些最佳实践。

  • 与其他工具相比,React 的学习曲线更加陡峭。

React 模板启动项目

在创建 React 应用时,有许多选项可供选择。您可以自己编写代码,然后添加库来帮助您打包代码,为生产做好准备(工具链),并完成编写代码时的其他常见标准任务。

Note

React 工具链是一套编程工具,用于为我们的最终开发/部署产品执行复杂的开发任务。

开始的另一个选择是使用许多 React starter 模板项目,这些项目已经负责搭建和配置,并包括帮助您快速完成工作的库。创建 React app 最流行的模板是 Create-React-App(https://github.com/facebook/create-react-app);该项目由脸书创建,在 GitHub 上有 85,000 颗星星。CRA 是基于一个单页面的应用,所以没有页面刷新,这种体验就像你在一个移动应用里面。这些页面应该在客户端呈现。这是中小型项目的理想选择。

另一个选项是 SSR,它在服务器上呈现页面,因此客户端(浏览器)无需做任何工作就可以显示应用。SSR 适用于某些用例,在这些用例中,如果渲染发生在客户端,用户体验会很慢。

CRA 不支持现成的 SSR。有一些方法可以配置 CRA 并使其与 SSR 一起工作,但是对于一些开发人员来说,这可能太复杂了,并且需要您自己维护配置,所以可能不值得花费精力。

如果您正在构建需要 SSR 的东西,最好是使用已经配置好的带有 SSR 的不同 React 库,如 Next.js framework、Razzle 或 Gatsby(在构建时将预呈现的网站转换为 HTML)。

如果你更喜欢带有 React 的 SSR,可以看看 Next.js、Razzle 或 Gatsby。

也就是说,使用 CRA,你可以进行预渲染,这是最接近 SSR 的方法,在本书后面的章节中,当我们优化 React 应用时,你会看到这一点。

在本书的例子中,我们将使用 CRA;然而,我们将要构建的组件是松散耦合的,可以很容易地导入到任何 React 项目中,几乎不需要任何努力。

Tip

我们将在本书中使用 CRA,项目将很容易理解。但是,可以随意使用任何 React 模板启动项目,甚至从头开始创建自己的 React 项目,并处理自己的工具链。在第十章,我将向你展示如何使用 SSR 和 Next.js 来设置你的 React 项目。

先决条件

我们将要安装的库被提交给 NPM ( https://www.npmjs.com/ )。需要 Node.js 来获得 NPM,使用 NPM 从 NPM 仓库下载包。

NPM 和 Node.js 携手并进。NPM 是 JavaScript 包管理器,也是 JavaScript Node.js 环境的默认包管理器。

在 Mac/PC 上安装节点和 NPM

如果你没有安装 Node.js,你需要安装它。Node.js 至少需要 8.16.0 或 10.16.0 版本。我们需要那个版本的原因是我们需要使用 NPX,这是 2017 年推出的 NPM 任务运行器,用于设置 CRA。

通过检查版本来确保您拥有它,如下所示:

$ node -v

如果没有安装,你可以从这里为 Mac 和 PC 安装(图 1-1 ):

img/510438_1_En_1_Fig1_HTML.jpg

图 1-1

在 Mac 上下载 Node.js

https://nodejs.org/en/

安装程序可以识别你的平台,所以如果你在 PC 上,步骤是一样的。

一旦你下载了安装程序,运行它;一旦完成,在终端/DOS 中运行node命令。

$ node -v

该命令将输出 Node.js 版本号。

下载库:纱线或 NPM

要从 NPM 资源库下载包,我们有两个选项:Yarn 或 NPM。NPM 附带 Node.js,无需安装即可使用。然而,在本书中,我们大多会使用另一个库:Yarn。我们将尽可能多地使用纱线来下载软件包,而不是 NPM。

我们在这本书里用纱代替 NPM 的原因是纱比 NPM 快。Yarn 缓存已安装的包并同时安装包。我们还安装了 NPM,因为它是 Node.js 附带的

在 Mac/PC 上安装 Yarn

要在 Mac 上安装 Yarn,一个好的选择是在终端中安装brew

$ brew install yarn

就像 Node.js 一样,用-v标志运行 Yarn 输发布本号。

$ yarn -v

在 PC 上,您可以从以下位置下载 MSI 下载文件:

https://classic.yarnpkg.com/latest.msi

您可以在此找到更多安装选项:

https://classic.yarnpkg.com/en/docs/install/#mac-stable

创建-React-应用 MHL 模板项目

配备了 Node.js 以及 NPM 和 Yarn,我们就可以开始了。我们可以在 https://github.com/EliEladElrom/cra-template-must-have-libraries 使用我为您创建的 CRA 必备图书馆(MHL)模板项目。

CRA 坚持己见,包括诸如 Jest、service workers 和 ES6 等库。MHL 模板项目甚至更加固执己见,包括以下库:

  • 打字检查器:打字稿

  • 预处理器:萨斯/SCSS

  • 状态管理 : Redux 工具包/反冲

  • CSS 框架:素材-UI

  • CSS-in-JS 模块:样式化组件

  • 路由:React 路由

  • 单元测试 : Jest 和 Enzyme + Sinon

  • E2E 测试:笑话和木偶师

  • 文件夹结构

  • 生成模板

  • 埃斯林特和更漂亮

  • 其他有用的库 : Lodash,Moment,Classnames,Serve,react-snap,React-Helmet,Analyzer Bundle

如果您想了解这些库是如何安装的,您可以创建自己的模板或修改现有的模板。那超出了本书的范围;但是,您可以阅读本文,了解每个库的完整分步安装:

https://medium.com/react-courses/setting-up-professional-react-project-with-must-have-reactjs-libraries-2020-9358edf9acb3

或者你可以参加我在 Udemy 上的课程:

https://www.udemy.com/course/getting-started-react17-with-must-have-libraries/

让我们首先用一个命令创建我们的react-d3-hello-world“Hello World”项目,如下所示:

$ yarn create react-app react-d3-hello-world --template must-have-libraries

或者我们可以使用npx,如下图所示:

$ npx create-react-app react-d3-hello-world --template must-have-libraries

一旦库和所有依赖项的安装完成下载,您就可以通过启动本地服务器来运行项目。

将目录切换到react-d3-hello-world项目,在终端运行start命令(见图 1-2 )。

img/510438_1_En_1_Fig2_HTML.jpg

图 1-2

CRA 编译成功

$ cd react-d3-hello-world
$ yarn start

您可以在package.json文件中看到这个运行命令。该命令指向react-scripts库,并在默认端口 3000(您可以更改)上的本地服务器上启动项目。

现在导航到本地主机并查看项目,如图 1-3 所示。

img/510438_1_En_1_Fig3_HTML.jpg

图 1-3

运行 CRA 启动项目的本地服务器

类型检查器:类型脚本

在编写 React 代码时,有两个选项可供选择:可以使用 JavaScript (JS)或 TypeScript (TS)编写代码。TypeScript 是 transpiler,这意味着 ES6 不理解 TS,但 TS 会被编译成标准的 JS,这可以用 Babel 来完成。

CRA·MHL 项目已经设置了 TS 作为开箱即用的类型检查器,因此您无需做任何事情。然而,我想扩展一下为什么我选择 TS 而不是 JS。

为什么应该将 TypeScript 集成到 React 项目中?

以下是一些有趣的事实:

  • 您知道 TypeScript 是由微软开发和维护的开源编程语言吗?

  • 根据 Stack Overflow 2020 年的调查,TypeScript 编程语言是第二受欢迎的语言,去年甚至超过了 Python!

为什么 TypeScript 这么受欢迎?

TS vs. JS,有什么大不了的?

顾名思义,TS 就是设置“类型”TS 比 JS 更容易调试和测试,并通过描述预期的内容来防止潜在的问题(当我们在本书后面测试我们的组件时,您会看到这一点)。使用 TS,一种成熟的面向对象编程(OOP)语言和模块将开发带到了更专业的水平,并提高了我们的代码质量。

如果我们做一个 TS 对 JS 的快速比较:

  • TypeScript 是一个 OOPJavaScript 是一种脚本语言。

  • TypeScript 使用遵循 ECMAScript 规范的静态类型。

  • TypeScript 支持模块。

类型系统将一个类型与每个值相关联—通过检查这些值的流程,它确保没有类型错误。

静态类型意味着在运行之前检查类型(允许您在运行之前跟踪 bug)。

JS 只包括以下八种动态(运行时)类型:BigInt、Boolean、Integers、Null、Number、Strings、Symbol、Object(对象、函数和数组)和 Undefined。

Note

所有这些类型都被称为原始类型,除了 Object,它被称为非原始类型。TS 通过设置编译器对源代码进行类型检查,将静态类型转换为动态代码,从而为 JavaScript 添加静态类型。

React 和 TypeScript 配合得很好,因为 TypeScript 使用 OOP 最佳实践提高了应用的代码质量,所以值得学习。

TS 的最新版本是版本 4 公共迭代。要在 TS 中玩编码,可以在 https://www.typescriptlang.org/play/ 的 TS 游乐场运行 TS 代码(见图 1-4 )。

img/510438_1_En_1_Fig4_HTML.jpg

图 1-4

TS 游乐场

TS 游乐场网站有大量的例子,可以帮助您更好地了解 TS。我建议探究这些例子。

请注意,该示例使用了“strict”,在 TS Config 菜单项中,您可以设置编译器选项。不同的编译器选项在 https://www.typescriptlang.org/docs/handbook/compiler-options.html 中解释。

这可能会使你的代码在编译时出现错误和警告,但这是值得的,因为它将帮助你避免以后编译器无法识别类型和你的应用在运行时中断的问题。

Tip

我们宁愿我们的应用在编译时中断,而不是在运行时。

我提到 TS 是 OOP 语言,遵循 ECMAScript 规范;然而,规范是动态的,经常变化,所以您可以指定 ECMAScript (ES)目标。参见图 1-5 。

img/510438_1_En_1_Fig5_HTML.jpg

图 1-5

指定 TS 操场中的 ECMAScript 目标

从 TS 开始的一个很好的地方是通过查看不同的可用类型来理解它的功能。如果您刚刚开始使用 TS,解释类型超出了本书的范围,但是我欢迎您查看下面的文章,其中还包括一个带有大量示例的备忘单:

https://medium.com/react-courses/instant-write-reactjs-typescript-components-complete-beginners-guide-with-a-cheatsheet-e32a76022a44

D3

D3(又名 D3js 或 D3.js)代表“数据驱动文档”,它使您能够创建整洁的数据驱动文档( https://github.com/d3/d3 )。这是一个图表库,有助于将数据变得生动。它是由 Mike Bostock 在纽约时报创建的,用于创建交互式网络可视化。这是基于他在斯坦福可视化小组攻读博士期间的工作。

  • D3 包括一个全面的库,有将近 170 个例子。 https://observablehq.com/@d3/gallery

  • D3 利用了这些基础技术:JavaScript、HTML、CSS3、Canvas 以及最后但同样重要的 SVG。

D3.js 是一个基于数据操作文档的 JavaScript 库。D3 使用 HTML、SVG 和 CSS 帮助你将数据变得生动。D3 对 web 标准的重视让您拥有现代浏览器的全部功能,而无需将自己束缚在一个专有的框架中,结合了强大的可视化组件和数据驱动的 DOM 操作方法。

https://d3js.org/

D3 是用 JavaScript 编写的,重点是将数据附加到文档对象模型(DOM)元素上。

典型的纯 D3 的过程可以分为三个部分。

  • Attach :将数据附加到 DOM 元素上。

  • 显示:使用 CSS、HTML 和/或 SVG 来显示数据。

  • Interactive :使用 D3 数据驱动的转换和转换使数据具有交互性。

撰写本文时的最新版本是 v6。阅读 changelog ( https://github.com/d3/d3/blob/master/CHANGES.md )来看看版本 6 有什么新变化。

D3 有一个陡峭的学习曲线,添加 React 和 TypeScript 使曲线更加陡峭。

其实 D3 有 30 多个模块,1000 个方法!要深入了解 D3,D3 团队提供的免费资源很少。

D3 版本> 4

正如我提到的,D3 已经是第六次迭代了。在 D3 版本 4 及以上,最大的变化是 D3 是模块化的。由于这种模块化,您可以只导入需要的东西,而不是带来整个厨房水槽。这里有一个例子:

$yarn add d3-axis d3-interpolate d3-scale d3-selection

请记住,对于 TS,我们还需要引入类型(yarn @types/module-name)。

Tip

当你在网上看例子时,比如在 https://observablehq.com/@d3/gallery 的例子,检查例子中使用的 D3 版本是很重要的。D3 v4 和更高版本已经经历了重大的变化,升级需要对 D3 库有很好的了解。

其他数据即库

除了 D3,还有许多其他基于 D3 构建的 React 库,包括现成的组件。以下是一些比较受欢迎的:

在第九章中,我将向你展示如何实现这些流行的库,同时回顾这些库来帮助你选择一个。

许多人会认为我们最不需要的就是另一个图表组件库,他们是对的。如果你需要现成的图表,有很多可供选择。

也就是说,使用图表库创建真正创新和高性能的可视化可能是一个挑战,您可能会发现自己需要使用 D3 或派生现有的图表库并进行更改。

除了 D3 和 React 库,还有其他高级库可以使用,比如 Vega ( https://vega.github.io/vega-lite/ )。

此外,还有商业智能(BI)工具,如 Tableau 或 PowerBI,您可以使用它们来分析数据,并将可视化集成到 React 项目中。

请记住,除了我提到的顶级库之外,GitHub 还充斥着制作终极图表库的失败尝试。您将很快淹没在选项、标志和props中,以满足所有的用例。

D3 是最终的“图表库”如果你想做一个定制化的数据可视化,那就花时间学习 D3 吧。

也就是说,有一些特定的项目需要更快的发布或者更多的定制,比如概念验证(POC)项目。在这些情况下,知道那里有什么以及如何集成或派生它将会派上用场,所以我将在第九章中介绍一些这样的库供你参考。

ReactTransitionGroup 附加组件

除了 D3,ReactCSSTransitionGroup是 React 库的一个例子,它提供了基于ReactTransitionGroup的高级 API。当 React 组件进入或离开 DOM 时,这是一种执行 CSS 过渡和动画的简单方法(该库的灵感来自 Angular 的ng-animate)。

你可以把这个库和其他的库一起使用,比如 D3;你可以在这里了解更多: https://reactjs.org/docs/animation.html

React v17 + D3 +类型脚本

我们已经介绍了名为react-d3-hello-world的起始项目,并查看了 TypeScript 和 D3,所以我们现在可以将它们放在一起,创建我们的第一个“Hello World”D3 项目。

首先,安装 D3 和所有的 D3 类型(对于 TS)。

$ yarn add d3 @types/d3

将功能组件与 D3 React

接下来,让我们创建一个简单的组件;我们称它为HelloD3 .,因为我们将使用一个我用generate-react-cli库( https://github.com/arminbro/generate-react-cli )创建的模板来移动文件和创建文件夹结构。

npx generate-react-cli component HelloD3 --type=d3

这里可以看到设置:generate-react-cli.json。导航到src/components,可以看到自动为你生成了三个文件(见图 1-6 )。

img/510438_1_En_1_Fig6_HTML.jpg

图 1-6

HelloD3 组件

  • HelloD3.scss

  • HelloD3.test.tsx

  • HelloD3.tsx

App.tsx

打开我们的 app 入口点App.tsx,添加我们创建的HelloD3组件。

import React from 'react'
import './App.scss'
import HelloD3 from './components/HelloD3/HelloD3'

function App() {
 return (
  <div className="App">
   <header className="App-header">
    <HelloD3 />
   </header>
  </div>
 )
}

export default App

现在,我们再来看看 localhost】(见图 1-7 )。你可以看到一个“Hello World”信息和两个方块。

img/510438_1_En_1_Fig7_HTML.jpg

图 1-7

CRA MHL D3 "你好世界"

恭喜你,你刚刚创建了你的第一个集成了 React 和 D3 的项目。

HelloD3.tsx

现在,让我们检查一下HelloD3组件代码。

// src/components/HelloD3/HelloD3

从 React 开始,我们将使用useEffectrefObject库。

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

让我们导入样式文件;我们现在没有使用它,但它会准备好,以备我们需要添加一种风格。

import './HelloD3.scss'

接下来,我们将导入整个 D3 库。

import * as d3 from 'd3' // yarn add d3 @types/d3

对于我们的组件,我们将使用一个纯函数组件并设置我们的引用对象。

Refs 提供了访问在 render 方法中创建的 DOM 节点或 React 元素的方法。

https://reactjs.org/docs/refs-and-the-dom.html

const HelloD3 = () => {
 const ref: RefObject<HTMLDivElement> = React.createRef()

注意 TS 类型被设置为HTMLDivElement.我怎么知道呢?我不得不钻研 React 代码来找出ref对象类型。这是使用 TS 时的常见做法,我发现深入研究实际的 React 库很有帮助,因为它增加了我对 React 的理解。

接下来,我们将使用useEffect钩子来调用一个draw()方法,一旦调用了useEffect,我们将创建这个方法。

钩子是 React 16.8 中新增的。它们允许您使用状态和其他 React 特性,而无需编写类。

https://reactjs.org/docs/hooks-effect.html

 useEffect(() => {
  draw()
 })

我们的draw()方法包括“选择”或者选择我们将要使用的 HTML 元素。接下来,我们“追加”或者换句话说,添加一个文本元素“Hello World”

Note

在整本书中,我将经常使用draw()函数,而不在useEffect.中将draw()函数列为依赖函数。最好的方法是记住带有useCallback的绘制对象,以确保不会出现无限循环(参见 https://reactjs.org/docs/hooks-reference.html#usecallback )。如果你对这种方法不熟悉,我会在第 11 (使用useCallback)Memorize函数)一章中向你展示如何进行优化。

此外,我们选择将在 JSX 呈现的 SVG 元素,附加一个组元素和一个矩形元素,并转换 SVG 的宽度和填充颜色属性。我们的 SVG 大小为 250×500 像素。

 const draw = () => {
  d3.select(ref.current).append('p').text('Hello World')
  d3.select('svg').append('g').attr('transform', 'translate(250, 0)').append('rect').attr('width', 500).attr('height', 500).attr('fill', 'tomato')
 }

 return (

在 JSX 渲染方面,我们设置我们的div来保存我们用来添加文本元素的引用。

<div className="HelloD3" ref={ref}>

类似地,我们设置一个宽度和高度为 500px 的 SVG 元素,并在其中绘制一个矩形,用绿色填充该空间。

   <svg width="500" height="500">
    <g transform="translate(0, 0)">
     <rect width="500" height="500" fill="green" />
    </g>
   </svg>
  </div>
 )
}

export default HelloD3

这里发生的情况是,我们有一个 500×500 像素大小的矩形,然后 D3 覆盖了另一个 250×500 像素大小的矩形,这占用了 React JSX 矩形的一半大小。

这个例子很简单,没有显示为什么我们需要 D3,因为我们可以在 JSX 写这个文本和这些矩形元素,让我们的代码更可读。

然而,这是一个简单的极简“Hello World”,旨在帮助您理解工作部件,在本章的后面,您将看到d3.HelloD3.scss的威力。

我们的div包含了一个名为HelloD3className,它与我们的HelloD3.scss文件绑定在一起。

<div className="HelloD3" ref={ref}>

我们的 SCSS 文件内容目前只是一个占位符。

// src/components/HelloD3/HelloD3.scss

.HelloD3 {
}

我们的项目自带 SCSS,Webpack 已经配置了 SCSS 加载器,所以除了 CSS,你可以不用配置任何东西就可以使用 SCSS。

如果你以前没有用过 SCSS,你可能会问,为什么我用 SCSS 而不是 CSS?

CSS 预处理程序:Sass/SCSS

级联样式表(CSS)是 HTML 的核心功能,如果您还不熟悉 CSS,那么您需要熟悉它。这尤其适用于 HTML 和 React。在大型项目中,CSS 预处理程序通常用于补充 CSS 和添加功能。

React 项目通常使用四个主要的 CSS 预处理程序:Sass/SCSS、PostCSS、Less 和 Stylus。

Note

CSS 用于表示不同设备上网页的可视布局。CSS 预处理器通常用于增强 CSS 功能。

简单的回答是,萨斯/SCSS 对今天的大多数项目来说更好,所以我们将使用它。

调查显示,萨斯/SCSS 最受欢迎,可能会让你找到薪水最高的开发工作( https://ashleynolan.co.uk/blog/frontend-tooling-survey-2019-results )。萨斯/SCSS 被认为是一个众所周知的工具。如果你想了解更多,并查看不同 CSS 预处理程序之间的比较,请查看我在 Medium 上的文章: http://shorturl.at/dJQT3

HelloD3.test.tsx

正如我提到的,您已经有了一个为您自动创建的单元测试文件。

// src/component/HelloD3/HelloD3.test.tsx

import React from 'react'
import { shallow } from 'enzyme'
import HelloD3 from './HelloD3'

describe('<HelloD3 />', () => {
 let component

 beforeEach(() => {
  component = shallow(<HelloD3 />)
 })

 test('It should mount', () => {
  expect(component.length).toBe(1)
 })
})

如果您查看代码,您可以看到测试文件只测试了组件被安装(添加)到我们的显示中。

expect(component.length).toBe(1)

代码使用 Jest 和 Enzyme 库。Jest 自带 CRA 和酵素,Sinon 自带 MHL,所以你不需要做任何配置。

为什么 Jest 和酵素+ Sinon?

Jest 和酶+否则

Jest 是 JavaScript 单元测试框架,也是 React 应用的标准。它是为任何 JavaScript 项目而构建的,并且是 CRA 自带的。然而,我们确实需要 Jest-dom 和 Enzyme 来增强 Jest 的能力。

不然呢

另一个我们应该知道并添加到我们工具箱中的必备库是 Sinon ( https://github.com/sinonjs/sinon )。

Jest 和 Sinon 的目的是一样的,但是有时候你会发现一个框架对于特定的测试来说更自然、更容易使用。

我想尽早向您介绍测试,因为它是开发不可或缺的一部分,在构建接口时需要考虑。

$ yarn test

现在,为了运行测试,package.json文件已经配置了一个运行任务。

"test": "react-scripts test"

运行脚本时,可以看到测试通过,如图 1-8 所示。

img/510438_1_En_1_Fig8_HTML.jpg

图 1-8

单元测试通过

当您对组件进行更改时,请确保更新单元测试文件,并测试组件功能的不同部分。

我想指出的是,您的项目还附带了开箱即用的端到端(e2e)测试。

"test:e2e": "jest -c e2e/jest.config.js",
"test:e2e-alone": "node e2e/puppeteer_standalone.js",
"test:e2e-watch": "jest -c e2e/jest.config.js --watch"

正如我前面提到的,如果您想了解这些库是如何安装的,您可以创建自己的模板或修改一个模板。那超出了本书的范围;但是,您可以阅读本文,了解每个库的逐步安装过程:

https://medium.com/react-courses/setting-up-professional-react-project-with-must-have-reactjs-libraries-2020-9358edf9acb3

或者你可以参加我在 Udemy 上的课程,其中包括一个 40 页的电子书,帮助你理解所有的移动部件以及它们是如何安装和配置的:

https://www.udemy.com/course/getting-started-react17-with-must-have-libraries/

将类组件与 D3 React

在上一节中,我们创建了一个函数组件。如果我们想创建一个集成 D3 的 React 类组件,过程是类似的。我们可以使用我为您设置的模板作为起点:

$ npx generate-react-cli component HelloD3Class --type=d3class

就像 React 函数组件一样,d3class创建了三个文件。

  • HelloD3Class.scss

  • HelloD3Class.test.tsx

  • HelloD3Class.tsx

HelloD3Class.tsx

我们来回顾一下HelloD3Class.tsx

不同的是,我们先设置引用对象,再设置构造函数;然后,在构造函数级别,我们可以初始化引用对象。

一旦组件生命周期事件componentDidMount被调用,我们就可以将元素添加到 DOM 中。

看一看:

import React, { RefObject } from 'react'
import './HelloD3Class.scss'
import * as d3 from 'd3' // yarn add d3 @types/d3

export default class HelloD3Class extends React.PureComponent<IHelloD3ClassProps, IHelloD3ClassState> {
 ref: RefObject<HTMLDivElement>

 constructor(props: IHelloD3ClassProps) {
  super(props)
  this.state = {
   // TODO
  }
  this.ref = React.createRef()
 }

 componentDidMount() {
  d3.select(this.ref.current).append('p').text('Hello World')

  // const svg = d3.select(this.myRef.current).append('svg').attr('width', 500).attr('height', 500)
  d3.select('svg')
   .append('g')
   .attr('transform', 'translate(250, 0)')
   .append('rect').attr('width', 500)
   .attr('height', 500)
   .attr('fill', 'tomato')
 }

 render() {
  return (
   <div className="HelloD3Class" ref={this.ref}>
    <svg width="500" height="500">
     <g transform="translate(0, 0)">
      <rect width="500" height="500" fill="green" />
     </g>
    </svg>
   </div>
  )
 }
}

interface IHelloD3ClassProps {
 // TODO
}

interface IHelloD3ClassState {
 // TODO
}

Lint ESLint 和 beauty

进行代码审查,并让别人格式化你的代码以确保它的一致性,这有多好?

任何代码库中的所有代码都应该看起来像是一个人输入的,不管有多少人参与。

—瑞克·瓦德伦

幸运的是,这是可以做到的。

Lint 是一个分析代码的工具。它是一个静态代码分析工具,用来识别在代码中发现的有问题的模式。漂亮是一个固执己见的代码格式化程序。

Note

林挺是运行一个程序来分析你的代码以发现潜在错误的过程。

Lint 工具可以分析您的代码,并警告您潜在的错误。为了让它工作,我们需要用特定的规则来配置它。

争论每一行是否应该有两个空格,或者一个制表符、单引号、双引号等等,这是不明智的。这个想法是有一个风格指南,并遵循风格的一致性。正如有人说得好,

关于风格的争论毫无意义。应该有一个风格指南,你应该遵循它。

—丽贝卡·墨菲

Airbnb——作为其风格指南的一部分——提供了任何人都可以使用的 ESLint 配置,并成为标准配置。

ESLint 已经安装在 CRA MHL 模板上,但它优化了风格指南,你不需要做任何事情就能享受使用它的乐趣。

该项目已经使用 Airbnb 的风格指南(被认为是标准的)与 ESLint 和 Prettier for TypeScript 一起建立起来了。

然而,如果你想更好地理解,请阅读我在 https://medium.com/react-courses/react-create-react-app-v3-4-1-a55f3e7a8d6d 的文章,使用 Airbnb 的风格指南,用 ESLint 和 Prettier for TypeScript 设置你的项目。

为您配置了三个文件。

  • .eslintrc : ESLint 运行命令配置文件

  • .eslintignore斯洛文尼亚语忽略文件

  • .prettierrc:漂亮运行命令配置文件

package.json文件的运行脚本已经为您准备好了,所以我们可以运行lintformat实用程序,甚至只需一个命令就可以运行应用构建(我们将在本书后面介绍的生产构建)。

"scripts": {
  ..
  ..
  ..
  "lint": "eslint --ext .js,.jsx,.ts,.tsx src --color",
  "format": "prettier --write 'src/**/*.{ts,tsx,scss,css,json}'",
  "isready": "npm run format && npm run lint && npm run build"
}

我们已经准备好让lint完成它的工作并修改我们的代码,如图 1-9 所示。

img/510438_1_En_1_Fig9_HTML.jpg

图 1-9

运行 lint 命令后的输出

$ yarn run lint

要运行格式化程序来清理我们的代码,我们也可以使用yarn,如下所示(见图 1-10 ):

img/510438_1_En_1_Fig10_HTML.jpg

图 1-10

运行格式后的输出

$ yarn run format

摘要

在这一章中,我向你介绍了我们将在本书中用到的工具。

我们讨论了 React 及其优点和局限性。我们使用 CRA 和 MHL 模板项目建立了我们的初始项目,并研究了 TS、SCSS、单元测试、格式化和林挺。我还简要地提到了 D3、ReactTransitionGroup插件和其他可用的数据库。

我们用 React 和 D3 创建了我们的第一个“Hello World”项目,使用 TS 作为功能组件和类组件,我们通过测试、林挺和格式化来确保它的质量。

质量通常很重要,尤其是在使用图表和动画作为资源时。低质量的代码可能会导致用户体验下降,因为许多图表使用大型数据集(内存使用)以及大量 CPU 资源在客户端机器上呈现。

在下一章中,我们将继续学习如何创建 React 组件,这些组件利用 D3 来执行诸如绘制图形、创建动画和处理用户交互事件等任务。

二、图形和交互

在这一章中,我将向你展示你有哪些选择,并将创建图表的过程分解成更小的部分,这样你就可以在深入研究和创建图表之前更好地理解这个过程。这个过程可以分为三层:数据、视图和用户交互。

在这些层的任何部分,您都可以使用 React、D3 或任何其他与 React 集成的库。有选择是很棒的;然而,决定使用什么和何时使用也是令人困惑的。理解你的选择是很重要的,因为它能帮助你做出明智的决定。

这一章分为三个主要部分。

  • 制图法

  • 用户手势

  • 鼓舞

在本章的第一部分,我将展示如何使用 React 函数组件和类组件创建带有 HTML 和 SVG 元素的图形。我们将利用 React 的 JSX 以及 D3。我们将消耗数据和绘制元素。在本节的最后一部分,我们将创建第一个简单的图表。

在本章的第二部分,我将向你展示如何在 React 和 D3 里设置鼠标事件。

最后,我们将学习如何使用 React 制作动画,以及如何使用 D3 制作动画。

在本章结束时,你会明白在绘图、设置事件和制作动画时你有哪些选择。在这个过程中,我将向您展示设置函数和类组件的选项,并且我们将稍微讨论一下组件生命周期挂钩。还有,我会给你一些提示。

我们开始吧。

创建图表概述

正如我提到的,要创建数据可视化组件,这个过程可以分为三个主要层:数据、视图和用户交互。

让我们看看每一层都包括什么。

数据层由以下任务组成:

  • 获取数据。

  • 设定日期

  • 处理数据。

视图层由以下内容组成:

  • 与 React 生命周期挂钩集成。

  • 画一辆手推车

  • 设置组件的样式。

用户交互层包括以下内容:

  • 添加转场。

  • 处理用户手势。

绘制图表时,从数据开始是一个好的起点。你需要看到数据在讲述什么故事。一旦你理解了这个故事,就该选择一个图表了。您可以集成许多不同的现成图表库,也可以使用 D3 创建您的自定义解决方案。你的选择是无限的。不管你决定使用什么,理解所有东西是如何工作的是至关重要的。

绘制图形

你可能知道,React JSX 代码可能看起来很像 HTML,但它不是。这是 JavaScript 扩展(JSX)代码。

Note

JSX 是一个 React 扩展,它使用模仿 HTML 代码的 JavaScript 标签,因此代码大部分类似于 HTML,但它不是 HTML。

为了理解 React 为什么使用 JSX 而不仅仅是纯 HTML,我们首先需要谈谈文档对象模型(DOM)。React 会在后台处理您的 JSX 代码,然后将这些更改提交到用户的浏览器中,以加快用户页面的加载速度。

Note

文档对象模型(DOM)是 HTML 的内存表示,并且是树形结构。

React 努力匹配 HTML,JSX 可以识别 HTML 中支持的标签。SVG 就是一个很好的例子。我们在第一章中看到了如何将 SVG 标签添加到 React 渲染部分。

可缩放矢量图形(SVG)是一种基于 XML 的标记语言,用于描述基于二维的矢量图形。因此,它是一个基于文本的开放 web 标准,用于描述可以以任何大小清晰呈现的图像,并且专门设计为与其他 Web 标准(包括 CSS、DOM、JavaScript 和 SMIL)配合使用。本质上,SVG 对于图形就像 HTML 对于文本一样。

https://developer.mozilla.org/en-US/docs/Web/SVG

SVG 元素包括许多不同类型的图形,每个元素都有自己的属性集,从图像到圆形、线条、文本元素、矩形、组等等。即使是最有经验的 HTML 开发人员也不知道 SVG API 中的所有 SVG 元素和属性。

要了解关于 SVG 的更多信息,您可以在这里查看可用 SVG 标签的完整列表:

https://developer.mozilla.org/en-US/docs/Web/SVG/Element

我建议您将此页面加入书签,并在需要时参考。

SVG vsHTML

要画图形,SVG 是标准。SVG 已经成熟,在撰写本文时是第 2 版(从 2016 年开始)。您可以在此阅读第 2 版:

https://github.com/w3c/svgwg/wiki/SVG-2-new-features

然而,我想指出还有其他方法来画图形。例如,我们可以通过 JavaScript 使用 HTML <canvas>元素。元素是一个图形容器。

SVG 对于较少数量的对象或较大的表面提供了更好的性能。对于较小的表面或较大数量的对象,画布可以提供更好的性能。

画布可能会变得模糊,需要检查和调整设备像素比率。为了避免不同设备像素比率的模糊图像,请在此处查看 Mozilla 文档:

https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio

下面是使用 canvas 和 SVG 之间的区别的简要概述:

  • SVG 允许用 CSS 进行样式化;画布只能通过脚本来更改。

  • SVG 是基于向量的;画布是基于光栅的(矩形像素网格)。

  • SVG 提供了比画布更好的可伸缩性,所以一旦一个元素需要缩放,SVG 是首选。

  • SVG 比 canvas 具有更大的屏幕和更少的对象的性能优势;但是,如果您使用较小的屏幕尺寸和许多对象,画布会更好。

JSX Canvas(密西西比州)

画布( https://www.w3schools.com/html/html5_canvas.asp )可以用来绘制 HTML 中的组件。事实上,您不仅可以使用画布来绘制组件,还可以将它制作成动画并操纵元素属性。React JSX 版本的画布没有什么不同。

让我们创造一个例子。我们可以使用我们在第一章 ( react-d3-hello-world)中创建的同一个项目,并添加一个包含画布的组件。你可以从该书的资源库下载本章所有示例的最终代码。

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch02

JSXCanvas.tsx

我们将组件称为JSXCanvas。你可以自己创建文件,或者使用我为你设置的模板从generate-react-cli那里获得帮助。

npx generate-react-cli component JSXCanvas --type=d3

在我们的JSXCanvas组件中,我们将使用画布 JSX 组件,它匹配 HTML 画布。我们将改变属性来改变画布的颜色为番茄红。

让我们看一下代码。

// src/component/JSXCanvas/JSXCanvas.tsx

首先,我们导入将要使用的 React 库和 SCSS 文件。

import React, { RefObject, useEffect, useRef } from 'react'
import './JSXCanvas.scss'

我们将使用ref对象和useEffect。我们首先创建对画布的引用。

当涉及到 TypeScript 时,我们需要定义对象的类型(当类型不能通过赋值清楚地推断出来时)。对于画布,它的类型应该是HTMLCanvasElement

const JSXCanvas = () => {

 const canvasRef: RefObject<HTMLCanvasElement> = useRef(null)

一旦组件被初始化,我们就可以设置useEffect方法。

什么是useEffectuseEffect在渲染过程完成后调用。useEffect检查上一次渲染的依赖值,如果其中任何一个发生变化,将调用你的效果函数。

在我们的例子中,当这种情况发生时,我们可以画出我们的画布。我们在这里使用 React 函数组件,设置一个 draw 方法是一个很好的方法,而不是仅仅在useEffect中编写代码,因为该函数可以被其他方法使用。然而,在某些情况下,在useEffect中编写实际代码会更有意义,这一点您将在本书后面看到。

  useEffect(() => {
    draw()
  })

我们的 draw 方法将使用我们创建的对<canvas>元素的引用,然后使用画布上下文,我们可以设置画布属性,如widthheightcolor。CanvasRenderingContext2D 接口是 Canvas API 的一部分,它为 Canvas 元素的绘制表面提供了 2D 渲染上下文。它用于绘制形状、文本、图像和其他对象。

https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D

看一看:

  const draw = () => {
    const canvas = canvasRef.current
    const context = canvas?.getContext('2d')
    if (context) {
      context.fillStyle = 'tomato'
      context.fillRect(0, 0, context.canvas.width, context.canvas.height)
    }
  }

最后,在渲染方面,我们将使用我们创建的引用来设置 JSX 画布。

  return (
    <>
      <canvas ref={canvasRef} />

    </>
  )
}
export default JSXCanvas

还有另一种方法可以编写更少的关于引用的代码。我们可以内联的方式来完成,而不是定义变量然后在组件中赋值。让我们来看看。

我们设定了ref

ref: SVGCircleElement | undefined

接下来,我们可以在 JSX 代码中内联赋值ref

<canvas
  ref={canvasRef} />
  // eslint-disable-next-line no-return-assign
  ref={(ref: SVGCircleElement) => (this.ref = ref)}
/>

然而,这不是最干净的代码,因为根据最佳编码实践,我们不应该返回赋值;然而,这就是为什么我通过添加一个eslint评论来禁止 ESLint 抱怨。但是,由于代码的模糊性,不推荐这种方法;我想让你看看这些选项。

App.tsx

记得将组件添加到您的App.tsx父组件中。

// src/App.tsx

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <JSXCanvas />
      </header>
    </div>
  )
}

要查看图形,请确保您仍在第一章中的端口 3000 上运行。

$ yarn start

图 2-1 显示了最终结果。

img/510438_1_En_2_Fig1_HTML.jpg

图 2-1

根据我们选择的尺寸和颜色进行画布绘制

缩放怎么样?是的,您可以使用缩放并根据屏幕大小调整画布,以避免画布放大后变得模糊。为此,使用context.scale

const { devicePixelRatio: ratio = 1 } = window
context.scale(ratio, ratio)

但是,如果您希望在所有设备上获得清晰的打印质量,或者您需要缩放等功能,SVG 会提供更好的结果。我不打算深入研究画布,因为我建议继续使用 SVG 然而,我想让你知道这是可能的,如果你曾经有一个用例需要一个画布,比如在一个针对较小屏幕尺寸和许多对象的用例中,你会在你的工具箱中有它。

React SVG

要用 React 创建 SVG 图形,让我们添加另一个组件,名为HelloSVG

npx generate-react-cli component HelloSVG --type=d3

generate-react-cli模板已经包含了在 React 中显示 SVG 元素所需的代码。看一看:

import React from 'react'
import './HelloSVG.scss'

const HelloSVG = () => {

  return (
    <div className="HelloSVG">
      <svg width="500" height="500">
        <g transform="translate(0, 0)">
          <rect width="300" height="300" fill="tomato" />
        </g>
      </svg>
    </div>
  )
}

export default HelloSVG

注意,我使用了transform属性。转换将组元素移动到左上角。您可以在 Mozilla 文档中了解更多关于transform属性的信息。

https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform

现在,如果我们想改变矩形的设计属性,而不使用fill属性用颜色填充 SVG 矩形。React 为 HTML 类提供了一个匹配的属性( https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/class ): className。我们可以指定一个类名,并在我们的 SCSS 文件中定义它。这并不新鲜,所有的 React 组件都是这样工作的。

// HelloSVG.tsx
<rect className="myRect" width="300" height="300" />

// HelloSVG.scss
.myRect {
  fill: #ba2121;
}

最后,我们需要将组件添加到我们的入口点App.tsx

// src/App.tsx

<HelloSVG />

图 2-2 显示了我们的 SVG 矩形元素。

img/510438_1_En_2_Fig2_HTML.jpg

图 2-2

使用 SVG 绘制矩形

属性可以用于很多事情,比如旋转、缩放和倾斜。

例如,如果我们想实现 Mozilla docs ( https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform )中的例子。该示例使用xlink将一个 SVG 路径分配给一个元素以供重用,从而创建一个心形和一个阴影效果。不幸的是,你经常会看到,如果没有一定程度的修改和知识,你不能将你在网上看到的大多数代码(无论是否与 D3 相关)复制粘贴到 React 中。

在我们的例子中,Mozilla 示例无法编译的原因是 React SVG 不包含use xlink,因此这段代码将生成一个错误:

<use xlink:href="#heart" fill="none" stroke="white"/>

代码将生成错误“Type { xlink:true;}不可赋给类型 SVGProps

该错误是由于 React SVGUseElement在 React 的当前版本(撰写本文时为版本 17)中不包含xlink而导致的。然而,不要惊慌,因为这不是世界末日。我们需要做的是定义标签,然后我们就可以使用它了。

const useTag = '<use xlink:href="#heart" />'

接下来,我们可以用一个 SVG 路径(在我们的例子中是一个心脏的形状)建立一个组,转换属性将使心脏看起来像一个阴影。

<g
  fill="grey"
  transform="rotate(-10 50 100)
    translate(-36 45.5)
    skewX(40)
    scale(1 0.5)"
>
  <path id="heart" d="M 10,30 A 20,20 0,0,1 50,30 A 20,20 0,0,1 90,30 Q 90,60 50,90 Q 10,60 10,30 z" />
</g>

然后我们可以使用我们在 SVG 标签中设置的useTag常量来拖动实际的心脏。

<svg dangerouslySetInnerHTML={{__html: useTag }} fill="none" stroke="white" />

使用dangerouslySetInnerHTML完成对useTag的赋值。

dangerouslySetInnerHTML听起来很吓人,顾名思义,你应该小心使用它,因为恶意代码可能会被注入到那个标签中。这就是为什么你需要添加eslint评论来防止 ESLint 对这个消息的攻击。

{/* eslint-disable-next-line react/no-danger */}

在我们的例子中,我们知道我们在做什么,我们没有传递一些可能导致注入攻击的运行时字符串,所以我们很好。

看一下整个代码块:

const HelloSVG = () => {
  const useTag = '<use xlink:href="#heart" />'
  return (
    <div className="HelloSVG">
      <svg width="500" height="500">
        <g transform="translate(0, 0)">
          <rect className="myRect" width="300" height="300" /* fill="tomato" */ />
        </g>
        <g
          fill="grey"
          transform="rotate(-10 50 100)
            translate(-36 45.5)
            skewX(40)
            scale(1 0.5)"
        >
          <path id="heart" d="M 10,30 A 20,20 0,0,1 50,30 A 20,20 0,0,1 90,30 Q 90,60 50,90 Q 10,60 10,30 z" />
        </g>
        {/* eslint-disable-next-line react/no-danger */}
        <svg dangerouslySetInnerHTML={{__html: useTag }} fill="none" stroke="white" />
      </svg>
    </div>
  )
}

输出为我们创建了心脏轮廓和阴影,如图 2-3 所示。如果我想给阴影添加一个轮廓,那不成问题。我们可以将相同的 SVG 代码放在 group 元素中。让我们继续试一试。

img/510438_1_En_2_Fig3_HTML.jpg

图 2-3

通过 xlink 使用 SVG

使用 JSX 在 React 中映射数据

为了使它比仅仅使用 SVG 更加真实和有趣,我们现在想使用 React props传递来自父组件的数据,并使用带有一些简单 React 代码的 HTML p标签来绘制数据。让我告诉你怎么做。

HelloJSXData.tsx

自己或者用模板创建一个新组件,并将其命名为HelloJSXData

npx generate-react-cli component HelloJSXData --type=d3

接下来,导入风格 SCSS 和 React。

// src/component/HelloJSXData/HelloJSXData.tsx

import React from 'react'
import './HelloJSXData.scss'

接下来,设置我们将传递给子组件的props。我指向一个我将在文件底部定义的接口。

const HelloJSXData = ( props : IHelloJSXDataProps ) => {

在 JSX 渲染方面,我将映射数组并从父组件传递它。一旦数据被映射,我们就可以使用p HTML 标签在屏幕上显示数据。看一看:

  return (
    <div className="HelloJSXData">
      {props.data.map((d, index) => (
        <p key={`key-${  d}`}>
          jsx {d}
        </p>
      ))}
    </div>)
}

这是我们对props的接口,只是传递一个由字符串组成的数组:

interface IHelloJSXDataProps {
  data: string[]
}

export default HelloJSXData
App.tsx

和往常一样,记得给 app 加上HelloJSXData

// src/app
function App() {
  return (
    <div className="App">
      <header className="App-header">
        <HelloJSXData data={['one', 'two', 'three', 'four']} />
      </header>
    </div>
  )
}

参见图 2-4 中的最终结果。

img/510438_1_En_2_Fig4_HTML.jpg

图 2-4

使用 React 代码映射数据示例

我将向您展示如何使用p HTML 标签来简化事情,但是这也可以是一个 SVG 元素,比如一个圆形或一个矩形,正如我将在其他示例中展示的那样。

使用 D3 在 React 中映射数据

前面的例子是纯粹、简单的 React 代码。在这个例子中,我们添加 D3 来为我们添加地图和绘图。我们姑且称这个组件为HelloD3Data

HelloD3Data.tsx

npx generate-react-cli component HelloD3Data --type=d3

我们将使用 D3,所以确保你已经有了 D3 和导入的类型,如果你以前没有为这个项目做过的话。

yarn add d3 @types/d3

让我们从定义将要使用的导入开始。

// src/component/HelloD3Data/HelloD3Data.tsx

import React, { useEffect } from 'react'
import './HelloD3Data.scss'
import * as d3 from 'd3'

和以前一样,我们将设置我们的props接口来传递相同的字符串数组。

const HelloD3Data = ( props : IHelloD3DataProps ) => {

  useEffect(() => {
    draw()
  })

当我们使用 D3 时,我们需要首先选择我们将在 JSX 渲染中设置的元素。接下来,通过使用 d3 selectAll API,我们可以选择 p HTML 元素和data属性。然后我们可以传递关于text属性的数据。我将使用 inline 函数将文本设置为数据值。看一看:

  const draw = () => {
    d3.select('.HelloD3Data')
      .selectAll('p')
      .data(props.data)
      .enter()
      .append('p')
      .text((d) => `d3 ${  d}`)
  }

对于我们的渲染,我们将设置一个div作为我们的p标签的包装器。

  return <div className="HelloD3Data" />
}

interface IHelloD3DataProps {
  data: string[]
}

export default HelloD3Data

最后,让我们设置父组件。

// src/app
<HelloD3Data data={['one', 'two', 'three', 'four']} />

你可以在图 2-5 中看到结果。

img/510438_1_En_2_Fig5_HTML.jpg

图 2-5

使用 D3 和 React 代码映射数据示例

如你所见,当比较 React 和 D3 之间数据迭代的两个例子时,D3 看起来不如 React 直观。我们的例子本质上很简单,但是当需要大量计算和大型数据集时,D3 将会大放异彩。

带 D3 的简单条形图

到目前为止,我们已经处理了简单的例子,并操作了 HTML 元素,如<canvas>p标签和div。此外,我们还使用了 SVG 和 D3。

现在我们准备用 D3 创建我们的第一个数据可视化图表,并进行 React。我们的例子与上一个例子相似。我们获取数据,并使用 D3 中的 data 属性,我们将遍历我们的数据,并使用 rectangle SVG 元素来绘制条形,就像我们使用p HTML 标签一样。看一看:

// src/component/SimpleChart/SimpleChart.tsx

import React, { RefObject } from 'react'
import './SimpleChart.scss'
import * as d3 from 'd3'

这一次,让我们用一个类组件而不是函数组件来创建图表,这样您就可以看到不同之处了。

我想指出的是,当你不需要shouldComponentUpdate生命周期事件时,最好扩展 React PureComponent

Tip

React.PureComponent在某些情况下提供了性能提升,但代价是失去了shouldComponentUpdate生命周期挂钩。你可以在 React 文档( https://reactjs.org/docs/react-api.html#reactpurecomponent )中了解更多。

export default class Component extends React.PureComponent<ISimpleChartProps, ISimpleChartState> {

我将保存对我将用来包装图表的div的引用,并将数据保存在 numbers 类型的数据数组中。

  ref: RefObject<HTMLDivElement>

  data: number[]

在类构造函数中,我设置接口,创建引用,并设置数据。

请注意,我并不需要状态,但我正在设置它,以防将来需要它。这是一个好习惯。

  constructor(props: ISimpleChartProps) {
    super(props)
    this.state = {
      // TODO
    }
    this.ref = React.createRef()
    this.data = [100, 200, 300, 400, 500]
  }

正如您在前面的函数组件示例中回忆的那样,我们称之为draw函数。在 React 类组件中,我们可以在componentDidMount生命周期挂钩期间调用draw方法。

Tip

componentDidMount()生命周期挂钩是安装阶段的一部分,可以被类组件覆盖。在第一次安装组件时,在一个componentDidMount()生命周期钩子中定义的任何动作只被调用一次。

  componentDidMount() {
    this.drawChart()
  }

drawChart方法上,我会借助 D3。我首先选择引用,然后使用 append 添加一个 SVG 元素,最后设置我们的宽度和高度。

由于我设置了data属性,D3 将自动遍历数据来绘制我们所有的矩形。这与我们在前面的例子中使用p标签是一样的,但是这里我们使用一个矩形标签来代替 HTML p标签。看一看:

  drawChart() {
    const size = 500
    const svg = d3.select(this.ref.current)
    .append('svg')
    .attr('width', size)
    .attr('height', size)

    const rectWidth = 95
    SVG
      .selectAll('rect')
      .data(this.data)
      .enter()
      .append('rect')
      .attr('x', (d, i) => 5 + i * (rectWidth + 5))
      .attr('y', (d) => size - d)
      .attr('width', rectWidth)
      .attr('height', (d) => d)
      .attr('fill', 'tomato')
  }

为了渲染,我正在设置我们将使用的div包装器。注意,我并不真的需要引用,因为我可以使用className选择元素。在本例中,这是一个由您决定什么更直观的偏好问题。

  render() {
    return <div className="SimpleChart" ref={this.ref} />
  }
}

Tip

在一些例子中,我们在元素类名上使用 D3 select,而在其他例子中使用引用。在我们的例子中,使用类名作为引用很好;然而,在有些情况下,我们希望 React 控制我们的组件,这就是设置引用更好的地方,因为 React 知道元素中的变化。这种情况会出现在列表中:array.map((item: object) => ( <ListItem item={item} /> ))}。React 遍历一个列表,如果我们使用一个 map 来呈现这个列表,而这个组件没有使用 reference,那么计算就会被关闭。

最后,正如我之前所做的,我正在定义props和状态,在这个例子中我不需要它们,但是我在这个例子中设置它们,因为它们在将来可能会被需要。

interface ISimpleChartProps {
  // TODO
}

interface ISimpleChartState {
  // TODO
}
App.tsx

<SimpleChart />组件添加到App.tsx,就像我们在前面的例子中所做的一样。你可以在图 2-6 中看到最终的结果。

img/510438_1_En_2_Fig6_HTML.jpg

图 2-6

使用 React 和 D3 的简单条形图

祝贺您,您刚刚创建了您的第一个图表—条形图!

React 组件生命周期挂钩

当谈到 React 时,你真的想熟悉 React 的 16.9 版和更高的生命周期挂钩,因为它们自 16.9 版以来发生了变化。深刻理解 React 的生命周期挂钩有助于确保仅在需要时才更改 DOM,以更好地配合 React 的 VDOM 范式,并确保您的组件得到优化。

React 中的每个组件都有一个生命周期,您可以在它的三个主要生命周期阶段对其进行监控和操作。

  • 挂载阶段:组件被创建来开始它的单向数据流之旅,并到达 DOM。它使用constructor(),以及以下方法:静态getDerivedStateFromProps()render()componentDidMount()

  • 更新阶段:组件被添加到 DOM 中,更新可以在属性或状态改变时重新呈现。事件有:静态getDerivedStateFromProps()shouldComponentUpdate()render()getSnapshotBeforeUpdate()componentDidUpdate()

  • 卸载阶段:这是组件生命周期的最后一个阶段。组件被销毁并从DOM.componentWillUnmount()移除。

在 React 17 中,对以前的方法如componentWillMount的访问被否决了,你应该使用新的钩子来访问组件生命周期。包括getDerivedStateFromPropsgetSnapshotBeforeUpdatecomponentDidMountcomponentDidUpdate

您可能需要重温您的生命周期挂钩知识,或者只是看看您可以用 React 创建的所有不同类型的类和函数组件。这超出了本书的范围,但是我强烈推荐您在这里阅读我的文章:

https://medium.com/react-courses/react-component-types-functional-class-and-exotic-factory-components-for-javascript-1a098a49a831

用户手势

用户与图形的交互与用户与任何 React 组件的交互没有什么不同。当涉及到鼠标事件时,React JSX 支持 HTML 和 SVG。

但是,您仍然可以选择如何利用这些事件。

您可以使用 JSX 事件或使用 D3。何时使用工具取决于您正在构建什么。如果您使用 D3 来创建图形,您将需要也使用 D3 事件;然而,如果你使用 JSX 创建你的图形,你可以使用 JSX 或 D3。选择权在你。

出于这些原因,我将向您展示这两种方法。让我们来看看。

对鼠标事件做出 React

让我们创建一个类组件,并将其命名为CircleWithEvents

// src/component/CircleWithEvents/CircleWithEvents.tsx

import * as React from 'react'
import './CircleWithEvents.scss'

export default class CircleWithEvents extends React.PureComponent<ICircleWithEventsProps> {

  componentDidMount() {
    // TODO
  }

接下来,设置事件处理程序。我只是设置了一个警报来显示事件被调度。代码被注释掉了,供您随意使用。

  onMouseOverHandler(event: React.MouseEvent<SVGCircleElement, MouseEvent>) {
    // alert('onMouseOverHandler')
  }

  onMouseOutHandler() {
    // alert('onMouseOutHandler')
  }

注意,我使用的是React.MouseEvent而不是标准 HTML 的MouseEvent

Tip

在 React 中,事件是对鼠标悬停、鼠标点击、按键等特定动作的触发 React。在 React 中处理事件类似于在 DOM 元素中处理事件。但是有一些句法上的差异。

synthetic event**,围绕浏览器原生事件的跨浏览器包装器。它与浏览器的本机事件具有相同的接口,包括 stopPropagation()和 preventDefault(),只是这些事件在所有浏览器中的工作方式相同。

https://reactjs.org/docs/events.html

事件使用 camel case 命名,而不是仅仅使用小写字母。这里举两个例子:React.KeyboardEventReact.MouseEvent

事件作为函数而不是字符串传递。如果您遵循代码,您可以看到 React 事件扩展了UIEvents,它扩展了SyntheticEvent

React 使用自己的事件系统。这就是为什么我们通常不能使用标准 DOM 中的MouseEvent

我们需要使用 React 的事件;否则,我们会得到一个错误,或者我们不能访问方法。一般来说,大多数事件都以相同的名称映射。

幸运的是,React 的类型为您提供了标准 DOM 中您可能熟悉的每一个事件的适当等价物。

我们可以使用React.MouseEvent或者从 React 模块导入MouseEvent类型。

在渲染方面,我将设置一个空的包装器标签,如,一个 SVG 组,一个半径(r)为 100 像素的圆,以及鼠标事件。

  render() {
    return (
    <>

        <svg width="500" height="500">
          <g>
            <circle
              className="circle"
              transform="translate(150 150)"
              r="100"
              onMouseOver={(event) => {
                event.stopPropagation()
                this.onMouseOverHandler(event)
              }}
              onMouseOut={(event) => {
                event.stopPropagation()
                this.onMouseOutHandler()
              }}

对于点击事件,让我们做一些不同的事情。我将使用内嵌函数(arrow 函数,又名 fat 函数)来显示鼠标点击事件的警告。

              onClick={(event) => {
                event.stopPropagation()
                // eslint-disable-next-line no-alert
                alert('onClick')
              }}
            />
          </g>
        </svg>
       </>

    )
  }
}
interface ICircleWithEventsProps {
  // TODO
}

注意,我的 SVG 圆圈包含一个名为circleclassName属性,在这里我将设置一些 CSS 属性,比如光标指针的显示、圆圈的宽度和高度以及灰色填充颜色。这些属性可以在组件本身上设置,但是这样代码更简洁。

// src/component/CircleWithEvents/CircleWithEvents.scss

.circle {
  cursor: pointer;
  width: 150px;
  height: 150px;
  fill: #6666;
}
App.tsx

最后,像往常一样,记住将组件添加到App.tsx。继续点击圆圈;不要害羞。

// src/App.tsx

import CircleWithEvents from './components/CircleWithEvents/CircleWithEvents'

<CircleWithEvents />

参见图 2-7 中的最终结果。

img/510438_1_En_2_Fig7_HTML.jpg

图 2-7

用鼠标事件圈出 SVG

D3 鼠标事件

在下一个示例中,我将复制上一个示例中的相同功能,用鼠标事件设置一个圆圈;然而,这一次,我将使用 D3 代替 React JSX。让我们来看看。

设置importsclass组件。

// src/component/CircleWithEvents/CircleWithD3Events.tsx

import * as React from 'react'
import './CircleWithEvents.scss'
import * as d3 from 'd3'

export default class CircleWithD3Events extends React.PureComponent<ICircleWithD3EventsProps> {

一旦调用了componentDidMount事件,我将调用我的draw()方法。

  componentDidMount() {
    this.draw()
  }

接下来,我将设置与上一个示例相同的事件处理程序。

  onMouseOverHandler(event: React.MouseEvent<SVGCircleElement, MouseEvent>) {
    // alert('onMouseOverHandler')
  }

  onMouseOutHandler() {
    // alert('onMouseOutHandler')
  }

我的draw()方法将选择我的 SVG 包装元素,然后添加一个组元素和一个圆形元素,并使用 D3 上的.on方法设置事件。

在这个例子中,我甚至附加了同一个类圈。

Note

React JSX 调用类属性className,在 D3 属性匹配 HTML SVG,所以它会是class

  draw = () => {
    d3.select('svg')
      .append('g')
      .append('circle')
      .attr('transform', 'translate(150, 150)')
      .attr('r', 100)
      .attr('class', 'circle')
      .on('click', () => {
        alert('onClick')
      })
      .on('mouseover', (event) => {
        this.onMouseOverHandler(event)
      })
      .on('mouseout', (event) => {
        this.onMouseOutHandler()
      })
  }

如您所料,这是我在渲染端的 SVG 包装器:

  render() {
    return (
    <>

        <svg id="svg" width="500" height="500" />

    </>
    )
  }
}

interface ICircleWithD3EventsProps {
  // TODO
}
App.tsx

添加我们的组件App.tsx,你会看到与图 2-7 相同的结果。

// src/App.tsx

import CircleWithD3Events from './components/CircleWithEvents/CircleWithD3Events'

<CircleWithEvents />

你可以看到,对于大多数人来说,React JSX 更直观,更容易阅读;但是,如果你需要写 D3 代码,你需要使用 D3 事件,一旦你明白你在做什么,它会很简单。

了解这两个选项会给您带来灵活性,并对您的代码有更多的控制。当我们希望 d3 负责我们组件的 DOM 时,最好使用 React 元素并尽可能多地绑定 React。在我们需要接管处理 DOM 任务的情况下,拥有 d3 可以帮助我们。

动画图形

在这一部分,我将介绍交互层的另一部分:创建动画。当我们改变数据或显示数据随时间的变化时,我们可能希望创建一个过渡,我们可以用动画或过渡以更优雅的方式来实现。

React 提供了许多创建转场的方法。以下是一些例子:

  • CSS 转场:我们可以使用普通的旧 CSS,将 CSS 转场作为样式表的一部分,或者动态地使用style={{someCSSPropertyExample: 50}}

  • CSS-in-JS 模块:如果使用 CSS-in-JS 模块之类的库,可以用直观的方式设置过渡。

  • D3 转换 : D3 也使用转换 API 提供转换。

当我们开发图表时,我们将使用过渡,正如我们在事件中看到的那样,知道我们的选项是很好的。

我将向您展示 CSS-in-JS 模块和 D3。

使用 React 制作动画

CSS-in-JS 模块是设计 React 应用的流行选项,因为它们与 React 组件紧密集成,并具有允许您更改从父组件传递的属性或从状态绑定属性的功能。

事实上,CRA·MHL 图书馆项目是用现成的 Material-UI 和样式组件建立的,所以你不需要安装任何东西;我们可以开始使用材质 UI 和样式组件。

例如,我们可以基于 React props改变我们的风格。此外,默认情况下,这些系统中的大多数都将所有样式的范围扩展到被样式化的相应组件。

当谈到 CSS-in-JS 模块时,有许多选项可供选择,如样式化组件、情感和样式化 jsx。

我选择了风格化组件,因为它与 Material-UI 密切相关;你可以在 https://styled-components.com 了解更多(如果你不熟悉的话)。

但是等等,为什么我们需要样式化的组件?Material-UI 不是已经有了类似于样式化组件的样式化导入吗?

是的,Material-UI 是一个很棒的组件库,它模仿了 Google 的材质设计,并内置了样式机制。那么为什么还不够呢?简单的回答是,Material-UI CSS-in-JS 解决方案感觉不如样式化组件强大。Material-UI 从一开始就被设计成使用自己的样式解决方案 CSS-in-JS。但是有时你想要其他在 Material-UI 风格中没有的特性,或者你像我一样,只是更喜欢风格化的组件。

幸运的是,Material-UI 确实简化了其他样式解决方案的使用。

Styled Components 是另一个用于样式化 React 组件的伟大库。这是通过定义没有 CSS 类的 React“样式化”组件来实现的。当您想要编写常规 CSS、传递函数和props时,最好使用样式化组件。你可能会问,为什么不直接使用样式化的组件呢?

在撰写本文时,还没有很多成熟的库处于 Material-UI 水平,当然也没有达到 Material-UI 成熟度水平。

我们可以同时利用 Material-UI 和 Styled 组件的优点。

使用样式化组件将为您提供以下能力:

  • 使用props有条件地渲染 CSS

  • SCSS 支持

  • CSS 的模板文字语法

还有更多。

对于我们将在本书后面使用的一个图表,我们需要一个脉动的圆。跳动的圆圈可以用来突出显示图表上的某些内容。

脉动圈. tsx

为了使用样式组件库创建那个脉动的圆形组件,让我们创建一个新的功能组件,并将其命名为PulsatingCircle.tsx

// src/component/PulsatingCircle/PulsatingCircle.tsx

import React from 'react'

我们从样式组件中导入关键帧和样式。

import styled, { keyframes } from 'styled-components'

现在,看看编写可以通过传递参数来更新的动态代码的能力。让我们定义一个采用两种颜色的函数。

const circlePulse = (colorOne: string, colorTwo: string) => keyframes`
0% {
  fill:${colorOne};
  stroke-width:20px
}
50% {
  fill:${colorTwo};
  stroke-width:2px
}
100%{
  fill:${colorOne};
  stroke-width:20px
}

`

现在使用动画,我们可以创建一个无限的四秒钟线性循环来形成这种脉动效果。

const StyledInnerCircle = styled.circle`
  animation: ${() => circlePulse('rgb(245,197,170)', 'rgba(242, 121, 53, 1)')} infinite 4s linear;
`

export default function PulsatingCircle(props: IPulsatingCircle) {

在渲染方面,我们可以使用作为 JSX 组件创建的StyledInnerCircle

注意,我从props开始给 x,y 赋值。父组件可以传递这些。

  return (
  <>

      <StyledInnerCircle cx={props.cx} cy={props.cy} r="8" stroke="limegreen" stroke-width="5" />
  </>

  )
}

在我们的props接口端,我们可以传递自定义组件的位置。

interface IPulsatingCircle {
  cx: number  cy: number
}

将我们的组件添加到App.tsx

// src/app.tsx

<svg width={400} height={400} viewBox="0 0 800 450">
  <g>
    <PulsatingCircle cy={100} cx={100} />
  </g>
</svg>

你现在有了一个脉动的动画圆,如图 2-8 所示。

img/510438_1_En_2_Fig8_HTML.jpg

图 2-8

使用 React 样式组件的脉动圆

用 D3 制作动画

就用 D3 制作动画而言,这个过程遵循一个动画序列。如果你熟悉任何动画软件,你会发现 D3 更直观,因为你了解动画的概念。对其他人来说,这可能会令人困惑。

脉动循环 3.tsx

作为一个例子,让我们创建另一个脉动圆,但这次让我们使用 D3。

导入并设置绘制方法。

import React, { useEffect } from 'react'
import * as d3 from 'd3'

const PulsatingCircleD3 = () /* props */ => {
  useEffect(() => {
    drawPulsatingCircle()
  })
  const drawPulsatingCircle = () => {

为了保持循环,我将创建一个名为repeat()的函数。该功能将选择circlSVGvg元素(我正在 JSX 创建圆;现在你知道怎么做了,如果你想得到 100%纯的 D3)。

接下来,我将设置一个过渡。

在第一个 300 毫秒期间,我首先将笔画的属性设置为 0,然后设置持续时间并将笔画不透明度从 0 更改为 0.5。

接下来,我设置另一个笔画来改变,并在动画中使用 D3 缓动来创建正弦缓动(是的,就是我们在数学课上使用的正弦圆)。

最后,函数使用递归函数(调用自身的函数)调用自身进行循环。

    (function repeat() {
      d3.selectAll('.circle')
        .transition()
        .duration(300)
        .attr('stroke-width', 0)
        .attr('stroke-opacity', 0)
        .transition()
        .duration(300)
        .attr('stroke-width', 0)
        .attr('stroke-opacity', 0.5)
        .transition()
        .duration(1000)
        .attr('stroke-width', 25)
        .attr('stroke-opacity', 0)
        .ease(d3.easeSin)
        .on('end', repeat)
    })()
  }

在 JSX,我正在创建一个半径为 8 像素,x,y 的位置为 50,50 的圆,如图 2-9 所示。

img/510438_1_En_2_Fig9_HTML.jpg

图 2-9

使用 D3 的脉动圆

  return (
  <>

      <svg>
        <circle className="circle" cx="50" cy="50" stroke="orange" fill="orange" r="8" />
      </svg>
  </>

  )
}
export default PulsatingCircleD3

你可以看到这个想法是为了给动画排序。我肯定有些人会觉得这更复杂,而其他人会觉得这比上一个例子简单。

摘要

本章分解了开始用 React 和 D3 创建图表所需要知道的内容。这一章分成三个主要部分。

  • 制图法

  • 用户手势

  • 鼓舞

在第一部分中,我向您展示了如何使用 React 函数和类组件用 HTML 和 SVG 元素创建图形。

我们同时使用了 JSX 和 D3。我们消耗数据,绘制元素。我们甚至创建了我们的第一个图表,一个条形图。

在第二部分中,您学习了用 D3 和 React 设置事件,以及用 React 和 D3 制作动画。

如你所见,在绘图、消耗数据、制作动画、甚至与选项交互时,你可以使用 React、D3 或其他库。这也是 D3 和 React 的独特之处。当需要将您的图表集成到现有代码中、对其进行测试,以及与拥有不同技能的不同成员团队合作时,这些选项集真的很方便;它甚至使你的代码更具可读性。

在下一章,我们将开始创建简单的图表,如折线图、面积图和条形图,以及消费数据、动画和与这些图表互动。

三、基本图表:第一部分

在前一章中,我介绍了 D3 和 React 的可能性。我们创建了函数和类组件,甚至创建了一个简单的条形图。在这一章中,我将介绍如何使用 TypeScript 作为类型检查器,用 React 和 D3 创建简单的图表。我将向您展示如何创建下面的简单图表,重点是让 D3 完成大部分工作。我将向您展示如何创建以下图表:

  • 折线图

  • 对比图

  • 条形图

我们开始吧。

设置

正如前几章一样,我将使用 CRA 和 MHL 模板来创建我们的起始项目。

$ yarn create react-app basic-charts --template must-have-libraries

$ cd basic-charts
$ yarn add d3 @types/d3
$ yarn start

打开起始页。

$ open http://localhost:3000

您可以从这里下载本章的完整代码:

github。com/ Apress/ integrating-d3。js-with-react/tree/main/ch03

折线图

我要展示的第一个图表是折线图。折线图以图形方式显示定量数据,被认为是最基本的图表类型之一。折线图由三个绘图元素组成:x 轴、y 轴和一条线。

幸运的是,D3 有一些方法可以帮助你完成创建折线图的整个过程。

line.csv

一个好的起点是数据。对于折线图,我将使用的数据直接来自雅虎融资。我将提取波音股票的历史数据,股票代码为 BA: https://finance.yahoo.com/quote/BA/history 。一旦进入该页面,您将看到一个下载数据的选项。

在下载的 CSV 文件中(见图 3-1 ,我保留了Date列和Open价格,删除了其他列。

img/510438_1_En_3_Fig1_HTML.jpg

图 3-1

BA 历史股票价格的 CSV 文件,在 Microsoft Excel 中打开

接下来,我将把日期重新格式化为%Y-%m-%d的格式,以便于阅读(见图 3-2 )。

img/510438_1_En_3_Fig2_HTML.jpg

图 3-2

包含 BA 历史股票价格的 CSV 文件的格式化日期

我获取 BA 的价格历史,然后将 CSV 转换为两个字段:datavalue

最后,我将把DateOpen列重命名为datevalue

我将文件保存在public/data文件夹中:public/data/line.csv

date,value
2020-01-27,321.75
2020-02-03,318.75
2020-02-10,337.220001
2020-02-17,338.769989
2020-02-24,320
..
..
..

除了数据文件,我还将创建几个文件。

  • BasicLineChart.scss : SCSS 风格文件

  • BasicLineChart.test.tsx:Jest/酵素测试文件

  • BasicLineChart.tsx:组件

  • types.tsts 类型

和往常一样,你可以自己创建这些文件,或者从generate-react-cli那里获得一点帮助。

$ npx generate-react-cli component BasicLineChart --type=d3

types.ts

创建类型文件是保持 TypeScript 类型有组织的常见做法。在我的图表中,我只需要我创建的日期和值,但这是一个很好的习惯,特别是对于复杂的图表,因为需要的类型数量不断增加。在我的例子中,我只需要一种保存日期和值的数据类型。

//  src/component/BasicLineChart/types.ts

export namespace Types {
  export type Data = {
    date: string
    value: number
  }
}

BasicLineChart.tsx

功能组件BasicLineChart做的很重,会画出轴和线图。

我将导入 React,SCSS,D3 和类型文件。结构和我们在上一章中的一样。

我已经将函数组件分解为一个被调用的draw()函数和一个 JSX 占位符。正如我们在上一章所做的一样,draw()函数被useEffect钩子调用。看一看:

// src/component/BasicLineChart/BasicLineChart.tsx

import React, { useEffect } from 'react'
import './BasicLineChart.scss'
import * as d3 from 'd3'
import { Types } from './types'

const BasicLineChart = (props: IBasicLineChartProps) => {
  useEffect(() => {
    draw()
  })

  const draw = () => {

首先,我将设置图表的尺寸和边距。我将通过props从父组件传递这些。

    const width = props.width - props.left - props.right
    const height = props.height - props.top - props.bottom

接下来,我将把一个 SVG 对象添加到我在 JSX 渲染部分设置的包装器div中,并分配属性。我还将添加一个组元素,就像我们在上一章所做的那样。

    const svg = d3
      .select('.basicLineChart')
      .append('svg')
      .attr('width', width + props.left + props.right)
      .attr('height', height + props.top + props.bottom)
      .append('g')
      .attr('transform', `translate(${props.left},${props.top})`)

我不仅可以使用 D3 绘制轴和线,还可以检索 CSV 数据。看一下dsv API ( https://github.com/d3/d3-dsv )。一旦检索到数据,我就将对象转换为我的Types.Data,然后使用d3.timeParse将字符串转换为 D3 Date对象。dsv API 将逐个遍历列表,我将返回一个由解析为d3valueDate组成的对象。

    d3.dsv(',', '/Data/line.csv', (d) => {
      const res = (d as unknown) as Types.Data
      const date = d3.timeParse('%Y-%m-%d')(res.date)
      return {
        date,
        value: res.value,
      }

一旦dsv方法完成,我就用 D3 Date格式的日期和值格式化对象。下一步是添加 x 轴,这将是域的日期。这将刻度的域设置为指定数组的域值,在我们的例子中是日期。

    }).then((data) => {
      const x = d3
        .scaleTime()
        .domain(
          d3.extent(data, (d) => {
            return d.date
          }) as [Date, Date]
        )
        .range([0, width])

Notice

我正在使用d3.extent,并将我的域转换为[Date, Date]d3.extent同时返回最小值和最大值。我还改变了从零到图表宽度的范围。

如果不将d3.extent转换为[Date, Date],我将得到如图 3-3 所示的平均 ESLint 错误消息。从消息中可以看出,它期待一个[Date, Date]

img/510438_1_En_3_Fig3_HTML.jpg

图 3-3

由于不兼容的类型导致的 ESLint 错误消息

TS2345: Argument of type '[undefined, undefined] | [Date, Date]' is not assignable to parameter of type 'Iterable<number | Date | { valueOf(): number; }>'.

最后,我将添加一个组,使用 translate 将该组设置到左下角的位置,并调用d3.axisBottom(x)来附加我的 x 轴。

      svg.append('g').attr('transform', `translate(0, ${height})`).call(d3.axisBottom(x))

Note

call方法是 D3 以选择的形式返回对自身的引用的常用方法。

这里发生的事情是,svg.append('g')将一个 SVG 组元素附加到 SVG 中,并以选择的形式返回对自身的引用。

当我们调用一个选择时,我们是在调用选择g的元素上的函数axisBottom。我们正在新创建和添加的组g上运行axisBottom功能。

对于 y 轴,过程是相似的,只是我设置了一个值而不是日期。我使用数学( https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/max )来获得可能的最大值。

Math.max(...data.map((dt) => ((dt as unknown) as Types.Data).value), 0)

这将确保我的图表设置为基于最大值运行;否则,高度将会不对齐并显示第一个值,而其他值可能会溢出。

      const y = d3
        .scaleLinear()
        .domain([
          0,
          d3.max(data, (d) => {
            return Math.max(...data.map((dt) => ((dt as unknown) as Types.Data).value), 0)
          }),
        ] as number[])
        .range([height, 0])
      svg.append('g').call(d3.axisLeft(y))

对于域,我需要确保对象被强制转换为number[],以避免 ESLint 中又一个不兼容类型的消息。

TS2345: Argument of type '(number | undefined)[]' is not assignable to parameter of type 'Iterable<NumberValue>'. The types returned by '[Symbol.iterator]().next(...)' are incompatible between these types.

最后,我需要添加我想画的线。为此,我可以添加一个 SVG path 元素,并将数据用于我的数据,这样它将遍历我的数据对象来绘制每一行。我正在使用从props开始的填充,笔画宽度为 1.6。

      svg
        .append('path')
        .datum(data)
        .attr('fill', 'none')
        .attr('stroke', props.fill)
        .attr('stroke-width', 1.5)
        .attr(
          'd',
          // @ts-ignore
          d3
            .line()
            .x((d) => {
              return x(((d as unknown) as { date: number }).date)
            })
            .y((d) => {
              return y(((d as unknown) as Types.Data).value)
            })
        )
    })
  }

在渲染方面,我设置了一个包装器div,其className值为basicLineChart

  return <div className="basicLineChart" />
}

对于接口,我将从父组件传递属性,这样我就可以对齐组件并设置填充颜色,这样就可以很容易地重用我的组件。

interface IBasicLineChartProps {
  width: number
  height: number
  top: number
  right: number
  bottom: number
  left: number
  fill: string
}

export default BasicLineChart

请注意,这些 D3 比例方法真的帮了大忙;他们能够进行计算,并将我们的数据转换成绘制图表所需的值。

如果我需要在基本折线图上添加一个脉动点或任何东西,我能做的就是重用这些 x 轴和 y 轴的值,例如y(300)

这个什么时候派上用场?假设我想在图表的末尾再画一条线。我可以存储 x,y 的最后位置,然后用 y 轴和 x 轴计算我想要的任何价格。

svg
 .append('line')
 .style('stroke', 'red')
 .style('stroke-width', 1)
 .attr('x1', lastX)
 .attr('y1', lastY)
 .attr('x2', lastX + x2)
 // y Axis is what turn the value to the value needed
 // on the chart
 .attr('y2', yAxis(300))

basiclinechart . scss

至于我的 SCSS 文件,我可以在这里设置我的绘图元素的属性,但我真的不需要设置任何东西,因为我正在使用props传递我的填充颜色和其他属性。也就是说,准备好我的 SCSS 供将来使用是一个好习惯。

.basicLineChart {
}

basiclenechart . test . tsx

为了测试,我使用 Jest 和 Enzyme 来确保组件挂载并使用我设置的props

如果你是第一次尝试用笑话和酶 React,看看我在 https://medium.com/react-courses/unit-testing-react-typescript-app-with-jest-jest-dom-enzyme-11f52487aa18 的文章。更多详情,请阅读我的《React 进度书: https://www.apress.com/gp/book/9781484266953

// src/component/BasicLineChart/BasicLineChart.test.tsx

import React from 'react'
import { shallow } from 'enzyme'
import BasicLineChart from './BasicLineChart'

describe('<BasicLineChart />', () => {
  let component

  beforeEach(() => {
    component = shallow(<BasicLineChart top={10} right={50} bottom={50} left={50} width={460} height={400} fill="tomato" />)
  })

  test('It should mount', () => {
    expect(component.length).toBe(1)
  })
})

App.tsx

最后,我可以用我设置的props添加简单的BasicLineChart

// src/App.tsx

import React from 'react'
import './App.scss'

import BasicBarChart from './components/BasicBarChart/BasicBarChart'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <BasicBarChart top={10} right={50} bottom={50} left={50} width={900} height={400} fill="tomato" />
      </header>
    </div>
  )
}

export default App

最终结果见图 3-4 。

img/510438_1_En_3_Fig4_HTML.jpg

图 3-4

英航股价折线图

现在我已经准备好了我的图表,我将运行我在package.json运行脚本中设置的格式、lint 和测试任务,以确保质量。

$ yarn format
$ yarn lint
$ yarn test

如果你打开package.json并查看scripts标签下,你可以看到这些任务被设置在那里。

"scripts": {
  "format": "prettier --write 'src/**/*.{ts,tsx,scss,css,json}'",
  "lint": "eslint --ext .js,.jsx,.ts,.tsx ./",
  "test": "react-scripts test",
  ..
  ..
}

我不会详细讨论格式、lint 和测试是如何设置的,但是正如我之前指出的,你可以在我的 React 和 Libraries 的书中了解更多,可以在 https://www.apress.com/gp/book/9781484266953 找到。

如果你不设置测试套件,这很好,不会影响任何功能;然而,用完整的测试覆盖来编写你的组件只是一个好的实践。

对比图

面积图以图形方式显示定量数据,类似于折线图。

不同的是轴和线之间的区域用颜色强调。

在编码方面,类似于我们刚刚在上例中做的折线图;唯一的区别是这个区域是有颜色的

area.csv

至于数据,我将使用标准普尔 500 股票行情自动收报机,并将数据格式化,就像我在折线图中所做的那样( https://finance.yahoo.com/quote/%5EGSPC/history )。

我将在public/data/area.csv保存结果。

date,value
2020-01-27,3282.330078
2020-02-03,3235.659912
2020-02-10,3318.280029
..
..

BasicAreaChart.tsx

我的主成分BasicAreaChart.tsx,和BasicLineChart.tsx差不多。看一看:

// src/component/BasicAreaChart/BasicAreaChart.tsx

import React, { useEffect } from 'react'
import './BasicAreaChart.scss'
import * as d3 from 'd3'
import { Types } from './types'

const BasicAreaChart = (props: IBasicAreaChartProps) => {
  useEffect(() => {
    draw()
  })

  const draw = () => {

我设置尺寸和边距。

    const width = props.width - props.left - props.right
    const height = props.height - props.top - props.bottom

接下来,我将svg对象添加到我将在渲染端设置的basicAreaChart JSX div中。

    const svg = d3
      .select('.basicAreaChart')
      .append('svg')
      .attr('width', width + props.left + props.right)
      .attr('height', height + props.top + props.bottom)
      .append('g')
      .attr('transform', `translate(${props.left},${props.top})`)

    d3.dsv(',', '/Data/area.csv', (d) => {
      const res = (d as unknown) as Types.data
      const date = d3.timeParse('%Y-%m-%d')(res.date)
      return {
        date,
        value: res.value,
      }
    }).then(function results(data) {

现在我可以将日期格式设置为 x 轴。

      const x = d3
        .scaleTime()
        .domain(
          d3.extent(data, (d) => {
            return d.date
          }) as [Date, Date]
        )
        .range([0, width])

      svg.append('g').attr('transform', `translate(0, ${height})`).call(d3.axisBottom(x))

我也可以将 y 轴设置为值。

      const y = d3
        .scaleLinear()
        // @ts-ignore
        .domain([
          0,
          d3.max(data, (d) => {
            return +d.value
          }),
        ] as number[])
        .range([height, 0])
      svg.append('g').call(d3.axisLeft(y))

对于图表的线条,我将使用路径,就像在BasicLineChart.tsx中一样。

      svg
        .append('path')
        .datum(data)
        .attr('fill', props.fill)
        .attr('stroke', 'white')
        .attr('stroke-width', 1.5)

面积图最大的不同是,我现在使用的是 D3 面积和曲线 API。

d3
.area()
.curve(d3.curveLinear)

这段代码是对路径线的补充,因为我需要用颜色填充这个区域。

我们通过设置xy0y1值来实现。

* @param x Sets the x accessor - in our case a date
* @param y0 Sets the y0 accessor - in our case it's zero since we start from the bottom.
* @param y1 Sets the y1 accessor - in our case it's the value of the stock.

看一看:

        .attr(
          'd',
          // @ts-ignore
          d3
            .area()
            .curve(d3.curveLinear)
            .x((d) => {
              return x(((d as unknown) as { date: number }).date)
            })
            .y0(y(0))
            .y1((d) => {
              return y(((d as unknown) as Types.data).value)
            })
        )
    })
  }

在渲染方面,我添加了div包装器。

  return <div className="basicAreaChart" />
}

界面与折线图相同。

interface IBasicAreaChartProps {
  width: number
  height: number
  top: number
  right: number
  bottom: number
  left: number
  fill: string
}

export default BasicAreaChart

接下来,设置types.tsscssBasicAreaChart.test.tsx文件。它们的代码与 line 示例中的代码相同,所以我不会在这里向您展示它们。确保它们被设置在BasicAreaChart文件夹中(参见图 3-5 )。

img/510438_1_En_3_Fig5_HTML.jpg

图 3-5

基础图表文件结构

App.tsx

最后,我们需要设置App.tsx来包含我们的BasicAreaChart,并通过props

// src/App.tsx

import React from 'react'
import './App.scss'

import BasicAreaChart from './components/BasicAreaChart/BasicAreaChart'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <BasicAreaChart top={10} right={50} bottom={50} left={50} width={1000} height={400} fill="tomato" />
      </header>
    </div>
  )
}

export default App

然后瞧啊!见图 3-6 。

img/510438_1_En_3_Fig6_HTML.jpg

图 3-6

基本图表完成

如你所见,折线图和面积图是相似的。一旦我们理解了检索和格式化数据、创建 x 轴和 y 轴以及绘制图表的过程,每次绘制图表都会变得更加容易。

条形图

我将在本章中创建的最后一个图表是另一种常用的图表类型,条形图。

条形图用于显示和比较数字、频率或其他指标。条形图之所以受欢迎,是因为它的创建非常简单,并且易于解释。

我已经在第二章向你展示了如何创建一个简单的条形图;然而,条形图没有 x,y 轴,并且数据不是从外部文件加载的。

在这个例子中,对于数据,我将使用 Stack Overflow 调查数据来显示 React 和其他框架的受欢迎程度: https://insights.stackoverflow.com/survey/2020 。这个图表帮助你选择一个网络框架。

酒吧. csv

对于这些数据,我用从栈溢出调查中复制的值创建了一个名为public/data/bar.csv的 CSV 文件。看一看:

framework,value
jQuery,43.3
React.js,35.9
Angular,25.1
ASP.NET,21.9
Express,21.2
.NET Core,19.1
..
..

BasicBarChart.tsx

在结构方面,我将保持类似于我之前创建的折线图和面积图的结构。

// src/component/BasicBarChart/BasicBarChart.tsx

import React, { useEffect } from 'react'
import './BasicBarChart.scss'
import * as d3 from 'd3'
import { Types } from './types'

const BasicBarChart = (props: IBasicBarChartProps) => {
  useEffect(() => {
    draw()
  })

  const draw = () => {

像以前一样,我们设置尺寸和边距。

    const width = props.width - props.left - props.right
    const height = props.height - props.top - props.bottom

为了绘制 x,y 范围,我将从 D3 获得一些帮助,并基于从父组件通过props传递的属性来设置它们。

    const x = d3.scaleBand().range([0, width]).padding(0.1)
    const y = d3.scaleLinear().range([height, 0])

接下来,我将把 SVG 对象追加到我的名为basicBarChartdiv包装器中,我将在渲染时添加该包装器,我将添加一个组并设置我的 SVG 宽度和高度属性。

    const svg = d3
      .select('.basicBarChart')
      .append('svg')
      .attr('width', width + props.left + props.right)
      .attr('height', height + props.top + props.bottom)
      .append('g')
      .attr('transform', `translate(${props.left},${props.top})`)

    d3.dsv(',', '/Data/bar.csv', (d) => {
      return (d as unknown) as Types.Data

一旦数据对象准备好了,我就可以在域中缩放Data的范围。

    }).then((data) => {
      x.domain(
        data.map((d) => {
          return d.framework
        })
      )
      y.domain([
        0,
        d3.max(data, (d) => {

我将使用我在内嵌图表中使用的相同数学函数来设置 y 的max值,并将我的域转换为number[]以避免 ESLint 对我咆哮。

          return Math.max(...data.map((dt) => (dt as Types.Data).value), 0)
        }),
      ] as number[])

为了绘制实际的条形图,我将使用selectAlldata属性,这样 D3 将遍历我的数据并为条形图添加矩形。

      svg
        .selectAll('.bar')
        .data(data)
        .enter()
        .append('rect')
        .attr('fill', props.fill)
        .attr('class', 'bar')
        .attr('x', (d) => {
          return x(d.framework) || 0
        })

注意,在返回时,我使用了“或零”:|| 0。原因是我们不确定是否有值,数据可以是未定义的(number | undefined)。这就是 TS 需要那个“或零”的原因——这是为了避免得到 ESLint 过载错误消息。

TS2769: No overload matches this call. Overload 1 of 4, '(name: string, value: null): Selection<SVGRectElement, Data, SVGGElement, unknown>', gave the following error. Argument of type '(this: SVGRectElement, d: Data) => number | undefined' is not assignable to parameter of type 'null'.

对于宽度,我使用的是x. bandwidth,它返回构成条形图的每个 bin(矩形)的宽度。对于高度→这将是图表边界的高度减去创建容器高度值的值。

        .attr('width', x.bandwidth())
        .attr('y', (d) => {
          return y(d.value)
        })
        .attr('height', (d) => {
          return height - y(d.value)
        })

接下来,我将添加 x 轴和 y 轴。

      svg.append('g').attr('transform', `translate(0,${height})`).call(d3.axisBottom(x))

      svg.append('g').call(d3.axisLeft(y))
    })
  }

现在我呈现我的名为basicBarChartdiv包装器。

  return <div className="basicBarChart" />
}

最后,我设置了我的接口。

interface IBasicBarChartProps {
  width: number
  height: number
  top: number
  right: number
  bottom: number
  left: number
  fill: string
}

export default BasicBarChart

正如我们在其他例子中所做的那样,设置types.tsscssBasicAreaChart.test.tsx

types.ts

对于类型,我设置了两个变量:frameworkvalue(类型为stringnumber)。

// src/component/BasicBarChart/types.ts

export namespace Types {
  export type Data = {
    framework: string
    value: number
  }
}

basicbarchart . scss

对于 SCSS,我可以在那里设置每个条的填充,但是由于我是在props中设置的,这将是一个重叠。我只是想告诉你,如果你需要的话,为我们在 SCSS 创建的 D3 元素设置属性不仅仅是可以接受的,而且很容易阅读和修改,尤其是当你在一个有设计师的团队中工作的时候。

.basicBarChart {
}

.bar {
  fill: tomato;
}

App.tsx

至于App.tsx,你已经知道该怎么做了,所以继续添加组件吧。

// src/App.tsx

import React from 'react'
import './App.scss'

import BasicBarChart from './components/BasicBarChart/BasicBarChart'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <BasicBarChart top={10} right={50} bottom={50} left={50} width={900} height={400} fill="tomato" />
      </header>
    </div>
  )
}

export default App

又来了!见图 3-7 。

img/510438_1_En_3_Fig7_HTML.jpg

图 3-7

条形图最终结果

看图表,你可以看到图表讲述了一个故事。乍一看,2020 栈溢出结果似乎表明 React 绕过了 Angular 近 11%。

从图表中可以看出(图 3-8 ),jQuery 显示为王者,但 React.js 正在蓄势待发,准备接管。

img/510438_1_En_3_Fig8_HTML.jpg

图 3-8

https://insights.stackoverflow.com/survey/2020#community

然而,事实并非如此。

该调查分别包括 React.js 和 Gatsby,尽管它们都基于 React,Angular 和 Angular.js 之间也有分裂。

实际上,如果我们把这些结果加在一起,Angular 的 41.2%、React 的 39.9%和 jQuery 的 43.3%几乎是完全相等的。

真实的结果是 React、Angular 和 jQuery 之间更加平均。如果我要相应地调整我的数据文件,我会得到这样的结果:

framework,value
jQuery,43.3
Angular + Angular.js,41.2
React.js + Gatsby,39.9

一旦我输入新的数据,我将得到一个完全不同的故事;参见图 3-9 。

img/510438_1_En_3_Fig9_HTML.jpg

图 3-9

react vs Angular vs jQuery 2020

如果你有兴趣比较 React 和 Angular,可以看看我在 https://medium.com/react-courses/angular-9-vs-react-16-a-2020-showdown-2b0b8aa6c8e9 发表的关于媒体的文章。

在写这本书的时候,2021 栈溢出的结果还没有发表,但是看看这些值如何随着时间的推移而变化会很有趣。现在你已经有了这张图表,你可以插入新的数据了。

现在我已经准备好了所有三个图表,我将最后一次运行 format、lint 和 test 任务,以确保质量。

$ yarn format
$ yarn lint
$ yarn test

继续将您的结果与我的进行比较(参见图 3-10 )。

img/510438_1_En_3_Fig10_HTML.jpg

图 3-10

所有图表的测试套件结果通过

✨  Done in 1.48s.$ eslint --ext .js,.jsx,.ts,.tsx ./
✨  Done in 9.88s.Test Suites: 5 passed, 5 total
Tests: 5 passed, 5 total
Snapshots: 0 total

摘要

在这一章中,我向你展示了如何用 React、ts 和 D3 创建流行的和基本的图表。我们创建了以下三种类型的图表:

  • 折线图

  • 对比图

  • 条形图

我向您展示了如何最大限度地利用 D3,不仅用于绘图,甚至用于检索数据,我还向您展示了如何避免常见的 ESLint 错误消息,因为 TS 要求拥有类型。我也给了你一些技巧,关于如何组织你的作品,用格式、lint 和测试运行脚本进行质量检查。

查看我的 d3 和 React 交互课程,看看你可以用不同的方法实现本章中的所有例子。互动课程涵盖了本节的更多主题,例如,对 DOM、色彩空间、交互性、设计的更多控制,以及对本章内容的扩展。该课程灵活地补充了本章和本书;https://elielrom.com/BuildSiteCourse

下一章将继续创建基本图表,我们将创建另外三个基本图表。

  • 圆形分格统计图表

  • 散点图

  • 直方图

四、基本图表:第二部分

正如您已经看到的,D3 是创建图表的标准,所以如果您对创建和定制图表很认真,您就无法逃避对 D3 的学习。React 与其他库(如 D3)集成;但是,将 TypeScript 添加到组合中确实需要特别注意。

在前面的章节中,我向您展示了如何使用 React、ts 和 D3 创建流行和基本类型的图表。此外,如果你上过我的 React + d3 交互课程( https://elielrom.com/BuildSiteCourse ),你会看到你可以用不同的方法实现上一章中的所有例子,比如应用记忆回调、处理大小调整、更多的交互以及处理 DOM。与只使用 JS 相比,React + d3 + TS 的组合需要一些特别的注意,本章和上一章的基本图表反映了我发现最有效的东西。

在这一章中,我将介绍如何使用 TypeScript 作为类型检查器,用 React 和 D3 创建更简单的图表。

我将向您展示如何创建以下简单的图表,重点是让 D3 完成大部分工作:

  • 圆形分格统计图表

  • 散点图

  • 直方图

我们开始吧。

圆形分格统计图表

饼图是最基本和最流行的图表类型之一。图表类型是圆形的统计图形。饼图通过使用切片表示整体的比例来表示数字。

英尺. csv

我的图表的数据指标只是总计 100%的随机数。

name,value
a,25
b,3
c,45
d,7
e,20

除了数据文件之外,我还将创建几个文件,就像我在上一章所做的那样。

  • BasicPieChart.tsx:主要成分

  • BasicPieChart.test.tsx : Jest 和酵素测试

  • SCSS 前置处理器

  • 保存我将要使用的类型的文件

和第一部分一样,您可以自己创建这些文件,或者从generate-react-cli那里获得一些帮助。

$ npx generate-react-cli component BasicPieChart --type=d3

types.ts

我的类型由与数据文件相同的列组成,如namevalue

// src/component/BasicPieChart/types.tsexport namespace Types {
  export type Data = {
    name: string
    value: number
  }
}

BasicPieChart.tsx

对于BasicPieChart,流程类似于我们在第一部分搭建的图表,使用useEffect绘制方法,加载数据,绘制图表。看一看:

// src/component/BasicPieChart/BasicPieChart.tsx

import React, { useEffect } from 'react'
import './BasicPieChart.scss'
import * as d3 from 'd3'

D3 是模块化构建的,所以我需要PieArcDatum ( https://github.com/d3/d3-shape )。PieArcDatum泛型是指传递给Pie生成器的输入数组中元素的数据类型。我将使用PieArcDatum来更好地投射我的物体。确保添加模块,如下所示:

yarn add d3-shape

看一看:

import { PieArcDatum } from 'd3-shape'
import { Types } from './types'

const BasicPieChart = (props: IBasicPieChartProps) => {
  useEffect(() => {
    draw()
  })

对于draw()方法,我将设置饼图的宽度、高度和半径。

  const draw = () => {
    const width = props.width - props.left - props.right
    const height = props.height - props.top - props.bottom
    const radius = Math.min(width, height) / 2

接下来,我选择我将要渲染的basicPieChart div,并添加一个名为svg的组,带有一个transform属性。

    const svg = d3
      .select('.basicPieChart')
      .append('svg')
      .attr('width', width)
      .attr('height', height)
      .append('g')
      .attr('transform', `translate(${width / 2},${height / 2})`)

我将上传 CSV pie.csv数据文件。

    d3.dsv(',', '/Data/pie.csv', (d) => {
      const res = (d as unknown) as Types.Data
      return {
        name: res.name,
        value: res.value,
      }
    }).then((data) => {

一旦我的数据对象准备好了,下一步就是设置色标。我将使用d3.scaleOrdinal(),对于域名,我可以设置每个名称有自己独特的颜色。

D3 使事情变得简单,因为有一组预定义的分类配色方案,所以我可以使用d3.schemeCategory10或任何其他( https://github.com/d3/d3-scale-chromatic )颜色类别。

const color = d3
        .scaleOrdinal()
        .domain(
          (d3.extent(data, (d) => {
            return d.name
          }) as unknown) as string
        )
        .range(d3.schemeCategory10)

注意,虽然我使用的是d3.schemeCategory10,但是我可以创建自己的配色方案,它可以作为props传递或者在我的数据文件中定义。

.range(['#000000', '#000000', '#000000', '#000000', '#000000'])

下一步是遍历我的数据并创建饼图。我可以把我的数据转换成键值对,然后把它传递给一个路径元素,就像这样:

const map = d3.map(data, (d) => {
  return { 'key': d.name, value: d.value }
})

但是有更好的方法。我可以用我的数据类型设置饼图,用我的数据类型使用泛型PieArcDatum,并为半径生成路径。然后我插入我的数据来创建一个饼图数据,我可以用它来遍历结果。

      const pie = d3
        .pie<Types.Data>()
        .sort(null)
        .value((record) => record.value)

      const path = d3.arc<PieArcDatum<Types.Data>>().innerRadius(0).outerRadius(radius)

      const pieData = pie(data)

现在我需要做的就是为每个饼图数据生成 arch SVGs,并使用我为每个名称创建的颜色。

      const arch = svg
        .selectAll('.arc')
        .data(pieData)
        .enter()
        .append('g')
        .attr('class', 'arc')
        .attr('fill', (d) => {
          return color(d.data.name) as string
        })

      arch.append('path').attr('d', path)
    })
  }

渲染方面,我需要一个包装div

  return <div className="basicPieChart" />
}

对于我的props接口,我正在放置将从我的父组件传递的对齐元素。

interface IBasicPieChartProps {
  width: number
  height: number
  top: number
  right: number
  bottom: number
  left: number
}

export default BasicPieChart

BasicPieChart.test.tsx

对于测试,我使用 Jest 和 Enzyme 来确保组件挂载,并使用我设置的props

// src/component/BasicPieChart/BasicPieChart.test.tsx

import React from 'react'
import { shallow } from 'enzyme'
import BasicPieChart from './BasicPieChart'

describe('<BasicPieChart />', () => {
  let component

  beforeEach(() => {
    component = shallow(<BasicPieChart width={900} height={400} top={10} right={50} bottom={50} left={50} />)
  })

  test('It should mount', () => {
    expect(component.length).toBe(1)
  })
})

App.tsx

最后,我的父组件App.tsx需要包含我的BasicPieChart和对齐props

// src/App.tsx

import React from 'react'
import './App.scss'

import BasicPieChart from './components/BasicPieChart/BasicPieChart'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <BasicPieChart width={400} height={400} top={10} right={10} bottom={10} left={10} />
      </header>
    </div>
  )
}

export default App

basicpiechart . scss

对于 SCSS 文件,我定义了一个占位符。我还不需要任何 SCSS,但是创建一个 SCSS 文件是一个很好的实践。

.basicPieChart {
}

再看一下本地主机端口 3000: http://localhost:3000/。你可以将你的结果与我的进行比较,如图 4-1 所示。

img/510438_1_En_4_Fig1_HTML.jpg

图 4-1

React 和 D3 饼图

和往常一样, y 你可以从这里下载本章的完整代码:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch03

要查看这个与 React 更好集成的基本饼图,以及调整大小和切换指标的交互,请查看我的 React + d3 交互课程:https://elielrom.com/BuildSiteCourse

BasicDonutChart.tsx

要创建一个小圆环图,过程几乎是相同的。我将使用PieArcDatum为内圆和外圆绘制圆弧,以创建一个圆环。变化很简单。当我们创建弧线时,只需更新'innerRadius'属性;innerRadius(10)

在下面的例子中,我可以修改代码并使用props来传递来自父组件的数据,而不是将它加载到我的组件中。我还将编写代码,以便在需要时可以使用该组件来更改数据。让我们来看看。

首先使用yarn add d3-shape导入库。

看一看BasicDonutChart.tsx代码:

import React, { RefObject, useEffect, useState } from 'react'
import * as d3 from 'd3'
import { PieArcDatum } from 'd3-shape'
import { Types } from './types'

const BasicDonutChart = (props: IBasicDonutChartProps) => {
 const ref: RefObject<HTMLDivElement> = React.createRef()
 const [data, setData] = useState<Types.Data[]>([])

useEffect里面,我会检查数据。这是必要的,以确保我只在新数据被更新时才改变饼图。例如,当你有新数据时,这种情况就会发生。

我所做的是将数据存储在一个 React 状态对象上,然后使用JSON.stringify将状态数据与props数据进行比较,看看是否有变化。如果有变化,我会将新数据存储在状态中。

 useEffect(() => {
   if (JSON.stringify(props.data) !== JSON.stringify(data)) {
     setData(props.data)

     const { width } = props
     const { height } = props

     const svg = d3
       .select(ref.current)
       .append('svg')
       .attr('width', width)
       .attr('height', height)
       .append('g')
       .attr('transform', `translate(${width / 2}, ${height / 2.5})`)

     const color = ['#068606', '#C1C0C0']

     const donut = d3
       .pie<Types.Data>()
       .sort(null)
       .value((record) => record.value)

     const path = d3.arc<PieArcDatum<Types.Data>>().innerRadius(10).outerRadius(20)

     const donutData = donut(props.data)

     const arch = svg
       .selectAll('.arc')
       .data(donutData)
       .enter()
       .append('g')
       .attr('class', 'arc')
       .attr('fill', (d, i) => {
         return color[i] as string
       })

     arch.append('path').attr('d', path)
   }
 }, [data, props, props.data, props.height, props.width, ref])

我需要指定我在useEffect中使用的变量。

 return <div className="basicDonutChart" ref={ref} />
}

interface IBasicDonutChartProps {
 data: Types.Data[]
 width: number
 height: number
}

export default BasicDonutChart

注意,在我的例子中,我使用了一个引用,而不是 D3 select。这样,我就可以将我的 pie 作为列表项呈现器,以防我需要在列表中使用这个组件。

App.tsx

要实现这一点,您可以在父组件中设置图表,并在App.tsx中传递数据。

<BasicDonutChart
 data={[
   { name: 'Yes', value: 80 },
   { name: 'No', value: 20 },
 ]}
 width={50}
 height={50}
/>

看一下图 4-2 。

img/510438_1_En_4_Fig2_HTML.jpg

图 4-2

基本圆环饼图

至于数据,在前面的例子中,我向您展示了实际的图表组件如何检索数据。

这使得我们的代码易于阅读和松散耦合,这是一个保持图表简单和数据在一个地方的伟大设计;然而,为了让您为下一章处理状态管理做好准备,这里我将数据提取到父组件App.tsx

我们希望从图表组件中提取数据的原因是为了在多个组件之间共享数据。在这种情况下,我们希望一次性加载数据,并与多个组件共享。一个很好的例子是使用相同的数据绘制不同类型的图表。

散点图

散点图(又名散点图散点图 h)用点表示数值。散点图是观察变量之间关系的好方法。

散点. csv

使用散点图的一个有趣方法是观察钻石价格与钻石大小的关系。我在 GitHub ( https://github.com/sakshi296/P1-1-Predicting-Diamond-Prices )上找到了发布的数据。一旦我下载了图表,我可以在 Excel 或任何其他程序中打开它来修改它,如图 4-3 所示。

img/510438_1_En_4_Fig3_HTML.jpg

图 4-3

每克拉钻石价格 CSV 数据

我将删除所有不需要的列,保留价格和克拉指标(见图 4-4 )。

img/510438_1_En_4_Fig4_HTML.jpg

图 4-4

清洗后每克拉钻石价格

我们的数据集很小,占用空间很小,但是清理您的数据并设置您的数据集以仅使用您需要的数据是优化您的数据并提高性能的良好做法。在第十章中,我将深入探讨优化图表的最佳实践。

我将把我的文件保存为public/data/scatter.csv中的scatter.csv

price,carat
1749,0.51
7069,2.25
2757,0.7
1243,0.47
789,0.3
728,0.33
...
...

types.ts

对于我的类型脚本数据,我将设置与我的 CSV 列相同的名称:pricecarat度量。

// src/component/BasicScatterChart/types.ts

export namespace Types {
  export type Data = {
    price: number
    carat: number
  }
}

BasicScatterChart.tsx

现在我准备开始绘制我的图表。

// src/component/BasicScatterChart/BasicScatterChart.tsx

import React, { useEffect } from 'react'
import './BasicScatterChart.scss'
import * as d3 from 'd3'
import { Types } from './types'

const BasicScatterChart = (props: IBasicScatterChartProps) => {
  useEffect(() => {
    draw()
  })  const draw = () => {
    const width = props.width - props.left - props.right
    const height = props.height - props.top - props.bottom

    const svg = d3
      .select('.basicScatterChart')
      .append('svg')
      .attr('width', width + props.left + props.right)
      .attr('height', height + props.top + props.bottom)
      .append('g')
      .attr('transform', `translate(${props.left},${props.top})`)

    d3.dsv(',', '/Data/diamonds.csv', (d) => {
      return {
        price: d.price,
        carat: d.carat,
      }
    }).then((data) => {

一旦数据准备就绪,我将创建 x 轴和 y 轴外设。第一步是找出价格和克拉的最高值,然后我可以将其设置为我的 axis max 值。

const maxPrice = Math.max(...data.map((dt) => (dt as unknown as Types.Data).price), 0)
const maxCarat = Math.max(...data.map((dt) => (dt as unknown as Types.Data).carat), 0)

接下来,我可以使用d3.scaleLinear来设置我的 x 轴和 y 轴。

      const x = d3.scaleLinear().domain([0, 18000]).range([0, width])
      svg.append('g').attr('transform', `translate(0,${height})`).call(d3.axisBottom(x))

      const y = d3.scaleLinear().domain([0, 4.5]).range([height, 0])
      svg.append('g').call(d3.axisLeft(y))

最后一部分是使用一个 circle SVG 元素绘制圆点,这个元素带有一种填充颜色,我将从父组件传递过来。我将我的半径设置为 1px,因为我有这么多的结果,但是你可以用更小的结果来尝试。

      svg
        .append('g')
        .selectAll('dot')
        .data(data)
        .enter()
        .append('circle')
        .attr('cx', (d) => {
          return x(((d as unknown) as Types.Data).price)
        })
        .attr('cy', (d) => {
          return y(((d as unknown) as Types.Data).carat)
        })
        .attr('r', 0.8)
        .style('fill', props.fill)
    })
  }

  return <div className="basicScatterChart" />
}

interface IBasicScatterChartProps {
  width: number
  height: number
  top: number
  right: number
  bottom: number
  left: number
  fill: string
}

export default BasicScatterChart

App.tsx

BasicScatterChart组件添加到我的App.tsx中。

// src/App.tsx

import BasicScatterChart from './components/BasicScatterChart/BasicScatterChart'

<BasicScatterChart width={800} height={400} top={10} right={50} bottom={50} left={50} fill="tomato" />

最后,如果您之前没有这样做,请创建BasicScatterChart.scssBasicScatterChart.test.tsx

  • 这只是 SCSS 的一个占位符。

  • BasicScatterChart.test.tsx:这个跟BasicPieChart.test.tsx一样。

现在,我们看到了钻石每克拉的历史价格,如图 4-5 所示。

img/510438_1_En_4_Fig5_HTML.jpg

图 4-5

React 和 D3 散点图

这张图表可以让我一目了然地看到价格范围,我可以看到每颗钻石的克拉大小和价格。如果我想改进图表,我可以插入其他字段,如钻石颜色的等级度量,并在图表上给出这些不同的颜色。我可以每年改变图表并过滤数据。

现在我已经准备好了所有三个图表,我将最后一次运行 format、lint 和 test 任务,以确保质量。

$ yarn format
$ yarn lint
$ yarn test

把你的结果和我的比较一下。

✨ Done in 1.97s.
$ yarn lint
yarn run v1.22.10
$ eslint — ext .js,.jsx,.ts,.tsx ./
✨ Done in 10.14s.
Test Suites: 7 passed, 7 total
Tests: 7 passed, 7 total
Snapshots: 0 total
Time: 9.476s

参见图 4-6 。

img/510438_1_En_4_Fig6_HTML.jpg

图 4-6

基本图表组件的测试结果

在“我的 d3 和 react 交互”课程中,你将学习如何包含交互方式线和设置调整大小,以及设置组件以便 React 更好地控制 DOM,参见 https://elielrom.com/BuildSiteCourse

设置在鼠标移动事件时移动的交互式平均线可以帮助用户更好地阅读结果,并快速预测每克拉的价格。

直方图

到目前为止,我们创建的图表相对简单。

在环形饼图中,我们确实检查了数据是否发生了变化,并且我们在函数组件状态中存储了新的数据集,但是我们没有在图表中实现任何变化。

我的目的只是向您展示如何将 D3 集成到 React 组件中,该组件使用 TypeScript 作为类型检查器,并尽可能多地使用 D3。

本章中我们将创建的最后一个图表是一个基本的直方图,它将包括来自用户、交互和动画的输入。这次我们将使用类组件,因此您可以看到如何使用 React 类组件内置的挂钩将动画和更改联系在一起。

直方图是由价格和时间指标组成的条形图。直方图是显示值落入范围的频率的常用方法。

直方图将数字数据分组到多个条块中。那么容器可以显示为分段的列。

我们将建立一个图表,回顾以太币的价格随时间的变化,这样你就能以一种简单直观的方式看到硬币的销售价格。

types.ts

对于 TypeScript 类型,我创建了两种类型。第一种类型用于数据(Data),该对象保存硬币的价格。我将使用的第二种类型(BarsNode)是处理条形,以及重新绘制这些条形。看一看:

//src/component/Histogram/types.ts

export namespace Types {
  export type Data = {
    price: number
  }
  export type BarsNode = {
    x0: number
    x1: number
    length: number
  }
}

接下来,对于实际的直方图组件,我将再次创建三个文件。这里没有什么新东西:

  • Histogram.tsx:自定义组件

  • Histogram.scss:风格

  • Histogram.test.tsx有一个测试

直方图. tsx

在直方图组件中,我将设置一个滑块。用户输入将决定显示多少条。一旦用户使用滑块输入选择,我就可以重新绘制图表。对于滑块,我将使用 Material-UI slide 组件,所以除了所有常用的导入之外,让我们导入滑块。此外,我将使用 Material-UI 中的排版模块来绘制我的图表。

// src/component/Histogram/Histogram.tsx

import React from 'react'
import './Histogram.scss'
import * as d3 from 'd3'
import Slider from '@material-ui/core/Slider'
import { Typography } from '@material-ui/core'
import { Types } from './types'

对于类签名,我将使用类纯组件(React.PureComponent)而不是React.Component,因为我不需要使用shouldComponentUpdate事件生命周期。

我的props和状态props接口将包含调整图表的属性。

export default class Histogram extends React.PureComponent<IHistogramProps, IHistogramState> {
  constructor(props: IHistogramProps) {
    super(props)

我的状态将由用户想要画多少刻度(条)组成;起始状态是 10。

    this.state = {
      numberOfTicks: 10,
    }
  }

接下来,一旦用户对滑块进行了更改,我们需要重新绘制图表。为此,我们使用了 Material-UI slider change 事件;然而,由于 React 虚拟 DOM 的工作方式,这并不保证我们的图表会得到更新。最好的方法是除了在初始渲染时调用的componentDidMount之外,还使用componentDidUpdate

  componentDidMount() {
    this.draw()
  }

  componentDidUpdate(prevProps: IHistogramProps, prevState: IHistogramState) {
    this.draw()
  }

现在我们也可以使用getDerivedStateFromProps代替componentDidUpdate,但是这个方法可能会在一次更新中被调用多次,所以我们需要放置一个验证器来检查状态是否被更新。

在更新发生后立即被调用。初始呈现时不调用此方法。

避免任何副作用是很重要的,所以您应该使用componentDidUpdate,它只在组件更新后执行一次。

一旦滑块改变,我们需要用我们想要显示的刻度数的新值来更新我们的状态,这发生在handleChange方法中。事件的类型为React.ChangeEvent。我还可以传递作为更新结果的新值。

  handleChange = (event: React.ChangeEvent<{}>, newValue: number | number[]) => {

一旦该事件被调用,我就可以将状态设置为numberOfTicks。我将绑定numberOfTicks,因此更新将会发生。

    const value = newValue as number
    this.setState((prevState: IHistogramState) => {
      return {
        ...prevState,
        numberOfTicks: value,
      }
    })
  }

重物的提升是用拉的方法完成的。我可以将这段代码更多地分解成一个助手类,但是这个例子并不太复杂。

我使用d3.selectAllhistogramChart设置为包装元素。

  draw = () => {
    const histogramChart = d3.selectAll('.histogramChart')

接下来,我将从图表中清除 x 和 y,因为它们可能会改变。这在第一次绘制时不需要,但在重新绘制时需要。为此,我使用了removeremove将删除我的主包装器下的所有组元素。

   d3.selectAll('.histogramChart').selectAll('g').remove()

一旦移除了这些条,我将为 x 轴和 y 轴创建一个新的 group SVG 元素,并将其添加到histogramChart group 元素中。

    const xAxisGroupNode = histogramChart.append('g')
    const yAxisGroupNode = histogramChart.append('g')

接下来,让我们初始化并缩放 x 轴。我在烘烤的价值,但他们可以动态设置。

    const xAxis = d3.scaleLinear().domain([75, 650]).range([0, this.props.width])

然后,我可以画出 x 轴。

    xAxisGroupNode.attr('transform', `translate(0,${this.props.height})`).call(d3.axisBottom(xAxis))

y 轴也是一样:初始化,缩放,然后绘制。

    const yAxis = d3.scaleLinear().range([this.props.height, 0])

我可以利用d3.bin ( https://github.com/d3/d3-array ),将数据点分组到桶中。我们可以为直方图设置数据、域和参数。我的领域数据在 0-750 之间,所以我正在烘烤它。

    const histogram = d3
      .bin()
      .value((d) => {
        return ((d as unknown) as Types.Data).price
      })
      .domain([0, 750])
      .thresholds(xAxis.ticks(this.state.numberOfTicks))

接下来,将此函数应用于数据以获得箱:

    const bins = histogram(this.props.data as Array<never>)

一旦我们设置了域并绘制了图表,y 轴将会更新这些值。

    const yAxisMaxValues = d3.max(bins, (d) => {
      return d.length
    }) as number
    yAxis.domain([0, yAxisMaxValues])

接下来,画 y 轴。

    yAxisGroupNode.transition().duration(750).call(d3.axisLeft(yAxis))

对于条形节点,我们用 bin 数据连接矩形,处理条形以及我们正在重画的新条形。

    const barsNode = histogramChart.selectAll<SVGRectElement, number[]>('rect').data(bins)

    const { height } = this.props

    barsNode
      .enter()
      .append('rect')
      .merge(barsNode) // get existing elements
      .transition() // apply changes
      .duration(750)
      .attr('transform',  (d) => {
        // @ts-ignore
        return `translate(${xAxis(d.x0)},${yAxis(d.length)})`
      })
      .attr('width', (d) => {
        return xAxis((d as Types.BarsNode).x1) - xAxis((d as Types.BarsNode).x0) - 1
      })
      .attr('height', (d) => {
        return height - yAxis(d.length)
      })
      .style('fill', this.props.fill)

最后,如果因为变更而出现额外的小节,我们需要删除它们。

    barsNode.exit().remove()
  }

jsx很简单。

然而,这一次我将使用 Material-UI 排版组件添加标题和标签,以包含我们的文本标签和 SVG 来保存<g>元素和一个 Material-UI 滑块。

我还利用父组件设置的props来整齐地对齐图表。

  render() {
    const { width, height, margin } = this.props
    return (
      <div className="histogram">
        <Typography id="discrete-slider" gutterBottom>
          2020 Eth Price days/price Histogram Chart
        </Typography>
        <svg height={height + margin.top + margin.bottom} width={width + margin.left + margin.right}>
          <text x={margin.left - 35} y={margin.top - 10} fontSize={10}>
            Days
          </text>
          <text x={width + margin.left + 20} y={height + margin.top + 16} fontSize={10}>
            Price
          </text>
          <g className="histogramChart" transform={`translate(${margin.left},${margin.top})`} />
        </svg>
        <div className="sliderDiv">
          <Typography id="discrete-slider" gutterBottom>
            Number of ticks:
          </Typography>
          <Slider
            defaultValue={this.state.numberOfTicks}
            getAriaValueText={(value: number) => {
              return `${value} ticks`
            }}
            valueLabelDisplay="auto"
            min={10}
            max={85}
            onChange={this.handleChange}
          />
        </div>
      </div>
    )
  }
}

该接口将保存数据和对齐属性。

interface IHistogramProps {
  data: Types.Data[]
  margin: {
    top: number
    right: number
    bottom: number
    left: number
  }
  width: number
  height: number
  fill: string
}

状态包含要显示的刻度数。

interface IHistogramState {
  numberOfTicks: number

}

直方图. scss

在我的 SCSS 中,我将为div设置一些填充,并为滑块和 SVG 文本颜色设置属性。

.histogram {
  padding-top: 50px;
}
.sliderDiv {
  width: 400px;
  padding-left: 50px;
  padding-top: 20px;
}
svg text {
  fill: white;
}

App.tsx

最后,我在App.tsx中加入了直方图组件。

在您看到的饼图中,数据是从父组件通过props传递的。

正如我提到的,我们希望从图表组件中提取数据,以备数据在多个组件之间共享。

在这里的图表中,我使用的是d3.dsv。但是,我将数据从App.tsx传递到子组件直方图。看一看:

import React, { useEffect } from 'react'
import './App.scss'
import * as d3 from 'd3'
import Histogram from './components/Histogram/Histogram'
import { Types } from './components/Histogram/types'

function App() {

我正在使用函数状态,所以一旦数据被更新,它将自动反映在直方图组件上。我的数据类型是类型number[],因为我将用价格度量设置一个数组。对于初始值,我可以用([{ 'price': 0 }])

  const [data, setData] = React.useState([{ 'price': 0 }] as Types.Data[])
  useEffect(() => {

在每次渲染时会被多次调用,所以我想限制只加载一次数据。

为此,我可以检查数据是否只有我设置的初始值。由于我用一个数组和一个结果设置了初始值,所以结果比那个多(data.length <= 1),可以检索数据。

    if (data.length <= 1) {
      d3.dsv(',', '/data/historicalPrice.csv', (d) => {
        return {
          price: d.open as unknown as number
        }
      }).then((d) => {

我使用 react set状态机制来设置数据。

        setData(d)
      })
    }
  })
  return (
    <div className="App">
      <header className="App-header">

在渲染中,我用props设置了直方图组件。

        <Histogram data={data} margin={{ top: 20, right: 45, bottom: 20, left: 50 }} width={400} height={400} fill="tomato" />
      </header>
    </div>
  )
}

export default App

正如我们之前所做的,使用 format、lint 和 test 功能来确保质量。

$ yarn format & yarn lint & yarn test

图 4-7 显示了最终结果。

img/510438_1_En_4_Fig7_HTML.jpg

图 4-7

显示以太币分组价格的直方图

看一下图表,似乎在 2020 年的大部分时间里,以太坊的价格要么是 225 美元(约 50 天),要么是 400 美元(约 37 天)。

这就是图表的力量。只要看一眼图表,我就能了解这个故事。

注意,我在本章中使用的图表是基于投资工具的,但我不建议投资本书中的任何股票或硬币。

您可以从这里下载直方图组件的完整代码:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch04-05/histogram-d3-ts

摘要

本章是上一章的延续,在这一章中,我介绍了如何使用 TypeScript 作为类型检查器,用 React 和 D3 创建一些简单的图表。

我向您展示了如何使用 React 函数和类组件创建下面的简单图表,重点是让 D3 完成大部分工作:

  • 圆形分格统计图表

  • 散点图

此外,我还向您展示了如何通过集成 D3 和 React 以及添加更多 React 库来创建直方图。

我们使用其他 React 库,如 Material-UI,并从父组件中检索数据,这样数据就可以在多个组件之间共享。

在我的 d3 和 React 交互课程中,你可以看到用不同的方法实现本章所有例子的其他方法。互动课程涵盖了本节的更多主题,例如,对 DOM、交互性、设计的更多控制,以及对本章内容的扩展。该课程灵活地补充了本章和本书; https://elielrom.com/BuildSiteCourse

在下一章中,我们将把 React 状态管理集成到 mix 中,这样我们就可以在整个应用中共享我们的数据,甚至可以与不是来自同一个父组件的多个组件共享。

五、集成状态管理

在前一章中,我向您展示了如何使用 React 函数和类组件以及 D3 创建简单的图表。在最后一个例子中,我们通过集成 D3 和 React 以及添加其他 React 库(如 Material-UI 和 Jest)创建了一个直方图。

在直方图中,我们从App.tsx父组件中检索数据,因此数据可以在多个组件之间共享。

在这一章中,我们将进一步发展这个图表。我们将把 React 状态管理集成到这个组合中,这样我们就可以在我们的应用中共享我们的数据,甚至可以在不是来自同一个父组件的多个组件中共享我们的数据。这将使用状态管理来完成。

在这一章中,您将了解由脸书引入的状态管理体系结构 Flux,然后您将了解来自脸书的新的实验性状态管理体系结构反冲。在这个过程中,我将向您展示如何向图表添加结构,这可以帮助您构建更复杂的图表,我们甚至将集成一个使用相同数据的表列表组件。

状态管理

数据本身的一个变化听起来无关紧要,对于您的组件来说,实现和管理足够简单。那么为什么我们需要一个状态管理库来完成这个任务呢?

通俗地说,状态管理帮助组织你的 app 的数据和用户交互,直到用户的会话结束。它还有助于确保您的代码不会因为添加了更多功能而变得混乱。

它使测试变得更加容易,并确保代码不依赖于特定的开发技术,并且可以扩展。

Note

状态管理是一种在用户会话结束前维护应用状态的方法。

如果你看看我们在前几章创建的图表,我们没有问题,也不需要设计模式来帮助我们管理数据移动。事实上,实现一个架构来控制我们的数据移动,对于这样简单的功能来说,可能会被视为矫枉过正。我们使用的状态是一旦接收到数据就保存它,并且一切正常。

然而,随着我们的代码增长,我们的应用变得更大,有多个开发人员和设计人员,我们需要某种架构来帮助处理数据移动,并实施最佳实践来帮助管理我们的代码,以便它不会随着每次更改而中断。

事实上,脸书遇到了这些挑战,并寻找解决这些问题的方法。

流量

脸书团队首先尝试了一些已经存在的工具。他们首先实现了模型-视图-控制器(MVC)模式;然而,他们发现随着越来越多的特性被添加,架构模式会导致问题,并且由于代码经常出错,一部分代码更难维护。

React 团队在使用 MVC 模式分离关注点和管理前端状态时遇到的挑战最终导致了 Flux 的产生。

重要的是要知道 Flux 状态管理正在被逐步淘汰,项目处于维护模式。还有许多更复杂的选择。

MVC 解决什么?

在复杂的应用中,MVC 模式是分离关注点的常见实践。

  • 模型:模型是应用中使用的数据。

  • 视图:视图是前端的表示层。

  • 控制器:这是绑定模型和视图的胶水。

脸书团队解释说,当开发人员尝试使用 MVC 时,他们遇到了可能导致循环的数据流问题,这可能会导致应用崩溃,因为它会成为内存泄漏(嵌套更新的级联效应),并不断更新渲染。

这些挑战被脸书团队解决了,他们推出了一个名为 Flux 的架构,最近又推出了一个名为反冲的实验库。

Note

Flux 是一个用于构建用户界面的应用架构。 https://facebook.github.io/flux/

“Flux 是脸书用来构建客户端 web 应用的应用架构。它通过利用单向数据流来补充 React 的可组合视图组件。它更多的是一种模式,而不是一个正式的框架。”

https://facebook.github.io/flux/docs/in-depth-overview

从我个人的经验来看,我曾经使用过许多大大小小的基于 MVC 的应用,我不得不有点不同意脸书团队的观点。其中一些项目是构建在 MVC 基础上的非常复杂的企业级应用,通过实施良好的习惯,基于 MVC 的应用可以无缝地工作。也就是说,在许多 MVC 框架的实现中涉及到大量的样板代码,并且经常需要进行代码审查来加强良好的习惯并保持关注点的分离。

脸书的 Flux 架构确实简化了分离关注点的过程,并且是一种新鲜的、受欢迎的状态管理替代方案,同时保持了较少的样板代码和松散耦合的组件。您可以在此了解更多关于 Flux 的信息:

https://github.com/facebook/flux

https://facebook.github.io/flux/

Flux 正在被淘汰,但是还有其他几个状态管理库。

报应

Redux(和 Redux 工具包)是编写本文时最流行的状态管理库。如果你想了解更多关于 Redux 的知识,我推荐你在 https://www.apress.com/gp/book/9781484266953 购买我的 React 和 Libraries 书,或者在 https://medium.com/react-courses/instance-learn-react-redux-4-redux-toolkit-in-minutes-a-2020-reactjs-16-tutorial-9adaec6f2836 阅读我的文章。

与 Redux 或 Redux 工具包不同,使用反冲,不需要设置复杂的中间件、连接您的组件或使用任何其他东西来使 React 组件相互之间很好地配合。

Did you know?

反冲库仍处于实验阶段,但它已经获得了一些非凡的人气,甚至超过了 Redux。反冲库在 GitHub 上有接近 10000 颗星,超过了 Redux 工具包的 4100 颗星!

我和许多其他人都认为,反冲将成为 React 中状态管理的标准,这是比继续利用 Redux 工具包 进行中间件开发更好的投资。

但是,请记住,了解 Redux 工具包仍然是很好的,因为您可能会参与到使用 Redux 的项目中。此外,反冲仍然是实验性的,因为这本书的写作,所以它不是为心脏的微弱。

为了了解反冲,我们将重构我们在前一章创建的Histogram组件。

反冲是脸书改变生活的状态管理实验,正在席卷 React 开发者社区。后坐力团队说的很好:

“后坐力的工作方式和思考方式都像 React。添加一些到您的应用中,获得快速灵活的共享状态。”

反冲是在有许多状态管理库的时候开发和发布的,所以你可能会问为什么我们还需要另一个状态管理来共享我们的应用状态。使用反冲可以更好、更容易地在多个组件之间共享状态和设置中间件吗?快速回答是肯定的!

如果你需要做的只是全局存储值,你选择的任何库都可以;然而,当您开始做更复杂的事情时,事情就变得复杂了,比如异步调用,或者试图让您的客户端与您的服务器状态同步,或者反向用户交互。

理想情况下,我们希望我们的 React 组件尽可能纯净,并且数据管理需要在没有副作用的情况下通过 React 钩子。我们还希望“真正的”DOM 为了性能而尽可能少地改变。

保持组件松散耦合对于开发人员来说总是一个好地方,因此拥有一个与 React 很好集成的库是对 React 库的一个很好的补充,因为它将 React 与 Angular 等其他顶级 JavaScript 框架放在一起。

拥有固态管理库将有助于 React 应用服务于企业级复杂应用,以及处理前端和中间层的复杂操作。反冲简化了状态管理,我们只需要创建两个成分:原子和选择器( https://recoiljs.org/docs/introduction/core-concepts/ )。

原子是物体。它们是组件可以订阅的状态单元。反冲让我们创建一个从这些原子(共享状态)流向组件的数据流图。

选择器是纯粹的函数,允许你同步或者异步地转换状态。

请记住,您不必创建原子和选择器。您可以只使用没有任何原子的选择器,也可以创建没有选择器的原子。

为了展示如何开始反冲,我将把这个过程分为两个步骤。

  • 步骤 1 :实施反冲

  • 第二步:重构视图层

为了开始,我们通常首先需要安装反冲(yarn add recoil)。在撰写本文时,反冲的版本是 0.1.2,但在您阅读本章时,这种情况将会改变。然而,我们的 CRA MHL 模板已经包括反冲和 Redux,所以反冲已经设置好了,不需要您的任何安装。

历史价格状态

我们开始吧。

ts:设置我们的数据类型

在我们的直方图中,我们创建了types.ts。该类保存我们在图表中使用的类型。这种类型的架构很棒,因为它允许我们复制我们的组件,并在任何我们想要的地方重用它,保持我们的代码松散耦合。

然而,反冲也需要一个定义。我可以只导入 types 类,但是这会在我们的状态和图表之间创建一个组合。

如果我有多个使用相同数据的图表,这就不理想了,因为我们需要导入类型。

Note

我的决定是创建一个模型类,我可以用它来初始化我的对象,并为 price 对象提供一个接口。这种设计不是强制性的;这取决于你需要什么。如果你能删除代码,并且一切正常,易于理解,那就继续删除代码吧。我只是让你在这里开始。

看一看:

// src/model/historicalPriceObject.ts

export interface historicalPriceObject {
  price: number
}

export const initHistoricalPrice = (): historicalPriceObject => ({
  price: 0,
})

Note

就像他们说的,有很多方法可以剥一只猫的皮。每种方法都有利弊;你需要判断这是否适合你。

index.ts:易于访问

接下来,建立一个索引文件,以便于访问我们的类型。

// src/model/index.ts
export * from './historicalPriceObject'

历史价格原子:共享状态

现在我有了模型对象,我可以创建反冲原子了。

正如我提到的,反冲原子是物体。它们是组件可以订阅的状态单元。反冲让我们创建一个从这些原子(共享状态)流向组件的数据流图。我可以在我的 Atom 中使用我的模型,如下面的代码所示。我们从反冲库中导入原子,以及我们在上一步中创建的模式initHistoricalPrice来设置默认值。

// src/recoil/atoms/historicalPriceAtoms.ts

import { atom } from 'recoil'
import { initHistoricalPrice } from '../../model'

export const historicalPriceState = atom({
  key: 'historicalPriceState',
  default: initHistoricalPrice(),
})

反冲中的键应该是唯一的键。一个好的做法是将密钥命名为与文件名相同的名称。因为所有的原子可以存在于同一个目录中,src/recoil/atoms/,我们不能有相同名称的重复文件名,所以这将确保我们的键是唯一的。

ts:转变我们的异步状态

反冲的第二个要素是选择器。选择器是纯函数,允许您同步或异步地转换状态。在我们的例子中,我们可以使用相同的d3.dsv代码从 CSV 文件中检索价格。

就像反冲原子一样,我们的选择器需要一个惟一的键,我正在进行一个异步调用并设置一个承诺,因为我不想停止我的代码。

一旦检索到数据,我将它转换为我的类型historicalPriceObject[],并使用 promise resolve 返回数据。

看一看:

//src/model/historicalPriceSelectors.ts

import { selector } from 'recoil'
import * as d3 from 'd3'

import { historicalPriceObject } from '../../model'

export const getHistoricalPriceData = selector({
  key: 'getHistoricalPriceData',
  get: async () => {
    return getData()
  },
})

const getData = () =>
  new Promise((resolve) =>
    d3
      .dsv(',', '/data/historicalPrice.csv', function results(d) {
        return {
          price: d.open,
        }
      })
      .then(function results(data) {
        resolve((data as unknown) as historicalPriceObject[])
      })
  )

注意,TS 不知道我们有什么类型的数据,所以我将把我的数据强制转换为historicalPriceObject

(data as unknown) as historicalPriceObject[]

HistogramWidget:自定义组件

在前一章中,我们将App.tsx作为检索数据的父组件,将Histogram.tsx作为图表组件。

我要做的是添加另一个组件。姑且称之为小部件吧。小部件组件可以处理数据,在加载数据时设置加载器,并处理使用相同数据或不同数据的其他潜在组件。图 5-1 显示了组件的高层图。

img/510438_1_En_5_Fig1_HTML.jpg

图 5-1

直方图小部件图

这种架构设计让我能够为接下来发生的事情做好准备。例如,假设我们想添加一个显示一段时间内价格的列表或另一个使用相同数据的图表。

带反冲的直方图

我们做一个带反冲的直方图。

HistogramWidget.tsx:自定义组件

根据 HistogramWidget 组件,我将创建三个文件。

  • Graph.tsx:组件

  • Graph.scss:风格

  • Graph.test.tsx : Jest 测试

CRA·MHL 有一个现成的库来帮助创建模板,它已经配置了有助于更快完成工作的组件。只需运行下面的npx命令,使用我创建的反冲模板生成图形文件..

$ npx generate-react-cli component HistogramWidget --type=recoil

您应该会得到以下输出:

Stylesheet "HistogramWidget.scss" was created successfully at src/components/Graph/HistogramWidget.scss
Test "HistogramWidget.test.tsx" was created successfully at src/components/Graph/HistogramWidget.test.tsx
Component "HistogramWidget.tsx" was created successfully at src/components/Graph/HistogramWidget.tsx

小部件代码将检索我们在反冲选择器中设置的数据,并呈现图表。

初始代码为我们提供了创建加载机制的框架。我们使用useRecoilValue提取数据,然后更新视图。

const HistogramWidget= () => {
  const results: useRecoilValue( getMethod )
  useEffect(() => {
    // TODO
  })
  return (

      {results ? (
        <>Loaded
      ) : (
        <>Loading
      )}

  )
}
export default HistogramWidget

现在我们插入将检索数据的方法,getHistoricalPriceData,以及我们接下来将创建的带有一些propsHistogram组件来对齐它。我们的HistogramWidget.tsx会是这样的样子。:

// src/widgets/HistogramWidget/HistogramWidget.tsx

import React, { useEffect } from 'react'
import './HistogramWidget.scss'
import { useRecoilValue } from 'recoil'
import { getHistoricalPriceData } from '../../recoil/selectors/historicalPriceSelectors'
import { historicalPriceObject } from '../../model'
import Histogram from '../../components/Histogram/Histogram'

const HistogramWidget = () => {

为了检索结果,我们useRecoilValue调用选择器并转换对象。

  const results: historicalPriceObject = useRecoilValue(getHistoricalPriceData) as historicalPriceObject

这段代码类似 React 的useState,非常直观。这就是后坐力发光的原因。

在渲染方面,我检查是否有。结果已经显示,或者显示直方图组件,或者显示“正在加载”的消息为此,我将使用jsx条件内联。看一看:

  return (
    <>
      {results?.length > 0 ? (
        <>
          <Histogram data={results} margin={{ top: 20, right: 45, bottom: 20, left: 50 }} width={400} height={400} fill="tomato" />

      ) : (
        <>Loading!
      )}

  </>
  )
}
export default HistogramWidget

数据绑定在Histogram组件上,由于我的两个对象Types.Data[]historicalPriceObject[]是相同的,TypeScript 不会抱怨。

这里我只是使用了一个加载消息,但这可以是任何组件、动画或图像。

直方图 scss

我不需要任何 SCSS 风格,所以只要保持HistogramWidget.scss。作为占位符。

.histogramWidget {
}

Graph.test.tsx

我们的 Jest 测试使用反冲有点不同。投保全险是个好习惯。

我保持我的测试简单,只是检查组件被安装。为此,我需要将我的反冲放在<RecoilRoot>标签中。

// src/component/HistogramWidget/HistogramWidget.test.tsx

import React from 'react'
import { shallow } from 'enzyme'
import { RecoilRoot } from 'recoil'
import Graph from './Graph'

describe('<HistogramWidget />', () => {
  let component

  beforeEach(() => {
    component = shallow(
      <RecoilRoot>
        <HistogramWidget />
      </RecoilRoot>
    )
  })

  test('It should mount', () => {
    expect(component.length).toBe(1)
  })
})

App.tsx

最后,一切准备就绪。我可以删除检索数据的useEffect代码,只放置我的小部件。

// src/App.tsx

import React from 'react'
import './App.scss'
import HistogramWidget from './components/HistogramWidget/HistogramWidget'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <HistogramWidget />
      </header>
    </div>
  )
}

export default App

见图 5-2 。

img/510438_1_En_5_Fig2_HTML.jpg

图 5-2

带反冲的直方图

价格表列表组件

在本章的这一节中,我将向您展示我们现在如何能够包含另一个组件,它可以与我们的直方图共享相同的数据。

我们将创建一个表格列表,在图表旁边显示日期和价格。

组件将使用 Material-UI。我为您设置了一个入门组件,或者您可以从头开始。

$ npx generate-react-cli component PriceTableList --type=materialui

types.ts

对于数据类型,我正在为我的PriceTableList.tsx组件创建另一个types.ts

这可能看起来有点过了,因为现在我在每个组件中都有两个相同的类型。然而,对我来说重要的是,我能够在未来的项目中借用这些组件,编写几行代码是一个很小的代价。

// src/component/PriceTableList/types.ts

export namespace Types {
  export type Data = {
    price: number
  }
}

PriceTableList.tsx

PriceTableList.tsx组件将使用makeStyle为根容器和表格组件创建样式。

我们将使用 Material-UI 中的TableBodyTableCellTableContainerTableRowPaper组件,因此它们需要被导入。

为了更好地理解材质-用户界面表,请看一下材质-用户界面文档:

https://material-ui.com/components/tables/

代码如下:

// src/components/PriceTableList/PriceTableList.tsx

import React from 'react'
import { makeStyles } from '@material-ui/core/styles'
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableHead from '@material-ui/core/TableHead'
import TableRow from '@material-ui/core/TableRow'
import Paper from '@material-ui/core/Paper'
import { Types } from './types'
import './PriceTableList.scss'

使用 Material-UI 中的makeStyles,您可以为每个组件设置一个样式。对我来说,我希望容器包装的最大高度为 400 像素,因为列表和表格很长。我也可以在这里设置样式。

const useStyles = makeStyles({
  root: {
    maxHeight: 400,
  },
  table: {
    minWidth: 650
  },
})

我的函数组件将包括IPriceTableListProps prop 接口,该接口将包括表格文本的数据和颜色。

const PriceTableList = (props: IPriceTableListProps) => {

我们设置const来使用样式。

  const classes = useStyles()

在渲染时,我使用我设置的 Material-UI 样式创建了TableContainer和表格。

  return (
    <TableContainer className={classes.root} component={Paper}>
      <Table className={classes.table} aria-label="simple table">
        <TableHead>
          <TableRow>

对于表头和行,我使用了一个自定义样式,我将在 SCSS 文件中创建该样式来设置背景以及从父组件传递过来的文本颜色。

            <TableCell className="priceTableListTableCellHead" style={{ color: props.textColor }}>
              Day
            </TableCell>
            <TableCell className="priceTableListTableCellHead" style={{ color: props.textColor }}>
              Price
            </TableCell>
          </TableRow>
        </TableHead>
        <TableBody>

为了遍历数据,我可以使用map方法传递价格值,并创建一个索引来设置天数。

          {props.data.map((d: Types.Data, index: number) => (
            <TableRow key={d.price}>
              <TableCell className="priceTableListTableCell" style={{ color: props.textColor }} component="th" scope="row">
                {index + 1}
              </TableCell>
              <TableCell className="priceTableListTableCell" style={{ color: props.textColor }} component="th" scope="row">

为了显示价格,我可以通过添加一个美元符号来格式化文本,并将变量转换为一个字符串以作为浮点数进行解析,并且我设置了一个固定值 2(只保留两位数)。

                ${parseFloat((d.price as unknown) as string).toFixed(2)}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  )
}

export default PriceTableList

该接口保存数据类型和文本颜色。

interface IPriceTableListProps {
  data: Types.Data
  textColor: string
}

价格表列表

对于 SCSS 文件,我为标题行和实际行设置了两种不同的背景色。

.priceTableListTableCellHead {
  background-color: #343434;
}

.priceTableListTableCell {
  background-color: #515151;
}

就这样,我准备将PriceTableList.tsx组件集成到父组件HistogramWidget.tsx中。

HistogramWidget.tsx

HistogramWidget.tsx父组件的更改将是使用 Material-UI 网格并排设置我的两个组件,并添加我的PriceTableList.tsx组件。查看该文件(突出显示了更改):

// src/widgets/HistogramWidget/HistogramWidget.tsx

我需要导入网格组件和HistogramWidget.tsx

import React, { useEffect } from 'react'
import './HistogramWidget.scss'
import { useRecoilValue } from 'recoil'
import Grid from '@material-ui/core/Grid'
import { getHistoricalPriceData } from '../../recoil/selectors/historicalPriceSelectors'
import { historicalPriceObject } from '../../model'
import Histogram from '../../components/Histogram/Histogram'
import PriceTableList from '../../components/PriceTableList/PriceTableList'

const HistogramWidget = () => {
  const results: historicalPriceObject = useRecoilValue(getHistoricalPriceData) as historicalPriceObject
  return (

      {results?.length > 0 ? (

我的网格由两列组成。

          <Grid container spacing={5}>
            <Grid item xs={6}>
              <Histogram data={results} margin={{ top: 20, right: 45, bottom: 20, left: 50 }} width={400} height={400} fill="tomato" />
            </Grid>

对于价格表列表,我用一个div包装组件,以确保我们可以向下滚动,并且小部件可以控制组件的大小。

      <Grid item xs={6}>
              <div className="priceTableListDivWrapper">
                <PriceTableList data={results} textColor="white" />
              </div>
            </Grid>
          </Grid>

      ) : (
        Loading!
      )}

  )
}
export default HistogramWidget

直方图 scss

从小部件 SCSS,我需要为我的价格表列表的div包装添加样式。

.priceTableListDivWrapper {
  padding-top: 100px;
  width: 500px;
  height: 500px;
}

最后,和以前一样,记得运行formatlinttest命令以确保质量。

$ yarn format && yarn lint && yarn test

图 5-3 显示了最终结果。

img/510438_1_En_5_Fig3_HTML.jpg

图 5-3

具有列表材质 UI 组件和使用反冲的共享状态的直方图

查看我的 d3 和 React 交互课程,看看如何使用函数组件实现这个直方图,并使用钩子进行优化: https://elielrom.com/BuildSiteCourse

摘要

在这一章中,我谈到了由脸书引入的状态管理架构 Flux,并了解了来自脸书的名为反冲的新的实验性状态管理。

我们采用了上一章开发的直方图,并用反冲状态管理替换了 React 状态。使用反冲状态管理,我们能够跨应用和多个组件共享数据。

这个设计吸收了两个世界的精华,由 D3 的模块库和 React SPA 范例组成,前者帮助我们用数据可视化图表讲述故事,后者借助虚拟 DOM 确保页面只在发生变化时才呈现。

我们使用 Material-UI table list 组件来创建另一个组件并共享数据,我们将组件重组到一个小部件中,这样我们就可以轻松地集成加载共享数据的多个组件的逻辑。

现在您已经知道了如何使用 D3、图表和数据管理来创建定制的 React 组件。我鼓励你使用我给你的例子,插入数据,改变图表,并创建新的图表。这将帮助你获得宝贵的经验。

这本书的其余部分侧重于使用更复杂的图表以及优化和发布技术。

在下一章,我们将开始处理更复杂的图表。在接下来的两章中,内容将致力于创建和使用一种通用类型的图表,即世界地图。

六、世界地图:第一部分

世界地图是展示全球物品的好方法。将 D3 与 React 和 TS 集成在一起,可以创建使用所有工具中最好的可读代码。在这一章,我将向你展示如何创建一个旋转地图,并根据坐标分配点。

具体来说,在这一章中,我将向你展示如何使用 React、D3 和 TS 作为类型检查器来操作世界地图。我将把这个过程分成几个步骤。在每一步中,我将添加更多的功能,直到我们有了用点代表坐标的旋转世界地图。

我已经将组件分成了五个文件,所以很容易看到和比较变化。

  • 世界地图图册 : WorldMapAtlas.tsx

  • 圆形世界地图 : RoundWorldMap.tsx

  • 旋转圆形世界地图 : RotatingRoundWorldMap.tsx

  • 坐标为 : RotatingRoundWorldMapWithCoordinates.tsx的旋转圆形世界地图

  • 重构 : WorldMap.tsx

该项目可以从这里下载:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch06/world-map-chart

设置

项目设置很简单,使用 CRA 与 MHL 模板项目。

$ yarn create react-app world-map-chart --template must-have-libraries
$ cd world-map-chart
$ yarn start
$ open http://localhost:3000

安装其他所需的库和类型

我们需要另外四个库来开始。

  • d3-geo:我们将使用d3-geo进行地理投影(绘制地图)。 https://github.com/d3/d3-geo

  • topojson-client:这是一个操纵 TopoJSON 的客户端。TopoJSON 是提供世界地图的库,我可以用它来绘制地图。https://github.com/topojson/topojson-client见。

  • geojson:这是地理数据的编码格式。 https://geojson.org/。TopoJSON 文件属于“拓扑”类型,遵循 TopoJSON 规范。GeoJSON 将用于格式化地理数据结构的编码。 https://geojson.org/

  • react-uuid:创建一个随机 UUID,我们将在映射 React 组件时使用它作为所需的列表键。https://github.com/uuidjs/uuid见。

继续用 Yarn 安装这些库:

$yarn add d3-geo @types/d3-geo
$yarn add topojson-client @types/topojson-client
$yarn add geojson @types/geojson
$yarn add react-uuid

最后,下载世界地图集的数据。数据由 TopoJSON 提供,其中包含预构建的状态数据( https://github.com/topojson/world-atlas )。下面是我将使用的实际 JSON:

https://d3js.org/world-110m.v1.json

将文件放在公共文件夹中以便于访问:/public/data/world-110m.json

世界地图图册

我将创建的第一个地图只是一个平面世界地图集类型的地图,将显示世界。

世界地图图册. tsx

自己创建文件或使用generate-react-cli

$ npx generate-react-cli component WorldMap --type=d3

正如我提到的,我将把组件作为单独的组件来创建,因此跟踪工作和比较变化将会很容易。第一档是WorldMapAtlas.tsx。以下是完整的组件代码:

// src/components/WorldMap/WorldMapAtlas.tsx
import React, { useState, useEffect } from 'react'
import { geoEqualEarth, geoPath } from 'd3-geo'
import { feature } from 'topojson-client'
import { Feature, FeatureCollection, Geometry } from 'geojson'
import './WorldMap.scss'

const uuid = require('react-uuid')

const scale: number = 200
const cx: number = 400
const cy: number = 150

const WorldMapAtlas = () => {
  const [geographies, setGeographies] = useState<[] | Array<Feature<Geometry | null>>>([])

  useEffect(() => {
    fetch('/data/world-110m.json').then((response) => {
      if (response.status !== 200) {
        // eslint-disable-next-line no-console
        console.log(`Houston we have a problem: ${response.status}`)
        return
      }
      response.json().then((worldData) => {
        const mapFeatures: Array<Feature<Geometry | null>> = ((feature(worldData, worldData.objects.countries) as unknown) as FeatureCollection).features
        setGeographies(mapFeatures)
      })
    })
  }, [])

  const projection

= geoEqualEarth().scale(scale).translate([cx, cy]).rotate([0, 0])

  return (
    <>
      <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
        <g>
          {(geographies as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={0.5}
            />
          ))}
        </g>
      </svg>

  )
}

export default WorldMapAtlas

我们来复习一下。

第一步,我们导入 React 和我们安装的库。我还创建了WorldMap.scss作为未来使用的样式占位符。

import React, { useState, useEffect } from 'react'
import { geoEqualEarth, geoPath } from 'd3-geo'
import { feature } from 'topojson-client'
import { Feature, FeatureCollection, Geometry } from 'geojson'
import './WorldMap.scss'

对于react-uuid库,TS 没有类型,所以我将使用require,这样 ESLint 就不会抱怨了。

const uuid = require('react-uuid')

接下来,我们设置地图比例和定位等属性。

const scale: number = 200
const cx: number = 400
const cy: number = 150

WorldMapAtlas设置为功能组件。这是一个偏好问题,我可以使用类组件。

至于状态的数据,我将客户端数据设置为州。一旦加载了数据,我就将 JSON 转换成可以呈现的特征几何数组。

  const [geographies, setGeographies] = useState<[] | Array<Feature<Geometry | null>>>([])

就类型而言,我必须通过钻取实际的geojson库来确定类型。

接下来,我将数据加载到useEffect钩子上。在本章的后面,我将重构这段代码,并把它移到父组件中,但现在我希望代码尽可能简单。这是我的工作地图:

  useEffect(() => {
    fetch('/data/world-110m.json').then((response) => {
      if (response.status !== 200) {
        console.log(`Houston we have a problem: ${response.status}`)
        return
      }
      response.json().then((worldData) => {

注意,我使用的是“fetch ”,然而,另一种方法是使用 d3.json 模块。D3 已经将对象格式化为 JSON,所以代码更少。

  useEffect(() => {
    d3.json('/data/world-110m.json').then((d) => { return d }).then((worldData) => {
        // @ts-ignore const mapFeature: Array<Feature<Geometry | null>> = (feature(worldData, worldData.objects.countries) as FeatureCollection).features setGeographies(mapFeature)
    })
  })

一旦得到响应,我就可以将 JSON 转换成一个Geometry特性数组,并将其设置为函数状态。

        const mapFeatures: Array<Feature<Geometry | null>> = ((feature(worldData, worldData.objects.countries) as unknown) as FeatureCollection).features
        setGeographies(mapFeatures)
      })
    })
  }, [])

用外行人的话来说,这个投影就是我希望我的实际地图集的样子。有很多选项可以选择(见 https://github.com/d3/d3-geo/blob/master/README.md )。让我们以geoEqualEarth作为第一次尝试。

  const projection = geoEqualEarth().scale(scale).translate([cx, cy]).rotate([0, 0])

为了呈现我的地图集,我将首先设置一个 SVG 包装器,它保存一个组元素,然后使用一个 map 遍历路径,通过我设置为 state 的geographies数据来绘制每个状态。

  return (
    <>
      <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
        <g>
          {(geographies as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={0.5}
            />
          ))}
        </g>
      </svg>
      </>

  )
}

export default WorldMapAtlas

注意,我正在使用key={path-${uuid()}}

键是标识唯一虚拟 DOM (VDOM) UI 元素及其相应数据的常见做法。如果不执行这一步,React VDOM 会在需要刷新 DOM 时感到困惑。这是最佳实践。您可以使用随机数,但请注意不要使用地图索引作为关键字,因为地图可能会发生变化,从而导致 VDOM 引用错误的项目。

按键帮助 React 识别哪些项目已经更改、添加或删除。应该给数组内部的元素赋予键,以给元素一个稳定的标识。

https://reactjs.org/docs/lists-and-keys.html

键和引用作为属性添加到一个React.createElement()调用中。它们通过回收 DOM 中的所有现有元素来帮助 React 优化渲染。

App.tsx

接下来,让我们将我们的WorldMapAtlas组件作为子组件添加到App.tsx中。

请注意,更改以粗体突出显示。

import React from 'react'
import './App.scss'
import WorldMapAtlas from './components/WorldMap/WorldMapAtlas'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <WorldMapAtlas />
      </header>
    </div>
  )
}

export default App

app . scss

对于App.scss样式,我将背景颜色改为白色。

.App-header {
  background-color: #ffffff;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

就是!参见图 6-1 。

img/510438_1_En_6_Fig1_HTML.jpg

图 6-1

简单世界地图图册

正如我提到的,对于投影,我使用了geoEqualEarth;但是,我可以很容易地将投影更改为其他投影。例如,如果我想换到geoStereographic,我的地图就会改变。见图 6-2 。

从此处更改投影:

import { geoEqualEarth, geoPath} from 'd3-geo'

const projection = geoEqualEarth().scale(scale).translate([cx, cy]).rotate([0, 0])

致以下内容:

img/510438_1_En_6_Fig2_HTML.jpg

图 6-2

使用地球立体投影的世界地图集

import { geoPath, geoStereographic } from 'd3-geo'
const projection = geoStereographic().scale(scale).translate([cx, cy]).rotate([0, 0])

另一个例子是geoConicConformal投影(图 6-3 )。

img/510438_1_En_6_Fig3_HTML.jpg

图 6-3

使用地理共形投影的世界地图集

const projection = geoConicConformal().scale(scale).translate([cx, cy]).rotate([0, 0])

圆形世界地图

现在我们知道如何绘制世界地图图册。接下来我们将看到如何创建一个圆形的世界地图。

要改变地图为圆形,我所要做的就是使用geoOrthographic投影。

为了让圆形地图看起来更好,我还打算使用 SVG circle 元素绘制一个圆形浅灰色背景。

RoundWorldMap.tsx

创建一个名为RoundWorldMap.tsx的新组件;查看此处突出显示的更改。

// src/components/WorldMap/RoundWorldMap.tsx

  const projection = geoOrthographic().scale(scale).translate([cx, cy]).rotate([rotation, 0])

  return (
    <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
      <g>
        <circle
          fill="#0098c8"
          cx={cx}
          cy={cy}
          r={scale}
        />
      </g>
      <g>
        {(geographies as []).map((d, i) => (
          <path
            key={`path-${uuid()}`}
            d={geoPath().projection(projection)(d) as string}
            fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
            stroke="aliceblue"
            strokeWidth={0.5}
          />
        ))}
      </g>
    </svg>
  )
}

记得更新App.tsx

return (
  <div className="App">
    <header className="App-header">
      <RoundWorldMap />
    </header>
  </div>
)

见图 6-4 。

img/510438_1_En_6_Fig4_HTML.jpg

图 6-4

环球地图图册

旋转圆形世界地图

现在我们有了一个圆形的世界地图图集,再加上动画和互动岂不是很棒?我们可以旋转图集,添加一个按钮开始动画。

AnimationFrame.tsx

要添加动画,我们可以调用 JavaScript 窗口requestAnimationFrame API ( https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame )。

requestAnimationFrame方法告诉浏览器我要执行一个动画,浏览器会调用我的回调函数,这样我就可以在下次重绘之前更新我的动画。

要使用requestAnimationFrame,我可以将下面的代码放在我的 React 组件中:

window.requestAnimationFrame(() => {
  // TODO
})

但是,更好的架构设计是使用useRef创建一个 hook 函数组件,并包装我的requestAnimationFrame。看一看:

// src/hooks/WindowDimensions.tsx
import { useEffect, useRef } from 'react'

export default (callback: (arg0: ICallback) => void) => {
  const frame = useRef()
  const last = useRef(performance.now())
  const init = useRef(performance.now())

  const animate = () => {
    const now = performance.now()
    const time = (now - init.current) / 1000
    const delta = (now - last.current) / 1000
    callback({ time, delta })
    last.current = now
    ;((frame as unknown) as IFrame).current = requestAnimationFrame(animate)
  }

  useEffect(() => {
    ((frame as unknown) as IFrame).current = requestAnimationFrame(animate)
    return () => cancelAnimationFrame(((frame as unknown) as IFrame).current)
  })
}

interface ICallback {
  time: number
  delta: number
}

让我们回顾一下代码。

我将回调作为参数传递。

export default (callback: (arg0: ICallback) => void) => {

接下来,我将使用performance.now() ( https://developer.mozilla.org/en-US/docs/Web/API/Performance/now )来跟踪帧。这个特性带来了一个一毫秒分辨率的时间戳,我可以用它来计算时间增量,以备不时之需。

Note

时间差是时间上的差异。

  const frame = useRef()
  const last = useRef(performance.now())
  const init = useRef(performance.now())

在每次调用requestAnimationFrame时,animate将返回当前时间戳。

  const animate = () => {
    const now = performance.now()
    const time = (now - init.current) / 1000
    const delta = (now - last.current) / 1000
    callback({ time, delta })
    last.current = now;
    (frame as unknown as IFrame).current = requestAnimationFrame(animate)
  }

然后我可以使用useEffect钩子来绑定animate方法。

我的效果需要在组件离开屏幕之前清理干净。为此,传递给useEffect的函数需要返回一个清理函数。在我的例子中,cancelAnimationFrame需要在useEffect返回回调中被调用。你可以在这里了解更多关于 React 特效和清理: https://reactjs.org/docs/hooks-reference.html

  useEffect(() => {
    (frame as unknown as IFrame).current = requestAnimationFrame(animate)
    return () => cancelAnimationFrame((frame as unknown as IFrame).current)
  })
}

RotatingRoundWorldMap.tsx

现在我们已经准备好将AnimationFrame钩子用于我们的动画,我可以添加我的旋转动画了。此外,我将从 Material-UI 添加一个图标按钮形式的用户手势来启动动画。

从我们之前的例子中复制RoundWorldMap.tsx文件,并将其保存为一个名为RotatingRoundWorldMap.tsx的新文件。看看这些来自RoundWorldMap.tsx的变化:

// src/components/WorldMap/RotatingRoundWorldMap.tsx

import PlayCircleFilledWhiteIcon from '@material-ui/icons/PlayCircleFilledWhite'
import { Button } from '@material-ui/core'

动画逻辑检查 360 度旋转是否结束,以在每次完成 360 度时重置旋转变量。动画检查isRotate状态是否设置为真,这样我的地图只有在我点击开始按钮时才会开始旋转。

  AnimationFrame(() => {
    if (isRotate) {
      let newRotation = rotation
      if (rotation >= 360) {
        newRotation = rotation - 360
      }
      setRotation(newRotation + 0.2)
      // console.log(`rotation: ${  rotation}`)
    }
  })

我正在添加一个按钮来启动动画。这是通过使用粗箭头内嵌函数将isRotate状态设置为 true 来实现的。

  return (
    <>
      <Button
        size="medium"
        color="primary"
        startIcon={<PlayCircleFilledWhiteIcon />}
        onClick={() => {
          setIsRotate(true)
        }}
      />
      <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
        <g>
          <circle
            fill="#0098c8"
            cx={cx}
            cy={cy}
            r={scale}
          />
        </g>
        <g>
          {(geographies as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={0.5}
            />
          ))}
        </g>
      </svg>
      </>

  )
}

记得更新App.tsx以包含RotatingRoundWorldMap组件。

return (
  <div className="App">
    <header className="App-header">
      <RotatingRoundWorldMap />
    </header>
  </div>
)

图 6-5 显示了最终结果。

img/510438_1_En_6_Fig5_HTML.jpg

图 6-5

旋转圆形世界地图图册

我们需要做的另一个改变是将地图数据的加载放在一个检查数据是否被加载的语句中。

原因是由于使用了动画钩子,useEffect 会一直被调用。

看一看;

useEffect(() => { if (geographies.length === 0) {
    // load map
}

带坐标的旋转圆形世界地图

在这一节中,我将向你展示如何在我们的地图上添加坐标点。

rotationroundworldmapwithcoordinates . tsx

复制前面例子中的RotatingRoundWorldMap.tsx文件,并将其命名为RotatingRoundWorldMapWIthCoordinates.tsx

是的,我知道这是一个很长的名字,但是使用莎士比亚的方法名,很容易看出这个组件在做什么。

为了创建坐标点,我将添加一个新的数据数组提要,其中包括坐标的经度和纬度。看看与之前的RotatingRoundWorldMap.tsx组件相比的变化:

// src/components/WorldMap/RotatingRoundWorldMapWIthCoordinates.tsx
import React, { useState, useEffect } from 'react'
import { geoOrthographic, geoPath } from 'd3-geo'
import { feature } from 'topojson-client'
import { Feature, FeatureCollection, Geometry } from 'geojson'
import './WorldMap.scss'
import PlayCircleFilledWhiteIcon from '@material-ui/icons/PlayCircleFilledWhite'
import { Button } from '@material-ui/core'
import AnimationFrame from '../../hooks/AnimationFrame'

const uuid = require('react-uuid')

const data: { name: string; coordinates: [number, number] }[] = [
  { name: '1', coordinates: [-73.9919, 40.7529] },
  { name: '2', coordinates: [-70.0007884457405, 40.75509010847814] },
]

const scale: number = 200
const cx: number = 400
const cy: number = 150
const initRotation: number = 50

const RotatingRoundWorldMapWithCoordinates = () => {
  const [geographies, setGeographies] = useState<[] | Array<Feature<Geometry | null>>>([])
  const [rotation, setRotation] = useState<number>(initRotation)
  const [isRotate, setIsRotate] = useState<Boolean>(false)

  useEffect(() => {
    if (geographies.length === 0) {
        fetch('/data/world-110m.json').then((response) => {
          if (response.status !== 200) {
            // eslint-disable-next-line no-console
            console.log(`Houston we have a problem: ${response.status}`)
          return
        }
        response.json().then((worldData) => {
          const mapFeatures: Array<Feature<Geometry | null>> = ((feature(worldData, worldData.objects.countries) as unknown) as FeatureCollection).features
          setGeographies(mapFeatures)
        })
      })
    }
  }, [])

  // geoEqualEarth
  // geoOrthographic
  const projection = geoOrthographic().scale(scale).translate([cx, cy]).rotate([rotation, 0])

  AnimationFrame(() => {
    if (isRotate) {
      let newRotation = rotation
      if (rotation >= 360) {
        newRotation = rotation - 360
      }
      setRotation(newRotation + 0.2)
      // console.log(`rotation: ${  rotation}`)
    }
  })

  function returnProjectionValueWhenValid(point: [number, number], index: number) {
    const retVal: [number, number] | null = projection(point)
    if (retVal?.length) {
      return retVal[index]
    }
    return 0
  }

  const handleMarkerClick = (i: number) => {
    // eslint-disable-next-line no-alert

    alert(`Marker: ${JSON.stringify(data[i])}`)
  }

  return (
  <>

      <Button
        size="medium"
        color="primary"
        startIcon={<PlayCircleFilledWhiteIcon />}
        onClick={() => {
          setIsRotate(true)
        }}
      />
      <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
        <g>
          <circle fill="#f2f2f2" cx={cx} cy={cy} r={scale} />
        </g>
        <g>
          {(geographies as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={0.5}
            />
          ))}
        </g>
        <g>
          {data.map((d, i) => (
            <circle

              key={`marker-${uuid()}`}
              cx={returnProjectionValueWhenValid(d.coordinates, 0)}
              cy={returnProjectionValueWhenValid(d.coordinates, 1)}
              r={5}
              fill="#E91E63"
              stroke="#FFFFFF"
              onClick={() => handleMarkerClick(i)}
              onMouseEnter={() => setIsRotate(false)}
            />
          ))}
        </g>
      </svg>

  </>
  )
}
export default RotatingRoundWorldMapWithCoordinates

Let’s review the changes in RotatingRoundWorldMapWIthCoordinates from RoundWorldMapAtlas. I am setting a data object that includes the names and coordinates.
const data: { name: string; coordinates: [number, number] }[] = [
  { name: '1', coordinates: [-73.9919, 40.7529] },
  { name: '2', coordinates: [-70.0007884457405, 40.75509010847814] },
]

对于初始的世界地图旋转位置,我可以用一个常量来设置,我希望世界地图开始旋转的角度。

const initRotation: number = 50

接下来,我将添加一个returnProjectionValueWhenValid方法来调整点的位置。这是需要的,因为世界地图是动画,投影地图上的位置将会改变。

function returnProjectionValueWhenValid(point: [number, number], index: number) {
    const retVal: [number, number] | null = projection(point)
    if (retVal?.length) {
      return retVal[index]
    }
    return 0
  }

一旦用户点击圆点,我将设置一个处理程序。一旦用户被点击,这可以用来打开一个详细的信息窗口或你想做的任何事情。

  const handleMarkerClick = (i: number) => {
    alert(`Marker: ${  JSON.stringify( data[i])}` )
  }

在渲染时,我将使用数组映射属性遍历坐标数组,并设置onClick处理程序和鼠标输入事件来停止动画,这样更容易单击标记。

        <g>
          {data.map((d, i) => (
            <circle
              key={`marker-${uuid()}`}
              cx={returnProjectionValueWhenValid(d.coordinates, 0)}
              cy={returnProjectionValueWhenValid(d.coordinates, 1)}
              r={5}
              fill="#E91E63"
              stroke="#FFFFFF"
              onClick={() => handleMarkerClick(i)}
              onMouseEnter={() => setIsRotate(false)}
            />
          ))}
        </g>

请注意,就像上一步一样,每次我在 React 中使用地图时,我都会为每个项目添加一个唯一的键。

最后,记得更新App.tsx

return (
  <div className="App">
    <header className="App-header">
<RotatingRoundRotatingRoundWorldMapWithCoordinatesWIthCoordinates />
    </header>
  </div>
)

图 6-6 显示了最终结果。

img/510438_1_En_6_Fig6_HTML.jpg

图 6-6

用坐标旋转圆形世界地图图册

重构

在本章的最后一步,我将做一些简单的重构工作。我将做以下事情:

  • 属性:提取props属性,以便父组件调整属性和数据。

  • 坐标:将坐标数据设置为 CSV 格式的第二个数据进给。

  • Loader :从父组件加载数据,使用异步任务将数据传递给 map chart。

  • Types :添加 TypeScript 类型,使代码可读性更好,避免错误,并有助于测试。

坐标. csv

最好将坐标数据提取到一个单独的 CSV 数据文件中。那不仅仅是为了清理代码。数据可能会增长,我可能需要从外部来源获取这些数据。我把坐标分解成纬度和经度,以防我需要用到这些信息。

id,latitude,longitude
1,-73.9919,40.7529
2,-70.0007884457405,40.75509010847814

将文件放在这里以便于访问:world-map-chart/public/data/coordinates.csv

types.tsx

正如您将在本书中看到的,TS 中的一个常见做法是为 TypeScript 创建类型。这不仅是一个好的实践,而且会清理代码,使其更具可读性。在我的例子中,我将为两个数据馈送设置两种类型:CoordinatesDataMapObject

// src/component/BasicScatterChart/types.ts

import { Feature, Geometry } from 'geojson'

export namespace Types {
  export type CoordinatesData = {
    id: number
    latitude: number
    longitude: number
  }

  export type MapObject = {
    mapFeatures: Array<Feature<Geometry | null>>
  }
}

引用类型时,通常使用小写的首字母。我们正在创建引用,因此任何一种方式都可以。换句话说,你可以决定你喜欢什么,但要保持一致。我想让你知道这两种选择。

export type coordinatesData

请注意,如果您以小写字母开头,您将会得到一个 lint 错误,因为我们的 lint 规则被设置为对任何不是以大写字母开头的导出类型进行投诉。您可以禁用它,只是这次禁用或全局禁用。

// eslint-disable-next-line @typescript-eslint/naming-convention

export type coordinatesData

WorldMap.tsx

对于我们的重构工作,复制前面示例中的RotatingRoundWorldMapWIthCoordinates.tsx文件,并将其命名为WorldMap.tsx

大部分代码保持不变。

对于props接口,我将添加数据馈送和对齐属性,因此我需要添加props接口并更改函数签名。

WorldMap.tsx的大部分改变只是增加了这些props而不是数据。更改以粗体突出显示。

// src/components/WorldMap/WorldMap.tsx

import React, { useState } from 'react'
import { geoOrthographic, geoPath } from 'd3-geo'
import './WorldMap.scss'
import PlayCircleFilledWhiteIcon from '@material-ui/icons/PlayCircleFilledWhite'
import { Button } from '@material-ui/core'
import AnimationFrame from '../../hooks/AnimationFrame'
import { Types } from './types'

const uuid = require('react-uuid')

const WorldMap = (props: IWorldMapProps) => {
  const [rotation, setRotation] = useState<number>(props.initRotation)
  const [isRotate, setIsRotate] = useState<Boolean>(false)

  const projection = geoOrthographic().scale(props.scale).translate([props.cx, props.cy]).rotate([rotation, 0])

  AnimationFrame(() => {
    if (isRotate) {
      let newRotation = rotation
      if (rotation >= 360) {
        newRotation = rotation - 360
      }
      setRotation(newRotation + 0.2)
    }
  })

  function returnProjectionValueWhenValid(point: [number, number], index: number ) {
    const retVal: [number, number] | null = projection(point)
    if (retVal?.length) {
      return retVal[index]
    }
    return 0
  }

  const handleMarkerClick = (i: number) => {
    alert(`Marker: ${  JSON.stringify( props.coordinatesData[i].id)}` )
  }

  return (
  <>

      <Button
        size="medium"
        color="primary"
        startIcon={<PlayCircleFilledWhiteIcon />}
        onClick={() => {
          setIsRotate(true)
        }}
      />
      <svg width={props.scale * 3} height={props.scale * 3} viewBox="0 0 800 450">
        <g>
          <circle
            fill="#f2f2f2"
            cx={props.cx}
            cy={props.cy}
            r={props.scale}
          />
        </g>
        <g>
          {(props.mapData.mapFeatures as ).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(30,50,50,${(1 / (props.mapData.mapFeatures ? props.mapData.mapFeatures.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={props.rotationSpeed}
            />
          ))}
        </g>
        <g>
          {props.coordinatesData?.map((d, i) => (
            <circle
              key={`marker-${uuid()}`}
              cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)}
              cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)}
              r={5}
              fill="#E91E63"
              stroke="#FFFFFF"
              onClick={() => handleMarkerClick(i)}
              onMouseEnter={() => setIsRotate(false)}
            />
          ))}
        </g>
      </svg>

  </>
  )
}

export default WorldMapinterface IWorldMapProps {
  mapData: Types.MapObject
  coordinatesData: Types.CoordinatesData[]
  scale: number
  cx: number
  cy: number
  initRotation: number
  rotationSpeed: number
}

如您所见,与上一个示例中的RotatingRoundWorldMapWIthCoordinates.tsx文件相比,我们在WorldMap.tsx中的代码现在更加清晰,可读性更好。

App.tsx

对于父组件,我将提取数据,把它放在 effect 钩子中,并使用 D3 加载 JSON 和 CSV。

因为我想在绘制地图之前加载两个数据集,所以一个好的方法是在将数据传递给图表组件之前对两个数据集使用异步调用。

我们可以用 D3 的queue()https://github.com/d3/d3-queue。D3 的queue()做异步任务。添加这些 D3 模块和 TS 类型。

第一步是将这些库添加到我们的项目中;

$ yarn add d3-queue d3-request @types/d3-queue @types/d3-request

接下来,让我们重构我们的App.tsx

// src/App.tsx

import React, { useEffect, useState } from 'react'
import './App.scss'
import { queue } from 'd3-queue'
import { csv, json } from 'd3-request'
import { FeatureCollection } from 'geojson'
import { feature } from 'topojson-client'
import WorldMap from './components/WorldMap/WorldMap'
import { Types } from './components/WorldMap/types'

function App() {
  const [mapData, setMapData] = useState<Types.MapObject>({ mapFeatures: [] })
  const [coordinatesData, setCoordinatesData] = useState<Types.CoordinatesData[]>([])

  useEffect(() => {
    if (coordinatesData.length === 0) {
      const fileNames = ['./data/world-110m.json', './data/coordinates.csv']
      queue()
        .defer(json, fileNames[0])
        .defer(csv, fileNames[1])
        .await((error, d1, d2: Types.CoordinatesData[]) => {
          if (error) {
            // eslint-disable-next-line no-console
            console.log(`Houston we have a problem:${error}`)
          }
          setMapData({ mapFeatures: ((feature(d1, d1.objects.countries) as unknown) as FeatureCollection).features })
          setCoordinatesData(d2)
        })
    }
  })
  return (
    <div className="App">
      <header className="App-header">
        <WorldMap mapData={mapData} coordinatesData={coordinatesData} scale={200} cx={400} cy={150} initRotation={50} rotationSpeed={0.5} />
      </header>
    </div>
  )
}

export default App

让我们回顾一下代码。

我们为geojsontopojson-client添加了imports,因为我们将在这里上传数据。我还使用d3-request来加载数据,而不是之前使用的 fetch。

import { queue } from 'd3-queue'
import { csv, json } from 'd3-request'
import { FeatureCollection } from 'geojson'
import { feature } from 'topojson-client'
import WorldMap from './components/WorldMap/WorldMap'
import { Types } from './components/WorldMap/types'

我正在将数据馈送设置为状态;这将允许我分配props并确保 React 在数据加载后刷新props

  const [mapData, setMapData] = useState<Types.MapObject>({ 'mapFeatures': [] })
  const [coordinatesData, setCoordinatesData] = useState<Types.CoordinatesData[]>([])

吊钩将承担起重物。if语句确保我不会多次加载我的数据。

D3 queue将加载两个数据馈送并设置状态。

  useEffect(() => {
    if ( coordinatesData.length === 0 ) {
      const fileNames = ['./data/world-110m.json', './data/coordinates.csv']
      queue()
        .defer(json, fileNames[0])
        .defer(csv, fileNames[1])
        .await((error, d1, d2: Types.CoordinatesData[]) => {
          if (error) {
            console.log(`Houston we have a problem:${  error}`)
          }
          setMapData({ mapFeatures: ((feature(d1, d1.objects.countries) as unknown) as FeatureCollection).features })
          setCoordinatesData(d2)
        })
    }
  })

最后,我需要用数据和属性props设置WorldMap,以匹配我们为WorldMap组件设置的props接口。

        <WorldMap mapData={mapData} coordinatesData={coordinatesData} scale={200} cx={400} cy={150} initRotation={50} rotationSpeed={0.5} />

从用户的角度来看,一旦检查了端口 3000: http://localhost:3000,实际上没有什么变化。然而,我的代码更有组织性,更容易阅读,也更容易实现状态管理,如反冲或 Redux,因为数据是从实际组件中提取的,可以与多个组件共享。

摘要

在本章中,我们在 D3、TopoJSON 和 React 的帮助下创建了一个世界地图。将地图绘制为背景并添加点、动画和交互的能力有助于创建引人注目的图表,该图表可用于讲述您的故事。

在这一章中,我将步骤分解为五个部分,并创建了五个组件。

  • 世界地图图册 : WorldMapAtlas.tsx

  • 圆形世界地图 : RoundWorldMap.tsx

  • 旋转圆形世界地图 : RotatingRoundWorldMap.tsx

  • 坐标为 : RotatingRoundWorldMapWithCoordinates.tsx的旋转圆形世界地图

  • 重构 : WorldMap.tsx

从这一章可以看出,在topojsongeojson的帮助下使用 D3 集成世界地图图集是很简单的。

让 React 参与进来使得添加动画和交互更加直观。TS 有助于确保我们理解我们正在做的事情,并避免潜在的错误,在做了一些重构后,您可以看到我们的组件不仅是可重用的,而且可以进行状态管理。

在下一章中,我将向您展示如何使用我们在这里创建的地图并实现反冲状态管理和一个列表来创建一个以交互方式显示简历的小部件。

七、世界地图:第二部分

在前一章中,我们使用 React 和 D3 创建了一个世界地图图集。在本章中,我将向您展示如何使用我们创建的 map 组件来跨多个组件共享状态并与 map 进行交互。

潜在客户经常问我以前在不同公司的工作和角色,这样他们就能知道我是否适合他们当前的项目。我将使用地图创建一个交互式简历,显示我以前的客户及其在世界各地的位置。

图 7-1 在我的网站上显示最终结果: https://elielrom.com/about

img/510438_1_En_7_Fig1_HTML.jpg

图 7-1

互动简历的最终结果

这一章是上一章的延续。

就结构而言,这一章分为三个步骤。

  • 第一步:设置

  • 第二步:状态管理

  • 步骤 3 :小工具创建

我们开始吧。

设置

为了保持一切整洁,我将开始一个新项目。项目设置是快速和简单的使用 CRA 与 MHL 模板项目,你应该熟悉了。

$ yarn create react-app world-map-widget --template must-have-libraries
$ cd world-map-widget
$ yarn start
$ open http://localhost:3000

正如我们在前一章所做的,我们需要安装额外的库和 TS 类型。

$ yarn add d3 @types/d3
$ yarn add d3-geo @types/d3-geo topojson-client @types/topojson-client
$ yarn add @types/geojson geojson
$ yarn add react-uuid

在前一章中,我使用了coordinates.csv,它包括idlatitudelongitude

代替coordinates.csv,对于客户端列表,我将创建一个新的 CSV 文件,该文件将包括客户端的 CSV 格式的数据馈送,然后添加其他字段。

用以下字段创建/public/data/client-list.csv:

id,latitude,longitude,name,logo,description,address,city,state,country,website

对于地图数据,从上一个项目中复制相同的world-110m.json文件并放在这里:/public/data/world-110m.json

共享状态管理

你可能还记得,在第五章中,我向你展示了如何处理反冲和共享状态。在这一章中,我们将做同样的事情,并通过跨不同组件共享数据来扩展这个主题。

我将创建一个模型对象来保存我们的类型,然后创建反冲选择器。

模型文件

有两种数据馈送,分别用于地图和客户端列表。

  • clientsObject.ts

  • mapObject.ts

如果你看代码,你会看到我正在初始化对象。当我需要设置一些测试并需要缺省值时,这将在本章后面派上用场。

// src/model/clientsObject.ts

export interface clientsObject {
  id: number
  latitude: number
  longitude: number
  name: string
  logo: string
  description: string
  address: string
  city: string
  state: string
  country: string
  website: string
}

export const initClientsObject = (): clientsObject => ({
  id: -1,
  latitude: 0,
  longitude: 0,
  name: '',
  logo: '',
  description: '',
  address: '',
  city: '',
  state: '',
  country: '',
  website: '',
})

地图对象保存地图特征。这与我们在App.tsx的前一章中的代码相同。这一次,我将把代码从App.tsx中移出,放到它自己的小部件组件中。这个过程并不新鲜。我们在第五章中做了同样的事情。

我们的 map 对象将包括对象本身的方法以及初始化和设置保存状态数组的对象的方法。看一看:

// src/model/mapObject.ts

import { Feature, Geometry } from 'geojson'

export interface mapObject {
  mapFeatures: Array<Feature<Geometry | null>>
}

export const initMapObject = (): mapObject => ({
  mapFeatures: Array<Feature<null>>(),
})

export const setMapObject = (data: Array<Feature<Geometry | null>>): mapObject => ({
  mapFeatures: data,
})

原子

现在我们有了模型对象集,我们可以创建反冲的原子和选择器。反冲简化了状态管理,所以我们只需要创建两种成分:原子和选择器。

对于我们的例子,我们可以使用clientAtom.tsmapAtoms.ts来设置初始状态,但是我们不需要它们。我们创造的模型就足够了。

反冲选择器不需要使用来创建原子,能够跳过一个步骤并编写更少的代码是很好的。原子对于一些情况非常有用,比如当我们想要获得多个组件的状态更新或者将状态传递给选择器时。在我们的例子中,我们不需要这些功能,所以设置原子是多余的,可以跳过。

选择器

您可能还记得,选择器是允许您同步或异步转换状态的纯函数。

我们的选择器将为客户端列表和地图提取 CSV 数据。我将使用 D3 dsv API 在选择器异步调用中提取 CSV 格式。

// src/recoil/selectors/clientsSelectors.ts

import { selector } from 'recoil'
import * as d3 from 'd3'

import { clientsObject } from '../../model'

export const getPreviousClientListData = selector({
  key: 'GetPreviousClientListData',
  get: () => {
    return getData()
  },
})

const getData = async () =>
  new Promise((resolve) =>
    d3
      .dsv(',', '/data/client-list.csv', function results(d) {
        return d
      })
      .then(function results(data) {
        resolve((data as unknown) as clientsObject[])
      })
  )

对于地图,我将使用fetch内置命令,类似于我们在上一章中所做的,并从world-110m.json中提取数据。

// src/recoil/selectors/mapSelectors.ts

import { selector } from 'recoil'
import { Feature, FeatureCollection, Geometry } from 'geojson'
import { feature } from 'topojson-client'
import { setMapObject } from '../../model'

export const getMapData = selector({
  key: 'GetMapData',
  get: async () => {
    return getMapDataFromFile()
  },
})

const getMapDataFromFile = () =>
  new Promise((resolve) =>
    fetch('/data/world-110m.json').then((response) => {
      if (response.status !== 200) {
        console.log(`Houston, we have a problem! ${response.status}`)
        return
      }
      response.json().then((worldData) => {
        const mapFeatures: Array<Feature<Geometry | null>> = ((feature(worldData, worldData.objects.countries) as unknown) as FeatureCollection).features
        resolve(setMapObject(mapFeatures))
      })
    })
  )

如果你想创建一个 atom,你可以把我返回的对象转换成那个类型。这一步不是必需的,正如您将看到的那样,代码不这样做也能正常工作。

小部件

就我们的前端部件而言,你可以在图 7-2 中看到前端的线框。

img/510438_1_En_7_Fig2_HTML.jpg

图 7-2

ClientsWidget 组件和子组件高级线框

要生成这些组件、Jest 测试和 SCSS 文件,您可以再次使用我在 CRA/MHL 放置的模板或自己创建的模板generate-react-cli

$ npx generate-react-cli component ClientsWidget --type=recoil
$ npx generate-react-cli component WorldMap --type=d3
$ npx generate-react-cli component ClientList --type=recoil
$ npx generate-react-cli component ClientListDetail --type=recoil

每个生成三个文件:Component.tsxComponent.test.tsxComponent.scss。以ClientList输出为例,如图 7-3 所示。

img/510438_1_En_7_Fig3_HTML.jpg

图 7-3

CRA·MHL 模板生成的客户列表

WorldMap.tsx

接下来,我将使用我们在前一章中创建的WorldMap.tsx组件,并做一些额外的重构,使它符合我们的需求。

突出显示了这些更改。看一看:

// src/components/WorldMap/WorldMap.tsx
import React, { useState } from 'react'
import { geoEqualEarth, geoPath } from 'd3-geo'
import './WorldMap.scss'
import AnimationFrame from '../../hooks/AnimationFrame'
import { Types } from './types'
import PulsatingCircle from '../PulsatingCircle/PulsatingCircle'
import { clientsObject } from '../../model'

const uuid = require('react-uuid')

const WorldMap = (props: IWorldMapProps) => {
  const [rotation, setRotation] = useState<number>(props.initRotation)
  const [isRotate, setIsRotate] = useState<Boolean>(true)

  const projection = geoEqualEarth().scale(props.scale).translate([props.cx, props.cy]).rotate([rotation, 0])

  AnimationFrame(() => {
    if (isRotate) {
      let newRotation = rotation
      if (rotation >= 360) {
        newRotation = rotation - 360
      }
      setRotation(newRotation + 0.2)
    }
  })

  function

returnProjectionValueWhenValid(point: [number, number], index: number) {
    const retVal: [number, number] | null = projection(point)
    if (retVal?.length) {
      return retVal[index]
    }
    return 0
  }

  const handleMarkerClick = (index: number) => {
    props.setSelectedItem(props.clientsData[index])
    setIsRotate(false)
  }

  return (
    <>
      <svg width={props.scale * 3} height={props.scale * 3} viewBox="0 0 800 450" onMouseMove={() => setIsRotate(false)} onMouseOut={() => setIsRotate(true)}>
        <g>
          {(props.mapData.mapFeatures as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(30,50,50,${(1 / (props.mapData.mapFeatures ? props.mapData.mapFeatures.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={props.rotationSpeed}
            />
          ))}
        </g>
        <g>
          {props.clientsData.map((d, i) => {
            return props.selectedItem.id !== d.id ? (
              <circle
                style={{ cursor: 'pointer' }}
                key={`marker-${uuid()}`}
                cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)}
                cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)}
                r="8"
                fill="rgba(242, 121, 53, 1)"
                stroke="#FFFFFF"
                onClick={() => handleMarkerClick(i)}
              />
            ) : (
              <PulsatingCircle key={`pulsatingCircle-${uuid()}`} cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)} cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)} />
            )
          })}
        </g>
      </svg>
    </>

  )
}

export default WorldMap

interface IWorldMapProps {
  mapData: Types.MapObject
  clientsData: Types.ClientData[]
  setSelectedItem: Function
  selectedItem: clientsObject
  scale: number
  cx: number
  cy: number
  initRotation: number
  rotationSpeed: number
}

如果我们将第六章的的WorldMap.tsx与本章最新的WorldMap.tsx进行比较,我们会发现有一些变化。

对于函数props的接口签名,我将根据我们创建的clientObjectmapObject设置props,并传递一个函数,一旦用户单击地图上选定的点,我们就可以使用该函数进行回调。

interface IWorldMapProps {
  mapData: Types.MapObject
  clientsData: Types.ClientData[]
  setSelectedItem: Function
  selectedItem: clientsObject
  scale: number
  cx: number
  cy: number
  initRotation: number
  rotationSpeed: number
}

在渲染中,最大的变化是坐标点,我在上一章中设置的,如果它们被选中,将被检查。

我为每个点返回一个圆或者一个PulsatingCircle分量。

return (
    <>
      <svg width={props.scale * 3} height={props.scale * 3} viewBox="0 0 800 450" onMouseMove={() => setIsRotate(false)} onMouseOut={() => setIsRotate(true)}>
        <g>
          {(props.mapData.mapFeatures as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(30,50,50,${(1 / (props.mapData.mapFeatures ? props.mapData.mapFeatures.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={props.rotationSpeed}
            />
          ))}
        </g>
        <g>

如果选择了一个点,我将使用一个自定义组件来创建一个脉动的圆。没错,我将使用我们在第二章中创建的代码来创建一个动画脉动圈。看一看:

          {props.clientsData.map((d, i) => {
            return props.selectedItem.id !== d.id ? (
              <circle
                style={{ cursor: 'pointer' }}
                key={`marker-${uuid()}`}
                cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)}
                cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)}
                r="8"
                fill="rgba(242, 121, 53, 1)"
                stroke="#FFFFFF"
                onClick={() => handleMarkerClick(i)}
              />
            ) : (
              <PulsatingCircle key={`pulsatingCircle-${uuid()}`} cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)} cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)} />
            )
          })}
        </g>
      </svg>
    </>

  )
}

我用的是geoEqualEarth,但是你可以试试geoOrthographic或者其他任何 D3 地理投影形状。

在 JSX 代码中使用selectedItem是至关重要的,因为它将确保当一个新的客户端被选择时,地图被渲染。

至于渲染,我将使用geoEqualEarth进行投影,使用window.requestAnimationFrame制作动画;与前一章相比,这里没有什么变化。

const projection = geoEqualEarth().scale(props.scale).translate([props.cx, props.cy]).rotate([rotation, 0])

AnimationFrame(() => {
  if (isRotate) {
    let newRotation = rotation
    if (rotation >= 360) {
      newRotation = rotation - 360
    }
    setRotation(newRotation + 0.2)
  }
})

handleMarkerClick处理器将客户端数据对象传递回父组件ClientsWidget.tsx并停止地图的旋转。

const handleMarkerClick = (index: number) => {
  props.setSelectedItem(props.clientsData[index])
  setIsRotate(false)
}

请注意,ClientListClientListDetail使用setSelectedClient来设置选定的客户端。我可以在这里使用反冲原子状态来避免钻取,但是,因为它不会钻取任何不需要数据的组件。有一种方法来处理这个问题是很好的,而且是帮助调试和避免麻烦的更安全的方法。

Avoid prop drilling

在 React 中,一切都是组件,数据通过props自上而下(从父到子)传递。假设您需要一个父组件的子组件的子组件中的属性。你是做什么的?您可以将属性从一个组件传递到另一个组件。使用层次结构中更高的另一个组件提供的数据来深度嵌套组件的技术被称为prop 钻探

正确钻孔的主要缺点是,原本不应该知道数据的组件变得不必要的复杂和麻烦。它们也更难维护,因为现在我们必须在测试中添加它们(如果我们可以测试的话),并试图找出提供数据的父组件。

脉动圈. tsx

React 大放异彩。我可以在 Material-UI 和样式组件的帮助下使用 JSX,而不是一些复杂的 D3 编码。我需要通过cxcy,这样我的脉动圈就会随着旋转图移动。

import React from 'react'
import styled, { keyframes } from 'styled-components'const circlePulse = (colorOne: string, colorTwo: string) => keyframes`
0% {
  fill:${colorOne};
  stroke-width:20px
}
50% {
  fill:${colorTwo};
  stroke-width:2px
}
100%{
  fill:${colorOne};
  stroke-width:20px
}
`
const StyledInnerCircle = styled.circle`
  animation: ${() => circlePulse('rgb(245,197,170)', 'rgba(242, 121, 53, 1)')} infinite 4s linear;
`export default function PulsatingCircle(props: IPulsatingCircle) {
  return (
    <>
      <StyledInnerCircle cx={props.cx} cy={props.cy} r="8" stroke="limegreen" stroke-width="5" />
    </>

  )
}interface IPulsatingCircle {
  cx: number
  cy: number
}

ClientList.tsx 子组件

ClientList.tsx是直截了当的素材——有风格的 UI。我正在制作一个代表我工作过的公司的标志列表,并允许滚动和选择。所选的项目将被传递回父组件,以便可以更新所有其他组件。看一下完整的代码:

// src/component/ClientList/ClientList.tsx
import React from 'react'
import './ClientList.scss'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemAvatar from '@material-ui/core/ListItemAvatar'
import { clientsObject } from '../../model'

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: '150px',
      maxWidth: 150,
      backgroundColor: theme.palette.background.paper,
      maxHeight: '340px',
      overflow: 'auto',
      paddingTop: '5px',
      scroll: 'paper',
    },
  })
)

const ClientList = (props: IClientListProps) => {
  const handleClick = (id: number) => {
    // console.log(`id: ${id}`)
    props.setSelectedItem(props.data[id])
  }
  const classes = useStyles()
  return (
    <List dense className={classes.root}>
      {props.data.map((value) => {
        return (
          <ListItem key={value.id} button onClick={() => handleClick(value.id - 1)}>
            <ListItemAvatar>
              <img alt={`${value.name} avatar`} src={`/clients-logo/${value.logo}`} width="100px" />
            </ListItemAvatar>
          </ListItem>
        )
      })}
    </List>
  )
}

interface IClientListProps {
  data: clientsObject[]
  setSelectedItem: Function

}

export default ClientList

让我们回顾一下代码。

对于样式,我使用了 Material-UI 主题和样式,并将组件设置为可滚动,这样用户就可以滚动浏览客户列表。

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: '150px',
      maxWidth: 150,
      backgroundColor: theme.palette.background.paper,
      maxHeight: '340px',
      overflow: 'auto',
      paddingTop: '5px',
      scroll: 'paper',
    },
  })
)

函数签名IClientListProps包括传递用户手势的setSelectedItem函数,该手势指示列表上的项目被选择,以及客户端数据的数据馈送。

const ClientList = (props: IClientListProps) => {
  const handleClick = (id: number) => {
    props.setSelectedItem(props.data[id])
  }
  const classes = useStyles()
  return (
    <List dense className={classes.root}>

我使用数组映射属性来迭代每个数据并绘制一个ListItemListItem包括映射到public/logo文件夹的徽标和点击处理程序。

      {props.data.map((value) => {
        return (
          <ListItem key={value.id} button onClick={() => handleClick(value.id - 1)}>
            <ListItemAvatar>
              <img alt={`${value.name} avatar`} src={`/clients-logo/${value.logo}`} width="100px" />
            </ListItemAvatar>
          </ListItem>
        )
      })}
    </List>
  )
}

最后,IClientListProps prop 接口包括从ClientsWidget.tsx父组件传递来的客户端数据提要和所选项方法。

interface IClientListProps {
  data: clientsObject[]
  setSelectedItem: Function
}

ClientListDetail.tsx 子组件

对于ClientListDetail,我正在设置客户的详细信息以及我的个人资料头像图片和对他们项目的贡献。以下是完整的代码:

// src/component/ClientListDetail/ClientListDetail.tsx
import React from 'react'
import './ClientListDetail.scss'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ListItem from '@material-ui/core/ListItem'
import ListItemText from '@material-ui/core/ListItemText'
import { Button, Typography } from '@material-ui/core'
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'
import ChevronRightIcon from '@material-ui/icons/ChevronRight'
import { clientsObject } from '../../model'

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: '500px',
      backgroundColor: theme.palette.background.paper,
      position: 'absolute',
      top: (props) => `${(props as IClientListDetailProps).paddingTop}px`,
      paddingLeft: '0px',
    },
    inline: {
      display: 'inline',
    },
    button: {
      margin: theme.spacing(0),
    },
  })
)

const profileImage = require('../../assets/about/EliEladElrom.jpg')

const ClientListDetail = (props: IClientListDetailProps) => {
  const classes = useStyles(props)
  const handleNext = () => {
    const index = props.data.indexOf(props.selectedItem)
    let nextItem
    if (index < props.data.length - 1) {
      nextItem = props.data[index + 1]
    } else {
      // eslint-disable-next-line prefer-destructuring
      nextItem = props.data[0]
    }
    props.setSelectedItem(nextItem)
  }
  const handlePrevious = () => {
    const index = props.data.indexOf(props.selectedItem)
    let nextItem
    if (index > 0) {
      nextItem = props.data[index - 1]
    } else {
      nextItem = props.data[props.data.length - 1]
    }
    props.setSelectedItem(nextItem)
  }
  return (
    <div className={classes.root}>
      <ListItem>
        <Button
          size="medium"
          color="primary"
          className={classes.button}
          startIcon={<ChevronLeftIcon />}
          onClick={() => {
            handlePrevious()
          }}
        />
        <img className="about-image" src={profileImage} alt="Eli Elad Elrom" />
        <ListItemText
          primary={props.selectedItem?.name}
          secondary={
            <>
              <Typography component="span" variant="body2" className={classes.inline} color="textPrimary">
                {props.selectedItem?.city}, {props.selectedItem?.state} {props.selectedItem?.country}
              </Typography>
              <br />
              Eli helped {props.selectedItem?.name} with - {props.selectedItem?.description} - visit them on the web:{' '}
              <a href={props.selectedItem?.website} target="_blank" rel="noopener noreferrer">
                {props.selectedItem?.website}
              </a>
            </>

          }
        />
        <Button
          size="medium"
          color="primary"
          className={classes.button}
          startIcon={<ChevronRightIcon />}
          onClick={() => {
            handleNext()
          }}
        />
      </ListItem>
    </div>
  )
}

interface IClientListDetailProps {
  selectedItem: clientsObject

  setSelectedItem: Function
  data: clientsObject[]
  // eslint-disable-next-line react/no-unused-prop-types
  paddingTop: number
}

export default ClientListDetail

我们来复习一下。

我使用material-ui样式来传递顶部的填充,这样我可以调整这个子组件。

 import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ListItem from '@material-ui/core/ListItem'
import ListItemText from '@material-ui/core/ListItemText'
import { Button, Typography } from '@material-ui/core'
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'
import ChevronRightIcon from '@material-ui/icons/ChevronRight'
import { clientsObject } from '../../model'

注意,我在顶部为从父组件通过props传递的组件设置了填充。props被传递给useStyle方法,它们可以被动态使用。

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: '500px',
      backgroundColor: theme.palette.background.paper,
      position: 'absolute',
      top: (props) => `${(props as IClientListDetailProps).paddingTop}px`,
      paddingLeft: '0px',
    },
    inline: {
      display: 'inline',
    },
    button: {
      margin: theme.spacing(0),
    },
  })
)

const profileImage = require('../../assets/about/EliEladElrom.jpg')

ClientListDetail组件包括左箭头和右箭头以及使用这些箭头在客户机中导航的方法。看看handleNexthandlePrevious

对于ClientListDetail组件签名,我们需要props接口,它将包含所选项目数据以及设置所选项目的函数。

const ClientListDetail = (props: IClientListDetailProps) => {

props被传递给useStyles以便使用。

  const classes = useStyles(props)
  const handleNext = () => {
    const index = props.data.indexOf(props.selectedItem)
    let nextItem
    if (index < props.data.length - 1) {
      nextItem = props.data[index + 1]
    } else {
      nextItem = props.data[0]
    }
    props.setSelectedItem(nextItem)
  }
  const handlePrevious = () => {
    const index = props.data.indexOf(props.selectedItem)
    let nextItem
    if (index > 0) {
      nextItem = props.data[index - 1]
    } else {
      nextItem = props.data[props.data.length - 1]
    }
    props.setSelectedItem(nextItem)
  }

细节组件被包装在一个ListItem中,包括材质-UI 图标按钮、照片和选定的项目细节。

  return (
    <div className={classes.root}>
      <ListItem>
        <Button
          size="medium"
          color="primary"
          className={classes.button}
          startIcon={<ChevronLeftIcon />}
          onClick={() => {
            handlePrevious()
          }}
        />
        <img className="about-image" src={profileImage} alt="Eli Elad Elrom" />
        <ListItemText
          primary={props.selectedItem?.name}
          secondary={
            <>

              <Typography component="span" variant="body2" className={classes.inline} color="textPrimary">
                {props.selectedItem?.city}, {props.selectedItem?.state} {props.selectedItem?.country}
              </Typography>
              <br />
              Eli helped {props.selectedItem?.name} with - {props.selectedItem?.description} - visit them on the web:{' '}
              <a href={props.selectedItem?.website} target="_blank" rel="noopener noreferrer">
                {props.selectedItem?.website}
              </a>
              </>

          }
        />
        <Button
          size="medium"
          color="primary"
          className={classes.button}
          startIcon={<ChevronRightIcon />}
          onClick={() => {
            handleNext()
          }}
        />
      </ListItem>
    </div>
  )
}

IClientListDetailProps props界面包括选择的项目、设置选择的项目的方法、客户端数据馈送和填充。

interface IClientListDetailProps {
  selectedItem: clientsObject
  setSelectedItem: Function
  data: clientsObject
  paddingTop: number

}

ClientsWidget 组件

ClientWidget 组件是父组件,它将借助反冲获取地图和客户端列表的结果,并将结果传递给子组件。

我正在使用 Material-UI 网格组件来设置一个客户列表和一个旋转的世界地图。我设置的网格将有一个容器和两列。以下是有助于理解结构的布局:

import Grid from '@material-ui/core/Grid'
<Grid container>
     <Grid item xs={6}>
         ...
     </Grid>
     <Grid item xs={6}>
         ...
     </Grid>
</Grid>

看一看ClientsWidget.tsx完整代码:

// src/widgets/ClientsWidget/ClientsWidget.tsx
import React, { useEffect, useState } from 'react'
import './ClientsWidget.scss'
import { useRecoilValue } from 'recoil'
import { Grid } from '@material-ui/core'
import { getPreviousClientListData } from '../../recoil/selectors/clientsSelectors'
import { clientsObject, mapObject } from '../../model'
import { getMapData } from '../../recoil/selectors/mapSelectors'
import WorldMap from '../../components/WorldMap/WorldMap'
import ClientListDetail from '../../components/ClientListDetail/ClientListDetail'
import ClientList from '../../components/ClientList/ClientList'

const ClientsWidget = () => {
  const clientsData: clientsObject = useRecoilValue(getPreviousClientListData) as clientsObject
  const mapData: mapObject = useRecoilValue(getMapData) as mapObject

  const [selectedItem, setSelectedItem] = useState<clientsObject>(clientsData[0])

  useEffect(() => {
    // results
    // console.log(`Result: ${JSON.stringify(clientsData)}`)
    // console.log(`Result: ${JSON.stringify(mapResults)}`)
  })
  return (
    <>

      {clientsData?.length > 0 && mapData.mapFeatures.length > 0 ? (
        <>

          <Grid container>
            <Grid item xs={3}>
              <ClientList data={clientsData} setSelectedItem={setSelectedItem} />
            </Grid>
            <Grid item xs={8}>
              <WorldMap mapData={mapData} clientsData={clientsData} selectedItem={selectedItem} setSelectedItem={setSelectedItem} scale={200} cx={0} cy={100} initRotation={100} rotationSpeed={0.3} />
            </Grid>
          </Grid>
          <ClientListDetail selectedItem={selectedItem} data={clientsData} setSelectedItem={setSelectedItem} paddingTop={400} />
        </>

      ) : (
        <>Loading!</>
      )}
    </>

  )
}
export default ClientsWidget

我们来复习一下。

我正在把反冲选择器的数据传送过来。

  const clientsData: clientsObject[] = useRecoilValue(getPreviousClientListData) as clientsObject[]
  const mapData: mapObject = useRecoilValue(getMapData) as mapObject

对于所选的项目,我正在使用状态。

  const [selectedItem, setSelectedItem] = useState<clientsObject>(clientsData[0])

对于 JSX 渲染,我正在检查以确保数据被上传,然后包括ClientListWorldMapClientListDetail子组件。

  return (
    <>

      {clientsData?.length > 0 && mapData.mapFeatures.length > 0 ? (
    <>

          ...
    </>

      ) : (
        <>Loading!</>
      )}
    </>

  )
}

App.tsx

最后一步,不要忘记将小部件添加到父组件App.tsx

// src/App.tsx

import React from 'react'
import './App.scss'
import ClientsWidget from './widgets/ClientsWidget/ClientsWidget'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <ClientsWidget />
      </header>
    </div>
  )
}

export default App

节目质量监视

现在,如果您运行formatlinttest命令,我们应该得到一个整洁的确认,我们都是好的。

$ yarn format && yarn lint

这将为您提供以下结果:

$ eslint --ext .js,.jsx,.ts,.tsx ./
✨  Done in x seconds.

WorldMap.test.tsx

对于 Jest 酶测试,我将确保组件已安装。

从代码中可以看出,使用initClientsObjectinitClientsObject来设置初始值非常方便。

// src/component/WorldMap/WorldMap.test.tsx

import React from 'react'
import { shallow } from 'enzyme'
import WorldMap from './WorldMap'
import { initClientsObject, initMapObject } from '../../model'

describe('<WorldMap />', () => {
  let component

  beforeEach(() => {
    component = shallow(<WorldMap mapData={initMapObject()} clientsData={[initClientsObject()]} selectedItem={initClientsObject()} setSelectedItem={Function} scale={200} cx={0} cy={100} initRotation={100} rotationSpeed={0.3} />)
  })

  test('It should mount', () => {
    expect(component.length).toBe(1)
  })
})

运行测试。

$ yarn test

您可以将您的结果与我的进行比较(参见图 7-4 )。

img/510438_1_En_7_Fig4_HTML.jpg

图 7-4

世界地图小部件测试结果

您可以从这里下载该项目:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch07/world-map-widget

摘要

在本章中,我向您展示了如何使用我们在上一章中创建的世界地图作为基础来构建一个工作小部件,以使用 D3、React v17、Material-UI、反冲和 TypeScript 来显示客户列表。

用 React 配合 D3 是纯善。我能够创建组件,并在需要时让 React 管理 DOM 的状态和更新。它需要很少的 D3 代码,但很有用,因为 JSX 可以处理大部分代码,我们的前端代码是可读的,并由 VDOM 管理。反冲有助于保持状态,仅在需要时渲染。

如您所见,将 D3 与 React 结合使用有助于创建简洁的可视化工具,从而更直观地展示信息。

最后,我能够运行formatlinttest来确保质量。

我希望这一章能启发你创建自己的互动简历,展示工作、客户名单、相册或任何你想突出的东西。

正如您在本章中看到的,使用这种小部件、状态管理和组件的结构,并设置数据的类型和模型,有助于创建可读和可测试的代码。一旦需要更改,您将能够轻松地重构和添加特性。

在下一章,我们将在 React 组件的帮助下创建一个 D3 力图。

八、力图:第一部分

力定向图是一类以吸引人的方式在图中布置数据的算法。力图可用于机械弹簧行为、网络可视化、知识表示等。使用 React 和 D3 的组合是很棒的,因为每个库可以用于不同的事情。它给你最好的一切。将 TypeScript 作为类型检查器添加到组合中有助于确保类型得到良好的定义,并帮助您避免潜在的错误。太好了!

在这一章中,我将向你展示如何创建一个基本的力图,也就是一个带有动画的气泡图,它将迫使每个气泡向中心移动。

我们开始吧。

泡泡图

气泡图在二维图中显示多个圆圈(图 8-1 )。气泡图是指出彼此相关的特定单词的理想方式。

img/510438_1_En_8_Fig1_HTML.jpg

图 8-1

气泡图的最终结果

我们将会建造什么?

在本章的这一节,我将向你展示如何使用 D3 和 React 创建一个带有机械弹簧行为的基本气泡图。对于数据提要,我将使用流行的固执己见的 React 库,这些库是每个开发人员工具箱中的必备工具。这些库在我的 React 和库一书中有更详细的介绍: https://www.apress.com/gp/book/9781484266953

家政

技术栈是固执己见的,在这一点上你应该很熟悉。以下是主要的移动部件:

  • React v17

  • 以打字打的文件

  • D3 v6

  • 我们将使用的其他库:Jest 和 Enzyme,react-uuid

我把这一章分成三步。

  • 第一步:设置

  • 第二步:气泡图创建

  • 第三步:重绘

设置

建立一个新的 CRA 项目,并使用 MHL 模板项目将其命名为bubble-chart,就像我们在前面章节中所做的那样。

$ yarn create react-app bubble-chart --template must-have-libraries
$ cd bubble-chart
$ yarn start // check http://localhost:3000

对于力量图,我将使用d3-force模块。不要忘记 TS 的类型。

$ yarn add d3 @types/d3 d3-force @types/d3-force

最后,我将安装一个库来生成随机密钥;正如您所记得的,我们在前面的章节中也使用了这个库。

$ yarn add react-uuid

气泡图组件

我们的气泡图组件包括以下内容:

  • BubbleChart.scss

  • BubbleChart.test.tsx

  • BubbleChart.tsx

  • types.tsx

generate-react-cli的帮助下或自己动手,你可以随意创建它们。

要使用generate-react-cli和我为d3类预先填充的模板创建脚手架,使用以下命令:

$ npx generate-react-cli component BubbleChart --type=d3class

types.ts

为了保存我将在气泡图组件中使用的组件类型,我将创建一个 TS 类型文件。

在类型文件中,我将保存数据馈送以及我的力图需要的特定数据,这只是每个气泡的大小。

请注意,对于每个气泡,我都持有其名称、大小,甚至填充颜色。ForceData对象只是保存了 D3 需要的气泡大小。

// src/component/BubbleChart/types.ts

export namespace Types {
  export type Data = {
    id: number
    name: string
    size: number
    fillColor: string
  }

  export type ForceData = {
    size: number
  }
}

气泡成分将包括 D3 功能以及 React 成分。让我们来看看完整的代码:

// src/BubbleChart/BubbleChart.tsx
import React from 'react'
import * as d3 from 'd3'
import { Simulation, SimulationNodeDatum } from 'd3-force'
import './BubbleChart.scss'
import { Types } from './types'

const uuid = require('react-uuid')

class BubbleChart extends React.PureComponent<IBubbleChartProps, IBubbleChartState> {

  public forceData: Types.ForceData[]

  private simulation: Simulation<SimulationNodeDatum, undefined> | undefined

  constructor(props: IBubbleChartProps) {
    super(props)
    this.state = {
      data: [],
    }
    this.forceData = this.setForceData(props)
  }

  componentDidMount() {
    this.animateBubbles()
  }

  setForceData = ( props: IBubbleChartProps ) => {
    const d = []
    for (let i= 0; i < props.bubblesData.length; i++) {
      d.push({ 'size': props.bubblesData[i].size })
    }
    return d
  }

  animateBubbles = () => {
    if (this.props.bubblesData.length > 0) {
      this.simulatePositions(this.forceData)
    }
  }

  radiusScale = (value: d3.NumberValue) => {
    const fx = d3.scaleSqrt().range([1, 50]).domain([this.props.minValue, this.props.maxValue])
    return fx(value)
  }

  simulatePositions = (data: Types.ForceData[]) => {
    this.simulation = d3
      .forceSimulation()
      .nodes(data as SimulationNodeDatum[])
      .velocityDecay(0.05)
      .force('x', d3.forceX().strength(0.2))
      .force('y', d3.forceY().strength(0.2))
      .force(
        'collide',
        d3.forceCollide((d: SimulationNodeDatum) => {
          return this.radiusScale((d as Types.ForceData).size) + 2
        })
      )
      .on('tick', () => {
        this.setState({ data })
      })
  }

  renderBubbles = (data: []) => {

    return data.map((item: { v: number; x: number; y: number }, index) => {
      const { props } = this
      const fontSize = this.radiusScale((item as unknown as Types.ForceData).size) / 4
      const content = props.bubblesData.length > index ? props.bubblesData[index].name : ''
      const strokeColor = props.bubblesData.length > index ? 'darkgrey' : this.props.backgroundColor
      return (
        <g key={`g-${uuid()}`} transform={`translate(${props.width / 2 + item.x - 70}, ${props.height / 2 + item.y})`}>
          <circle
            style={{ cursor: 'pointer' }}
            onClick={() => {
              this.props.selectedCircle(content)
            }}
            id="circleSvg"
            r={this.radiusScale((item as unknown as Types.ForceData).size)}
            fill={props.bubblesData[index].fillColor}
            stroke={strokeColor}
            strokeWidth="2"
          />
          <text
            onClick={() => {
              this.props.selectedCircle(content)
            }}
            dy="6"
            className="bubbleText"
            fill={this.props.textFillColor}
            textAnchor="middle"
            fontSize={`${fontSize}px`}
            fontWeight="normal"
          >
            {content}
          </text>
        </g>
      )
    })
  }

  render() {
    return (
      <div>
        <div id="chart" style={{ background: this.props.backgroundColor, cursor: 'pointer' }}>
          <svg width={this.props.width} height={this.props.height}>
            {this.renderBubbles(this.state.data as [])}
          </svg>
        </div>
      </div>
    )
  }
}

interface IBubbleChartProps {

  bubblesData: Types.Data[]
  width: number
  height: number
  backgroundColor: string
  textFillColor: string
  minValue: number
  maxValue: number
  selectedCircle: (content: string) => void
}

interface IBubbleChartState {
  data: Types.ForceData[]
}

export default BubbleChart

让我们回顾一下代码。import语句包括d3-force、样式文件和 ts 类型。

// src/BubbleChart/BubbleChart.tsx

import React from 'react'
import * as d3 from 'd3'
import { Simulation, SimulationNodeDatum } from 'd3-force'
import './BubbleChart.scss'
import { Types } from './types'

const uuid = require('react-uuid')

对于类签名,我正在设置props和状态接口,以及一个包含我需要的所有属性的气泡数据对象。

interface IBubbleChartProps {
  bubblesData: Types.Data[]
  width: number
  height: number
  backgroundColor: string
  textFillColor: string
  minValue: number
  maxValue: number
  selectedCircle: (content: string) => void
}

interface IBubbleChartState {
  data: Types.ForceData[]
}

在前两章中,我们使用了一个函数组件。这一次,我想向您展示您可以用类组件做同样的事情。至于类签名,最好使用PureComponent并包含props和状态,而不是React.Component

class BubbleChart extends React.PureComponent<IBubbleChartProps, IBubbleChartState> {

我将克隆我的数据,因为我正在喂养 D3。D3 获取数据,并通过添加属性来扩展它。React props和 state 变量不应该扩展到包括 D3 可能需要的其他字段,一种方法是克隆数据。

public forceData: Types.ForceData[]

当涉及到气泡的力动画模拟时,我将它设置为一个私有成员,我可以在我的组件中访问它。

如果您想知道我是如何知道模拟 TS 类型的,我必须深入 D3 代码才能找到类型。这种复杂性是值得努力的,因为我在整个代码中都进行了类型检查。付出的代价并不大。

private simulation: Simulation<SimulationNodeDatum, undefined> | undefined

我的构造函数将设置props和状态,以及创建我的状态变量的方法。

我设置一个状态变量而不是使用来自props的数据对象的原因是,图表气泡需要的数据变量包括其他属性,如 x,y 位置,所以最好创建第二个对象,而不是试图在属性数据上设置它。forceData将保存我可以用来生成气泡的每个气泡的大小。setForceData方法将遍历属性数据来提取每个气泡的大小。

constructor(props: IBubbleChartProps) {
  super(props)
  this.state = {
    data: [],
  }
  this.forceData = this.setForceData(props)
}

一旦组件安装完毕(componentDidMount),我将通过调用animateBubbles使用 D3 力制作气泡动画。

我设置的setForceData方法是为了克隆 D3 所需的大小字段。

componentDidMount() {
  this.animateBubbles()
}setForceData = ( props: IBubbleChartProps ) => {
  const d = []
  for (let i= 0; i < props.bubblesData.length; i++) {
    d.push({ 'size': props.bubblesData[i].size })
  }
  return d
}

animateBubbles = () => {
  if (this.props.bubblesData.length > 0) {
    this.simulatePositions(this.forceData)
  }
}

radiusScale函数将采用d3.scaleSqrt并根据我们将提供的最小和最大props缩小比例。

如果需要,您可以包含逻辑来处理气泡大小的不同用例。这将使代码更加复杂,所以我们不打算在这里这样做。

radiusScale = (value: d3.NumberValue) => {
  const fx = d3.scaleSqrt().range([1, 50]).domain([this.props.minValue, this.props.maxValue])
  return fx(value)
}

simulatePosition方法就是做繁重的工作。D3 力模块进行速度计算;https://github.com/d3/d3-force见。

我可以用力量法调整力量。一旦更改生效,我就可以将它与组件状态联系起来。看一看:

simulatePositions = (data: Types.ForceData[]) => {
  this.simulation = d3
    .forceSimulation()
    .nodes(data as SimulationNodeDatum[])
    .velocityDecay(0.05)
    .force('x', d3.forceX().strength(0.2))
    .force('y', d3.forceY().strength(0.2))
    .force(
      'collide',
      d3.forceCollide((d: SimulationNodeDatum) => {
        return this.radiusScale((d as Types.ForceData).size) + 2
      })
    )
    .on('tick', () => {
      this.setState({ data })
    })
}

Note

这些strengthvelocitycollide设置可以作为props移动。小变化会让动画和视觉效果发生大变化。

为了渲染气泡,我可以使用 D3,但是 React 更适合这项工作,因为它有 VDOM,代码也更容易阅读。

Note

和前几章一样,我使用uuid库给每个组的 SVG 一个键。你可以自己随机生成一个密钥,但这是我最喜欢的生成密钥的方式。

我使用地图来遍历结果,然后渲染结果。我正在生成气泡和文本。这些结果是可点击的,并且将向用户提供用户点击的气泡的名称。函数renderBubbles返回 JSX 代码,可以在我们的类返回方法中使用。

renderBubbles = (data: []) => {
  return data.map((item: { v: number; x: number; y: number }, index) => {
    const { props } = this
    const fontSize = this.radiusScale((item as unknown as Types.ForceData).size) / 4
    const content = props.bubblesData.length > index ? props.bubblesData[index].name : ''
    const strokeColor = props.bubblesData.length > index ? 'darkgrey' : this.props.backgroundColor
    return (
      <g key={`g-${uuid()}`} transform={`translate(${props.width / 2 + item.x - 70}, ${props.height / 2 + item.y})`}>
        <circle
          style={{ cursor: 'pointer' }}
          onClick={() => {
            this.props.selectedCircle(content)
          }}
          id="circleSvg"
          r={this.radiusScale((item as unknown as Types.ForceData).size)}
          fill={props.bubblesData[index].fillColor}
          stroke={strokeColor}
          strokeWidth="2"
        />
        <text
          onClick={() => {
            this.props.selectedCircle(content)
          }}
          dy="6"
          className="bubbleText"
          fill={this.props.textFillColor}
          textAnchor="middle"
          fontSize={`${fontSize}px`}
          fontWeight="normal"
        >
          {content}
        </text>
      </g>
    )
  })
}

在 render 方法中,我们设置了一个divsvg包装器,并包含了将返回气泡内容的renderBubbles方法。注意,因为我们允许用户点击每个圆圈并获得结果,所以我们将光标设置为整个div的指针。

render() {
    return (
      <div>
        <div id="chart" style={{ background: this.props.backgroundColor, cursor: 'pointer' }}>
          <svg width={this.props.width} height={this.props.height}>
            {this.renderBubbles(this.state.data as [])}
          </svg>
        </div>
      </div>
    )
  }
}

bubblechart . scss

对于气泡图的 SCSS 风格文件,我将每个气泡内的文本设置为深灰色阴影,这样更容易阅读文本标签。

.bubbleText {
  text-shadow: 1px 0 0 darkslategrey, 0 1px 0 darkslategrey, -1px 0 0 darkslategrey, 0 -1px 0 darkslategrey;
}

App.tsx

为了实现这个文件,我将在App.tsx文件中包含气泡图组件。

我将数据设置为一个数组。数组的类型将是我在 bubble 组件中定义的类型,并将保存气泡的名称、大小和填充颜色。

这可以很容易地提取为 CSV 或 JSON 文件,如果需要,可以使用反冲在多个组件之间共享数据。正如我们在前面几章中所做的那样,我们将在本章的下一节创建第二张力图时这样做。

至于BubbleChart,我会通过我在 bubble 组件中设置的可视化设置。看一看:

// src/App.tsx

import React from 'react'
import './App.scss'
import BubbleChart from './components/BubbleChart/BubbleChart'
import { Types } from './components/BubbleChart/types'

function App() {

  const d: Types.Data[] = [
    { id: 1, name: 'React', size: 350, fillColor: '#D3D3D3' },
    { id: 2, name: 'TypeScript', size: 100, fillColor: '#9d9a9f' },
    { id: 3, name: 'SCSS', size: 75, fillColor: '#605f62' },
    { id: 4, name: 'Recoil', size: 150, fillColor: '#D3D3D3' },
    { id: 5, name: 'Redux', size: 150, fillColor: '#D3D3D3' },
    { id: 6, name: 'Material-UI', size: 125, fillColor: '#c6c5c6' },
    { id: 7, name: 'Router', size: 230, fillColor: '#808080' },
    { id: 8, name: 'Jest', size: 70, fillColor: '#C0C0C0' },
    { id: 9, name: 'Enzym', size: 70, fillColor: '#C0C0C0' },
    { id: 10, name: 'Sinon', size: 70, fillColor: '#C0C0C0' },
    { id: 11, name: 'Puppeteer', size: 70, fillColor: '#C0C0C0' },
    { id: 12, name: 'ESLint', size: 50, fillColor: '#A9A9A9' },
    { id: 13, name: 'Prettier', size: 60, fillColor: '#A9A9A9' },
    { id: 14, name: 'Lodash', size: 70, fillColor: '#DCDCDC' },
    { id: 15, name: 'Moment', size: 80, fillColor: '#DCDCDC' },
    { id: 16, name: 'Classnames', size: 90, fillColor: '#DCDCDC' },
    { id: 17, name: 'Serve', size: 100, fillColor: '#DCDCDC' },
    { id: 18, name: 'Snap', size: 150, fillColor: '#DCDCDC' },
    { id: 19, name: 'Helmet', size: 150, fillColor: '#DCDCDC' },
  ]

  const [data, setData] = React.useState<Types.Data[]>(d.slice(1, 10))

  const selectedKeyHandler = (key: string) => {
    alert(key)
  }

  return (

    <div className="App">
      <header className="App-header">
        <BubbleChart bubblesData={data} width={800} height={600} textFillColor="drakgrey" backgroundColor="#ffffff" minValue={1} maxValue={150} selectedCircle={selectedKeyHandler} />
      </header>
    </div>
  )
}

export default App

注意,我们有一个函数,BubbleChart可以调用它来通过循环selectedKeyHandler,我们将调用一个警报。

app . scss

对于 app 风格,一切照旧;我只是将背景色从默认的深色改为白色,这样图表在本书的印刷版本中会显示得更好。

.App-header {
  background-color: #ffffff;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

节目质量监视

现在我们运行formatlinttest。我们应该得到一切都好的明确确认。这是我确保代码质量的标准做法,就像我们在前面章节中所做的那样。

$ yarn format && yarn lint

对于测试文件,测试模板文件为我们创造了一切;我们只需要实现组件props

// src/component/BubbleChart/BubbleChart.test.tsx

        <BubbleChart bubblesData={[]} width={800} height={600} textFillColor="drakgrey" backgroundColor="#ffffff" minValue={1} maxValue={150} selectedCircle={Function} />
      </RecoilRoot>

我们现在可以运行测试。

$ yarn test

如果你查看我们组件的当前状态(图 8-2 ,你可以看到气泡正在渲染,每个气泡都是可点击的,一旦点击库名,就会给你一个警告。但是,气泡互相覆盖,并且此时没有激活。

img/510438_1_En_8_Fig2_HTML.jpg

图 8-2

没有动画的 BubbleChart

重画并制作动画

现在我们已经有了气泡图,下一步是添加代码,这样我们就可以成功地重新绘制气泡图,并提供动画和更好地显示它们。我们需要能够在数据发生变化的情况下重新绘制。

要进行这些更改,复制BubbleChart.tsx组件,创建一个新文件,并将其命名为BubbleChartWithAnimation.tsx

BubbleChartWithAnimation.tsx

为了制作动画并支持数据的更改,我们可以利用 React componentDidUpdate类组件生命周期。一旦组件基于数据更改进行了更新,我们就可以用新的数据集更新气泡图。为了检查数据是否改变,我们可以使用JSON.stringify来创建数据的克隆,并将prevProps数据与新的props数据进行比较。如果数据改变了,设置新的forceData作为数据,并制作气泡动画。

Note

我只展示了BubbleChart.tsxBubbleChartWithAnimation.tsx之间的变化。

首先,实现componentDidUpdate

// src/component/BubbleChart/BubbleChartWithAnimation.tsx

componentDidUpdate(prevProps: IBubbleChartProps, prevState: IBubbleChartState) {
  if (JSON.stringify(prevProps.bubblesData) !== JSON.stringify(this.props.bubblesData)) {
    this.forceData = this.setForceData(this.props)
    this.animateBubbles()
  }
}

为了使泡沫复活,一切都在那里;我需要做的就是添加一个按钮(我使用的是 Material-UI)并设置 click 事件处理程序来使用我已经有的animateBubbles()方法。我正在使用粗箭头内联函数,所以我不需要绑定我的处理程序。

import { Button } from '@material-ui/core'render()

    return (
      <div>
        <Button
          className="buttonFixed"
          variant="contained"
          color="default"
          onClick={() => {

            this.animateBubbles()
          }}
        >
          Animate
        </Button>
           ...
      </div>
    )
  }
}

App.tsx 更新

对于父组件,让我们添加一个按钮来更新数据。我能做的是随机排序我的数组数据,这样气泡图将模拟数据的变化。突出显示App.tsx的变化。

Note

我只显示了以前的App.tsx文件的变化。

// src/App.tsx
import { Button } from '@material-ui/core'
const changeData = () => {
  setData(d.sort(() => Math.random() - 0.5))
}
return (
    <div className="App">
      <header className="App-header">
        <Button
          className="appButtonFixed"
          variant="contained"
          color="default"
          onClick={() => {
            changeData()
          }}
        >
          Change data
        </Button>
        <BubbleChartWithAnimation bubblesData={data} width={800} height={600} textFillColor="drakgrey" backgroundColor="#ffffff" minValue={1} maxValue={150} selectedCircle={selectedKeyHandler} />
      </header>
    </div>
  )

App.scss 更新

对于App.scss,我将为我的按钮添加样式,使其位于屏幕顶部的固定位置。

.appButtonFixed {
  position: fixed;
  left: -100px;
  top: 60px;
}

看一下图 8-3 中的最终结果。一旦我单击了 change data 按钮,数据就会发生变化,组件就会变成动画。

此外,当我使用气泡图中的动画按钮时,我可以看到我的气泡重新动画,正如预期的那样。

img/510438_1_En_8_Fig3_HTML.jpg

图 8-3

重绘气泡图

您可以从这里下载完整的项目:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch08/bubble-chart

摘要

在这一章中,我向你展示了如何使用 React、D3 和 TypeScript 构建一个简单的气泡图。

我们能够创建自己的组件,并使用 React 来帮助管理状态,并在需要时更新和重绘 DOM。

这种架构设计吸收了所有领域的精华:D3 的模块库让我们可以讲故事,React 虚拟 DOM 确保页面只在发生变化时才呈现。

在下一章,我将向你展示如何创建一个更复杂的网络力图的例子,使用我在本章和前几章中介绍的工具。

九、力图:第二部分

在这一章中,我将向你展示如何创建一个网络图(强制定向图)。我们将创建一个带有机械弹簧行为的网络可视化图表,来表示您可能希望在下一次 React 工作面试之前回顾的面试问题。我们开始吧。

力定向图

力定向图通常用于显示节点之间的关系。

我们将会建造什么?

图 9-1 显示最终结果; https://elielrom.com/ReactQuestions亦见

img/510438_1_En_9_Fig1_HTML.jpg

图 9-1

简单力图最终结果

家政

我把这一小节分成了三个步骤。

  • 第一步:设置

  • 第二步:力图创建

  • 步骤 3 :反冲部件创建

我们开始吧。

设置

创建新项目。

$ yarn create react-app force-chart --template must-have-libraries

安装我们在前一个项目中安装的相同的库。

$ yarn add d3 @types/d3 d3-force @types/d3-force react-uuid

力图组件

对于数据馈送,我将使用 top React 和打字稿面试问题和答案。

就结构而言,数据馈送由节点和链接组成。我把数据文件放在这里:/public/data/power_network.json。看一看它:

/public/data/power_network.json
{
  "results": [
    {
      "nodes": [
        {
          "name": "JavaScript / TypeScript",
          "radiusSize": 20,
          "fillColor": "#fa6502"
        }
      ],
      "links": [
        {
          "source": "Erase a character",
          "target": "JavaScript / TypeScript",
          "value": "How would you erase a character from a string?"
        }
      ]
    }
  ]
}

types.ts

类型文件将保存我将用于SimpleForceGraph的所有 TS 类型。

  • 这些是我们在数据馈送中设置的节点。

  • 这些是我们在数据馈送中设置的链接。

  • dataObject:数据对象包括节点和链接。

  • Point:这代表一个 x 和一个 y。

  • Datum:这是 D3 的内部数据。

看一下代码:

// src/component/SimpleForceGraph/types.ts

export namespace Types {
  export type node = {
    name: string
    group: number
    radiusSize: number
    fillColor: string
  }
  export type link = {
    source: string
    target: string
    value: string
  }
  export type dataObject = {
    nodes: node[]
    links: link[]
  }
  export type point = {
    x: number
    y: number
  }
  export type datum = {
    x: number
    y: number
    fx: number | null
    fy: number | null
  }
}

图形子组件

为了表示图中的元素,我可以用 D3 画出所有的东西。然而,混合使用 D3 和 React 更适合这项工作,因为 React 可读性更好,并且具有内置逻辑,只在刷新时更新 DOM 和样式。

该图由多个圆圈、链接和标签组成。我将把这些元素分解成相应的文件。

  • Circles.tsx 和 Circle.tsx :用于圆的元素

  • Links.tsx 和 Link.tsx :用于 Link(节点间的连线)元素

  • Labels.tsx 和 Label.tsx :每个节点的文本标签

Circles.tsx 和 Circle.tsx

我将创建一个包含所有节点的组件。每个节点将被表示为一个圆,但是这可以被改变为我们喜欢的任何形状。

Circles.tsx

我将创建一个包含所有节点的组件。每个节点将被表示为一个圆,但是如果你愿意,这可以被改变为其他的东西。

为了实现这一点,我将传递节点数据以及restartDragstopDrag方法,这样我的父组件就可以告诉何时停止这种交互。我宁愿让我的父组件知道发生了什么,以防我需要添加额外的逻辑。看一下完整的代码:

// src/component/SimpleForceGraph/Circles.tsx
import * as React from 'react'
import * as d3 from 'd3'
import { D3DragEvent } from 'd3'
import Circle from './Circle'
import { Types } from './types'

const uuid = require('react-uuid')

export default class Circles extends React.PureComponent<ICirclesProps, {}> {
  componentDidMount() {
    this.setMouseEventsListeners()
  }

  componentDidUpdate(prevProps: ICirclesProps) {}

  setMouseEventsListeners = () => {
    const { props } = this
    d3.selectAll('.node')
      // @ts-ignore
      .call(d3.drag<SVGCircleElement, Types.datum>().on('start', onDragStart).on('drag', onDrag).on('end', onDragEnd))

    // @ts-ignore
    function onDragStart(event: D3DragEvent<SVGCircleElement>, d: Types.datum) {
      if (!event.active) {
        props.restartDrag()
      }
      // eslint-disable-next-line no-param-reassign
      d.fx = d.x
      // eslint-disable-next-line no-param-reassign
      d.fy = d.y
    }

    function onDrag(event: D3DragEvent<SVGCircleElement, never, never>, d: Types.datum) {
      // eslint-disable-next-line no-param-reassign
      d.fx = event.x
      // eslint-disable-next-line no-param-reassign
      d.fy = event.y
    }

    function onDragEnd(event: D3DragEvent<SVGCircleElement, never, never>, d: Types.datum) {
      if (!event.active) {
        props.stopDrag()
      }
      // eslint-disable-next-line no-param-reassign
      d.fx = null
      // eslint-disable-next-line no-param-reassign
      d.fy = null
    }
  }

  render() {
    const nodes = this.props.nodes.map((node: Types.node) => {
      return <Circle key={`node-${uuid()}`} node={node} />
    })
    return <g className="nodes">{nodes}</g>
  }
}

interface ICirclesProps {
  nodes: Types.node[]
  restartDrag: () => void
  stopDrag: () => void
}

注意,对于鼠标事件处理程序,我使用了 D3 拖动事件,并为 D3 设置了新的 x 和 y。有一个事件需要改变。

  setMouseEventsListeners = () => {
    const { props } = this
    d3.selectAll('.node')
      .call(d3.drag<SVGCircleElement, Types.datum>().on('start', onDragStart).on('drag', onDrag).on('end', onDragEnd))

    function onDragStart(event: D3DragEvent<SVGCircleElement>, d: Types.datum) {
      if (!event.active) {

        props.restartDrag()
      }
      d.fx = d.x
      d.fy = d.y
    }

    function onDrag(event: D3DragEvent<SVGCircleElement, never, never>, d: Types.datum) {
      d.fx = event.x
      d.fy = event.y
    }

    function onDragEnd(event: D3DragEvent<SVGCircleElement, never, never>, d: Types.datum) {
      if (!event.active) {
        props.stopDrag()
      }
      d.fx = null
      d.fy = null
    }
  }

对于 render 方法,我将通过数据映射来绘制每个节点,并为每个节点设置一个唯一的键,以便 React 执行 VDOM,从而知道哪个元素需要更新才能使 React 执行得更好。

  render() {
    const nodes = this.props.nodes.map((node: Types.node) => {
      return <Circle key={`node-${uuid()}`} node={node} />
    })
    return <g className="nodes">{nodes}</g>
  }
}

Circle.tsx

对于每个节点,我使用一个引用(ref),一旦节点被挂载,我将使用节点数据中的数据来填充和设置每个节点的颜色和大小。

// src/component/SimpleForceGraph/Circle.tsx

import * as React from 'react'
import * as d3 from 'd3'
import { Types } from './types'

export default class Circle extends React.Component<{ node: Types.node }> {
  ref: SVGCircleElement | undefined

  componentDidMount() {
    if (this.ref) d3.select(this.ref).data([this.props.node])
  }

  render() {
    return (
      // eslint-disable-next-line no-return-assign
      <circle className="node" r={this.props.node.radiusSize} fill={this.props.node.fillColor as string} ref={(ref: SVGCircleElement) => (this.ref = ref)}>
        <title>{this.props.node.name}</title>
      </circle>
    )
  }
}

Links.tsx 和 Link.tsx

对于链接(每个节点之间的线路),我遵循为节点设置的相同体系结构。我创建一个 links 子组件,附加事件,并映射每个项目来绘制每个链接。

Links.tsx

注意,因为我只需要我的props的链接数据,所以我只是在签名中设置它,而不是创建一个props接口。

在渲染时,我通过数据映射来绘制每个链接。

// src/component/SimpleForceGraph/Links.tsx

import * as React from 'react'
import Link from './Link'
import { Types } from './types'

const uuid = require('react-uuid')

export default class Links extends React.PureComponent<{ links: Types.link[] }> {
  render() {
    const links = this.props.links.map((link: Types.link) => {
      return <Link key={`links-${uuid()}`} link={link} />
    })
    return <g className="links">{links}</g>
  }

}

链接本身为每个映射的项目调用link.tsx组件,以保持代码整洁。

Link.tsx

每个链接都用一个 SVG 线路径(线)来表示。我还放置事件,并在鼠标悬停事件上显示数据。看看完整的Link.tsx代码:

// src/component/SimpleForceGraph/Link.tsx
import * as React from 'react'
import * as d3 from 'd3'
import { Types } from './types'

export default class Link extends React.PureComponent<ILinkProps> {
  ref: SVGElement | undefined

  componentDidMount() {
    if (this.ref) d3.select(this.ref).data([this.props.link])
  }

  // eslint-disable-next-line class-methods-use-this
  onMouseOverHandler(event: React.MouseEvent<SVGLineElement, MouseEvent>, link: ILinkProps) {
    d3.select('.linkGroup')
      .append('text')
      .attr('class', 'linkTextValue')
      .text((link.link.value as string).replace(/(.{50})..+/, '$1…'))
      .attr('x', event.nativeEvent.offsetX)
      .attr('y', event.nativeEvent.offsetY)
  }

  // eslint-disable-next-line class-methods-use-this
  onMouseOutHandler() {
    d3.select('.linkTextValue').remove()
  }

  render() {
    return (
      <g className="linkGroup">
        <line
          // eslint-disable-next-line no-return-assign
          ref={(ref: SVGLineElement) => (this.ref = ref)}
          className="link"
          onMouseOver={(event) => {
            this.onMouseOverHandler(event, this.props)
          }}
          onMouseOut={(event) => {
            this.onMouseOutHandler()
          }}
        />
      </g>
    )
  }
}

interface ILinkProps {
  link: Types.link
}

注意,在 mouseover 事件中,我使用 D3 选择 link group 元素并添加文本副本。

  onMouseOverHandler(event: React.MouseEvent<SVGLineElement, MouseEvent>, link: ILinkProps) {
    d3.select('.linkGroup')
      .append('text')
      .attr('class', 'linkTextValue')
      .text((link.link.value as string).replace(/(.{50})..+/, '$1...'))
      .attr('x', event.nativeEvent.offsetX)
      .attr('y', event.nativeEvent.offsetY)
  }

鼠标离开时,我使用 D3 删除文本。

  onMouseOutHandler() {
    d3.select('.linkTextValue').remove()
  }

呈现包括 SVG 组和事件行。

      <g className="linkGroup">
        <line
          ref={(ref: SVGLineElement) => (this.ref = ref)}
          className="link"
          onMouseOver={(event) => {
            this.onMouseOverHandler(event, this.props)
          }}
          onMouseOut={(event) => {
            this.onMouseOutHandler()
          }}
        />
      </g>

Labels.tsx 和 Label.tsx

对于标签,我可以遵循与呈现节点和链接相同的过程。

Labels.tsx

标签将迭代每个节点的所有标签。接口将是节点,我还设置了一个选定的节点调度程序来将数据传递回父图。

// src/component/SimpleForceGraph/Labels.tsx
import * as React from 'react'
import { Dispatch, SetStateAction } from 'react'
import Label from './Label'
import { Types } from './types'

const uuid = require('react-uuid')

export default class Labels extends React.PureComponent<ILabelsProps> {
  render() {
    const labels = this.props.nodes.map((node: Types.node) => {
      return <Label key={`label-${uuid()}`} node={node} onNodeSelected={this.props.onNodeSelected} />
    })
    return <g className="labels">{labels}</g>
  }
}

interface ILabelsProps {
  nodes: Types.node[]
  onNodeSelected: Dispatch<SetStateAction<number>>
}

请注意,我通过映射数组来创建每个标签。

<Label key={`label-${uuid()}`} node={node} onNodeSelected={this.props.onNodeSelected} />

Label.tsx

对于每个标签,我需要节点数据和 dispatcher 来指示所选择的标签。

Note

这段代码可以改为使用上下文,而不是我在这里做的props演练,但是我想让代码保持简单。请随意重构它。

import * as React from 'react'
import * as d3 from 'd3'
import { Dispatch, SetStateAction } from 'react'
import { Types } from './types'

export default class Label extends React.PureComponent<ILabelProps> {
  ref: SVGTextElement | undefined

  componentDidMount() {
    if (this.ref) d3.select(this.ref).data([this.props.node])
  }

  render() {

    return (
      <text
        style={{ cursor: 'pointer' }}
        className="label"
        // eslint-disable-next-line no-return-assign
        ref={(ref: SVGTextElement) => (this.ref = ref)}
        onClick={() => {
          this.props.onNodeSelected(((this.props.node as unknown) as { index: number }).index - 1)
        }}
      >
        {this.props.node.name}
      </text>
    )
  }
}

interface ILabelProps {
  node: Types.node
  onNodeSelected: Dispatch<SetStateAction<number>>
}

simple force graph . tsx-简单力图. tsx

简单的力图顾名思义将是主要的图形组件。

就接口而言,我将展示对齐的属性,如宽度、高度、距离、链接强度和居中。

此外,数据将从父组件传递,最后,我将传递一个函数,以便在用户选择一个节点时传递数据。以下是完整的代码:

// src/component/SimpleForceGraph/SimpleForceGraph.tsx
import * as React from 'react'
import * as d3 from 'd3'
import './SimpleForceGraph.scss'
import { Simulation, SimulationNodeDatum } from 'd3-force'
import { Dispatch, SetStateAction } from 'react'
import Links from './Links'
import Circles from './Circles'
import Labels from './Labels'
import { Types } from './types'

class SimpleForceGraph extends React.PureComponent<ITopContentPowerChartProps, ITopContentPowerChartState> {

  private simulation: Simulation<SimulationNodeDatum, undefined> | undefined

  constructor(props: ITopContentPowerChartProps) {
    super(props)
    this.state = {
      // EE: the clone data is needed to avoid:
      // TypeError: Cannot add property index, object is not extensible
      clonedData: JSON.parse(JSON.stringify(this.props.data)),
    }
  }

  componentDidMount() {
    this.simulatePositions()
    this.drawTicks()
    this.addZoomCapabilities()
  }

  componentDidUpdate(prevProps: ITopContentPowerChartProps, prevState: ITopContentPowerChartState) {
    this.simulatePositions()
    this.drawTicks()
  }

  simulatePositions = () => {
    this.simulation = d3
      .forceSimulation()
      .nodes(this.state.clonedData?.nodes as SimulationNodeDatum[])
      .force(
        'link',
        d3
          .forceLink()
          .id((d) => {
            return (d as Types.node).name
          })
          .distance(this.props.linkDistance)
          .strength(this.props.linkStrength)
      )
      .force('charge', d3.forceManyBody().strength(this.props.chargeStrength))
      .force('center', d3.forceCenter(this.props.centerWidth, this.props.centerHeight))

    // @ts-ignore
    this.simulation.force('link').links(this.state.clonedData?.links)
  }

  drawTicks = () => {
    const nodes = d3.selectAll('.node')
    const links = d3.selectAll('.link')
    const labels = d3.selectAll('.label')

    if (this.simulation) {
      this.simulation.nodes(this.state.clonedData?.nodes as SimulationNodeDatum[]).on('tick', onTickHandler)
    }

    function onTickHandler() {
      links
        .attr('x1', (d) => {
          return (d as { source: Types.point }).source.x
        })
        .attr('y1', (d) => {
          return (d as { source: Types.point }).source.y
        })
        .attr('x2', (d) => {
          return (d as { target: Types.point }).target.x
        })
        .attr('y2', (d) => {
          return (d as { target: Types.point }).target.y
        })
      nodes
        .attr('cx', (d) => {
          return (d as Types.point).x
        })
        .attr('cy', (d) => {
          return (d as Types.point).y
        })
      labels
        .attr('x', (d) => {
          return (d as Types.point).x + 5
        })
        .attr('y', (d) => {
          return (d as Types.point).y + 5
        })
    }
  }

  addZoomCapabilities = () => {
    const container = d3.select('.container')
    const zoom = d3
      .zoom()
      .scaleExtent([1, 8])
      .translateExtent([

        [100, 100],
        [300, 300],
      ])
      .extent([
        [100, 100],
        [200, 200],
      ])
      .on('zoom', (event) => {
        let { x, y, k } = event.transform
        x = 0
        y = 0
        k *= 1
        container.attr('transform', `translate(${x}, ${y})scale(${k})`).attr('width', this.props.width).attr('height', this.props.height)
      })

    // @ts-ignore
    container.call(zoom)
  }

  restartDrag = () => {
    if (this.simulation) this.simulation.alphaTarget(0.2).restart()
  }

  stopDrag = () => {
    if (this.simulation) this.simulation.alphaTarget(0)
  }

  render() {
    if (JSON.stringify(this.props.data) !== JSON.stringify(this.state.clonedData)) {
      this.setState({
        clonedData: JSON.parse(JSON.stringify(this.props.data)),
      })
    }
    const initialScale = 1
    const initialTranslate = [0, 0]
    const { width, height } = this.props
    return (
      <svg className="container" x={0} y={0} width={width} height={height} transform={`translate(${initialTranslate[0]}, ${initialTranslate[1]})scale(${initialScale})`}>
        <g>
          <Links links={this.state.clonedData?.links as Types.link[]} />
          <Circles nodes={this.state.clonedData?.nodes as Types.node[]} restartDrag={this.restartDrag} stopDrag={this.stopDrag} />
          <Labels nodes={this.state.clonedData?.nodes as Types.node[]} onNodeSelected={this.props.onNodeSelected} />
        </g>
      </svg>
    )
  }

}

interface ITopContentPowerChartProps {
  width: number
  height: number
  data: Types.dataObject
  onNodeSelected: Dispatch<SetStateAction<number>>
  linkDistance: number
  linkStrength: number
  chargeStrength: number
  centerWidth: number
  centerHeight: number
}

interface ITopContentPowerChartState {
  clonedData: Types.dataObject
}

export default SimpleForceGraph

让我们回顾一下代码。

D3 扩展了为 force graph 提供的数据,所以我需要克隆这个项目,因为 D3 添加了一个索引,我不想修改从父组件传递的原始数据。

这不仅仅是好的实践;这也将避免我们得到这个类型脚本错误:“类型错误:无法添加属性索引,该对象是不可扩展的。”

interface ITopContentPowerChartState {
  clonedData: Types.dataObject
}

对于导入部分,我将添加一个 React 库和链接,圆,标签子组件,以及 D3,d3-force库,和一个样式 SCSS 文件。

import * as React from 'react'
import * as d3 from 'd3'
import './SimpleForceGraph.scss'
import { Simulation, SimulationNodeDatum } from 'd3-force'
import { Dispatch, SetStateAction } from 'react'
import Links from './Links'
import Circles from './Circles'
import Labels from './Labels'
import { Types } from './types'

class SimpleForceGraph extends React.PureComponent<ITopContentPowerChartProps, ITopContentPowerChartState> {

我以私人成员的身份持有力模拟的副本,所以我可以通过组件使用它。

  private simulation: Simulation<SimulationNodeDatum, undefined> | undefined

签名包括props和我将用于 D3 的克隆数据。

  constructor(props: ITopContentPowerChartProps) {
    super(props)
    this.state = {
      clonedData: JSON.parse(JSON.stringify(this.props.data)),
    }
  }

一旦组件安装完毕,我将设置力模拟的位置,绘制每个项目,并添加缩放功能。

  componentDidMount() {
    this.simulatePositions()
    this.drawTicks()
    this.addZoomCapabilities()
  }

如果我想更新数据,我需要设置componentDidUpdate来确保 DOM 被重画。

  componentDidUpdate(prevProps: ITopContentPowerChartProps, prevState: ITopContentPowerChartState) {
    this.simulatePositions()
    this.drawTicks()
  }

对于 D3 力模拟,我正在设置诸如居中、强度和链接距离等属性。

  simulatePositions = () => {
    this.simulation = d3
      .forceSimulation()
      .nodes(this.state.clonedData?.nodes as SimulationNodeDatum[])
      .force(
        'link',
        d3
          .forceLink()
          .id((d) => {

            return (d as Types.node).name
          })
          .distance(this.props.linkDistance)
          .strength(this.props.linkStrength)
      )
      .force('charge', d3.forceManyBody().strength(this.props.chargeStrength))
      .force('center', d3.forceCenter(this.props.centerWidth, this.props.centerHeight))

    // @ts-ignore
    this.simulation.force('link').links(this.state.clonedData?.links)
  }

我正在设置一个方法来绘制一切,设置一个基于每个时间跨度的刻度句柄,并重置新的 x,y 值。

  drawTicks = () => {
    const nodes = d3.selectAll('.node')
    const links = d3.selectAll('.link')
    const labels = d3.selectAll('.label')

    if (this.simulation) {
      this.simulation.nodes(this.state.clonedData?.nodes as SimulationNodeDatum[]).on('tick', onTickHandler)
    }

    function onTickHandler() {
      links
        .attr('x1', (d) => {
          return (d as { source: Types.point }).source.x
        })
        .attr('y1', (d) => {
          return (d as { source: Types.point }).source.y
        })
        .attr('x2', (d) => {
          return (d as { target: Types.point }).target.x
        })
        .attr('y2', (d) => {
          return (d as { target: Types.point }).target.y
        })
      nodes
        .attr('cx', (d) => {
          return (d as Types.point).x
        })
        .attr('cy', (d) => {
          return (d as Types.point).y
        })
      labels
        .attr('x', (d) => {
          return (d as Types.point).x + 5
        })
        .attr('y', (d) => {
          return (d as Types.point).y + 5
        })
    }
  }

接下来,我将添加addZoomCapabilities方法来处理鼠标和触控板缩放功能。我使用最少的代码来获得工作的功能。我们可以改进这段代码,但是我想保持这个组件简单。注意,D3 需要设置缩放,然后使用调用来设置这些处理程序。

  addZoomCapabilities = () => {
    const container = d3.select('.container')
    const zoom = d3
      .zoom()
      .scaleExtent([1, 8])
      .translateExtent([
        [100, 100],
        [300, 300],
      ])
      .extent([
        [100, 100],
        [200, 200],
      ])
      .on('zoom', (event) => {
        let { x, y, k } = event.transform
        x = 0
        y = 0
        k *= 1
        container.attr('transform', `translate(${x}, ${y})scale(${k})`).attr('width', this.props.width).attr('height', this.props.height)
      })

    // @ts-ignore
    container.call(zoom)
  }

如果您还记得当我绘制节点时,我将重新启动和停止拖动处理程序传递给了父节点;它们在这里,一旦阻力停止,我可以处理模拟和动画的力量图:

  restartDrag = () => {
    if (this.simulation) this.simulation.alphaTarget(0.2).restart()
  }

  stopDrag = () => {
    if (this.simulation) this.simulation.alphaTarget(0)
  }

对于渲染方法,我将数据设置为缩放比例(用于缩放功能)。最后,我正在添加我的子组件,这样链接、圆圈和标签就会显示出来。

  render() {
    if (JSON.stringify(this.props.data) !== JSON.stringify(this.state.clonedData)) {
      this.setState({
        clonedData: JSON.parse(JSON.stringify(this.props.data)),
      })
    }
    const initialScale = 1
    const initialTranslate = [0, 0]
    const { width, height } = this.props
    return (
      <svg className="container" x={0} y={0} width={width} height={height} transform={`translate(${initialTranslate[0]}, ${initialTranslate[1]})scale(${initialScale})`}>
        <g>
          <Links links={this.state.clonedData?.links as Types.link[]} />
          <Circles nodes={this.state.clonedData?.nodes as Types.node[]} simulation={this.simulation} restartDrag={this.restartDrag} stopDrag={this.stopDrag} />
          <Labels nodes={this.state.clonedData?.nodes as Types.node[]} onNodeSelected={this.props.onNodeSelected} />
        </g>
      </svg>
    )
  }
}

export default SimpleForceGraph

反冲小部件

我已经准备好了力图。现在最后一部分是设置数据,并将力图组件包含在App.tsx父组件中。

对于数据管理,我将使用反冲,就像我们在前面的章节中所做的那样。我将设置一个选择器,使用最少的代码获取数据,并设置一个小部件组件。最后,我将在App.tsx中包含这个小部件。这个过程分为三个部分,我们将使用这三个组件;

  • 权力图选择器

  • 网络部件

  • 应用

powerChartSelectors.ts

反冲选择器会拉 JSON 文件,并将其转换为Types.dataObject。我可以创建一个 atom,而不是使用我的 force 图中的 TS 类型,但是我想保持代码最少,不需要 atom。

// src/recoil/selectors/powerChartSelectors.ts

import { selector } from 'recoil'
import { Types } from '../../components/SimpleForceGraph/types'

export const getPowerChartData = selector({
  key: 'getPowerChartData',
  get: () => {
    return getDataFromAPI()
  },
})
const getDataFromAPI = () =>
  new Promise((resolve) =>
    fetch('/data/power_network.json').then((response) => {
      if (response.status !== 200) {
        // eslint-disable-next-line no-console
        console.log(`Houston, we have a problem! ${response.status}`)
        return
      }

      response.json().then((data) => {
        const d = data.results[0] as Types.dataObject
        resolve(d)
      })
    })
  )

NetworksWidget.tsx

现在我们已经有了所有的部分,即力图和反冲选择器,最后一步是实现它们。

正如您在前面的章节中看到的,使用小部件非常好,因为我可以添加其他相关组件、共享数据并提供交互性。看一下完整的代码:

// src/component/QuestionsWidget/QuestionsWidget
import React, { useState } from 'react'
import './NetworksWidget.scss'
import { useRecoilValue } from 'recoil'
import SimpleForceGraph from '../../components/SimpleForceGraph/SimpleForceGraph'
import { Types } from '../../components/SimpleForceGraph/types'
import { getPowerChartData } from '../../recoil/selectors/powerChartSelectors'

const NetworksWidget = () => {
  const forceData: Types.dataObject = useRecoilValue(getPowerChartData) as Types.dataObject
  const [selectedIndex, setSelectedIndex] = useState(0)

  return (
    <>

      {forceData ? (
        <>

          <div className="selectedText">Selected Index: {selectedIndex}</div>
          <div className="wrapperDiv">
            <SimpleForceGraph
              width={800}
              height={350}
              data={forceData}
              onNodeSelected={setSelectedIndex}
              linkDistance={80}
              linkStrength={1}
              chargeStrength={-20}
              centerWidth={350}
              centerHeight={170}
            />
          </div>

        </>

      ) : (
        <>Loading</>
      )}

    </>
  )
}
export default NetworksWidget

我们来复习一下。

我的选择器数据将在forceData中被捕获,我还添加了一个状态以传递给我的力图,这样我就可以知道一个被选择的节点。

  const forceData: Types.dataObject = useRecoilValue(getPowerChartData) as Types.dataObject
  const [selectedIndex, setSelectedIndex] = useState(0)

为了渲染组件,我只在反冲选择器组件设置了forceData后才使用props设置力图;否则,它会显示加载消息。

一旦用户选择了一个节点,{selectedIndex}就被绑定并将显示所选择的节点。

  return (
    <>

      {forceData ? (

       <>
          <div className="selectedText">Selected Index: {selectedIndex}</div>
          <div className="wrapperDiv">
            <SimpleForceGraph
              width={800}
              height={350}
              data={forceData}
              onNodeSelected={setSelectedIndex}
              linkDistance={80}
              linkStrength={1}
              chargeStrength={-20}
              centerWidth={350}
              centerHeight={170}
            />
          </div>
        </>

      ) : (
        <>Loading</>

      )}

    </>
  )
}

networkswidget . scss

最后,我设置了一个包装器div来裁剪我的图表,这样它就不会溢出其他内容,然后我设置了我用来显示所选索引的所选文本。

.wrapperDiv {
  width: 800px;
  height: 350px;
  clip-path: inset(10px 20px 30px 40px);
}

.selectedText {
  font-size: 13px;
  color: #373636;
}

请记住,还有其他方法可以确保内容不出血。这只是一个简单的选择。

父组件应用

我的父组件很简单;只需添加NetworksWidget文件。

App.tsx

代码如下:

// src/App.tsx

import React from 'react'
import './App.scss'
import NetworksWidget from './widgets/NetworksWidget/NetworksWidget'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <NetworksWidget />
      </header>
    </div>
  )
}

export default App

app . scss

对于App.tsx样式,我将颜色改为白色,就像我对气泡图所做的那样,这样图像在本书的印刷版本中看起来更好。

.App-header {
  background-color: #ffffff;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

就这样!导航到端口 3000 进行检查。继续拖动节点,看看力模拟如何将它们拉回到中心。

现在,我能用这张图表做什么?

我可以将我的图表与我创建的动画书联系起来,以显示 React 并打印面试问题和答案,就像在 https://elielrom.com/ReactQuestions (图 9-2 )。

img/510438_1_En_9_Fig2_HTML.jpg

图 9-2

活动挂图

您可以从这里下载该项目:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch09/force-chart

摘要

在这一章中,我向你展示了如何使用 React、D3 和 TypeScript 构建一个力定向图表。

我使用反冲状态管理来获取数据,添加逻辑以便能够共享状态。我添加了一个小部件来包含图表。

这一章向你展示了如何创建一个更复杂的图表,使用我在前面章节中提到的工具。

我们让 React 管理我们的状态,并在需要时更新和重绘 DOM。这给了我们最好的一切:来自 D3 的模块库,让我们重用逻辑、计算、力模拟,甚至鼠标事件处理,并在虚拟 DOM 的帮助下做出 React,以确保页面只在发生变化时才呈现。

在下一章,我将介绍如何集成基于 D3 构建的流行图表库。

十、集成基于 D3 的流行图表库

正如我们在本书中所提到的,D3 是创建图表的标准。

我们已经看到了如何将 React 的功能与 D3 结合起来。这带来了一个额外的好处,因为利用 React VDOM 可以确保 DOM 只在需要的时候更新。您还可以受益于其他 React 库。

在这一章中,我将探索一些最流行的 React/D3 库。我将首先介绍我用来测试每个 React D3 库的标准,并对它们进行比较。在本章的第二部分,我将向你展示如何实现每一个库,并给出每一个库的优缺点。

请记住,我没有在每个库上工作很长时间,所以我的观点仅限于我从实现每个示例中学到的东西。这些库是有生命的,这里提供的信息可能会有变化,所以我建议你自己去看看这些库。

为什么使用现成的组件?

在 D3 的基础上构建了大量的库,包括已经过测试的现成组件。它们是跨平台的,包括文档、社区支持、示例、模拟数据等等。

也就是说,使用图表“库”创建一个真正创新的可视化图表可能是一个挑战,并且您很可能会发现自己需要使用 D3 从头创建图表,或者派生您正在使用的原始库来实现对图表的更多控制。

然而,这些第三方库确实有它们的用途,可以帮助创建概念证明(POC ),或者在这些香草风味满足您的需求的情况下加快开发。

我想指出的是,使用这些库是有代价的。许多库可能没有被设置为模块,这可能会使您的包膨胀为一个简单的图表。通用图表在网上随处可见,因为它们可能被数百甚至数千个应用使用,它们可能有许多需要很长时间才能解决的错误。

Caution

我想指出,使用这些 D3 React 库是有代价的。许多库没有被设置为模块,这可能会增加一个简单图表的数量。

基于 D3 构建的流行 React 图表

当我说“受欢迎”时,我是基于 GitHub 参与和我个人的观点来陈述的。

我发现的最受欢迎的网站如下:

基于 D3 构建的流行 React 图表的比较

表 10-1 列出了我根据不同标准得出的结果。

表 10-1。 热门 React 排行榜

img/510438_1_En_10_Figa_HTML.png

让我们回顾一下这些标准。

支持 TS

让 TypeScript (TS)作为类型检查器对我来说是必须的。即使您现在不使用 TS,将来也可能会使用。

很多时候,只需要参与者添加类型,或者您可能需要自己设置类型。如果你有一个严格的时间表,你可能没有这样做的奢侈。

幸运的是,我介绍的所有库都包含类型支持。请记住,React-vis 包括对主库的 TS 支持,但不包括对许多模块的支持。

模块

基于模块方法的库比包含整个库要好。

在表 1-1 中,我使用了每个模块的成本(如果可能的话)。如果你需要一个可以用 D3 创建的简单图表,只需要几行代码,那么使用这些库是没有意义的,特别是因为 D3 是由 D3 版本 4 及更高版本的模块组成的。

Rechart 不包括模块化,必须包括和使用整个库。React-vis 有一个相当大的主库,其他库是独立的模块。

我检查的其余库都是按模块组织的,当我只使用一个简单的图表时,花费很少。

模拟数据

拥有模拟数据使得创建图表变得更加容易,而不是必须提供数据并进行设置。有了模拟数据,一开始使用库就变得更加愉快。

Victory 带来的一个小惊喜是,它包含了模拟数据,因此无需自带数据。

简单

当我谈到简单性时,我指的是准备好图表需要多长时间。大多数图表库都有很好的文档和例子,并且很容易上手。

Visx 看起来是所有版本中最复杂的,因为它是由小部分组成的。这在灵活性方面非常好,但是需要更多的时间来学习和实现。

已经设计好了

拥有一个外观漂亮且易于设计的库大有裨益。

很高兴看到 React-vis 中包含了样式表,与其他一些看起来更像需要样式化的线框的库相比,Visx 看起来很棒。

文档和示例

拥有好的文档和例子是至关重要的,可以帮助你理解如何实现它们。

Nivo 是一个挑战,因为我不能在屏幕上呈现和显示任何东西,只是发现包装容器需要设置容器的宽度和高度。

让文档尽可能简单是关键。大多数库通过文档和例子很容易理解。

贡献者、受欢迎程度和未决问题

知名度、开放问题和贡献者是一个很大的考虑因素。Rechart 在人气方面领先。

Visx 排在第二位,因为它有最少的公开 bug,所以得到了额外的分数。React-vis 在受欢迎程度和支持度方面都排在最后。

实现库

在这一节中,我将实现每个库,尝试看看它有多容易上手。我将实现以下库:

  • 重新开始

  • 维斯克斯

  • 获胜

  • 清华普天

  • 对…做出 React

启动项目

使用 CRA 和 MHL 模板项目创建一个新项目。

$ yarn create react-app react-chart-libraries --template must-have-libraries
$ cd react-chart-libraries
$ yarn start
$ open http://localhost:3000

重新开始

Rechart ( https://recharts.org/en-US/ )被描述为构建在 React 组件上的可组合图表库。

Rechart 是我找到的最流行的库,它是用 React 和 D3 构建的。它有 15800 颗星星,在 GitHub ( https://github.com/recharts/recharts )上还有一个 1.2K 的叉子。

此外,它有一个专门的网站,有例子和文档,它似乎得到了很好的维护,有 177 个贡献者,但有 130 个开放的问题。

该库基于声明性组件(仅表示性组件),是轻量级的,仅包含几个 D3 模块,并支持 SVG。

设置

安装 Rechart 和 TS 类型。

yarn add recharts @types/recharts

实施重新计费

我将实现一个简单的折线图,并使用 SVG 作为自定义的 x 轴和 y 轴。

对于这些类型,我将设置一个文件来保存我将要设置的数据对象。

types.ts

文件如下:

// src/component/SimpleLineChart/types.ts

export namespace Types {
  export type Data = {
    date: string
    value: number
  }
}

simplelinechart component 简单线条图表元件

我正在实现一个简单的折线图。看一下完整的代码:

// src/component/SimpleLineChart/SimpleLineChart.tsx
import React from 'react'
import { LineChart, Line, XAxis, CartesianGrid, Tooltip, YAxis } from 'recharts'
import { Types } from './types'

const CustomizedAxisTick = (props: { x: number; y: number; payload: { value: string } }) => {
  return (
    <g transform={`translate(${props.x},${props.y})`}>
      <text fontSize={12} x={0} y={0} dy={16} textAnchor="end" fill="black" transform="rotate(-35)">
        {props.payload.value}
      </text>
    </g>
  )
}

const CustomizedYAxisTick = (props: { x: number; y: number; payload: { value: string } }) => {
  return (
    <g transform={`translate(${props.x},${props.y})`}>
      <text fontSize="12px" x={0} y={0} dy={0} textAnchor="end" fill="black">
        {props.payload.value}
      </text>
    </g>
  )
}

const EmptyDot = () => {
  return <></>
}

const SimpleLineChart = (props: ISimpleLineChartProps) => {
  return (
    <>
      <LineChart
        width={500}
        height={300}
        data={props.data}
        margin={{
          top: 5,
          right: 30,
          left: 20,
          bottom: 5,
        }}
      >
        <CartesianGrid strokeDasharray="3 3" />
        <XAxis
          height={60}
          dataKey="date"
          // @ts-ignore
          tick={<CustomizedAxisTick />}
        />
        <YAxis
          // @ts-ignore
          tick={<CustomizedYAxisTick />}
        />
        <Tooltip />
        <Line type="monotone" dataKey="value" stroke="#8884d8" dot={<EmptyDot />} />
      </LineChart>

    </>
  )
}

interface ISimpleLineChartProps {
  data: Types.Data[]
}

export default SimpleLineChart

我们来复习一下。

我正在设置一个函数组件,该组件将具有自定义文本,并且我将呈现旋转了 35 度的 x 轴。看一看:

const CustomizedAxisTick = (props: { x: number; y: number; payload: { value: string } }) => {
  return (
    <g transform={`translate(${props.x},${props.y})`}>
      <text fontSize={12} x={0} y={0} dy={16} textAnchor="end" fill="black" transform="rotate(-35)">
        {props.payload.value}
      </text>
    </g>

  )
}

至于自定义 y 轴,我只是使用 SVG 设置一些自定义文本:

const CustomizedYAxisTick = (props: { x: number; y: number; payload: { value: string } }) => {
  return (
    <g transform={`translate(${props.x},${props.y})`}>
      <text fontSize="12px" x={0} y={0} dy={0} textAnchor="end" fill="black">
        {props.payload.value}
      </text>
    </g>
  )
}

默认情况下,图表的每个点上都有点,因为我有很多点,我不想在图表上看到它们,所以我将设置一个空的渲染,而不是显示它们。

const EmptyDot = () => {
  return <>
}

const SimpleLineChart = ( props : ISimpleLineChartProps ) => {
  return (
    <>

对于折线图,我使用了 Rechart LineChart 组件,并为 x 轴、y 轴和点设置了自定义 SVG。

      <LineChart
        width={500}
        height={300}
        data={props.data}
        margin={{
          top: 5,
          right: 30,
          left: 20,
          bottom: 5,
        }}
      >

        <CartesianGrid strokeDasharray="3 3" />
        <XAxis
          height={60}
          dataKey="date"
          // @ts-ignore
          tick={<CustomizedAxisTick />}
        />
        <YAxis
          // @ts-ignore
          tick={<CustomizedYAxisTick />}
        />
        <Tooltip />
        <Line
          type="monotone"
          dataKey="value"
          stroke="#8884d8"
          dot={<EmptyDot />}
        />
      </LineChart>

    </>
  )
}

lineDataSelectors.ts

对于数据,我使用反冲选择器通过 D3 获取数据。

// src/recoil/selectors/lineDataSelectors.ts

import { selector } from 'recoil'
import * as d3 from 'd3'

export const getLineData = selector({
  key: 'getLineData',
  get: () => {
    return getData()
  },
})

const getData = () =>
  new Promise((resolve) =>
    d3
      .dsv(',', '/Data/line.csv', (d) => {
        const res = d as { date: string; data: string }
        const value = parseFloat(res.data as string)
        const { date } = res
        return {
          date,
          value,
        }
      })
      .then((data) => {
        resolve(data)
      })
  )

App.tsx

最后一部分是让父组件App.tsx使用反冲来获取状态,并将其传递给图表组件。

// src/App.tsx

import React from 'react'
import './App.scss'

import { useRecoilValue } from 'recoil'
import SimpleLineChart from './components/SimpleLineChart/SimpleLineChart'
import { Types } from './components/SimpleLineChart/types'
import { getLineData } from './recoil/selectors/lineDataSelectors'

function App() {
  const data = useRecoilValue(getLineData) as Types.Data[]

  return (
    <div className="App">
      <header className="App-header">
        <SimpleLineChart data={data} />
      </header>
    </div>
  )

}

export default App

你可以在图 10-1 中看到最终的结果。

img/510438_1_En_10_Fig1_HTML.jpg

图 10-1

重新绘制折线图组件

费用

一个图表的 Rechart 的开销是 186.5KB 解析;见图 10-2 。这并不像我想象中的大众图书馆那么小;然而,考虑到我们所得到的,这个大小是可以接受的。

img/510438_1_En_10_Fig2_HTML.jpg

图 10-2

重新计算图书馆成本

赞成的意见

我喜欢使用 SVG 定制我的图表如此简单。可用图表的数量、受欢迎程度和社区参与度也给我留下了深刻的印象。很容易理解这个图书馆的受欢迎程度。

骗局

每个组件都不是模块化的,所以我需要引入整个库。也就是说,如果您需要使用来自 Rechart 的多个图表,186.5KB 并不是太大的代价。

维斯克斯

由 Airbnb 创建的 Visx ( https://airbnb.io/visx/ )被描述为 React 的一组富有表现力的低级可视化原语。

Visx 是第二受欢迎的库。Airbnb 团队似乎在这个库、它的视觉吸引力和它的支持上做了一些额外的努力。

设置

我将使用 Visx 示例中的条形图。

Visx 是作为模块构建的,所以我可以只安装我需要的,而不是整个库。

yarn add @visx/mock-data @visx/group @visx/shape @visx/scale

实施 Visx

我使用了来自 Visx GitHub 位置的示例,做了一些修改来克服一些 ESLint 错误,而不是丢弃或忽略所有错误。

以下是我的主要变化:

  • 条形键是基于数组索引的(这是一个大禁忌!).

  • 变量数据使用了两次。

  • 我删除了未使用的代码。

  • 我把名字重构为SimpleBarGraph

看一看:

// src/component/SimpleBarGraph/SimpleBarGraph.tsx

import React from 'react'
import { letterFrequency } from '@visx/mock-data'
import { Group } from '@visx/group'
import { Bar } from '@visx/shape'
import { scaleLinear, scaleBand } from '@visx/scale'

const uuid = require('react-uuid')

// We'll use some mock data from `@visx/mock-data` for this.
const data = letterFrequency

// Define the graph dimensions and margins
const width = 500
const height = 500
const margin = { top: 20, bottom: 20, left: 20, right: 20 }

// Then we'll create some bounds
const xMax = width - margin.left - margin.right
const yMax = height - margin.top - margin.bottom

// We'll make some helpers to get at the data we want
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const x = (d: { letter: any }) => d.letter
const y = (d: { frequency: React.Key }) => +d.frequency * 100

// And then scale the graph by our data
const xScale = scaleBand({
  range: [0, xMax],
  round: true,
  domain: data.map(x),
  padding: 0.4,
})
const yScale = scaleLinear({

  range: [yMax, 0],
  round: true,
  domain: [0, Math.max(...data.map(y))],
})

// Compose together the scale and accessor functions to get point functions
// @ts-ignore
const compose = (scale, accessor) => d => scale(accessor(d))
const xPoint = compose(xScale, x)
const yPoint = compose(yScale, y)

// Finally we'll embed it all in an SVG
function SimpleBarGraph() {
  return (
    <svg width={width} height={height}>
      {data.map((d, i) => {
        const barHeight = yMax - yPoint(d)
        return (
          <Group key={`bar-${uuid()}`}>
            <Bar
              x={xPoint(d)}
              y={yMax - barHeight}
              height={barHeight}
              width={xScale.bandwidth()}
              fill="grey"
            />
          </Group>
        )
      })}
    </svg>
  )
}

export default SimpleBarGraph

对于App.tsx,只需包含组件:<SimpleBarGraph />

你可以在图 10-3 中看到最终的结果。

img/510438_1_En_10_Fig3_HTML.jpg

图 10-3

Visx 条形图组件

费用

42KB 的解析大小很小(见图 10-4 )。

img/510438_1_En_10_Fig4_HTML.jpg

图 10-4

条形图的 Visx 模块大小

赞成的意见

总的来说,我对 Visx 印象深刻。它占地面积小,正如承诺的那样,Visx 由低级组件组成,当您想要获得最终产品并且 Visx 功能接近您所寻求的功能时,这是一个很好的选择。我喜欢它内置的模拟数据。拥有这一点很好,这样我就可以专注于组件的前端开发。

骗局

从代码中可以看出,这些组件被分解成小的、低级别的组件,开发人员需要将它们修补在一起。使用 Visx 库需要一个学习曲线。对于简单的图表来说,这似乎有些矫枉过正,最好只是将时间投入到掌握 D3 上。

获胜

Victory ( https://formidable.com/open-source/victory/ )被描述为 React.js 组件,用于模块化制图和数据可视化。

与 Visx 相比,它在 GitHub 上的星数几乎相同。也就是说,有双倍的贡献者和双倍的错误。

设置

我们需要安装胜利。

$ yarn add victory

实现胜利

我将基于 https://formidable.com/open-source/victory/docs/victory-pie 的代码实现一个饼状图。

胜利很容易实现。只需导入库并包含组件。它甚至不需要传递数据;模拟数据作为默认数据存在。

// src/component/SimplePie/SimplePie.tsx

import React from 'react'
import { VictoryPie } from 'victory'

const SimplePie = () => {
  return (
    <div className="SimplePie">
      <VictoryPie />
    </div>
  )
}

export default SimplePie

您可以像在任何其他数据 viz 库中一样传递数据。

// src/component/SimplePie/SimplePie.tsx

import React from 'react'
import { VictoryPie } from 'victory'

const SimplePie = () => {
  return (
    <div className="SimplePie">
      <VictoryPie
        data={[
          { x: 'Cats', y: 35 },
          { x: 'Dogs', y: 40 },
          { x: 'Birds', y: 55 }
        ]}
      />
    </div>
  )
}

export default SimplePie

看看最后的结果(见图 10-5 )。

img/510438_1_En_10_Fig5_HTML.jpg

图 10-5

胜利饼图组件

费用

解析的开销为 164KB(见图 10-6 )。

img/510438_1_En_10_Fig6_HTML.jpg

图 10-6

饼图中的胜利图书馆成本

赞成的意见

在我见过的所有库中,Victory 是最容易实现的。例子很简单,有令人印象深刻的画廊例子,有大量的追随者,粉丝和支持者。

骗局

Victory 没有其他图书馆那么多图表。另外,在撰写本文时,似乎有太多未解决的问题( https://github.com/FormidableLabs/victory/issues )。

清华普天

Nivo ( https://nivo.rocks/ )提供了一组丰富的数据,即构建在 D3 和 Reactjs 库之上的组件。

设置

我们可以只安装 Nivo 核心和我们需要的模块。

$ yarn add @nivo/core @nivo/calendar

实施 Nivo

我将创建一个日历图表( https://nivo.rocks/calendar/ )。

日历. json

对于数据,让我们创建一个 JSON 文件。

/public/data/calendar.json
[
  {
    "day": "2020-01-01",
    "value": 100
  },
]

日历数据选择器

我将使用反冲选择器来检索数据。

// src/recoil/selectors/calendarDataSelectors.ts

import { selector } from 'recoil'

export const getCalendarData = selector({
  key: 'getCalendarData',
  get: () => {
    return getData()
  },
})
const getData = () =>
  new Promise((resolve) =>
    fetch(`${process.env.PUBLIC_URL}/data/calendar.json`).then((response) => {
      if (response.status !== 200) {
        // eslint-disable-next-line no-console
        console.log(`Houston, we have a problem! ${response.status}`)
        return
      }
      response.json().then((data) => {

        resolve(data)
      })
    })
  )

SimpleCalendarChart

现在是主日历组件的时间了。以下是完整的代码:

// src/component/SimpleCalendarChart/SimpleCalendarChart.tsx

import React from 'react'
import { ResponsiveCalendar } from '@nivo/calendar'

const SimpleCalendarChart = (props: ISimpleCalendarChartProps) => {
  return (
    <div style={{ width: 800, height: 500 }}>
      <ResponsiveCalendar
        data={props.data}
        from="2019-01-01"
        to="2021-12-31"
        emptyColor="#eeeeee"
        colors={['#61cdbb', '#97e3d5', '#e8c1a0', '#f47560']}
        margin={{ top: 40, right: 40, bottom: 40, left: 40 }}
        yearSpacing={40}
        monthBorderColor="#ffffff"
        dayBorderWidth={2}
        dayBorderColor="#ffffff"
        legends={[
          {
            anchor: 'bottom-right',
            direction: 'row',
            translateY: 36,
            itemCount: 4,
            itemWidth: 42,
            itemHeight: 36,
            itemsSpacing: 14,
            itemDirection: 'right-to-left',
          },
        ]}
      />
    </div>
  )
}

interface ISimpleCalendarChartProps {
  data: { day: string; value: number }
}

export default SimpleCalendarChart

我们来复习一下。

为了设置父组件的宽度和高度,我使用了 Nivo ResponsiveCalendar组件。

  return (
    <div style={{ width: 800, height: 500 }}>
      <ResponsiveCalendar
        data={props.data}
        from='20110-01-01'
        to='2021-12-31'
        emptyColor='#eeeeee'
        colors={[ '#61cdbb', '#97e3d5', '#e8c1a0', '#f47560' ]}
        margin={{ top: 40, right: 40, bottom: 40, left: 40 }}
        yearSpacing={40}
        monthBorderColor='#ffffff'
        dayBorderWidth={2}
        dayBorderColor='#ffffff'
        legends={[
          {
            anchor: 'bottom-right',
            direction: 'row',
            translateY: 36,
            itemCount: 4,
            itemWidth: 42,
            itemHeight: 36,
            itemsSpacing: 14,
            itemDirection: 'right-to-left'
          }
        ]}
      />
    </div>
  )
}

对于类型,我可以将数据提取到它自己的 ts 类型中(就像我们在本书前面的例子中所做的那样)。为了使我们的例子简单,我把它留在了界面中。

interface ISimpleCalendarChartProps {
  data: { day: string, value: number }[]
}

App.tsx

App.tsx将使用我创建的反冲选择器检索数据,并将数据传递给SimpleCalendarChart组件。

import React from 'react'
import './App.scss'
import { useRecoilValue } from 'recoil'
import SimpleCalendarChart from './components/SimpleCalendarChart/SimpleCalendarChart'
import { getCalendarData } from './recoil/selectors/calendarDataSelectors'

function App() {

  const data = useRecoilValue(getCalendarData) as { day: string, value: number }[]

  return (
    <div className="App">
      <header className="App-header">
        <SimpleCalendarChart data={data} />
      </header>
    </div>
  )
}

export default App

看看图 10-7 中的最终结果。

img/510438_1_En_10_Fig7_HTML.jpg

图 10-7

Nivo 日历组件

费用

Nivo 库是基于模块的;但是,使用一个模块需要核心库,需要 241KB,包括 React-spring ( https://www.npmjs.com/package/react-spring )、D3、lodash 等库,以及少数 Nivo 库。

赞成的意见

我对 Nivo 提供的独特图表的选择印象深刻;它们看起来很漂亮,也很容易实现。该库有一个服务器端呈现(SSR) API,这在处理大型数据集时非常有用。

骗局

这些例子并不简单明了;当我试图使用文档提供的一些例子时,我无法让它们呈现出来,所以我必须弄清楚包装容器需要设置宽度和高度;否则,什么都不会显示。如果他们已经设置了一些缺省值来获取一些要渲染的东西,并允许我们覆盖缺省设置,那就更容易了。

对…做出 React

由优步创建的 React-vis ( https://uber.github.io/react-vis/ )被描述为一个可组合的图表库。

这是名单上最不受欢迎的图书馆。有 116 个贡献者和 277 个公开的 bug。

设置

我们需要安装库和类型。

$ yarn add react-vis @types/react-vis

实施 React-vis

我将创建一个简单的折线图。我使用了来自 https://github.com/uber/react-vis/blob/master/docs/getting-started/getting-started.md 的例子,并做了一些修改。看一看:

// src/component/SimpleReactVizChart/SimpleReactVizChart.tsx

import React from 'react'
import './BasicRadarChart.scss'
import '../../../node_modules/react-vis/dist/style.css'
import { XYPlot, LineSeries, XAxis, YAxis, HorizontalGridLines, VerticalGridLines } from 'react-vis'

const SimpleReactVizChart = () => {
  return (
    <>

      <div className="App">

        <XYPlot height={300} width={300}>
          <LineSeries
            data={[
              { x: 0, y: 8 },
              { x: 1, y: 5 },
              { x: 2, y: 4 },
              { x: 3, y: 9 },
              { x: 4, y: 1 },
              { x: 5, y: 7 },
              { x: 6, y: 6 },
              { x: 7, y: 3 },
              { x: 8, y: 2 },
              { x: 9, y: 0 },
            ]}
          />
          <VerticalGridLines />
          <HorizontalGridLines />
          <XAxis />
          <YAxis />
        </XYPlot>

      </div>
    </>

  )
}

export default SimpleReactVizChart

参见图 10-8 。

img/510438_1_En_10_Fig8_HTML.jpg

图 10-8

React-可见折线图组件

费用

该库包括一个核心库和附加的模块化库。635KB 未解析(316KB 已解析),这是一个很高的数字。参见图 10-9 。

img/510438_1_En_10_Fig9_HTML.jpg

图 10-9

对…做出 React

赞成的意见

React-vis 易于实现,它包含大量简单的自定义图表。此外,我非常喜欢 React-vis 自带的内置 CSS 文件来帮助设计组件的样式。

骗局

这个库看起来不一致,体积很大,有太多未解决的错误,这个库可以使用架构重构。

您可以从这里下载该项目:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch10/react-chart-libraries

摘要

在这一章中,我介绍了一些最流行的 React D3 库。在本章的第一部分,我使用特定的标准比较了不同的库。

在这一章的第二部分,我向你展示了如何建立、实现和使用每个库,包括每个库的成本和优缺点。

如您所见,通过 React 使用构建在 D3 上的现成组件是显而易见的。我们毫不费力地完成了几个小时的工作;许多图表是开源的和跨平台的;它们还附带了模拟数据、文档、示例,甚至社区支持。

也就是说,在所有这些库中有太多公开的 bug,并且在我使用的许多库中,发布包显著增加。如果你所需要的只是一个简单的图表,它们就显得有些多余了。继续在 D3 上投入时间似乎是更好的选择。

如您所见,每个库包含不同的图表集,各有利弊。

以下是一些例子:

  • Rechart 是最受欢迎的,拥有最多的贡献者。

  • Visx 是一个令人印象深刻的库,提供独特的图表、支持和组织水平。理解和使用所有的模块部分确实需要付出努力。

  • 胜利是伟大的,因为它很容易实现,并有许多不同的图表提供。

归结起来就是找到你要找的图表。如果你找到了一个接近你所需要的,那么从零开始,用 D3 实现是值得的。

也就是说,D3 也有一个学习曲线。我认为了解这些库并把它们记在心里是有用的;但是,如果你对图表很认真,学习 D3 是不可避免的。一旦你理解了 D3,这些知识将帮助你定制这些现成的组件。另外,如果开源组件没有您需要的东西,您可以随时使用它们。

请记住,我只关注了六个库,但是还有许多其他的库,例如:

请记住这些库,即使您不使用它们,这些库也可以作为您定制图表的灵感。

十一、性能提示

很多时候,数据可视化需要大量的资源来运行。例如,考虑普通图表包含的功能:动画、运行时更新的实时数据馈送、包含数千条记录的图表或放在单个页面上的几个图表,等等。

这些要求可能会导致缓慢的体验,特别是当用户使用功能较弱的设备时,如旧的移动设备或内存较低或网络连接较慢的计算机。

在这一章中,我将向你展示一些性能技巧,你可以实现它们来提供更好的用户体验。

这一章分为不同的主题领域。

  • 数据加载

  • 安装模块而不是全局导入

  • 服务器端渲染

  • 树摇晃

  • 仅在需要时更新 DOM

  • 在 JSON 上使用 CSV

  • 通过预渲染、预取和预缓存优化 CRA

  • useCallback记忆功能

完整的章节代码可以在这里找到:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch11/knick-knacks

设置

让我们建立一个项目来实现我将在本章中向您展示的性能增强。

$ yarn create react-app knick-knacks — template must-have-libraries
$ cd knick-knacks
$ yarn start

数据加载

使用图表的核心是数据。优化通过网络发送的数据会对加载图表的时间产生重大影响。

您应该只传输您需要的指标,而不是加载整个数据集。例如,在我们的前一章中,我们使用数据集calendar.json用 Nivo 绘制了一个日历图表。

该数据集包括 2018 年的数据;然而,我使用的图表设置为显示 2019 年至 2021 年的数据,因此我们的应用不需要 2018 年的所有数据,这将不必要地降低用户体验。

类似地,当我们在前一章创建网络力图时,我们使用了power_network.json文件,该文件包括带有节点颜色的fillColor字段。

我们可以控制每个节点的颜色,这太棒了;然而,这是不需要的,因为我们可以将其更改为一个类型,并创建一个枚举类,该类指向我们稍后可以在代码中使用的类型。

每个小的变化可能看起来并不显著,但是将它们结合起来,这些小的度量将减少数据大小并提高性能。

export enum ColorsTypeEnum {
 ONE = '#fffff',
 TWO = '#00000'
}

减少开销的一般规则是避免数据和代码中的重复。

同样,如果您通过网络发送数据,有许多方法可以减小数据的大小,只发送您需要的内容。GraphQL ( https://graphql.org/ )就是一个很好的例子,它让客户能够准确地要求所需要的东西,仅此而已。

要测量进行这些服务调用需要多长时间,您可以使用浏览器工具或其他第三方工具。

例如,在 Chrome 中,右键单击,选择 Inspect,然后转到 Network 选项卡。在图 11-1 中,可以看到响应时序击穿用了 17.39 毫秒;这并不多,但是对于一个大数据集来说,用户需要等待半秒甚至几秒钟的时间来加载图表。

img/510438_1_En_11_Fig1_HTML.jpg

图 11-1

calendar.json 的 Chrome 网络响应时间分解

最后,在使用服务时有三个快速的基本规则,正如最近 https://catchjs.com/Blog/PerformanceInTheWild 在渲染了一百万个网页后所展示的。

  • 尽可能少的请求:保持低数量的请求比传输的千字节数更重要,这适用于任何资源。性能测试证明了这一点。

  • HTTP 3 over HTTP2,避免 HTTP : HTTP 3 是最好的选择,而且它要常见 100 倍左右。为什么呢?这是因为大多数网站链接到相同的资源,如analytics.jsfbevents.js

  • 异步过阻塞请求:使用异步,尽可能避免阻塞请求。

安装模块而不是全局导入

使用 D3(版本 4 及以上)以及许多其他库,可以导入某些模块而不是整个库。这样做可以显著减少运行应用所需的包大小。

要了解这一点,您可以分析产品构建。我已经用运行脚本设置了 CRA·MHL 模板,所以你只需要安装cra-bundle-analyzer作为开发者依赖项。

$ yarn add --dev cra-bundle-analyzer

现在,您可以运行分析工具并亲自检查,如图 11-2 所示。

img/510438_1_En_11_Fig2_HTML.jpg

图 11-2

CRA MHL 模板初始捆绑大小

$ yarn analyzer

如您所见,CRA·MHL 解析后的树形图大小为 214KB。这包括 React 和 React DOM v17 (129.17KB)以及反冲(54KB)。

为了更好地理解不同尺寸代表什么,请看下面的列表:

  • Stat size :这是 webpack 捆绑之后、优化(比如缩小)之前的输入大小。

  • 解析大小:这是优化后文件在磁盘上的大小。它是客户端浏览器解析的 JavaScript 代码的有效大小。

  • gzip 大小:这是 gzip 通常通过网络传输后文件的大小。请记住,gzip 到达客户端(浏览器)后需要解压缩。

首先,让我们创建一些简单的 React D3 代码来绘制一个矩形。

$ npx generate-react-cli component Rectangle --type=d3

接下来,让我们安装 D3 全局库以及我们唯一需要的模块(d3-selection)。

$ yarn add d3-selection @types/d3-selection
$ yarn add d3 @types/d3

如果我们创建相同的代码,我们可以先使用全局 D3 库,然后使用我们正在使用的d3-selection模块。代码几乎相同;然而,足迹发生了变化。

下面是导入整个 D3 库(import * as d3 from d3')时Rectangle.tsx函数组件的版本:

// src/component/Rectangle/Rectangle.tsx

import React, { useEffect, RefObject } from 'react'
import * as d3 from 'd3'

const Rectangle = () => {
  const ref: RefObject<HTMLDivElement> = React.createRef()

  useEffect(() => {
    draw()
  })

  const draw = () => {
    d3.select(ref.current).append('p').text('Hello World')
    d3.select('svg')
      .append('g')
      .attr('transform', 'translate(250, 0)')
      .append('rect').attr('width', 500)
      .attr('height', 500)
      .attr('fill', 'tomato')
  }

  return (
    <div className="Rectangle" ref={ref}>
      <svg width="500" height="500">
        <g transform="translate(0, 0)">
          <rect width="500" height="500" fill="green" />
        </g>
      </svg>
    </div>
  )
}

export default Rectangle

再次运行分析仪检查捆尺寸,如图 11-3 所示。

img/510438_1_En_11_Fig3_HTML.jpg

图 11-3

D3 全局库解析大小

$ yarn analyzer

如您所见,D3 库被解析了 37.57KB。

现在,让我们修改代码,只包含d3-selection模块,我们正在使用它,因为我们不需要来自 D3 的任何其他代码。

// src/component/Rectangle/Rectangle.tsx

import React, { useEffect, RefObject } from 'react'
import { select } from 'd3-selection'

const Rectangle = () => {
  const ref: RefObject<HTMLDivElement> = React.createRef()

  useEffect(() => {
    draw()
  })

  const draw = () => {
    select(ref.current).append('p').text('Hello World')
    select('svg')
      .append('g')
      .attr('transform', 'translate(250, 0)')
      .append('rect').attr('width', 500)
      .attr('height', 500)
      .attr('fill', 'tomato')
  }

  return (
    <div className="Rectangle" ref={ref}>
      <svg width="500" height="500">
        <g transform="translate(0, 0)">
          <rect width="500" height="500" fill="green" />
        </g>
      </svg>
    </div>
  )
}

export default Rectangle

再次运行分析仪以检查束尺寸。见图 11-4 。

正如你所看到的(图 11-4 ,通过使用d3-selection而不是进行全局导入,D3 解析的大小从 37.57KB 减少到 11.97KB。这意义重大!

img/510438_1_En_11_Fig4_HTML.jpg

图 11-4

D3 模块库解析大小

服务器端渲染

CRA (SPA)模式在某些情况下非常好,因为你不会得到任何页面刷新,所以这种体验就像你在一个移动应用中一样。

这些页面应该在客户端呈现。除了 SPA,还有一个我之前在书中提到的选项:服务器端渲染(SSR)。

CRA 不支持现成的 SSR。但是,有一些方法可以配置路由等。,并让 CRA 作为 SSR 工作,但这可能涉及到您自己退出和维护配置,可能不值得这样做。

Note

退出意味着您正在承担更新您可能不完全理解的配置构建代码的责任。如果构建中断,CRA 可能无法支持您设置的自定义配置,并且更新构建文件可能会中断。

如果您正在构建需要 SSR 来提高性能的东西,那么最好使用已经配置了 SSR 的不同 React 库,例如 Next.js framework、Razzle 或 Gatsby(它们在构建时包含一个 HTML 格式的 prerender 网站)。

Tip

如果你想用 React 和 Node.js 做服务器渲染,可以去看看 Next.js,Razzle,或者 Gatsby。

树摇晃

摇树( https://webpack.js.org/guides/tree-shaking/ )是 JavaScript 中使用的一个术语,意思是移除死代码。

当我说死代码时,可能有两种情况:

  • 从未执行过的代码:代码在运行时从不执行。

  • 结果从未使用过:代码被执行,但结果从未使用过。

在我们当前的项目配置中,我们有反冲状态管理,但是我们没有对任何东西使用反冲特性。

现在,如果我们深入我们的 JS 包,看看发生了什么,我们可以看到反冲几乎使用了 54.24KB,如图 11-5 所示。

img/510438_1_En_11_Fig5_HTML.jpg

图 11-5

反冲库大小占地面积

在我的发布构建代码中包含反冲的原因是,我使用反冲作为暂停回退来显示加载消息,直到组件被加载,但是我的代码加载很快,并且不需要该代码,因为我没有使用选择器或任何需要该代码的异步调用。

// src/AppRouter.tsx

import React, { FunctionComponent, Suspense } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import { RecoilRoot } from 'recoil'
import App from './App'

const AppRouter: FunctionComponent = () => {
  return (
    <Router>
      <RecoilRoot>
        <Suspense fallback={<span>Loading...</span>}>
          <Switch>
            <Route exact path="/" component={App} />
          </Switch>
        </Suspense>
      </RecoilRoot>
    </Router>
  )
}

为了优化代码,重构AppRouter.tsx并移除反冲引用。

// src/AppRouter.tsx

import React, { FunctionComponent } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import App from './App'

const AppRouter: FunctionComponent = () => {
  return (
    <Router>
      <Switch>
        <Route exact path="/" component={App} />
      </Switch>
    </Router>
  )
}

你甚至可以通过运行yarn remove命令从你的package.json文件中删除反冲来清理东西,但是如果它没有被使用,如果你认为你以后需要反冲,那就没有必要了。

$ yarn remove recoil

为我们不使用的库导入会增加我们代码的大小(JS 包)。这些进口商品应该取消。

再次运行分析器,您可以看到包的大小减少到了 175KB。

$ yarn analyzer

仅在需要时更新 DOM

当使用 D3 和 React 时,正如我们所看到的,最大的优势是我们可以让 React 控制 DOM。React VDOM 只需要更新一次改变。我们需要确保只有在需要改变时才重新渲染。

D3 代码被认为是副作用,因为它在 React 的 VDOM 机制之外向 DOM 添加了内容。正因为如此,我们想让 VDOM 知道什么时候重画。

在一个函数组件中,useEffect钩子用于任何副作用,在组件卸载后,任何不是基于 React 的事件都需要手动清理,以确保不会出现内存泄漏。

但这还不够。我们可能关心的另一个问题是确保我们的图表只在需要时更新,而不是在每次组件更新时更新。

例如,如果你查看我们在本书前几章中使用的HelloD3Data.tsx函数组件,你会发现我们可以通过函数props传递数据,并将每个数据字符串绘制为文本标签。

// src/component/HelloD3Data/HelloD3Data.tsx

import React, { useEffect } from 'react'
import './HelloD3Data.scss'
import { select, selectAll } from 'd3-selection'
interface IHelloD3DataProps {
  data: string[]
}
const HelloD3Data = (props: IHelloD3DataProps) => {
  useEffect(() => {
    draw()
  })

  const draw = () => {
    console.log('draw!')
    select('.HelloD3Data')
      .selectAll('p')
      .data(props.data)
      .enter()
      .append('p')
      .text((d) => `d3 ${d}`)
  }
  return <div className="HelloD3Data" />
}

export default HelloD3Data

父组件App.tsx保存数据数组字符串作为状态。单击一个按钮,我用与原始初始值相同的数组字符串值来改变状态:['one', 'two', 'three', 'four']。看一看:

import React, { useState } from 'react'
import './App.scss'
import { Button } from '@material-ui/core'
import HelloD3Data from './components/HelloD3Data/HelloD3Data'

function App() {
  const [data, setData] = useState<string[]>(['one', 'two', 'three', 'four'])
  return (
    <div className="App">
      <HelloD3Data data={data} />
      <Button onClick={() => setData(['one', 'two', 'three', 'four'])}>
        Click
      </Button>
    </div>
  )
}

export default App

现在,如果我们运行这段代码并一直单击 click 按钮,那么每次单击都会重绘子组件HelloD3Data.tsx。见图 11-6 。

img/510438_1_En_11_Fig6_HTML.jpg

图 11-6

hello 3d data . tsx 组件

这里发生的事情是,React 在每次更新时调用useEffect,由于 D3 绘制 DOM,它导致相同代码的重绘。

我们需要做的是检查数据更新。

有几种方法可以做到这一点。这里有三个:

  • 检查 D3 数据:检查 D3 元素内部的数据。

  • 克隆:本地克隆数据。

  • 创建 React 类组件 : React 类组件已经内置了传递先前值的逻辑。

检查 D3 数据

为了检查 D3 元素中的数据,我们可以选择所有正在使用的p元素,然后遍历数组来创建一个可以比较的先前数据对象。

// src/component/HelloD3Data/HelloD3Data.tsx

import React, { useEffect } from 'react'
import './HelloD3Data.scss'
import { select, selectAll } from 'd3-selection'

const HelloD3Data = (props: IHelloD3DataProps) => {
  useEffect(() => {
    draw()
  })

  const draw = () => {
    const previousData: string[] = []
    const p = selectAll('p')
    p.each((d, i) => {
      previousData.push(d as string)
    })
    if ( JSON.stringify(props.data) !== JSON.stringify(previousData) ) {
      console.log('draw!')
      select('.HelloD3Data')
        .selectAll('p')
        .data(props.data)
        .enter()
        .append('p')
        .text((d) => `d3 ${d}`)
    }
  }

  return (
    <div className="HelloD3Data">
    </div>
  )
}

interface IHelloD3DataProps {
  data: string[]
}

export default HelloD3Data

有了数据检查,我们就可以更新结果,而不用担心 DOM 会过多地重绘我们的元素。

<Button onClick={() => setData(['one', 'two', 'three', 'four', 'five'])}>
  Click
</Button>

克隆数据

第二种方法是克隆数据,然后我们可以将props值与我们的状态值进行比较。

// src/component/HelloD3DataCloned/HelloD3DataCloned.tsx

import React, { RefObject, useEffect, useState } from 'react'
import './HelloD3Data.scss'
import { select } from 'd3-selection'

const ref: RefObject<HTMLDivElement> = React.createRef()

const HelloD3DataCloned = (props: IHelloD3DataProps) => {

  const [data, setData] = useState<string[]>([])

  useEffect(() => {
    if (JSON.stringify(props.data) !== JSON.stringify(data)){
      setData(props.data)
      console.log('draw!')
      select(ref.current)
        .selectAll('p')
        .data(data)
        .enter()
        .append('p')
        .text((d) => `d3 ${d}`)
    }
  }, [data, props.data, setData])

  return <div className="HelloD3Data" ref={ref} />
}

interface IHelloD3DataProps {
  data: string[]
}

export default HelloD3DataCloned

这种方法很好,但是不如在 D3 中检查数据理想,因为我们现在将相同的数据存储在内存或引用中三次(props、state 和 HTML 元素)。

也就是说,克隆数据有时是必要的一步,因为 D3 逻辑会改变对象内部的数据,而 React props不会容忍这种情况。TypeScript 实际上会吐出一条错误消息:“TypeError:无法添加属性索引,对象不可扩展。”正如您所记得的,我们在创建气泡图和功率图时就是这样做的。

React 类组件

第三种选择是创建一个类组件,而不是一个函数组件,因为当组件被安装和更新时,它已经有了生命周期挂钩(如componentDidUpdate)来处理。我们可以用它来比较之前的数据和当前的数据。

// src/component/HelloD3DataClass/HelloD3DataClass.tsx

import React, { RefObject } from 'react'
import { select } from 'd3-selection'

export default class HelloD3DataClass extends React.PureComponent<IHelloD3DataClassProps, IHelloD3DataClassState> {
  ref: RefObject<HTMLDivElement>

  constructor(props: IHelloD3DataClassProps) {
    super(props)
    this.ref = React.createRef()
  }

  componentDidMount() {
    this.draw()
  }

  componentDidUpdate(prevProps: IHelloD3DataClassProps, prevState: IHelloD3DataClassState) {
    if (JSON.stringify(prevProps.data) !== JSON.stringify(this.props.data)) {
      this.draw()
    }
  }

  draw = () => {
    // eslint-disable-next-line no-console
    console.log('draw!')
    select(this.ref.current)
      .selectAll('p')
      .data(this.props.data)
      .enter()
      .append('p')
      .text((d) => `d3 ${d}`)
  }

  render() {

    return (
      <div className="HelloD3DataClass" ref={this.ref} />
    )
  }
}

interface IHelloD3DataClassProps {
  data: string[]
}

interface IHelloD3DataClassState {
  // TODO
}

如您所见,我向您展示的所有选项(检查 d3 数据、克隆和 React 类组件)都是有效的,它们允许我们控制 D3 更新仅在数据改变时发生,以避免不必要地不断更新 DOM。

Note

正如你所看到的和我在书的前面提到的,我尽可能使用React.PureComponent而不是React.Component,因为它在某些情况下提供了性能提升,但代价是失去了shouldComponentUpdate生命周期事件。React.PureComponentReact.memo()都优先于React.Component

在 JSON 上使用 CSV

在创建 D3 图表时,通常使用 CSV 和 JSON 作为数据源,事实上我们在书中的许多例子中都使用了 CSV 和 JSON。

请记住,如果您可以选择,CSV 比 JSON 更受欢迎。

  • CSV 使用更少的带宽 : CSV 使用字符分隔符,JSON 仅仅为了语法格式就需要更多的字符。

  • CSV 处理数据更快:CSV 字符分隔符拆分更快,在 JSON 中需要解释语法。

通过预渲染、预取和预缓存优化 CRA

在某些情况下,CRA (SPA)模式非常好,因为你不会刷新页面,感觉就像在移动应用中一样。

这些页面应该在客户端呈现。CRA 不支持服务器端渲染。

但是,有一些方法可以配置路由等。,并让 CRA 作为 SSR 工作,但这可能涉及到您自己退出和维护配置,可能不值得这样做。

如果您正在构建需要 SSR 的东西,那么最好使用已经配置了 SSR 的不同 React 库,比如 Next.js framework、Razzle 或 Gatsby。

Tip

如果你想用 React 和 Node.js 做服务器渲染,可以去看看 Next.js,Razzle,或者 Gatsby。

CRA 在后端是不可知的,只产生静态的 HTML/JS/CSS 包。也就是说,通过 CRA,我们可以进行预渲染,这是目前最接近 SSR 的方法。参见 CRA 文档 https://create-react-app.dev/docs/pre-rendering-into-static-html-files/

为每个路线或相对链接生成 HTML 页面有许多选项。这里有几个:

  • React-捕捉

  • React-快照

  • Webpack 静态站点生成器插件

我推荐 React-snap ( https://github.com/stereobooster/react-snap ),因为它在 GitHub 上更受欢迎,并且可以与 CRA 无缝协作。

React-snap 使用 Puppeteer 在应用中自动创建不同路径的预渲染 HTML 文件。

最大的好处是,一旦我们使用 React-snap,应用不会关心 JS 包是否成功加载,因为我们设置的每个页面都是独立的。

请记住,对于要单独加载的每个页面,有些包可能有多余的代码,所以这是有代价的。

第一步:开始使用这个:

$ yarn add --dev react-snap

第二步:接下来添加package.jsonpost build运行脚本。

// package.json
"scripts": {
  ...
  "postbuild": "react-snap"
},

第三步:几乎即时渲染的静态 HTML 默认情况下,它是未设置样式的,可能会导致显示“未设置样式的内容闪烁”(FOUC)的问题。如果使用 CSS-in-JS 库来生成选择器,这一点尤其明显,因为 JavaScript 包必须在设置任何样式之前完成执行。

React-snap 使用另一个名为minimalcss ( https://github.com/peterbe/minimalcss )的第三方库来提取不同路线的任何关键 CSS。

您可以通过在您的package.json文件中指定以下内容来启用它:

// package.json"scripts": {
  ...
  "postbuild": "react-snap"
},
"reactSnap": {
  "inlineCss": true
},

第四步:现在在src/index.tsx就是我们要补水的地方,我们也可以用这个serviceWorker.register()在那里注册预缓存。在下一节中,您将了解更多关于预缓存的内容。

// src/index.tsx
import React from 'react'
import { hydrate, render } from 'react-dom'
import './index.scss'
import AppRouter from './AppRouter'
import * as serviceWorker from './serviceWorker'

const rootElement = document.getElementById('root')
if (rootElement && rootElement!.hasChildNodes()) {
  hydrate(<AppRouter />, rootElement)
  serviceWorker.register()
} else {
  render(<AppRouter />, rootElement)
}

第五步:现在构建你的应用的生产版本。

$ yarn build

将通过在 CRA 配置的 NPM 脚本自动调用后期构建脚本。你应该看到成功的结果。

$ react-snap
✅ crawled 1 out of 1 (/)

将矩形添加为页面

如果你打开AppRouter.tsx,你可以看到有一条到App.tsx的路线。

// src/AppRouter.tsx

import React, { FunctionComponent, Suspense } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import { RecoilRoot } from 'recoil'
import App from './App'

const AppRouter: FunctionComponent = () => {
  return (
    <Router>
      <RecoilRoot>
        <Suspense fallback={<span>Loading...</span>}>
          <Switch>
            <Route exact path="/" component={App} />
          </Switch>
        </Suspense>
      </RecoilRoot>
    </Router>
  )
}

现在让我们添加另一个页面路由,并将其添加到路由标签(Rectangle)。

// src/AppRouter.tsx

import Rectangle from './components/Rectangle/Rectangle'

const AppRouter: FunctionComponent = () => {
  return (
    <Router>
      <RecoilRoot>
        <Suspense fallback={<span>Loading...</span>}>
          <Switch>
            <Route exact path="/" component={App} />
            <Route exact path="/Rectangle" component={Rectangle} />
          </Switch>
        </Suspense>
      </RecoilRoot>
    </Router>

  )
}

现在,如果您导航到此处显示的Rectangle URL,您可以看到我们构建的组件:

http://localhost:3000/Rectangle

再次运行yarn build

$ yarn build

它仍然给出一个页面被抓取的相同结果,但是为什么呢?

✅ crawled 1 out of 1 (/)

原因是Rectangle页面没有任何链接,无法抓取。为了抓取页面,我们需要在我们的App.tsx文件中添加一个路由链接。

<NavLink to='/Rectangle' key='Rectangle'>
  Navigate To Rectangle
</NavLink>

现在再次运行构建,现在两个页面都被抓取了。

$ react-snap
✅  crawled 1 out of 3 (/)

✅  crawled 2 out of 2 (/Rectangle)

您也可以在公共文件夹中看到这一点;见图 11-7 。

img/510438_1_En_11_Fig7_HTML.jpg

图 11-7

在构建文件夹中创建的矩形文件夹

预取

您可能已经在 React 中使用了高阶组件(hoc)来增强组件功能( https://reactjs.org/docs/higher-order-components.html )。对于我们的 JS 包,我们可以采用类似的方法。

我们希望首先加载页面,然后检索 JS 包,所以我们要尽快显示页面。

让我们来看看。您可以构建一个 prod 版本,并使用serve命令来查看构建。

$ yarn build:serve

在 Chrome DevTools 中检查构建(http://localhost:5000/Rectangle)。看看 JS bundle chunks 的层次结构;他们在顶部(见图 11-8 )。

img/510438_1_En_11_Fig8_HTML.jpg

图 11-8

没有为 JS 包设置层次结构

我们想把这些块束移到底部。为此,我们可以使用 Quicklink ( https://github.com/GoogleChromeLabs/quicklink )。

Quicklink 通过使用技术来决定先加载什么,试图使后续页面的导航加载得更快。让我们安装 Quicklink。

$ yarn add -D webpack-route-manifest

$ yarn add quicklink

请注意,您的package.json文件是用以下代码更新的:

"devDependencies": {
  "webpack-route-manifest": "¹.2.0"
}

在我们的例子中,我们使用 React CRA 温泉;我们将使用 React HOC,在这里我们希望为我们延迟加载的页面添加预取功能。为此,我们只需使用一个空的选项对象,并用 Quicklink HOC 包装我们的组件。

// src/AppRouter.tsx

import React, { FunctionComponent, lazy, Suspense } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import { RecoilRoot } from 'recoil'

import { withQuicklink } from 'quicklink/dist/react/hoc.js'

import App from './App'

const MyPage = lazy(() => import('./components/Rectangle/Rectangle'))
const options = {
  origins: [],
}

const AppRouter: FunctionComponent = () => {
  return (
    <Router>
      <RecoilRoot>
        <Suspense fallback={<span>Loading...</span>}>
          <Switch>
            <Route exact path="/" component={App} />
            <Route exact path="/Rectangle" component={withQuicklink(MyPage, options)} />
          </Switch>
        </Suspense>
      </RecoilRoot>
    </Router>
  )
}

export default AppRouter

Note

运行prerender并提供静态页面不一定总是最好的方法;这实际上会给用户带来不愉快的体验,因为每个页面都将被加载,并且组件的加载会跨页面分布。对于轻量级应用,等待半秒钟来加载所有内容可能比每次页面加载时等待一会儿更好,不会有更多的等待时间。你需要自己测试和观察,但是要意识到这个特性。

步骤 5 :要在本地剥离生产构建,运行 CRA 模板,然后运行脚本(yarn build:serve)。它使用的是 serve 库,所以如果你使用的是 CRA MHL 模板,你甚至不需要安装或配置package.json

运行serve运行脚本来添加本地服务器并查看生产构建。

$ yarn build:serve

正如你所看到的,在实现了逻辑之后,HOC 工作了,现在我们的包块在底部,如图 11-9 所示。

img/510438_1_En_11_Fig9_HTML.jpg

图 11-9

特设工作束块在底部

http://localhost:5000/Rectangle

我想指出的是,使用prerender的另一个重要原因是,除了用搜索引擎优化(SEO)进行优化之外,还需要静态页面。如果您预渲染页面,并希望生成不同的标题、描述、元数据等。,对于由于 SEO 原因的每个页面,或者需要通过社交媒体共享单个页面,请查看react-helmet,它可以帮助为每个 React 页面组件设置唯一的标题。

预缓存:脱机工作

能够脱机是渐进式 web 应用(PWA)的核心功能。我们可以用一个serviceWorker来做。

CRA 将serviceWorker包含在index.tsx文件中。

serviceWorker.unregister()

这是什么意思?

CRA 包括一个开箱即用的“生产工具箱”( https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin )。

要启用该功能,只需将serviceWorker状态改为 register 即可。

serviceWorker.register()

在前面的部分中,我们已经在为生产构建时将serviceWorker添加到了我们的index.tsx组件中。

const rootElement = document.getElementById('root')
if (rootElement && rootElement!.hasChildNodes()) {
  hydrate(<AppRouter />, rootElement)
  serviceWorker.register()
} else {
  render(<AppRouter />, rootElement)
}// serviceWorker.unregister()

现在当您再次构建($yarn build)时,会出现一个新文件:build/precache-manifest.[string].js

参见图 11-10 。

img/510438_1_En_11_Fig10_HTML.jpg

图 11-10

运行时主包文件被添加到我们的静态文件夹中

要查看工作器的运行情况,您需要再次运行发布构建脚本。

$ yarn build:serve

看一下 Chrome DevTools 的网络选项卡;在尺寸栏中,可以看到写着 ServiceWorker,如图 11-11 所示。

img/510438_1_En_11_Fig11_HTML.jpg

图 11-11

预缓存 ServiceWorker 出现在 Chrome DevTools 的网络选项卡中

继续,关闭你的网络连接,或者在 Chrome DevTools 的网络标签上,勾选离线复选框。

您现在可以模拟离线体验了。刷新 app 它仍然像你在线一样工作!

你的应用如何离线工作?

CRA 的工作箱默认预缓存策略是CacheFirst。静态资产从服务工作者缓存机制中检索,如果失败,则发出网络请求。

Workbox 支持CacheOnlyNetworkFirst等不同的策略。,但 CRA 可能需要被驱逐,以使用不同于默认的策略。

点击 https://create-react-app.dev/docs/making-a-progressive-web-app/ 了解更多关于此功能的信息。

用 useCallback 记忆函数

我在本章前面和整本书中已经向你展示了如何使用useEffectdraw。看一看:

useEffect(() => {
   draw()
}const draw = () => {
  // TODO
}

然而,这段代码是有问题的,因为它会导致无限循环!有助于防止这种情况。

useCallback()可以与useEffect()一起使用,帮助防止函数的重新创建。https://reactjs.org/docs/hooks-reference.html#usecallback 。)

useEffect(() => {
   memoizedDrawCallback()
}, [memoizedDrawCallback]const memoizedDrawCallback = useCallback(() => {
  // TODO using data as dependency
}, [data])

如你所见,memoizedDrawCallback之类的函数只不过是 JS 中的对象。将它们包装在函数声明中并定义函数的依赖关系,可以确保只有当函数的依赖关系发生变化时才重新创建函数。例如,您可以在 dependencies 数组中列出您需要的数据或props

[props.bottom, props.data, props.fill, props.height, props.left, props.right, props.top, props.width]

memoizedDrawCallback函数不会在每次渲染 DOM 周期更新时重新构建,所以我们已经打破了潜在的无限循环!在我的 d3 和 React 交互课程中,你可以看到如何用 memorizedDrawCallback 方法实现本章中的许多例子。 https://elielrom.com/BuildSiteCourse

摘要

应用本章中的方法并测量结果有助于减少应用的占用空间和宝贵的加载时间。

当你分析和调试你的应用时,很多技巧都会用到。如果你对分析和调试 React 应用有点生疏,可以看看我的 React 书( https://www.apress.com/gp/book/9781484266953 )。此外,我有两篇文章可以提供帮助。

在下一章,也是最后一章,我将介绍发布你的 React D3 图表作为 SPA 和 SSR。

十二、发布 React D3 应用

恭喜你读到了这本书的最后一章。你们应该为自己的承诺感到骄傲,我很高兴你们已经走到了这一步。现在我们已经准备好了一些图表,是时候发布 React 和 D3 代码了。

有很多因素要考虑,也有很多选择。作为团队领导、创业顾问、首席技术官或任何技术专家,您可能需要决定使用哪种工具。那么,你应该选哪个呢?

在这一章中,您将了解一些可以为 React starter 项目选择的最佳选项。此外,我将向您介绍创建一个 SPA React 应用和一个 SSR React 应用的示例并发布代码的过程。

本章分为以下几节:

  • 选择您的启动项目

  • 使用 Next.js 创建和发布 SSR 应用

  • 与 CRA 一起创建和发布水疗中心

  • 有用的调试分析工具

我们开始吧。

选择您的启动项目

在许多情况下,为开发环境选择技术栈已经为您完成了。然而,如果你需要推荐一个技术栈(即使你还不需要发布你的应用),创建一个已发布的版本是一个很好的实践。

为什么在开发阶段发布构建?

创建一个已发布的版本是很重要的,因为与那些已经优化并准备好进行部署的库相比,它给你一个更真实的版本。

当你在做你的项目时。代码中的变化可能是显著的,重要的是不断地创建发布的构建,而不是等到冲刺结束的最后一分钟才发现构建被破坏了或者没有按预期工作。

我坚信你应该快速发布,经常发布,甚至一天两次。

你应该选择什么工具?

当决定选择什么工具时,有许多付费和免费的解决方案和选择,在发布您的作品方面,它可能会变得势不可挡。

此外,您可以选择使用 React 作为 SPA,而不是选择 SSR,使用完全配置的服务器,如 Ubuntu 或 Windows,或者作为无配置解决方案的无服务器。

归结起来就是你需要集成哪些其他技术、使用、成本、维护、要部署的语言、你的个人偏好、团队经验、社区支持以及许多其他因素。

为了给你一个看待它的方法,我创建了图 12-1 中的高层活动图。此图表可以帮助您决定如何从头到尾规划您的发布选项。请注意,这个活动图是简化的,没有深入到应该考虑的所有细节。

在每个选项中,都有多个选项。例如,当你扩大规模时,免费的解决方案通常会开始收费,所以应该检查并考虑这一点。

img/510438_1_En_12_Fig1_HTML.jpg

图 12-1

选择启动 React 项目并发布的活动图

SPA 与 SSR

一旦你决定使用 React 作为你的网络技术,我建议你采取的第一步是决定你的应用是作为单页应用(SPA)还是具有服务器端渲染(SSR)。

使用动态内容而不是简单的静态页面可以改善用户体验,提高参与度。例如,考虑一下您的代码可能包含的这些特性:交互、动画、运行时的动态数据更新、处理数千条记录、单个页面上放置的组件数量等等。

使用 SPA 时,渲染在客户端完成,外部资源的使用受到限制。服务器端呈现是应用在服务器上显示内容的能力,而不是像在单页应用中那样在浏览器中呈现内容。

有时使用 SSR 比 SPA 更受欢迎。关于何时以及为何在 SPA 上使用 SSR,有许多考虑因素,这又回到团队的经验、技术栈、性能、代码大小以及许多其他考虑因素。

SSR 的建立是为了支持 SEO,服务于静态页面;然而,SPAs 也可以用额外的库来设置,这样内容就可以被缓存,甚至可以离线工作,就像你在前一章看到的那样。这可以通过使用 useCallback、预渲染、预取和预缓存等技术优化 SPA 来实现,正如您在上一章中看到的那样。

在我看来,SSR 相对于 spa 的最大优势是当您需要以下内容时:

  • 用户机器少缴税

  • 能够与后端 Node.js 共享代码

以下是 SSR 优于 SPA 的缺点:

  • SSR 增加了应用的复杂性。

  • 如果服务器繁忙,SSRs 会降低响应时间。

您可能已经在使用 SSR 或 SPA,那么我如何在两者之间转换呢?

即使您选择了 SPA,并且需要将项目转换为 SPA,反之亦然,如果构建正确,React component 的第一个范例是为拖放组件而构建的,因此您应该能够通过移动组件来设置项目。

SPA 和 SSR 启动项目

一旦你决定了 SSR 和 SPA,你需要决定你想要使用的启动库。使用一个启动项目是很棒的,因为你不需要配置项目,你可以马上开始。在我们的书的例子中,你看到了我们是多么容易能够和 CRA·MHL 一起快速启动项目。

然而,由于模板是香草味的,所以它是通用的,您需要努力添加和/或删除您使用或需要的任何基于 React 的库。

CRA ( 然而,这个启动项目是基于 SPA 技术的。

将该项目转化为一个纯粹的 SSR 应用是可能的,但这需要退出(您需要自己管理配置)并自行设置。我不推荐这种方法,除非你对移动的部分非常了解。

如果你需要水疗,我会选择 Next.js ( https://nextjs.org/ )。在撰写本文时,Next.js 是最受欢迎的基于 SSR 的 React starter 项目(在 GitHub 上有 63,000 颗星),并被许多成功的公司使用,如网飞、GitHub、Hulu 和优步。

除了 CRA 和 Next.js 之外,许多其他入门库也在不断发展壮大,它们都是为了增强特定需求而构建的。以下是一些例子:

此外,您可以从头开始您的项目,安装您自己需要的库,以及管理您自己的配置。它确实需要更多的努力来设置,但你可以确保你的项目像手套一样适合你的需要。

发布您的 React 代码

在发布你的作品方面有如此多的解决方案和选择,如果你需要自己选择技术,这可能会变得势不可挡。

例如,有一些免费的解决方案(在撰写本文时是免费的)非常适合于概念证明(POC)或小型或非商业项目。

  • github pages-github 页面

  • 你看

  • Firebase(火力基地)

  • 网易

  • 赫罗库

  • 和许多其他人

除了免费入门的平台,还有传统的付费解决方案,比如设置传统服务器以及无服务器解决方案。以下是几个例子:

  • 亚马逊 AWS(参见 Lambda for serverless)

  • 阿祖拉(参见无服务器的 Azure 函数)

  • 阿里云(见无服务器功能计算)

  • 和许多其他人

请记住,今天许多解决方案都是免费的,基于试用,基于您的早期使用,或者两者兼而有之;然而,一旦你开始使用这些资源,账单就会接踵而来。

您应该估计您的资源使用情况,以备扩大规模或试用期结束时使用,因此请确保阅读细则并经常阅读。此外,在“计费”部分设置警报(如果存在)。我在这里并不是以任何方式推荐任何工具;在选择解决方案之前,先做自己的研究。

公司如何负担得起免费提供服务?大多数云服务意识到,收集美分变成了数百万,并且通常需求增长超过了最低使用量。如果没有很好地记录设置过程,迁移到不同的解决方案会很困难。

Tip

我强烈建议您记录您如何设置项目,以避免与解决方案结合。价格和条款经常变化。

在本章的下一节,我将向你展示如何用 Next.js 建立一个初学者项目并发布作品。

用 Next.js 创建和发布 SSR

到目前为止,在本书中,我们已经使用 CRA 为我们的项目。在这一节中,我们将使用 Next.js 创建一个 starter 项目。唯一的先决条件是安装 Node.js 和 Yarn,我们已经安装了。

设置 Next.js 启动项目

让我们用最少的代码用一个 D3 库建立一个简单的 Next.js React 项目。

$ yarn create next-app

当它在终端问我“你的项目叫什么?”我选择了 nextjs-ts-chart,但是当然您可以使用任何您喜欢的名称。安装完成后,您可以将目录更改为nextjs-ts-chart.

$ cd nextjs-ts-chart

下载完库后,运行应用。

$ yarn run dev
$ open localhost:3000

安装 TypeScript

Next.js 默认用 js 设置;然而,我强烈建议用类型检查器的 TypeScript 来设置你的项目,就像我们用 CRA 一样。

$ yarn add -D typescript @types/react @types/node

当我们运行这些库时,请注意,tsconfig.json文件会自动添加到您的项目中。

安装 D3

我要安装的最后一个库是 D3,用来帮我画一些图形。因为我只使用 select 模块,所以我可以只安装我需要安装的整个 D3 全局库。

$ yarn add d3-selection @types/d3-selection

矩形. tsx

现在项目已经设置好了。我将使用 React 的函数组件,用简单的 D3 代码设置项目。创建一个components文件夹,并添加我们在前面章节中使用的Rectangle.tsx组件。

// components/Rectangle/Rectangle.tsx

import React, { useEffect, RefObject } from 'react'
import { select } from 'd3-selection'

const Rectangle = () => {
  const ref: RefObject<HTMLDivElement> = React.createRef()

  useEffect(() => {
    draw()
  })

  const draw = () => {
    select(ref.current).append('p').text('Hello World')
    select('svg').append('g').attr('transform', 'translate(250, 0)').append('rect').attr('width', 500).attr('height', 500).attr('fill', 'tomato')
  }

  return (
    <div className="Rectangle" ref={ref}>
      <svg width="500" height="500">
        <g transform="translate(0, 0)">
          <rect width="500" height="500" fill="green" />
        </g>
      </svg>
    </div>
  )
}

export default Rectangle

更新 index.ts

接下来,为了包含Rectangle.tsx组件并使用 TS,将索引从index.js更改为index.ts,并添加我们刚刚创建的Rectangle.tsx组件。最后,删除剩余的代码。下面是完整的index.tsx组件:

// src/component/pages/index.tsx

import Rectangle from "../components/Rectangle/Rectangle"
import styles from '../styles/Home.module.css'
import React from "react";

export default function Home() {
  return (
    <div className={styles.container}>
      <Rectangle />
    </div>
  )
}

看看最后的结果,如图 12-2 所示。

img/510438_1_En_12_Fig2_HTML.jpg

图 12-2

Next.js starter 项目中我的自定义 React + D3 组件

用 Express 发布 Next.js

现在我们已经准备好了我们的应用,一种方法是在 Express 服务器上发布应用。

为此,安装express库( https://github.com/expressjs/express )作为开发人员依赖项。

$ yarn add -D express

server.js

接下来,让我们创建一个可以为我们的应用服务的express服务器文件。在代码级别,我允许您传递将要使用的端口,或者将其设置为端口 9000(可以随意更改为端口 3000 或您喜欢的任何端口)。

我还允许传递一个NODE_ENV以便应用知道它是在开发还是在生产中运行。

// server.js
const express = require('express')
const next = require('next')const port = process.env.PORT || 9000;
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()app.prepare()
    .then(() => {
        const server = express()        server.get('*', (req, res) => {
            return handle(req, res)
        })        server.listen(port, (err) => {
          if (err) throw err
            console.log(`Server Ready on http://localhost:${port}`)
        })
    })
    .catch((ex) => {
        console.error(ex.stack)
        process.exit(1)
    })

既然我们已经设置了 Node.js express服务器代码,我们需要构建应用的发布版本。这是使用在package.json文件中为我们设置的运行脚本完成的。

$ yarn build

因为我们是在本地机器上部署应用,所以我们需要在终端中设置一个环境变量。在 Windows 上,您可以这样做:

$ SET NODE_ENV=development

在 macOS/Linux 上,您可以这样做:

$ export NODE_ENV=development

使用 Node 运行express服务器,并在端口 9000 上打开 localhost 以查看结果。

$ node server.js
$ open http://localhost:9000/

如果我们想在任何支持express的服务器上部署这个应用,比如 Ubuntu,只需复制文件并运行服务器脚本,将NODE_ENV变量设置为 production。

$ export NODE_ENV=production

用 Heroku 发布 Next.js 无服务器

我们已经看到我们的 SSR React 应用在内部 Next.js 脚本以及我的自定义 Node.js Express 服务器脚本的帮助下运行。一旦您理解了该过程并多次练习,就可以轻松地使用不需要配置的无服务器选项进行生产。

正如我提到的,在野外有很多免费和付费的解决方案。

Heroku 就是其中之一。首先,你需要创建一个账户: https://signup.heroku.com/

您还需要 Heroku CLI。

对于使用 brew 的 macOS 用户,只需在终端中运行以下命令:

$ brew tap heroku/brew && brew install heroku

对于 Windows 用户,请下载安装程序。

https://devcenter.heroku.com/articles/heroku-cli

接下来,在终端中,您需要登录到该帐户。login 命令将打开您的浏览器,要求您登录并确认。

$ heroku loginLogging in... done
Logged in as [your email address]

现在我们准备创建 Heroku 项目。在项目路径中,键入Heroku和您的项目名称。这将创建项目并设置您的公共地址和 Git 位置。

$ heroku create nextjs-ts-chart
Creating ⬢ nextjs-ts-chart... donehttps://nextjs-ts-chart.herokuapp.com/ | https://git.heroku.com/nextjs-ts-chart.git

在我们发布之前,还有一个步骤。我们需要用一个指向我们创建的服务器文件的start run 命令在pacakge.json上设置我们的运行脚本。Heroku 将自动使用该启动命令。

由此改变package.json:

// package.json"scripts": {
  "start": "next start"
  ..
}

致以下内容:

// package.json"scripts": {
  "start": "node server.js"
  ..
}

接下来,让我们使用package.json run build命令脚本创建构建并推送到 Heroku 服务器。

$ yarn run build
$ git remote
$ git push heroku master

正如你所看到的,用 Next.js 创建一个启动项目,运行一个已发布的版本,然后发布你的代码,这很简单,我们只需要做很少的工作,就像我们用 CRA 做的一样。

使用 Heroku 的常见有用命令

一旦你做了修改,就像你平常用 Git 做的那样。

$ git add && git commit -m 'change' && git push heroku master

如果您在主服务器之外的 Git 分支上(例如 dev、main 或 production ),您可以通过设置分支名称来避免此错误:“主服务器不匹配任何错误,无法将一些引用推送到 Heroku”。

$ git push heroku [branch name]:master

这里有一个例子:

$ git push heroku HEAD:master

最后,假设您想要删除我们刚刚创建的回购。只需使用以下代码:

$ git remote rm heroku

如果您需要检查任何错误,请使用tail标志。

$ heroku logs --tail

与 CRA 一起创建和发布 SPA

使用 CRA MHL,让我们用cra-ts-chart创建一个新项目。

$ yarn create react-app cra-ts-chart --template must-have-libraries
$ cd cra-ts-chart
$ yarn start
$ open http://localhost:3000

矩形. tsx

接下来,使用我们之前创建的相同的Rectangle.tsx组件,并将其放在这里:src/components/Rectangle/Rectangle.tsx

App.tsx

正如我们对 Next.js 所做的那样,添加父组件App.tsx中的组件Rectangle.tsx,并删除其余的样板代码。

// src/App.tsx

import React from 'react'
import './App.scss'
import Rectangle from './components/Rectangle/Rectangle'

function App() {
  return (
    <div className="App">
      <Rectangle />
    </div>
  )
}

export default App

发布 CRA 与服务

发布 CRA 代码最简单的方法是使用 CRA serve并让它处理剩下的事情。

使用我用一个命令添加的 CRA·MHL 模板项目的运行脚本。

$ yarn build:serve

如果您检查package.json,您可以看到这个运行脚本运行另外两个运行脚本:

$ yarn build && serve -s build

请记住,相同的脚本也适用于 Next.js。这将打开带有已发布脚本的浏览器。

该脚本创建了一个优化版本的应用,并将其设置在端口 5000 上(如果未使用),如图 12-3 所示。

img/510438_1_En_12_Fig3_HTML.jpg

图 12-3

使用 serve 运行已发布的版本

若要关闭服务器,请在 Mac 上按 Command+C(或在 PC 上按 Control+C)来关闭服务器。

用快递发布 CRA

接下来,我们可以用与设置 Next.js 相同的方式设置 CRA 和 Express

将 Express 作为开发人员依赖项安装。

$ yarn add -D express

Server.js

接下来,让我们创建一个express Node.js 文件,类似于我们为 Next.js 创建的文件。我使用process.env.PORT或端口 9000,并以index.html作为入口点指向构建目录。

const express = require('express')
const path = require('path')
const server = express()

const publicFolder = path.join(__dirname, 'build')
const port = process.env.PORT || 9000;

server.use(express.static(publicFolder))
server.get('*', (req, res) => {
  res.sendFile(path.join(publicFolder, 'index.html'))
});

server.listen(port, (err) => {
  if (err) throw err
    console.log(`Server Ready on http://localhost:${port}`)
})

运行它来测试。

$ node server.js

用 Heroku 发布 CRA 无服务器

用 Heroku 发布我们的 CRA 无服务器,步骤和 Next.js 一样,我们已经有了 Heroku CLI,所以不需要重新安装。从登录部分开始,如下所示:

$ heroku login

该命令应该会打开一个浏览器,允许您登录并接受。

接下来,创建项目名称,您将看到与 Next.js 步骤中相同的 Git 和公共 URL。

$ heroku create cra-ts-chartCreating ⬢ cra-ts-chart... donehttps://cra-ts-chart.herokuapp.com/ | https://git.heroku.com/cra-ts-chart.git

我们需要为 Heroku 添加一个运行脚本。

// package.json"scripts": {
  "start": "node server.js",
  ..}

最后一部分和之前一样。运行构建并将代码推送到 Heroku repo。

$ yarn build

$ git remote
$ git push heroku master

现在,看到那个 https://cra-ts-chart.herokuapp.com/ 被部署到 Heroku。

$ heroku open

见图 12-4 。

img/510438_1_En_12_Fig4_HTML.jpg

图 12-4

在 Heroku 上发布的项目

CRA 在 https://create-react-app.dev/docs/deployment/ 有一个发布选项页面。

有用的调试分析工具

现在您已经发布了您的应用,您可能会发现一两个错误,需要修复您的应用。调试是检测和删除代码中可能导致不良行为的错误(也称为bug)的常见做法。

当您使用 React 应用时,当遇到问题时,可以使用一些特定的有用工具来调试和分析您的应用。拥有适合工作的工具并知道如何使用它们可以消除棘手问题并加快流程。

React 基于 JavaScript,所有适用于任何基于 JS 的应用的工具都可以在 React 上工作。诸如检查 DOM 元素、IDE 调试、设置警告和控制台消息等技术都是有效的。

我不打算展示简单和通用的方法来调试和分析你的应用,因为这本书假设你有一些 React、HTML 和 JavaScript 的工作知识;然而,请随意查看我的另一本 React 书:

https://www.apress.com/gp/book/9781484266953

此外,您可以在线查看我的两篇文章:

这些资源强调了一些你可能不知道的方法。

在本章的最后一节,我想指出一些额外的工具,一旦你发布了你的代码,它们可以帮助你完成工作。

下面是我将要介绍的调试和分析工具:

  • 使用 Chrome DevTools 进行调试和配置

  • React Chrome 开发工具扩展

  • React 探查器 API

使用 Chrome DevTools 进行调试和配置

Chrome DevTools extensions 是用于调试和分析应用的标准工具,也是最常用的工具之一。如果你需要在其他浏览器中测试一个应用,记住他们也提供类似 Chrome 的开发工具。

什么是 Chrome DevTools 扩展?

React 团队以及 React 社区构建了一个 Chrome DevTools 扩展,可以提供帮助。

这里有三个有用的 React development DevTools 扩展管理工具:

React 开发者工具 Chrome DevTools 扩展

React 开发者工具允许你在 Chrome 开发者工具中检查 React 组件层次结构。我们在你的 Chrome 开发工具中获得了两个新标签:⚛组件和⚛剖析器。

为了测试这个工具,我使用我的网站并导航到 https://elielrom.com/Books

我们可以看到很多关于组件和 React 的信息,比如 React 的版本(在我的例子中是v17-rc.0)、0s、路由信息和组件层次结构,如图 12-5 所示。

img/510438_1_En_12_Fig5_HTML.jpg

图 12-5

React 开发人员工具—组件窗口

第二个选项卡用于 Profiler,在这里我们可以记录一个产品概要构建。

React 开发工具 Chrome DevTools 扩展中的探查器

React Developer Tools Chrome DevTools 扩展有两个选项卡:组件和概要分析器。Profiler 选项卡提供了对 Flamegraph 的深入了解。

Flamegraph 是一个有序的图表工具,显示每个组件渲染所用的总时间。颜色表示渲染时间(越绿越好),以及从 VDOM 到“真实”DOM 渲染或重新渲染这些变化所花的时间。它包括排名和互动的标签。见图 12-6 。

img/510438_1_En_12_Fig6_HTML.jpg

图 12-6

我的开发版本的火焰图结果

记住,我们创建了一个运行脚本来分析生产构建。您可以比较优化版本和开发版本的不同结果。图 12-7 显示了生产构建($ yarn build:profile)的分级分析结果。

img/510438_1_En_12_Fig7_HTML.jpg

图 12-7

我的生产版本的排名结果

您还可以选择进入您的产品版本,以使概要分析工作正常进行。请记住,在生产版本上设置概要分析确实会带来一些开销。

React Chrome DevTools 扩展的实现

组件是 React 的核心。一旦你安装了 React 开发工具,有一个很棒的工具可以帮助你可视化 React 组件树。该工具有助于跟踪状态,并为您提供组件层次结构的整体概述。见图 12-8 。

img/510438_1_En_12_Fig8_HTML.jpg

图 12-8

https://EliElrom.com 上实现 React Chrome DevTools

这种全面的概述正在我的个人网站上整齐地分解AppRouter.tsx文件。

Recoil Chrome DevTools Extension(回收铬 DevTools 扩展)

当使用诸如 Redux 或反冲之类的状态管理时,能够跟踪状态的内部工作将会非常有用。有很多关于 Redux Chrome DevTools 扩展的文章,但我想指出一个我们在书中使用的用于状态管理的新反冲工具。该工具提供了关于原子、选择器和订阅者的信息。参见图 12-9 。

img/510438_1_En_12_Fig9_HTML.jpg

图 12-9

回火铬债务工具在 https://EliElrom.com

在我的例子中,atom 是基于bookObject,的,我可以在浏览器中检查状态值和变化。

export interface bookObject {
  title: string
  author: string
  pubDate: string
  link: string
  thumbnail: string
}

React 探查器 API

React Profiler API ( https://reactjs.org/docs/profiler.html )包括一个<Profiler/>组件,帮助定制来自源代码的度量标准,以测量组件的生命周期时间。

为了测试这个组件,您可以用 CRA 模板项目建立一个新的项目。

$ yarn create react-app your-project-name --template must-have-libraries

接下来重构路由AppRouter.tsx,用<Profiler/>组件包装;见图 12-10 。

img/510438_1_En_12_Fig10_HTML.jpg

图 12-10

CRA-MHL 模板开发构建的探查器 API 结果

// src/AppRouter.tsx

import { Profiler } from 'react'

const AppRouter: FunctionComponent = () => {
  return (
    <Profiler onRender={(id, phase, actualTime, baseTime, startTime, commitTime) => {
      console.log(`${id}'s ${phase} phase:`);
      console.log(`Actual time: ${actualTime}`);
      console.log(`Base time: ${baseTime}`);
      console.log(`Start time: ${startTime}`);
      console.log(`Commit time: ${commitTime}`);
    }}>
    <Router>
      <RecoilRoot>
        <Suspense fallback={<span>Loading...</span>}>
          <Switch>
            <Route exact path="/" component={App} />
          </Switch>
        </Suspense>
      </RecoilRoot>
    </Router>
    </Profiler>
  )
}

在这个例子中,我记录了所有的事情,但是我们可以创建一个脚本来过滤结果并以不同的方式处理它们。

摘要

如您所见,在选择启动项目和发布 React 应用时,您有很多选择。首先,决定是使用 SPA 还是 SSR,接下来选择启动项目,然后选择 server 或 serverless。归结起来就是你需要将 React 项目与哪些其他技术集成,使用,成本,维护,语言,你个人和团队的经验,以及许多其他事实。

在本章的最后一部分,我向您展示了一些有用的调试和分析工具,它们可以在您的开发之旅中为您提供帮助。我想感谢你购买这本书,并祝贺你完成它。

查看我的 d3 和 React 交互课程,看看你可以用不同的方法、见解和更多的解释来实现本书中的所有例子。

互动课程包含更多主题的材料,例如,对 DOM、色彩空间、交互性、设计进行更多控制,以及扩展本书的内容。互动课程补充了这本书,可以帮助你掌握 React 和 D3; https://elielrom.com/BuildSiteCourse

把你和这本书的社交媒体帖子发给我,我会收到互动课程的折扣代码。

如果你用这本书制作了一个很酷的带有 React 图表的 D3,我很乐意收到你的来信并看看你的图表。请给我留言并分享:

posted @ 2024-10-01 21:05  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报