React-和库教程-全-
React 和库教程(全)
一、学习 React 基本概念
这一章是我们起飞和开始飞行之前的地面学校。我们将创建一个极简的独立 React 应用,名为“Hello World”然后我们将回顾它是如何工作的,看看在引擎盖下发生了什么。我们还将学习 TypeScript 和初学者模板项目,我们可以用它们来加速开发。
这一章是理解 React 的基础,尽管我们并不是在这里构建一个华而不实的应用,但是其中涉及的概念是至关重要的。一旦你完成了这本书,甚至在阅读后面的章节时,如果你需要参考一些基本的概念,你可以随时回到这一章。
我们开始吧!
React 入门
在本节中,我们将创建一个极简的、独立的“Hello World”React 示例。我们将安装一个集成开发环境(IDE ),我们将涵盖一些重要的概念,如 JSX、DOM、React 虚拟 DOM、Babel 和 ES5/ES6。
不过,首先,我们将讨论 React 是什么以及为什么要使用它。尽管直接开始编写代码可能更令人满意,但关注关键概念将有助于您更好地理解 React。整本书依赖于你对这部分内容的理解。
什么是 React?
React(也称为 ReactJS)是一个 JavaScript 库,由脸书( https://github.com/facebook/react
)开发,用于创建 Web 用户界面。React 是由当时在脸书广告公司工作的乔丹·沃克发明的。与 jQuery、Angular、Vue.js、Svelte 等其他前端开发框架和库竞争。
在 2017 年 9 月发布的上一版本 React 16.x 中,React 团队增加了更多的工具和开发支持,并消除了 bug。React 17 于 2020 年 10 月发布。
Note
React 17 是一个“垫脚石”版本,该版本主要专注于使 React 在未来版本中更容易升级,以及提高与浏览器的兼容性。React 团队的支持表明该库势头强劲,不会很快消失。
为什么要 React?
你知道吗?
-
React 是开发人员的最爱。事实上,根据 StackOverFlow 的一项调查(
https://insights.stackoverflow.com/survey/2020
),React.js 已经连续两年是“最受喜爱”的 web 框架。 -
对 React 开发人员的需求激增;据 Indeed.com(
https://www.indeed.com/q-React-jobs.html
)统计,React 开放的开发者岗位接近 56000 个。 -
React 库本身比 Ember 或 Angular 等竞争对手的 Web 框架更轻,见此:
https://gist.github.com/Restuta/cda69e50a853aa64912d
(大约 100KB)和更快。 -
React 很容易上手。
安装 IDE
处理代码时,建议您使用 IDE。
为什么我甚至需要一个 IDE?
你不“需要”一个 IDE 您总是可以用文本编辑器编写代码。然而,IDE 可以帮助人们编写代码并提高生产率。这是通过提供编写代码所需的常见功能来实现的,例如源代码编辑器、构建自动化工具和调试器,以及许多其他功能。
说到 ide,可以选择的有很多。一些例子是微软的 Visual Studio、IntelliJ IDEA、WebStorm、NetBeans、Eclipse 和 Aptana Studio。
对于这本书,我选择了 Visual Studio Code (VS Code ),因为它是一个轻量级、免费、跨平台(Linux、macOS、Windows)的编辑器,可以用插件进行扩展。您可能正在为一家将为您提供特定 ide 的公司工作,或者您可能决定投资购买顶级 IDE 许可证。提到的大多数顶级 ide 都提供类似的功能,所以归结起来就是你习惯使用的东西,许可证等等。
Note
选择一个 IDE 取决于很多因素,我不会深入讨论这些因素。您可以安装或使用您习惯使用的任何 IDEVS 代码只是一个建议。
您还可以选择 Microsoft Visual Studio Express 版本,而不是 VS 代码,与 Microsoft Visual Studio 相比,VS 代码的功能有所减少。
如何安装 VS 代码?
要开始,请访问 VS 代码下载页面:
https://code.visualstudio.com/download
选择你的平台,一旦下载完成,打开 VS 代码。您应该会看到如图 1-1 所示的欢迎页面。
图 1-1
Visual Studio 代码欢迎页
创建一个极简的独立“Hello World”程序
是时候创建我们新的“Hello World”应用,并在我们的浏览器中运行该项目了。要在 VS 代码中创建新文件,请选择 new file。然后,粘贴以下代码,并将文件保存到您想要的任何位置:
<html>
<head>
<meta charSet="utf-8">
<title>Hello world</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@17.0.0/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js">
</script>
</head>
<body>
<div id="app"></div>
<script type="text/babel">
ReactDOM.render(
<h1>Hello World</h1>,
document.getElementById('app')
);
</script>
</body>
</html>
您可以从本书的 GitHub 位置下载该代码:
https://github.com/Apress/react-and-libraries/tree/master/01/index.html
Note
你可以从这本书的 GitHub 位置( https://github.com/Apress/react-and-libraries
)下载你在这本书里看到的所有代码和练习。这些文件是按章组织的。
现在我们已经创建了文件,将其重命名为index.html
,并用您最喜欢的浏览器打开它(见图 1-1 )。
图 1-2
在浏览器中 React“Hello World”index.html 应用
祝贺您,您刚刚创建了您的第一个 React 应用!
现在,让我们解码这个例子来理解这里发生了什么。
看看我们创建的ReactDOM.render
函数。看起来是这样的:
ReactDOM.render(
<h1>Hello World</h1>,
document.getElementById('app')
);
虽然代码看起来像 HTML,但它不是。这是 JavaScript 扩展(JSX)代码。
Note
JSX 是一个 React 扩展,它使用 JavaScript 标签来模仿 HTML 代码,因此代码与 HTML 相似但不相同。
什么是 JSX,我们为什么需要它?
为了理解 React 为什么使用 JSX 而不仅仅是 HTML,我们首先需要谈谈文档对象模型(DOM)。简单来说,React 在将这些更改提交给用户浏览器之前,会在后台处理您的 JSX 代码,以加快用户页面的加载速度。让我们更深入地了解这一过程。
Note
HTML 中的 DOM 代表文档对象模型。它是 HTML 的内存表示,并且是树形结构。
数字正射影像图
文档对象模型是 HTML 甚至 XML 文档(如 SVG 图像)的 API。API 通过包含定义 HTML 元素的功能的接口以及它们所依赖的任何接口和类型来描述文档。HTML 文档包括对各种事物的支持和访问,如与用户交互,如事件处理程序、焦点、复制和粘贴等。
DOM 文档由节点( https://developer.mozilla.org/en-US/docs/Web/API/Node
)的层次树组成,节点接口不仅允许访问文档,还允许访问每个元素,如图 1-3 所示。
图 1-3
DOM 文档由一个层次树组成
使用 Chrome 的 DevTools 控制台检查器(右键单击网页并选择检查➤控制台)并在控制台中键入以下内容:
window.document
图 1-4 显示了结果。您可以访问文档分层树结构以及构成该树的元素。
图 1-4
Chrome 的 DevTools 控制台检查器显示 DOM 文档
DOM 操作是现代交互式网页的核心,可以动态地改变网页的内容。从技术上来说,这可以通过使用 JavaScript 的方法来完成,如getElementById
和getElementsByClassName
以及removeChild
和getElementById("myElement").remove();
。这些 API 由 HTML DOM 公开,旨在提供在运行时更改代码的能力。
React 根据 React 元素表示确定对实际 DOM 进行什么更改,并在虚拟 DOM 中的后台进行更改。您可以将 React 虚拟 DOM 视为模拟。
然后,它只向实际用户的 DOM(浏览器)提交所需的更改。这个过程背后的原因是为了加快性能。
关键是实际的 DOM 树结构操作要尽可能快。例如,假设我们有一个产品页面,我们想更新产品列表的第一项。大多数 JavaScript 框架更新整个列表只是为了更新一个条目,而这个条目可能包含数百个条目。
大多数现代 web 页面拥有大型 DOM 结构,这种行为给用户带来了太多负担,导致 HTML 页面加载速度变慢。
引擎盖下的 React 虚拟 DOM
虚拟 DOM (VDOM)是一个编程概念,其中 UI 的理想或虚拟表示保存在内存中,并通过 ReactDOM 等库与“真实”DOM 同步。这个过程叫做和解。React VDOM 的目的是加快这一进程。
React 持有 HTML DOM 的一个副本(那就是虚拟 DOM)。一旦需要更改,React 首先对虚拟 DOM 进行更改,然后同步实际的 HTML DOM,避免了更新整个 HTML DOM 的需要,从而加快了过程。
例如,当我们渲染 JSX 元素时,每个虚拟 DOM 对象都会更新。更新整个虚拟 DOM 而不是实际的 HTML DOM 的过程更快。在对账过程中,React 需要弄清楚哪些对象发生了变化,这个过程叫做 diffing 。然后 React 只更新真实 HTML DOM 中需要更改的对象,而不是整个 DOM 树。
在我们的代码中,JSX 代码被包装在一个ReactDOM.render
方法中。为了进一步挖掘幕后的过程,了解引擎盖下发生的事情,我们还需要了解巴别塔和 ES5/ES6。
巴别塔和 ES5/ES6
我们编写的 JSX 代码只是编写React.createElement()
函数声明的一种更简洁的方式。每次组件使用 render 函数时,它都会输出一个 React 元素树或组件输出的 HTML DOM 元素的虚拟表示。
ECMAScript version 5 (ES5)是 2009 年完成的普通老式“常规 JavaScript”。所有主流浏览器都支持它。ES6 是下一个版本;它于 2015 年发布,增加了语法和功能。在撰写本文时,所有主流浏览器几乎都完全支持 ES6。事实上,React 团队在版本 17 中做了许多更改,以便与 ES6 更加一致和兼容。
我们希望利用 ES6 的功能;然而,与此同时,我们希望向后兼容旧的 ES5,这样我们就可以兼容所有版本的浏览器。为此,我们使用巴别塔。
Babel 是将 ES6 转换成 ES5 的库(不支持 ES6 的浏览器需要它)。ReactDOM.render()
函数,顾名思义,渲染 DOM。render 函数应该返回一个虚拟 DOM(浏览器 DOM 元素的表示)。
Note
从巴别塔 8 开始,React 获得了一个特殊的功能。render
方法改为jsx
。
注意,从 Babel 8 开始,React 得到了一个名为jsx
的特殊函数来代替render
函数。在 Babel 8 中,它还会在需要的时候自动导入react
(或者其他支持新 API 的库),这样你直接写到 Babel 就不用再手动包含了。
例如,看看这个输入:
function Foo() {
return <div />;
}
巴别塔会把代码变成这样:
import { jsx as _jsx } from "react/jsx-runtime";
function Foo() {
return _jsx("div", ...);
}
同样从 Babel 8 开始,jsx
将自动成为默认运行时。你可以在我关于媒介的文章中读到更多关于这些变化: http://shorturl.at/bxPZ7
.
现在您已经理解了 React 在幕后做什么以及 Babel 的角色,您可以回头看看我们的代码示例。注意,我们导入了三个库。
-
React version 17 :我们使用 React 来定义和创建我们的元素,例如使用生命周期钩子(在本书后面会有更多关于钩子的内容)。
-
ReactDOM 版本 17 :这是用于 web 应用的(移动设备有 React Native)。这就是为什么 React 和 ReactDOM 之间的代码会有一个分割。ReactDOM 是 React 和 DOM 之间的粘合剂。它包含了允许我们访问 DOM 的特性,比如用
ReactDOM.findDOMNode()
找到一个元素或者用ReactDOM.render()
安装我们的组件。 -
正如我们所解释的,Babel 是将 ES6 转换成 ES5 的库。
现在我们已经看了 React“Hello World”代码,我们理解了为什么我们要导入这些库以及在幕后发生了什么。这些关键概念对于理解 React 至关重要。如果你需要复习,请随时回到这一章。
在本节中,我们创建了一个名为“Hello World”的极简独立 React 应用,并回顾了如何工作。我们安装了 VS 代码集成开发环境,学习了一些重要的概念,比如 JSX、DOM、React 虚拟 DOM、Babel 和 ES5/ES6。
TypeScript 入门
在编写 React 代码时,有两种选择。您可以使用 JavaScript (JS)或 TypeScript (TS)编写代码。TypeScript 是 transpiler,这意味着 ES6 不理解 TS,但 TS 会被编译成标准的 JS,这可以用 Babel 来完成。在下一章中,我们将配置我们的项目来接受 TS 文件和 ES6 JS 文件。
为什么应该将 TypeScript 集成到 React 项目中?
以下是一些有趣的事实:
-
您知道 TypeScript 是由微软开发和维护的开源编程语言吗?
-
根据 2020 年 StackFlow 的一项调查,TypeScript 编程语言的受欢迎程度排名第二,超过了 Python!
-
ReactJS 框架以 35.9%的比例位居第二,它正在绕过“国王”jQuery。
-
与此同时,32.9%的受访者说他们实际上害怕打字稿。
问题是,为什么 TypeScript 这么受欢迎?
TypeScript 与 JavaScript:有什么大不了的?
JavaScript 是一种脚本语言,而 TypeScript (TS)是一种成熟的编程语言。TS,顾名思义,就是要有更多的类型。TS 比 JS 更容易调试和测试,并且 TS 通过描述预期的内容来防止潜在的问题(当我们在本书后面测试我们的组件时,您将看到这一点)。拥有成熟的面向对象编程(OOP)语言和模块将 JS 带到了企业级,并提高了代码质量。
以下是 TypeScript 相对于 JavaScript 的优势:
-
TypeScript 是一种 OOP 语言;JavaScript 是一种脚本语言。
-
TypeScript 使用遵循 ECMAScript 规范的静态类型。
-
TypeScript 支持模块。
Note
类型系统将一个类型与每个值相关联。通过检查这些值的流程,它确保没有类型错误。
静态类型意味着在运行之前检查类型(允许您在运行之前跟踪 bug)。JS 只包括以下八种动态(运行时)类型:BigInt
、Boolean
、Integer
、Null
、Number
、String
、Symbol
、Object
(对象、函数、数组)和Undefined
。
Note
所有这些类型都被称为原始类型,除了Object
,它是非原始类型。TS 通过设置编译器对源代码进行类型检查,将静态类型添加到 JavaScript 中,从而将其转换为动态代码。
React 和 TypeScript 配合得很好,通过使用 TypeScript 和描述类型,您的代码质量提高了使用 OOP 最佳实践的应用的代码质量,这是值得学习的。
在下一章中,我们将用 TypeScript 创建一个启动项目;但是,您可以现在就开始尝试 TypeScript 并学习基础知识。
这一步让您很好地了解了 TS 的类型,以及如何在代码中利用 TS 的力量。
TS 的版本是 4。要玩编码 TS,可以在 TS 游乐场运行实际的 TS 代码,可在 https://tc39.github.io/ecma262/#sec-ecmascript-language-types
处获得,如图 1-5 所示。
图 1-5
TS 游乐场
操场左边是 TS,右边是同样编译成 JS 的代码。接下来,打开 JavaScript 控制台,点击 Run 查看代码运行情况。
操场网站有大量的例子可以帮助你更好地理解 TS。我建议研究这些例子。
注意,这个例子使用了“strict”,在 Config 菜单项中,您可以从 Config 链接设置编译器选项。不同的编译器选项位于 https://www.typescriptlang.org/docs/handbook/compiler-options.html
。
这可能会对抗编写代码时左右弹出的错误,但它会避免以后编译器无法识别类型时出现的问题。我提到 TS 是 OOP,遵循 ECMAScript 规范;然而,规范是动态的,经常变化,所以您可以指定 ECMAScript (ES)目标。参见图 1-6 。
图 1-6
指定 TS 操场中的 ECMAScript 目标
打字稿备忘单
开始使用 TypeScript 的一个很好的地方是通过查看不同的可用类型来理解 TS 的功能。我的编程风格是遵循像莎士比亚那样的编码的函数命名惯例,让方法的名称自我解释。
要试用 TypeScript,请将此处显示的 TypeScript 备忘单代码粘贴到位于 https://www.typescriptlang.org/play/
的 TS 游乐场。然后,您可以继续查看 JS 编译器的结果并运行代码。正如你所看到的,我把我的例子分成了基本类型和非基本类型。
// primitive types
// The most basic datatype - true/false switch
const flag1: Boolean = true;
// inferred-type
const flag2 = true;
// Be a programming god - create your own type
type Year = number;
const year: Year = 2020;
// Undefined and null not included in types like js, to
// get a null you will set it as either null or number
let willSetValueLater: null | number = null;
console.log('willSetValueLater: ' + willSetValueLater);
willSetValueLater = 2020;
console.log('willSetValueLater: ' + willSetValueLater);
// none-primitive
// Arrays
let arrayNumber: number[] = [];
let myCastTypeArrayNumber: Array<number> = [];
myCastTypeArrayNumber.push(123);
console.log('myCastTypeArrayNumber: ' + JSON.stringify(myCastTypeArrayNumber));
// Tuples (two values)
let longlatSimple: [number, number] = [51.5074, 0.1278];
let longlatInferredType = [51.5074, 0.1278];
// Enums design pattern
enum ChildrenEnum {JOHN = 'John', JANE = 'Jane', MARY = 'Mary'}
let john: ChildrenEnum = ChildrenEnum.JOHN;
console.log('What is JOHN enum? ' + john);
// Maps
// inferred type: Map<string, string>
const myLongLatMapWithInferredType = new Map([
['London', '51.5074'],
['London', '0.1278'],
]);
// interfaces
// Typing objects-as-records via interfaces
interface longlat {
long: number;
lat: number;
}
function longlatToString(longlat: longlat) {
return `${longlat.long}, ${longlat.lat}`;
}
// Object literal (anonymous interfaces) inline:
function longlatToStringWithAnonymousInterfaces(longlat: {
long: number;
lat: number;
}) {
return `${longlat.long}, ${longlat.lat}`;
}
// Place optional params (phone) and method in interface
interface Client {
name: string;
email: string;
phone?: string
;
longlatToString(longlat: longlat): string;
}
// Factory design pattern made easy using type cast
interface resultsWithUnkownTypeCast<SomeType> {
result: SomeType;
}
type numberResult = resultsWithUnkownTypeCast<number>;
type arrayResult = resultsWithUnkownTypeCast<[]>;
// functions
// Simple function
const turnStringToNumber: (str: String) => Number =
(str: String) => Number(str);
// %inferred-type: (num: number) => string
const turnStringToNumberMinimalist = (str: String) => Number(str);
console.log('turnStringToNumber: ' + turnStringToNumber('001'));
console.log('turnStringToNumberMinimalist: ' + turnStringToNumberMinimalist('002'));
function functionWithExplicitReturn(): void { return undefined }
function functionWithImplicitReturn(): void { }
// Simple functions with callbacks
function simpleFunctionWithCallback(callback: (str: string) => void ) {
return callback('done something successfully');
}
simpleFunctionWithCallback(function (str: string): void {
console.log(str);
});
// Never callback - not placing never is inferred as never
function neverCallbackFunction(callback: (str: string) => never ) {
return callback('fail');
}
// neverCallbackFunction(function(message: string): never {
// throw new Error(message);
// });
// Complex Callback and specifiy result types
function complexCallbackWithResultType(callback: () => string): string {
return callback();
}
console.log('doSomethingAndLetMeKnow: ' + (complexCallbackWithResultType(String), 'Done it!'));
// Function with optional params using '?'
function functionWithOptionalCallbackParams(callback?: (str: String) => string) {
if (callback === undefined) {
callback = String;
}
return callback('sucess');
}
// Function with setting the type with default values
function createLonglatWithDefaultValues(long:number = 0, lat:number = 0): [number, number] {
return [long, lat];
}
console.log('createLonglatWithDefaultValues: ' + createLonglatWithDefaultValues(51.5074, 0.1278))
console.log('createLonglatWithDefaultValues: ' + createLonglatWithDefaultValues())
// function with rest parameters
// A rest parameter declared by prefixing an identifier with three dots (...)
function functionWithRestParams(...names: string[]): string {
return names.join(', ');
}
console.log(functionWithRestParams('John', 'Jane', 'Mary'));
// Function with potential two params types
function isNumber(numOrStr: number|string): Boolean {
if (typeof numOrStr === 'string') {
return false;
} else if (typeof numOrStr === 'number') {
return true;
} else {
throw new Error('Not sure the type');
}
}
console.log('isNumber: ' + isNumber('123'));
您可以从这里下载完整的代码:
https://github.com/Apress/react-and-libraries/tree/master/01/TS-getting-started.ts
如果你需要更多的解释,请看官方的 TS 基本类型手册:
https://www.typescriptlang.org/docs/handbook/basic-types.html
将代码从 TS 转换为 JS 的 JS 编译器和控制台结果将帮助您理解这些示例。参见图 1-7 。
图 1-7
TypeScript 操场示例
React 模板启动项目选项
在编写 React 应用时,您有几个选择。您可以自己编写整个代码,就像我们在“Hello World”示例中所做的那样,然后添加库来帮助您完成打包代码并为生产做好准备的任务。另一个选择是使用一个 starter template 项目,它已经处理好了搭建和配置工作,并且包含了一些库,可以帮助您只需编写代码就可以快速完成工作。
正如您所看到的,VDOM 过程发生在幕后,当页面发生变化时,我们不需要刷新页面。
事实上,创建 React 应用最流行的模板是 Create-React-App (CRA)项目( https://github.com/facebook/create-react-app
)。该项目由脸书团队创建,GitHub 上有近 84,000 颗星星。
CRA 是基于单页面应用(SPA)的,这很好,因为你不用刷新页面,感觉就像你在一个移动应用中。
这些页面应该在客户端呈现。这对中小型项目来说非常好。
另一个选项是服务器端呈现(SSR)。SSR 在服务器上呈现页面,因此客户端(浏览器)将显示应用,而无需做任何工作。这有利于某些用例(通常是大型应用),在这些用例中,如果渲染发生在客户端,用户体验会很慢。
CRA 一开始就不支持 SSR。有一些方法可以配置 CRA 并让它像 SSR 一样工作,但是对于一些开发人员来说,这可能太复杂了,并且需要您自己维护配置,所以可能不值得这样做。
如果您正在构建需要 SSR 的更大的东西,最好使用已经配置了 SSR 的不同 React 库,如 Next.js framework、Razzle 或 Gatsby(在构建时将 prerender 网站包含到 HTML 中)。
Note
如果你想用 React 和 Node.js 做服务器渲染,可以去看看 Next.js,Razzle,或者 Gatsby。Create React App 与后端无关,只生成静态的 HTML/JS/CSS 包。 https://github.com/facebook/create-react-app
见。
也就是说,通过 CRA,我们可以做一个 prerender,这是最接近 SSR 的方法,在本书的最后一章,当我们优化 React 应用时,你会看到这一点。
在本书的例子中,我们将使用 CRA;但是,如果对这本书有了扎实的理解,您可以很容易地迁移到任何使用 React 的模板库。
摘要
在本章中,我们创建了一个名为“Hello World”的极简独立 React 应用,并探索了它的工作原理。我们安装了 VS 代码集成开发环境,学习了一些重要的概念,如 JSX、DOM、React 的虚拟 DOM 和 Babel,以及 ES5/ES、SPA、SSR 和 TypeScript。
在下一章,我们将学习更多关于 CRA 项目的知识,并用基本库建立我们的启动项目和开发环境。
二、React 起始项目和朋友
在这一章中,我们将涉及许多库,这可能会让人不知所措。然而,我想为你提供一个很好的基础,这样你就可以拥有最好的创业项目。启动项目将为您提供很好的服务,因为您可以复制并重用我们将在本书中使用的所有项目和示例的代码。这种实践将使你成为一流的 React 开发人员,能够处理任何规模的项目,并帮助你找到理想的工作。也会加速你的发展。
在本章结束时,你将会有一个入门项目,其中包括了我们将在本书中涉及的许多库。我们开始吧!
Create-React-App Starter 项目入门
在前一章中,我们从头开始创建了一个简单的“Hello World”应用,我们讨论了它在幕后是如何工作的。我们讨论了 JSX、DOM、React 虚拟 DOM、Babel、ES5/ES6 和 spa 等主题。
创建一个 React 应用很容易,只需要几行代码。然而,要创建一个基于一个页面(一个 SPA)的真实应用,这个页面有多个视图、许多用户手势、有成百上千个条目的列表、成员区域和其他在当今应用中常见的部分,我们需要学习的还有很多。
在本书中,我的目标是为您提供一个大型工具箱,里面装满了最好的库和工具,这样您就可以构建一个顶级的 React 应用,并充分利用 React。为此,我们需要看看 React 世界中几个流行的库和工具。
我们可以从零开始,但这不是必需的。正如我在前一章提到的,Create-React-App (CRA)是 React 基于 web 的应用最流行的启动项目。(见 https://github.com/facebook/create-react-app
)。)它提供了一个样板项目,在构建顶级 React 应用时,您可以快速启动并运行许多必要的工具和标准库。
它包括香草口味的包装和其他更有价值的包装。此外,CRA 可以选择包含更复杂库的模板,或者您可以创建自己的模板,其中包含 CRA 没有包含的某些元素。
CRA 已经为我们做了一些决定。例如,构建工具是一个名为 Webpack over Rollup 或 Parcel 的工具。Task Runners 是用 NPM 脚本安装的,而不是像 Gulp 这样的工具。CSS、JS 和 Jest 被用作默认值。
在使用 React 完成工作的项目和库之后,很难保持中立而不推荐某些工具,这就是为什么我选择使用本章中的工具。此外,由于许多库不容易移植到,您将希望从正确的方面开始,而不是在以后切换工具。这就是为什么我们在这个库中建立我们的项目。这些库将帮助您完成工作,并且您可以将该模板用于其他项目,无论是小型项目还是大型企业级项目。
React 开发者路线图
成为一名真正的专业 React 开发人员不仅仅是了解 React 库。正如我们提到的,有一些工具和库可以帮助加速开发,因为记住,React 不是一个框架。React 是脸书开发的 JavaScript 库,用于处理用户界面,仅此而已。
为了帮助找出将 React 转变为一个成熟框架的部分,该框架能够高效地创建高质量的应用,并能与其他 JS 框架竞争,请查看图 2-1 中的路线图。
图 2-1
React 开发者路线图(来源: https://github.com/adam-golab/react-developer-roadmap
)
该图表展示了成为顶尖专业 React 开发人员的推荐途径。
不要被这张图表吓倒。如果你阅读了这本书,当这本书结束时,你将会了解许多这样的库,并且能够处理任何规模的项目。
如何将这些工具集成到我的 ReactJS 项目中?
正如我提到的,CRA 没有包括许多可以帮助您处理现实生活中的 React 项目的工具。然而,我已经建立了一个 CRA 模板,将包括所有必须知道的图书馆。只需一个命令,您就可以获得包含所有需要的库的启动项目。我们将在本章中对此进行简要介绍。
请记住,事物瞬息万变;另外,您的项目可能需要不同的工具,或者您可能想要进行试验。这就是为什么在这一节我们将把它全部分解并展示如何安装每个库。
在本节中,我们将安装以下库:
-
打字检查器:打字稿
-
预处理器:萨斯/SCSS
-
状态管理 : Redux 工具包/反冲
-
CSS 框架:素材-UI
-
组件:样式组件
-
路由:React 路由
-
单元测试 : Jest 和 Enzym + Sinon
-
E2E 测试:笑话和木偶师
-
棉绒:变得又亮又漂亮
-
其他有用的库 : Lodash,Moment,Classnames,Serve,react-snap,React-Helmet,Analyzer Bundle
该列表是 React 库。文件夹结构是我将要介绍的内容的一部分,但它不是一个图书馆。我们可以加上这句话向读者解释。
先决条件
您可以使用 NPM ( https://www.npmjs.com/
)来安装这些库。你需要 Node.js 来获取 NPM。
NPM 和 Node.js 携手并进。NPM 是 JavaScript 包管理器,也是 JavaScript Node.js 环境的默认包管理器。
在 Mac/PC 上安装节点和 NPM
Node.js 版本至少应为 8.16.0 或 10.16.0。我们需要那个版本的原因是我们需要使用 NPX。NPX 是 2017 年推出的 NPM 任务跑步者,它用于设置 CRA。
如果缺少它,请在终端(Mac)或 Windows 终端(Windows)中键入以下内容进行安装:
$ node -v
如果没有安装,您可以从这里为 Mac 和 PC 安装(见图 2-2 ):
图 2-2
Nodejs.org
https://nodejs.org/en/
安装程序可以识别你的平台,所以如果你在 PC 上,步骤是一样的。
下载安装程序后,运行它。完成后,在终端中运行node
命令,如下所示:
$ node -v
该命令将输出 Node.js 版本号。
下载库:纱线或 NPM
要从 NPM 资源库下载包,我们有两个选项:Yarn 或 NPM。NPM 是在我们安装 Node.js 的时候安装的,然而在本书中,我们将主要使用纱库。我们将尽可能使用 Yarn 来下载包,而不是 NPM,因为 Yarn 比 NPM 快。Yarn 缓存已安装的包并同时安装包。
在 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,我们就可以开始了。我们可以使用我为您创建的 CRA 必备库(MHL)模板项目,它将产生本章的最终结果,并包括我们在本章中设置的所有库。
您可以从这里获得:
https://github.com/EliEladElrom/cra-template-must-have-libraries
将这个模板项目作为您的启动库是很好的,不仅因为它很容易并且包含了我们需要的所有库,还因为我将能够在书发布后很长时间内更新这个启动项目,以防出现任何问题或需要更新,就像 NPM 图书馆经常发生的那样。
您可以使用带有纱线的 CRA 模板和一个命令来创建本章的最终项目,如下所示:
$ yarn create react-app starter-project --template must-have-libraries
或者您可以使用 NPX 创建它,如下所示:
$ npx create-react-app your-project-name --template must-have-libraries
要运行这个项目,您可以将目录更改为starter-project
并在终端中运行start
命令,如下所示:
$ cd starter-project
$ yarn start
此命令将安装所有依赖项,并在本地服务器上启动项目。在下一节中,您将了解到更多关于引擎盖下发生的事情。
在这一章的其余部分,我将解释这个项目包括什么,并为你逆向工程这个项目,这样你就可以完全理解在引擎盖下发生了什么。因为你已经准备好了最终项目,所以不一定要完成本章中的所有步骤。
香草风味创造 React 应用
因为您能够安装 starter 模板项目,所以您可以立即开始开发。了解每个安装的库是很好的,这样你就可以完全理解这个项目包含了什么,并且可以根据你的具体需求定制你的项目。要创建 CRA 而不使用 CRA MHL 模板项目,您可以安装我们自己的每个库。只需使用 vanilla-flavor 模板,通过在终端中使用yarn
运行以下命令来启动一个新的 CRA:
$ yarn create react-app hello-cra
请记住,yarn
命令相当于下面使用 NPM NPX 任务运行器的命令,就像我们在 MHL 模板项目中看到的一样:
$ npx create-react-app hello-cra
第一个参数是我们正在下载的库。第三个参数的hello-cra
是我们的项目名称。接下来,我们需要将目录更改为我们的项目。
$ cd hello-cra
最后,键入以下命令在终端/Windows 终端中启动项目:
$ yarn start
NPM 的情况也是如此,如下图所示:
$ npm start
我们在终端收到这条消息:
Happy hacking!
Compiled successfully!
您现在可以在浏览器中查看hello-cra
。
Local: http://localhost:3000
On Your Network: http://192.168.2.101:3000
请注意,开发构建并没有优化。要创建生产版本,请使用yarn build
。
我们的应用将使用默认浏览器在端口 3000 上自动打开。如果没有,可以用任何浏览器,用这个网址:http://localhost:3000/
。
Note
如果您想停止开发服务器,请使用 Ctrl+C。
后台发生的事情是 CRA 应用为我们创建了一个开发服务器。参见图 2-3 。
图 2-3
我们浏览器 3000 端口上的香草 CRA
祝贺您,您刚刚在开发服务器上创建并发布了 CRA!React 项目包括 CRA 开箱即用的所有库。
如果你检查为我们创建的代码和文件(见图 2-4 ,你可以看到有许多文件组成了我们的单页应用。例子有App.js
和index.js
。我们也有样式表文件,如App.css
和index.css
。
图 2-4
CRA 文件和文件夹
React 最初被设计为 SPA,通常我们需要编写一个脚本来将我们的文件组合成一个文件或一大块文件,我们需要将这些文件包含在我们的应用中,以便代码可用。但是,由于我们使用 CRA,所有这些都是自动完成的。
CRA 使用 Webpack 和 Babel 将我们的文件打包到一个单独的index.html
页面和名为bundle.js
和*.chunk.js
的文件中。它可以根据需要生成其他文件。
Note
CRA 正在使用现成的 Webpack 模块捆绑器。Webpack 是一个开源的 JavaScript bundler,它使用加载器来捆绑文件和创建静态资产。
当我们键入命令yarn start
时,脚本启动一个 NPM 脚本,为我们创建一个开发环境。该脚本将我们的文件缓存在内存中,并在我们导航到 URL 时提供给我们。
看一下图 2-5 ,它说明了这个过程。
图 2-5
从 10,000 英尺的角度来说明 CRA 使用 Webpack 和 Babel 将我们的文件打包到一个 index.html 页面中
要查看应用的源代码并检查为我们创建的文件,请访问 Chrome 上的 URL:http://localhost:3000/
。
或者我们也可以用 Chrome DevTools 检查 HTML DOM 元素。在 Chrome 上右键单击并选择 Inspect 来查看 DOM 树。
您可以在 body HTML 标记中看到以下文件:
<script src="/static/js/bundle.js"></script>
<script src="/static/js/0.chunk.js"></script>
<script src="/static/js/main.chunk.js"></script>
bundle.js
文件,顾名思义,将我们的 JavaScript 源代码文件捆绑成一个文件,*.chunk.js
文件将我们的样式捆绑成文件块。您可以访问这些文件的 URL 来查看内容。
繁重的工作是在我们的项目中一个名为node_modules
的文件夹中完成的,这个文件夹包含了许多我们的项目正在使用的依赖库。看一下图 2-6 。
图 2-6
具有依赖关系的 node_modules 文件夹
您可以在 Node.js 使用的名为package.json
的文件中的node_modules
文件夹中找到依赖项列表。该文件包含有关我们项目的信息,如版本、正在使用的库,甚至 Node.js 将运行的脚本。
打开package.json
文件。请注意,如果与node_modules
文件夹中的长列表相比,我们的项目使用的库依赖列表很短(请记住,在图书发布后,库版本可能会经常更改)。
"dependencies": {
"@testing-library/jest-dom": "⁴.2.4",
"@testing-library/react": "⁹.3.2",
"@testing-library/user-event": "⁷.1.2",
"react": "¹⁶.13.1",
"react-dom": "¹⁶.13.1",
"react-scripts": "3.4.3"
每个依赖项都包含库名和版本号。
如果我们检查我们的node_modules
文件夹,它充满了其他开发者的库。事实上,有 1000 多个,当我们下载我们的 CRA 时,需要一段时间(当然取决于你的网速)来下载项目。
这是因为所有这些依赖性。那么,所有这些依赖来自哪里呢?
每个库都包含其他依赖项或子依赖项,所以尽管我们没有在项目的package.json
文件中列出这些库,但是它们会在其他库或子库中列出。
还要注意,在我们的package.json
文件中有一个指定脚本的部分。
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}
事实上,当我们运行yarn start
时,创建本地服务器的过程正在进行。那个剧本是从哪里来的?要回答这个问题,请打开下面的库,查看下面构建开发服务器的代码:
hello-cra/node_modules/react-scripts/scripts/start.js
React 脚本
这些脚本正在使用一个名为react-scripts
的库。如果您转到hello-cra/node_modules/react-scripts/package.json
文件,您会看到一长串依赖项,代码使用这些依赖项将文件与 Babel 和 Webpack 打包并构建我们的服务器。这些子库中的每一个都有其他的依赖项,依此类推。
这就是为什么我们的node_modules
文件夹中有超过 1000 个库。
吉蒂尔
通常的做法是在每个项目中创建一个.gitignore
文件。该文件可以包含我们想要排除的文件。
例如,CRA 已经包含了一个.gitignore
文件,而node_modules
已经列出了要排除的文件。然后我们运行yarn
命令并检查package.json
文件,它为我们安装了所有这些依赖项,而不是将所有这些库包含在我们的项目中,这将使我们的项目非常大。
公共文件夹
我们的应用中还有一个名为public
的文件夹,其中包含我们的应用图标。具体来说,它包含以下内容:
-
public/favicon.ico
、logo192.png
、logo512.png
:在manifest.json
文件中使用的图标 -
public/index.html
:我们的索引页 -
关于我们的应用和风格的信息
-
public/robots.txt
:搜索引擎说明
如果我们检查我们的index.html
页面,我们会看到在manifest.json
文件中设置的令牌,但是如果我们打开 HTML body 标签中的public/index.html
文件,我们不会看到任何 JS 包块,比如我们在浏览器中检查代码时看到的*.chunk.js
和bundle.js
文件。看一看:
public/index.html;
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
原因是 NPM 脚本根据这个文件以及它生成的文件来生成我们的索引文件。例如,如果我们想将我们的应用发布到生产环境中,我们将运行不同的命令(build
而不是start
),并且会生成不同的文件。在本书的后面,您将了解更多关于发布到产品以及如何优化 JS bundle 块的信息。
您可以从这里下载代码:
https://github.com/Apress/react-and-libraries/tree/master/01/hello-cra
要让 Yarn 使用package.json
文件并下载所有依赖项,运行以下命令:
$ yarn start
使用类型脚本创建-React-应用
CRA 有两种风格:香草和打字稿。注意,我们的应用文件src/public/App.js
和src/public/index.js
有.js
文件扩展名,这意味着它们是 JavaScript 文件。CRA 将我们项目的默认设置设置为 JavaScript。
然而,在编写 React 代码时,有两个主要选项。我们可以用 JavaScript (JS)或 TypeScript (TS)编写代码。您下载的香草味 CRA 被设置为 JavaScript。然而,正如我们在前一章提到的,我们将在本书中使用 TypeScript 编写代码。
带打字稿的 CRA 模板
要使用 Yarn 设置我们的 TS 项目,命令类似于我们设置 CRA 的方式。唯一的区别是我们为 React 社区创建的 TypeScript 添加了 TS 模板。
$ yarn create react-app starter-project --template TypeScript
我们在 TS 中使用了--template
标志,并将我们的项目命名为starter-project
。
现在,将目录更改为您的项目,并启动项目以确保一切顺利,就像我们之前所做的那样。
$ cd starter-project
Tip
我建议在每次安装后测试您的项目,以确保它仍然在运行。库和依赖项经常变化,确保项目不中断应该是您的首要任务。
$ yarn start
应用应该在端口 3000 上打开,就像我们之前做的一样:http://localhost:3000/.
参见图 2-7 。
图 2-7
在端口 3000 上运行的 CRA TS 应用
请注意,应用上的副本更改为src/App.tsx
(来自图 2-3 ),我们应用中的文件也从.js
更改为.tsx
。
接下来,我们需要为 TS 添加以下类型,以便 Webpack 知道如何处理.tsx
文件类型并捆绑它们,这样它们就可以作为静态资产包含在我们的项目中。
$ yarn add -D typescript @types/node @types/react @types/react-dom @types/jest @typescript-eslint/scope-manager
当使用 Yarn 时,当我们想要更新项目的package.json
以在devDependencies
下包含一个库时,我们使用-D
标志(代表开发者“依赖”)。package.json
文件保存了项目的库。
Note
为 TypeScript 安装的类型用于为 TypeScript 提供有关用 JavaScript 编写的 API 的类型信息。
我们刚刚安装的类型包括 Jest 测试的 TypeScript 和 ESLint。CRA 捆绑了 Jest 和 Jest-dom 来测试我们的应用。我们将在本书的后面学习用 Jest 和 ESlint 测试我们的应用,但是我想让你知道我们已经在设置这些类型了。
除了将我们的代码文件从.js
更改为.ts
,模板项目还包括一个名为tsconfig.json
的文件。这个文件为编译器保存了一个特定的设置,编译器将编译从.tsx
到.js
的文件,其中包含我们的目标信息,比如 ES6 和其他设置。
CSS 预处理程序:Sass/SCSS
级联样式表(CSS)是 HTML 的核心功能,如果您还不熟悉 CSS,那么您需要熟悉它。这尤其适用于 HTML,尤其是 React。在大型项目中,CSS 预处理程序通常用于补充 CSS 和添加功能。我们将在本书的后面讨论这些。
就 CSS 预处理程序而言,有四种主要的 CSS 预处理程序经常与 React 项目一起使用:Sass/SCSS、PostCSS、Less 和 Stylus。
Note
CSS 用于表示不同设备上网页的可视布局。CSS 预处理器用于增强 CSS 功能。
萨斯/SCSS 公司在今天的大多数项目中占据上风,所以这就是我们将使用的。事实上,萨斯/SCSS 是最受欢迎的,根据调查( https://ashleynolan.co.uk/blog/frontend-tooling-survey-2019-results
)作为一名开发人员,它可能会给你带来收入最高的工作。如果你想看不同的 CSS 预处理程序之间的比较,可以看看我关于 Medium 的文章: http://shorturl.at/dJQT3
.
我们将在本书的后面学习更多关于 CSS 和 SCSS 的知识,但是现在,用 Yarn 安装它。
$ yarn add -D node-sass
就像 CSS 一样,如果我们想像在 JavaScript 中一样在 TypeScript 中使用 SCSS 模块,我们需要安装 Webpack 的 Sass/SCSS 加载器。它叫做scss-loader.
因为我们正在使用 TS,我们需要一个能够与 TS 一起工作并为 Sass/SCSS 生成类型的scss-loader
的替代品。用纱线安装加载器。
$ yarn add -D scss-loader typings-for-scss-modules-loader
Redux 工具包/反冲
Redux 工具包 是组织 React 应用数据和用户交互的标准方式,因此您的代码不会变得混乱。
Note
Redux 是一个用于管理应用状态的开源 JavaScript 库。它通常与 React 一起用于构建用户界面。 https://redux.js.org
见。
我们将在本书的后面讨论 Redux 和 Redux 工具包。现在,让我们安装 Redux 工具包库和类型。
$ yarn add -D redux @reduxjs/toolkit react-redux @types/react-redux
反冲是另一个由脸书团队创建的状态管理库,我相信它将接管 Redux 工具包。我们将在本章的后面使用这两个库。要安装反冲,使用:
$ yarn add recoil@⁰.0.13
材质-UI CSS 框架
CSS 框架(或 CSS 库)是基于为你的 web 开发带来更标准化的实践的概念。与只使用 CSS(或其他样式表)相比,使用 CSS 框架可以加快您的 web 开发工作,因为它允许您使用预定义的 web 元素。
是的,我们可以创建所有的定制组件并设计它们的样式,但是大多数时候这是不值得的,除了编写组件,我们还需要在所有的浏览器和设备上测试它们。你能想象吗?
import 'bootstrap/dist/css/bootstrap.css';
在某些项目中,Bootstrap 和 Material-UI 都是很好的 CSS 框架,无需花费大量时间创建自己的组件就可以立即开始使用。
为了安装它,我将设置 Material-UI 核心以及图标包。
$ yarn add -D @material-ui/core @material-ui/icons
为了让 Material-UI 与 TS 无缝协作,我们还需要更新我们的tsconfig.json
文件中的一些设置,以便 React 上的 TS 页面不会遇到任何错误。
用文本编辑器打开tsconfig.json
并更新。
// tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": [
"es6", "dom",
...
...
],
"noImplicitAny": true,
"noImplicitThis": true,
"strictNullChecks": true,
样式组件
风格化的组件与 Material-UI 密切相关。Styled Components 是一个样式化的解决方案,也可以用在 Material-UI 之外。
要将样式化的组件和类型添加到我们的项目中,请使用:
$ yarn add -D styled-components @types/styled-components
此外,如果您需要安装字体,请将此链接放在手边:
https://medium.com/r/?url=https%3A%2F%2Fgithub.com%2Ffontsource%2Ffontsource
React 路由
React 基于单页应用;然而,在 React 的单页面应用范例中,大多数应用需要多个视图。
在单个组件上构建应用并不理想,因为代码和复杂性可能会增加,这可能会成为开发人员维护和测试的噩梦。我们将在下一章学习更多关于创建组件和子组件的知识。
为了处理路由,有许多工具可供选择:React Router、Router5、Redux-First Router 和 Reach Router 等等。
在撰写本文时,React 项目的标准是 React Router。要为 Webpack 添加 React 路由和类型,请执行以下命令:
$ yarn add -D react-router-dom @types/react-router-dom
Jest 和酶+否则
Jest 是 JavaScript 单元测试框架,也是 React 应用的标准。它是为任何 JavaScript 项目而构建的,并且是 CRA 自带的。然而,我们确实需要 Jest-dom 和 Enzyme 来增强 Jest 的能力。
对于 Enzyme,我们希望安装 React 16 适配器(这是撰写本文时的最新版本,但可能会更改为 17)。此外,我们需要安装react-test-renderer
,这样我们就可以将 React 组件呈现为纯 JavaScript 对象,而不依赖于 DOM 或原生移动环境。在本书的后面,我们将使用 Jest 的快照测试特性来自动将 JSON 树的副本保存到一个文件中,并使用测试来检查它是否发生了变化。
要安装这些工具,请使用以下命令:
$ yarn add -D enzyme enzyme-adapter-react-16 react-test-renderer
我们还希望通过安装enzyme-to-json
库来简化我们的生活,这样我们在本书后面使用这些库时,我们的代码会得到简化。
$ yarn add -D enzyme-to-json
不然呢
另一个我们应该知道并添加到我们工具箱中的必备库是 Sinon ( https://github.com/sinonjs/sinon
)。以下是您可以用来添加它的命令:
$ yarn add sinon @types/sinon
Jest 和 Sinon 的目的是一样的,但是有时候你会发现一个框架对于特定的测试来说更自然、更容易使用。我们将在本书后面讨论 Sinon。
E2E 测试:笑话和木偶
测试是交付高质量软件的核心。测试是有层次的,通常只有在单元测试和集成测试完成后才会考虑 E2E。
端到端测试(E2E 测试)是一种测试方法,包括从头到尾测试我们的应用工作流程。我们在 E2E 所做的是复制真实的用户场景,因此我们的应用在集成和数据完整性方面得到了验证。
E2E 测试的解决方案是 Jest 和木偶师。木偶师是最受欢迎的 E2E 解决方案,它与 Jest 集成。要开始这项工作,请使用:
$ yarn add puppeteer jest-puppeteer ts-jest
不要忘记添加 TS 的类型。
$ yarn add yarn add @types/puppeteer @types/expect-puppeteer @types/jest-environment-puppeteer
这就是你设置 Jest 和 Puppeteer,配置所有东西,查看 E2E 测试App.tsx
的例子所需要的。在这本书的后面,你会学到更多关于 E2E 的知识。
组件文件夹结构
当您在一个 React 项目中工作,并且代码不断增长时,您可能拥有的组件数量会变得令人不知所措,然后很难找到它们。
组织你的组件以便更容易找到的一个简洁的方法是将组件分成一个独立的组件类型,如图 2-8 所示。
图 2-8
Redux 和 TS 的建议 React 文件夹结构
这是我推荐的开始使用的文件夹结构;但是,请随意使用它作为一个建议。
要遵循此结构,请创建以下文件夹:
-
src/components
-
src/features
-
src/layout
-
src/pages
-
src/redux
-
src/recoil/atoms
如果您使用的是 Mac,您可以在终端中使用这个一行程序:
$ mkdir src/components src/features src/layout src/pages src/redux src/recoil/atoms
生成模板
作为开发人员,我们不喜欢一遍又一遍地写代码。是我们可以使用的有用工具。它是基于模板的,所以我们不需要一遍又一遍地写代码。
如果您来自 Angular,您可能会喜欢 Angular CLI,它可以为您的项目生成模板。
您可以在 React 中以类似的方式使用generate-react-cli
项目生成项目模板。为了安装,我们将使用 NPX 任务脚本。
$ npx generate-react-cli component Header
因为这是第一次运行这个脚本,它会用您第一次使用这个工具时选择的选项来安装和创建generate-react-cli.json
,但是您可以随意手动更改这些选项。
一个很酷的功能是我们可以创建自己的模板。下面是一个为 React 页面创建自定义模板的示例。
此时不要深究模板代码。我们只是设置这些模板,我们将在下一章构建我们的组件时检查代码及其含义。
将generate-react-cli.json
改为指向我们将要创建的模板文件。
{
"usesTypeScript": true,
"usesCssModule": false,
"cssPreprocessor": "scss",
"testLibrary": "Enzyme",
"component": {
"default": {
"path": "src/components",
"withStyle": true,
"withTest": true,
"withStory": false,
"withLazy": false
},
"page": {
"customTemplates": {
"component": "templates/page/component.tsx",
"style": "templates/page/style.scss",
"test": "templates/page/test.tsx"
},
"path": "src/pages",
"withLazy": false,
"withStory": false,
"withStyle": true,
"withTest": true
},
"layout": {
"customTemplates": {
"component": "templates/component/component.tsx",
"style": "templates/component/style.scss",
"test": "templates/component/test.tsx"
},
"path": "src/layout",
"withLazy": false,
"withStory": false,
"withStyle": true,
"withTest": true
}
}
}
用一个 React 路由和一个到路径名:templates/component/component.tsx
的钩子为TypeScript
类页面组件创建一个模板文件。在下一章中,我们将创建一个自定义的 React 组件,这个模板组件将是有意义的。当然可以把作者名改成自己的名字和网址。
/*
Author: Eli Elad Elrom
Website: https://EliElrom.com
License: MIT License
Component: src/component/TemplateName/TemplateName.tsx
*/
import React from 'react';
import './TemplateName.scss';
import { RouteComponentProps } from 'react-router-dom'
export default class TemplateName extends React.PureComponent<ITemplateNameProps, ITemplateNameState> {
constructor(props: ITemplateNameProps) {
super(props);
this.state = {
name: this.props.history.location.pathname.substring(
1,
this.props.history.location.pathname.length
).replace('/', '')
}
}
// If you need 'shouldComponentUpdate' -> Refactor to React.Component
// Read more about component lifecycle in the official docs:
// https://reactjs.org/docs/react-component.html
/*
public shouldComponentUpdate(nextProps: IMyPageProps, nextState: IMyPageState) {
// invoked before rendering when new props or state are being received.
return true // or prevent rendering: false
} */
static getDerivedStateFromProps:
React.GetDerivedStateFromProps<ITemplateNameProps, ITemplateNameState> = (props:ITemplateNameProps, state: ITemplateNameState) => {
// invoked right before calling the render method, both on the initial mount and on subsequent updates
// return an object to update the state, or null to update nothing.
return null
}
public getSnapshotBeforeUpdate(prevProps: ITemplateNameProps, prevState: ITemplateNameState) {
// invoked right before the most recently rendered output is committed
// A snapshot value (or null) should be returned.
return null
}
componentDidUpdate(prevProps: ITemplateNameProps, prevState: ITemplateNameState, snapshot: ITemplateNameSnapshot) {
// invoked immediately after updating occurs. This method is not called for the initial render.
// will not be invoked if shouldComponentUpdate() returns false.
}
render() {
return (
<div className="TemplateName">
{this.state.name} Component
</div>)
}
}
interface ITemplateNameProps extends RouteComponentProps<{ name: string }> {
// TODO
}
interface ITemplateNameState {
name: string
}
interface ITemplateNameSnapshot {
// TODO
}
创建一个 SCSS 文件模板:templates/component/style.scss
。
.TemplateName {
font-family: 'Open Sans', sans-serif;
font-weight: 700;
}
用酶创建一个测试文件:templates/component/test.tsx
。
import React from 'react'
import { shallow } from 'enzyme'
import TemplateName from './TemplateName'
describe('<TemplateName />', () => {
let component
beforeEach(() => {
component = shallow(<TemplateName />)
});
test('It should mount', () => {
expect(component.length).toBe(1)
})
})
此时,你应该有一个模板文件夹,里面的文件如图 2-9 所示。
图 2-9
模板文件夹结构和文件
您可以对类型页面的组件或任何您喜欢的组件重复相同的步骤。
林挺:埃斯林特和更漂亮
进行代码审查,并让别人格式化你的代码以确保它的一致性,这有多好?
任何代码库中的所有代码都应该看起来像是一个人输入的,不管有多少人贡献。
——瑞克·瓦德伦,约翰尼-五
的创造者
幸运的是,这是可以做到的。
Lint 是一个分析代码的工具。它是一个静态代码分析工具,用来识别在代码中发现的有问题的模式。漂亮是一个固执己见的代码格式化程序。
Note
林挺是运行程序来分析您的代码以发现潜在错误的过程。
Lint 工具可以分析您的代码,并警告您潜在的错误。为了让它工作,我们需要用特定的规则来配置它。
争论每一行或一个制表符中是否应该有两个空格,应该有单引号还是双引号等等是不明智的。这个想法是有某种风格指南,并遵循它的一致性。正如有人说得好:
关于风格的争论毫无意义。应该有一个风格指南,你应该遵循它。
—丽贝卡·墨菲
作为其风格指南的一部分,Airbnb 提供了一个 ESLint 配置,任何人都可以将其用作标准。
ESLint 已经安装在 Create-React-App 上,但它没有使用样式指南和 TypeScript 进行优化。
要使用 Airbnb 的样式指南(被认为是标准的)用 ESLint 和 Prettier for TypeScript 设置您的项目,请使用以下内容:
$ yarn add -D --save-exact eslint-config-airbnb eslint-config-airbnb-TypeScript eslint-config-prettier eslint-config-react-app eslint-import-resolver-TypeScript eslint-loader eslint-plugin-flowtype eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-react-hooks babel-eslint eslint-plugin-jest @TypeScript-eslint/parser @TypeScript-eslint/eslint-plugin$ yarn add -D --save-exact prettier prettier-eslint prettier-eslint-cli eslint-plugin-prettier
阅读我的文章( https://medium.com/react-courses/react-create-react-app-v3-4-1-a55f3e7a8d6d
)了解更多信息。
按照shorturl.at/otuU8
的指示更新以下文件,或者从 CRA·MHL 模板项目中复制它们。
我们将配置三个文件。
-
.eslintrc
: ESLint 运行命令配置文件。 -
.eslintignore
斯洛文尼亚语忽略文件 -
.prettierrc
:漂亮运行命令配置文件
最后,我们可以更新package.json
文件的运行脚本,这样我们就可以运行 Lint 和 format 实用程序,甚至只用一个命令就可以运行应用构建(我们将在本书后面介绍的生产构建)。
"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 完成它的工作并修改我们的代码(见图 2-10 )。
图 2-10
运行 Lint 后的输出
$ yarn run lint
为了运行格式化程序来清理我们的代码,我们也可以使用 Yarn。
$ yarn run format
现在,通过检查端口 3000 或运行yarn start
确认我们仍然可以编译,如果你停止了进程(见图 2-11 )。
图 2-11
格式化并林挺后编译我们的代码
$ yarn start
其他有用的库
我们将安装几个有用的库,它们将在本书后面的开发练习中派上用场。
同学们
Classnames ( https://github.com/JedWatson/classnames
)是一个简单的 JavaScript 实用程序,用于有条件地将classNames
连接在一起。
$ yarn add -D classnames @types/classnames
下面是它的用法示例:
import classNames from 'classnames'
const footerClasses = classNames('foo', 'bar') // => 'foo bar'
属性类型
Prop-types 是一个很棒的小工具( https://github.com/facebook/prop-types
),用于 React 属性和类似对象的运行时类型检查。我们正在用 TypeScript 设置我们的 starter 项目,所以我们真的不需要?? 这个工具,因为我们将传递 TypeScript 对象并进行类型检查。然而,仅仅因为我们使用 TS 并不意味着我们永远不需要 JS。有些情况下,比如从一个不同的项目中导入一个组件,我们可能需要这个小工具。
$ yarn add -D prop-types
您可以从这里下载代码:
https://github.com/Apress/react-and-libraries/tree/master/01/starter-project
下面是它的用法示例:
import PropTypes from "prop-types";
whiteFont: PropTypes.bool
要让 Yarn 使用package.json
并下载所有依赖项,运行以下命令:
$ yarn start
其他有用的实用程序
以下是一些附加的有用实用程序:
-
Lodash (
https://github.com/lodash/lodash
):这让 JS 变得更容易,因为它免去了处理数组、数字、对象、字符串等的麻烦。 -
时刻 (
https://github.com/moment/moment
):对于与日期打交道的人来说,这是必备的。 -
发球 (
https://github.com/vercel/serve
):用$ yarn add serve
安装这个。它添加了一个本地服务器。CRA 脚本包括发布应用的运行脚本。它会生成一个名为build
的文件夹。我们希望能够在投入生产之前测试我们的构建代码。您将在后面的章节中了解更多关于生产构建的内容。 -
Precache React-snap 离线工作:这是一个优化库,我们将使用它来配置我们的应用离线工作。参见第十一章。
-
react-helmet change header metadata:为 SEO 更新每个页面的一个表头;你会在第十一章学到更多。
-
分析器包:你可以安装
source-map-explorer
和cra-bundle-analyzer
工具来查看我们的 JS 包块内部(更多内容在第十一章)。
摘要
在这一章中,我们学习了 Create-React-App 项目,并用我们将在本书中学习的基本库设置了我们的启动项目和开发环境。我们安装了 CRA·MHL 模板项目,它已经包含了我们需要的一切,我们还学习了香草 CRA 和 TypeScript 模板。
我们还了解了一些库,如 NPM、Yarn、Webpack、NPM 脚本、TypeScript、萨斯/SCSS、Redux 工具包、Material-UI、样式组件、路由、Jest 和 Enzyme、生成模板、ESLint 和 appelliter 以及其他有用的库。
在下一章,我们将构建 React 定制组件和子组件。
三、React 组件
在这一章中,我会给你一个 React 组件的概述,以及你可以在 React 中用它们做什么。您需要理解 React 组件是什么,因为它们是 React 的核心。
在前面的章节中,我们创建了我们的第一个 React 项目,我们设置了我们的环境,我们创建了一个 starter 项目,它包括了我们将在本书中使用的许多库。
我们的简单项目已经包含了组件和子组件。在本章中,我们将更深入地研究组件,并创建更复杂的组件和子组件。我们还将查看相关的库,它们可以帮助我们加速开发以及维护我们的项目。
什么是 React 组件?
React 组件类似于函数。它们允许您通过将复杂的 UI 分解成独立的小块来构建前端实现。事实上,React 的核心只不过是协调工作的组件的集合。看看 React.org 对组件是怎么说的:
组件让你将用户界面分割成独立的、可重用的部分,并孤立地考虑每一部分。【—React.org 文档】
有三种类型的组件。
-
功能组件
-
类别组件
-
工厂组件
以正确的方式编写组件可以帮助您降低应用的复杂性,确保您为工作选择正确的组件类型,避免缺陷,并提高性能。
本节分为以下几个部分:
-
JavaScript (JS)函数和类组件
-
TypeScript (TS)函数和类组件
-
外来组件,如工厂组件
-
复杂的 TS 工厂组件
-
React.PureComponent
对React.Component
JavaScript 函数和类组件
函数组件(也叫功能性无状态组件)无非就是 JavaScript 函数。它们与函数式编程(FP)携手并进。FP 意味着用纯功能构建我们的软件,避免共享状态、可变数据和副作用。
FP 是声明性的而不是命令性的,应用状态流过纯函数。因为 React 是一种声明式语言(它实际上并不直接操纵 DOM 本身),所以它与 React 集成得很好,因为我们希望使用声明式架构。
Note
声明式编程是一种表达计算逻辑而不描述其控制流的范式。命令式范式使用改变程序状态的语句,比如直接改变 DOM。
如果我们想通过在 HTML 文件的 JavaScript 标记内创建一个 React 组件来编写最基本的 React 组件,代码应该如下所示。在我们在第一章编写的应用中,我们通过在一个带有 JavaScript 标签的 HTML 文件中创建一个 React 组件来编写最基本的 React 组件。
<div id="app"></div>
<script type="text/babel">
ReactDOM.render(
<h1>Hello World</h1>,
document.getElementById('app')
);
</script>
以简单的“Hello World”为例,假设我们想要传递用户定义的属性。我们可以设置一个函数并分配一个用户定义的属性,然后将其传递给 React DOM render,作为 JSX 代码执行。请参见以下示例:
function WelcomeUser(props) {
return <h1>Hi {props.userName}</h1>;
}
const element = <WelcomeUser userName="John" />;
ReactDOM.render(
element,
document.getElementById('app')
);
props
代表我们传递的属性和 React Element
返回的函数。这里我们传递一个userName
,它将显示在我们的用户界面上。
在自己喜欢的浏览器中打开这个例子,如图 3-1 所示。
图 3-1
WelcomeUser.html 输出示例
您可以从 GitHub 网站下载该代码。
https://github.com/Apress/react-and-libraries/tree/master/03/ WelcomeUser.html
JavaScript 功能组件
当我们编写组件时,我们通常不会使用一个组件,而是将子组件包含在父组件中。
我们可以看到,例如,在创建-React-应用(CRA)。App
子组件嵌套在我们的主组件索引中。
ReactDOM.render(<App />, document.getElementById('root'))
然后,我们可以创建一个子组件,并用纯 JavaScript 编写子组件。看一下这个基本的例子,它给我们同样的“Hi John”结果,但是这次是在我们的 CRA starter 项目中,而不是在独立的 HTML 页面中:
function Welcome(props) {
return <h1>Hi {props.userName}</h1>;
}
Javascript 类组件
使用纯 JavaScript 创建一个带有props
的类组件来产生与Hello userName
相同的输出是类似的。使用 ES6 语法,产生相同输出的 JS 类组件如下所示:
class Welcome extends React.Component {
render() {
return <h1>Hi {this.props.userName}</h1>;
}
}
React 钩
在函数和类组件中,我们可以使用钩子来访问状态和组件生命周期特性。如果我们通过从 React 导入特性来使用Hook
函数,那么类组件会扩展React.Component
。
Note
钩子是允许我们“挂钩”React 状态和生命周期特性的函数。
你可以在 ReactJS.org 网站上找到关于钩子的好资源。
https://reactjs.org/docs/hooks-overview.html
对于下一步,您可以使用您在上一步中创建的starter-project
,或者使用一个命令重新开始,如下所示:
$ yarn create react-app starter-project --template must-have-libraries
接下来,我们可以将目录更改为新项目并启动项目。
$ cd starter-project
$ yarn start
如果端口 3000 没有被另一个应用使用,该应用应该在该端口上运行,换句话说,http://localhost:3000
。更多细节请参考上一章。
CRA 生成了一个名为src/index.tsx
的组件,它将另一个组件应用封装在 React 渲染函数中。
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
)
如果我们查看src/App.tsx
子组件,我们会发现 JSX 代码,它包括我们运行纱线启动脚本(yarn start
)时看到的代码。
import React from 'react'
function App() {
return (
<div className="App">
..
</div>
)
}
export default App
如果我们想利用 React 的状态特性,我们需要从 React 库中导入useState
特性。
import {useState} from 'react'
接下来,我们可以在函数组件中编写 JSX 代码,该代码将呈现一个按钮,该按钮在单击事件时将增加计数器的值。计数器变量就是我们的state
。
function App() {
const [count, setCount] = useState(0);
return (
..
..
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
..
..
)
}
export default App
useState
是状态挂钩;它返回一个带有应用当前状态的状态,在本例中,是我们的计数器的状态(count
)。注意,还有第二个参数,它是一个更新状态的函数(setCounter
)。
一旦用户点击按钮,我们调用函数通过setCounter
更新应用的状态,在那里我们可以增加Count
+ 1 的值。这将更新变量 count,使值增加 1。使用reflection {count}
,段落标签中的数据会出现在用户界面上。
src/App.tsx
的完整更新代码将如下所示:
import React, { useState } from 'react'
import logo from './logo.svg'
import './App.scss'
function App() {
const [count, setCount] = useState(0)
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>You clicked {count} times</p>
<button type="submit" onClick={() => setCount(count + 1)}>
Click me
</button>
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://github.com/EliEladElrom/react-tutorials"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
)
}
export default App
如果你还在运行yarn start
命令,可以直接进入http://localhost:3000
页面,你会看到变化(图 3-2);否则,使用yarn start
命令。
图 3-2
用计数器状态更新 src/App.tsx
单击该按钮后,您将看到计数器变量按预期发生变化。见图 3-2 。
TypeScript 组件
目前为止一切顺利。我们能够创建 JavaScript 函数和类组件。注意,在前面的例子中,我们使用了一个.tsx
文件扩展名;然而,我们并没有真正编写任何特定的 TypeScript 代码。代码仍然是好的。
在本书中,我们用 TypeScript 建立了我们的项目。使用函数和类组件类似于我们用ES6
类设置 JavaScript 函数的方式;但是,除了类型检查之外,还有其他特定于语法的方法,这些方法是 TypeScript 所独有的。让我们来看看。
纯函数
这是 React 的核心。FP 是声明性的而不是命令性的,应用状态流过纯函数。因为 React 是一种声明性语言(它实际上并不直接操纵 DOM 本身),所以我们希望 React 组件是纯函数。
举个例子:
const Component = (props: IProps) =>
render() {
return
}
}
它可以编译和工作,但不理想,因为它不纯。要把它变成一个纯组件,就像这样写你的纯函数:
const Component = (props: IProps) =>
有副作用的纯函数
Note
副作用是在被调用函数之外可以观察到的状态变化,而不是它的返回值。副作用的例子包括:改变外部变量或对象属性(全局变量,或父函数作用域链中的变量)的值,没有说明符的导入语句(即,import someLib),登录到控制台,获取数据,设置订阅,或手动改变 DOM。
我们的纯函数可以处理副作用(也称为效果)。为了做到这一点,我们将副作用包装在useEffect
中,并确保它们不会在每次更改时都呈现出来。例如,查看下面的组件,滚动到使用浏览器 API 的顶部:
export default function ScrollToTop() {
const { pathname } = useLocation()
useEffect(
() => () => {
try {
window.scrollTo(0, 0)
},
[pathname]
)
return null
}
TypeScript 函数组件
对于 TypeScript,我们可以指定我们的props
接口的类型。我们可以使用useState
功能,就像我们对纯 JavaScript 示例所做的那样。
// src/components/MyCounter/MyCounter.tsx
import React, { useState } from 'react'
export const MyCounter: React.FunctionComponent<IMyCounterProps> = (props: IMyCounterProps) => {
const [count, setCount] = useState(0)
return (
<p>You clicked MyCounter {count} times</p>
<button type="submit" onClick={() => setCount(count + 1)}>Click MyCounter</button>
)
}
interface IMyCounterProps {
// TODO
}
你可以从 GitHub 下载这个。
https://github.com/Apress/react-and-libraries/tree/master/03/starter-project/src/components/MyCounter/MyCounter.tsx
Tip
在 JSX,<div></div>
相当于<></>
。
现在我们可以将这个子组件包含在我们的src/App.tsx
组件中。
import {MyCounter} from './components/MyCounter/MyCounter'
function App() {
return (
<div classname="App">
<header className="App-header">
...
<MyCounter />
...
</div>
</div>
)
}
这将产生图 3-3 中的结果。
图 3-3
用 MyCounter 子组件更新了 src/App.tsx
TypeScript 类组件
要创建与MyCounter
中相同的子组件作为 TypeScript 类,我们不需要使用useState
方法。
相反,我们可以创建一个状态接口(IMyClassCounterState
),并将变量 count 设置为类型号。
然后在构造函数中,我们设置初始组件状态。我们可以将计数器设置为零。
最后,当我们需要改变组件的状态时,我们可以使用this.setState
函数并更新我们的计数器。点击此处查看完整代码:
// src/components/MyCounter/MyClassCounter.tsx
import React from 'react'
export default class MyClassCounter extends React.Component<IMyClassCounterProps, IMyClassCounterState> {
constructor(props: IMyClassCounterProps) {
super(props)
this.state = {
count: 0,
}
}
render() {
return (
<div>
<p>You clicked MyClassCounter {this.state.count} times</p>
<button type="submit" onClick={() => this.setState({ count: this.state.count + 1 })}>
Click MyClassCounter
</button>
</div>
)
}
}
interface IMyClassCounterProps {
// TODO
}
interface IMyClassCounterState {
count: number
}
这段代码可以从 GitHub 下载。
https://github.com/Apress/react-and-libraries/tree/master/03/starter-project/src/components/MyClassCounter/MyClassCounter.tsx
要在我们的用户界面中查看这些变化,请在src/App.tsx
组件中包含我们的子组件,这样我们就可以直观地看到我们的子组件(图 3-4 )。
图 3-4
用 MyClassCounter 子组件更新了 src/App.tsx
import { MyCounter } from './components/MyCounter/MyCounter'
import MyClassCounter from './components/MyCounter/MyClassCounter'
function App() {
const [count, setCount] = useState(0)
return (
<div className="App">
<header className="App-header">
...
<p>You clicked {count} times</p>
<MyCounter />
<MyClassCounter />
...
</header>
</div>
)
}
export default App
这个例子很好,但是这里有一个问题。运行我们在项目中设置的 linter 来检查潜在的编码错误。
$ yarn lint
当引用以前的状态时,您将得到一条错误消息error Use callback in setState
。
starter-project/src/components/MyCounter/MyClassCounter.tsx
15:69 error Use callback in setState when referencing the previous state react/no-access-state-in-setstate
✖ 1 problem (1 error, 0 warnings)
在 React 中使用状态时,我们可以通过基于以前的状态更新当前状态来简化我们的代码,对于许多用例,代码将按预期编译和工作;然而,这实际上破坏了 React 架构,应该避免。
让我们修改我们的代码。
// src/components/MyCounter/MyClassCounter.tsx
handleClick(e: React.MouseEvent) {
this.setState(prevState => {
const newState = prevState.count+1
return ({
...prevState,
count: newState
});
})
}
render() {
return (
<div>
<p>You clicked MyClassCounter {this.state.count} times</p>
<button type="submit" onClick={this.handleClick}>
Click MyClassCounter
</button>
</div>
)
}
}
再次运行 Lint,我们现在没有错误了。
$ yarn lint
yarn run v1.22.10
$ eslint — ext .js,.jsx,.ts,.tsx ./
Done in 7.31s.
阅读我的文章(shorturl.at/cmRW0
)了解常见的林挺错误以及如何避免它们。
外来组件:工厂组件
我想谈的最后一类组件是工厂组件。React 允许您创建看起来像功能组件的组件;然而,功能组件使用 React 钩子来访问生命周期事件。
看一下这段代码:
function Hello(props) {
return {
componentDidMount() {
alert('wow')
}
render() {
return <div>Hi, {this.props.name}</div>
}
};
}
您可以在 React 16 中编写代码;但是,在 React 版本 17 中,如果您这样做,代码将生成一个错误消息。
Tip
在 React 17 中,对之前事件如componentDidMount
的访问被否决了,你应该使用钩子特性来访问组件生命周期事件getDerivedStateFromProps
、getSnapshotBeforeUpdate
和componentDidUpdate
。在本章的后面你会学到更多关于这些钩子的知识。
看看 GitHub 上的问题,React 核心团队要求为使用这种类型组件的开发人员创建一个警告;参见 https://github.com/facebook/react/issues/13560
.
复杂 TS 功能组件
扩展功能组件的props
接口赋予了我们的功能超能力。在这一节中,我将向您展示两个扩展 React Router 和 Material-UI 样式的接口的例子,以及创建您自己的接口扩展。
当扩展一个类时,有三种选择。
-
遗产
-
连接
-
作文
继承是指一个类继承了另一个类的特性。一个接口只是这个类的签名(蓝图)。组合是指创建小功能,然后创建更大更完整的功能,并将两者结合使用。
当我们扩展接口时,最好避免继承,因为对象是接口。
一般来说,当涉及到组件继承时,React 团队提倡尽可能使用复合而不是继承;但是,这里我们做的是props
继承。https://reactjs.org/docs/composition-vs-inheritance.html
见。
使用这种类型的模式使我们能够重用代码以及实现纯 JavaScript 或 TypeScript 代码。对于什么是最佳实践有一些不同意见。无论您做什么,您都希望避免大量的样板代码和复杂性,以便您的代码更具可读性和可测试性。
这是一个更大的讨论,超出了本书的范围,但是如果您发现自己需要扩展接口,请注意有几种方法。
路由的延伸支柱
如果你还记得在第二章中,我们设置了项目以便能够使用generate-react-cli
库生成项目模板,并且我们设置了一个名为starter-project/ templates/component/component.js
的定制模板文件。
既然我们已经理解了 React 函数和类组件以及钩子,我们可以再次检查代码。
这里我们有一个类组件,它有两个接口,ITemplateNameProps
和ITemplateNameState
。它们尚未实现。
在我们的类内部,我们调用类的构造函数,传递props
,并设置应用的状态。
父组件将设置属性,我们将设置应用的状态,就像我们对useState
所做的一样。因为我们扩展了React.Component
,所以不需要从 React 导入那个特性;我们可以自动使用它。
此外,我们得到这些钩子特征来访问组件生命周期:getDerivedStateFromProps
、getSnapshotBeforeUpdate
和componentDidUpdate
。
这些都与组件被安装、渲染和卸载的顺序有关,如果我们需要做一些操作,比如避免内存泄漏,这些都可以在这里完成。
看一下我们用来为generate-react-cli
生成组件的模板文件。它包括接口ITemplateNameProps
,它扩展RouteComponentProps
以获得组件名称,以及componentDidUpdate
钩子的ITemplateNameSnapshot
和状态的接口(ITemplateNameState
)。你可以在 React 文档中读到何时使用哪个钩子: https://reactjs.org/docs/react-component.html
。
import React from 'react';
import './TemplateName.scss';
import { RouteComponentProps } from 'react-router-dom'
export default class TemplateName extends React.Component<ITemplateNameProps, ITemplateNameState> {
constructor(props: ITemplateNameProps) {
super(props);
this.state = {
name: this.props.history.location.pathname.substring(
1,
this.props.history.location.pathname.length
).replace('/', '')
}
}
// Read more about component lifecycle in the official docs:
// https://reactjs.org/docs/react-component.html
public shouldComponentUpdate(nextProps: ITemplateNameProps, nextState: ITemplateNameState) {
// invoked before rendering when new props or state are being received.
return true // or prevent rendering: false
}
static getDerivedStateFromProps:
React.GetDerivedStateFromProps<ITemplateNameProps, ITemplateNameState> = (props:ITemplateNameProps, state: ITemplateNameState) => {
// invoked right before calling the render method, both on the initial mount and on subsequent updates
// return an object to update the state, or null to update nothing.
return null
}
public getSnapshotBeforeUpdate(prevProps: ITemplateNameProps, prevState: ITemplateNameState) {
// invoked right before the most recently rendered output is committed
// A snapshot value (or null) should be returned.
return null
}
componentDidUpdate(prevProps: ITemplateNameProps, prevState: ITemplateNameState, snapshot: ITemplateNameSnapshot) {
// invoked immediately after updating occurs. This method is not called for the initial render.
// will not be invoked if shouldComponentUpdate() returns false.
}
render() {
return (
<div className="TemplateName">
{this.state.name} Component
</div>)
}
}
interface ITemplateNameProps extends RouteComponentProps<{ name: string }> {
// TODO
}
interface ITemplateNameState {
name: string
}
interface ITemplateNameSnapshot {
// TODO
}
Note
componentWillMount
、componentDidUpdate
、componentWillUpdate
在 React 16.9.0 ( https://reactjs.org/blog/2019/08/08/react-v16.9.0.html
)中被弃用。
props
接口扩展了RouteComponentProps
并要求我们传递名称,当我们将这个组件包含在一个Route
标签中时,名称将从父组件传递过来。你将在本章后面看到这一点。
interface ITemplateNameProps extends RouteComponentProps<{ name: string }>
然后我们可以通过钩子访问路由 API。
this.state = {
name: this.props.history.location.pathname.substring(1, this.props.history.location.pathname.length)
}
我的父组件需要用Router
标签包装我的组件。React 路由可以将数据传递给我们的子组件。
function AppRouter() {
return (
<Router>
<Switch>
<Route exact path="/" component={App} />
</Switch>
<div className="footer">
</div>
</Router>
)
}
扩展材质的属性-用户界面风格
如果我想创建一个使用 Material-UI 的组件,并且我想将样式保存在一个单独的类中,我可以通过用WithStyles
扩展该类来赋予我的props
特殊能力,并且props
类和属性将对我可用。
例如,如果我想创建一个布局组件,它将为其他组件居中显示内容,我可以创建一个名为Centered.tsx
的组件。
// src/layout/Centered/Centered.tsx
import * as React from 'react'
import { withStyles, WithStyles } from '@material-ui/core/styles'
import styles from './Centered.styles'
const CenteredViewInner: React.FunctionComponent<Props> = (props) => (
<div className={props.classes.container}>{props.children}</div>
)
interface Props extends WithStyles<typeof styles> {}
export const Centered = withStyles(styles)(CenteredViewInner)
然后创建一个名为ClassName.styles.ts
的样式类,供Centered.tsx
访问。
import { createStyles, Theme } from '@material-ui/core/styles'
export default (theme: Theme) =>
createStyles({
'@global': {
'body, html, #root': {
paddingTop: 40,
width: '100%',
},
},
container: {
maxWidth: '400px',
margin: '0 auto',
},
})
我们将在本书的后面了解更多关于这种类型的组件。
自己扩展属性继承
为了使用继承来扩展props
并创建我们自己的组件,我们可以创建一个扩展接口的基类。然后,我们的类将扩展我们的基类子类,并且可以从基类和子类中强制属性。
用一个例子更容易理解。例如,假设我想创建一个自定义按钮。我不用按钮,只是用标签给出按钮的名称。我的自定义按钮叫SpecialButton
。按钮将使用标签和名称变量向用户显示按钮名称。我需要实现逻辑来处理按钮的名称,并设置一个点击事件,这样用户就可以与我的按钮进行交互。看一下下面的代码:
// src/components/SpecialButton.tsx
// eslint-disable-next-line max-classes-per-file
import React from 'react'
interface IBaseProps {
name: string
}
// eslint-disable-next-line react/prefer-stateless-function
class Base<P> extends React.Component<P & IBaseProps, {}> {
// TODO
}
interface IChildProps extends IBaseProps {
label: string
className: string
handleClick: () => void
}
export class SpecialButton extends Base<IChildProps> {
render(): JSX.Element {
return (
<div>
<button type="submit" className={this.props.className} onClick={this.props.handleClick}>
{this.props.label} - {this.props.name}
</button>
</div>
)
}
}
Notice
我在两个地方禁用 Lint,因为我们将 Lint 设置为每个类只有一个类。这只是为了举例说明,所以我想保持简单。对于生产代码,这应该分解成两个文件,每个文件包含一个类。
现在,为了实现我们的SpecialButton
,我们的父组件需要传递类名、标签、名称和事件处理程序。看一看:
<SpecialButton
className='specialButton'
label='Special'
name='Button'
handleClick={() => setCount(count + 1)}
/>
我们可以将这段代码放在App.tsx
中。由于我的事件处理程序被放在保存计数状态的App.tsx
代码中,它将在我们特殊按钮的每次点击中增加状态,如图 3-5 所示。
图 3-5
src/App.tsx 更新了特殊按钮子组件
在这一节中,我们将使用 JavaScript 和 TypeScript 创建函数和类组件。我们还学习了如何扩展prop
接口来实现特定的功能。
理解函数和类组件有助于我们知道何时使用什么。如果我们出于各种原因(比如清理和设置)需要完全访问 React 组件生命周期事件,那么使用类组件是明智的。当我们不需要设置完整的钩子时,功能组件应该被更多地使用。使用 TypeScript 有助于我们防止错误数据的渗入,并且在测试过程中会派上用场。
尽可能使用纯组件
最后但同样重要的是,在前面的例子中,我们在创建类组件时使用了React.Component
。它让我们可以访问组件的shouldComponentUpdate
。但是,请记住,有两种选择。
-
React.Component
:见https://reactjs.org/docs/react-api.html#reactcomponent
。 -
React.PureComponent
:见https://reactjs.org/docs/react-api.html#reactpurecomponent
。
不需要shouldComponentUpdate
的时候,用PureComponent
代替比较好。
extends React.PureComponent
React.PureComponent
在某些情况下提供了性能提升,但代价是失去了shouldComponentUpdate
生命周期。你可以在 React 文档( https://reactjs.org/docs/react-api.html#reactpurecomponent
)中了解更多。
这里有一个例子:
import React from 'react'
import './MyPage.scss'
import { RouteComponentProps } from 'react-router-dom'
import Button from '@material-ui/core/Button
export default class MyPage extends React.PureComponent<IMyPageProps, IMyPageState> {
constructor(props: IMyPageProps) {
super(props)
this.state = {
name: this.props.history.location.pathname
.substring(1, this.props.history.location.pathname.length)
.replace('/', ''),
results: 0
}
}
render() {
return (
<div className="TemplateName">
{this.state.name} Component
</div>)
)
}
}
interface IMyPageProps extends RouteComponentProps<{ name: string }> {
// TODO
}
interface IMyPageState {
name: string
results: number
}
重新-重新-重新-重新渲染
也就是说,有时需要使用shouldComponentUpdate
,因为我们可以使用该方法让 React 知道该组件不受父组件状态变化的影响,并且不需要重新呈现。
public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
return false // prevent rendering
}
摘要
在这一章中,我们讲述了 React 组件基础知识,并转移到更复杂的练习。我们看了 JS 和 ts 函数和类组件、外来组件、复杂的 TS 函数组件等等。
理解函数和类组件有助于我们知道何时使用什么。如果我们出于各种原因(比如清理和设置)需要完全访问 React 组件生命周期事件,那么使用类组件是明智的。当我们不需要完整的钩子设置时,功能组件应该被更多地使用。使用 TypeScript 有助于我们防止错误的数据进入,并且在测试过程中会派上用场。
在学习编写组件的方法时,您了解了如何使用 React 挂钩、避免副作用以及扩展prop
接口。
在下一章,我们将学习 React 路由和 Material-UI 框架。
四、React 路由和材质——用户界面
在这一章中,你将学习如何集成两个有用的库来帮助你加速开发工作,并使你的代码更具可读性、可测试性和可维护性。它们是 React 路由和材质界面。
在本书中,我们将创建一个功能完整的应用,测试它的一部分,甚至将其发布到生产中。该应用将是一个销售产品的网站,有一个登录的会员区和许多应用中常见的其他元素。
在本章中,我们将通过做两个练习来开始这个应用项目。具体来说,我们将创建一个包含顶部菜单的可用项目,然后我们将添加下拉菜单和一个抽屉。
图 4-1 显示了完成本章后的最终结果。在接下来的章节中,我们将继续添加更多的子组件、样式表和其他库。在了解 React 的所有库和元素的同时,我们将继续构建我们的应用。
图 4-1
第四章的最终 app 结果
集成 React 路由
在本章的这一节,我们将集成一个大多数应用都需要的共同特性:路由。我们将把 React 路由的最新版本 v5.2.0 集成到 React TypeScript 项目中。
我们为什么需要路由?
我们为什么需要路由呢?React 不就是一个单页范例吗?在 React 的单页面应用(SPA)范式中,大多数应用都需要多个视图。即使您的应用很简单,没有很多组件,如果它只是一个不需要更改的单个网页,或者如果它可以内置到一个单一的主要组件中,在未来可能会发生变化,所以最好在项目的早期阶段将路由代码集成到应用中。
在单个组件上构建应用并不理想,因为代码和复杂性会增加,维护和测试会成为开发人员的噩梦。
如果你还记得第一章中的内容,我们在 React 中看到了 DOM 操作是如何发生的。通过 DOM 上的getElementById
和removeChild
等方法动态改变网页的内容。
我们在路由过程中真正做的是在 React 中使用一个单页应用,并使用 React 虚拟 DOM 在浏览器中动态切换不同的树对象。与传统的 HTML 范式相比,这种变化发生得更快,因为实际的 DOM 上只更新变化。
在这个“协调”过程中,React 会计算出哪些对象在不同的过程中发生了变化。然后 React 只更新“真正的”HTML DOM 中需要更改的对象。这加快了进程。
在本节中,我将向您展示如何使用最新版本的 React Router with TypeScript 并实现路由。
Note
React 是基于 SPA 的概念构建的,但是大多数应用实际上都是使用多个视图构建的。
要实现路由,您可以从一些流行的选项中进行选择。以下是最流行的路由库:
-
React 路由
-
路由 5
-
冗余优先路由
-
到达路由
到达路由(也称为@reach/route
)适用于数量较少的路由,并且应该可以在 React 路由和到达路由之间轻松地来回迁移。实际上,Reach 路由和 React 路由是由同一个团队打造的。React 路由被认为是必知的,是 GitHub 上最受欢迎的项目,有超过 61,000 颗星。
请注意,React Router 在版本 v5.x.
中发生了巨大的变化。“随着钩子的引入,React 从根本上改变了我们编写状态和行为的方式,我们希望利用它。”
—Ryan Florence,React Training 联合创始人/首席执行官
React Router 的好处包括性能和将 API 暴露给 React 挂钩,如location
和navigate
挂钩。
您可以在 https://reacttraining.com/blog/reach-react-router-future/
了解更多关于 React 路由的信息。你也可以在 https://reactrouter.com/web/api/
查看 React API。
如何将 React 路由集成到 ReactJS 项目中?
我们将把这个过程分成两步。
-
第一步:搭建,在这里我们创建菜单和页面
-
步骤 2 :显示视图,在这里我们创建路由逻辑和链接
第一步:脚手架
我们将把我们的应用分成页眉、正文和页脚。见图 4-2 。
图 4-2
将我们的应用分成页眉、页脚和正文
我们还将包括一个抽屉菜单,将打开一个菜单图标。我们开始吧。
使用must-have-libraries
创建一个新的 CRA 项目,并将其命名为练习-4-1。
$ yarn create react-app exercise-4-1 --template must-have-libraries
接下来,我们可以将目录更改为新项目并启动项目。
$ cd exercise-4-1/
$ yarn start
您可以在这里找到并下载本练习的完整代码:
https://github.com/Apress/react-and-libraries/exercise-4-1
首先,我们将修改src/App.tsx
来显示一个div
,它将输出“应用页面”而不是默认的 CRA 欢迎页面。稍后,当我们设置路由时,我们将在路由中使用该页面。新的App.tsx
页面将如下所示:
// src/App.tsx
import React from 'react'
import './App.scss'
function App() {
return (
<div className="App">
<div>App page</div>
</div>
)
}
export default App
由于index.tsx
组件已经包含了App
子组件,如果您运行yarn start,
,您应该会看到我们所做的更改。见图 4-3 。
图 4-3
将 App.tsx 更改为输出“应用页面”后我们的应用
我们将使用 CRA MHL 附带的generate-react-cli
库来创建我们的页面和布局元素。我们可以使用我们设置的定制模板用generate-react-cli
创建Header
和Footer
组件。在项目的根文件夹中运行以下命令:
$ npx generate-react-cli component Footer --type=layout
输出将为您提供为您创建的组件。它生成了 SCSS、Jest 测试文件和组件文件。例如,这里有一个Footer
组件:
Stylesheet "Footer.scss" was created successfully at src/layout/Footer/Footer.scss
Test "Footer.test.tsx" was created successfully at src/layout/Footer/Footer.test.tsx
Component "Footer.tsx" was created successfully at src/layout/Footer/Footer.tsx
如果您打开src/layout
文件夹,您将看到为我们创建的文件。见图 4-4 。
图 4-4
页脚组件文件夹和文件
该项目为我们建立了初始的测试 Jet 文件(在本书的后面你会学到更多关于测试的知识)。我们在第二章中讨论过。现在我们实际上使用 Lint 来运行脚本命令。然后,我们将格式化并测试它们,以确保我们的项目通过某些编码标准。
-
ESLint :我们会做代码分析,标记编程错误、bug、风格错误和任何其他可疑的构造。
-
Jest 测试:我们将确保我们的测试运行并通过。
-
格式:我们将确保我们的代码使用我们设定的最佳实践进行格式化(在我们的例子中,我们设定了 Airbnb 风格)。
为此,运行这些命令,它们是我们在package.json
中设置的运行脚本:
$ yarn format
$ yarn lint
$ yarn test
Tip
如果您得到任何 Lint 错误和警告,您可以运行$ yarn lint --fix
来自动修复它们,并根据需要调整.eslintrc
文件。
你可以把你的结果和我的比较一下,如图 4-5 所示。
图 4-5
格式、lint 和测试结果
Note
在本书的下一章中,我们将更详细地介绍测试,以及如何设置自动化开发和部署流程以及优化您的应用。在这里,我想至少向您展示这些任务,我强烈建议您在每次编码时执行,以确保您的代码质量。
类似地,要创建我们的Header
布局组件,运行以下命令来生成组件文件:
$ npx generate-react-cli component Header --type=layout
如果打开模板文件,可以看到它被设置为React.PureComponent
。将Hooter
和Footer
组件添加到渲染 JSX 输出中。
import React from 'react'
import './Header.scss'
export default class Header extends React.PureComponent<IHeaderProps, IHeaderState> {
constructor(props: IHeaderProps) {
super(props)
this.state = {}
}
render() {
return Header
}
}
interface IHeaderProps {
// TODO
}
interface IHeaderState {
// TODO
}
在我们的应用中,我包含了一个销售数字内容的网站的不同页面,如联系人、书籍、课程、教练服务等。如果你愿意,你可以改变这些页面;这些只是建议。
$ npx generate-react-cli component HomePage --type=page
$ npx generate-react-cli component ContactPage --type=page
$ npx generate-react-cli component BooksPage --type=page
$ npx generate-react-cli component BuildSiteCoursePage --type=page
$ npx generate-react-cli component YouBuildMySitePage --type=page
$ npx generate-react-cli component CoachingHourlyPage --type=page
$ npx generate-react-cli component CoachingPackagePage --type=page
$ npx generate-react-cli component MembersPage --type=page
$ npx generate-react-cli component LoginPage --type=page
$ npx generate-react-cli component ArticlesPage --type=page
$ npx generate-react-cli component NotFoundPage --type=page
为了保持理智,运行 Lint 并测试它。如果您遵循了到目前为止的所有步骤,您应该会得到以下内容:
Test Suites: 15 passed, 15 total, and lint pass results
步骤 2:显示视图,在这里我们创建路由逻辑和链接
如果您打开 pages 组件,正如您在第一章中看到的,我们设置的模板文件示例包括一个带有 React Router 的 TypeScript 类组件和一个指向路径名的钩子。
我们导入 React 路由 DOM,然后使用一个钩子,我们可以访问当前浏览器位置的 URL 位置来显示名称。例如,如果 URL 是http://localhost:3000/home
或home/
,那么name
变量就是home
。
import { RouteComponentProps } from 'react-router-dom'
this.state = {
name: this.props.history.location.pathname.substring(
1,
this.props.history.location.pathname.length
).replace('/', '')
}
这个模板类中的react-route
使用由react-route
API 设置的钩子从react-route
上的历史 API 中提取页面名称。
注意,在模板的接口类中,接口需要扩展RouteComponentProps
才能使this.props.history
工作。另一件需要做的事情是,父组件将这个子组件包装在一个<Router>
标签中,这样钩子才能工作。
我们已经创建了页面,我们将用一个抽屉组件和菜单链接到这些页面。在本节中,我们将包括我们创建的所有页面,并设置带有页眉和页脚的页面布局。
为了完成所有这些,我们将创建一个子组件,我们可以将它包含在我们的主index.tsx
组件中。
我们可以在index.tsx
中编写所有这些代码,但是如果我们将这些代码分割成子组件,我们的代码将更易读、更易于测试和维护。
这就是为什么我们的模板项目中已经有了一个名为src/AppRouter.tsx
的子组件。我们可以重构代码以包含 React 和 React Router 的导入以及所有组件页面。因此,我们将使用以下内容:
// src/AppRouter.tsx
import React from 'react'
import { BrowserRouter as Router, Redirect, Route, Switch } from 'react-router-dom'
import App from './App'
import Home from './pages/HomePage/HomePage'
import Contact from './pages/ContactPage/ContactPage'
import Books from './pages/BooksPage/BooksPage'
import BuildSiteCourse from './pages/BuildSiteCoursePage/BuildSiteCoursePage'
import YouBuildMySite from './pages/YouBuildMySitePage/YouBuildMySitePage'
import CoachingHourly from './pages/CoachingHourlyPage/CoachingHourlyPage'
import CoachingPackage from './pages/CoachingPackagePage/CoachingPackagePage'
import Members from './pages/MembersPage/MembersPage'
import Login from './pages/LoginPage/LoginPage'
import Articles from './pages/ArticlesPage/ArticlesPage'
import NotFound from './pages/NotFoundPage/NotFoundPage'
import Footer from "./layout/Footer/Footer";
import Header from "./layout/Header/Header";
对于函数return
部分,我们需要将每个组件包装在路由标签中,以便访问路由挂钩。Header
和Footer
组件总是会出现在页面上,而内容会改变。
为了实现这一点,每个内容页面都被设置为具有确切路径的路由,如下所示:
function AppRouter() {
return (
<Router>
<Header />
<Switch>
<Route exact path="/" component={App} />
<Route exact path="/Home" component={Home} />
<Route exact path="/contact" component={Contact} />
<Route exact path="/Books" component={Books} />
<Route exact path="/BuildSiteCourse" component={BuildSiteCourse} />
<Route exact path="/YouBuildMySite" component={YouBuildMySite} />
<Route exact path="/CoachingHourly" component={CoachingHourly} />
<Route exact path="/CoachingPackage" component={CoachingPackage} />
<Route exact path="/Members" component={Members} />
<Route exact path="/Login" component={Login} />
<Route exact path="/Articles" component={Articles} />
<Route path="/404" component={NotFound} />
<Redirect to="/404" />
</Switch>
<Footer />
</Router>
)
}
export default AppRouter
注意,404 NotFound
组件有一个Redirect
标签。我们将使用该组件,以防用户试图进入一个不存在的页面。
接下来,注意我们的项目已经在index.tsx
中包含了AppRouter
子组件,所以我们不需要做任何事情。我只是想给你指出来。
ReactDOM.render(<AppRouter />, document.getElementById('root'))
在这种状态下,最终结果应该如图 4-6 所示。
图 4-6
决赛成绩
此时,我们没有在页面间导航的菜单;但是,如果您将浏览器地址栏的 URL 更改为我们在路由中设置的页面之一,例如,如果我们导航到http://localhost:300/Home
,我们将看到图 4-7 中的屏幕。
图 4-7
主页的最终结果
在本练习中,您学习了 React 路由。我们将我们的应用分为页眉、页脚和内容部分。我们生成了子组件,然后我们创建了一个路由App
子组件来创建结构并能够使用 React 路由 API。
在本章的下一节,我们将学习 Material-UI CSS 框架,这样我们可以加快开发速度,创建菜单和抽屉,然后我们可以链接到我们创建的子组件页面。
集成材质-UI CSS 框架
到目前为止,您已经了解了 React 和 DOM 以及幕后发生的事情。您甚至学习了如何创建简单和复杂的组件,以及如何使用 React Router。
现在我们需要链接我们的页面。我们可以开始构建自己的定制组件;然而,为了加快开发速度,通常的做法是使用 CSS 框架。
在本章的这一节,我们将构建大多数网站都需要的通用元素。
为了创建一个带抽屉的顶层菜单,我们将从 Material-UI CSS 框架中获得帮助。
这个练习的完整代码可以从这里下载:
https://github.com/Apress/react-and-libraries/exercise-4-2
为什么我们需要一个 CSS 框架?
CSS 框架(或 CSS 库)为您的开发带来了更加标准化的实践。使用 CSS 框架,我们可以加快我们的开发工作,而不是仅仅使用普通的旧 CSS(或其他样式表),因为它允许我们使用预定义的元素。是的,我们可以从头开始创建所有这些定制组件,设计它们的样式,并在所有设备甚至遗留浏览器上测试它们,但是大多数时候这是不值得的。我们在这里不是要重新发明轮子。相反,我们可以只使用预定义的元素。
此外,请记住,您仍然可以在现有的项目中使用样式表,所以我们并没有放弃样式表或其他样式选项。框架只是提供了补充。
现在,当谈到 CSS 框架时,有一些流行的选项可供选择。React 项目中使用的主要工具(基于 GitHub stars)如下:
-
自举:143000 颗恒星
-
Material-UI : 6 万颗星星
-
Bulma : 4 万颗星星
-
语义 UI:483000 颗星
还有其他很棒的框架,比如 Tailwind、野餐 CSS、PaperCSS 以及其他许多适合解决不同挑战的框架。
虽然 Bootstrap 是最流行的框架,但我相信 Material-UI 是更好的选择。请记住,Bootstrap 可以与 Material-UI 一起使用,所以我们在这里不仅限于使用一个框架。
Material-UI 框架( https://material-ui.com/
)基于脸书的 React 框架,与 React 很好的集成。由脸书团队构建是一件大事,因为我们希望确保当我们将 React 项目升级到未来的 React 版本时,我们的代码不会中断。Material-UI 的 GitHub 页面在这里:
https://github.com/mui-org/material-ui
Note
Material-UI 项目包含根据 Material-UI 指南制作并遵循 Material-UI 设计原则的组件。
我们如何将 Material-UI 集成到我们的 ReactJS 项目中?
我们将添加的功能将是一个抽屉菜单,你可以打开链接,以及一个链接的标题顶部,将折叠为小屏幕尺寸。
是的,我们可以使用我们在前一个练习中创建的相同的Header
组件;然而,这段代码会很复杂,很难测试和维护。
因此,最好将Header
组件分解成子组件。我将Header
组件分解成四个子组件。请看图 4-8 。
图 4-8
标题组件线框
你可以在这里看到这个野外遗址: https://elielrom.com
。
我们开始吧
在第一章的 Material-UI 所需的库方面,我们已经在 CRA 的香草风味上安装了这些库,所以我们已经准备好不用安装任何库就开始了。
让我们回顾一下我们安装了什么。我们在第一章安装了材质-UI 核心、图标和样式组件,它已经是我们起始项目的一部分了。如果你想回顾图书馆,请随意访问那一章。
我们使用styled-components
的原因是它允许我们在 JavaScript 类中编写实际的 CSS。关于styled-components
的更多细节,请访问图书馆的官方网站:
https://styled-components.com/
在接下来的章节中,我们将使用 CSS 和预处理器库,比如scss
,所以我们不会在这里详细讨论样式和预处理器。然而,请注意,我们在应用中使用了文件扩展名.scss
而不是.css
,我们的项目被设置为能够处理scss
文件类型。
标题组件
HeaderTheme
组件将包装整个Header
组件。选择架构的原因是我们可以让用户选择某些偏好,如应用的主题。我们还可以使用这个包装器来判断用户是否登录,以及用户正在使用什么类型的设备,然后通过将这些信息传递给子组件来相应地调整应用。
这种设计是理想的,因为我们不希望每个子组件都知道这些事情,让父组件传递这些信息可以让我们轻松地重构代码。
让我们回顾一下代码,如下所示:
// src/layout/Header/HeaderTheme.tsx
import React, { FunctionComponent } from 'react'
import AppBar from '@material-ui/core/AppBar/AppBar'
import { useMediaQuery } from '@material-ui/core'
import HeaderComponent from './Header'
function appBarBackgroundStyle() {
return {
background: '000000',
}
}
export const HeaderTheme: FunctionComponent = () => {
const smallBreakPoint = useMediaQuery('(min-width: 0px) and (max-width: 1100px)')
return (
<AppBar position="fixed" style={appBarBackgroundStyle()}>
<HeaderComponent smallBreakPoint={smallBreakPoint} />
</AppBar>
)
}
请注意,我使用来自@material-ui/core
的useMediaQuery
来判断我们是否需要为小屏幕实现任何逻辑。断点(smallBreakPoint
)被设置为 1100 像素的分辨率,但是这可以被调整为不同的值。
然后我们使用 Material-UI 中的AppBar
组件( https://material-ui.com/components/app-bar/
)将我们的组件包装在一个整洁的栏中,在里面我们有我们的HeaderComponent
,在那里我们传递作为属性的smallBreakPoint
。AppBar
非常适合显示与当前屏幕相关的信息和动作。
我们使用一个函数将我们的AppBar
颜色设置为白色,我们可以将主题调整为不同的颜色,甚至可以从父组件中调整。
标题子组件
Header
子组件将包装这两个子组件:
-
HeaderTopNav
-
HeaderDrawer
// src/layout/Header/Header.tsx
import HeaderDrawer from './HeaderDrawer'
import HeaderTopNav from './HeaderTopNav'
在代码层面上,import
语句包括我们将使用的组件以及 Material-UI 组件。我们将使用Toolbar
、Box
和Button
组件。
你可以在 https://material-ui.com/
了解更多关于这些材质界面组件的信息。
import Toolbar from '@material-ui/core/Toolbar'
import Box from '@material-ui/core/Box'
import Button from '@material-ui/core/Button'
导入我们将使用的样式文件和字体。
import './Header.scss'
有几种方法可以导入字体;一个简单的方法是使用纱线。
$ yarn add fontsource-open-sans
import 'fontsource-open-sans'
我们将进口 React 和风格 SCSS。
import React from 'react'
import './Header.scss'
为了导航不同的页面,我们可以使用路由链接组件。React Router 支持Link
和NavLink
,因此我们可以将组件链接到不同的页面。
// router
import { Link } from 'react-router-dom'
在类定义中,我们不需要任何钩子,所以最好使用PureComponent
,因为它能给我们最好的性能。
此时我们不需要存储任何状态,对于属性,我们需要设置从父组件传递过来的smallBreakPoint
。
interface IHeaderProps {
smallBreakPoint: boolean
}
interface IHeaderState {
// TODO
}
export default class Header extends React.PureComponent<IHeaderProps, IHeaderState> {
constructor(props: IHeaderProps) {
super(props)
this.state = {}
}
在 render 方法中,我们可以定义我们的组件。我们将把所有东西都包装在一个Toolbar
组件中,并用position
设置一个 box 组件,因为我们希望用我们设置的媒体查询折叠导航器,并且我们希望确保顶部的元素留在屏幕上。
看一看我们将使用的材质 UI 元素:
<Toolbar>
<Box>
Logo
</Box>
<Box>
<Nav />
</Box>
<Box>
<Drawer />
</Box>
</ToolBar>
请注意,我使用的是材质框( https://material-ui.com/components/box/
),它适合我们的需要,因为我们可以设置层。flexGrow
组件将我们的导航项目保持在同一级别( https://material-ui.com/system/flexbox/#flex-grow
)。
让我们实现它,如下所示:
render() {
return (
<Toolbar>
<div style={{ width: '100%' }}>
Material-UI 中的按钮已经包含了我们可以用来导航到页面的属性。例如,当单击我们的徽标时,我们希望导航回主页/
。
<Box display="flex" p={1}>
<Box p={1} flexGrow={1}>
<Button component={Link} to="/">
ELI ELAD ELROM
</Button>
</Box>
接下来,我们可以使用内嵌逻辑,或者显示HeaderTopNav
或者通过显示Nav
组件来折叠它。
<Box p={1}>
{this.props.smallBreakPoint ? (
<nav />
) : (
<HeaderTopNav />
)}
</Box>
<Box p={1}>
<div
style={{
position: 'absolute',
right: '0.5rem',
}}
>
抽屉密码可能在这里。然而,这会使代码更难阅读,所以我将代码分解成另一个子组件,名为:HeaderDrawer
。
<HeaderDrawer />
</div>
</Box>
</Box>
</div>
</Toolbar>
)
}
}
HeaderTopNav 子组件
在屏幕足够大的情况下,HeaderTopNav
子组件将显示页面的链接,或者在用户屏幕较小的情况下,仅显示抽屉图标以打开抽屉。我们将使用材质界面的Button
和MenuItem
。
为了链接页面,我们将同时使用Link
和NavLink
,以及我们在第一章中介绍的styled-components
库,它允许我们在组件内部创建一个styles
对象。
// src/layout/Header/HeaderTopNav.tsx
import React from 'react'
import Button from '@material-ui/core/Button'
import Menu from '@material-ui/core/Menu'
import { Link, NavLink } from 'react-router-dom'
import MenuItem from '@material-ui/core/MenuItem'
import styled from 'styled-components'
Material-UI 带有我们可以使用的预定义图标。因此,我们也将导入它们。
你可以在这里查看所有不同的材质界面图标: https://material-ui.com/components/material-icons/
。
我们将使用 GitHub 图标,以及图标按钮 API ( https://material-ui.com/api/icon-button/
)
import IconButton from '@material-ui/core/IconButton'
import GitHubIcon from '@material-ui/icons/GitHub'
接下来,我们将使用styled-components
库为链接的悬停状态设置一些 CSS 样式。我使用 0.5 秒的过渡来改变悬停时的颜色为灰色。
const DetectHover = styled.div`
transition-duration: 0.5s;
:hover {
color: grey;
span {
opacity: 1;
}
}
`
我们也会考虑媒体的询问。CSS 中的媒体查询允许我们为不同的屏幕尺寸放置特定的逻辑。在我们的例子中,如果屏幕太小,我们希望折叠导航链接,因为我们有许多页面,链接不适合小屏幕。这就是为什么我们将使用一个抽屉,它会打开同样的链接。我将导航的样式设置为block
,在 400 像素以下的小屏幕上隐藏导航,在 1100 像素以下的大屏幕上显示。
对于Nav
,我们不需要任何风格;我们刚刚设置了组件。
const Nav = styled.nav``
我们需要跟踪一些事情,我们可以将它们设置为应用的状态。
例如,我们需要组件锚点的位置,我们可以用它来对齐下拉菜单以及标志,以指示下拉菜单是否打开。
为此,让我们设置anchorElement
,我们将使用它来跟踪锚的位置。
let anchorElement: HTMLButtonElement
对于标志,我们有三个标志,因为我们将设置三个下拉菜单,每个父菜单一个(Build My Site、Coaching 和 Resources 父菜单项)。
interface IHTNState {
menuBuildItem1Flag: boolean
menuBuildItem2Flag: boolean
menuBuildItem3Flag: boolean
}
在这一点上,我们不需要props
,但是我们还是会定义它,因为我们的代码可能会改变,并且将来很有可能需要props
。
interface IHTNavProps {
// TODO
}
至于实际的组件,我们正在创建一个 React TypeScript 类组件,我们传递定义和设置应用初始状态的props
和state
接口,将所有标志设置为 false,因为所有菜单都将关闭。
export default class HeaderTopNav extends React.PureComponent<IHTNavProps, IHTNState> {
constructor(props: IHTNavProps) {
super(props)
this.state = {
menuBuildItem1Flag: false,
menuBuildItem2Flag: false,
menuBuildItem3Flag: false,
}
}
接下来,我们需要一种机制来处理用户单击顶部链接并打开包含更多链接的下拉菜单的情况。我们可以用鼠标事件处理器和开关来实现。在switch
案例中,我们将设置打开下拉菜单的标志,并设置锚点,使下拉菜单与正确的组件对齐。
我们给每个switch
案例一个编号,这样我们就知道是谁调用了这个处理程序(点击了哪个下拉菜单)。
接下来,我们还需要三个处理者。
-
当用户单击一个菜单项时,我们希望在选择一个菜单项后像预期的那样关闭切换菜单。
-
当用户点击菜单关闭图标按钮时,我们要关闭所有的子菜单下拉菜单。
-
我们希望有一个开关的抽屉图标来打开和关闭抽屉。
看一看:
handleMenuOpen = (event: React.MouseEvent, item: string) => {
anchorElement = (event as React.MouseEvent<HTMLButtonElement>).currentTarget
switch (item) {
case '1':
this.setState((prevState) => {
return {
...prevState,
menuBuildItem1Flag: true,
}
})
break
case '2':
this.setState((prevState) => {
return {
...prevState,
menuBuildItem2Flag: true,
}
})
break
case '3':
this.setState((prevState) => {
return {
...prevState,
menuBuildItem3Flag: true,
}
})
break
}
}
handleMenuClose = () => {
this.setState((prevState) => {
return {
...prevState,
menuBuildItem1Flag: false,
menuBuildItem2Flag: false,
menuBuildItem3Flag: false,
}
})
}
render() {
return (
<Nav>
<Button onClick={(event) => this.handleMenuOpen(event, '1')}>Build My Website</Button>
<Menu id="menu-appbar1" anchorEl={anchorElement} open={this.state.menuBuildItem1Flag} onClose={this.handleMenuClose}>
每个下拉菜单父项都有一些到其他页面的链接。我们可以通过创建一个数组来设置它们,然后将该数组映射到一个NavLink
React 路由组件,该组件将导航到我们想要的链接。
带有映射的数组允许我们用所有的NavLink
组件创建一个循环,而不是创建几个NavLink
组件。这非常方便,尤其是当您有不确定数量的项目,或者您想要将数据连接到后端数据源时。
{[
{ name: 'Build My Own Site', url: '/BuildSiteCourse' },
{ name: 'You Build My Site', url: '/YouBuildMySite' },
].map((itemObject, index) => (
<NavLink exact to={itemObject.url} className="NavLinkItem" key={itemObject.name}>
<MenuItem onClick={this.handleMenuClose}>{itemObject.name}</MenuItem>
</NavLink>
))}
</Menu>
每个导航项都映射到一个事件处理程序,以打开下拉菜单。我们为每个菜单项设置一个数字开关。
<Button onClick={(event) => this.handleMenuOpen(event, '2')}>Coaching</Button>
<Menu id="menu-appbar2" anchorEl={anchorElement} getContentAnchorEl={null} open={this.state.menuBuildItem2Flag} onClose={this.handleMenuClose}>
{[
{ name: 'Hourly', url: '/CoachingHourly' },
{ name: 'Packages', url: '/CoachingPackage' },
].map((itemObject, index) => (
<NavLink exact to={itemObject.url} className="NavLinkItem" key={itemObject.name}>
<MenuItem onClick={this.handleMenuClose}>{itemObject.name}/MenuItem>
</NavLink>
))}
</Menu>
我们可以对每个父按钮重复相同的过程。
<Button onClick={(event) => this.handleMenuOpen(event, '3')}>Resources</Button>
<Menu id="menu-appbar2" anchorEl={anchorElement} getContentAnchorEl={null} open={this.state.menuBuildItem3Flag} onClose={this.handleMenuClose}>
{[
{ name: 'Books', url: '/Books' },
{ name: 'Articles', url: '/Articles' },
].map((itemObject, index) => (
<NavLink exact to={itemObject.url} className="NavLinkItem" key={itemObject.name}>
<MenuItem onClick={this.handleMenuClose}>{itemObject.name}</MenuItem>
</NavLink>
))}
</Menu>
如果我们有一个没有孩子的导航项目,我们可以使用按钮并在组件属性中绑定Link
。以此处显示的联系人链接为例:
<Button component={Link} to="/Contact">
Contact
</Button>
我们也可以使用 Material-UI 中的IconButton
并使用href
。这在 JSX 的表现就像你在 HTML 中期望的那样,并且引用一个外部 URL。
Tip
我们使用Link
而不使用href
的原因是我们不希望页面被刷新,这将减慢在 SPA 上使用这种设置翻页的过程。
<a href="https://github.com/EliEladElrom/react-tutorials" target="_blank" rel="noopener noreferrer">
<IconButton>
<DetectHover>
<GitHubIcon fontSize="large" />
</DetectHover>
</IconButton>
</a>
</Nav>
)
}
}
React 事件处理程序
如果您来自 JavaScript 世界,您应该知道所使用的事件处理程序。React 使用 JSX 并有自己的事件系统。
这就是为什么我们不能使用我们习惯的来自 JS 的事件比如叫做MouseEvent
的handleMenuOpen
方法。
这段代码是错误的,会产生 Lint 错误:
onClickHandler = (e: MouseEventHandler)
Tip
我们需要使用React.MouseEvent
;否则,我们会得到一个错误,或者我们将不能访问方法。一般来说,大多数事件的映射名称与 HTML 页面中使用的 JavaScript 名称相同。
幸运的是,React 类型化为您提供了标准 DOM 中您可能熟悉的每个事件的适当等价物。
我们可以使用React.MouseEvent
或者从 React 模块导入MouseEvent
类型。
这里有一个例子:
const onClickHandler = (e: React.MouseEvent) => {
e.preventDefault()
}<Button type="submit" onClick={onClickHandler}>
HeaderDrawer 子组件
HeaderDrawer
子组件将为HeaderTopNav
子组件提供一个菜单图标,加上包含相同导航链接的实际抽屉。
我们需要HeaderTopNav
和HeaderDrawer
子组件的原因是,当用户的屏幕小于 1100 像素时,我们可以容纳所有的导航链接,用户应该仍然能够使用菜单项和使用抽屉在页面之间导航。这是移动设备上常见的用户界面(UI)设计。
在编码层面上,我们将导入将要使用的 Material-UI 组件,以及styled-components
和NavLink
和 Material-UI 图标。
// src/layout/Header/HeaderDrawer.tsx
import Drawer from '@material-ui/core/Drawer'
import Divider from '@material-ui/core/Divider'
import List from '@material-ui/core/List'
import { NavLink } from 'react-router-dom'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import WebIcon from '@material-ui/icons/Web'
import WebAssetIcon from '@material-ui/icons/WebAsset'
import ListItemText from '@material-ui/core/ListItemText'
import SupervisorAccountIcon from '@material-ui/icons/SupervisorAccount'
import SupervisedUserCircleIcon from '@material-ui/icons/SupervisedUserCircle'
import MenuBookIcon from '@material-ui/icons/MenuBook'
import LibraryBooksIcon from '@material-ui/icons/LibraryBooks'
import MailIcon from '@material-ui/icons/Mail'
import IconButton from '@material-ui/core/IconButton'
import React from 'react'
import MenuIcon from '@material-ui/icons/Menu'
import styled from 'styled-components'
我们将使用styled-components
来设置悬停状态的样式。
const DetectHover = styled.div`
transition-duration: 0.5s;
:hover {
color: grey;
span {
opacity: 1;
}
}
`
props
和state
的接口将包括toggleMenuFlag
的状态,以指示抽屉是否打开。
interface IHDProps {
// TODO
}
interface IHDState {
toggleMenuFlag: boolean
}
默认状态是抽屉关闭,我们需要绑定将要创建的handleToggle
方法,以便它在渲染阶段可用。
export default class HeaderDrawer extends React.PureComponent<IHDProps, IHDState> {
constructor(props: IHDProps) {
super(props)
this.state = {
toggleMenuFlag: false,
}
this.handleToggle = this.handleToggle.bind(this)
}
一旦单击了列表项,我们就将子组件的状态设置为 false。
Note
...prevState
保持之前的状态。我们在这里不需要它,因为我们在状态中只有一个变量,但是总是这样写代码是一个好习惯,以防我们添加另一个状态变量。
handleListItemClick = () => {
this.setState((prevState) => {
return {
...prevState,
toggleMenuFlag: false,
}
})
}
在 toggle 上,我们只是切换状态,由于状态是绑定的,它会关闭或打开抽屉。
handleToggle() {
this.setState((prevState) => {
const newState = !prevState.toggleMenuFlag
return {
...prevState,
toggleMenuFlag: newState,
}
})
}
render() {
return (
至于抽屉,我们需要一个图标按钮,它将通过我们创建的handleToggle
方法的事件处理程序来打开和关闭(切换)我们的抽屉。
点击事件上的切换图标被绑定到handleToggle
,它将切换状态。
<IconButton style={{ color: 'black' }} onClick={this.handleToggle}>
<DetectHover>
<MenuIcon fontSize="large" />
</DetectHover>
</IconButton>
对于抽屉本身,我们将创建一个链接列表。我们将使用List
Material-UI 组件创建它们,并再次使用带有贴图的数组来创建几个NavLink
React 路由组件并链接到这些页面。我只展示了第一个List
,但是 GitHub 中的代码包含了所有这些。也是同样的过程。List
标签也可以包装在另一个数组中,以避免复制和粘贴同一段代码。
<Drawer anchor="left" open={this.state.toggleMenuFlag} onClose={this.handleToggle}>
<Divider />
<List>
{[
{ name: 'Build My Site', url: '/BuildSiteCourse' },
{ name: 'We Build Site', url: '/YouBuildMySite' },
].map((itemObject, index) => (
<NavLink to={itemObject.url} className="NavLinkItem" key={itemObject.url} activeClassName="NavLinkItem-selected">
<ListItem button key={itemObject.name} onClick={this.handleListItemClick}>
<ListItemIcon>{index % 2 === 0 ? <WebIcon /> : <WebAssetIcon />}</ListItemIcon>
<ListItemText primary={itemObject.name} />
</ListItem>
</NavLink>
))}
</List>
<Divider />
)
}
}
Header.scss
最后,在我们的Header.scss
样式文件中,我们没有使用任何特定的scss
样式特性。这是普通的 CSS。我们为NavLinkItem
和NavLinkItem-selected
设置了字体系列和样式。看一看:
.Header {
font-family: 'Open Sans', sans-serif;
font-weight: 700;
}
.NavLinkItem {
color: black;
max-width: 360px;
text-decoration: none;
}
.NavLinkItem-selected nav {
background-image: linear-gradient(120deg, #a1c4fd 0%, #c2e9fb 100%);
}
footer . scss
至于Footer
组件,我想从顶部对齐 500 像素,从左侧填充 20 像素。因为我们的页面是空的,所以现在这样就可以了。
.Footer {
position: relative;
padding-top: 500px;
padding-left: 20px;
}
SCS 页面
为了让页面在菜单下对齐,我们可以放置一个普通的 CSS 样式,或者只设置每个页面的填充,这样它们就可以很好地对齐。
.ArticlesPage {
padding-top: 120px;
padding-left: 20px;
}
AppRouter.tsx
最后,我们需要将Header
子组件切换到我们创建的HeaderTheme
组件。
function AppRouter() {
return (
<Router>
<HeaderTheme />
...
</Router>
)
}
图 4-1 已经显示了最终结果。如果我们切换我们的抽屉图标,我们可以看到抽屉随着我们的抽屉链接向左打开。见图 4-9 。
图 4-9
抽屉练习的最终结果
你可以从这里下载这个练习的完整代码:
https://github.com/Apress/react-and-libraries/exercise-4-2
摘要
在本章中,我们在学习 React 路由和 Material-UI 框架的同时完成了两个练习。我们还学习了更多关于创建真实组件的知识。
我们开始构建一个包括页眉、页脚和内容区域的应用,并创建了一个带有导航链接的顶部菜单、一个下拉菜单和一个抽屉。我们还通过媒体查询调整了内容,以改进我们在不同屏幕尺寸上的导航和内容。
在下一节中,我们将继续构建我们的应用,同时开始集成状态管理,这将允许我们在不破坏 React 架构的情况下管理我们的状态并在不同组件之间进行通信。
五、状态管理
在前一章中,我们借助 Material-UI 构建了一个带有菜单和抽屉的 header 组件。我们还使用 React 路由连接页面。我们学习了 React 组件以及如何使用props
将信息从父组件传递到子组件。我们还学习了组件生命周期和组件状态。然而,如果我们想在大多数应用中实现其他公共元素,如存储用户信息、购物车或多个组件或子组件可能需要的任何其他基于数据的状态,我们需要其他库的帮助来构建高质量的软件。
在这一章中,我将向你介绍一个重要的库,它是你的 React 武库工具箱中的必备之物。这是一个状态管理库,它可以帮助确保您编写的防弹代码不会随着时间的推移而变得混乱,难以调试和测试,并导致重构和添加新功能的噩梦。
具体来说,您将了解由脸书引入的状态管理架构,称为 Flux,然后您将了解撰写本文时最流行的状态管理,称为 Redux。最后,您将逐步学会使用 Redux 工具包。在这个过程中,我们将在上一章开始构建的应用中实现 Redux 工具包。
当您完成本章时,您将已经创建了您可以在图 5-1 中看到的最终结果。
图 5-1
最终结果包括一个带有主题的页脚
如果你看我们的页脚,你会注意到它有特殊的力量。具体来说,用户将能够调整我们的应用的主题从亮到暗,或反之亦然。应用的配色方案会相应改变,包括菜单上的字体颜色。图 5-2 显示了更改为黑暗主题的结果。
图 5-2
最终结果:更改应用主题
状态管理架构
当用户点击“更改主题”按钮时(参见图 5-2 中的页脚),我们希望相应地更改我们的应用以及所有组件和子组件的外观。为此,我们可以将用户的数据存储在一个用户首选项对象中。
决定的主题颜色的用户偏好是数据,或者换句话说,是我们的应用的“状态”,我们希望数据以一个方向流向我们的组件和子组件,这意味着组件不需要发送回数据。他们只需要收到一条消息,说明数据已经更改。一旦我们的视图收到数据已经更改的消息,我们就在组件和子组件(我们的视图)中进行更改。
为什么我们需要一个状态管理架构?
这种改变本身听起来微不足道,而且易于实施和管理。那么,为什么我们需要一个状态管理库来完成这个任务呢?
通俗地说,状态管理帮助组织你的 app 的数据和用户交互,直到用户的会话结束。它还有助于确保您的代码不会因为添加了更多功能而变得混乱。它使测试变得更加容易,并确保代码不依赖于特定的开发,并且可以扩展。
Note
状态管理是一种在用户会话结束前维护应用状态的方法。
在这一点上,我们并不真的需要一个设计模式来帮助我们管理我们的数据移动,并且实现一个架构来控制我们的数据移动对于这样简单的功能来说可能被认为是大材小用。
然而,随着我们的代码或团队的增长,我们需要某种架构来帮助处理数据移动,以及强制执行最佳实践来帮助管理我们的代码,以便它不会随着每次更改而中断。
事实上,脸书遇到了这些挑战,并寻找解决这些问题的方法。
脸书解决方案:Flux
脸书团队在扩大规模和维护代码方面的问题导致他们首先尝试现有的东西。他们首先实现了模型-视图-控制器(MVC)模式;然而,他们发现随着越来越多的特性被添加进来,架构模式引起了一些问题。一部分代码很难维护,而且代码经常出错。
什么是 MVC,它解决什么?
在一个复杂的应用中,建议把关注点分开(想想把你的衣服分开来洗)。
-
模型:模型就是 app 数据。
-
视图:视图是前端的表示层。这是一个不应直接更新的实现;理想情况下,它应该通过反射设计模式进行更新。反射意味着一旦数据改变,视图也会“神奇地”改变
-
控制器:这是绑定模型和视图的光晕。
脸书团队尝试使用 MVC,但他们遇到了可能导致循环的数据流问题,这反过来会导致应用崩溃,因为它会成为内存泄漏(嵌套更新的级联效应)。
脸书团队解决了这些问题,他们提出了一个名为 Flux 的架构,最近又提出了一个名为反冲的实验库(我们将在下一章讨论和实现)。
Note
Flux 是一个用于构建用户界面的应用架构。 https://facebook.github.io/flux/
见。
“Flux 是脸书用来构建客户端 web 应用的应用架构。它通过利用单向数据流来补充 React 的可组合视图组件。这更多的是一个模式而不是一个正式的框架“
Flux 是脸书团队使用的一种数据管理模式。焊剂可以分解成三种成分。
-
Dispatcher :中心 hub 保存向 Dispatcher 注册的回调,并将数据发送到商店。数据是通过动作发送的。
-
存储:顾名思义,存储的是数据。换句话说,它们保存着应用的状态。
-
视图(我们的组件):视图是前端实现。我们的组件应该旨在尽可能的纯净和无状态,并且一旦状态改变就自动更新(通过使用反射)。
数据将像这样在应用中流动:
行动➤调度员➤店➤查看
使用这种类型的架构可以让我们知道数据来自哪里,去往哪里。这使得任何开发人员都可以“跳进去”并开始编码,因为他们可以观察动作并快速解决问题,还可以轻松地帮助调试、测试和实现新功能。
例如,假设我们想要更改应用的状态。视图上的用户交互可以调用触发 dispatcher 回调的操作,这将更新应用在商店中的状态,最终这些更改将在视图组件中使用。见图 5-3
图 5-3
Flux 数据流(信用: https://facebook.github.io/flux/docs/in-depth-overview
)
脸书提出的应用架构 Flux 旨在通过以下方式实现其目标:
-
显式数据:使用显式数据,不使用派生数据(显式数据是未插入的原始数据)。
-
分离关注点:从视图中分离数据,就像在 MVC 中一样。
-
避免级联效应:防止嵌套更新(MVC 模式下可能发生的),这是 Flux 和 MVC 最大的区别。
观看此视频可以了解更多脸书 Flux 心态: https://youtu.be/nYkdrAPrdcw
.
我使用过许多大大小小的基于 MVC 的应用,有些是构建在 MVC 基础上的复杂的企业级应用。我不得不有点不同意脸书团队。通过加强良好的习惯,基于 MVC 的应用可以无缝地工作。也就是说,在许多 MVC 框架实现中涉及到大量的样板代码,并且代码审查通常是必要的,以加强良好的习惯并保持关注点的分离。
脸书的 Flux 架构确实简化了分离关注点的过程,并且是状态管理的一种新的替代方式,同时保持了较少的样板代码和松散耦合的组件。
香草冰淇淋
有许多状态管理库可供选择。以下是一些例子:
-
脸书的 Flux(
https://github.com/facebook/flux
) -
脸书的反冲(
https://recoiljs.org/
) -
流动 React 物(
https://github.com/christianalfoni/flux-react
事实上,状态管理非常重要,它可以成为选择一个框架而不是另一个框架的理由。请记住,React 不是一个框架,而是一个库,不像 Angular 或 Vue 等其他框架那样与 React 竞争,它没有开箱即用的关注点分离。
在撰写本文时,实现 Flux 的最流行的状态管理工具是 Redux。脸书团队还在 2020 年年中提出了自己的状态管理库,名为反冲( https://recoiljs.org/
)。它非常有前途,我将在下一章中介绍它。然而,在撰写本书时,它仍处于起步阶段,还没有经过实验阶段而变得成熟。
为什么 Redux
正如 Redux.js 组织所言,
Redux 是一个用于管理应用状态的开源 JavaScript 库。它通常与 React 或 Angular 等库一起用于构建用户界面。
vanilla Redux 版本是非个人化的,这意味着您可以在架构和实践方面随心所欲。然而,Redux 团队官方推荐的方法是使用一个名为 Redux 工具包 的独立附加包,其中包括一些固执己见的默认设置,以帮助更有效地使用 Redux。是 Redux 配合 React 使用的标准。
Note
Redux 的香草味不推荐,这一节只是帮助你了解 Redux。Redux 工具包 更易于使用,是大多数应用的推荐方法。
在继续介绍 Redux 工具包之前,我将首先向您展示 Redux。
首先学习 Redux 更容易,因为它基于相同的组件。您将很快获得 Redux 工具包。
怎么才能学会 Redux?
为了教你 Redux,我把这个过程分解为四个步骤。
-
建立一个 CRA 项目。
-
安装 Redux。
-
创建一个 Redux 循环:action、reducer 和 store。
-
提起诉讼。
我们开始吧。
你可以从本书的 GitHub 位置下载代码。
https://github.com/Apress/react-and-libraries/tree/master/05/hello-redux
和往常一样,如果您从 GitHub 下载这段代码,请记住运行以下代码:
$ yarn install
$ yarn start
并且检查 3000 端口的 app,换句话说就是http://localhost:3000/
。
步骤 1:设置项目
现在,让我们创建我们的项目。正如我们在第一章中所做的,我们将基于 CRA 创建一个新项目,并将其命名为hello-redux.
$ yarn create react-app hello-redux --template must-have-libraries
安装完成后,确保它在端口 3000 上正确运行,将目录更改为hello-redux
,并使用以下命令运行它:
$ yarn start
接下来,我们将安装带有-D
标志的 Redux,因此它会将 Redux 添加到package.json
文件中。在撰写本文时,Redux 的版本是⁴.0.5.
$ yarn add redux -D
CRA·MHL 模板项目和我们在第二章安装的是 Redux 工具包。这就是为什么我们需要安装香草 Redux。
步骤 2:创建一个 Redux 循环
让我们以一个简单的用户手势为例。假设用户点击一个按钮,应用中需要一个数据更改。例如,用户想要登录或退出我们的应用。这意味着我们的应用需要两种状态:一个用户登录,一个用户注销。
这听起来简单而普通;然而,如果没有一个正式的机制来分离这些关注点,这可能会很快变得混乱。
我们的应用可能需要进行全面的数据更改,从更改菜单到更改页面信息,再到更改成员的访问权限等等。
此外,您的应用现在可能很简单,但随着代码的增长,您可能会添加其他状态,如注册用户、已登录的现有用户、用户交互超时等状态。
这就是 Redux 可以帮忙的地方。它有助于使你的应用更具可读性,更易于重构和维护。即使你现在不需要它,通过使用 Redux,你也在为你的应用未来的增长做准备。看一下图 5-4 中 Redux 的万尺图。
图 5-4
Redux 的 10000 英尺图(鸣谢:Medium.com)
如您所见,它与 Flux 架构保持一致。与其用这个图向你解释,不如让我们来看一下登录/注销状态。如果您想更好地理解应用和代码中发生的事情,请随时参考这个图表。看看代码,测试一下,跟着做,就有意义了。
首先,让我们创建文件夹层次结构。首先,我们将创建actions
、reducers
和store
文件夹。在 Mac 上,使用以下命令:
$ mkdir -p src/redux/actions src/redux/reducers src/redux/store
Note
我正在使用-p
标志。-p
标志创建每个目录以及我们为了避免错误而创建的更高级目录(js
)。
在面向 PC 用户 Window 终端上,使用以下命令:
$ md src/redux/actions src/redux/reducers src/redux/store
此时,文件夹结构应该如图 5-5 所示。
图 5-5
hello-redux 应用的文件夹结构
在 Redux 中,我们需要创建一个保存状态的存储。我们将很快创建 Redux 商店;坚持住。
重复操作
我们将从行动开始。创建一个新文件,并将其命名为src/js/actions/MenuActions.ts
。
存储保存状态树。更改状态树只能通过发出一个动作来完成;这是单向的。
一个物体描述了发生的事情。在我们的例子中,我们创建了两个状态,一个用于用户登录,另一个用于用户注销。我们的 reducer 在用户登录和注销时都会有逻辑。我们姑且称这些为showLogin
和showLogout
。
// src/js/actions/MenuActions.ts
export const USER_LOGIN = 'USER_LOGIN'
export const USER_LOGOUT = 'USER_LOGOUT'
export function showLogin() {
return { type: USER_LOGIN }
}
export function showLogout() {
return { type: USER_LOGOUT }
}
还原剂
为了指定动作如何转换状态树,我们编写了 reducers。在我们的例子中,我们将有一个在状态改变时更新的菜单。创建一个新文件,并将其命名为src/js/reducers/MenuReducer.ts
。
Note
我们的 reducer 拥有控制应用状态的逻辑。
在代码级别,让我们创建一个默认的登录状态和一个开关来处理状态更改。
数据变更会将is_login
从true
更新为false
。这将使我们能够知道用户是否登录。
// src/js/reducers/MenuReducer.ts
import { USER_LOGIN, USER_LOGOUT } from '../actions/MenuActions'
const DEFAULT_LOGIN_STATE = {
isLogin: false
}
export default (state = DEFAULT_LOGIN_STATE, action: { type: String }) => {
switch (action.type) {
case USER_LOGIN:
return { is_login: true }
case USER_LOGOUT:
return { is_login: false }
default:
return state
}
}
与我们将在接下来讨论的 Redux 工具包 不同,Redux 是“非个人化的”,因此我们在这里可以做任何我们想做的事情。我们可以设计我们的应用,并将所有代码放在一个文件中,如果这是我们想要的。或者,我们可以坚持更好的设计,让每个缩减器使用自己独立的、松散耦合的代码。
你怎么想呢?我更喜欢后者。
当我们构建我们的应用时,我们会有很多逻辑,所以最好将每个逻辑部分分成它自己的归约器。因为我们会有很多这样的减速器,我们能做的就是创建一个RootReducer
对象来保存所有这些减速器。继续用下面的代码创建一个RootReducer.ts
文件:
// src/js/reducers/RootReducer.ts
import { combineReducers } from 'redux'
import MenuReducer from './MenuReducer'
export default combineReducers({
MenuReducer,
})
在这个设计中,我们可以将 reducer 添加到根文件中,根文件将作为我们的 reducer 的目录。
第三步:Redux 商店
正如我在 Flux 架构中提到的,我们应用的整个状态应该存储在一个单独的存储中的一个对象树中。我们准备好创建商店了。创建一个名为src/js/store/index.ts
的文件。
在代码层面上,基于我们通过添加根 reducer 创建的 reducer 创建一个存储,它包含我们所有的 reducer。
// src/js/store/index.ts
import { createStore } from 'redux'
import rootReducer from '../reducers/RootReducer'
const index = createStore(rootReducer)
export default index
步骤 4:调用操作
让我们创建逻辑来处理我们的前端代码。我想让我们的例子尽可能简洁,所以让我们使用窗口来访问 DOM 文档。创建src/redux/store/index.ts
。
Window
界面代表一个包含 DOM 文档的窗口。我们的例子只是为了学习,所以我使用Window
接口只是为了显示逻辑,而不是显示它与实际组件的连接。
// src/redux/store/index.ts
import store from './store/index'
import { showLogin, showLogout } from './actions/MenuActions'
export interface StoreWindow extends Window {
store: typeof store
showLogin(): { type: string }
showLogout(): { type: string }
}
declare let window: StoreWindow
window.store = store
window.showLogin = showLogin
window.showLogout = showLogout
Note
我声明StoreWindow
是因为 TS 需要一个接口。这是因为 TS 需要定义类型。
在代码级别,我们所做的是设置存储和我们的函数来显示和隐藏登录状态。一旦我们订阅了商店的回调,我们就可以使用dispatch
并看到状态的变化。
在我们的例子中,我们只是使用警报让您知道状态变化,但是任何人都可以订阅这些事件并更新视图。
最后,添加我们的import
语句来运行我们的src/index.js
。
// src/index.js
import { StoreWindow } from './redux'
declare let window: StoreWindow
window.store.getState()
window.store.subscribe(() => window.alert(JSON.stringify(window.store.getState())))
window.store.dispatch(window.showLogin())
window.store.dispatch(window.showLogout())
在浏览器中,当状态改变时,您会收到两个警告。
{"MenuReducer":{"is_login":true}}
{"MenuReducer":{"is_login":false}}
图 5-6 显示了最终结果。
图 5-6
Redux hello-world 最终结果
让我们回顾一下
我们能够快速创建 Redux 数据移动,并理解 Redux 是怎么一回事。我们设置了 React 项目并安装了 Redux。我们创造了一个循环。最后,我们调用了一个动作。
Redux 工具包
正如我们所见,原始香草 Redux 风味是一个有用的工具。它帮助我们组织应用的数据和用户交互,因此我们的代码不会变得混乱。
我们有一个存储的概念,它可以保存我们的序列化键值对,并保存我们想要的任何值。
Redux 工具包 架构将每个功能分成一个特性片,随着越来越多的特性被添加,我们可以访问每个“片”数据。如果我们从食物的角度考虑,Redux 是一个香草冰淇淋蛋卷,然后我们的商店保存所有的数据或口味。
在我们的 Redux 工具包中,我们的商店更像一个比萨饼。我们的“切片”就像一片披萨,所有的切片组成了我们的商店。这就是所谓的鸭子模式(“re-dux”听起来像鸭子,懂吗?),所以每个功能都将存储在单独的文件夹中。
Redux 工具包 是固执己见的,我们被鼓励创建一个特性文件夹来保存所有不同的片段。Redux 工具包包括一些自以为是的 API,帮助我们更有效地使用 Redux。
Redux API 由以下主要函数组成:createSlice
、configureStore
、createReducer
、createAction
、createAsyncThunk
和createEntityAdapter
。
其想法是提供样板工具,抽象设置过程,处理最常见的用例以及有用的实用程序,让用户简化编写代码。它负责中间件和 DevTools,而不需要做任何设置,并且它允许更容易的测试。
Redux 工具包 的目的是解决与 Redux 工具包.js 组织( https://redux-toolkit.js.org/introduction/quick-start
):
“配置 Redux 存储太复杂
——我必须添加许多包才能让 Redux 做任何有用的事情
——Redux 需要太多的样板代码"
Redux 工具包 在 React 企业级应用中被大量使用,这使得它成为高级到专家 React 开发人员的必备库,根据 React 团队的说法,“这些工具应该对所有 Redux 用户有益。”我个人同意,但我会让你来评判。
引擎盖下的 Redux 工具包
如果我们得到更多的技术,在引擎盖下,Redux 工具包 APIs 减少了我们需要编写的代码。有了样板代码,它可以防止我们犯常见的错误,因为代码会将错误减到最少。例如,常见的错误可能包括改变状态(在创建后更改数据并引入潜在的错误)或在状态中设置不可序列化的数据,如函数和承诺(而不是单独处理它们)。
这是通过redux-immutable-state-invariant
和serializable-state-invariant-middleware
以及createAsyncThunk()
API 来完成的,以处理不可序列化的数据异步动作,比如与您的 API 交互。这与函数式编程(FP)密切相关。正如我们在前面的章节中看到的,功能组件是 React 的主要构件之一。
FP 意味着我们正在用纯粹的功能构建我们的软件,避免共享状态、可变数据和副作用。FP 是声明性的而不是命令性的,应用状态流过纯函数。因为 React 是一种声明性语言(它实际上并不直接操纵 DOM 本身),所以它与 Redux 工具包 集成得很好,因为它有助于加强声明性架构。
Note
声明式编程表达了计算的逻辑,而没有描述它的控制流。命令式范式使用改变程序状态的语句,比如直接改变 DOM。
也就是说,如果你想创建自己的中间件,Redux 工具包 仍然有getDefaultMiddleware()
,同时利用默认设置。getDefaultMiddleware()
将在我们创建商店时被调用。
不会调用redux-immutable-state-invariant, serializable-state-invariant-
中间件和createAsyncThunk()
,需要自己处理逻辑。
Redux 工具包 也运行 Immer 库( https://github.com/immerjs/immer
),它让我们以一种变异的方式编写代码。我们通过简单地修改当前树来创建下一个不可变的状态树。我会在createSlice()
代码里给你看。
我们真正想要的是使用尽可能多的纯功能组件。没有生命周期方法的组件要求我们依赖基于声明性的props
方法,并提供性能改进,因为任何地方都不会有内存泄漏。
在纯函数方法中,对函数的每次调用都产生相同的结果,没有副作用。有道理吗?总的来说,这是很好的编程实践。
使用 Redux 工具包实现主题
现在我们已经介绍了 Redux 工具包,我们可以开始实现逻辑了。让我们创建一个首选项功能,我们可以在其中保存用户特定的首选项,如应用设置。
我们将从上一章停止的地方开始。
https://github.com/Apress/react-and-libraries/04/exercise-4-2
你可以从本书的 GitHub 位置下载本章的完整代码。
https://github.com/Apress/react-and-libraries/05/exercise-5-1
一旦你运行了完整的代码,你就可以切换应用的主题,如图 5-1 和图 5-2 所示。
模型枚举对象:首选项对象
在创建切片之前,让我们创建一个枚举文件来保存我们的首选项。我们可以设置一个变量,用两个选项叫它ThemeEnum
:dark
和light
。
首先,创建src/model/preferencesObject.ts
。
// src/model/preferencesObject.ts
export enum ThemeEnum {
Dark = 'dark',
Light = 'light',
}
模型索引文件
当我们访问我们的模型时,有一个简单的方法来访问这些对象。我们知道我们需要我们的包 JS chuck 中的全部对象内容,所以我们可以在 model 文件夹中创建一个类,它将包含我们创建的所有方法。
// src/model/index.ts
export * from './preferencesObject'
接下来,随着我们模型库的增长,我们可以以更直观的方式访问它们。
而不是这个:
import { ThemeEnum } from '../../model/preferencesObject'
我们可以这样访问我们的方法:
import { ThemeEnum } from '../../model'
这种方法有利也有弊。当我们优化我们的应用时,你会在第十二章学到更多关于优化 React 应用块的技术。
将主题设置为全局样式:index.scss
src/index.scss
是放置主题颜色的好地方,因为它们与不止一个组件相关,并且是一种全局风格。
.dark {
color: white;
background-color: #2b2b2b;
}
.light {
color: black;
background-color: darkgrey;
}
创建一个状态切片:preferencesSlice.ts
接下来,创建一个文件并将其命名为preferencesSlice.ts
。在切片内部,我们设置数据的初始状态。我把它设置为浅色(ThemeEnum.Light
)作为初始状态。
对于减压器,我设置了三种类型的行动。
-
setThemeDark :将我们的应用主题设置为深色
-
setThemeLight :将我们的应用主题设置为浅色
-
切换主题:切换到相反的配色方案
看一看:
// src/features/Preferences/preferencesSlice.ts
import { createSlice } from '@reduxjs/toolkit'
import { ThemeEnum } from '../../model’
interface SliceState {
theme: ThemeEnum
}
createSlice
是逻辑所在的地方,我们创建initialState
以及减少器。
const preferences = createSlice({
name: 'preferences',
initialState: {
theme: ThemeEnum.Light,
} as SliceState,
我们的减速器保持动作。虽然我只需要使用switchTheme
而不是其他方法来切换主题,但是我已经为我可能需要的其他动作创建了 reducers。在测试方面,当我们到第 X 章时,你可以看到这种逻辑是如何有意义的,因为每个动作都可以单独测试。
reducers: {
setThemeDark: (state, action) => {
state.theme = ThemeEnum.Dark
},
setThemeLight: (state, action) => {
state.theme = ThemeEnum.Light
},
switchTheme: (state, action) => {
state.theme = state.theme === ThemeEnum.Light ? ThemeEnum.Dark : ThemeEnum.Light
},
},
})
最后,导出操作。
export const { setThemeDark, setThemeLight, switchTheme } = preferences.actions
export default preferences.reducer
Redux 工具包商店
现在我们已经有了带有特定动作的片段,我们可以设置Store.ts
。我们所做的是用我们设置的片来配置存储。这就是我们所需要的。看一看:
// src/redux/store.ts
import { configureStore, combineReducers } from '@reduxjs/toolkit'
import prefSlice from '../features/Preferences/preferencesSlice'
const store = configureStore({
reducer: combineReducers({
preferences: prefSlice,
}),
})
export default store
太好了。我们已经准备好了状态逻辑。我们现在可以实现视图了。在我们名为src/layout/Footer/Footer.tsx
的页脚视图文件中,我们可以创建一个带有链接列表和按钮的页脚,该按钮将向切换颜色主题的操作发送回调。
页脚架构
至此,我们已经为页脚创建了一个占位符。我们的页脚需要知道用户的偏好,并能够更新主题。为了实现这一点,我们可以用父组件FooterTheme.jsx
包装子组件Footer
,就像我们对Header
所做的那样。看看图 5-7 。
图 5-7
页脚组件的高级架构
页脚子组件
我们创建一个类型为FunctionComponent
的类,并传递主题prop
。然后我们可以将视图包装在导航标签中。这个逻辑将设置页脚的背景来改变整个容器。我们还使用styled-component
库来提取组件的样式。
// src/layout/Footer/Footer.tsx
import React, { FunctionComponent } from 'react'
import classNames from 'classnames'
import { List, ListItem } from '@material-ui/core'
import { NavLink } from 'react-router-dom'
import InvertColorsIcon from '@material-ui/icons/InvertColors'
import { useStyles } from './Footer.styles'
// Redux 工具包
import store from '../../redux/store'
import { ThemeEnum } from '../../model’
import { switchTheme } from '../../features/Preferences/preferencesSlice'
const Footer: FunctionComponent<TFooterProps> = ({ theme }) => (
<nav className={theme === ThemeEnum.Dark ? 'dark' : 'light'}>
<NestedGrid />
</nav>
)
export type TFooterProps = {
theme: ThemeEnum
}
export default Footer
我们的NestedGrid
函数可以接受主题prop
并返回链接列表。
function NestedGrid() {
const classes = useStyles()
const footerClasses = classNames({
[classes.footer]: true,
})
const aClasses = classNames({
[classes.a]: true,
})
updatePref
方法将使用 Redux 工具包 片来产生一个动作,该动作将更新首选项的状态。
const updatePref = () => {
store.dispatch(switchTheme(store.getState()))
}
然后我们可以在渲染函数return
中设置这些样式类:
return (
<footer className={footerClasses}>
<div className={classes.container}>
<div className={classes.left}>
<List className={classes.list}>
{[
{ name: 'Contact', url: '/Contact' },
{ name: 'About', url: '/About' },
{ name: 'Books', url: '/Books' },
{ name: 'Courses', url: '/BuildSiteCourse' },
].map((itemObject, index) => (
<NavLink to={itemObject.url} className={classes.block} key={itemObject.url} activeClassName="NavLinkItem-selected">
<ListItem className={classes.inlineBlock}>{itemObject.name}</ListItem>
</NavLink>
))}
</List>
</div>
<div className={classes.right}>
© {new Date().getFullYear()}{' '}
下面是我们的按钮,它使用商店并调度一个回调来切换我们在切片中创建的主题动作:
<button type="submit" onClick={() => updatePref()} className={aClasses}>
<InvertColorsIcon className={classes.icon} /> Change theme to {store.getState().preferences.theme === ThemeEnum.Dark ? 'light' : 'dark'}
</button>
</div>
</div>
</footer>
)
Footer.styles.ts 样式组件
注意,我们在Footer.tsx
子组件中调用了Footer.styles.ts
。我们将视图和风格分开,并将风格放在一个名为Footer.styles.ts
的单独文件中。我们的代码可以导入我们的样式文件。
import { useStyles } from './Footer.styles'.
然后使用类的对象名,我们可以设置链接的字体颜色。如果背景是暗的,我们需要亮色字体,如果背景是亮的,我们需要深色字体。
在Footer.styles.ts
中,我们的代码可以设置链接的样式,甚至包括媒体查询,以使用 Material-UI 核心 APIcreateStyles
、makeStyles
和Theme
调整我们的页脚容器。
import { createStyles, makeStyles, Theme } from '@material-ui/core'
export const useStyles = makeStyles((theme: Theme) =>
createStyles({
block: {
color: 'inherit',
padding: '0.9375rem',
fontWeight: 500,
fontSize: '12px',
textTransform: 'uppercase',
borderRadius: '3px',
textDecoration: 'none',
position: 'relative',
},
left: {
display: 'block',
},
right: {
padding: '15px 0',
margin: '0',
},
footer: {
padding: '0.9375rem 0',
textAlign: 'center',
display: 'flex',
zIndex: 2,
position: 'relative',
},
a: {
color: '#9c27b0',
textDecoration: 'none',
backgroundColor: 'transparent',
},
footerWhiteFont: {
'&,&:hover,&:focus': {
color: '#FFFFFF',
},
},
container: {
paddingRight: '15px',
paddingLeft: '15px',
marginRight: 'auto',
marginLeft: 'auto',
width: '100%',
'@media (min-width: 576px)': {
maxWidth: '540px',
},
'@media (min-width: 768px)': {
maxWidth: '720px',
},
'@media (min-width: 992px)': {
maxWidth: '960px',
},
'@media (min-width: 1200px)': {
maxWidth: '1140px',
},
},
list: {
marginBottom: '0',
padding: '0',
marginTop: '0',
},
inlineBlock: {
display: 'inline-block',
padding: '0px',
width: 'auto',
},
icon: {
width: '18px',
height: '18px',
position: 'relative',
top: '3px',
},
})
)
最后,我们需要用名为FooterTheme.tsx
的父组件包装我们的Footer
子组件,如果用户决定切换主题,父组件将监听来自商店的调度回调。该架构允许我们从任何视图调度回调,而不是从页脚强制进行交互。
FooterTheme 包装父组件
为了实现这一点,我们将FooterTheme.tsx
创建为FunctionComponent
并订阅回调。
store.subscribe(() => {
setTheme(store.getState().preferences.theme)
})
下面是完整的 FooterTheme.tsx 代码;
// src/layout/Footer/FooterTheme.tsx
import React, { FunctionComponent, useState } from 'react'
import { ThemeEnum } from '../../model’
import store from '../../redux/store'
import FooterComponent from './Footer'
export const FooterTheme: FunctionComponent = () => {
const [theme, setTheme] = useState<ThemeEnum>(ThemeEnum.Light)
store.subscribe(() => {
setTheme(store.getState().preferences.theme)
})
return <FooterComponent theme={theme} />
}
HeaderTheme 父组件
我们需要为标题做同样的事情。对于头部,我们已经设置了HeaderTheme
包装器父组件,并设置了appBarBackgroundStyle
方法。对于HeaderTheme.tsx
来说,代码重构的变化是突出的。
// src/layout/Header/HeaderTheme.tsx
import React, { FunctionComponent, useState } from 'react'
import AppBar from '@material-ui/core/AppBar/AppBar'
import { useMediaQuery } from '@material-ui/core'
import HeaderComponent from './Header'
import store from '../../redux/store'
import { ThemeEnum } from '../../model’
function appBarBackgroundStyle(color: string) {
return {
background: color,
}
}
我们使用 React useState
方法来设置状态。然后,我们可以为AppBar
设置样式,并将状态传递给HeaderComponent
。看一看:
export const HeaderTheme: FunctionComponent = () => {
const smallBreakPoint = useMediaQuery('(min-width: 0px) and (max-width: 1100px)')
const [theme, setTheme] = useState<ThemeEnum>(ThemeEnum.Light)
store.subscribe(() => {
setTheme(store.getState().preferences.theme)
})
return (
<AppBar position="fixed" style={appBarBackgroundStyle(theme === ThemeEnum.Dark ? '#2b2b2b' : 'white')}>
<HeaderComponent theme={store.getState().preferences.theme} smallBreakPoint={smallBreakPoint} />
</AppBar>
)
}
标题子组件
需要重构Header
组件,以便能够保存主题状态的属性,并将状态传递给不同的子组件,并更新 Material-UI 组件的状态。
让我们来看看。重构IHeaderProps
接口,这样我们可以从HeaderTheme
父组件传递主题。这里突出显示了代码更改:
import { ThemeEnum } from '../../model'
interface IHeaderProps {
theme: ThemeEnum
smallBreakPoint: boolean
}
为了设置按钮的颜色,我们将使用一个名为menuLabelBackgroundStyle
的方法来传递颜色编号。
function menuLabelBackgroundStyle(color: string) {
return {
color,
padding: 20,
}
}
现在我们的材质界面按钮能够在props
更新时切换颜色。
<Button component={Link} style={menuLabelBackgroundStyle(this.props.theme === ThemeEnum.Dark ? 'white' : 'black')} to="/">
ELI ELAD ELROM
</Button>
我们还需要将prop
主题变量传递给两个叫做HeaderTopNav
和HeaderDrawer
的Header
子组件。
<HeaderTopNav theme={this.props.theme} />
<HeaderDrawer theme={this.props.theme} />
HeaderTopNav 子组件
对于我们的HeaderTopNav
子组件,我们需要将prop
添加到接口中。
import { ThemeEnum } from '../../model'
interface IHTNavProps {
theme: ThemeEnum
}
我们将使用与在Header
组件中相同的方法来设置材质 UI 按钮的样式。由于它与Header
组件中的代码相同,我们可以将代码提取到一个实用程序中;然而,由于代码很小,我更喜欢保持它的副本,这样每个组件都可以作为一个独立的组件提取出来,没有其他的依赖,但是提取重复的代码并不是一个坏主意。
function menuLabelBackgroundStyle(color: string) {
return {
color,
padding: 20,
}
}
接下来,我们可以为每个父按钮设置样式,并为每个按钮重复这个过程:联系人、资源、指导和建立我的网站。我只显示了一个按钮,但会为每个按钮重复相同的样式属性。
<Button style={menuLabelBackgroundStyle(this.props.theme === ThemeEnum.Dark ? 'white' : 'black')} onClick={(event) => this.handleMenuOpen(event, '1')}>
Build My Website
</Button>
HeaderDrawer 子组件
对于headerDrawer
,我现在不打算实现任何样式更改,但是我想为以后的更改做准备,所以我将让子组件通过props
知道状态,就像我们在HeaderTopNav
中做的那样。
import { ThemeEnum } from '../../model'
interface IHDProps {
theme: ThemeEnum
}
适当的重构
既然我们已经重构了我们的页脚,我们需要改变AppRouter.tsx
来使用<FooterTheme />
子组件而不是<Footer />
。看一看:
const AppRouter: FunctionComponent = () => {
return (
<Provider store={store}>
<Router>
<HeaderTheme />
<Switch>
...
</Switch>
<div className="footer">
<FooterTheme />
</div>
</Router>
</Provider>
)
}
export default AppRouter
恭喜你!我们完成了编码。如果您检查应用,您应该会看到如图 5-1 和图 5-2 所示的结果。
因为我们的页面不包含任何内容,所以我们可以调整页面的 SCSS 高度,使其看起来整洁。
// src/App.scss
.App {
text-align: center;
padding-top: 120px;
height: 350px;
}
我们现在有了一个包含导航、页面、抽屉、主题和状态管理的站点脚手架,能够在组件和子组件之间传递状态。
为了保证质量,正如我们在上一章中所做的那样,您应该养成运行以下程序的习惯:
$ yarn format
$ yarn lint
$ yarn test
这将确保我们通过测试,林挺,并解决任何问题。本例中的代码通过了所有的测试,所以您可以将您的最终结果与我的进行比较。
redux devtools extension(redux devtools 扩展)
我想向您展示的最后一件事将是添加到 React 工具箱的一个很好的附加功能,它是一个跟踪 Redux 工具包 中发生的事情的插件。它被称为 Redux DevTools 扩展。对于 Chrome,你可以从这里下载:
https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en
此外,您也可以随意访问 GitHub 上的项目,获取其他浏览器的插件。
https://github.com/zalmoxisus/redux-devtools-extension
当我们使用我们构建的应用时,我们现在可以看到插件上正在调度的操作,如图 5-8 所示。点击链接切换我们应用的主题。在我们的例子中,应用的主题从亮变暗。随着您添加越来越多的切片和代码的增长,这个工具会派上用场。
图 5-8
Chrome 的 Redux DevTools 扩展
摘要
在这一章中,你学习了状态管理和脸书提出的状态管理架构 Flux。通过使用 CRA 创建一个hello-redux
应用,您了解了在撰写本文时最流行的状态管理方法 Redux。然后你学习了 Redux 工具包,并在这个过程中在我们上一章开始构建的应用中实现了 Redux 工具包。在 Redux 工具包 和样式化组件的帮助下,我们添加了主题和使用 Flux 状态管理在不同组件之间进行状态通信的能力。最后,我们为 Chrome 安装了 Redux DevTools 扩展。
在下一章中,我们将继续构建我们的应用,并使用反冲和 Mongo-Express-React-node . js(MERN)栈实现一个具有独占私有成员区域的登录。
六、MERN 栈:第一部分
在前一章中,你学习了状态管理和脸书的 Flux 状态管理架构。您了解了目前最流行的状态管理工具:vanilla Redux 和 Redux 工具包。在这一章中,我们将使用反冲和 Mongo-Express-React-node . js(MERN)栈实现一个具有独占私有成员区域的登录。具体来说,我们将使用 React with 反冲在不同组件之间共享状态,作为视图层和中间件。在下一章中,我们将实现 Node.js、Express 和 MongoDB 作为后端。
Note
中间件为我们的组件提供了 React 库中没有的服务。将中间件视为“软件粘合剂”
这一章将包含大量的实践编码。当您完成本章时,您将会看到使用 React 和其他相关技术的完整周期。当您完成下一章时,您将拥有成为全栈开发人员的工具。
同样,MERN 栈分为两章。
-
在这一章中,我们将设置前端,React 部分。
-
在下一章,我们将设置后端。
这一章分为两部分。在第一部分中,我们将重构我们在前面章节中开始的应用,并重构状态管理以使用反冲。在本章的第二部分,我们将构建一个专属的会员区,其中包含一个我们可以在整个应用中使用的全球 toast 消息组件。
图 6-1 显示了最终的结果,用户可以登录和注销一个专属的、安全的、会员专用的区域。我们的组件将动态变化。
图 6-1
本章的最终结果
用反冲更新首选项
在这一节中,我们将重构我们的应用,使用反冲而不是 Redux 工具包 来为反冲中间件与后端的集成做好准备,我们将在下一章中设置后端。不过,首先让我们谈谈 MERN 和反冲。
什么是 MERN 栈?
MERN 栈是一个 JavaScript 栈,它使得开发过程更加容易,因为它完全基于 JavaScript 技术。MERN 包括四种开源技术: M ongoDB、 E xpress、 R eact、 N ode.js,这些技术为我们提供了从前端到后端框架完整的端到端循环。在本章中,我们将完成 MERN 栈的 React 部分。
什么是后坐力?
为了了解反冲,我们将重构我们在上一章中创建的首选项组件的状态,但是这一次我们将使用脸书反冲库取代状态管理,而不是使用 Redux 工具包。
反冲( https://recoiljs.org/
)是脸书正在席卷 React 开发者社区的改变生活的状态管理实验。后坐力团队是这样说的:
“后坐力的工作原理和思考方式类似 React。添加一些到您的应用中,获得快速灵活的共享状态。”
为什么要用后座力?
正如我们在前一章看到的,有许多状态管理库,那么为什么我们还需要另一个状态管理来共享我们的应用状态呢?跨多个组件共享状态和设置中间件能做得更好更容易吗?快速回答是肯定的!
如果您需要做的只是全局存储值,那么您选择的任何库都可以工作。然而,当您开始做更复杂的事情,比如异步调用,或者试图让您的客户端与您的服务器状态同步,或者反向用户交互时,事情就开始变得复杂和混乱了。
理想情况下,正如在本书中多次提到的,我们希望我们的 React 组件尽可能纯净,我们希望数据管理在 React 钩子中流动,没有副作用。我们还希望“真正的”DOM 为了性能而尽可能少地改变。
保持组件松散耦合对于开发人员来说总是一个好地方,因此拥有一个与 React 很好集成的库是对 React 库的一个很好的补充,因为它将 React 与 Angular 等其他顶级 JavaScript 框架放在一起。
拥有固态管理库将更好地促进 React 应用为企业级复杂应用提供服务,并处理前端和中间层的复杂操作。反冲简化了状态管理,我们只需要创建两个成分:原子和选择器。
原子是对象,是组件可以订阅的状态单元。反冲让我们创建一个从这些原子(共享状态)流向组件的数据流图。选择器是允许同步或异步转换状态的纯函数。
与 Redux 或 Redux 工具包 不同,不需要处理复杂的中间件设置并连接您的组件或使用任何其他东西来让 React 组件很好地运行。
你知道吗?反冲库仍处于实验阶段,但已经获得了一些非凡的人气,超过了 Redux 的人气。反冲库在 GitHub 上的得分接近 10,000 星,超过了 Redux 工具包 的 4,100 星!
我和许多其他人都认为,反冲将成为状态管理的标准,并且比继续使用 Redux 工具包 作为中间件更好的投资。然而,了解 Redux 工具包 仍然是很好的,因为您可能参与了一个使用 Redux 的项目。此外,反冲仍然是一个实验,并没有达到释放阶段,因为这本书的写作,所以它不适合心脏虚弱的人。
用反冲分享用户的偏好状态
为了开始使用反冲,我将通过重构我们在上一章中构建的首选组件,从通过 Redux 工具包 共享状态到利用反冲,让您更容易理解。这将使您能够并排比较这两个库。我把这个过程分成两步。
-
步骤 1 :实施反冲
-
第二步:重构视图层
在项目层面,我们将从上一章停止的地方继续。
https://github.com/Apress/react-and-libraries/05/exercise-5-1
你可以在这里下载重构我们偏好的完整代码:
https://github.com/Apress/react-and-libraries/06/exercise-6-1
实施反冲
为了开始,我们通常首先需要安装反冲(yarn add recoil
)。在撰写本文时,反冲的版本是 0.0.13,但在您阅读本章时,情况可能会发生变化。然而,我们的 CRA MHL 模板已经包括反冲,所以它已经设置没有任何额外的工作对你的一部分。
更新首选项对象
我们将更新的第一个文件是preferencesObject.ts
,它是我们在前一章中创建的。对象为两个应用状态保存一个枚举常数。我们将向该对象添加一种保存首选项对象类型的方法和一种初始化该对象的方法。这在后座力上会派上用场。看一看:
// src/model/preferencesObject.ts
...
// eslint-disable-next-line @typescript-eslint/naming-convention
export interface preferencesObject {
theme: ThemeEnum
}
export const initPreferencesObject = (): preferencesObject => ({
theme: ThemeEnum.Light,
})
注意,我放置了eslint-disable
代码,让 Lint 忽略签名行,因为我们的 ESLint 基于 Airbnb 风格,所以被设置为不使用小写作为命名约定。我想把界面设置成小写。另一个选项是更改 ESLint 配置文件。
另一件要注意的事情(有些人可能会有争议)是将枚举放在对象模型中以保持它“更纯粹”如果您来自 Java 世界,您应该听说过值对象(VOs)和数据传输对象(dto)。我不会讲太多细节,但是对象可以重构,枚举可以提取到枚举对象及其自己的文件夹中,如果这是您想要的设计。
为我们的对象创建一个带有类型的模型文件夹让我们可以充分利用 TypeScript,并且使用对象类型可以帮助我们编写代码和测试。
首选项原子
接下来,我们将创建我们的反冲物体,称为原子。我们将调用文件preferencesAtoms.ts
,反冲希望我们为每个原子定义一个惟一的键,并设置一个默认值。我将文件原子称为复数而不是原子的原因是我可以在该文件中添加其他相关原子。这只是一个好习惯。
看一看:
// src/recoil/atoms/preferencesAtoms.ts
import { atom } from 'recoil'
import { initPreferencesObject } from '../../model'
export const preferencesState = atom({
key: 'PreferencesState',
default: initPreferencesObject(),
})
注意,对于默认值,我使用模型的initPreferencesObject
方法用默认值(ThemeEnum.Light
)初始化对象。我们稍后会用到它。
会话原子
我们还将创建一个sessionAtoms.ts
文件,它将保存一个用户密钥,我们可以用它来确保用户被授权登录到我们的安全会员区。我们不会在本章中实现它,但是在您完成相关章节后,您可以随意创建逻辑并自己实现该功能。
// src/recoil/atoms/sessionAtoms.ts
import { atom } from 'recoil'
export const sessionState = atom({
key: 'SessionState',
default: '',
})
重构视图层
现在我们有了原子preferencesAtoms
和sessionAtoms.ts
,我们准备设置页脚和页眉,从反冲而不是 Redux 工具包 获取共享状态值,并通过反冲设置值。
UserButton 子组件
我们将从创建一个名为UserButton.tsx
的新子组件开始。
UserButton
将了解用户的偏好以及用户是否登录了我们的应用。该按钮将根据状态调整颜色和信息。
为了实现所有这些,我们将在界面中包含主题的状态,以及稍后可以使用的登录状态的状态。
我们的导入需要包括 Material-UI 按钮组件、一个链接组件、ThemeEnum
以及一个包含应用主题的prop
的接口,我们将从父组件传递该主题。看一看:
//src/components/UserButton/UserButton.tsx
import React from 'react'
import './UserButton.scss'
import Button from '@material-ui/core/Button/Button'
import { Link } from 'react-router-dom'
import { ThemeEnum } from '../../model'
export interface IUserButtonProps {
isLoggedIn: boolean
theme: ThemeEnum
}
接下来,我们将创建两个常量,我们可以用它们来根据用户的偏好改变菜单标签的样式。
const menuLabelsLight = {
color: 'white',
padding: 20,
}
const menuLabelsDark = {
color: 'black',
padding: 20,
}
对于类签名,我们可以使用PureComponent
。当你不需要shouldComponentUpdate
挂钩时,使用这个:
export class UserButton extends React.PureComponent<IUserButtonProps, {}> {
render() {
在按钮样式中,我们可以让开关决定使用哪个样式元素。在我们的例子中,用户登录与否的唯一区别是顶部菜单上显示的“members”或“login ”,但是代码被设置为可以显示任何想要的子组件。例如,您可能希望在用户登录时显示用户名或图片。看一看:
const { isLoggedIn } = this.props
return isLoggedIn ? (
<Button component={Link} style={this.props.theme === ThemeEnum.Dark ? menuLabelsLight : menuLabelsDark} to="/Members">
Members
</Button>
) : (
<Button component={Link} style={this.props.theme === ThemeEnum.Dark ? menuLabelsLight : menuLabelsDark} to="/Members">
Login
</Button>
)
}
}
内联条件语句与用纯 JavaScript 编写时是一样的。
let value = isLoggedIn ? 'Members' : 'Login'
用户列表按钮组件
类似地,我们将创建UserButton
,它将知道登录状态并在我们的抽屉组件中使用它。我们的组件将把一个单击的用户手势传递给父组件,因此我们的组件将像任何其他链接组件一样,能够在不破坏封装的情况下关闭抽屉。
export interface IUserListButtonProps {
isLoggedIn: boolean
onClick: MouseEventHandler
}
对于我们的导入,我从 Material-UI 中挑选了一些图标,我们可以用它们来指示用户已经登录或注销。我们还需要来自 Material-UI 的ListItem
和ListItemText
,以及路由的NavLink
。
import React, { MouseEventHandler } from 'react'
import { NavLink } from 'react-router-dom'
import ListItem from '@material-ui/core/ListItem/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText/ListItemText'
import VpnKeyIcon from '@material-ui/icons/VpnKey'
import CardMembershipIcon from '@material-ui/icons/CardMembership'
我们的类签名可以再次是PureComponent
,内联的if
/ else
语句将显示成员按钮或登录按钮。
export class UserListButton extends React.PureComponent<IUserListButtonProps, {}> {
render() {
const { isLoggedIn } = this.props
return isLoggedIn ? (
<NavLink to="/Members" className="NavLinkItem" key="/Members" activeClassName="NavLinkItem-selected">
<ListItem button key="Members" onClick={this.props.onClick}>
<ListItemIcon>
<CardMembershipIcon />
</ListItemIcon>
<ListItemText primary="Members" />
</ListItem>
</NavLink>
) : (
<NavLink to="/Members" className="NavLinkItem" key="/Members" activeClassName="NavLinkItem-selected">
<ListItem button key="Login" onClick={this.props.onClick}>
<ListItemIcon>
<VpnKeyIcon />
</ListItemIcon>
<ListItemText primary="Login" />
</ListItem>
</NavLink>
)
}
}
HeaderTheme 组件更改
有了原子,我们可以继续前进,重构。我们将更新组件,并通过props
将共享反冲原子的prop
传递给Header
子组件。
我们将useRecoilState
以及preferencesState
和sessionState
添加到我们的import
语句中。
// src/layout/Header/HeaderTheme.tsx
import { useRecoilState } from 'recoil'
import { preferencesState } from '../../recoil/atoms/preferencesAtoms'
import { sessionState } from '../../recoil/atoms/sessionAtoms'
接下来,我们将利用useRecoilState
从反冲中获取我们的首选项和会话令牌的值。反冲的这种集成感觉很自然,并且更符合 React 组件的内置状态机制。您可以在这里看到反冲力的作用(代码更改突出显示):
export const HeaderTheme: FunctionComponent = () => {
const [preferences] = useRecoilState(preferencesState)
const [session] = useRecoilState(sessionState)
...
}
然后我们可以使用偏好的共享值,并将数据传递给Header
组件。再也不需要 Redux 工具包 的store.getState()
了。
<HeaderComponent theme={preferences.theme} session={session} smallBreakPoint={smallBreakPoint} />
正如您所看到的,我通过将共享状态作为props
传递来保持子组件尽可能纯净的状态,这不仅使调试和测试变得容易,因为状态将从父状态传递到子组件,而且还可以将任何组件从一个应用提取到另一个应用。
标题子组件
在我们的Header
子组件(Header
、HeaderTopNav
和HeaderDrawer
)中,我们可以采用相同的主题prop
方法,并通过props
将其传递给其他两个子组件。由于Header
组件没有 set 方法,它只读取主题状态,我们的代码是松散耦合的,我们可以很容易地将子组件从一个项目复制粘贴到另一个项目。此外,当我们需要测试一个组件不依赖于另一个父组件时,最好保持我们的组件松散耦合。
标题子组件
我们可以用Header
子组件代码开始重构Header
子组件。在Header props
中,我们可以设置主题和会话变量。
// src/layout/Header/Header.tsx
interface IHeaderProps {
theme: ThemeEnum
session: string
smallBreakPoint: boolean
}
对于渲染 JSX 方面,我们使用主题设置样式,并设置会话密钥。
请注意,我们直接在组件上检查会话状态。原因是一旦共享状态被更新,我们需要组件被更新。我们正在检查会话共享状态以及localStorage
的状态。我们将两者都设置的原因是我们需要两种情况,如下所示:
-
应用通过钩子自动更新
-
一旦用户刷新浏览器,应用就会更新
Note
localStorage
也叫 DOM storage,是 web 存储,它赋予我们的 web app 存储我们客户端数据的能力,当我们刷新浏览器时,数据会被持久化。
<HeaderTopNav theme={this.props.theme} isLoggedIn={this.props.session === 'myUniqueToken' || localStorage.getItem('accessToken') === 'myUniqueToken'} />
<HeaderDrawer theme={this.props.theme} isLoggedIn={this.props.session === 'myUniqueToken' || localStorage.getItem('accessToken') === 'myUniqueToken'} />
注意,在代码级别,我正在为会话检查创建烘焙代码myUniqueToken
。这可以连接到通过加密/解密算法检查会话的逻辑。
HeaderTopNav 子组件
对于HeaderTopNav
子组件重构工作,我们将导入UserButton
,它知道我们创建的首选项和登录状态。
// src/layout/Header/HeaderTopNav.tsx
import { UserButton } from '../../components/UserButton/UserButton'
接下来,我们需要子组件知道用户是否登录以及主题。我们将这些信息传递给孩子。
interface IHTNavProps {
theme: ThemeEnum
isLoggedIn: boolean
}
我想重构的另一个项目是 GitHub 图标。GitHub 图标样式需要基于主题偏好,所以要匹配其余导航项;否则,它保持不变的颜色。看一看:
function githubIconStyle(color: string) {
return {
color,
width: 120,
}
}
<IconButton style={githubIconStyle(this.props.theme === ThemeEnum.Dark ? 'white' : 'black')}>
最后,让我们在现有的 Contact 按钮下添加UserButton
组件。
<UserButton theme={this.props.theme} isLoggedIn={this.props.isLoggedIn} />
HeaderDrawer 子组件
HeaderDrawer
子组件的重构工作类似于HeaderTopNav
子组件。我们设置我们创建的新按钮的导入,UserListButton
;更新props
界面;并添加按钮。其余保持不变。看一看:
// src/layout/Header/HeaderDrawer.tsx
import { UserListButton } from '../../components/UserListButton/UserListButton'
interface IHDProps {
theme: ThemeEnum
isLoggedIn: boolean
<UserListButton isLoggedIn={this.props.isLoggedIn} onClick={this.handleListItemClick} />
页脚组件重构
在我们的Footer
组件工作中,过程类似于我们在Header
组件中所做的。然而,我们不只是阅读偏好状态;我们还让用户改变应用主题的状态。一旦用户使用了onClick
函数,我们就可以使用setPreferences
来设置首选项,所以我们需要重构代码并移除 Redux 工具包。
FooterTheme 主题组件
FooterTheme.tsx
是一个返回FooterComponent
的FunctionComponent
包装器。我们需要包装器的原因是我们正在使用反冲钩子,而且我们只能在纯函数中这样做。
正如我们对Header
组件所做的那样,我们将通过props
将preferencesAtom
对象传递给其他子组件。我们不需要订阅 Redux 工具包商店。看看这些变化:
// src/layout/Footer/FooterTheme.tsx
import { useRecoilState } from 'recoil'
import { preferencesState } from '../../recoil/atoms/preferencesAtoms'
Next, we can set preferences to tie that to our state.
export const FooterTheme: FunctionComponent = () => {
const [preferences] = useRecoilState(preferencesState)
return <FooterComponent theme={preferences.theme} />
}
如你所见,我正在使用FooterTheme
函数中的useRecoilState
反冲函数来检索原子,然后我将它传递给FooterComponent
。
页脚子组件
我们从Footer
主题组件传递了首选项状态,它与反冲共享状态相关联。当我们使用setPreferences
时,状态将被使用该状态的任何组件和子组件全局共享。此外,React 将自动识别何时用任何需要更改的组件和子组件来更新真正的 DOM,我们这边不需要做任何工作。
要做到这一点,我们需要首先引入反冲。
// src/layout/Footer/Footer.tsx
import { useRecoilState } from 'recoil'
接下来,我们现有的NestedGrid
函数可以保存状态并绑定使用反冲的数据。
function NestedGrid() {
const [preferences, setPreferences] = useRecoilState(preferencesState)
...
}
我们需要做的另一个改变是,当用户点击更改首选项时,我们现在可以直接将setPreferences
更改为组件状态,而不是使用 Redux 工具包。这样好很多,也更直观。
const updatePref = () => {
setPreferences({
theme: preferences.theme === ThemeEnum.Light ? ThemeEnum.Dark : ThemeEnum.Light,
})
}
合适的组件
最后,在App
组件中,我们需要将路由代码包装在RecoilRoot
标签中。
什么是反冲根?
RecoilRoot
提供上下文,作为所有使用反冲挂钩的组件的祖先。
悬念标签
在反冲中,我们还需要设置一个后备悬念标签。暂停标签设置将在加载期间显示的组件。
在我们的例子中,我们可以将悬念组件设置为只显示加载消息。看一下代码:
// src/AppRouter.tsx
import React, { Suspense } from 'react'
import { RecoilRoot } from 'recoil'
function AppRouter() {
return (
<Router>
<RecoilRoot>
<Suspense fallback={<span>Loading...</span>}>
<HeaderTheme />
<Switch>
...
</Switch>
<div className="footer">
<FooterTheme />
</div>
</Suspense>
</RecoilRoot>
</Router>
)
}
干得好!现在我们可以正式删除redux
和Preferences
文件夹,因为我们不再使用它们了。
-
src/redux/store.ts
-
src/features/Preferences/preferencesSlice.ts
和往常一样,如果你没有运行应用,使用yarn start
。参见图 6-2 中的最终结果。
图 6-2
重构我们的应用,使用反冲而不是 Redux
继续尝试使用页脚按钮切换主题。
正如你所看到的,反冲的行为和感觉就像是 React 库的扩展,反冲还为我们提供了一个共享的状态管理,而没有麻烦。最重要的是,DOM 只在需要的时候更新。这是一个速度提升。假设我们正在更新一个包含数千个结果的列表,或者维护一个服务器-客户端状态。React 和反冲配合得很好,并分开我们的关注。
现在我们已经重构了我们的应用,并且我们正在使用反冲而不是 Redux 工具包。到目前为止,我们的应用功能很简单;它显示内容页面,并有一个开关来改变用户的主题偏好。
然而,假设我们想增加更多的功能。例如,应用中的一个常见功能是登录到安全的会员专用区域或提交表单,因此我们需要添加更多的逻辑。
至此,我们已经很好地了解了如何将 React 组件分解成子组件,分离关注点,以及如何使用反冲共享状态库。我们已经准备好处理更复杂的功能。
用 MERN 栈创建一个会员专用区
我们现在将学习如何使用 MERN 栈创建一个会员专用区域。
我们如何用 MERN 栈建立一个会员专用区?
为了实现逻辑以便我们能够登录到一个安全的会员专用区域,我将这个过程分为两个主要部分。
-
创建前端
-
创建后端
前端将包括我们的应用的中间件和视图层。后端将包括服务器、服务和数据库。
在下一节中,我们将使用反冲作为中间件,然后在第七章中,我们将集成 Express、NodeJS 和 MongoDB 作为后端。
让我们开始写我们的私人会员区的前端。
前端
您可以从这里下载该练习的完整代码:
https://github.com/Apress/react-and-libraries/06/exercise-6-2
我们将构建两个前端组件。
-
Toast 消息组件
-
登录和会员区组件
toast 消息组件,顾名思义,会像烤面包机一样弹出消息。这些消息可以用来在整个应用中通知用户成功、失败、信息和警告消息,而不仅仅是这个特性。
登录和成员组件将包括一个表单,用户可以提交该表单以验证用户名和密码,并获得对成员专用区域的访问权限。
Toast 消息组件
为了开始我们的 toast 消息组件,我们将从定义数据开始。这是一个良好的开端,因为我们首先设置了将在组件中使用的数据。
每个 toast 消息需要有一个唯一的 ID,这样我们就知道将显示什么。toast 还需要一个消息类型,如success
或failed
,以及我们想要显示的消息的描述。我们将接受四种类型的消息:成功、失败、警告和信息。我们会给每条信息一个不同的风格和图标。
你可以在图 6-3 中看到最终的结果。
图 6-3
测试烤面包机组件
目标模型
一个好的起点是数据。toastObject
数据对象是我们在 preference 数据对象中使用的类似架构设计。我们将包括一个接口和一个方法来启动 toast 消息。我还将包含一个方法来获取一个随机 ID 号,我们可以用它作为每个 toast 消息的唯一 ID。
设置消息类型的枚举是一个好主意,这样我们就不需要不断地键入它们。设置一个不同消息背景颜色的枚举也不会有什么坏处,这样我们就可以从一个地方很容易地改变它们。创建toastObject.ts
并查看代码,如下所示:
// src/model/toastObject.ts
// eslint-disable-next-line @typescript-eslint/naming-convention
export interface toastObject {
id: number
type: string
description: string
}
export const initEmptyToast = (): toastObject => ({
id: -1,
type: '',
description: '',
})
export const initToast = (id: number, type: string, description: string): toastObject => ({
id: id,
type: type,
description: description,
})
// eslint-disable-next-line @typescript-eslint/naming-convention
export const randomToastId = () => {
return Math.floor(Math.random() * 101 + 1)
}
export enum notificationTypesEnums {
Success = 'Success',
Fail = 'Fail',
Info = 'Info',
Warning = 'Warning',
}
// eslint-disable-next-line @typescript-eslint/naming-convention
export enum backgroundColorEnums {
Success = '#5bb85a',
Fail = '#d94948',
Info = '#55bede',
Warning = '#f0a54b',
}
注意,我正在设置initEmptyToast
和initToast
。initEmptyToast
将 toast 消息的 ID 设置为-1,表示 toast 消息未设置。initToast
让我们决定 toast 对象的值。
记住还要将 toast 模型添加到src/model/index.ts
文件中,以便于访问。
// src/model/index.ts
export * from './toastObject'
反冲:烤面包原子
现在我们有了模型数据,我们可以创建 toast 原子(toastAtoms.ts
)。我们需要保存一个惟一的键和默认值,就像我们对 preference 原子所做的那样。
// src/recoil/atoms/toastAtoms.ts
import { atom } from 'recoil'
import { initEmptyToast } from '../../model'
export const toastState = atom({
key: 'ToastState',
default: initEmptyToast(),
})
吐司成分
在组件层次结构设计方面,我们的组件设计将有一个ToastNotification
组件,它将保存我们需要显示的所有祝酒词。当我们分解功能时,这种设计是健康的,并且易于维护。为了帮助理解这个层次,请看图 6-4 。
图 6-4
Toast 组件架构设计
配备了反冲吐司原子和模型,我们可以开始和创建我们的前端组件。我们将从一个Toast
组件开始。这将是我们的通知组件用来显示祝酒词的子组件。它代表了我们将要分发的每一份吐司。
让我们回顾一下Toast
组件。我们的import
语句包括图标、反冲、悬念和通知枚举。
// src/components/Toast/Toast.tsx
import React, { Suspense } from 'react'
import './Toast.scss'
import { useRecoilState } from 'recoil'
import { toastState } from '../../recoil/atoms/toastAtoms'
import checkIcon from '../../assets/toast/check.svg'
import errorIcon from '../../assets/toast/error.svg'
import infoIcon from '../../assets/toast/info.svg'
import warningIcon from '../../assets/toast/warning.svg'
import { backgroundColorEnums, initEmptyToast, notificationTypesEnums } from '../../model'
我们的导入图标可以从 GitHub 的/assets/toast
文件夹下载,或者你可以制作自己的 SVG 自定义图标。
为了在我们的Toast
组件中绑定反冲共享状态,我们将使用useRecoilState
。
export const Toast = () => {
const [toast, setToast] = useRecoilState(toastState)
StyleDetails
界面将保存选中的 toast 图标和背景选中的颜色代码。
interface StyleDetails {
background: string
icon: string
}
接下来,我们将创建一个方法(getToastStyle
),它将保持一个开关来分配一个带有图标和背景颜色的对象。我使用了一个开关,但这也可以用一个对象文字来完成。
const getToastStyle = (toastType: string) => {
let retVal: StyleDetails = {
background: backgroundColorEnums.Success,
icon: checkIcon,
}
switch (toastType) {
case notificationTypesEnums.Fail:
retVal = {
background: backgroundColorEnums.Fail,
icon: errorIcon,
}
break;
case notificationTypesEnums.Warning:
retVal = {
background: backgroundColorEnums.Warning,
icon: warningIcon,
}
break;
case notificationTypesEnums.Info:
retVal = {
background: backgroundColorEnums.Info,
icon: infoIcon,
}
break;
}
return retVal;
}
在渲染 JSX 方面,我们将忽略任何 ID 为-1 的 toast(记住,这是初始值)。这样,我们可以清除屏幕上显示的未设置或需要删除的 toast。
return (
{toast.id !== -1 && (
我使用的是悬念 API,它允许我们在组件加载时显示加载消息。如果您愿意,可以将装载消息更改为 spinner。
<Suspense fallback={<span>Loading</span>}>
<div className="notification-container top-right">
每个 toast 都会被分配一个随机的、唯一的 toast ID,这种设计允许我们删除一个 toast,以防我们希望实现多个 toast 同时显示,或者我们将来可能需要的任何其他功能。
<div
key={toast.id}
className={`notification toast top-right`}
对于背景和图标的样式,我们将使用我们创建的getToastStyle
。我们通过toast.type
得到背景颜色。看一看:
style={{ backgroundColor: getToastStyle(toast.type).background }}
>
一旦用户点击关闭 toast,我们使用我们在模型中创建的initEmptyToast
方法将 toast 的 ID 设置为-1 来清除 toast。这将删除祝酒词。看一看:
<button type="button" onClick={() => setToast(initEmptyToast())}>X</button>
<div className="notification-image">
<img src={getToastStyle(toast.type).icon} alt="" />
</div>
<div>
<p className="notification-title">{toast.type}</p>
<p className="notification-message">{toast.description}</p>
</div>
</div>
</div>
</Suspense>
)}
)
}
烤面包。SCS
至于Toast.scss
,我不会在这里显示完整的代码,但是你可以从 GitHub 的资源库下载。但是我想指出一个特点。
在我们的代码中,我们将 toast 设置在用户屏幕的右上角,并设置了一个动画来将 toast 从右向左移动,但是您可以更改代码来从屏幕的任何角落显示 toast 的动画。看一下处理位置的代码的Toast.scss
部分:
.top-right {
top: 12px;
right: 12px;
transition: transform 0.6s ease-in-out;
animation: toast-in-right 0.7s;
}
@keyframes toast-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
ToastNotification 子组件
现在我们已经准备好了 toast 子组件,我们将创建父组件ToastNotification.tsx
。反冲使用钩子,所以为了能够使用 React 组件而不是纯函数,我们需要将 React 组件包装在纯函数中,并通过props
传递反冲状态。这是怎么做到的?看一看。
我们需要导入 SCSS 文件、反冲状态和值、toast 模型、toast 原子以及Toast
组件。
// src/components/Toast/ToastNotification.tsx
import React from 'react'
import './ToastNotification.scss'
import { SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil'
import { initEmptyToast, toastObject } from '../../model'
import { toastState } from '../../recoil/atoms/toastAtoms'
import { Toast } from './Toast'
我们检索 toast 状态的纯函数以及创建使用getToastState
和useSetRecoilState
API 设置 toast 的方法需要在ToastNotification
包装器中设置。这些将通过props
传递给我们正在包装的ToastNotificationInner
组件。
// wrapper
export default function ToastNotification() {
const setToastState = useSetRecoilState(toastState)
const getToastState: toastObject = useRecoilValue(toastState)
return <ToastNotInner setToastState={setToastState} getToastState={getToastState} />
}
现在我们的ToastNotification
包装已经准备好了。ToastNotificationInner
React 组件实现了setToastState
和getToastState
接口。
interface IToastNotProps {
setToastState: SetterOrUpdater<toastObject>
getToastState: toastObject
}
interface IToastNotState {}
class ToastNotInner extends React.PureComponent<IToastNotProps, IToastNotState> {
我们希望即使用户没有点击关闭 toast,我们的 toast 也会自动消失,所以我们可以用 5000 毫秒(半秒)的计时器在componentDidUpdate
方法上设置一个间隔。
componentDidUpdate()
方法是放置我们代码的好地方,因为它是在最近提交的输出被提交之后被调用的。
Tip
主要区别在于getDerivedStateFromProps
是在调用 render 方法之前被调用的。getSnapshotBeforeUpdate
在提交最近渲染的输出之前运行。componentDidUpdate
穷追不舍。
componentDidUpdate() {
const interval = setInterval(() => {
if (this.props.getToastState.id !== -1) {
this.props.setToastState(initEmptyToast())
}
}, 5000)
return () => {
clearInterval(interval)
}
}
如果你想让面包烤得更久,你可以把时间从半秒增加到一秒(10,000 毫秒)。在 JSX 渲染端,我们返回我们创建的Toast
子组件。
render() {
return (
<>
<Toast />
</>
)
}
}
toastnotification . scss
最后,对于我们的ToastNotification.scss
文件,我们需要设置 toast 按钮设计属性。
.toast-buttons {
display: flex;
}
.toast-buttons button {
color: white;
font-size: 14px;
font-weight: bold;
width: 100px;
height: 50px;
margin: 0 10px;
border: none;
outline: none;
}
.select {
display: flex;
width: 30%;
margin-top: 10px;
}
.position-select {
background-color: inherit;
color: #fff;
width: 100%;
height: 30px;
font-size: 16px;
}
适当的组件重构
既然我们已经创建了 toast 子组件和 notification 父组件,我们可以向我们的AppRouter
组件添加逻辑来包含ToastNotification
。出于两个原因,最好将这段代码放在全局位置。
-
我们需要
RecoilRoot
,因为我们正在使用反冲 API。 -
我们可以在任何页面中使用 toast 组件。
在<RecoilRoot>
标签内添加<ToastNotification>
到AppRouter.tsx
。
<RecoilRoot>
<ToastNotification />
..
..
</RecoilRoot>
我们都准备好了。这种架构设计允许我们的代码在全局级别上接受来自任何组件的 toast 通知。反冲为我们提供了一个共享状态,我们可以随时使用它来分配一个祝酒词。
让我们试驾一下 toast 组件。例如,为了在App.tsx
中发送一个 toast,我们设置 toast 状态,并且我们模仿我们喜欢的任何类型的 toast。当我们刷新页面时,看看吐司是什么样子,如图 6-3 所示。
// src/App.tsx
import { useSetRecoilState } from 'recoil'
import { toastState } from './recoil/atoms/toastAtoms'
import { initToast, notificationTypesEnums, randomToastId } from './model'
function App() {
const setToastState = useSetRecoilState(toastState)
setToastState(initToast(randomToastId(), notificationTypesEnums.Success, 'Hello World'))
...
}
专属会员区
在本章的最后一个练习中,我们将创建我们谈到的受密码保护的会员专用区域。会员区需要一个登录,将有一个电子邮件和密码输入框,用户可以填写,以便他们可以登录到一个专属的会员专用区。
您可以从这里下载该练习的完整代码:
https://github.com/Apress/react-and-libraries/06/exercise-6-3
使用者物件模型
和往常一样,一个好的起点是定义我们的用户对象模型文件(userObject.ts
)。用户对象保存电子邮件和密码以及初始化对象的方法,就像我们在前面添加的模型中所做的一样。
// src/model/userObject.ts
export interface userObject {
email: string
password: string
}
export const initUser = (): userObject => ({
email: '',
password: '',
})
向索引文件添加导出;
// src/model/index.ts
export * from './userObject'
登录反冲逻辑
我们来看看登录逻辑。
用户原子
接下来,正如我们在 preference 和 toast 组件中所做的那样,我们需要设置一个反冲原子。创建userAtoms.ts
。该文件将保存用户状态,默认值设置为模型对象。
// src/recoil/atoms/userAtoms.ts
import { atom } from 'recoil'
import { initUser } from '../../model'
export const userState = atom({
key: 'UserState',
default: initUser(),
})
除了用户原子,我们还需要一个会话原子。如果您还记得在本章前面我们设置首选项时,我们还创建了sessionAtoms.ts
来保存用户密钥,我们可以用它来确保用户被授权登录到我们的安全会员区。
反冲用户选择器
对于用户组件,我们需要一些中间件来进行服务调用。这将通过使用反冲选择器 API 来实现。
反冲选择器共享状态,因此我们所有的 React 组件都可以订阅数据。选择器是纯函数,允许我们同步或异步地转换状态。
在我们的例子中,我们使用服务调用,因此调用将是异步的。看看显示选择器纯函数userSelectors.ts
的代码级别:
// src/recoil/selectors/userSelectors.ts
import { selector } from 'recoil'
import { userState } from '../atoms/userAtoms'
对于服务调用,我将简单地使用一个名为axios
( https://github.com/axios/axios
)的有用库来调用代码。确保将该库添加到项目中($ yarn add axios
)。
import axios from 'axios'
在反冲中,我们可以使用一个选择器或者一个selectorFamily
来传递用户信息。selectorFamily
是一种类似于selector
的模式,但是它允许向选择器的 get 和 set 回调传递参数。因此,您可以修改代码,设计一个selectorFamily
来传递用户电子邮件,而不是将其存储在 atom 中。我将它存储起来,以防其他组件需要知道所选择的用户电子邮件,例如,一个时事通讯组件。
反冲选择器就像反冲原子一样,需要一个唯一的键来设置。我使用带有get
方法的async
来检索userState
。看一看:
export const submitUserLoginSelector = selector({
key: 'SubmitUserLoginSelector',
get: async ({ get }) => {
const payload = get(userState)
我可以在这里设置另一个验证来确保用户填写了表单。
if (payload.email === '' || payload.password === '') return `Error: Please complete form`
接下来,我将我的服务调用包装在try
和catch
标签中,以确保不会产生错误。
try {
const urlWithString = `http://localhost:8081/validate?email=${ payload.email }&password=${ payload.password}`
const res = await axios({
url: urlWithString,
method: 'get',
})
return res?.data?.status
} catch (err) {
// console.warn(err)
return `Error: ${ err}`
}
},
})
Note
我使用的是可选链接(res?.data?.status
)。这让我们可以编写代码,如果遇到空值,TypeScript 可以立即停止运行表达式。这是健康的,有助于在服务调用失败或变得不正常时避免错误。
登录组件
现在我们有了原子和选择器,我们可以在视图层工作了。
我们将从登录组件的表单组件开始。
我们将使用 Material-UI 风格的组件库来加速我们的开发工作。正如我们之前所做的,我们将使用一个包装器组件(LoginForm
)并将我们的LoginPage
包装在一个LoginForm
中。LoginForm
组件将有另一个内部组件来保存实际的表单。看图 6-5 。
图 6-5
登录组件层次结构
在LoginForm
中,我们将使用一个样式文件将样式从视图中分离出来。创建LoginForm.styles.ts
来居中并设置我们的组件为一个列容器。
这种增加的复杂性是不必要的;然而,它帮助我们更好地分离组件,并保持它们的松散耦合,如果我们需要将LoginForm
放在这个应用的其他地方或将其拖到另一个项目,额外的步骤是值得的。
LoginForm.styles.ts
我们的风格决定了我们将要使用的容器类型。它指定了如何将组件中的表单元素作为页面的列和中心进行布局。看一看:
// src/components/Login/LoginForm.styles.ts
import { createStyles, Theme } from '@material-ui/core/styles'
export default (theme: Theme) =>
container: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
},
})
log in form-登入表单
接下来,让我们创建我们的LoginForm
包装登录组件。LoginForm.tsx
将包括来自 Material-UI、我们的模型和登录风格的表单元素。
// src/components/Login/LoginForm.tsx
import * as React from 'react'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import { withStyles, WithStyles } from '@material-ui/core/styles'
import CircularProgress from '@material-ui/core/CircularProgress'
import { userObject } from '../../model'
import styles from './LoginForm.styles'
为了包装LoginForm.styles
,我们将创建一个内部 React 函数组件,并使用withStyle
API 来导出将我们的组件与样式连接起来的组件。
export const LoginForm = withStyles(styles)(LoginFormInner)
我们将要设置的props
包括扩展withStyle
API,从父组件传递onLogin
和onUpdateLoginField
函数,传递用户的状态,最后传递一个标志来显示表单提交后的加载进度。
interface ILoginFormProps extends WithStyles<typeof styles> {
onLogin: () => void
onUpdateLoginField: (name: string, value: string) => void
loginInfo: userObject
loading: boolean
}
我们将使用在props
中设置的onTextFieldChangeHandler
和onUpdateLoginField
将用户更新表单的值传递给父组件。
const LoginFormInner: React.FunctionComponent<ILoginFormProps> = (props: ILoginFormProps) => {
const onTextFieldChangeHandler = (fieldId: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
props.onUpdateLoginField(fieldId, e.target.value)
}
在 JSX 渲染端,我们将设置电子邮件和密码文本字段输入,将onChange
绑定到onTextFieldChangeHandler
,并通过props.loginInfo
将值绑定到状态。看一看。
return (
<div className={props.classes.container}>
<TextField label="email address" margin="normal" value={props.loginInfo.email} onChange={onTextFieldChangeHandler('email')} />
<TextField label="password" type="password" margin="normal" value={props.loginInfo.password} onChange={onTextFieldChangeHandler('password')} />
我们的提交按钮将使用来自props
的回调和加载prop
标志。在提交按钮里面,我们会看到一个加载动画,这要归功于 Material-UI CircularProgress
组件。
<Button variant="contained" color="primary" disabled={props.loading} onClick={props.onLogin}>
Login
{props.loading && <CircularProgress size={30} color="secondary" />}
</Button>
</div>
)
}
太好了,我们的登录表单组件已经可以使用了。
登录页面
我们的LoginPage
组件是父组件,它将包含一个内部组件(LoginPageInner
)来显示表单或处理表单的提交,并显示成功或失败的逻辑。为了更好地理解我在做什么,请看图 6-6 。
图 6-6
登录页面组件和子组件
如您所见,LoginPage
保存了内页包装器(LoginPageInner
)。里面将是一个子组件,将我们的登录表单居中。
InnerPage
要么显示我们的表单,要么使用一个名为SubmitUserFormComponent
的子组件,一旦表单被提交,这个子组件就会被使用,并且根据结果设置状态并显示一条消息。此外,它将启动我们之前创建的 toast 组件。
另外,看一下图 6-7 中的活动图,更好地理解提交登录表单的流程。
图 6-7
LoginFormInner 活动图
中心内容组件
让我们从中心组件开始,它可以帮助我们调整登录表单组件。这个组件可以在其他页面中重用,所以让我们把它放在布局文件夹中重用。
为了清楚起见,我们将把组件从样式中分离出来。我们称之为样式组件Centered.styles.ts
。
// src/layout/Centered/Centered.styles.ts
import { createStyles, Theme } from '@material-ui/core/styles'
export default (theme: Theme) =>
createStyles({
'@global': {
'body, html, #root': {
paddingTop: 40,
width: '100%',
},
},
container: {
maxWidth: '400px',
margin: '0 auto',
},
})
接下来,我们可以创建我们的包装器组件(Centered.tsx
),我们将使用它作为我们的容器类,它将使我们的子组件居中。
// src/layout/Centered/Centered.tsx
import * as React from 'react'
import { withStyles, WithStyles } from '@material-ui/core/styles'
import styles from './Centered.styles'
const CenteredViewInner: React.FunctionComponent<Props> = (props) => <div className={props.classes.container}>{props.children}</div>
interface Props extends WithStyles<typeof styles> {}
export const Centered = withStyles(styles)(CenteredViewInner)
最后,让我们创建一个index.ts
文件来更直观地访问这个组件,这样我们就可以将它用作 JSX 标签,就像这样:<Centered />
。
// src/layout/index.ts
export { Centered } from './Centered'
登录页面组件
对于LoginPage
,我们已经有了这个组件,但是它只是显示页面的名称。开始更新吧。我们的导入需要包括材质-UI 组件、反冲元素、toast 组件和我们的登录表单。
// src/components/Login/LoginPage.tsx
import './LoginPage.scss'
import { Card, CardContent, CardHeader } from '@material-ui/core'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { Centered } from '../../layout/Centered'
import { LoginForm } from '../../components/Login/LoginForm'
import { initToast, initUser, notificationTypesEnums, randomToastId } from '../../model'
import { userState } from '../../recoil/atoms/userAtoms'
import { toastState } from '../../recoil/atoms/toastAtoms'
import { submitUserLoginSelector } from '../../recoil/selectors/userSelectors'
import { sessionState } from '../../recoil/atoms/sessionAtoms'
LoginPage
需要包括LoginPageInner
组件,这是繁重工作发生的地方。
export default LoginPage
const LoginPage = () => {
return <LoginPageInner />
}
LoginPageInner 和子组件
正如我提到的,LoginPageInner
组件设置组件的本地登录页面状态(userLoginPageState
)以及反冲页面状态(user
)。
我们需要本地状态和共享状态的原因是,我们不需要在用户的任何击键更新时更新全局共享状态,而只需要在用户完成表单时更新。我们还将在这里设置加载状态标志,并将其传递给表单,这样我们就可以从父组件控制它,并保持子组件干净。
function LoginPageInner() {
const [userLoginPageState, setUserLoginPageState] = useState(initUser)
const [loading, setLoading] = useState(false)
const [user, setUser] = useRecoilState(userState)
一旦用户点击onLogin
按钮,我们设置表单的加载状态以及反冲用户状态值。
const onLogin = () => {
// console.log(`LoginPage.tsx :: onLogin :: userLoginPageState :: ${JSON.stringify(userLoginPageState)}`)
setLoading(true)
setUser(userLoginPageState)
// eslint-disable-next-line no-console
console.log(JSON.stringify(user))
}
const onUpdateLoginFieldHandler = (name: string, value: string) => {
// console.log(`LoginPage.tsx :: Update name: ${name}, value: ${value}`)
setUserLoginPageState({
...userLoginPageState,
[name]: value,
})
// console.log(`LoginPage.tsx :: onUpdateLoginFieldHandler :: user :: ${JSON.stringify(user)}`)
}
在 JSX 端,我们检查表单是否通过我们的州旗加载。我们使用SubmitUserFormComponent
,在这里我们将根据表单是否被提交来放置进行服务调用或显示表单的逻辑。这个if
/ else
条件逻辑的原因是我们不想显示LoginForm
。如果用户提交表单,并且表单正在被处理,我们可以创建另一个页面组件来导航或者将代码分割成更多的组件。然而,这种设计现在已经足够了,因为显示结果的代码很简单。
return (
<Centered>
{loading ? (
<SubmitUserFormComponent />
) : (
<Card>
<CardHeader title="Members Login" />
<CardContent>
<LoginForm onLogin={onLogin} onUpdateLoginField={onUpdateLoginFieldHandler} loginInfo={userLoginPageState} loading={loading} />
</CardContent>
</Card>
)}
</Centered>
)
}
SubmitUserForm 子组件
最后,我们的SubmitUserFormComponent
是使用反冲选择器的逻辑发生的地方。我们使用useRecoilValue
模式来调用我们的选择器。
function SubmitUserFormComponent() {
const results = useRecoilValue(submitUserLoginSelector)
const setSessionState = useSetRecoilState(sessionState)
const setToastState = useSetRecoilState(toastState)
一旦从选择器中检索到结果,我们需要设置两个方法来处理成功和失败逻辑。在这里,我们可以设置可以在其他组件中使用的烘焙会话状态令牌。在我们的例子中,令牌是硬编码,但在现实生活中,我们需要加密和解密密钥,并根据业务逻辑的要求将其存储在数据库中。
onSuccessLogin Logic
这里是onSuccessLogin
逻辑:
const onSuccessLogin = () => {
localStorage.setItem('accessToken', 'myUniqueToken')
setSessionState('myUniqueToken')
}
注意,我也使用了localStorage
浏览器 API。这样,我可以让用户返回页面,而不需要再次登录,所以我在localStorage
上设置了令牌。
错误逻辑
对于失败逻辑,我们可以调用我们创建的自定义 toast 组件并传递失败消息,例如当我们的服务停止时出现网络错误。
const onFailLogin = () => {
setToastState(initToast(randomToastId(), notificationTypesEnums.Fail, results))
localStorage.removeItem('accessToken')
setSessionState('')
}
然后我们可以使用逻辑来检查从服务器端检索结果的值是成功还是失败。
results === 'success' ? onSuccessLogin() : onFailLogin()
在 JSX 呈现级别,我们根据从服务调用中检索到的结果,返回一条成功消息或一条失败消息。如果需要,这可以扩展为它自己的子组件,而不仅仅是一条文本消息。
return (
{results === 'success' ? Success : We were unable to log you in please try again}
)
}
我们已经为登录组件的视图准备好了所有的逻辑,包括一个 atom 和一个选择器,它充当我们的中间件并进行服务调用。
接下来,我们将创建一个成员组件,它可以检查会话令牌,并在用户没有登录时显示安全成员区域或登录组件。
成员页面
对于我们的会员区,我们将更新会员页面组件,使其显示登录页面或专属会员区主页。看一下图 6-8 中的活动图。
图 6-8
会员区活动图
让我们首先为成员可以登录的安全区域创建一个名为MembersHome.tsx
的主页。目前,该组件只有一个注销按钮,代码被设置为清除本地存储。我还将清空会话状态。这样,一旦会话状态改变,其他组件将自动更新,如图 6-9 所示。
图 6-9
带有注销按钮的会员专属区
会员之家
在代码层面上,MembersHome
组件将首先向您展示导入库。
// src/page/MembersPage/MembersHome.tsx
import Button from '@material-ui/core/Button'
import React from 'react'
import { useSetRecoilState } from 'recoil'
import { sessionState } from '../../recoil/atoms/sessionAtoms'
接下来,我们的 pure 函数利用useSetRecoilState
模式来设置会话状态。我们包含了让用户注销的onClickHandler
逻辑。当我们注销用户时,我们需要清除会话共享原子和localStorage
。
const MembersHome = () => {
const setSessionState = useSetRecoilState(sessionState)
const onClickHandler = (e: React.MouseEvent) => {
e.preventDefault()
localStorage.removeItem('accessToken')
setSessionState('')
}
对于渲染,我们此时只显示一个注销按钮。如果我们将来需要更复杂的视图,这可以扩展到更多的子组件。
return (
<Button type="submit" variant="contained" color="primary" onClick={onClickHandler}>
Logout
</Button>
)
}
export default MembersHome
成员页面
最后,我们需要一个名为MembersPage.tsx
的成员页面的父组件,它将持有一个开关,根据令牌状态显示成员安全区域或登录页面。
// src/page/MembersPage/MembersPage.tsx
import React from 'react'
import './MembersPage.scss'
import { useRecoilValue } from 'recoil'
import LoginPage from '../LoginPage/LoginPage'
import MembersHome from './MembersHome'
import { sessionState } from '../../recoil/atoms/sessionAtoms'
交换机的逻辑检查会话状态是否等于固定值myUniqueToken
。稍后,我们可以创建逻辑来加密和解密会话,甚至将它存储在我们的数据库中。
我正在检查反冲共享状态和localStorage
是否都设置为该值。我们需要这两者,以防用户返回页面或页面被刷新。
const MembersPage = () => {
const isMemberHasAccess = useRecoilValue(sessionState) === 'myUniqueToken' || localStorage.getItem('accessToken') === 'myUniqueToken'
return <MembersPageInner isMemberHasAccess={isMemberHasAccess} />
}
对于显示成员页面的内部组件,我们传递一个名为isMemberHasAccess
的prop
,它可以告诉组件用户是否应该访问该组件,这将被用作显示成员或登录组件的条件。
const MembersPageInner = (props: IMembersPageInnerProps) => (
{props.isMemberHasAccess ? <MembersHome /> : <LoginPage />}
)
interface IMembersPageInnerProps {
isMemberHasAccess: boolean
}
export default MembersPage
我们完成了编码。太棒了。
我将在第九章中介绍 Jest 测试,但是现在删除会员页面的 Jest 测试,因为我们修改了代码,所以我们的测试不会通过:MembersPage.test.tsx
。
记得运行format
、lint
、test
,保证代码质量。
$ yarn format & yarn lint & yarn test
我们终于可以运行我们的代码了,如果你还没有运行它的话($ yarn start
),看看我们的首选项和登录系统的工作逻辑。见图 6-1 。
如果您尝试登录,您将收到一条错误消息“我们无法让您登录,请重试。”这是意料之中的,因为我们还没有建立我们的服务呼叫(图 6-10 )。
图 6-10
表单失败提示和消息
摘要
本章深入研究了编码。您了解了更多关于如何构建函数、类、组件和子组件的知识。您还了解了 MERN 栈。我们在本章一开始就向你介绍了反冲。我们将共享用户偏好状态从 Redux 工具包 更改为反冲,您可以看到这两个工具之间的差异。您还看到了反冲如何轻松地与 React 配合使用,以及与导入的库相比,它开箱即用的感觉如何。
您还了解了如何通过创建一个使用 React 作为 MERN 栈前端的登录系统来构建一个会员专属区域,我们甚至创建了一个自定义的 toast 消息组件来向全球用户显示消息。反冲有许多功能来维护组件生命周期中的状态,甚至是客户端和服务器端之间的状态。我们只是带着后座力接触了表面。我鼓励你访问反冲网站( https://recoiljs.org/
),了解更多的可能性。现在我们的前端已经准备好了,在下一章中,您将学习如何使用 Node.js、MongoDB 和 Express 设置后端。
七、MERN 栈:第二部分
在前一章中,您了解了如何构建 React 函数、类、组件和子组件。你被介绍给了 MERN 栈。您了解了反冲以及如何通过创建一个以 React 作为 MERN 栈前端的登录系统来构建一个会员专属区域。我们甚至创建了一个定制的前端 toast 消息组件来显示消息。
在本章中,我们将从上一章停止的地方开始,用 Node.js、Express 和 MongoDB——MERN 栈的其他部分——创建我们的后端。在这一章中,你将看到完整的开发周期,从编写前端代码到完成后端,这样你就有了一个可以工作的应用。这个应用不仅能够包括组件,而且能够登录到一个安全的会员专用区域并与数据库交互的实际功能。它甚至提供了连接到套接字以接收实时消息的基础。
在本章的第二部分,我们将添加一个注册组件,并通过加密和解密用户密码以及更新登录选择器来完成登录周期,这样我们就可以实际注册一个用户并登录到我们的专属会员区。
我们将会建造什么
在我们应用的这一点上,如果我们试图实际登录到我们在之前的练习中构建的会员专属区域,我们将会得到一个网络错误,如图 7-1 所示。
图 7-1
尝试登录我们的应用
那不是错误。在这一点上,这是正确的行为。我们收到这条消息是因为我们还没有设置后端服务。在本章中,我们将设置我们的后端逻辑,让登录系统工作,并通过添加一个注册组件和后端 API 来完成这个循环。
您可以从这里下载我们将创建的完整后端代码:
https://github.com/Apress/react-and-libraries/07/exercise-7-1
为什么是 Node.js?
Node.js 对你来说应该不陌生。事实上,我们已经安装了 Node.js,并且从第一章开始就一直使用 Node.js 包管理器 NPM。我们一直在安装和使用 NPM 的库。
Note
Node.js 是基于 Google 's V8 JS 引擎的单线程、非阻塞框架。
Node.js 使代码执行速度超快。Node.js 是一种非阻塞、事件驱动的输入/输出方法,允许大量快速并发连接。这是通过 JavaScript 事件循环完成的。
当我说很多时,我们可以创建多少个并发连接?这实际上取决于我们运行的服务器以及我们运行的服务器的内部配置设置。
事实上,Node.js 可以在一个标准的 Ubuntu 服务器上同时运行 1000 个 Node.js 的并发连接,而不会冻结 CPU,甚至可以通过 Posix ( https://github.com/ohmu/node-posix
)等库来增加。对于中小型应用来说,这通常就足够了,无需设置复杂的服务器栈、负载平衡器等。
事实上,你知道 Node.js 正在被 PayPal、LinkedIn 和 Yahoo 等公司使用吗?
为什么要快递?
我们可以使用 Node.js HTTP 模块,从头开始编写我们的 web 服务器,那么为什么我们需要在 Node.js 之上使用 Express 呢?
Express 构建在 Node.js 之上,利用 Node.js HTTP/HTTPS 模块。有许多框架都是基于 Node.js 构建的。Express 是最古老、使用最广泛的 Node.js 框架之一,因为它提供了关注点的分离。Express 被 Myspace、Twitter、Stack 和 Accenture 等公司使用。
Note
Node.js 是一种快速的低级输入/输出机制,内置了 HTTP/HTTPS 模块。Express 是一个 web 应用框架,位于 Node.js 之上。Express 是轻量级的,有助于组织您的服务器端 web 应用。
为什么应该使用 MongoDB?
MongoDB 是一个面向文档的 NoSQL 数据库,用于大容量数据存储。MongoDB 提供了集合和文档,而不是使用传统的关系数据库将数据存储在表和行中。文档由键值对组成。这里有一个有趣的事实:据报道,超过 3000 家公司在他们的技术栈中使用 MongoDB,包括优步、Lyft 和送货服务 Hero。
构建后端
对于后端服务,我们将使用 Express 通过我创建的名为rooms.js
( https://github.com/EliEladElrom/roomsjs
)的库与 MongoDB 进行交互。
旨在加速我们的开发工作。rooms.js
提供了一种发送和接收消息并切换到不同传输器的方式,以创建房间并在用户之间传输数据,从数据库传输数据,甚至从第三方 CDN 传输数据。
在这个过程的最后,我们将能够调用我们将在本地主机上创建的服务文件。
http://localhost:8081/validate?email=youremail@gmail.com&password=isDebug
在下一章中,我们将把项目中的前端和后端发布到远程 Ubuntu 服务器上,这样我们就可以看到应用正在实时运行。
在本章中,我将这个过程分解为以下编码步骤:
-
数据库模式
-
验证服务 API
-
快速框架
-
MongoDB
您可以从这里下载后端代码的完整代码:
https://github.com/Apress/react-and-libraries/07/exercise-7-1
数据库模式
首先,如果你还记得我们上一章学习反冲的时候,我们的第一步是建立一个模型,然后是原子。我们的后端项目的第一步是相似的:在后端建立数据模型。继续创建一个database.js
文件,我们将使用它来为我们的用户设置数据库模式。当我们稍后需要与 MongoDB 数据库进行交互时,这个模式将会派上用场。我们的用户对象有一个类型为String
的电子邮件和密码。我们的数据库将包括其他变量,这些变量在我们想要注册用户时会很方便,比如加密哈希和 salt(本章后面会详细介绍加密)、用户上次登录的时间、注册日期、登录令牌、电话号码、用户名以及用户尝试登录但失败的次数。看一看:
// models/database.js
let usersSchema = {
username: 'String',
email: 'String',
passwordHash: 'String',
passwordSalt: 'String',
lastLoginDate: 'String',
attempt: 'Number',
signDate: 'String',
emailEachLogin: 'Boolean',
loginToken: 'String',
phone: 'String'
};
if (typeof exports != 'undefined' ) {
exports.usersSchema = usersSchema;
}
验证服务 API
接下来,我们需要创建一个服务 API 来验证我们的用户。它需要输入用户数据,并输出成功或失败的身份验证响应。我在这里没有展示注册服务 API 来设置用户注册,但是为了安全起见,登录系统通常需要包括加密和解密。部分代码已经实现,在本章的第二部分会派上用场。
此时,我允许用户通过内置的密码isDebug
绕过所有的加密和解密逻辑。我还建立了一个机制来检查用户尝试登录系统的次数,这样我们就可以阻止用户,以防黑客在我们的登录系统上启动攻击机器人来试图破解用户密码。安全应该永远是你的第一要务。
为了开始使用我们的服务 API,我们将创建一个服务文件,并将其命名为validate.js
。看一下代码:
// src/services/validate.js
'use strict';
在文件的顶部,使用use strict
来指示代码应该在严格模式下执行是一个很好的做法,这意味着我们不能,例如,使用未声明的变量。
接下来,我们将使用在上一步中为用户创建的数据库模式,并定义我们将使用的库和变量。
let usersSchema = require("../models/database").usersSchema,
logger = require('../utils/log.js').logger,
moment = require("moment"),
CryptoJS = require('crypto-js'),
async = require('async'),
connector,
users,
isUserExists = false,
params,
user;
我们的主函数validate
将从我们的服务器文件中获取数据,并连接到我们设置的数据库。
function validate(data, dbconnectorCallBackToRooms) {
connector = this.getConnector();
params = data.query || data.params;
params.member_id = -1;
我们将使用异步系列逻辑。Node.js 是基于事件的循环,我们无法停止 node . js;然而,在某些情况下,我们需要构建基于异步调用的逻辑,有时每个操作都依赖于之前的一个或多个操作。
这可以通过使用async.series
库( https://github.com/hughsk/async-series
)来完成。该库允许一个或多个操作一个接一个地执行。
在我们的例子中,我们只做了一个checkUserInfo
操作,但是在未来的迭代中,我们可能会扩展这个操作并包含其他操作,比如向用户发送一封电子邮件,告诉他们已经登录到我们的系统或者其他需要的逻辑。
let operations = ;
operations.push(checkUserInfo);
async.series(operations, function (err, results) {
let retData = {
"exist_member_id": params.member_id,
"isUserExists": isUserExists,
"user": user
};
users = null;
user = null;
isUserExists = false;
params = null;
一旦异步操作的结果传入,我们就可以将它们传递回输出端进行显示。
if (err) {
dbconnectorCallBackToRooms(data, {status: 'error', error_message: JSON.stringify(err), params: []});
} else {
dbconnectorCallBackToRooms(data, {status: 'success', params: retData});
}
});
}
对于checkUserInfo
操作,我们需要根据 MongoDB 数据库验证用户。
function checkUserInfo(callback) {
logger.info('validate.js :: checkUserInfo');
if (connector.isModelExists('users')) {
users = connector.getModel('users');
} else {
let schema = connector.setSchema(usersSchema);
users = connector.setModel('users', schema);
}
let findObject = {
email: (params.email).toLowerCase()
};
使用 Mongoose 库对 MongoDB 文档进行排序,搜索任何包含我们定义的对象的文档。在我们的例子中,这是用户的电子邮件地址。
users.find(findObject)
.then((doc) => {
if (doc.length > 0) {
user = doc[0]._doc;
params.member_id = (user._id).toString();
一旦我们有了结果,我们就可以使用Hash
和Salt
来解密用户信息。
let passwordParam = (params.password).toString(),
password = user.passwordHash,
salt = user.passwordSalt,
attempt = user.attempt,
lastLoginDate = user.lastLoginDate;
let databaseTime = moment(lastLoginDate),
now = moment().format(),
diff = moment(now).diff(databaseTime, 'minutes');
如果有三次尝试失败,我们会将用户锁定 60 秒。
// don't even attempt to login - 3 attempts
if (diff < 60 && attempt >= 3) {
callback('three_attempts_wait_one_hour', null);
} else {
let decryptedDatabasePassword = (CryptoJS.AES.decrypt(password, salt)).toString(CryptoJS.enc.Utf8),
decryptedURLParam = (CryptoJS.AES.decrypt(passwordParam, "SomeWordPhrase")).toString(CryptoJS.enc.Utf8),
loginSuccess = (decryptedDatabasePassword === decryptedURLParam && decryptedDatabasePassword !== ''),
new_attempt_count = (loginSuccess) ? 0 : attempt + 1;
现在,我在这里重写了整个加密和解密逻辑,所以我们可以使用密码isDebug,
来测试服务,但是稍后我将扩展我们如何加密和解密。这是标准协议。我把它分成两步的原因是,首先我们只想看到我们的系统工作。然后我们实现安全性,这是确保我们的代码正常工作的良好实践。
if (params.password === 'isDebug’) {
loginSuccess = true;
}
users.updateOne({"email": params.email}, {
$set: {
attempt: new_attempt_count,
lastLoginDate: now
}
}).then((doc) => {
if (loginSuccess) {
isUserExists = true;
callback(null, null);
} else {
isUserExists = false;
callback('No login success', null);
}
}).catch((err) => {
logger.info(err);
callback(err.message, null);
});
}
} else {
callback('no user found', null);
}
})
.catch((err) => {
logger.info(err);
callback(err.message, null);
});
}
module.exports.validate = validate;
快速框架
既然我们已经准备好了验证服务 API,我们希望能够访问逻辑、发送数据并显示结果。
为此,我们将使用 Express,借助于我自己的库roomsjs
( https://github.com/EliEladElrom/roomsjs
)。该库通过 SSL 或 HTTP 简化了服务的创建,并且该库可以连接到任何数据库(包括 MongoDB)。我们将设置 MongoDB 使用默认设置,但是我们可以很容易地将数据库更改为使用 MySQL 或任何其他数据源。
在 Node.js 中,我们可以使用一个server.js
文件来设置我们的数据库和服务库,从而利用 Express 服务器。我们首先导入我们需要的库。
// server.js
let os = require('os'),
rooms = require('roomsjs'),
roomdb = require('rooms.db'),
bodyParser = require('body-parser'),
port = (process.env.PORT || 8081),
logger = require('./utils/log.js').logger,
log = require('./utils/log.js'),
isLocalHost = log.isLocalHost();
接下来,我们创建 Express 服务器。
let express = require('express'),
app = express().use(express.static(__dirname + '/public'));
我们需要允许跨域的逻辑。
// will overcome the No Access-Control-Allow-Origin header error
let allowCrossDomain = function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
};
app.use(allowCrossDomain);
Parse any ‘url’ encoded ‘params’;
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
现在,我们只需要 HTTP 模块,因为我们将在本地机器上运行这个逻辑,但是在下一章,当我们发布到产品时,我们将为 HTTPS 添加逻辑。
// create server
let server = require('http').createServer(app).listen(port, function () {
logger.info('Listening on http://' + os.hostname() + ':' + port);
});
roomdb
( https://github.com/EliEladElrom/roomsdb
)是roomjs
的伴库。它设置一个到任何数据库的连接器,并遍历我们设置的任何服务文件。
// services
roomdb.setServices('services/', app, 'get');
logger.info('hostname: ' + os.hostname());
let roomsSettingsJSON;
logger.info('*** Listening *** :: ' + os.hostname() + ', localhost: ' + isLocalHost);
roomsSettingsJSON = (isLocalHost) ? require('./roomsdb-local.json') : require('./roomsdb.json');
roomdb.connectToDatabase('mongodb', 'mongodb://' + roomsSettingsJSON.environment.host + '/YourSite', { useNewUrlParser: true, useUnifiedTopology: true });
该库使用套接字设置了一个传送器。在这一点上,我们不需要套接字,HTTP 在我们的逻辑中已经足够了,但是有它也很好。如果我们想创建一个需要实时消息的应用,例如聊天应用,可以使用套接字。
// set rooms
rooms = new rooms({
isdebug : true,
transporter : {
type: 'engine.io',
server : server
},
roomdb : roomdb
});
这超出了本章的范围,但是我想让你知道什么是可能的。
最后,我们希望捕捉任何uncaughtException
并显示它们,这样我们就可以解决代码中的任何问题。
process.on('uncaughtException', function (err) {
logger.error('uncaughtException: ' + err);
});
本地 MongoDB
难题的最后一部分是在我们的本地机器上设置 MongoDB。在下一章中,我们将在远程服务器上创建我们的 MongoDB 并发布我们的代码,但是现在我们首先需要一个工作的本地版本。
我们可以在 Mac 上使用以下命令创建一个本地 MongoDB。首先,我们可以用自制软件安装 MongoDB。
Note
家酿是开源软件包管理,以简化 macOS 和 Linux 上的软件安装。brew
是家酿的核心命令。
先用 Ruby 安装 Brew,然后更新。
$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
$ brew doctor
$ brew update
接下来,使用brew
命令安装 MongoDB,并设置我们正在存储的 MongoDB 数据的位置和权限。
$ brew install mongodb
$ sudo mkdir /data
$ sudo mkdir /data/db
$ sudo chown -R `id -un` /data/db
$ cd /Locally run mongodb
就这样。我们已经安装了 MongoDB,可以随时使用了。
现在,我们可以运行 Mongod 和 Mongo;见图 7-2 和图 7-3 。
图 7-2
运行在 Mac 终端的 Mongod 进程
$ mongod
$ mongo
图 7-3
mongo shell 命令
为什么是两个命令?这些命令是什么意思?
Mongod 将启动 MongoDB 进程,并在后台运行它。Mongo 会给我们一个命令行 shell,它连接到我们正在运行的 Mongod 进程。在 Mongo shell 中,我们可以执行命令。
这取决于您安装的 MongoDB 版本。一些 MongoDB 版本在后台作为服务运行,终端不需要保持打开。
Note
mongod(“Mongo 守护进程”)是 MongoDB 的守护进程。Mongod 处理 MongoDB 数据请求,管理数据访问,并运行管理操作。mongo
是连接 Mongod 的命令行 shell。
现在,在 Mongo shell 终端窗口中,我们需要创建我们的数据库名称。
$ use YourDatabaseName
然后我们插入我们的用户。
db.users.insert({"username": "user", "email":"youremail@gmail.com", password:"123456", passwordHash: "someHash", passwordSalt: "someSalt", attempt: 0, lastLoginDate: "2020-01-15T12:10:00+00:00", "signDate":"2020-01-15T11:50:12+00:00" })
您可以使用以下命令来确认这一点:
db.users.find();
该命令将返回我们插入的用户。虽然您可以在 Mongo shell 中键入所有命令,但是有许多图形用户界面(GUI)可以帮助管理 MongoDB 数据库。我推荐的两个可以提供帮助的好 GUI 是 Compass ( https://www.mongodb.com/try/download/compass
)和 nosqlbooster ( https://nosqlbooster.com/downloads
)。
这些工具可以帮助备份、导出、导入和运行命令。参见 MongoDB 团队提供的 MongoDB Compass 下载页面,如图 7-4 。
图 7-4
MongoDB Compass GUI 下载页面
有许多工具,付费的和免费的,所以请随意做你自己的研究(DYOR)。每个工具根据订阅提供不同的功能集,并支持不同的平台。我不是在推荐任何工具,你也不必使用任何 GUI。Mongo 命令行 shell 可以满足您的所有需求。
要连接和设置这些 GUI 工具,过程是相同的:我们在本地或远程机器上连接到 MongoDB。由于我们没有更改默认的 MongoDB,所以 MongoDB 的端口应该是 27017。
GUI Client > local localhost:27017 > connect
许多 GUI 也支持在 URI 中粘贴。没有适当安全性的本地主机的 URI 将如下所示:
uri:mongob://localhost:27017
Note
URI
值表示创建 Mongo 实例的统一资源标识符(URI)。URI 描述了主机和选项。
现在我们已经设置了 MongoDB 数据库,创建了服务 API,并且有了利用 Express 的 Node.js 服务器文件,我们已经准备好运行后端服务了。在一个单独的窗口中,调用:
$ node server.js
如果一切顺利,您将在终端中得到以下输出:
{"message":"Listening on http://Computer-name-or-ip:8081","level":"info"} type: master, masterPeerId: 548e09f70356a1237594fbe489e33684, channel: roomsjs, port: 56622
这意味着服务文件遍历我们的服务文件,并为我们建立一个套接字,以备将来需要。现在,如果您使用以下代码测试服务:
http://localhost:8081/validate?email=youremail@gmail.com&password=isDebug
您将在浏览器中获得以下结果:
{"status":"success","params":{"exist_member_id":"5f58278c81cb4a742188d3cb","isUserExists":true,"user":{"_id":"5f58278c81cb4a742188d3cb","user": "user", "email":"YouEmail@gmail.com","password":"123456","passwordHash":"someHash","passwordSalt":"someSalt","attempt":0,"lastLoginDate":"2020-01-15T12:10:00+00:00","signDate":"2020-01-15T11:50:12+00:00"}}}
接下来,如果您检查您的前端代码,使用isDebug
密码和您设置的电子邮件地址,您可以再次运行您的应用($yarn start
)。你会发现我们可以成功登录我们的安全会员区。图 7-5 显示了会员安全区域,现在只有我们在上一章创建的注销按钮。
图 7-5
用户成功登录后保护会员区
现在让我们打开浏览器开发人员控制台。例如,在 Chrome DevTools 中,从顶部菜单选择查看➤开发者➤开发者工具。
我们可以看到我们的应用在我们的本地存储中创建了accessToken
,使用我们设置的值,如图 7-6 所示。我们的应用使用这些值来确定用户是否可以访问安全区域。
图 7-6
Chrome 开发者工具本地存储值
设置 MongoDB 身份验证
我们已经在本地机器上设置了 MongoDB,所以下一步是创建 MongoDB 身份验证。没有身份验证,任何未经授权的用户都可以连接到您的数据库,为所欲为。
要进行设置,在终端中,以管理员身份连接到 Mongo shell 终端(确保 Mongod 正在运行)。一旦我们连接到数据库,我们就可以创建将要使用的用户,并设置读和写的角色。以下是命令:
$ mongo admin
$ use MyDatabase
switched to db MyDatabase
$ db.createUser({ user: "myuser", pwd: "YOUR_PASSWORD", roles: ["readWrite"] })
Successfully added user: { "user" : "myuser", "roles" : [ "readWrite" ] }
对于健全性检查,我们可以运行getUsers
命令来确保我们的用户被正确添加。
$ db.getUsers()
[
{
"_id" : "MyDatabase.myuser",
"user" : "myuser",
"db" : "MyDatabase",
"roles" : [
{
"role" : "readWrite",
"db" : "MyDatabase"
}
],
"mechanisms" : [
"SCRAM-SHA-1",
"SCRAM-SHA-256"
]
}
]
接下来,断开与 Mongo shell 的连接(Cmd+C)。
我们的用户有一个安全密码。接下来,我们可以在 Mongod 配置文件中启用身份验证。启用安全性,如果还没有启用的话。
$ vim /usr/local/etc/mongod.conf
security: authorization: enabled
太好了。现在用用户名和密码连接到 MongoDB。
$ mongo MyDatabase -u myuser -p YOUR_PASSWORD
太棒了。现在我们的数据库有密码保护。在 Mongo shell 或 GUI 中,如果你试图在没有凭证的情况下连接,你会得到一个错误,如图 7-7 所示。
图 7-7
MongoDB Compass 上的验证错误
为了解决这个认证错误,我们需要使用我们的凭证进行连接,如图 7-8 所示。
图 7-8
在 MongoDB Compass 中设置身份验证
最后一部分是设置我们的代码使用身份验证进行连接。如果你看一下server.js
的代码级别,我们正在连接roomdb
,它使用 Mongoose 库( https://github.com/Automattic/mongoose
)进行连接。
roomdb.connectToDatabase('mongodb', 'mongodb://' + roomsSettingsJSON.environment.host + '/YourSite', { useNewUrlParser: true, useUnifiedTopology: true });
代码被设置为使用本地机器上的本地文件(roomsdb-local.json
)和远程机器上的roomsdb.json
(我们将在下一章中设置)。
如果我们在我们的roomsdb-local.json
文件中设置新的认证信息,我们会得到:
{
"name": "db-config",
"version": "1.0",
"environment": {
"host":"localhost",
"user":"myuser",
"password":"YOUR_PASSWORD",
"dsn": "YourSite"
}
}
我们现在可以按照以下语法重构连接:
mongoose.connect('mongodb://username:password@host:port/database')
看一看:
let db_host = 'mongodb://' + roomsSettingsJSON.environment.user + ':' + roomsSettingsJSON.environment.password + '@' + roomsSettingsJSON.environment.host + '/' + roomsSettingsJSON.environment.dsn;
roomdb.connectToDatabase('mongodb', db_host, { useNewUrlParser: true, useUnifiedTopology: true });
来吧,试一试:
$ node server.js
这个设计为我们的生产做好了准备,因为我们有两个用于开发和生产的文件(roomsdb-local.json
和roomsdb.json
),它们保存数据库信息和基于代码运行位置的代码切换。
完全登录注册系统
到目前为止,我们已经完成了登录组件从前端到后端的一个完整周期,但是我们仍然没有完成代码。我们的代码没有读写我们的 MongoDB。
原因是在现实生活中,我们需要对用户的密码进行加密和解密,而不是仅仅传入烤入的数据。为此,我们需要添加一些注册逻辑,它可以获取用户的密码字符串,加密该字符串,然后将其存储在我们的数据库中。当用户想要登录时,我们希望解密用户的密码,并将其与用户在登录输入框中提供的密码进行匹配。所有这些都是安全登录注册系统的常规安全协议。
在本章的这一部分,我们将这样做。我们将创建一个加密用户密码的注册组件和一个将数据写入 MongoDB 的服务 API。最后,我们将重构反冲登录选择器,在发送密码之前对其进行加密,这样我们就可以测试读取用户的密码并比较结果。
您可以从这里下载我们将要编写的完整前端代码:
https://github.com/Apress/react-and-libraries/07/exercise-7-2
我们开始吧。
注册模型
我们可以从模型对象开始,采用与登录组件相同的方式。用我们想要捕获的寄存器信息创建一个registerObject.ts
。
在我们的例子中,我们的表单将捕获用户名、电子邮件和密码,并确保在重复输入密码的情况下正确插入密码。这可以扩展到您想要捕捉的任何其他信息。我们还将设置initRegister
方法来设置我们的默认值。
export interface registerObject {
username: string
email: string
password: string
repeat_password: string
}
export const initRegister = (): registerObject => ({
username: '',
email: '',
password: '',
repeat_password: '',
})
记住还要将寄存器模型添加到src/model/index.ts
文件中,以便于访问。
// src/model/index.ts
export * from './registerObject'
注册原子
接下来是设置我们的反冲原子。创建一个新文件,并将其命名为registerAtoms.ts
。该文件将使用initRegister
来设置默认值。
// src/recoil/atoms/regsiterAtoms.ts
import { atom } from 'recoil'
import { initRegister } from '../../model'
export const registerState = atom({
key: 'RegisterState',
default: initRegister(),
})
现在我们已经准备好了 atom,我们可以继续创建我们的寄存器选择器。
寄存器选择器
为了加密和解密用户的密码,我们将使用一个名为crypto-js
( https://github.com/brix/crypto-js
)的库。这是一个包含加密标准的 JavaScript 库。
我们将需要安装的库和类型。
$ yarn add crypto-js @types/crypto-js
我们的registerSelectors.ts
将类似于我们的登录选择器。
// src/recoil/selectors/registerSelectors.ts
import { selector } from 'recoil'
import axios from 'axios'
import { registerState } from '../atoms/registerAtoms'
import * as CryptoJS from 'crypto-js'
export const registerUserSelector = selector({
key: 'RegisterUserSelector',
get: async ({ get }) => {
const payload = get(registerState)
if (
payload.email === '' ||
payload.password === '' ||
payload.repeat_password === '' ||
payload.username === '' ||
payload.repeat_password !== payload.password
) {
// eslint-disable-next-line no-console
console.log(
'registerSelectors.ts :: registerUserSelector :: ERROR incomplete form :: ' +
JSON.stringify(payload)
)
return 'Error: Please complete form'
}
try {
// console.log('registerSelectors.ts :: registerUserSelector :: start encrypt')
但是我们会添加加密。为了加密,我们可以创建一个我们可以决定的秘密密码短语,然后使用CryptoJS.AES.encrypt
方法来加密我们的密码。看一看:
const secretPassphrase = 'mySecretPassphrase'
const passwordEncrypt = CryptoJS.AES.encrypt(payload.password, secretPassphrase)
const passwordEncryptEncodeURI = encodeURIComponent(passwordEncrypt.toString())
// console.log('passwordEncryptEncodeURI: ' + passwordEncryptEncodeURI)
此外,我们可以为发布产品代码做准备,这将在下一章中进行。CRA 接受添加环境变量( https://create-react-app.dev/docs/adding-custom-environment-variables/
)。事实上,process.env.NODE_ENV
已经和development
或production
一起被植入我们的应用中。我们可以用它来设置我们的服务 API URL,我们可以在我们的代码中设置它。
const host = process.env.NODE_ENV === 'development' ? 'http://localhost:8081' : ''
// console.log(
`userSelectors.ts :: submitUserLoginSelector :: process.env.NODE_ENV: ${process.env.NODE_ENV}`
)
const urlWithString =
host +
'/register?name=' +
payload.username.toLowerCase() +
'&email=' +
payload.email.toLowerCase() +
'&password=' +
passwordEncryptEncodeURI
// eslint-disable-next-line no-console
console.log('registerSelectors.ts :: registerUserSelector :: url: ' + urlWithString)
const res = await axios({
url: urlWithString,
method: 'get',
})
// const status = `${res.data.status}`
// console.log(`userSelectors.ts :: registerUserSelector :: results: ${JSON.stringify(status)}`)
return res?.data?.status
} catch (err) {
// console.warn(err)
return `Error: ${err}`
}
},
})
我们的寄存器选择器已经完成,可以使用了。然而,在我们继续构建我们的视图表示层之前,为了安全起见,我们还可以调整登录选择器来发送密码的加密字符串。让我们来看看。
重构登录
在这一节中,我们将重构我们的登录,这样它将调整我们的userSelectors.ts
逻辑,以便发送我们用户密码的加密版本。
通过传递用户名和密码,我们得到了:
const urlWithString = `http://localhost:8081/validate?email=${payload.email}&password=${payload.password}`
要在crypto-js
库(yarn add crypto-js
)的帮助下加密用户名和密码,请使用:
// src/recoil/selectors/userSelectors.ts
import * as CryptoJS from 'crypto-js'
const secretPassphrase = 'mySecretPassphrase'
const passwordEncrypt = CryptoJS.AES.encrypt(payload.password, secretPassphrase)
const passwordEncryptEncodeURI = encodeURIComponent(passwordEncrypt.toString())
// console.log('passwordEncryptEncodeURI: ' + passwordEncryptEncodeURI)
const urlWithString = `${host}/validate?email=${payload.email}&password=${passwordEncryptEncodeURI}`
太好了!现在,我们的登录和注册选择器已经准备好将用户的加密密码传递给我们的服务 API。
在下一章中,我们将把我们的应用发布到产品中,并设置在 SSL 服务器上,这样数据不仅被加密,还能防止黑客窃取我们的用户信息。正如您所记得的,我们还放置了一些逻辑来检查用户尝试登录的次数,以获得额外的安全性。
注册视图层
对于注册视图层,我们将使用与登录视图层相同的方法。我们将创建以下内容:
-
RegisterForm.tsx
和RegisterForm.styles.ts
-
RegisterPage.tsx
,包括子组件RegisterPageInner
、SubmitUserFormComponent
和onFailRegister
看看寄存器组件的层次结构,如图 7-9 所示。
图 7-9
注册视图层组件层次线框
在RegisterForm.tsx
中,我们的RegisterPage
将包装RegisterPageInner
纯子组件,以便 React 挂钩工作。一旦用户提交了 atom,表单就会被更新,并且这些变化会反映在SubmitUserFormComponent
中,就像我们对 login 组件所做的那样。onSuccessRegister
和onFailRegister
方法处理成功和失败的登录尝试。这与我们在前一章中用于Login
视图层的过程相同。参见图 7-10 ,该图显示了该流程的活动流程图。
图 7-10
注册活动流程图
登记表
我们的注册表单是保存注册表单元素的子组件。我们将从显示导入的库开始。
// src/components/Register/RegisterForm.tsx
import * as React from 'react'
import TextField from '@material-ui/core/TextField'
import Button from '@material-ui/core/Button'
import { withStyles, WithStyles } from '@material-ui/core/styles'
import CircularProgress from '@material-ui/core/CircularProgress'
import styles from './RegisterForm.styles'
import { registerObject } from '../../model/registerObject'
RegisterFormInner
是我们包装的 React 函数组件,我们将使用它来传递样式和props
。
const RegisterFormInner: React.FunctionComponent<IRegisterFormProps> = (
props: IRegisterFormProps
) => {
const onTextFieldChangeHandler = (fieldId: any) => (e: any) => {
props.onUpdateRegisterField(fieldId, e.target.value)
}
为了简单起见,代码采用了与我们在前一章中创建的Login
表单组件相同的方法。我们将对onUpdateRegisterField
和onRegister
方法的更改传递给父组件。
return (
<div className={props.classes.container}>
<TextField
label="username"
margin="normal"
value={props.registerInfo.username}
onChange={onTextFieldChangeHandler('username')}
/>
<TextField
label="email address"
margin="normal"
value={props.registerInfo.email}
onChange={onTextFieldChangeHandler('email')}
/>
<TextField
label="password"
type="password"
margin="normal"
value={props.registerInfo.password}
onChange={onTextFieldChangeHandler('password')}
/>
<TextField
label="repeat password"
type="password"
margin="normal"
value={props.registerInfo.repeat_password}
onChange={onTextFieldChangeHandler('repeat_password')}
/>
<Button
variant="contained"
color="primary"
disabled={props.loading}
onClick={props.onRegister}
>
Register
{props.loading && <CircularProgress size={30} color="secondary" />}
</Button>
</div>
)
}
我们的接口可以绑定到我们的registerObject
来设置默认值。
interface IRegisterFormProps extends WithStyles<typeof styles> {
onRegister: () => void
onUpdateRegisterField: (name: string, value: any) => void
registerInfo: registerObject
loading: boolean
}
最后,我们的Form
子组件绑定了样式对象。
export const RegisterForm = withStyles(styles)(RegisterFormInner)
注册表单样式
在我们的RegisterForm.styles.ts
文件中,我们设置了容器样式,以 flex 列样式对齐我们的容器。
// src/components/Register/RegisterForm.styles.ts
import { createStyles, Theme } from '@material-ui/core/styles'
export default (theme: Theme) =>
createStyles({
container: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
},
})
现在我们已经有了带有样式集的Form
子组件,我们可以创建我们的父组件,也就是注册页面。
注册页面
我们还没有创建RegisterPage
组件。您可以使用generate-react-cli
创建注册页面。
$ npx generate-react-cli component RegisterPage --type=page
在我们的RegisterPage.tsx
组件中,让我们更新代码。首先设置import
报表。
src/pages/RegisterPage/RegisterPage.tsx
import React, { useState } from 'react'
import { Card, CardContent, CardHeader } from '@material-ui/core'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { Centered } from '../../layout/Centered'
import { RegisterForm } from '../../components/Register/RegisterForm'
import { initToast, notificationTypesEnums, randomToastId } from '../../model'
import { registerState } from '../../recoil/atoms/registerAtoms'
import { toastState } from '../../recoil/atoms/toastAtoms'
import { registerUserSelector } from '../../recoil/selectors/registerSelectors'
import { sessionState } from '../../recoil/atoms/sessionAtoms'
import { initRegister } from '../../model/registerObject'
接下来,我们设置我们的RegisterPage
来包装 React 挂钩的RegisterPageInner
子组件。
const RegisterPage = () => {
return <RegisterPageInner />
}
在RegisterPageInner
中,我们传递props
并获取寄存器状态对象。
function RegisterPageInner(props: IRegisterPageProps) {
const [userRegisterPageState, setUserRegisterPageState] = useState(initRegister)
const [loading, setLoading] = useState(false)
const [user, setUser] = useRecoilState(registerState)
const onRegister = () => {
setLoading(true)
setUser(userRegisterPageState)
}
一旦从表单中调用了onUpdateRegisterFieldHandler
,我们就更新状态。
const onUpdateRegisterFieldHandler = (name: string, value: string) => {
setUserRegisterPageState({
...userRegisterPageState,
[name]: value,
})
}
在 JSX 端,我们设置SubmitUserFormComponent
以防表单被提交或显示表单。
return (
<Centered>
{loading ? (
<SubmitUserFormComponent />
) : (
<Card>
<CardHeader title="Register Form" />
<CardContent>
<RegisterForm
onRegister={onRegister}
onUpdateRegisterField={onUpdateRegisterFieldHandler}
registerInfo={userRegisterPageState}
loading={loading}
/>
</CardContent>
</Card>
)}
</Centered>
)
}
interface IRegisterPageProps {
// TODO
}
export default RegisterPage
在SubmitUserFormComponent
子组件中,我们使用选择器进行服务调用并显示结果。
function SubmitUserFormComponent() {
console.log(`RegisterPage.tsx :: SubmitUserFormComponent`)
const results = useRecoilValue(registerUserSelector)
const setSessionState = useSetRecoilState(sessionState)
const setToastState = useSetRecoilState(toastState)
onSuccessRegister
和onFailRegister
方法处理成功的登录尝试和失败的登录尝试。在这一点上,我们只是将它与我们创建的内置令牌联系起来,但稍后我们可以实现一个逻辑,让我们的后端系统生成唯一的令牌,并让这些令牌由我们的前端代码解释;例如,我们可以让它们在 24 小时内过期。
const onSuccessRegister = () => {
localStorage.setItem('accessToken', 'myUniqueToken')
setSessionState('myUniqueToken')
}
const onFailRegister = () => {
setToastState(initToast(randomToastId(), notificationTypesEnums.Fail, results))
localStorage.removeItem('accessToken')
setSessionState('')
}
results === 'success' ? onSuccessRegister() : onFailRegister()
return (
<div className="RegisterPage">
{results === 'success' ? (
Success
) : (
We were unable to register you in please try again. Message: `{results}`
)}
)
}
对于样式 SCSS,我们可以在按钮上设置一些填充,这样页面的格式就很好,因为除了注销按钮,我们没有其他内容。
RegisterPage.scss
.RegisterPage {
padding-bottom: 350px;
}
我们已经完成了注册组件的前端视图。
重构逼近器
为了显示我们创建的RegisterPage
页面,我们需要在我们的AppRouter
组件中包含页面组件。看一看:
// src/AppRouter.tsx
import Register from './pages/RegisterPage/RegisterPage'
function AppRouter() {
return (
<Router>
<RecoilRoot>
<Suspense fallback={<span>Loading...</span>}>
<ToastNotification />
<HeaderTheme />
<Switch>
<Route exact path="/" component={App} />
<Route exact path="/Register" component={Register} />
...
</Switch>
<div className="footer">
<FooterTheme />
</div>
</Suspense>
</RecoilRoot>
</Router>
)
}
注册用户服务 API
现在我们已经完成了前端代码,我们可以在 Node.js 应用中创建我们的register.js
服务文件。服务类似于validate.js
。
让我们来看看。我们的imports
语句包括crypto-js
库,因此我们可以使用 React 应用中使用的相同库来解密密码。
// src/services/register.js
'use strict';
let usersSchema = require("../models/database").usersSchema,
logger = require('../utils/log.js').logger,
moment = require("moment"),
async = require('async'),
CryptoJS = require('crypto-js'),
params,
user,
isUserExists = false,
connector,
users;
我们的主函数register
包括三个操作:readUserInfoFromDB
、insertUser
和getUserId
。
function register(data, dbconnectorCallBackToRooms) {
logger.info('---------- register ----------');
connector = this.getConnector();
params = data.query || data.params;
params.member_id = -1;
let operations = [];
operations.push(readUserInfoFromDB);
operations.push(insertUser);
operations.push(getUserId);
async.series(operations, function (err, results) {
let retData = {
"exist_member_id": params.member_id,
"isUserExists": isUserExists,
"user": user
};
user = null;
users = null;
isUserExists = false;
params = null;
if (err) {
logger.info(err);
dbconnectorCallBackToRooms(data, {status: 'error', error_message: err, params: retData});
} else {
dbconnectorCallBackToRooms(data, {status: 'success', params: retData});
}
});
}
readUserInfoFromDB
操作将检查用户是否已经存在于数据库中,因为我们不希望多个用户使用相同的电子邮件地址。
function readUserInfoFromDB(callback) {
logger.info('---------- register :: readUserInfoFromDB ----------');
if (connector.isModelExists('users')) {
users = connector.getModel('users');
} else {
let schema = connector.setSchema(usersSchema);
users = connector.setModel('users', schema);
}
let findObject = {
username: params.name,
};
users.find(findObject)
.then((doc) => {
if (doc.length > 0) {
isUserExists = true;
params.member_id = doc[0]._id;
logger.info('isUserExists');
} else {
isUserExists = false;
}
callback(null, doc);
})
.catch((err) => {
logger.info(err);
params.member_id = -1;
callback(err.message, null);
});
}
我们的insertUser
操作将使用我们在 React 前端代码上创建的相同秘密字符串来解密密码。我们添加了一个随机的salt
和hash
,以确保我们用户的密码安全地存储在我们的数据库中。
原因是我们不想对所有密码使用相同的密码秘密,而是对每个密码使用唯一的密钥。这是一个常见的安全协议,以确保我们的用户的个人信息受到保护。
function insertUser(callback) {
logger.info('---------- register :: insertUser isUserExists :: ' + isUserExists + ', member_id: ' + params.member_id);
if (isUserExists) {
callback('error', 'user_exists_already');
} else {
let passwordEncrypt;
let secretPassphrase = 'mySecretPassphrase';
有了所有的安全措施,我将设置一个覆盖密码isDebug
,它仅用于测试目的,应该在生产构建中删除。
if (params.password === 'isDebug') {
passwordEncrypt = CryptoJS.AES.encrypt("123456", secretPassphrase);
let passwordEncryptEncodeURI = encodeURIComponent(passwordEncrypt);
logger.info('debug passwordEncryptEncodeURI: ' + passwordEncryptEncodeURI);
} else {
passwordEncrypt = params.password;
}
let user_password = (CryptoJS.AES.decrypt(passwordEncrypt, secretPassphrase)).toString(CryptoJS.enc.Utf8),
pass_salt = Math.random().toString(36).slice(-8),
encryptedPassword = CryptoJS.AES.encrypt(user_password, pass_salt),
now = moment().format();
logger.info('---------- register :: insertUser :: user_password : ' + user_password);
let newUsers = new users({
username: (params.name).toLowerCase(),
email: (params.email).toLowerCase(),
passwordHash: encryptedPassword,
passwordSalt: pass_salt,
lastLoginDate: now,
attempt: 0,
signDate: now,
emailEachLogin: true,
loginToken: '',
phone: ''
});
newUsers.save(function (err) {
if (err) {
logger.info('Error' + err.message);
callback(err.message, null);
} else {
callback(null, 'success');
}
});
}
}
最后,getUserId
操作将检索userId
,这样我们可以将它传递给我们的应用,并确保我们的数据被正确插入。
function getUserId(callback) {
if (isUserExists) {
callback('error', 'user_exists_already');
} else {
logger.info('register :: getUserId');
users.find({
username: params.name,
})
.then((doc) => {
if (doc.length > 0) {
params.member_id = doc[0]._id;
callback(null, doc);
} else {
callback('error username return no results');
}
})
.catch((err) => {
logger.info(err);
callback(err.message, null);
});
}
}
module.exports.register = register;
Tip
我在代码中留下了大量日志注释,以帮助您在前端和后端代码中更好地调试和理解代码。
现在在两个终端窗口中运行 Node.js 和 Mongo。
$ node server.js
$ mongod
如果您在http://localhost:3000/Register
导航到注册页面,您将看到图 7-11 中的屏幕。您现在可以注册新用户了。
图 7-11
注册页面
如果一切顺利,您可以使用 Mongo shell 或您喜欢的其他 GUI 来查看数据库,并看到我们刚刚输入的用户。参见图 7-12 。
图 7-12
进入 MongoDB 数据库的用户的 GUI 视图
摘要
在本章中,我们在 Node.js、Express 和 MongoDB 的帮助下创建了我们的后端。我们首先创建数据库模式,然后创建验证服务。我们使用了roomsjs
和roomdb
库来加速 Express 和 Node.js 的开发。我们为后端设置了本地环境,包括创建身份验证。在本章的第二部分,我们添加了一个注册组件,并通过加密和解密用户密码以及更新登录选择器来完成登录周期。
这一章是令人兴奋的,因为所有以前的章节现在都聚集在一起,创造一个循环。我们能够创建一个完整的工作网站/应用,允许用户不仅可以查看 React 组件制作的页面,还可以拥有注册和登录等常见功能。我们甚至实施了安全措施。在这个过程中,您能够了解 React、状态管理、浏览器本地存储、数据处理以及如何构建 React 组件和子组件。
在下一章中,您将学习如何将您的工作发布到部署服务器。
八、React 部署:MERN 栈
在这一章中,我们将继续上一章中我们停止的地方,并通过将我们的工作应用和 API 发布到实际的远程实时服务器来完成我们的开发周期。本章分为两个主要部分。
-
设置 Ubuntu 服务器
-
使用 Grunt 脚本发布
在我们开始之前,备份您的工作是一个很好的做法。是的,您可以在本地和外部驱动器上备份您的工作,但是在与团队合作时,您通常需要与其他人共享您的工作。一个常见的解决方案是版本控制,Git 是最流行的版本控制解决方案。如果你还没有设置版本控制工具,我强烈建议你在本章中设置它。
为您的项目设置 Git Repo
我们将在本章中使用的命令将把你的代码上传到你为你的项目设置的 Git repo 中,以确保你不会丢失你的工作,并且还会保存你的变更的历史。如果你从来没有设置过 Git repo,注册 GitHub 或者任何你喜欢的版本控制,然后创建一个新的 repo。在 GitHub 上,进入 https://github.com/new
创建一个新的回购。
Note
您可以将回购设置为私有(只有您可以访问)或公共(对全世界开放)。
在项目级别,在终端中设置您的 repo 的用户名和密码。
$ git config --global user.email YOUR-EMAIL@mail.com
$ git config --global user.name "YOUR NAME"
Tip
强烈建议学习和设置 Git,因为它可以帮助确保您永远不会丢失您的工作。如果你不得不在团队中工作并跟踪工作历史,它也会对你有所帮助。它还有其他几个有用的特性。
将 Ubuntu 服务器设置为生产服务器
正如我们在前一章看到的,MERN 栈使开发过程变得更加容易。这不仅适用于开发周期,也适用于发布作品的时间。如果你遵循敏捷和 Scrum 方法论,你就会知道快速发布和经常发布是多么重要。在这一章中,我会告诉你在 Ubuntu 服务器上发布应用的步骤,包括免费设置 SSL。
为什么选择 Ubuntu 服务器?
当涉及到发布你的作品时,有许多服务器可以使用,有许多方法可以选择。您可以设置一个虚拟的非专用服务器,并将前端和后端分成两个独立的服务器,Heroku 和 Windows Server。你甚至可以使用无服务器框架,这里仅举几个例子。选择是无限的。
在这一节中,我们将在 Ubuntu 服务器上发布,但请记住,有许多解决方案,您需要做自己的研究,以找到最适合您正在做的事情和您的确切需求。
在 Ubuntu 服务器上用 MERN 发布有很多好处,因为我们可以设置一台服务器,让它存储我们的后端、前端和数据库。有了这个资源,除了 web 服务器之外,我们还可以运行其他脚本,比如自动化脚本。
此外,Ubuntu 服务器是世界上最流行的用于云环境的 Linux 服务器。Ubuntu 服务器为所有工作负载提供了一个很好的虚拟机(VM)平台,从 web 应用到 NoSQL 数据库,如 MongoDB 和 Hadoop。
我喜欢这种设置,因为如果我想设置 cron 作业和修改头,我可以完全控制一个专用服务器。我以前是把后端和前端分开的;但是,对于中小型非企业项目来说,在同一台服务器上安装两者更为理想。
Ubuntu 16.04,18.04,还是 20.04?
Ubuntu 20.04 LTS,命名为 Focal Fossa,是 Ubuntu 最新的稳定版本。然而,使用最新版本并不总是最好的,因为并不是所有的服务器提供商都有这个选项,而且已经推出两年的服务器可能更“稳定”Ubuntu 版提供了一些不错的功能。它重启速度更快,并有值得探索的新功能,如高级 GUI。将支持到 2025 年 4 月。
Ubuntu 18.04 比 16.04 更稳定,16.04 已经接近生命的尽头,所以一定要麻烦安装 16.04。
如果你用 2018 年 4 月 26 日发布的仿生海狸(Ubuntu 18.04),你将能够获得安全和更新,直到 2023 年 4 月。在我写作的时候,or 20.04 是我对 Ubuntu 服务器的建议。
Ubuntu 19.04 是一个短期支持版本,支持到 2020 年 1 月,所以这个版本应该完全跳过。
如何在 Ubuntu 18.04/20.04 上安装 MERN?
我把这个教程分成了五个步骤。
-
第一步:推出 Ubuntu 服务器。
-
第二步:设置 SSH 并安装服务器软件。
-
第三步:安装 MongoDB。
-
步骤 4:设置身份验证。
-
步骤 5:设置 SSL。
为 MERN 安装 Ubuntu 服务器
在这一节中,我将告诉你为我们的 MERN 代码建立一个 Ubuntu 服务器的步骤。如果您按照我描述的步骤操作,您将拥有一个包含 MongoDB 的 Ubuntu 服务器,它具有身份验证功能以及一个免费的安全套接字层(SSL)证书。
第一步:推出 Ubuntu 服务器
第一步是推出服务器。
亚马逊 EC2 和微软阿祖拉是推出 Ubuntu 服务器最流行的方式。它们都列在 Ubuntu 网站上( https://ubuntu.com/public-cloud
)。这些云解决方案可以作为你的服务器使用,但是我推荐你在这里使用 EC2 或者阿祖拉。这些只是建议。每种平台都有其优点和警告,在决定使用什么平台之前,你应该做自己的研究(DYOR)。
EC2 和阿祖拉在 Ubuntu 服务器上提供免费的专用服务器;然而,如果你不密切关注,并超过分配的使用,你经常会发现自己与一个沉重的发票。
亚马逊和阿祖拉都要求你提供你的信用卡信息,他们会在免费周期或使用结束时自动向你收费,无论哪种情况先发生。同样,如果你忘记取消,你将被收费。
Azure 同时提供 Ubuntu Server 20.04 LTS 和 18.04; https://azuremarketplace.microsoft.com/en-us/marketplace/apps/canonical.0001-com-ubuntu-server-focal?tab=Overview
见。
在任何专用服务器上,步骤都是相似的。
-
启动 Ubuntu Server 18.04/20.04 LTS 版。
-
设置安全措施。
-
使用 SSH 来限制对服务器的访问,以便只有您的 IP 地址可以登录。
-
下载密钥对。
-
设置公共 IP 地址。
HTTP, HTTPS ports sets as: (0.0.0.0/0, ::/0)
Custom TCP: 27017 (for MongoDB) — (0.0.0.0/0, ::/0)
Port 8000 (for express https server)
Important: For HTTP and HTTPS,
8000
Note
出于安全考虑,我建议将 SSH 设置为您当前的 IP 地址。这比将 SSH 端口暴露给任何 IP 地址要好得多。
这里有一个例子说明如何在 Amazon EC2 免费层上使用 Ubuntu。请遵循以下步骤:
图 8-2
EC2,选择实例类型
图 8-1
EC2,启动 Ubuntu 服务器
-
点击实例,然后选择“Ubuntu Server 18.04 / 20.04 LTS (HVM),SSD 卷类型- ami-06d51e91cea0dac8d (64 位 x86)”,如图 8-1 。
-
实例类型选择 t2.micro,如图 8-2 所示。
-
准备好仓库。您可以免费获得高达 30GB 的空间。
接下来,我们需要配置我们的安全组。
图 8-3
配置安全组
-
配置安全组。使用 SSH、HTTP、HTTPS、自定义 TCP: 27017(对于 MongoDB)、80、8000 和 443(对于 Express HTTPS 服务器)。
重要提示:对于 HTTP 和 HTTPS,选择“8000 (0.0.0.0/0,:/0)”并将 SSH 限制设置为我的 IP,如图 8-3 所示。
-
通过选择 site-api-amazon-key 创建一个新的密钥对,然后单击 Download Key Pair。
-
发射!
Tip
英特尔 x86 几乎普遍比 ARM 快。
在 Azure 上,创建免费帐户后的过程是相似的。先去 https://portal.azure.com/?quickstart=true
。
在快速入门中心,点击“部署虚拟机”,然后点击开始(图 8-4 )。
图 8-4
Azure 快速入门中心
接下来,点击“创建一个 Linux 虚拟机”,如图 8-5 所示。
图 8-5
Azure,部署虚拟机
接下来,使用选项选择 Ubuntu 版本,选择硬盘驱动器,设置安全性,然后启动。
步骤 2:设置 SSH 并安装软件和升级
现在,您已经有了一个运行并准备好的服务器(无论是 EC2、阿祖拉还是任何其他解决方案),我们希望能够 SSH 服务器以及升级服务器的软件。我们将从让我们的生活变得简单开始。我们将创建一个 SSH 快捷方式来访问服务器。在 Mac 上,使用 vim 或您喜欢的编辑器,输入以下内容:
$ vim ~/.ssh/config
Paste the host and the location of the pem file;
Host My-site-name
HostName YOUR-IP-ADDRESS
User ubuntu
IdentityFile location-to-pem/key.pem
Note
你在我们创建的 pem 文件中看到的,代表了你想要的网站名称,选择任何名称。这是您将用于 SSH 服务器的名称。要查找公共实例的 IP 地址,请查看 EC2 实例详细信息页面。
接下来,设置密钥的权限。
$ chmod 600 *.pem
让我们试一试。
$ ssh my-site-name
在 SSH 服务器之前,您需要设置 EC2 来接受 SSH 到您的 IP 地址。
选择 EC2,单击“安全组”,单击“编辑入站规则”,单击 SSH,单击“源类型”,然后单击我的 IP。
完成后,您就可以登录到这个盒子。我喜欢设置一些别名,以便使用预定义的命令来管理服务器。
$ vim ~/.bash_profile
粘贴 PEM 文件的主机和位置。
alias tailall='sudo lnav /home/ubuntu/www/logs'
alias tailwatchers='tail -f /home/ubuntu/www/logs/watch-log.log'
alias cleanlogs='sudo rm -f /home/ubuntu/www/logs/*.* && sudo touch /home/ubuntu/www/logs/server.log && sudo touch /home/ubuntu/www/logs/server-error.log && sudo touch /home/ubuntu/www/logs/watch-error.log && sudo touch /home/ubuntu/www/logs/watch-log.log'
alias l='ls -ltra'
alias c="clear"
alias cls="clear"
alias ll='ls -ltra'
alias killnode='sudo killall -2 node'
export PORT=8081
sudo iptables -A PREROUTING -t nat -i eth0 -p tcp --dport 80 -j REDIRECT --to-port 8081
sudo iptables -A PREROUTING -t nat -i eth0 -p tcp --dport 443 -j REDIRECT --to-port 8000
parse_git_branch() {
git branch 2> /dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/ (\1)/'
}
export PS1="\u@\h \[\033[32m\]\w\[\033[33m\]\$(parse_git_branch)\[\033[00m\] $ "
这些别名包括 Express 的端口导出、终止所有节点实例的命令、显示 Git 目录的良好设置以及访问工作目录的简洁命令。请记住运行该文件以应用这些更改。
$ . ~/.bash_profile
接下来,我们希望升级并安装我们将需要的软件,比如 Python、NPM 和 Forever(以保持 Node.js 运行)。我们还需要为 Express 和其他软件设置端口。
$ sudo apt-get update
$ sudo apt-get -y upgrade
$ sudo apt-get dist-upgrade
$ sudo apt-get install build-essential
$ sudo apt-get install libssl-dev
$ sudo apt-get install git-core
$ sudo apt-get install python2.7
$ type python3 python2.7
接下来,让我们在 Ubuntu 服务器上安装 Node.js。
$ sudo apt install nodejs
安装 NPM 并更新至最新版本。
$ sudo apt install npm
$ sudo npm install -g npm@latest
现在,创建一个 web 文件夹,我们将在其中放置我们的文件。
$ cd ~
$ mkdir ~/www
$ cd ~/www
全球永久安装( https://github.com/foreversd/forever
)。
$ cd ~
$ sudo npm install -g forever
Forever 是一个简单的 CLI 工具,可以确保我们的脚本持续运行(即永远运行)。
接下来,我们要配置默认网站使用端口 8080,而不是端口 80。您可以使用以下命令将端口从 8081 转发到 80:
$ export PORT=8081
$ sudo iptables -A PREROUTING -t nat -i eth0 -p tcp --dport 80 -j EDIRECT --to-port 8081
Note
如果您需要删除我们在这里设置的 iptable,只需使用-D
标志。
sudo iptables -D 预路由-t nat -i eth0 -p tcp - dport 80 -j 重定向到端口 8081
第 3 步:安装 MongoDB
我们的 MERN 服务器需要一个 MongoDB 服务器。要安装 MongoDB 服务器,请使用以下命令:
安装 MongoDB 服务器。
$ sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 9DA31620334BD75D9DCB49F368818C72E52529D4
$ echo "deb [ arch=amd64 ] https://repo.mongodb.org/apt/ubuntu bionic/mongodb-org/4.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb.list
现在,运行更新和安装命令。
$ sudo apt update
$ sudo apt install mongodb-org
$ sudo systemctl unmask mongod
$ sudo systemctl enable mongod
$ sudo service mongod status
您可以将您的 MogoDB 状态输出与我的进行比较,如下所示:
ubuntu@ip-ip ~/www $ sudo service mongod status
mongod.service - MongoDB Database Server
Loaded: loaded (/lib/systemd/system/mongod.service; enabled; vendor preset: enabled)
Active: inactive (dead) since
Docs: https://docs.mongodb.org/manual
Process: 903 ExecStart=/usr/bin/mongod --config /etc/mongod.conf (code=exited, status=
Main PID: 903 (code=exited, status=0/SUCCESS)
systemd[1]: Started MongoDB Database Server.
systemd[1]: Stopping MongoDB Database Server...
systemd[1]: Stopped MongoDB Database Server.
现在我们已经安装了 MongoDB,请更新 MongoDB,以便所有 IP 地址都可以接收来电。为此,编辑mongod.conf
文件。
$ sudo vim /etc/mongod.conf
我们需要做的更改是将我们的端口从绑定更改为特定地址。
net:
port: 27017
bindIp: 127.0.0.1
要绑定到所有传入的 IP 地址,请使用:
net:
port: 27017
bindIpAll: true
# bindIp: 127.0.0.1
Note
端口 27017 应该在我们部署服务器时就已经打开了。
现在,要停止/启动/重启 MongoDB 服务以在后台运行,请使用以下命令。要启动它,请使用以下命令:
$ sudo systemctl start mongod
如果您想要停止或重新启动,请使用以下命令:
$ sudo systemctl stop mongod
$ sudo systemctl restart mongod
试一试,确保安装顺利。接下来,让我们创建第一个数据库来尝试 MongoDB。
$ mongo
将您在 Ubuntu 服务器命令行上运行 Mongo 的输出与我的输出进行比较。它可能会输出不同的结果,但会以命令行输入提示:>
结束。看一看:
ubuntu@ip $ mongo
MongoDB shell version v4.0.21
connecting to: mongodb://ip:27017/?gssapiServiceName=mongodb
Implicit session: session { "id" : UUID("3236f117-2e73-4d78-bd37-eeffef73fb85") }
MongoDB server version: 4.0.20
>
接下来,让我们使用数据库并插入用户。
> use your-database-name
> db.users.insert({"email":"someemail@gmail.com", password:"123456"})
> db.users.find();
以下是预期的输出:
> use your-database-name
switched to db your-database-name
> db.users.insert({"email":"someemail@gmail.com", password:"123456"})
WriteResult({ "nInerted" : 1})
> db.users.find();
{ "_id" : ObjectId("some-objectId"), "email": "someemail@gmail.com", "password": "123456"}
>
蒙戈布客户端
我建议设置一个 MongoDB GUI 客户端,如果您在上一章中还没有这样做的话,这样可以更容易地管理您的数据库。MongoDB Compass 基础版是免费的,易于使用,由 MongoDB 团队制作。 https://www.mongodb.com/products/compass
见。
步骤 4:设置身份验证
现在我们已经在远程机器上设置了 MongoDB,下一步是创建身份验证。身份验证不是可选的;它真的是远程服务器上的必备。没有身份验证,任何黑客都可以使用您的 MongoDB 数据库。您不希望未经授权的用户只是连接到您的数据库并在其上放置蠕虫。
若要设置鉴定,请在“终端”中通过 SSH 连接到远程服务器。
$ ssh my-site-name
我们将创建一个新的管理员用户,并为该用户设置身份验证。看一看:
$ mongo admin
$ db.createUser({ user: 'myuser', pwd: '123456', roles: [ { role: 'root', db: 'admin' } ]});
将用户添加到 MongoDB 数据库后的输出应该如下所示:
> db.createUser({ user: 'myuser', pwd: '123456', roles: [ { role: 'root', db: 'admin' } ]});
Successfully added user: {
"user": "myuser",
"roles": [
{
"roles": "root",
"db": "admin"
}
]
}
> db.getUsers()
这将输出用户列表。
[
{
"_id" : "MyDatabase.myuser",
"user" : "myuser",
"db" : "MyDatabase",
"roles" : [
{
"role" : "readWrite",
"db" : "MyDatabase"
}
],
"mechanisms" : [
"SCRAM-SHA-1",
"SCRAM-SHA-256"
]
}
]
退出数据库。
$ exit
Note
在您的密码中,您不能使用@
符号作为密码字符串的一部分。当试图从 Express 应用连接到数据库时,您将得到“mongoparserror:un escaped at-sign in authority”。
这里,我在主数据库上设置认证,而不是在我们创建的名为MyDatabase
的特定数据库上。
要登录到远程服务器上的数据库,请使用以下命令:
$ mongo -u myuser -p 123456
> use MyDatabase
switched to db MyDatabase
> db.users.find();
{ "_id" : ObjectId("5fb7af6772f31b6884299994"), "email" : "someemail@gmail.com", "password" : "123456" }
对于健全性检查,您可以运行getUsers
命令来确保我们的用户被正确添加。
接下来,绑定所有 IP 地址并设置安全性,这样我们就能够在启用安全性的情况下读写数据库,就像我们在本地所做的一样。
$ sudo vim /etc/mongod.conf
net:
port: 27017
bindIpAll: true
bindIp: 0.0.0.0
security:
authorization: 'enabled'
Note
如果你需要的话,你可以启用本地 ip 地址:bindIp: 127.0.0.1
。
要应用这些更改,请重新启动。
$ sudo systemctl restart mongod
连接到安全的 MongoDB
现在,我们可以连接到我们的远程服务器,并传递新的认证信息。
$ mongo -u 'myuser' -p '123456'
> use MyDatabase
switched to db MyDatabase
> db.users.find();
{ "_id" : ObjectId("5fb7af6772f31b6884299994"), "email" : "someemail@gmail.com", "password" : "123456" }
或者从我们的本地机器,通过终端,我们可以不使用 SSH 连接。只需告诉 Mongo 要使用的远程 IP 地址、端口和用户。
$ mongo -u 'myuser' -p '123456' remote_ip_address:port/admin
现在,连接 URI 将没有数据库名称,如下所示:
mongodb://myuser:123456@ip_address:27017
删除用户
如果您需要删除用户并设置新用户,或者如果您忘记了密码,您需要首先删除安全性。
$ sudo vim /etc/mongod.conf
然后,用dropUser
删除用户(注意removeUser
已弃用)。
$ mongo admin
$ db.dropUser(myuser)
你可以在这里找到关于dropUser
的信息: https://docs.mongodb.com/manual/reference/method/db.removeUser/#db-removeuser
。
更改存储位置
注意,在mongod.conf
文件中,我们可以通过dbPath
改变实际存储的位置。现在它被设置在这个位置:
/var/lib/MongoDB
Tip
你可以把这个改成任何你喜欢的位置,比如/data/DB
。只要确保您用正确的权限设置了路径。
如果您决定更改数据库的位置,下面是您将如何设置权限,以便 Mongo 可以访问该文件:
$ sudo mkdir -p /data/db
$ sudo chown -R $USER:$USER /data/db
要查看不同路径上的数据库操作,您可以重启 MongoDB 或在新位置运行mongod
。
$ mongod --dbpath /data/db
步骤 5:设置 SSL
今天在构建网站时,SSL 不是可选的。这是必须的。如果你的网站没有设置 SSL,浏览器会给你一个错误信息。
你可以为所有域名和服务器注册商设置 SSL,只需支付年费,减少麻烦,并在 SSL 检查中显示一个有信誉的 SSL 证书,但你也可以免费设置。
要免费设置 SSL,可以使用 Certbot ( https://certbot.eff.org/
)。首先,安装软件。
$ sudo apt-get install software-properties-common
$ sudo add-apt-repository universe
$ sudo add-apt-repository ppa:certbot/certbot
Enter to continue
$ sudo apt-get update
For Ubuntu 18.04;
$ sudo apt-get install certbot python-certbot-apache
For Ubuntu 20.04;
$ sudo apt-get install certbot python3-certbot-apache
现在设置证书。
$ sudo certbot certonly --manual
命令行向导会启动并向您提问。
对于站点,使用您的站点名称,如some-site.com
。
如果您对记录您的 IP 地址感到满意,请回答 y。请参见 Ubuntu 输出中的“设置 SSL 证书机器人向导”信息。
ubuntu@ip-ip $ sudo certbot certonly --manual Saving debug log to /var/log/letsencrypt/letsencrypt.log Plugins selected: Authenticator manual, Installer None Enter email address (used for urgent renewal and security notices) (Enter 'c' to cancel): your-email@gmail.com
--------------------------------------------------------------
Please read the Terms of Service at https://letsencrypt.org/documents/LE—SA—v1.2—November-15-2017.pdf. You must agree in order to register with the ACME server at https://acme—v02.api.letsencrypt.org/directory
--------------------------------------------------------------
(A)gree/(C)ancel: A
--------------------------------------------------------------
Would you be willing to share your email address with the Electronic Frontier Foundation, a founding partner of the Let's Encrypt project and the non—profit organization that develops Certbot? We'd like to send you email about our work encrypting the web, EFF news, campaigns, and ways to support digital freedom.
--------------------------------------------------------------
(Y)es/(N)o: N Please enter in your domain name(s) (comma and/or space separated) (Enter 'c' to cancel): YourSite.com Obtaining a new certificate Performing the following challenges: http-01 challenge for YourSite.com
接下来,它将要求您证明您有权访问该域。
Create a file containing just this data:
[SOME RANDOM GENERATE CODE]
使其在您的 web 服务器上可用,网址为:
http://some-site.com/.well-known/acme-challenge/KEY
停下来,创建所需的文件。
将目录更改为/home/ubuntu/www/dist/
,并按照说明创建文件。
> cd /home/ubuntu/www/dist/
> mkdir .well-known
> cd .well-known
> mkdir acme-challenge
> cd acme-challenge
> vim "[FILE NAME]"
[CODE]
勾选 http://some-site.com/.well-known/acme-challenge/
[string]
。
有用吗?太好了。现在我们可以继续了。
Press Enter to Continue
Waiting for verification...
Cleaning up challenges
IMPORTANT NOTES:
- Congratulations! Your certificate and chain have been saved at:
/etc/letsencrypt/live/MY-SITE-NAME.com/fullchain.pem
Your key file has been saved at:
/etc/letsencrypt/live/MY-SITE-NAME.com/privkey.pem
设置目录的权限。
$ sudo chown -R ubuntu:ubuntu /etc/letsencrypt
续订证书
要续订证书,请运行以下命令:
sudo certbot certonly --manual -d "your-site.com"
过程是一样的;你只需要准备好挑战。
$ mkdir -p public/.well-known/acme-challenge
接下来,用数据设置文件名。
$ vim /home/ubuntu/www/public/.well-known/acme-challenge/[file name]
[some text]
443 安全
将 SSH 端口添加到安全部分。如果在上一步中没有添加,请使用以下命令:
HTTPS > TCP > 443 > 0.0.0.0/0
Custom > 8000 > 0.0.0.0/0
如果您还记得在vim ~/.bash_profile
中,我们正在将端口 443 重定向到 8000。
$ sudo iptables -A PREROUTING -t nat -i eth0 -p tcp --dport 443 -j REDIRECT --to-port 8000
当我们创建 HTTPS 服务器时,我们应该指向端口 8000。
let https_server = require('https').createServer({
key: fs.readFileSync('/etc/letsencrypt/live/some-site.com/privkey.pem', 'utf8'),
cert: fs.readFileSync('/etc/letsencrypt/live/some-site.com/cert.pem', 'utf8'),
ca: fs.readFileSync('/etc/letsencrypt/live/some-site.com/chain.pem', 'utf8')
}, app).listen(8000, function () {
logger.info('Listening on https://' + os.hostname() + ':8000');
});
太棒了。
使用 Grunt 脚本发布
当谈到自动化时,如果选择使用某些工具,设置它可能是一个棘手的问题,换句话说,是开发工作流程中一项耗时的任务。MERN 栈使开发过程更加容易。这不仅仅是对发展而言;这也适用于何时发布你的作品。
为什么咕哝?
谈到自动化和部署构建工具,有很多可供选择。我们已经在 CRA 项目中设置了 Webpack 包和 NPM 脚本,那么为什么我们还需要另一个库呢?
咕噜与咕噜、网络包与 NPM 脚本
这个主题确实值得有它自己的一章,但是我将在这里给你一个快速的纲要。当我们查看这些工具的受欢迎程度时,我们会发现 Webpack 是最受欢迎的,Gulp 位居第二。
-
NPM 脚本:33600(
https://github.com/npm/cli
) -
咕哝:一万二千(
https://github.com/gruntjs/grunt
) -
大口:四万两(
https://github.com/gulpjs/gulp
) -
Webpack:5.57 万(
https://github.com/webpack/webpack
)
虽然 Webpack 是最受欢迎的,但这并不意味着它是赢家。每个工具都是为解决特定问题而创建的。理解每个工具的用途是很重要的。
让我们后退一步。我们在这里试图实现什么?我们最重要的任务是将我们的文件发布到生产中。我们不需要做测试,林挺格式化,等等。我们在 CRA 已经有了剧本。
Webpack 是一个模块捆绑器。它适用于较大的项目,与 Grunt 和 Gulp 相比,它更难设置。它不是一个任务运行器,所以尽管它有由 Webpack 团队和社区创建的插件,但它真的不是这项工作的最佳工具。
NPM 的剧本怎么样了?CRA 已经提供了运行脚本。事实上,在我们的package.json
文件中有一个部分指定了 NPM 运行脚本,包括调用react-scripts
来测试和构建部署构建,我们在第二章中看到了这一点。
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}
当我们构建项目时,这些react-scripts
脚本很好地结合到我们的开发服务器中,您可以在这里看到它们:project/node_modules/react-scripts/scripts/start.js
。
当我们建立项目时,CRA 就是这样将我们的node_modules
文件夹中仅有的 6 个库变成了 1000 多个。我们将通过package.json
使用 NPM 运行脚本来自动化任务,但这不是放置自动化任务来帮助运行它们的合适位置。
为您设置的 NPM 运行脚本旨在帮助您自动运行重复性任务,我们将使用它们来设置我们的部署自动化任务。
由于我们希望创建任务来发布我们的代码(而不是构建我们的模块或进行任何林挺、格式化、测试或运行重复的任务),Grunt 和 Gulp 更适合这项工作,它们可以在 NPM 运行脚本中设置。
我们可以排除 Webpack 和 NPM 运行脚本作为将文件发布到远程服务器的最佳工具。参见图 8-6 。
图 8-6
为发布自动化任务寻找合适的工具
Grunt 和 Gulp 是用于自动化任务的,非常适合我们这里需要做的事情。Grunt 是最老的。Grunt 是为任务配置创建的。Gulp 更受欢迎,类似于 Grunt,但是您可以使用它以比设置 Grunt 任务更直观的方式编写 JavaScript 函数。有了 Gulp,你的代码将更具可读性,所以 Gulp 在某些方面优于 Grunt。
但说到插件,Grunt 的插件数量众多,占据上风。
-
咕哝:6250(
https://gruntjs.com/plugins
) -
大口:4120(
https://gulpjs.com/plugins/
)
就插件而言,我们真的受到开源社区的支配,如果你找不到你需要的插件,你可能需要创建自己的插件,或者如果你的插件失败或没有得到维护,你将不得不自己修复它或添加功能。
此外,为了安全起见,我们需要确保我们的插件做它们应该做的事情,特别是当我们依赖将要进入我们的生产服务器的内容时。这是一个大问题,因为它会将你的整个发布置于危险之中,甚至会使它停止。
例如,在 2018 年末,黑客能够使用 NPM JavaScript 库( https://www.npmjs.com/package/event-stream
)成功地将恶意代码插入事件流。这个库被数百万人使用,目标是一家名为 Bitpay 的公司,该公司有一个名为copay
( https://github.com/bitpay/copay
)的 Git 库。
像许多开源库一样,开发人员在事件流上的工作没有得到报酬,在将项目交给新的维护者之前,他们对项目失去了兴趣。
新的维护者注入了针对copay
的恶意代码。该代码从余额超过 100 比特币或 1000 比特币现金的账户中捕获账户细节和私钥。
copay
随后在 5.0.2 版本上更新了其依赖库,并纳入了攻击者代码,导致损失数百万。
该代码捕获受害者的帐户数据和私钥,然后使用服务调用,将数据发送到攻击者的服务器而不被发现。
这次攻击的完整细节和分析可以在这里找到: https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident
。
综上所述,我更喜欢使用 Gulp 而不是 Grunt,因为它更符合 MERN 的 JavaScript 范式。然而,我经常选择 Grunt 而不是 Gulp,因为它的插件数量更多,而且 Grunt 也更成熟。话虽如此,但如果你使用 Gulp,我在这里做的简单任务就不会出错。
如何用 Grunt 发布你的剧本?
我将这一部分的过程分为两步。
-
步骤 1 :将前端发布到远程服务器上(
app/Gruntfile.js
) -
步骤 2 :将后端发布到远程服务器上(
api/Gruntfile.js
)
使用 Grunt 发布内容
我想发布我们在同一个 Ubuntu 服务器上创建的后端和前端。对于相对较小的中小型应用来说,这是一个很好的设置。在一个更复杂的应用中部署多个服务器、负载平衡器和其他方法,在这里会显得有些矫枉过正,更难维护。
首先,全局安装 Grunt。
$ npm install -g grunt-cli
此外,安装用于运行 shell 命令的grunt-shell
插件。
$ yarn add -D grunt-shell
步骤 1:发布前端
您可以从如下所示的 Apress GitHub 位置下载该脚本:
https://github.com/Apress/react-and-libraries/08/app/Gruntfile.js
在 Grunt 中,一切都是为了创建任务。我们将创建两个主要任务。
-
这将是我发布代码的默认任务。
-
local-prod-build
:这将是一项模拟生产构建进行测试的任务。
我们的默认任务将执行以下操作:
-
将我们的代码发布到我们为项目设置的 Git repo 中。
-
format
:通过 Airbnb 风格指南进行格式化和 lint。你还记得,我们在第一章中设置了格式和林挺。 -
这是构建应用的内置 CRA·NPM 运行脚本。构建 React 应用的生产优化版本。
-
delete_old_files_api
:删除旧的dist
文件夹,该文件夹用于存放 React 应用的优化版本。 -
copy_new_files_api
:将 React 应用的优化版本复制到 API 应用项目内部的 dist 中(这样我们就可以使用 Express 服务器运行我们的应用)。 -
server_upload_app
:上传文件到远程 Ubuntu 服务器。 -
stop_node
:停止生产服务器上的 Node.js 脚本。这将停止 Express 服务器。 -
start_node
:在生产服务器上启动 Node.jsserver.js
脚本。这将启动 Express 服务器。
类似地,要设置local-prod-build
,该任务需要执行以下任务:
-
将我们的代码发布到我们为项目设置的 Git repo 中。
-
使用 Airbnb 风格指南格式化我们的代码。
-
yarn_build
:构建 React 应用的生产优化版本。 -
这是一个 CRA 内置的 NPM 脚本,用来创建一个本地服务器来模拟生产服务器。
-
open_build
:打开带有生产版本的本地主机。
为了在代码级别(app/Gruntfile.js
)实现这些任务,这些任务和子任务看起来应该是这样的:
// app/Gruntfile.js
module.exports = function (grunt) { grunt.loadNpmTasks('grunt-shell'); grunt.initConfig({ /**
* We read in our `package.json` file so we can access the package name and
* version.
*/
pkg: grunt.file.readJSON('package.json'),shell: {
git_add: {
command: [
'git add .',
'git add -u',
"git commit -m '<%= pkg.version %> -> <%= pkg.commit %>'",
'git push'
].join('&&')
},
lint: {
command: 'yarn run lint'
},
format: {
command: 'yarn run format'
},
yarn_build: {
command: 'yarn build'
},
yarn_serve: {
command: 'serve -s build'
},
open_build: {
command: 'open http://localhost:5000'
},
delete_old_files_api: {
command: 'rm -rf /YOUR-API-LOCATION/app-api/dist'
},
copy_new_files_api: {
command: 'cp -rf /YOUR-APP-LOCATION/app/build/ /YOUR-API-LOCATION/app-api/dist/'
},
server_upload_app: {
command: 'scp -r -i /YOUR-PEM-LOCATION/key.pem' +
' /YOUR-API-LOCATION/app-api/dist/* ubuntu@YOUR-UBUNTU-PUBLIC-IP:/home/ubuntu/www/dist'
},
stop_node: {
command: "ssh -i /YOUR-PEM-LOCATION/key.pem ubuntu@YOUR-UBUNTU-PUBLIC-IP 'sudo pkill -f node'"
},
start_node: {
command: "ssh -i /YOUR-PEM-LOCATION/key.pem ubuntu@YOUR-UBUNTU-PUBLIC-IP 'sudo forever start /home/ubuntu/www/server.js'"
},
}
});
grunt.registerTask('default', ['shell:git_add', 'shell:format', 'shell:yarn_build', 'shell:delete_old_files_api', 'shell:copy_new_files_api', 'shell:server_upload_app', 'shell:stop_node', 'shell:start_node']);
grunt.registerTask('local-prod-build', ['shell:git_add', 'shell:format', 'shell:yarn_build', 'shell:yarn_serve', 'shell:open_build']);
};
注意,对于 Git 任务,我在将用于 Git 注释的package.json
文件中设置版本和提交,因此在运行 Grunt 发布脚本之前,需要在package.json
中设置版本和提交消息。
{
"name": "api",
"version": "0.0.0872",
"commit": "add service call",
我们现在可以使用一个命令为生产服务器进行部署。
$ grunt
您还可以运行任务,在我们的本地机器上创建生产版本。
$ grunt local-prod-build
我们能让它变得更简单吗?没错。让我们把 Grunt 和 NPM 剧本联系起来。在package.json
文件的脚本标签中,添加一个“推送”脚本。
"scripts": {
...
"push": "grunt"
}
既然我们的任务自动化更好地绑定到了我们的 NPM 脚本,我们可以试一试了。
$ yarn push # or npm push
步骤 2:将后端代码发布到远程服务器
接下来,在我们的后端,我们希望能够用一个命令发布 Node.js Express 应用,就像我们对前端代码所做的那样。
您可以从 Apress GitHub 站点下载该脚本。
https://github.com/Apress/react-and-libraries/08/api/Gruntfile.js
我们将创建三个任务。
-
default
:默认任务将代码上传到远程服务器。 -
upload-app
:上传 React 代码到远程服务器。我们已经在 Grunt 任务中这样做了,但是在这里这样做也没有坏处。 -
node-restart
。重启 Node.js,这将重启我们的 Express 应用。
默认任务
我对上传的内容更有选择性,因为我不想每次推送都上传所有文件。请遵循以下步骤:
-
multiple
:将 API 代码发布到 Git。 -
server_package
:上传package.json
文件到远程服务器。 -
stop_node
:停止 Node.js。 -
upload_server_file
:上传server.js
文件。 -
server_upload_services
:上传任何 get 类型的服务。 -
server_upload_services_post
:上传任何帖子类型的服务。 -
server_upload_utils
:上传实用文件夹。 -
start_node
:再次启动 Node.js,因为它在上一个任务中已经停止。
你可能需要不同的文件夹和文件,但你知道的。如果需要,可以随意添加。让我们回顾一下这些任务。
上传应用任务
您还记得,使用我们设置的app/Gruntfile.js
任务将生产文件从我们的 CRA 复制到 API 服务器的dist
文件夹中。我们在这里设置的任务可以使用我们设置的任务将这些文件上传到远程服务器。
节点重启任务
这个任务是在需要的时候重启 Node.js。在不需要 SSH 服务器的情况下,在一个命令中使用它是很好的。我们将在Gruntfile.js
中设置两个任务来轻松地停止和启动节点脚本
-
停止节点
-
开始节点
看一下代码,如下所示:
// api/Gruntfile.js
module.exports = function (grunt) { grunt.loadNpmTasks('grunt-replace');
grunt.loadNpmTasks('grunt-shell');
grunt.loadNpmTasks('grunt-open'); grunt.initConfig({ /**
* We read in our `package.json` file so we can access the package name and
* version.
*/
pkg: grunt.file.readJSON('package.json'), shell: {
multiple: {
command: [
'git add .',
'git add -u',
"git commit -m '<%= pkg.version %> -> <%= pkg.commit %>'",
'git push'
].join('&&')
},
upload_server_file: {
command: 'scp -r -i /YOUR-APP-LOCATION/app/docs/keys/ee-amazon-key.pem /YOUR-API-LOCATION/api/server.js ubuntu@YOUR-UBUNTU-PUBLIC-IP:/home/ubuntu/www/'
},
server_upload_services: {
command: 'scp -r -i /YOUR-APP-LOCATION/app/docs/keys/ee-amazon-key.pem /YOUR-API-LOCATION/api/services/* ubuntu@YOUR-UBUNTU-PUBLIC-IP:/home/ubuntu/www/services'
},
server_upload_services_post: {
command: 'scp -r -i /YOUR-APP-LOCATION/app/docs/keys/ee-amazon-key.pem /YOUR-API-LOCATION/api/services_post/* ubuntu@YOUR-UBUNTU-PUBLIC-IP:/home/ubuntu/www/services_post'
},
server_package: {
command: 'scp -i /YOUR-APP-LOCATION/app/docs/keys/ee-amazon-key.pem /YOUR-API-LOCATION/api/package.json ubuntu@YOUR-UBUNTU-PUBLIC-IP:/home/ubuntu/www/package.json'
},
server_upload_utils: {
command: 'scp -r -i /YOUR-APP-LOCATION/app/docs/keys/ee-amazon-key.pem /YOUR-API-LOCATION/api/utils/* ubuntu@YOUR-UBUNTU-PUBLIC-IP:/home/ubuntu/www/utils'
},
server_upload_app: {
command: 'scp -r -i /YOUR-APP-LOCATION/app/docs/keys/ee-amazon-key.pem /YOUR-API-LOCATION/api/dist/* ubuntu@YOUR-UBUNTU-PUBLIC-IP:/home/ubuntu/www/dist'
},
stop_node: {
command: "ssh -i /YOUR-APP-LOCATION/app/docs/keys/ee-amazon-key.pem ubuntu@YOUR-UBUNTU-PUBLIC-IP 'sudo pkill -f node'"
},
start_node: {
command: "ssh -i /YOUR-APP-LOCATION/app/docs/keys/ee-amazon-key.pem ubuntu@YOUR-UBUNTU-PUBLIC-IP 'sudo forever start /home/ubuntu/www/server.js'"
}
}
}); grunt.registerTask('default', ['shell:multiple', 'shell:server_package', 'shell:stop_node', 'shell:upload_server_file', 'shell:server_upload_services', 'shell:server_upload_services_post', 'shell:server_upload_utils', 'shell:start_node']);
grunt.registerTask('upload-app', ['shell:server_upload_app']);
grunt.registerTask('node-restart', ['shell:stop_node','shell:start_node']);
};
太棒了。
我们可以运行默认脚本。
$ grunt
在这一节中,我将这个过程分为两步。
-
步骤 1:用 Grunt 将前端发布到远程服务器
-
步骤 2:用 Grunt 将后端发布到远程服务器
现在我们已经将 React 应用和 API 文件上传到了远程 Ubuntu 服务器上。我们还设置了化名。我们可以使用以下命令。
使用 SSH 连接到服务器。
$ ssh my-site-name
使用cdr
别名导航到目录/home/ubuntu/www
。
$ cdr
要启动节点而不是$ sudo forever start
,我们可以使用startnode
别名。
$ startnode
要停止 Node.js,不使用$ sudo forever stop 0
,我们可以使用stopnode
别名。
$ stopnode
要查看所有别名,请键入以下内容:
$ vimb
如果一切设置正确无误,您应该能够使用您的服务器的公共 IP 地址访问我们在前面章节中构建的应用。
看看 Ubuntu 的一些示例输出,通过 SSH 连接到服务器,停止 Node.js 服务器,并在 Forever 的帮助下再次启动它。然后你可以把你的输出和我的进行比较。
ubuntu@ip ~ $ cdr
ubuntu@ip ~/www $ stopnode # stopall # or stopall
ubuntu@ip ~/www $ startnode
warn: --minUptime not set. Defaulting to: 1000ms
warn: --spinSleepTime not set. Your script will exit if it does not stay up for at least 1000ms
info: Forever processing file: /home/ubuntu/www/server.js
ubuntu@ip ~/www $ shownodes
root 23924 1 0 18:38 ? 00:00:01 /usr/bin/node /home/ubuntu/www/server.js
ubuntu 24052 23003 0 18:41 pts/1 00:00:00 grep node
注意,我使用的是我们在设置服务器时设置的命令,比如shownodes
,它显示了所有正在运行的节点(ps -ef | grep node
)。如您所知,您可以使用($ vimb
)查看命令,这相当于vim ~/.bash_profile
。
摘要
在这一章中,我们首先为我们的项目设置了一个 Git repo,这样我们就可以与我们团队的其他成员(如果我们有的话)共享我们的项目,并保存我们的变更历史。我们还将获得使用版本控制的许多其他特性和优势。
我们研究了为什么使用 Ubuntu 服务器以及使用什么版本,并且我们将 Ubuntu 服务器设置为 React 应用和 Node.js Express API 脚本的生产服务器。
接下来,我们在 Ubuntu 服务器上安装了 MERN 栈,并为 MERN 栈设置了 Ubuntu 服务器。一旦我们有了为我们设置的服务器,我们期待发布我们的代码。我们首先看了使用 Grunt 的优势以及我们的其他选择。然后,我们看了如何用 Grunt 发布我们的 React 和 Express 脚本,并创建了一个 Grunt 文件来自动化我们的任务,这样我们就可以经常发布和发布。在下一章,我们将研究如何测试我们的 React 应用。
九、测试第一部分:React 应用的单元测试
在测试 React 应用时,需要考虑三个方面的测试。
-
单元测试:测试能够被隔离的最小的一段代码。
-
集成测试:将单个模块组合在一起进行测试
-
E2E 测试:模拟真实的最终用户体验
在这一章中,你将学习如何像专家一样对 React 应用进行单元测试。
说到单元测试,有很多库可以和 React 一起使用,比如 Jest、Enzyme、Sinon、Mocha、Chai、和 Tape。这些库被认为是最常用的 React 单元测试库。
在这一章中,你将学习如何利用 Jest、Enzyme 和 Sinon 对你的应用进行单元测试。
设置项目
为了了解如何对 React TypeScript 组件进行单元测试,我们将创建一个新项目。我们项目的最终结果将是一个计算器组件。见图 9-1 。
图 9-1
我们的计算器组件的最终结果
首先,我们将使用本书中一直使用的 CRA 项目模板。您还记得,要设置 CRA·MHL 项目模板,我们只需要一行命令。这将设置我们这个项目所需的大部分。
对于项目名称,我们将使用hello-jest-enzyme-ts
。
$ yarn create react-app hello-jest-enzyme-ts --template must-have-libraries
一旦安装完成,你会得到一个“快乐的黑客!”消息。接下来,我们将使用generate-react-cli
模板 CLI 创建计算器 TS 组件。
$ cd hello-jest-enzyme-ts
$ npx generate-react-cli component Calculator
终端输出将确认这三个文件是为我们创建的:
-
样式表 :
src/components/Calculator/Calculator.scss
-
测试 :
src/components/Calculator/Calculator.test.tsx
-
组件 :
src/components/Calculator/Calculator.tsx
最后,确保您仍然可以运行项目,并确保一切按预期运行。
$ yarn start
这不是我们第一次讨论单元测试。在前面的章节中,我们运行了format
、lint
和test
命令。
$ yarn format & yarn lint & yarn test
你在第二章中学习了格式化和林挺,并为我们的项目安装了所有的测试库。我们还让generate-react-cli
为我们生成测试。我们已经为我们设置了 Jest 测试和package.json
run 脚本。
接下来,让我们回顾一下我们将使用的主要测试库。
-
玩笑
-
有房子吗
-
酶
-
不然呢
Jest 库是什么?
Jest ( https://github.com/facebook/jest
)是一个脸书 JavaScript 单元测试框架。它可以用于任何 JavaScript 项目。
Jest 是一个测试运行器、断言库和嘲讽库。如果需要,它还会提供快照。以下是它的好处:
-
开发者就绪:这是一个全面的 JavaScript 测试解决方案。它适用于大多数 JavaScript 项目。
-
即时反馈:它有一个快速的、交互式的观看模式,只运行与更改的文件相关的测试文件。
-
快照测试:它捕获大型对象的快照,以简化测试并分析它们如何随时间变化。
你可以在这里找到 Jest 文档: https://jestjs.io/docs/en/tutorial-react
。
什么是笑话王国?
Jest-dom ( https://github.com/testing-library/jest-dom
)是一个使用定制匹配器来扩展 Jest 的库,使得对 dom 元素的断言更加容易。
Jest-dom 不需要使用 react 测试库,但是它使得编写我们的测试更加方便。
你可以在这里找到 Jest-dom 文档: https://noriste.github.io/reactjsday-2019-testing-course/book/intro-to-react-testing/jest-dom.html
。
酶是什么?
Enzyme ( https://github.com/enzymejs/enzyme
)是一个为 React 构建的 JavaScript 测试实用程序,可以更容易地测试 React 组件的输出。
在给定输出的情况下,您还可以操纵、遍历和以某种方式模拟运行时。
Enzyme 的 API 旨在通过模仿 jQuery 的 DOM 操作和遍历 API 来实现直观和灵活。
Note
Enzyme 可以不用 Jest,但是需要搭配另一个单元测试框架。你可以在这里找到酵素文档: https://airbnb.io/enzyme/docs/api/
。
不然呢?
Sinon ( https://github.com/sinonjs/sinon
)是一个独立的测试 JS 框架,包括测试间谍、存根和模拟。发音是"叹息-非"
你为什么要学笑话和酶?
Jest+Jest-DOM+Enzyme+React = React 项目的完整测试能力。
Jest 和 Enzyme 很好地集成在一起,提供了灵活和创造性的测试能力。Sinon 增加了功能。
以下是一些有趣的事实:
-
Jest 由脸书创建并用于测试其服务和 React 应用。
-
Create-React-App 捆绑了 Jest 和 Jest-DOM;它不需要单独安装。
-
酵素工具是 Airbnb 创造的。
-
Sinon 可以嵌入 Jest + Enzyme 框架,由 Airbnb 赞助。
如果你认为测试既昂贵又耗时,那就试试而不是测试。从长远来看,这将花费你更多的钱。
你知道吗,QA 和软件测试公司 QualiTest 对 1000 多名美国人进行的一项调查显示,88%的人在遇到错误或故障时会放弃一个应用。
-
其中约 51%的人表示,如果他们每天至少遇到一个 bug,他们可能会完全停止使用某个应用。
-
此外,32%的受访者表示,他们可能会在遇到故障时放弃某个应用。
怎样可以学会笑话和酶?
为了帮助你理解 Jest 和 Enzyme,我将本教程中的过程分解为三个步骤。
-
步骤 1 :设置和配置我们的项目
-
步骤 2 :为自定义计算器组件编写代码
-
第三步:测试代码
如果你想更好地理解我们正在使用的库,我们也会深入了解一下。
创建和测试计算器组件
在本节中,我们将创建自定义的 React 组件,这是一个计算器,并设置一些测试。我们的组件将被打印,我们将利用 Jest 和 Enzyme 来测试组件。
步骤 1:设置和配置我们的项目
对于 Enzyme,我们希望安装 React 16 适配器(这是最新版本,但在您阅读本文时可能会发生变化),它可以与 React 17 一起工作。我们还需要安装react-test-renderer
,这样我们就可以将 React 组件呈现为纯 JavaScript 对象,而不依赖于 DOM。这将有助于我们拍摄快照。
Note
酶、酶适配器和 JSON 库的酶都是 CRA·MHL 模板项目自带的,所以不需要安装它们。不过,如果您想从 CRA 的普通版本开始,下面是命令:
$ yarn add enzyme enzyme-adapter-react-16 react-test-renderer
我们将使用 Jest 的快照测试特性来自动将 JSON 树的副本保存到一个文件中,并通过我们的测试来检查它是否没有改变。
在这里阅读更多关于这个特性: https://reactjs.org/docs/test-renderer.html
。
我们想让我们的生活更简单,所以我们将安装enzyme-to-json
库( https://github.com/adriantoine/enzyme-to-json#readme
)来简化我们的代码。查看本章中的“引擎盖下”一节,了解更多详情。
$ yarn add -D enzyme-to-json
对于我们的计算器,我将使用 macOS 内置计算器来计算我的图形。我将使用图像映射来映射每个键,把图像变成一个可点击的按钮。
为了做到这一点,我们将安装一个react-image-mapper
组件( https://github.com/coldiary/react-image-mapper
),它让我们映射图像的区域。
$ yarn add react-image-mapper
我们已经建立了项目,并安装了开始项目所需的所有库。
步骤 2:创建我们的定制组件
在代码级别,我正在创建一个定制的计算器组件。我所做的是截取一张 macOS 计算器的截图,然后使用一个在线工具将该图像(image-map.net
)映射到一个可点击的地图区域。我没有将图形用于生产部署。我在这里只是用它来说明测试过程。如果您构建生产代码,您将需要获得使用该图形的权限。此外,请注意我使用的是image-map.net
,但是有许多在线工具和程序来绘制图像,我与该网站没有任何关系。
您可以从 Apress GitHub 下载整个项目。
https://github.com/Apress/react-and-libraries/09/hello-jest-enzyme-ts
我们一起来复习一下代码。
// src/components/Calculator/Calculator.tsx
import React from 'react'
import './Calculator.scss'
注意,对于ImageMapper
组件,我在import
语句前放置了一个@ts-ignore
注释。那是因为ImageMapper
组件是 JS 组件,不是为 TS 设置的,没有类型。这将导致 Lint 编译时错误。
// @ts-ignore
import ImageMapper from 'react-image-mapper'
接下来,我将设置放置图像的 URL,并绘制图像区域。我正在使用export
常量语句,所以如果需要的话,我可以在我的测试文件中使用相同的const MAP
代码。
export const URL = 'calculator.jpg'
export const MAP = {
name: 'my-map',
areas: [
{
name: '0',
shape: 'rect',
coords: [3, 387, 227, 474],
},
{
name: '1',
shape: 'rect',
coords: [2, 291, 112, 382],
},
{
name: '2',
shape: 'rect',
coords: [116, 290, 227, 382],
},
{
name: '3',
shape: 'rect',
coords: [342, 382, 232, 290],
},
{
name: '4',
shape: 'rect',
coords: [3, 194, 111, 290],
},
{
name: '5',
shape: 'rect',
coords: [115, 193, 227, 290],
},
{
name: '6',
shape: 'rect',
coords: [231, 194, 343, 290],
},
{
name: '7',
shape: 'rect',
coords: [4, 97, 111, 191],
},
{
name: '8',
shape: 'rect',
coords: [115, 99, 227, 191],
},
{
name: '9',
shape: 'rect',
coords: [231, 98, 343, 191],
},
{
name: '+',
shape: 'rect',
coords: [348, 291, 463, 382],
},
{
name: '-',
shape: 'rect',
coords: [348, 195, 463, 290],
},
{
name: '*',
shape: 'rect',
coords: [348, 98, 463, 191],
},
{
name: '/',
shape: 'rect',
coords: [348, 3, 463, 93],
},
{
name: '=',
shape: 'rect',
coords: [348, 387, 463, 474],
},
],
}
接下来,我用prop
接口(ICalculatorProps
)和状态接口(ICalculatorState
)设置类定义语句。我也在为类本身设置一个接口(ICalculator
)。这样做的原因是我可以转换我的类的一个实例,我将在我的测试中使用这个接口。
对于ICalculator
,它将具有与 React 组件语句相同的签名。它需要包含我们将要定义和使用的类的方法:startOver
、calculateTwoNumbers
和clicked
。对于startOver
,我将它设置为?
只是为了向你展示如何设置一个不需要实现的方法。
export interface ICalculator extends React.PureComponent<ICalculatorProps, ICalculatorState> {
startOver?(): void
calculateTwoNumbers(num1: number, num2: number, operator: string): void
clicked(btnName: string): void
}
对于ICalculatorProps
,我将传递一个标题和版本。
interface ICalculatorProps {
componentTitle: string
version: string
}
对于ICalculatorState
,我们需要存储计算器的输出,即运算符类型和我们正在处理的两个数字。
interface ICalculatorState {
output: number
operatorType: string
number1: number
number2: number
}
现在我们已经准备好了接口,我们可以定义我们的Calculator
类了。
export default class Calculator
extends React.PureComponent<ICalculatorProps, ICalculatorState>
implements ICalculator {
constructor(props: ICalculatorProps) {
super(props)
对于初始状态,我们设置默认值。
this.state = {
output: 0,
operatorType: '',
number1: 0,
number2: -1,
}
}
startOver
方法只是将初始默认值设置回计算器。我们可以用它来重新开始,计算新的数字。
startOver = () => {
this.setState((prevState) => {
return {
...prevState,
operatorType: '',
number1: 0,
number2: -1,
output: 0,
}
})
}
我们举重若轻的方法是calculateTwoNumbers
。它需要两个数字和一个运算符,然后进行数学运算。
calculateTwoNumbers = (num1: number, num2: number, operator: string) => {
let retVal = 0
switch (operator) {
case '+':
retVal = num1 + num2
break
case '-':
retVal = num1 - num2
break
case '*':
retVal = num1 * num2
break
case '/':
retVal = num1 / num2
break
default:
// eslint-disable-next-line no-alert
alert('Operator not recognized')
}
return retVal
}
点击处理程序期望按钮名称被传递,一个开关将处理不同的用例。我只实现了数字、加号和等号按钮,但是请继续并完成这段代码。
clicked = (btnName: string) => {
switch (btnName) {
case '-':
case '*':
case '/':
case '+':
this.setState((prevState) => {
const newState = Number(prevState.output)
return {
...prevState,
operatorType: btnName,
number1: newState,
}
})
break
case '=':
this.setState((prevState) => {
const newState = this.calculateTwoNumbers(
prevState.number1,
Number(prevState.output),
prevState.operatorType
)
return {
...prevState,
output: newState,
}
})
break
default:
this.setState((prevState) => {
const isFirstDigitNumber2 = prevState.operatorType && prevState.number2 === -1
const newNumberState = isFirstDigitNumber2 ? 0 : prevState.number2
const newOutput = isFirstDigitNumber2
? Number(btnName)
: Number(prevState.output + btnName)
return {
...prevState,
number2: newNumberState,
output: newOutput,
}
})
}
}
对于 render 方法,我们将创建一个按钮来单击重新开始,并将其链接到startOver
方法。对于计算器,我们将使用设置了映射区域MAP
的ImageMapper
组件。
render() {
return (
<>
<a href="http://twitter.com/elieladelrom" className="follow">
@elieladelrom
</a>
<h1 className="title">
{this.props.componentTitle} - Version #{this.props.version}
</h1>
<p>
<button id="btn" onClick={this.startOver}>Start Over</button>
</p>
<div className="calculator-output">{this.state.output}</div>
<ImageMapper src={URL} map={MAP} onClick={(area: { name: string }) => { this.clicked(area.name) } } />
</>
)
}
}
注意,我已经用我的 Twitter 句柄添加了一个div
。这可能看起来像是无耻的自我推销,但我想把它包括进来,以便向您展示如何做一些测试。
这与允许我们重新开始的按钮和我们将设置为属性的标题是一样的。我们将使用这两者进行测试。
对于 SCSS,我正在创建一个样式来格式化我的输出。
// src/components/Calculator.scss
.calculator-output
{
background:url('/calculator-input.jpg') no-repeat right top;
color:#fff;
font-size:50px;
width: 464px;
height: 120px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
}
接下来,我们需要更新我们的App.js
文件以包含我们的定制组件,这样当我们转到http://localhost:3000
时就可以看到我们的组件。
// src/App.ts
import React from 'react';
import './App.scss';
import Calculator from "./components/Calculator";
function App() {
return (
<div className="App">
<Calculator componentTitle="Online Calculator" version="0.01-beta"/>
</div>
);
}
export default App
;
就这样。我们现在可以运行代码($ yarn start
),并且能够查看和使用我们的计算器。
正如我之前提到的,我没有映射所有的计算器键和功能,只是基本的,但可以随意完成任务。最终结果见图 9-1 。
步骤 3:测试代码
在我们运行测试之前,我们需要对我们的测试环境做更多的配置。我还想向您展示开箱即用的产品。好消息是,我们需要的大部分已经为您设置了 CRA MHL 模板项目。
设置适配器
打开src/setupTests.ts
,你会看到它配置了酶适配器和 Jest-dom。这允许我们添加自定义 Jest 匹配器来断言 DOM 节点。
// src/setupTests.ts
import '@testing-library/jest-dom/extend-expect'
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16'
configure({ adapter: new Adapter() });
使用 enzyme-to-json 设置快照
为了获得快照,我们需要在package.json
文件中创建snapshotSerializers
标签。这将创建一个快照并加速 Jest 测试。
// package.json
"jest": {
"snapshotSerializers": [
"enzyme-to-json/serializer"
]
},
一旦我们的测试发生变化,快照将需要更新(按 U 键更新),否则您将收到一条错误消息。参见图 9-2 。
图 9-2
测试套件中的快照失败
这在我们运行 Jest 并持续使用 Jest watcher 特性的--watch
标志时尤其有用。
Tip
Jest 中的--watch
标志将持续运行您的测试。
你不需要设置任何东西。package.json
文件已经包含了一个 NPM 运行脚本,它使用react-scripts
来运行带有观察器的测试。
// package.json
"scripts": {
...
"test": "react-scripts test"
}
对于我们的测试,我们有两个现成的测试,CRA·MHL 负责App.test.tsx
和AppRouter.test.tsx
。事实上,在前面的章节中,我们运行的是$yarn
测试命令,这些测试是为我们运行的。
测试应用组件:App.test.tsx
打开通过 CRA 模板项目提供的src/App.test.js
文件。我们在这里的测试是为了确保我们的计算器组件包含在我们的App.tsx
组件中,并且不会崩溃。
// src/App.test.tsx
describe('<App />', () => {
let component
beforeEach(() => {
component = shallow(<App />)
})
test('It should mount', () => {
expect(component.length).toBe(1)
})
})
测试路由组件:AppRouter.test.tsx
AppRouter.test.tsx
也是如此。我们希望确保添加路由时不会崩溃。我们还可以进行其他测试。这只是基本的测试,理想情况下,我们希望涵盖每个功能。
// src/AppRouter.test.tsx
import React from 'react'
import { shallow } from 'enzyme'
import AppRouter from './AppRouter'
describe('<AppRouter />', () => {
let component
beforeEach(() => {
component = shallow(<AppRouter />)
})
test('renders without crashing', () => {
expect(component.length).toBe(1)
})
})
现在我们已经准备好了第一个测试,我们可以运行它了。
为了在我们的模板上运行我们的测试,我们的项目已经配备了一个测试脚本来处理一切。我们可以使用 CRA 提供的测试 NPM 脚本。运行测试命令。
$ yarn test # or npm run test
我们第一次使用测试脚本时,我们得到一条消息,如下所示:
Watch Usage
› Press a to run all tests.
› Press f to run only failed tests.
› Press u to update failing snapshots.
› Press q to quit watch mode.
› Press p to filter by a filename regex pattern.
› Press t to filter by a test name regex pattern.
› Press Enter to trigger a test run.
现在的情况是创建了一个快照,我们可以按 A 来运行所有的测试。这个脚本将继续运行,并不断更新,因此您可以确保在编写代码时没有破坏任何功能。
我们获得了完整的测试套件、测试和测试的快照结果。见图 9-3 。
图 9-3
测试套件、测试和快照测试结果
我们只创建了一个测试。然而,当我们运行generate-react-cli
命令时,我在templates/test.js
中为您包含的模板文件为我们创建了测试,这是一个典型的测试模板。让我们看一下代码。
// src/components/Calculator/Calculator.test.tsx
import React from 'react';
import { shallow } from 'enzyme';
import Calculator from './Calculator';
describe('<Calculator />', () => {
let component;
beforeEach(() => {
component = shallow(<Calculator />);
});
test('It should mount', () => {
expect(component.length).toBe(1);
});
});
测试是检查计算器组件是否已安装并且没有出现故障。我希望您注意到,在我们的计算器组件文件夹(src/components/Calculator
)中,添加了一个名为__snapshots__
的新文件夹。见图 9-4 。
图 9-4
计算器组件内的快照文件夹
这个文件夹保存了测试文件Calculator.test.tsx.snap
,只要我们的 Yarn 测试脚本还在运行,它就会被更新。要停止脚本,只需按 Command+C。
当我们更改代码时,测试观察特性会告诉我们测试中断了。例如,要破解代码,清空Calculator.test.tsx
文件的内容并保存它。见图 9-5 。正如您所看到的,由于测试文件中至少缺少一个测试,我们的测试中断了。
图 9-5
由于清除 Calculator.tsx 文件中的代码,测试套件失败
创建我们的计算器组件测试文件
Jest 的工作方式是,它将在以下任何位置查找测试文件:
-
__tests__
文件夹中带有.js
和.ts
后缀的文件 -
带有
.test.js
和.test.ts
后缀的文件 -
带有
.spec.js
和.spec.ts
后缀的文件
generate-react-cli
自动为我们创建Calculator.test.tsx
。我喜欢测试后缀,因为它很简单,但是.spec.js
是其他框架的标准,比如 Angular,所以无论你选择什么都可以。
让我们重构我们的代码。在我们的组件测试文件中,我们可以测试一些东西。首先导入 React,我们将使用的酶 API(shallow
和mount
,以及我们的自定义组件计算器。
// src/components/Calculator/Calculator.test.tsx
import React from 'react'
import { shallow } from 'enzyme'
import Calculator, { ICalculator } from './Calculator'
我们需要 React 库,Enzyme 项目中的shallow
和mount
特性来访问 DOM 上的方法,最后是我们创建的ICalculator
接口,这样我们就可以对我们的对象进行造型。
非交互组件的单一测试
为了创建一个非交互式组件的单一测试,我们可以测试我的无耻的自我推销 Twitter 帐户。
我们可以为我们的定制组件创建一个包装器,并且可以使用带有find
方法的shallow
特性来访问我们的链接 DOM 标签中的文本字段。
// non-interactive components - using it (single test)
it('should render the link url', () => {
const wrapper = shallow(<Calculator componentTitle="Online `Calculator" version="0.01-beta" />)
const a = wrapper.find('a')
const result = a.text()
expect(result).toBe('@elieladelrom')
})
Note
要创建一个单独的测试,您可以使用test
关键字或者it
关键字。他们是一样的,只是别名。
快照测试套件
到目前为止,我们已经创建了一个测试。但是,如果我们想将几个测试组合在一起呢?为此,我们将使用一个测试套件。一个测试套件创建了一个将几个测试组合在一起的模块。我们可以列出所有我们想包含的测试。
要创建一个测试套件,我们可以使用以下格式将这些单个测试组合在一起:
describe('`Calculator Snapshots', () => {
it ...
it ..
})
例如,要确保我们的包装器与快照匹配,并将测试包装为测试套件,请使用:
describe('`Calculator Snapshots', () => {
it('should render our Snapshots correctly', () => {
const wrapper = shallow(<Calculator componentTitle="Online `Calculator" version="0.01-beta" />)
expect(wrapper).toMatchSnapshot()
})
})
测试组件属性
例如,使用测试套件设置一个测试来检查我们从主入口点传递过来的标题上的props
,这个过程类似于我们之前的测试。我们设置一个包装器,并根据我们注入的数据检查h1
文本标签。
// it(is aliased by test so it does the same thing as it)
test('should render component title', () => {
const wrapper = shallow(<Calculator componentTitle="Online `Calculator" version="0.01-beta" />)
const title = wrapper.find('h1.title').text()
expect(title).toBe('Online `Calculator - Version #0.01-beta')
})
测试交互式按钮
对于交互式组件,我们可以使用simulate
方法来模拟用户点击按钮的手势。然后我们可以比较输出中的结果。我们正在重新开始,所以输出应该会清除。
test('Testing output indirectly - should clean our result box clicking clear', () => {
const wrapper = shallow(<Calculator componentTitle="Online `Calculator" version="0.01-beta" />)
const btn = wrapper.find('#btn')
btn.simulate('click')
const output: string = wrapper.find('.calculator-output').text()
expect(output).toBe('0')
})
测试套件以直接测试功能
有时候我们需要直接测试函数。看一看。我在这里创建一个测试套件来测试我的calculateTwoNumbers
。在测试套件中,我可以包含对所有不同操作符的测试。
describe('Testing `Calculator calculateTwoNumbers testsuite directly', () => {
test('Testing calculateTwoNumbers Directly - add', () => {
const wrapper = shallow(<Calculator componentTitle="Online `Calculator" version="0.01-beta" />)
const instance = wrapper.instance() as ICalculator
expect(instance.calculateTwoNumbers(1, 2, '+')).toBe(3)
})
test('Testing calculateTwoNumbers Directly - multiple', () => {
const wrapper = shallow(<Calculator componentTitle="Online `Calculator" version="0.01-beta" />)
const instance = wrapper.instance() as ICalculator
expect(instance.calculateTwoNumbers(2, 2, '*')).toBe(4)
})
})
注意,包装器上的instance()
属性非常强大,因为我们可以访问我们的方法。
因为我们使用的是 TS 而不是 JS,所以我们可以转换我们创建的ICalculator
接口来访问我们的方法(静态类型)并确保我们的类型是正确的。这个东西很重要,因为它真的让 TS 比普通的 JS 更耀眼。
const instance = wrapper.instance() as ICalculator
instance.calculateTwoNumbers(...)
测试交互按钮和我们的状态
使用instance()
,我们也可以测试我们的状态。例如,这里有一个测试来检查如果我们单击 1 按钮,输出会产生 1:
test('test clicked calculator button method', () => {
const wrapper = shallow(<Calculator componentTitle="Online `Calculator" version="0.01-beta" />)
const instance = wrapper.instance() as ICalculator
instance.clicked('1')
expect(wrapper.state('output')).toBe(1)
})
使用间谍进行测试
一旦我们的应用增长,我们可能需要与异步数据交互,Jest 有内置的功能来处理模拟数据和函数。
再看更高级的测试题目比如间谍和 mock:https://jestjs.io/docs/en/jest-object
。
test('spy', () => {
const wrapper = shallow(<Calculator componentTitle="Online `Calculator" version="0.01-beta" />)
const instance = wrapper.instance() as ICalculator
jest.spyOn(instance, 'startOver')
wrapper.find('button').simulate('click')
expect(wrapper.state('output')).toBe(0)
})
一旦我们所有的测试套件和存根都准备好了,您应该会看到如图 9-6 所示的测试输出。
图 9-6
包括所有计算器测试的快照摘要
在后台
这里有很多事情正在发生。我想指出一些可以帮助你的事情。
酶包括三种渲染方法。我们只用了Shallow
,但是还有Mount
和Render
。这些方法提供了对 DOM 树的访问。
增加
这些是Mounting
的优势:
-
包括子组件的完整 DOM 呈现
-
非常适合需要与 DOM API 交互的组件或者必须使用 React 生命周期方法的用例
-
允许访问直接传入根组件的
props
(包括默认的props
)和传入子组件的props
浅的
这些是Shallow
的优势:
-
仅渲染单个组件,不渲染子组件。这对于隔离纯单元测试的组件很有用。它防止子组件中的更改或错误。
-
默认情况下,浅层组件可以访问生命周期方法。
-
它不能访问传入根组件的
props
,但是它可以访问传入子组件的 props,并且可以测试传入根组件的props
的效果。 -
当我们调用
shallow(<Calculator />)
时,我们测试的是计算器呈现的内容,而不是我们传递给Shallow
的元素。
提出
这些是渲染的优点:
-
它呈现静态 HTML,包括子元素。
-
它无权使用 React 生命周期方法。
-
与其他 API 相比,它使用的资源较少,但功能较少。
酶转 json
在我们的代码中,我们使用了enzyme-to-json
,但是为什么呢?
这样做是为了我们的快照比较有一个比酶的开箱即用的内部组件表示更好的组件格式。
在使用快照时,snapshotSerializers
API 允许我们编写更少的代码并消除重复。在将组件传递给 Jest 的快照匹配器之前,我们不需要每次用a.toJson()
创建组件时都进行序列化。
expect(toJson(rawRenderedComponent)).toMatchSnapshot();
我们通过在package.json
中添加snapshotSerializers
来做到这一点。
"snapshotSerializers": ["enzyme-to-json/serializer"],
然后,我们能够将一个由 Enzyme 创建的组件传递给 Jest .toMatchSnapshot()
方法,而不使用toJson
语法。
expect(wrapper).toMatchSnapshot()
有火柴吗
当我们编写测试时,我们需要检查我们的测试值是否满足某些条件。Expect API 让我们可以访问许多“匹配器”,帮助我们验证不同的东西。
Jest 方面,Expect API 为大多数用户提供了足够的选项;https://jestjs.io/docs/en/expect
见。但是,如果您找不到您想要的,请查看 Jest 社区的jest-extended
以获得更多匹配,或者如果您找不到您需要的,请创建您自己的匹配。
否则图书馆
另一个我们应该知道并添加到我们工具箱中的必备库是 Sinon ( https://github.com/sinonjs/sinon
)。您不需要添加它,因为它已经包含在我们的 CRA 模板项目中。(如果您确实需要添加它,您可以使用yarn add sinon @types/sinon
。)
玩笑和兴农的目的是一样的。那么为什么要加西农呢?
答案是,有时您可能会发现一个框架比另一个框架更自然、更容易用于您需要的特定测试,所以两个都有也无妨。可以对比一下 Jest ( https://jestjs.io/docs/en/api
)和 Sinon ( https://sinonjs.org/releases/v9.2.0/
)上的 API 列表。
举个例子,伪造计时器。这两个框架都有伪造时间的方法。
幸运的是,我们可以添加 Sinon 并使用 Jest 或 Sinon 进行测试。他们一起工作,所以我们不需要选择。
假设我们想要为我们的计算器创建一个加载器,因为我们需要一个服务调用或者等待我们需要的任何东西。
这次我们可以从测试开始,而不是先写代码。为此,我们可以使用测试驱动开发(TDD ),首先编写测试,然后让测试失败。最后,我们编写通过测试的代码。
TDD 是一个软件开发过程,它依赖于一个开发周期,这个周期需要建立测试用例,然后编写代码以便通过测试。
例如,假设我们的需求是,我们需要放置一个带有消息的副标题,告诉用户加载阶段已经完成。
使用假 Sinon 计时器的测试看起来会是这样的:
import sinon from 'sinon'
describe('Loader component', () => {
it('should render complete after x seconds', () => {
const wrapper = shallow(<Calculator componentTitle="Online `Calculator" version="0.01-beta" />)
const clock = sinon.useFakeTimers()
const instance = wrapper.instance() as ICalculator
instance.startLoader(3000)
clock.tick(3000)
const title = wrapper.find('h1.subTitle').text()
expect(title).toBe('Loading Complete')
})
})
我们这里的代码使用了sinon.useFakeTimers
,一旦我们调用了我们的类startLoader
上的方法,我们就可以启动时钟:clock.tick(3000)
。最后,我们确保副标题更改为“加载完成”
现在,在我们的Calculator.tsx
代码中,让我们重构ICalculator
接口和ICalculatorState
状态的签名。
export interface ICalculator extends React.PureComponent <ICalculatorProps, ICalculatorState> {
...
startLoader(seconds: number): void
}
interface ICalculatorState {
...
LoaderStatus: string
}
接下来,我们可以让一个计时器在组件挂载后开始运行三秒钟,并设置一个startLoader
方法来启动计时器并更新状态。
componentDidUpdate() {
this.startLoader(3000)
}
startLoader = (seconds: number) => {
setTimeout(() => {
this.setState((prevState) => {
const newState = 'Loading Complete'
return {
...prevState,
LoaderStatus: newState,
}
})
}, seconds)
}
最后,在渲染 JSX 端,添加一个带有字幕的h1
标签。
render() {
return (
<>
...
<h1 className="subTitle">
{this.state.LoaderStatus}
</h1>
...
</>
)
}
}
运行测试以确保一切按预期运行。
测试路由页面
在我们结束本章之前,我想指出 CRA 模板项目附带的另一个测试文件。
如果您还记得,在之前的练习中,我们使用路由构建了我们的应用。我们的路由页面正在扩展RouteComponentProps
。这很有用,因为我们能够使用路由 API 并提取路由页面的名称。例如,对于文章页面,我们设置了这个接口签名,它是通过generate-react-cli
为我们创建的模板页面。
interface IArticlesPageProps extends RouteComponentProps<{ name: string }> {
// TODO
}
此接口使用路由历史 API 提取页面名称;然后我们就可以在渲染中使用这个名字了。
generate-react-cli
还为我们创建了一个名为ArticlesPage.test.tsx
的配套文件,这是一个 Jest 测试页面,包括一个通过routeComponentPropsMock
访问路由的方法,这是我创建的一个模拟对象。
注意,routeComponentPropsMock
只实现了RouteComponentProps
的历史 API,所以如果你使用其他 API,你需要在routeComponentPropsMock
对象中模仿它们。看一看:
// ArticlesPage.test.tsx
import React from 'react'
import { shallow } from 'enzyme'
import ArticlesPage from './ArticlesPage'
const routeComponentPropsMock = {
history: {
location: {
pathname: '/ArticlesPage',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
location: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
match: {} as any,
}
describe('<ArticlesPage />', () => {
let component
beforeEach(() => {
component = shallow(<ArticlesPage {...routeComponentPropsMock} />)
})
test('It should mount', () => {
expect(component.length).toBe(1)
})
})
我们可以确认应用正在通过format
、lint
和test
命令。
$ yarn format & yarn lint & yarn test
我想指出的是,lint
和format
命令也会检查测试文件,所以我们使用 Airbnb 样式设置的林挺规则仍然适用。
摘要
在这一章中,我们关注单元测试。我将这个过程分为三个步骤:设置和配置项目、编写代码和测试代码。
你学习了 Jest、Jest-dom、Enzyme 和enzyme-to-json
。您学习了如何在 React 组件上执行不同类型的单元测试,更好地理解相关的库,创建快照,创建单个测试,以及在测试套件中对测试进行分组。
我们查看了我为您设置的模板的单元测试组件和 page 的测试模板,以及App.test.tsx
和AppRouter.test.tsx
测试。
我也给了你一些额外的“引擎盖下”的东西,最后,我们考虑添加 Sinon 库和 TDD。
在下一章,我们将深入集成测试。
十、测试第二部分:开发和部署周期
在前一章中,我们在 Jest、Jest-dom、Enzyme 和 Sinon 库的帮助下进行了单元测试。单元测试只是测试的一种。除了单元测试,端到端(E2E)测试模拟真实的最终用户体验,集成测试结合了测试。
这一章分为两部分。在第一部分中,我将介绍 E2E 测试,并向您展示如何使用 Jest 和 Puppeteer 库来实现它。在本章的第二部分,我将介绍如何创建一个完整的自动化开发和部署集成周期,包括完整的代码覆盖,以及如何测试您的代码并确保质量得到维护。
执行端到端测试
端到端(E2E)集成,模拟真实的最终用户体验。E2E 测试通过使用一个无头浏览器在一个真实的网络环境中进行模拟。
在本章的这一节中,我将借助 Jest 和 Puppeteer 库重点介绍 CRA 的 E2E 测试。
你为什么应该关心 E2E 测试?
E2E 测试是一种从头到尾测试我们的应用工作流程的方法。我们在 E2E 所做的是复制真实的用户场景,以便我们的应用在集成和数据完整性方面得到验证。
您可能会惊讶地发现,大多数公司都愿意部署一个完整的质量保证(QA)人工测试团队,但是不重视投资 E2E 测试。
E2E 是我经常听到开发人员说的任务之一,“我们总有一天会到达那里;这是我们的遗愿清单。”但是那一天永远不会到来,因为要专注于特性和满足紧张的最后期限。
是的,建立 E2E 测试确实需要时间,但是 E2E 测试减少了人为错误,并且有助于交付高质量的软件。一些公司已经承认了这一点,事实上,近年来,我已经看到一些公司开始重视 E2E 测试,并让全职开发人员参与进来,仅仅是为了创建和维护测试。
你知道吗,根据 QualiTest 的调查,几乎 90%的人表示,如果他们遇到错误或故障,他们会放弃一个应用,并每天进行测试。
为什么打字稿?
在本书中,我们使用了 TS。TS 在测试方面大放异彩。使用 TS,可以更容易地调试和测试你的应用,并通过描述预期的对象来防止潜在的问题。
为什么是木偶师?
以下是一些 E2E 选项及其受欢迎程度:
-
木偶师(
https://github.com/puppeteer/puppeteer
):66100 星 -
柏树(
https://github.com/cypress-io/cypress
):24200 颗 -
硒(
https://github.com/SeleniumHQ/selenium
):18900 颗 -
nigh watch . js(
https://github.com/nightwatchjs/nightwatch
):一万颗星星 -
cucumber.js (
https://github.com/cucumber/cucumber-js
):四千星
如你所见,基于 GitHub stars,木偶师是最受欢迎的 E2E 图书馆。它是基于 Chrome 的,也是我们将要使用的(见图 10-1 )。
图 10-1
木偶师标志
木偶库是由谷歌 Chrome 团队创建的。木偶师让我们控制 Chrome(或 Chrome DevTools 基于协议的浏览器)并执行常见的动作,就像在真正的浏览器中一样——这意味着通过 API 以编程方式进行。
“puppeter 是一个节点库,它提供了一个高级 API 来控制 Chrome 或 DevTools 协议上的 Chrome。默认情况下,Puppeteer 无头运行,但可以配置为运行完整(非无头)Chrome 或 Chrome。
我们能对木偶师做些什么并做出 React?
-
从页面中生成截图和 pdf
-
爬网页面
-
自动化用户交互
-
捕获站点的时间线跟踪,以帮助诊断性能问题
-
可以独立使用它们,也可以将它们与 Jest 或 Mocha 等其他流行的 React 测试框架集成在一起
为什么是玩笑和玩笑木偶师?
我们在上一章已经用过 Jest ( https://github.com/facebook/jest
)(见图 10-2 )。在这一章中,我们将整合 Jest 木偶师( https://github.com/smooth-code/jest-puppeteer
)。Jest Puppeteer 附带了在运行测试套件时启动服务器的功能。此外,Jest Puppeteer 可以在测试完成后自动关闭服务器。
图 10-2
有一个标志
如何将 E2E 测试集成到我的 CRA React 应用中?
为了帮助您理解如何在 CRA 项目中使用 Jest 和 Puppeteer,我将本节中的过程分为三个步骤。
-
步骤 1 :设置和配置我们的项目
-
第二步:编写代码
-
第三步:运行 E2E 测试
我们开始吧。
步骤 1:设置和配置我们的项目
在 Jest 的小丑鞋图标和木偶师标志之间,这里开始感觉像一个马戏团。但是说真的,正确地设置您的项目并不是一个玩笑,可能会比您预期的花费更多的时间,特别是因为我们需要考虑 CRA,它有一些固执己见的库需要配置,以及需要额外配置步骤的 TS。
开始运行 TS、Jest、Puppeteer、Jest、Enzyme 和其他必备库的 CRA 项目的最简单方法是使用我在本书中使用的 CRA 模板。让我们称我们的测试项目为e2e_testing_with_puppeteer.
$ yarn create react-app e2e_testing_with_puppeteer --template must-have-libraries
Tip
什么都配置好了,什么都不需要设置!
CRA MHL 项目已经包括以下:puppeteer
、jest-puppeteer ts-jest,
和类型。
$ yarn add puppeteer jest-puppeteer ts-jest @types/puppeteer @types/expect-puppeteer @types/jest-environment-puppeteer
除了类型puppeteer,
,我们还有@types/expect-puppeteer
用于jest-puppeteer
的断言库,我们需要使用@types/jest-environment-puppeteer
来提供全局浏览器定义。你可以在 https://github.com/smooth-code/jest-puppeteer
找到更多信息。
为了确认一切按预期工作,运行yarn start
(见图 10-3 )。
图 10-3
起始项目模板
$ cd e2e_testing_with_puppeteer
$ yarn start
如果你查看你的项目的文件夹(图 10-4 ,你可以看到有一个名为e2e
的文件夹包含了测试和 Jest 配置。
图 10-4
e2e 文件夹包括测试和 Jest 配置
在下一节中,我将深入介绍为您设置的配置和文件,以便您完全了解如何设置 E2E。但是,如果您使用的是 CRA MHL 模板项目,则不需要设置这些。
配置我们的项目
在我们开始配置我们的项目之前,这里有一些日常规则。我正在 E2E 文件夹中设置 E2E 集成测试。E2E 可以与 Jest 的单元测试集成在一起;然而,我个人认为最好将单元测试和 E2E 测试的任务分开。也就是说,如果您想要或需要它们在一起,可以随意更改这种配置。
为了让我们的 E2E 测试正常工作,添加并重构了一些文件。
-
jest-puppeteer.config.js
:指定一个服务器。 -
.env
:设置木偶服务器的环境变量。 -
tsconfig.json
:我们使用tsconfig.json
来指定编译 TypeScript 项目所需的根级文件和编译器选项。 -
.eslintignore
:这告诉 ESLint 忽略特定的文件和目录。 -
e2e/global.d.ts
:指定全局类型。我们需要添加一个变量来“神奇地”为我们提供一个接口。 -
那是 Jest 配置文件。
-
我们将添加脚本标签来运行我们的测试。
让我们回顾一下这些文件和我们需要做的事情。
jest-pup Peter . config . js-设定档
文件指定了木偶师的服务器配置。
使用yarn
设置本地启动,但是如果您使用 NPM 管理您的库,您可以将其更改为npm start
。
module.exports = {
server: {
command: `yarn start`,
port: 3000,
launchTimeout: 10000,
debug: true,
},
}
。包封/包围(动词 envelop 的简写)
文件.env
用于设置木偶服务器的环境变量。在这里,我告诉木偶服务器做一个飞行前检查,不要在测试中使用浏览器,所以它将在后台运行。
SKIP_PREFLIGHT_CHECK=true
BROWSER=none
tsconfig.json 文件
我们需要重构这个tsconfig.json
文件来添加我们用于测试的文件夹。这将允许我们编译我们的类型脚本测试。
...
"include": [
...
"e2e"
]
。eslintingnore
需要忽略配置文件,因为我们不需要它们来遵循 React 项目的 Lint 角色。我们可以通过将这些文件添加到.eslintignore
文件的末尾来忽略它们。
e2e/jest.config.js
e2e/puppeteer_standalone.js
jest-puppeteer.config.js
请注意文件名puppeteer_standalone.js
,我们将创建并很快讨论它来独立运行木偶剧。
e2e/jest.config.js
接下来,我们要重构jest.config.js
。这个文件是 Jest 配置文件。我将它设置在e2e
文件夹中,因为它允许我定义不同的jest.config.js
文件(以防我需要它们)。您将看到,当我们定义我们的package.json
脚本时,我们可以引用特定的脚本来使用。
在testRegex
中,我正在设置我们的测试以这种格式设置的项目:[component name].test.tsx
。这里有一个例子:app.test.tsx
。
这种格式与我们设置测试的方式一致,并且这种格式将允许我们的e2e
被发现。
请注意,我正在设置一个全局变量SERVER_URL
,这使得我们的工作变得简单,以防我们需要为所有测试全面更新服务器名称。
module.exports = {
preset: 'jest-puppeteer',
globals: {
SERVER_URL: "http://localhost:3000"
},
testRegex: './*\\.test\\.tsx$'
}
console.log('RUNNING E2E INTEGRATION TESTS - MAKE SURE PORT 3000 IS NOT IN USAGE')
注意,我使用console.log
来显示一条带有注释的消息,提醒您本地主机需要运行这个测试。
这个配置将打开一个无头的 Chrome 浏览器,在端口 3000 上创建我们的测试,所以如果您运行了终端,请确保在终端中使用yarn start
,因为我们不能同时运行两个localhost:3000
实例。
另一种方法是在不同的端口上运行这个独立测试;如果你需要的话,你可以这样配置。我想保持简单。
e2e/全局. d.ts
global.d.ts
文件让我们添加需要设置的 ts 声明。因为我们把SERVER_URL
和JEST_TIMEOUT
放在了jest.config.js
里面,我们的 Lint 规则会因为只使用这些变量而不声明它们而产生矛盾。TS 需要定义所有东西,所以通常我不建议只添加声明,但是这里我们可以例外,因为我们知道我们在做什么。
// globals defined in jest.config.js need to be included in this `d.ts`
// file to avoid TS lint errorsdeclare var SERVER_URL: string
declare var JEST_TIMEOUT: number
package.json
最后,我在package.json
中设置了三个测试。
-
test:e2e
:运行 E2E 测试,指向我们在e2e
文件夹中设置的e2e/jest.config.js
配置文件。 -
这运行我们独立的 NodeJs 木偶脚本。
-
如果我们想让一个观察器运行,我们就用这个。
"scripts": {
...
"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"
}
步骤 2:编写代码
我们将编写一个可以独立运行的独立 Nodejs 脚本和一个 Jest Puppeteer 脚本来测试我们的App.tsx
文件。
我们将创建两个文件。
-
这个独立的文件并不是真的需要,但在某些情况下它会派上用场,例如,如果我们需要在第三方库和脚本上进行测试,或者只是想不带玩笑地进行测试。
-
e2e/app.test.tsx
:为App.tsx
提供 E2E 集成测试。正如我提到的,虽然理论上我们可以一起进行单元测试和 E2E 测试,但是在测试的这一部分,我们将只做 E2E。
让我们看一下这些文件。
e2e/木偶师 _standalone.js
在代码层面上,我们正在创建一个无头 Chrome 浏览器,并测试到http://localhost:3000
的链接将通过使用脚本点击它来打开 URL。然后我们等待两秒钟,关闭 Chromeless 浏览器。
我们需要在http://localhost:3000
(和$yarn start
)上运行我们的应用,所以我留了一条try
- catch
消息,以防你忘记这么做。
const puppeteer = require('puppeteer');
const SERVER_URL = 'http://localhost:3000';
(async function main(){
try {
const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
await page.goto(SERVER_URL, {waitUntil: 'domcontentloaded'});
const urlLink = await page.$('a[href*="https://github.com"]');
if (urlLink) {
await urlLink.click();
} else {
console.log('No "urlLink" found on page');
}
// wait 2 secs and shut down!
await new Promise(resolve => setTimeout(resolve, 2000));
await browser.close();
} catch (error) {
if (error.message.includes('ERR_CONNECTION_REFUSED'))
console.log('Make sure you have React running: $ yarn start');
console.log('Error message', error.message);
}
})();
e2e
对于我们的app.test.tsx
组件的 E2E 测试,我将测试一些东西。
首先,我放置了一个健全检查测试来打开 Google。并检查单词谷歌是否出现。这只是一个磨利剑的示范。随意丢弃它。
我们的应用测试套件将包括以下三项测试:
-
确保编辑在页面上。
-
检查
<a href>
是否正确放置并链接到正确的 URL。 -
检查页面上是否有 React 旋转徽标图像。
要了解更多关于我们可以运行的 E2E 测试的类型,请访问 https://devdocs.io/puppeteer/
。
另外,我的文件以@ts-ignore
开头。这是因为我在这个文件中没有 import 语句,ESLint 会对isolatedModules
有问题,因为它希望这个文件成为模块的一部分。
// @ts-ignore due to isolatedModules flag - no import so this needed
describe('Google', () => {
beforeAll(async () => {
await page.goto('https://google.com', {waitUntil: 'domcontentloaded'})
})
it('sanity check, test Google server by checking "google" text on page', async () => {
await expect(page).toMatch('google')
})
})
// @ts-ignore due to isolatedModules flag - no import so this needed
describe('<App />', () => {
beforeAll(async () => {
await page.goto(SERVER_URL, {waitUntil: 'domcontentloaded'})
}, JEST_TIMEOUT)
it('should include "edit" text on page', async () => {
await expect(page).toMatch('Edit')
}, JEST_TIMEOUT)
it('should include href with correct link', async () => {
const hrefsArray = await page.evaluate(
() => Array.from(
document.querySelectorAll('a[href]'),
a => a.getAttribute('href')
)
)
expect(hrefsArray[0]).toMatch('https://github.com/EliEladElrom/react-tutorials')
}, JEST_TIMEOUT)
it('should include the React svg correct image', async () => {
const images = await page.$$eval('img', anchors => [].map.call(anchors, img => img['src']));
expect(images[0]).toMatch(SERVER_URL + '/static/media/logo.5d5d9eef.svg')
}, JEST_TIMEOUT)
})
因为 Jest 设置了 5000 毫秒的超时,这可能不足以运行您的测试,所以我将组件设置为不同的超时,以确保我不会收到这个不好的消息。请随意更改超时时间。以下是您将收到的 Jest 超时错误消息:
Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.Error: Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.
这里我不做任何用户交互,因为唯一的交互是点击页面上的链接,我们已经在独立的例子中测试过了。如果你想看另一个例子,比如 CRA 测试一个表单元素并填充表单,请看 https://github.com/smooth-code/jest-puppeteer/tree/master/examples/create-react-app
。
步骤 3:执行 E2E 测试
既然我们已经编写了独立的 E2E NodeJS 脚本,以及对App.tsx
组件的 E2E 测试,我们可以运行这些测试并检查结果。
正如您所记得的,为了运行这些测试,我们在我们的package.json
文件中创建了运行脚本,以便能够用一个命令运行我们的测试。
让我们运行这些。
首先,在单独的窗口中运行以下命令:
$ yarn start # if not running
$ yarn test:e2e-alone
NPM 运行脚本与下面的package.json
命令相关联:
// package.json
"test:e2e-alone": "node e2e/puppeteer_standalone.js"
当您运行该命令时,如果一切顺利,该脚本将使用我们的 localhost 3000 版本打开 Chrome headless 浏览器,并单击链接打开一个新选项卡。然后该脚本在两秒钟内关闭浏览器,您将得到以下消息。参见localhost:3000
单机 E2E 测试结果。
yarn run v1.22.10
$ node e2e/puppeteer_standalone.js
Done in 5.36s.
恭喜你!我们通过了测试。
这发生得很快,因为我们设置 Chrome 在 2 秒内关闭。但是正如你所看到的,脚本打开了一个浏览器,点击了我们的应用中打开另一个窗口的链接(如我们所料),然后关闭了测试!印象深刻,对吧?
接下来,要使用 Jest Puppeteer 运行我们对App.tsx
组件的 E2E 测试,停止本地服务器,并运行名为test:e2e
的 NPM 脚本。
$ yarn test:e2e
这将运行 NPM 运行脚本命令。
// package.json
"test:e2e": "jest -c e2e/jest.config.js --watch"
"test:e2e-watch": "jest -c e2e/jest.config.js --watch"
运行该命令后,您应该会得到以下 E2E 测试 Jest 和 Puppeteer 输出:
Jest dev-server output:
[Jest Dev server] $ react-scripts start
[Jest Dev server] [wds]: Project is running at
[Jest Dev server] [wds]: webpack output is served from
[Jest Dev server] [wds]: Content not from webpack is served
[Jest Dev server] [wds] : 404s will fallback to /
[Jest Dev server] Starting the development server...
RUNS e2e/app.test.tsx
如果 2E 测试结果顺利,您将会收到一条甜蜜的成功消息,如下所示:
PASS
e2e/app.test.tsx (15.264s)
<App />
✅ should include "edit" text on page (189ms)
✅ should include href with correct link (9ms)
✅ should include the React svg correct image (29ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 15.57s, estimated 19s
Ran all test suites.
太棒了。我们能够运行独立的 E2E 测试,并覆盖我们应用的特定部分。
自动化开发和使用持续集成周期
当谈到发布高质量的代码时,有许多方法来配置您的项目,并且关于如何做有许多不同的意见。你应该用什么?应该如何配置?选择似乎是无限的。
在本章的这一部分,我的目的是给你配备一些很棒的工具,以确保你的代码质量很高,并帮助你避免打嗝。
该过程在我们的开发环境中开始,通过建立编码指南,坚持这些指南,并从那里继续创建单元测试,集成这些测试(集成测试),然后进行端到端测试和覆盖。一旦我们做到了这一点,我们就可以进入持续集成(CI)周期了。
Note
持续集成意味着将所有的开发副本合并到一个共享的主线中。尽可能经常这样做。
在 CI 方面,我们的代码仍然需要测试和检查覆盖率、质量和依赖性,以确保我们确实遵循了我们设定的测试和编码指南,找到潜在的编码错误,最后开绿灯以表明部署一切正常。冲洗并重复。
在本章的这一节,我将我的最终 React 质量发展和部署周期,这是赫斯基➤木偶师➤ GitHub 工作流程➤ Codecov.io ➤工作服➤特拉维斯➤ DeepScan。
作为奖励,你可以得到一些很酷的徽章来证明你的项目达到了你设定的标准。图 10-5 显示了整个循环中使用的库。
图 10-5
最终 React 质量开发和部署周期
动机
React 只是一个库,仅此而已。确保工程质量的任务就交给了我们。配置和设置我们项目的开发和部署周期并不容易。您应该选择什么工具,如何确保代码的质量以及您的应用符合您设定的质量水平?
此外,建立一个良好的开发和部署过程是至关重要的一部分;在我看来,这个过程应该在你写第一行代码之前设置好。理想情况下,我们希望快速发布并经常发布,并确保我们的代码质量不会随着代码库的增长而下降。
本章中的过程可以确保你一开始就获得 100%的覆盖率(如果这是你想要的),并确保你一直保持下去。目标不仅是确保您和您的团队实现开发指导方针并编写无错误的代码,理想情况下,而且任何新加入的开发人员也应该遵守这些指导方针,甚至不需要从团队领导那里获得预期实践是什么的解释。
结构
为了帮助您理解,我将该过程分解为以下步骤:
-
第一步:设置
-
哈士奇
-
步骤 3: GitHub 动作
-
第四步:Codecov.io
-
第五步:特拉维斯
-
工作服
-
步骤 7:深度扫描
我们开始吧。
设置项目
你可以从一个新项目开始,或者在你喜欢的任何项目上实现这些改变。
$ yarn create react-app your_project_name --template must-have-libraries
要确认一切正常,运行yarn start
。
$ cd your_project_name
$ yarn start
测试
在编写测试的过程中,我们可能会在源代码中发现新的错误或语法问题,这些问题在发布我们的应用之前必须解决。正如我们所看到的,我们的 CRA·MHL 模板项目附带了单元测试和 E2E 固执己见的库来帮助你以更直观的方式编写你的测试。
新闻报道
一旦我们准备好了单元测试,我们就可以检查覆盖率了。为了建立覆盖率,我们也可以使用 Jest。
Note
代码覆盖率衡量当一个特定的测试套件运行时,我们的源代码被执行了多少。
Jest 允许我们创建不同格式的报告,并设置我们希望从哪里收集这些报告(或不从哪里收集),以及确保coverageThreshold
值。看看我的package.json
设置,如下所示:
// package.json
"jest": {
"coverageReporters": [
"json",
"text",
"html",
"lcov"
],
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*/*.d.ts",
"!src/**/*/Loadable.{js,jsx,ts,tsx}",
"!src/**/*/types.ts",
"!src/**/store.ts",
"!src/index.tsx",
"!src/serviceWorker.ts",
"!<rootDir>/node_modules/",
"!**/templates/**",
"!**/template/**"
],
"coverageThreshold": {
"global": {
"branches": 50,
"functions": 50,
"lines": 50,
"statements": 50
}
},
在这个例子中,我执行 50%的coverageThreshold
。当我设置这个时,它将确保我在我的阈值内测试,否则我将得到一个错误。这很方便,因为我们可以设置这些值来确保每一个函数、语句、行和分支得到至少 50%的测试覆盖率,甚至 100%的测试覆盖率。
在package.json
中,我们可以通过将watchAll
设置为false
来设置我们的运行脚本以包含覆盖率测试,这样一旦测试完成,脚本就会关闭。
// package.json
"scripts": {
...
"coverage": "npm test -- --coverage --watchAll=false"
}
运行脚本后,如下所示:
$ yarn run coverage
您将获得一个用报告创建的文件夹(图 10-6 )。
图 10-6
Jest 覆盖文件夹树和文件
由于我的package.json
文件包含一个 HTML 报告,我们实际上可以打开创建的coverage/index.html
文件,用一个简洁的用户界面查看我们的报告(图 10-7 )。
图 10-7
Jest 覆盖 HTML 报告
您可以根据需要随意配置您的项目。我的建议是,用某些运行脚本来设置您的package.json
文件,以确保代码的格式,并确保风格指南得到实施,以及测试覆盖按照您的要求完成。
使用 Husky 设置 Git 挂钩
接下来,我们将安装一个名为 Husky ( https://github.com/typicode/husky
)的库。
$ yarn add husky
Husky 提供对 Git 挂钩的访问,我们可以使用这些挂钩来确保在允许我们的团队成员提交到我们的 repo 之前成功完成某些任务。看一下这个例子:
// package.json
{
"husky": {
"hooks": {
"pre-commit": "npm test",
"pre-push": "npm test",
"...": "..."
}
}
}
如果你还记得前面的章节,我们在每个完成的练习结束时手动运行format
、lint
和test
。在我们的例子中,我们可以设置一个预提交钩子来运行format
、lint
和单元测试以及 E2E 测试,作为预提交的一个条件。
"husky": {
"hooks": {
"pre-commit": "yarn format && yarn lint && yarn test:e2e && yarn coverage"
}
},
一旦你提交任何代码,这个钩子就会运行。除非这些运行脚本正确无误地完成,否则不允许提交。这将确保我们的代码遵循我们设定的准则、格式和测试。
在 GitHub repo 上设置您的库,并尝试提交以查看预提交的运行情况。
$ git add .
$ git commit -m 'my first commit'
使用 GitHub 操作设置工作流程
现在我们的代码已经在 GitHub 中完成了,我们可以确保代码的质量,然后执行具体的操作。这些操作为我们的存储库增加了自动化。
例如,我们可以确保代码编译,通过覆盖,并通过其他测试,然后将代码上传到生产服务器,或者我们可以将代码上传到其他系统进行分析。
这可以使用 GitHub 动作来完成。要了解更多关于 GitHub 动作的信息,请查看位于 https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions
的 GitHub 文档。这些动作是用一种叫做 YAML 的语言写的。
Note
YAML 是“YAML 不是标记语言”的递归首字母缩写,是一种人类可读的、直观的数据序列化格式。
要创建这些动作,您需要创建这个文件夹结构:.github/workflows
。然后,我们将创建以下 GitHub 操作:
-
Main :运行 Lint,测试 Main 动作,上传到
codecov
。 -
构建:确保构建动作构建。
-
测试:测试并上传测试动作到
coverall
。 -
皮棉:确保我们的项目通过皮棉测试。
手动操作:手. yml
我们的主要任务(main.yml
)与推送提交挂钩。我们将使用 Node.js 版本 12.x 并在 Ubuntu 服务器上运行。我们将安装项目和依赖项,运行 Lint,测试并将我们的代码上传到 Codecov 应用(我们将很快设置我们的帐户)。
name: CIon: [push]jobs:
build:
runs-on: ubuntu-latest strategy:
matrix:
node-version: [12.x] steps:
- uses: actions/checkout@v1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: install dependencies
run: |
yarn
- name: run lint
run: |
yarn lint
- name: run tests
run: |
yarn test --watchAll=false --coverage --reporters=default
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1.0.14
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: build
run: |
yarn build
构建操作:build.yaml
为了构建我们的代码(build.yaml
),我将使用ubuntu-latest
服务器(在撰写本文时,它的版本是 20.10)。默认情况下,将警告视为错误。如果你想禁用默认行为,你可以设置process.env.CI = false
。
你可以在 https://github.com/facebook/create-react-app/issues/3657
了解更多信息。我的钩子将被设置为在推拉请求时运行。看一看:
name: build
on:
- push
- pull_request
jobs:
createAndTestTemplateCRA:
runs-on: ubuntu-latest
steps:
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Settings to fix problem on Ubuntu
run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
- run: sudo sysctl -p
- name: Create CRA from downloaded template
run: npx create-react-app --template cra-template-must-have-libraries .
- name: No need to fail due to warnings using CI=false
run: CI= npm run build
测试操作:test.yaml
我们的测试脚本(test.yaml
)在push
和pull_request
上有一个钩子,并使用节点 v12。对于测试,我们将运行覆盖率脚本,并将代码上传到coverallsapp
。我们将在本章的后面设置连体工作服。
name: test
on:
- push
- pull_requestjobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- run: npm i
- run: npm run coverage
- name: Upload coverage file to Coveralls using lcov.info
uses: coverallsapp/github-action@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
该链接用于创建可用于 coverallsapp 和任何其他第三方工具的个人访问令牌。请遵循以下步骤:
https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token
Lint 行动:lint.yml
对于 Lint,我们已经在开发周期中运行了 ESLint,那么为什么我们在部署周期中还需要这个(lint.yml
)呢?
在这里,我们再次运行 Lint,以确保开发人员实际上正在运行 Lint,并且没有更改他的本地设置和提交没有 Lint 的代码。
name: lint
on:
- push
- pull_request
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm i
- run: npm run lint
最后,您可以为每个 GitHub 动作生成一个徽章,方法是点击 Actions,选择动作名称,然后点击“创建状态徽章”,如图 10-8 所示。
图 10-8
GitHub 操作构建结果和徽章
Codecov.io 的测试覆盖率
Codecov 是一个用来测量你的代码库的测试覆盖率的工具。它通常通过检查在运行单元测试时执行了哪些代码行来计算覆盖率。
为什么是 Codecov?
Codecov 生成的覆盖报告具有增强 CI 工作流程的功能。使用代码覆盖工具激励开发人员编写测试并提高覆盖率。
它允许我们托管我们在本地生成的覆盖报告,因此我们可以检查历史并增强我们的 CI 工作流和团队合作。
“Codecov 专注于整合和促进健康的拉式请求。Codecov 将覆盖率指标直接交付或“注入”到现代工作流中,以提高代码覆盖率,尤其是在新功能和错误修复经常出现的拉式请求中。”
为此,我们可以创建一个名为codecov.yml
的文件。如果您没有设置codecov.yml
,默认配置会自动为您设置。你可以在 https://docs.codecov.io/docs/codecov-yaml
找到 Codecov 文档。
用你的 GitHub 账号登录 Codecov,你会看到你的回购,自从我们在 GitHub action hook 期间上传到 Codecov 之后(图 10-9 )。
图 10-9
Codecov 链接存储库
Travis 的 CI
Travis 是一个托管的 CI 服务,用于构建和测试我们的代码。
为什么是特拉维斯?
通过让 Travis 调用连体工作服运行脚本.travis.yml
,可以将连体工作服设置为连接到 Travis。
language: node_js
sudo: false
node_js:
- "stable"
branches:
only:
- master
cache:
directories:
- node_modules
before_install:
- npm update
- sudo apt-get update
- npm install --global http-server
install:
- npm install
- npm build
script:
- npm run coveralls
要启用 Travis,请登录您的 GitHub 帐户,找到 repo,然后打开它(图 10-10 )。
图 10-10
Travis CI 集成存储库
确保启用了构建推送分支。你可以点击建造徽章,Travis 会为你生成一个很酷的徽章(图 10-11 )。
图 10-11
Travis CI 集成存储库详细信息页
用工作服跟踪代码覆盖率
工装跟踪 GitHub repos 的代码覆盖率,并确保所有新代码都被完全覆盖。
为什么穿工作服?
我们使用 Codecov,为什么我们还需要工作服?那不是多此一举吗?
Codecov 主持我们的报道,但工作服做得更多。工作服筛选覆盖数据,在问题变成问题之前寻找我们可能没有发现的问题。
创建一个有工作服的账户,打开回购,如图 10-12 所示。
图 10-12
集成存储库页面的工作服
接下来,如果你正在运行一个私有的回购协议,你需要使用你的密匙,并在.coveralls.yml
文件中设置它。
service_name: travis-pro
repo_token: [YOUR-KEY]
您可以从套装设置中获取令牌,如图 10-13 所示。
图 10-13
工作服储存库密钥
接下来,package.json
需要一个运行脚本来显示lcov.info
文件,该文件是我们通过在 Travis 中使用的覆盖运行脚本生成的,以便让工作服链接到这些报告。
为此,在package.json
文件中设置一个名为coveralls
的运行脚本,为工作服显示这些脚本。
// pacakge.json
"scripts": {
...
"coveralls": "cat ./coverage/lcov.info | coveralls"
}
使用 DeepScan 检查代码质量
DeepScan 是一个静态分析工具,让我们可以全面地检查我们的代码。
为什么选择 DeepScan?
除了在 Lint 中设置的规则,DeepScan 还可以检查我们代码的质量。
使用您的 GitHub 帐户登录 DeepScan 并启用回购,如图 10-14 所示。
图 10-14
DeepScan 集成存储库
在 DeepScan 中,您可以看到回购和任何潜在问题的评级,并生成徽章,如图 10-15 所示。
图 10-15
DeepScan 存储库详细信息和徽章
摘要
在本章的第一部分,我介绍了 E2E 测试,并展示了如何使用 Jest 和 Puppeteer 库来模拟 E2E 测试。我们看到了如何配置我们的项目,创建独立的 E2E 测试,以及为特定的测试与 Jest 集成。
在本章的第二部分,我介绍了如何创建一个完全自动化的开发和部署集成周期。
为了帮助你理解从开发到 CI 的过程,我把这一章的过程分解成几个步骤。我们使用 Husky 创建了一个预提交挂钩,以确保一切运行无误。在 CI 部署方面,我们使用 Github 操作和 YAML 文件来检查我们的代码,并将我们的代码上传到 Codecov.io、Travis、工作服和 DeepScan,以分析和存储我们的数据和报告。最后,我向您展示了如何为您的项目生成一些很酷的徽章。
在下一章,你将学习如何调试和分析你的 React 应用。
十一、调试和分析 React 应用
当您使用 React 应用时,当您遇到问题时,可以使用一些特定的工具来调试和分析 React 应用。拥有适合工作的工具并知道如何使用它们可以消除棘手问题并加快流程。
React 基于 JavaScript (JS),适用于任何基于 JS 的应用的所有工具都适用于 React。与 Next.js 或 Razzle 等其他基于 React 的框架不同,CRA 基于开箱即用的单页面应用(SPA)。为了更好地了解我们的应用中发生了什么,有一些特定的工具可以帮助我们调试应用。在这一章中,你将学习帮助你完成工作的最佳工具。
本章分为两个部分:调试你的应用和分析你的应用。在每一节中,我将为您提供特定的工具,不仅可以帮助您调试和分析您的应用,还可以帮助确保在代码增长时性能不会下降。
您可以使用前面章节中的任何项目来学习如何调试和分析应用。
调试 React 应用
调试是检测和删除代码中可能导致不良行为的错误(也称为bug)的常见做法。本节涵盖了调试应用的八种方法。您可以使用任何一种或几种的组合。这种选择归结为您试图消除的 bug、您对代码的访问权限以及代码驻留的位置。我将介绍的调试选项如下:
-
断点
-
安慰
-
调试器
-
调试 Jests 测试
我们开始吧!
断点
在代码中设置断点并停止和检查数据通常不仅仅用于粉碎 bugs 这是任何职业发展努力的核心。
所有主流的现代 ide 都有设置断点和检查数据的选项。我将向您展示如何在 VS 代码中使用断点,这是我们在第一章中设置的,以及另一个名为 WebStorm 的 IDE,这是一个流行的 React IDE 但是,您可以选择任何 IDE,因为大多数 IDE 都可以设置为执行此任务。
使用 Visual Studio 代码调试 React 应用
在第一章,我们设置 VS 代码。VS 代码有一个调试 JavaScript 代码的特性。在本节中,我们将安装和调试我们的代码。
步骤 1 :在 VS 代码中使用顶层菜单安装附加调试器工具,参见图 11-1 :
https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome
图 11-1
Chrome 调试器扩展
第二步:在项目根目录下配置一个launch.json
文件,端口 3000。
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}
确保应用正在运行($ yarn start
)。接下来,在App.tsx
中放置一个断点。
第三步:现在从左侧菜单中选择运行。然后点击顶部菜单中的启动 Chrome。见图 11-2 。
图 11-2
在调试模式下为 VS 代码启动 Chrome
你可以在这里找到更多关于调试 Visual Studio 代码的内容: https://code.visualstudio.com/docs/editor/debugging
。
在 WebStorm 上调试 React 应用
在第一章,我们安装了 VS 代码。我提到还有其他流行的 ide,比如 WebStorm。我在这里将 WebStorm IDE 作为一个选项展示的原因是,尽管它需要钱,但它包括一些高级功能,可以帮助您调试和分析您的应用,并且是编写 JavaScript 代码的一个流行选项。
如果您运行的是 WebStorm,那么您可以使用配置向导像配置 WebStorm 上的任何其他项目一样配置您的项目。
点击顶栏中的添加配置,如图 11-3 所示。
图 11-3
点击 WebStorm 中的添加配置
接下来,点击+图标添加新的配置,然后选择 JavaScript Debug,如图 11-4 所示。
图 11-4
为 JavaScript 调试添加新的配置窗口
使用名称、URL ( http://localhost:3000/
)和您将使用的浏览器设置配置,如图 11-5 所示。
图 11-5
设置配置以调试我们的应用
现在,我们可以在要调试的行上设置断点,点击调试图标,开始调试 app,如图 11-6 所示。
图 11-6
用 WebStorm 调试 React 应用
使用控制台
在 JavaScript 中,console.log()
是记录消息的实际方法。可以留言,显示数值。控制台是调试基于 web 的应用的好的、老的方法,它今天仍然适用。
浏览器包括一个控制台,用于与 web 平台 API 进行交互,您可以使用它在代码中留下消息。例如,将这个console.log
放在AppRouter.tsx
中:
// src/AppRouter.tsx
const AppRouter: FunctionComponent = () => {
console.log('console testing')
return (...)
}
例如,要在 Chrome 中看到这条消息,右键单击页面,选择 Inspect,然后单击 Console 选项卡,如图 11-7 所示。
图 11-7
控制台选项卡中的 console.log 消息
Tip
建议您在发布代码之前隐藏这些console.log()
消息,因为将它们留在这里是不专业的。也被认为是副作用。如果您还记得,我们将 ESLint 设置为在您留下消息时吠叫,并且我们使用 Husky 为 Git 配置了一个提交挂钩,以确保如果开发人员试图用这些消息提交代码时提交失败。其他方法是创建一个debug
变量,在 Grunt 或 Gulp 上添加一个插件来删除这些消息,或者使用 Webpack 上的 Logger API。
使用调试器语句
如果我们想在某段代码上暂停浏览器,一个好的方法是使用调试器语句。将debugger
添加到您的组件将暂停正在呈现页面的浏览器。见图 11-8 。
// src/index.tsx
debugger
图 11-8
使用 debugger 语句停止 Chrome 浏览器的渲染
调试 Jest 测试
单元测试和 E2E 测试呢?在前面的章节中,我们在 Jest 和其他库的帮助下建立了单元测试和 E2E 测试。当测试失败并且您需要调试您的测试时,有两个主要的选择。
-
使用浏览器调试
-
设置您的 IDE
用 Chrome DevTools 调试 Jest 测试
CRA 有一个脚本来帮助使用 Jest 调试单元测试和 E2E 测试。我们可以使用类似的方法来调试测试,就像我们在开发代码中所做的那样。为了调试测试,在测试中放置一个debugger
语句。
// src/App.test.tsx
import React from 'react'
import { shallow } from 'enzyme'
import App from './App'
describe('<App />', () => {
let component
beforeEach(() => {
component = shallow(<App />)
})
debugger
test('It should mount', () => {
expect(component.length).toBe(1)
})
})
接下来,向package.json
中的运行脚本添加一个测试调试脚本。
// package.json
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache"
右键单击网页并选择 Inspect,打开 Chrome DevTools。在 Chrome DevTools 检查器中,你会看到一个绿色图标,代表为 Node.js 打开一个专用的 DevTools,如图 11-9 所示。
图 11-9
Chrome DevTools 检查器
现在当我们运行yarn
命令时:
$ yarn test:debug
DevTools 将在我们的调试器点停止。参见图 11-10 。
图 11-10
Node.js 的 Chrome 专用开发工具
用 VS 代码 IDE 调试 Jest 测试
调试 Jest 测试的第二个选项是使用 Visual Studio 代码中可用的调试工具。
步骤 1 :重构launch.json
配置文件,使其包含以下设置:
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug CRA Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/react-scripts",
"args": ["test", "--runInBand", "--no-cache", "--watchAll=false"],
"cwd": "${workspaceRoot}",
"protocol": "inspector",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env": { "CI": "true" },
"disableOptimisticBPs": true
}
]
}
第二步:将debugger
语句放在你喜欢的任何地方。
第三步:点击调试控制台按钮(看起来像带有 bug 图片的运行图标),如图 11-11 所示。
图 11-11
用 VS 代码调试 Jest 测试
用 WebStorm 调试 Jest 测试
在 WebStorm 中,过程是相似的。
步骤 1 :为 Jest 添加一个配置,类似于我们之前所做的。见图 11-12 。
图 11-12
在 WebStorm 中添加 Jest 的新配置
步骤 2 :接下来在运行/调试配置对话框中,可以设置配置文件,设置 Jest 包设置,如图 11-13 所示。
图 11-13
WebStorm 运行/调试配置对话框
第三步:接下来,把debugger
语句放在你喜欢的任何地方。
步骤 4 :现在在调试模式下运行测试(红色的 bug 图标,绿色的 place 图标旁边),如图 11-14 所示。
图 11-14
在 Jests 测试中以调试模式运行
你可以在这里找到更多: https://www.jetbrains.com/help/webstorm/running-unit-tests-on-jest.html#ws_jest_navigation
。
此外,请访问 CRA 调试测试页面以跟上任何更新: https://create-react-app.dev/docs/debugging-tests/
。
使用 Chrome 开发工具
您还记得,在第一章中,我们介绍了 React 如何利用虚拟 DOM (VDOM)的概念,并在内存中保存一个 UI 版本来与“真实”DOM 同步。
这是使用 React 16 中的纤程协调引擎完成的。有时候你可能需要深入了解实际的 DOM,Chrome DevTools 可以帮上忙。
为了帮助更好地查看、访问和理解 DOM,我推荐以下两个资源:
查看 DOM 元素
例如,在 CRA,我可以设置index.tsx
来将AppRouter
呈现到我们在index.html
页面中设置的根元素中(<div id="root"></div>
)。
// src/index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import './index.scss'
import AppRouter from './AppRouter'
import * as serviceWorker from './serviceWorker'
ReactDOM.render(<AppRouter />, document.getElementById('root'))
serviceWorker.unregister()
图 11-15 显示了 DOM 结构。
图 11-15
DOM 结构
然后我们可以打开 Chrome DevTools 并粘贴到控制台中。
window.document.getElementById('root')
这将给我们根元素,我们可以检查 DOM 子树元素,如图 11-16 所示。
图 11-16
检查 Chrome DevTools 中的根元素
在 DOM 更改时设置断点
Chrome DevTools 的另一个强大功能是我们可以根据某些标准设置断点,比如节点删除、子树修改和属性修改。
例如,在前面的章节中,我们创建了一个完整的站点,它包括一个主题首选项按钮,可以改变页脚和页眉的配色方案。
我们的主题按钮将页脚的颜色从亮变暗。我可以右键单击元素并选择“Break on”,然后选择“属性修改”,如图 11-17 所示。
图 11-17
在属性修改时设置中断元素
接下来,当我单击将主题更改为 dark 时,代码将在调试模式下暂停,我可以看到是什么导致了更改。浏览器的断点指向react-dom.development.js
。你可以在我的网站上看到野外的现场演示: EliElrom。com 。参见图 11-18 。
图 11-18
属性更改导致调试器暂停
使用 Chrome DevTools 扩展
正如您在前面的例子中看到的,当我们检查属性更改时,我们看到的结果对于理解 React 中发生的事情没有多大意义。
然而,Chrome DevTools 的扩展可以提供帮助。
什么是 Chrome DevTools 扩展?
“一个 DevTools 扩展为 Chrome DevTools 增加了功能。它可以添加新的 UI 面板和侧边栏,与被检查的页面进行交互,获取有关网络请求的信息,等等。”
有两个有用的 React development DevTools 扩展和两个状态管理工具,您应该知道。这些是 React 开发开发工具扩展:
-
React 开发者工具 :
https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi
-
意识到 React 过来 :
https://chrome.google.com/webstore/detail/realize-for-react/llondniabnmnappjekpflmgcikaiilmh
这些是状态管理工具:
-
Redux :
https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd
-
反冲 :
https://chrome.google.com/webstore/detail/recoil-dev-tools/dhjcdlmklldodggmleehadpjephfgflc
Chrome DevTools 扩展:React 开发者工具
React 开发工具允许你在 Chrome 开发工具中检查 React 组件的层次结构。你将在 Chrome DevTools 界面中获得两个新标签。
-
⚛组件
-
⚛ 型材
为了测试这个工具,使用前面章节中的编码例子,比如当我们构建我们的应用时。我正在使用我的网站并导航到一个/books
页面。
我们可以看到很多关于组件的信息,比如 React 的版本(撰写本文时为 v17.0.0)、props
信息、路由信息以及组件层次结构,如图 11-19 所示。
图 11-19
React 开发人员工具,组件窗口
第二个选项卡是用于概要分析器的,在这里我们可以记录产品概要分析构建。我将在这一章的后面讲述更多关于概要分析的内容。
Chrome DevTools 扩展:React 的实现
正如您在本书中了解到的,组件是 React 的核心。一旦你安装了 React 开发工具,Realize 是一个很好的工具,可以帮助你可视化 React 组件树。
该工具有助于跟踪状态,并为您提供组件层次结构的整体概述。参见图 11-20 。
图 11-20
实现 React
下面的整体概述对我们在前几章创建的 CRA 模板项目进行了分解:
// src/AppRouter.tsx
const AppRouter: FunctionComponent = () => {
useEffect(() => {
// console.log('AppRouter.tsx :: CheckAuthentication')
}, [])
return (
<Router>
<ScrollToTop />
<RecoilRoot>
<ToastNotification />
<ShareSocialMediaButtons />
<Suspense
fallback={
<div className="home_loading_container">
<img width="250px" className="loading-home" alt="loading" src={myImage} />
</div>
}
>
<HeaderTheme />
<Switch>
<Route exact path="/" component={App} />
<Route exact path="/Home" component={Home} />
...
<Route path="/404" component={NotFoundPage} />
<Redirect to="/404" />
</Switch>
<Testimonials />
<SocialMediaButtons />
<div className="footer">
<FooterTheme />
</div>
</Suspense>
</RecoilRoot>
</Router>
)
}
export default AppRouter
Chrome DevTools Extension:退火
如本书前面所述,当使用 Redux 或反冲进行状态管理时,如果能够跟踪状态的内部工作情况,那就太好了。
网上有很多关于 Redux Chrome DevTools 扩展的信息,但我想指出如何深入了解反冲,这是我们在本书前面设置的。它给出了关于原子、选择器和订阅者的信息。参见图 11-21 。
图 11-21
反冲铬 DevTools 扩展图表的书页
在我的例子中,如图 11-21 所示,原子是基于bookObject
的,我可以在浏览器中检查状态值和变化。
export interface bookObject {
title: string
author: string
pubDate: string
link: string
thumbnail: string
}
使用 Web 代理
我们可以使用 web 代理服务器来调试我们的应用。一个网络代理被设置为我们计算机上的另一层,它作为一个中枢,通过它所有的互联网请求将被处理。
利用查尔斯
Chrome DevTools 很棒,但有时我们需要更多。除了网络资源,查尔斯还对其他资源提出了很好的见解。例如,您可以进行节流(模拟较慢的互联网)、SSL 代理、数据格式化和 Ajax 调试,以及记录和保存不同的会话等。从这里下载查尔斯:
https://www.charlesproxy.com/latest-release/download.do
这在处理 SSL 站点时很方便,因为数据通常是隐藏的。为了能够查看 SSL 站点的详细信息,您需要设置一个 Charles 根证书。要设置 Charles 根证书,请打开 Charles。
从 Charles 的顶部菜单中,选择帮助,SSL 代理,然后选择“安装 Charles 根证书”
每个站点或所有站点都需要信任该证书。对于 Mac,您可以连按证书并使用“信任”标签来将证书设置为始终受信任。
然后我们可以看到 SSL 站点上的安全项目,如图 11-22 所示。
图 11-22
Charles 配置了 SSL 代理
有关设置 SSL 证书的更多信息,请访问 https://www.charlesproxy.com/documentation/using-charles/ssl-certificates/
。
Note
我想指出的是,查尔斯的一个缺点是它不是免费的。它有一个使用有限的试用版。然而,还有其他类似的 web 代理工具,如 Fiddler ( https://www.telerik.com/fiddler
)在撰写本文时是免费的。
使用网络协议分析器
有时,我们需要更深入地了解我们的应用或 API 的网络数据,我们连接到这些数据,但无法直接控制它们。在这些情况下,您可以使用网络协议分析器。
Wireshark
当您需要更深入地了解网络数据时,例如,如果您需要对网络协议进行深入分析,Wireshark 就是标准。
它允许您深入检查数百个协议,并提供实时捕获功能。
从这里下载: https://www.wireshark.org/download.html
。
您可以过滤结果并观察网络请求。图 11-23 显示了一个例子。
图 11-23
Wireshark 捕获结果
剖析您的 React 应用
与传统的 web 应用相比,当您的代码构建正确时,React 速度更快,这主要是因为使用了虚拟 DOM 概念。然而,这并不意味着你不能写内存泄漏或你不应该试图提高性能。
事实上,一个好的开发习惯是保存应用的性能配置文件,并在每次添加新功能时引用它。
在这一部分,我将与您分享四种分析 React 应用的可靠方法。
JS 中的内存是由垃圾收集器自动管理的。很容易造成内存泄漏。
Note
GC 是自动内存管理的过程。它基于收集不再需要的记忆。
但是,即使没有内存泄漏,大量的内存占用也会降低我们的应用的速度,并导致 CPU 峰值和大量内存使用。
认知度是确保你的应用性能良好的关键。你应该不断衡量你添加的新功能的表现,以及它对你现有应用的影响。在前一章中,我们自动化了我们的开发和部署。发布应用时,还应该考虑自动化性能测试。
如何描述我的 React 应用?
有很多工具可以帮助完成这项工作。以下是我在处理性能问题时最常用的选项:
-
活动监视器/Windows 任务管理器
-
Chrome DevTools 的“性能”标签
-
React Chrome 开发工具扩展
-
React 探查器 API
在我们开始之前,我想谈谈用于概要分析的构建。
分析本地版本时,最好使用版本的生产版本,而不是开发版本,因为开发没有经过优化,与生产版本不同。
为了让产品构建在本地运行,我们正在使用的 CRA·MHL 模板项目已经包含了serve
库( https://github.com/vercel/serve
)。
可以全局安装serve
。为此,确保您设置了对全局node_modules
的读写权限并安装了它。
$ sudo chown -R $USER /usr/local/lib/node_modules
$ npm install -g serve
接下来,通过指定-- profile
标志来创建一个分析构建。
将这些运行脚本添加到您的package.json
文件中,以构建生产构建和分析构建,并为这些构建启动一个本地服务器。
// package.json
"build:serve": "yarn build && serve -s build && open http://localhost:5000",
"build:profile": "yarn build --profile && serve -s build && open http://localhost:5000"
活动监视器/Windows 任务管理器
就我个人而言,我喜欢从我们计算机上的内置工具开始,对我们的内存占用和处理器使用情况进行 10,000 英尺的概述。
是的,它们是“穷人的”工具,但却是一个很好的起点。例如,Safari 会按网站对使用情况进行分类。
看看活动监视器使用前后的情况。图 11-24 显示浏览器加载网站前后的内存使用情况,图 11-25 显示 CPU 使用增加了 0.6%。
图 11-25
使用 Safari 导航到“我的网站”后的活动监视器
图 11-24
Safari 打开后的活动监视器
浏览器的分析工具
主要的浏览器都为开发者提供了工具,可以帮助他们调试和分析你的应用。谷歌是领先的搜索引擎,为开发者提供了很好的工具。所以在这里,我将介绍 Google DevTools,但是要注意,其他流行的浏览器如 Safari、Firefox 和 Microsoft Edge 也包含开发人员工具。
Chrome DevTools 的“性能”标签
Chrome DevTools 有一个性能标签。要打开性能选项卡,请在浏览器上单击鼠标右键,然后单击检查。
接下来,选择截图和内存复选框来查看两者。
图 11-26 和 11-27 显示了一个比较 CRA 模板和我的网站的例子。
图 11-27
埃利罗姆。Chrome DevTools 的“性能”标签中的 com
图 11-26
Chrome DevTools 的“性能”标签中的 CRA 模板
Chrome DevTools 扩展:React 开发者工具中的 Profiler
之前我们安装了 React 开发者工具扩展。该扩展有两个选项卡:组件和 Profiler。Profiler 选项卡让您深入了解什么叫做火焰图。
火焰图是一个有序的图表,显示每个组件渲染所用的总时间。颜色表示渲染时间(越绿越好)以及从虚拟 DOM 到“真实”DOM 渲染或重新渲染这些变化所花费的时间。它包括不同排名的标签以及互动。参见图 11-28 。
图 11-28
我的开发版本的火焰图结果
记住,我们创建了一个运行脚本来分析生产构建。您可以比较优化构建和开发构建的不同结果。图 11-29 显示了生产构建($ yarn build:profile
)的分级分析结果。
图 11-29
我的生产版本的排名结果
您还可以选择进入您的产品版本,以使概要分析工作正常进行。请记住,在产品版本上设置概要分析确实会带来很小的开销,所以只在需要的时候才使用它。您可以在此处找到说明:
https://gist.github.com/bvaughn/25e6233aeb1b4f0cdb8d8366e54a3977
React 探查器 API
React Profiler API ( https://reactjs.org/docs/profiler.html
)包括一个<Profiler/>
组件,可以帮助我们从源代码定制指标,并测量组件的生命周期时间。
为了测试这个组件,您可以用 CRA 模板项目建立一个新的项目,或者使用我们之前创建的任何一个项目。
$ yarn create react-app your-project-name --template must-have-libraries
接下来,重构路由sec/AppRouter.tsx
并用Profiler
组件包裹它,如图 11-30 所示。
图 11-30
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>
)
}
正如你在 DevTools 的控制台标签中看到的,我得到了结果。在本例中,我记录了所有内容,但是我们可以创建一个脚本来过滤结果并以不同的方式处理它们。
摘要
在本章中,我介绍了调试和分析 React 应用的方法。在本章的第一部分,我介绍了调试 React 应用的八种方法。
-
我向您展示了如何在 Visual Studio 代码 IDE 和 WebStorm 上创建断点和调试 React。
-
我向您展示了如何使用 Console 选项卡并与 web 平台 API 交互。
-
您了解了
debugger
语句,并使用 Chrome DevTools 以及 VS 代码和 WebStorm IDEs 将debugger
语句用于 Jest 测试。 -
我们通过学习如何查看 DOM 元素以及基于 DOM 变化放置断点来了解 Chrome DevTools。
-
我们安装了这些 Chrome DevTools 扩展:React 开发者工具、Realize、Redux 和反冲。
-
最后,我们检查了 Charles web 代理和 Wireshark 网络协议分析器。
在本章的第二部分,我介绍了四种分析 React 应用的方法。
-
我向您展示了如何使用活动监视器(Mac)/任务管理器(Windows)来检查内存和 CPU 占用。
-
我介绍了 Chrome DevTools 的 Performance 选项卡以及 React Chrome DevTools 扩展的 Profiling 选项卡。
-
最后,您学习了如何使用 React Profiler API 来获取组件的呈现时间。
在下一章,也是最后一章,我将向你展示一些技术,你可以用这些技术来优化你的 React 应用,以提高性能和应用的质量。
十二、优化您的 React 应用
优化您的代码是一个高级主题,需要确保我们交付一个高质量的产品,降低资源占用并更快地加载我们的应用。在这一章中,我将强调一些在你写第一行代码之前应该知道的优化技术。涵盖的主题包括预缓存、延迟加载、代码拆分、树抖动、预取和子画面拆分等等。
为什么我们需要优化?
正如你在这本书里看到的,CRA 是创建 React 应用的一种流行方式。它设置为使用react-scripts
管理您的应用配置。它利用 Webpack 来优化产品构建,包括缩小、丑化和压缩代码。你不需要做太多,因为所有这些技术都是 CRA 自带的。
此外,在前面的章节中,我们使用了自动化开发和部署技术来提高代码的质量。具体来说,我们建立了 ESLint、Huskey、单元测试、E2E 测试等等来识别糟糕的编码和弱点。
然而,尽管如此,CRA 仍然是一个香草味的,一刀切的工具,有时你会想微调和配置你的应用更多一点,以满足你的特定需求,并更多地了解正在发生的事情。此外,如果你不小心,你的应用可能会膨胀,变成一个“骗子”
Note
邱建不是错别字;这是一个低质量的过程,会导致应用的响应时间很短,或者阻碍用户交互。
CRA 所基于的单页应用(SPAs)的主要性能挑战之一是,用户需要等待组成应用的 JS 包完成下载,然后才能看到内容。
如果 JS 包变得臃肿,对于网速慢的用户来说会花费很多时间,使得你的应用运行缓慢或者对某些用户不可用。这将导致失去游客和业务。我们的目标是构建渐进式网络应用(PWAs)的应用。
“渐进式 Web 应用(PWA)是使用现代 API 构建和增强的,以提供增强的功能、可靠性和可安装性,同时使用单一代码库在任何设备上向任何人、任何地方提供服务。”
你会学到什么?
按照本章中的步骤,您将了解如何减少应用的内存占用,避免内存泄漏,减少捆绑文件大小,只在使用中加载一次资源,减少查看内容的等待时间,提高性能,并确保它随时随地工作,甚至离线。
你还会更加了解你的应用中发生了什么,这样你就可以更好地配置你的应用,而不是使用默认设置。这是一个很大的主题,但我的目标是给你最重要的优化方法。
如何优化我的应用?
我把优化你的 CRA React TS 应用的最好方法分成几个部分。
-
尽可能使用 PureComponent 作为类组件
-
惰性装载
-
预呈现静态页面
-
预缓存—脱机工作
-
代码分割
-
树摇晃
-
缩小媒体尺寸
-
预取
-
清除未使用的副作用事件处理程序
但是在开始之前,让我们创建一个可以用来试验和测试的项目。
我将使用我的 CRA 启动模板项目;我把它的名字改成optimize-ts
。
$ yarn create react-app optimize-ts --template must-have-libraries
让我们创建一个可以用来实验的页面组件。我使用的是已经包含在 CRA MHL 模板项目中的generate
模板。这些模板可以帮助我们用一个命令快速创建这些页面。
$ cd optimize-ts
$ npx generate-react-cli component MyPage --type=page
模板使用templates/page
文件夹中的模板集为我们自动生成了三个文件(SCSS 风格、TS 组件和 Jest 测试文件)。我们在终端得到确认。
Stylesheet "MyPage.scss" was created successfully at src/pages/MyPage/MyPage.scss
在 src/pages/my page/MyPage.test.tsx 中成功创建了测试“my page . test . tsx”
组件“MyPage.tsx”已在 src/pages/MyPage/MyPage.tsx 中成功创建
接下来,打开AppRouter.tsx
。让我们将创建的页面添加到路由中。
// src/AppRouter.tsx
import MyPage from './pages/MyPage/MyPage'
const AppRouter: FunctionComponent = () => {
return (
<Router>
<RecoilRoot>
<Suspense fallback={<span>Loading...</span>}>
<Switch>
<Route exact path="/" component={App} />
<Route exact path="/MyPage" component={MyPage} />
</Switch>
</Suspense>
</RecoilRoot>
</Router>
)
}
测试一切是否正常工作(您应该只看到带有链接的微调器)。
$ yarn start
此时,您可以导航到页面http://localhost:3000/MyPage
并确保它工作正常。
接下来,让我们添加一个到我们创建的页面组件的链接,这样我们就可以从主页导航到页面。
打开src/App.tsx
并添加NavLink
组件,这样我们就可以将它链接到我们的页面菜单。我将它设置为一个数组,以防我们想要添加更多的页面。
<List>
{[
{ name: 'MyPage', url: '/MyPage' }
].map((itemObject, index) => (
<NavLink
to={itemObject.url}
key={itemObject.url}
>
<ListItem>{itemObject.name}</ListItem>
</NavLink>
))}
</List>
你可以在图 12-1 中看到最终的结果。
图 12-1
我们的 CRA 模板启动项目与页面和导航
您可以从这里下载用于本章的最终完整项目:
https://github.com/Apress/react-and-libraries/12/optimize-ts
分析器包
另一个值得了解的工具是 Analyzer Bundle。它可以帮助您调试和分析您的应用。
如果我们想知道 JS 包的内幕,我们可以从 CRA 弹出( https://create-react-app.dev/docs/available-scripts/
)并修改我们的代码来查看 JS 包的内容。
在不弹出的情况下调整 CRA Webpack 配置的另一个选项是使用这个库:react-app-rewired
( https://github.com/timarney/react-app-rewired
)。
但是您不需要做所有这些,因为弹出会迫使您维护配置文件。我们可以使用source-map-explorer
来查看我们的包的地图。
$ npm install -g source-map-explorer
现在,您可以打开库并直观地查看库。
$ source-map-explorer optimize-ts/build/static/js/[my chunk].chunk.js
或者,可以使用bundle-analyzer
( https://github.com/svengau/cra-bundle-analyzer
)。它更加丰富多彩,在一个页面中包含了所有的包,而不是用source-map-explorer
一个一个地调用它们。
$ yarn add -D cra-bundle-analyzer
现在我们可以创建报告了。
$ npx cra-bundle-analyzer
一旦运行yarn build
,该命令将在build/report.html
中生成一个webpack-bundle-analyzer
报告。
尽可能使用 PureComponent 作为类组件
正如您在前面章节中回忆的那样,React 17 在创建 React Component
类时提供了两个主要选项。
-
React.Component
-
React.PureComponent
在整本书中,我们使用了PureComponent
而不是React.Component
,但是为什么呢?当你不需要访问shouldComponentUpdate
方法时,最好使用PureComponent
来代替。
extends React.PureComponent
React.PureComponent
在某些情况下提供了性能提升,但代价是失去了shouldComponentUpdate
生命周期。你可以在 React 文档( https://reactjs.org/docs/react-api.html#reactpurecomponent
)中了解更多。
在我们的代码中,我们不需要访问shouldComponentUpdate
,所以我们可以使用PureComponent
。下面是显示页面名称的初始文件的代码:
import React from 'react'
import './MyPage.scss'
import { RouteComponentProps } from 'react-router-dom'
import Button from '@material-ui/core/Button
// or React.Component
export default class MyPage extends React.PureComponent<IMyPageProps, IMyPageState> {
constructor(props: IMyPageProps) {
super(props)
this.state = {
name: this.props.history.location.pathname
.substring(1, this.props.history.location.pathname.length)
.replace('/', ''),
results: 0
}
}
render() {
return (
<div className="TemplateName">
{this.state.name} Component
</div>)
)
}
}
interface IMyPageProps extends RouteComponentProps<{ name: string }> {
// TODO
}
interface IMyPageState {
name: string
results: number
}
重新-重新-重新-重新渲染
也就是说,有时需要使用shouldComponentUpdate
,因为我们可以使用该方法让 React 知道该组件不受父组件状态变化的影响,并且不需要重新呈现。在这种情况下,你需要将你的类设置为React.Component
,然后你就可以访问shouldComponentUpdate
。
public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
return false // prevent rendering
}
在这些情况下,我们需要控制组件并希望停止重新呈现器。使用React.Component
可以获得更好的性能,因为我们可以停止重新渲染过程。
要找到行为不端的公民,可以使用 Chrome DevTools 扩展 React Developer Tools,并使用其高亮更新复选框来查找行为不端的组件。它通过寻找过度渲染的组件来做到这一点。
惰性装载
延迟加载是提高应用性能并快速看到显著效果的最简单方法之一。我会说,这种努力是优化的低挂果实。
最好从路由开始。让我们创建一个优化的生产版本。
$ yarn build
如果您导航到为我们创建的build/static
文件夹,您可以看到我们有三个 JS 文件和一个许可证文件。见图 12-2 。
图 12-2
CRA 生产在利用延迟加载之前构建
现在更新代码以包含延迟加载。Suspense
组件将在组件的加载阶段显示,使用 lazy 方法导入组件将确保组件只在使用后才加载。
这一变化更新了我们导入组件的方式,具体如下:
import MyPage from './pages/MyPage/MyPage'
致以下内容:
const MyPage = lazy(() => import('./pages/MyPage/MyPage'))
组件加载时,Suspense
组件包括一个回退。看一下完整的代码:
import React, { FunctionComponent, lazy, Suspense } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import { RecoilRoot } from 'recoil'
import App from './App'
// Normal
// import MyPage from './pages/MyPage/MyPage'
// Lazy loading
const MyPage = lazy(() => import('./pages/MyPage/MyPage'))
const AppRouter: FunctionComponent = () => {
return (
<Router>
<RecoilRoot>
<Suspense fallback={<span>Loading...</span>}>
<Switch>
<Route exact path="/" component={App} />
<Route exact path="/MyPage" component={MyPage} />
<Redirect to="/" />
</Switch>
</Suspense>
</RecoilRoot>
</Router>
)
}
再次运行yarn build
。您可以看到,构建脚本将我们的包块从三个文件分解为四个文件,这是因为我们采用了惰性加载。见图 12-3 。
图 12-3
CRA 生产构建已优化
当您使用yarn start
时,您看不到回退加载消息,因为它发生得太快了,因为我们的页面组件只包含页面的名称。然而,你可以在我的网站( https://elielrom.com
)上看到一个例子,我为登录页面放置了延迟加载,并为回退页面放置了一个图像微调器。浏览页面,您将在第一次浏览登录页面时看到微调器。如果没有,可以减少互联网连接 DevTools。
如果你使用 Chrome DevTools 或者 Charles(关于调试的更多信息,参见第十一章),你实际上可以测量结果。这取决于你的应用的大小和你正在做的事情,但这个简单的方法可以让你获得几秒钟的时间。
Note
关于延迟加载有一点需要注意。有时候,对所有页面进行延迟加载是没有意义的,因为页面很轻。拆分和加载多个包文件可能需要更长时间。
您需要尝试一下延迟加载,因为它的效果如何取决于具体情况。此外,最好只在某些组件上包装这些惰性加载方法。例如,一个登录成员区域不是所有用户都使用的,而其他页面可以一起加载。最好的方法是试验并设置内存分析,然后降低网络速度,找出最佳用户体验。这不是放之四海而皆准的事情。
你可以在 React 文档中找到更多信息( https://reactjs.org/docs/code-splitting.html#route-based-code-splitting
)。
预呈现静态页面
在某些情况下,CRA (SPA)模式非常好,因为你不会刷新页面,感觉就像在移动应用中一样。
这些页面应该在客户端呈现。CRA 一开始就不支持服务器端渲染(SSR)。但是,有一些方法可以配置路由并让 CRA 作为 SSR 工作,但是这可能需要您自己退出和维护配置,因此可能不值得这样做。
服务器端呈现(SSR)是在服务器上将客户端 JavaScript 站点呈现为静态 HTML 和 CSS 的过程,而不是在客户端(浏览器)呈现站点。
如果您正在构建需要 SSR 的更大的东西,最好使用已经配置了 SSR 的不同 React 库,如 Next.js framework、Razzle 或 Gatsby(包括如果您在构建时将网站预渲染为 HTML)。
Tip
如果你想用 React 和 Node.js 做服务器渲染,可以去看看 Next.js,Razzle,或者 Gatsby。
Create-React-App 在后端是不可知的,产生静态的 HTML/JS/CSS 包。也就是说,通过 CRA,我们可以进行预渲染,这是目前最接近 SSR 的方法。参见 CRA 文献: https://create-react-app.dev/docs/pre-rendering-into-static-html-files/
。
有许多选项可以为每个路径或相对链接生成 HTML 页面。以下是一些例子:
-
react-snap
-
react-snapshot
-
Webpack 静态站点生成器插件
我推荐react-snap
( https://github.com/stereobooster/react-snap
),在 GitHub 上有 4000 个明星最受欢迎,和 CRA 配合的天衣无缝。react-snap
使用我们在第十章中用于 E2E 测试的相同的操纵器,在你的应用中自动创建不同路径的预渲染 HTML 文件。
最大的好处是,一旦你使用了react-snap
,应用并不关心 JS 包是否成功加载,因为你设置的每个页面都是独立的。
请记住,对于每个单独加载的页面,有些包可能有多余的代码,所以这是有代价的。
步骤 1 :要开始,运行以下命令:
$ yarn add --dev react-snap
步骤 2 :接下来,添加postbuild
运行脚本。
// package.json
"scripts": {
...
"postbuild": "react-snap"
},
第三步:静态 HTML 几乎立即呈现。默认情况下,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.tsximport 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)
}
步骤 5 :现在运行 Yarn build
命令,之后,构建将通过在 CRA 配置的 NPM 脚本自动调用。你应该看到成功的结果。把你的结果和我的比较一下。
$ react-snap
✅ crawled 1 out of 3 (/)
✅ crawled 2 out of 3 (/404.html)
✅ crawled 3 out of 3 (/MyPage)
✨ Done in 29.29s.
打开 build 文件夹,会看到自动创建的静态页面,如图 12-4 所示。
图 12-4
静态页面
Note
预先呈现和提供静态页面不一定总是最好的方法。这实际上会给用户带来不愉快的体验,因为每个页面都将被加载,并且组件负载会跨页面分布。对于轻量级应用,最好等待半秒钟,这样所有的内容都可以加载,这样就不会有更多的等待时间,而不是在每次页面加载时等待一会儿。您需要对此进行测试并亲自查看,但是要注意这个特性。
步骤 5 :要在本地剥离本地生产构建,运行 CRA 模板运行脚本。它使用的是serve
库,所以如果你使用的是 CRA MHL 模板,你甚至不需要安装或配置package.json
。
运行serve
运行脚本来添加本地服务器并查看生产构建。
$ yarn build:serve
我想指出,使用 prerender 的另一个重要原因是除了优化之外对静态页面的需求:搜索引擎优化(SEO)。如果您预渲染页面,并希望生成不同的标题、描述、元数据等。,对于由于 SEO 原因的每个页面,或者您需要通过社交媒体共享单个页面,请选中react-helmet
,这可以帮助您为每个 React 页面组件设置唯一的标题。
如何让 react-helmet 为每个页面生成一个标题?
在这一节中,我将解释为每个页面生成标题的步骤。
第一步:安装react-helmet
和 TS 的型号。
$ yarn add react-helmet @types/react-helmet
步骤 2 :现在,我们可以重构我们的MyPage.tsx
并添加Helmet
组件。
import Helmet from 'react-helmet'render() {
return (
<div className="MyPage">
<Helmet>
<title>My Page</title>
</Helmet>
{this.state.name} Component
</div>)
}
请注意,在我们的代码中,state 存储了页面的名称,该名称是从 React Router 中提取的,因此我们需要使用.replace('/', ')
来表示this.state.name
,因此如果用户刷新静态页面,它的末尾将会有about/
。
constructor(props: IMyPageProps) {
super(props);
this.state = {
name: this.props.history.location.pathname.substring(
1,
this.props.history.location.pathname.length
).replace('/', '')
}
}
现在,如果您查看源代码,一旦您单击 MyPage 链接,它就会有标题。
预缓存:脱机工作
能够离线是 PWA 的一个核心功能。我们可以用一个serviceWorker
来做。
CRA 将serviceWorker
包含在索引文件中。
serviceWorker.unregister()
这是什么意思?
CRA 包括一个用于生产构建的 Workbox webpack 插件( https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin
)。
要启用此功能,只需将serviceWorker
状态更改为register
。
serviceWorker.register()
我们已经在前面的部分中为生产进行构建时添加了serviceWorker
。
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
。
见图 12-5 。
图 12-5
添加到静态文件夹中的运行时主包文件
要查看工作器的运行情况,您需要再次发布构建。
$ yarn build:serve.
看看 Chrome DevTools 的网络标签。在尺寸栏中,你可以看到上面写着“(ServiceWorker)”,如图 12-6 所示。
图 12-6
预缓存服务人员出现在 Chrome DevTools 的网络选项卡上
你现在可以通过关闭网络并连接或者选择 Chrome DevTools 的网络选项卡上的离线复选框来模拟离线体验。刷新应用,它仍然可以工作!
你的应用如何离线工作?
CRA 的工作箱默认预缓存策略是CacheFirst
。静态资产从服务工作者缓存机制中检索,如果失败,则发出网络请求。
一个工具箱支持不同的策略,如CacheOnly
、NetworkFirst
等。,但 CRA 可能需要被驱逐,以使用不同于默认的策略。
点击 https://create-react-app.dev/docs/making-a-progressive-web-app/
了解更多关于此功能的信息。
代码拆分
当我们使用延迟加载时,我们能够将 JS 包分解成多个块,并在只需要它们的时候提供服务。
动态导入
我们可以做得更多。CRA 用 Webpack 处理代码拆分任务。我们可以告诉 Webpack 进一步拆分我们的 JS 包,并动态导入这些模块。
让我们来看看。创建文件src/page/MyPage/math.tsx
并添加以下代码:
// src/page/MyPage/math.tsx
export function square(x: number) {
return x * x
}export function cube(x: number) {
return x * x * x
}export function add(x: number, y: number) {
return x + y
}
为了使用add
方法,我们通常会这样写代码:
import { add } from './math'
console.log(add(1, 2))
但是,如果我们想要分割代码,以便 JS 包只在需要时才被检索,并且只绑定使用过的内容,我们可以执行以下操作:
import("./math").then(math => {
console.log(math.add(1, 2))
})
让我们创建一个实际的工作示例。通过使用 math add
函数,我们可以有一个包含可变结果的状态和一次单击更新。看一看MyPage.tsx
代码,如下所示:
// src/page/MyPage/MyPage.tsx
render() {
const onClickHandler = (event: React.MouseEvent) => {
event.preventDefault()
import('./math').then((math) => {
this.setState({
results: (math.add(1, 2))
})
})
}
return (
<div className="MyPage">
<Helmet>
<title>My Page</title>
</Helmet>
{this.state.name} Component
<Button type="submit" onClick={onClickHandler}>
Math.add
</Button>
{this.state.results}
</div>
)
}
或者更好的是,让我们添加每次点击的结果。
import('./math').then((math) => {
this.setState(prevState => {
const newState = prevState.results + (math.add(1, 2))
return ({
...prevState,
results: newState
})
})
})
现在构建生产代码,并在本地机器上运行它。
$ yarn build:serve
Tip
我们的构建设置为预缓存,所以你需要在 Mac 上通过按 Shift+Refresh 来强制清除缓存,或者你可以在 Chrome 网络商店找到一个奇特的插件来做这件事。否则,你可能是在服务老页面,拔头发。
见图 12-7 。
图 12-7
Meth.add 在本地运行生产版本的代码分割功能
请注意,我们的包增加了 1,当我们调用MyPage
和按钮时,包文件将被检索,而不是在我们第一次加载页面时让用户等待包。见图 12-8 。
图 12-8
代码拆分后的 JS 包
模块联盟
我们能做得更多吗?答案是有也没有。
在撰写本文时,CRA 的 Webpack 版本为 4.42.0(见node_modules/react-scripts/package.json
)。然而,截至 2020 年 10 月,Webpack 的当前版本是 v5.4.0,它包括了大量的性能改进。一个主要的问题涉及到模块联合。
模块联合允许您将远程 Webpack 构建导入到应用中。使用 Webpack v5,我们不仅可以从我们的项目中导入这些块,还可以从不同的源(项目)中导入这些块。你可以在 https://webpack.js.org/guides/build-performance/
了解更多信息。
您可以弹出和升级 Webpack。如果你需要的话,确保你自己构建并包含模块联合。
树摇晃
摇树 ( https://webpack.js.org/guides/tree-shaking/
)是 JavaScript 上下文中使用的一个术语,意思是删除死代码。当谈到删除死代码时,有很多事情要做。
当我说死代码时,它可能意味着这两件事:
-
从未执行过的代码:运行时永远不能执行的代码
-
结果从未使用过:代码被执行,但结果从未使用过
比如在我们搭建的 app 里,我在src/AppRouter.tsx
里定义了反冲。
<RecoilRoot>
<Suspense fallback={<span>Loading...</span>}>
<Switch>
<Route exact path="/" component={App} />
<Route exact path="/MyPage" component={MyPage} />
<Redirect to="/" />
</Switch>
</Suspense>
</RecoilRoot>
但是在这个例子中,我从来没有使用反冲功能。
现在,如果我们深入我们的 JS 包,看看发生了什么,我们可以看到反冲使用了几乎 38.19KB。见图 12-9 。
图 12-9
我们的捆绑带反冲的源图
$ source-map-explorer optimize-ts/build/static/js/[my chunk].chunk.js
这里我们重构代码,去掉反冲,重新构建:
<Router>
<Suspense fallback={<span>Loading...</span>}>
<Switch>
<Route exact path="/" component={App} />
<Route exact path="/MyPage" component={MyPage} />
<Redirect to="/" />
</Switch>
</Suspense>
</Router>
现在代码从 276KB 下降到 224.51KB,反冲不包括在内。参见图 12-10 。
图 12-10
无后座力我们束的来源图
CRA 包括现成的 Webpack,将我们的应用与一些已经为我们设置好的设置捆绑在一起。
您可以看到包含compression
和OptimizeCSSAssetsPlugin
的优化标签。
如果需要更改,需要自行弹出和维护。然而,即使没有弹射,我们也可以做一些事情。
为了更好地理解幕后发生的事情,在 CRA 打开 Webpack 配置文件,它位于react-scripts
中。
$ open node_modules/react-scripts/config/webpack.config.js
或者对于开发服务器,使用以下命令:
$ open node_modules/react-scripts/config/webpackDevServer.config.js
先说副作用。
Note
副作用是在被调用函数之外可以观察到的状态变化,而不是它的返回值。副作用的例子包括:改变外部变量或对象属性(全局变量或父函数作用域链中的变量)的值、没有说明符的导入语句(即import 'someLib'
)、登录到控制台、获取数据、设置订阅或手动改变 DOM。
如果您使用 Webpack,您可以指导 Webpack 如何处理库。事实上,大多数 NPM 图书馆都有副作用。
如果您查看 CRA Webpack 的webpack.config.js
文件,您会看到导入的库都设置了sideEffects: true
标志。这是根据每个库设置的。
为我们不使用的库导入会增加我们代码的大小(JS 包)。这些进口商品应该取消。
导入所需模块与使用不带说明符的 Import 语句
我们来看一个例子。假设我添加了一个import
声明,说明我没有使用和导入我需要的特性,比如useRecoilValue
。
// src/page/MyPage/MyPage.tsx
import { useRecoilValue } from 'recoil'
CRA react-script
工具允许我开始并构建我的生产构建,但我会得到一条警告消息,如图 12-11 所示。
图 12-11
未使用代码的警告
有这些警告不好,但也不是世界末日。
在这个具体的例子中,Webpack 正在使用的 Terser ( https://github.com/terser/terser
)试图解决这个问题,并决定不需要这个代码。因此,尽管它创建了一个警告,但是它并没有在我们的 build JS 包中包含这个库。
代码没有包括在内,因为这个包已经过优化,库已经为我们移除了(您可以通过检查源代码图来验证这一点)。
但是不要依赖 Terser 总是能够解决这个问题。当它不能时,它将包括死代码。
然而,如果我使用一个没有说明符(import 'recoil'
)的import
语句,就像这样:
// src/page/MyPage/MyPage.tsx
import 'recoil'
代码将为我们编译、构建,甚至运行 ESLint,没有任何警告,但它将包括整个反冲库。
原因是 Webpack 将不带说明符(import 'recoil'
)的import
语句视为副作用,并且它将在我们的源映射中包含反冲,就好像我们有意这样做一样。
Note
只包含您需要的模块,避免使用没有说明符的import
语句。
您还可以微调并将sideEffects
属性添加到项目的package.json
文件中,以告诉 Webpack 如何在不弹出的情况下处理副作用。你可以在 Webpack 文档中读到更多关于这个和关于树摇动的内容: https://webpack.js.org/guides/tree-shaking/
。
缩小介质尺寸
我们讨论了树抖动和将 JS 包的大小减到最小,但是除了包的大小之外,在一个应用中经常使用其他资源,这会占用很多资源。这些是媒体文件,如图像、视频、音频文档以及与大数据同步。
要优化资源,有很多工具可以使用。如果你有 Adobe 产品,你有带“存储为 Web 格式”选项的 Photoshop,你可以用它来确保你的图像保持较小的尺寸。Adobe Premiere 可以针对不同的设备和不同的设置对您的视频进行编码。
您可以使用一个库来检查用户的网络速度,并根据用户的连接提供不同的资源。这是视频传输的常见做法。
理想情况下,我们希望在运行时而不是编译时上传资源,因为我们不希望用户等待资源。
另一件事是 SVG。SVG 是基于向量的,非常棒。它给用户清晰的图形外观,在任何分辨率的屏幕尺寸;然而,这是有代价的。React 提高性能的方法是减少对服务器的请求数量。
导入小于 10KB 的图像会返回数据 URI,而不是实际的 SVG 文件;参见 React 文档( https://create-react-app.dev/docs/adding-images-fonts-and-files/
)。这在 CRA 开发中被默认忽略,但是您可以在产品构建中看到这种行为;见IMAGE_INLINE_SIZE_LIMIT
( https://create-react-app.dev/docs/advanced-configuration
)。
拥有大量的 SVG 图形很容易使你的应用膨胀。最好将它们收集到一个 JPG 图像文件中,并使用图像精灵。加载单个图像比逐个加载单个图像更快。
预取
你可能已经看到 React 中的高阶组件(hoc)可以增强组件的能力( https://reactjs.org/docs/higher-order-components.html
)。对于我们的 JS 包,我们可以采用类似的方法。
我们希望首先加载页面,然后检索 JS 包,这样我们就可以尽快显示页面。
让我们来看看。如果你用$ yarn build:serve
构建了一个量产版,并在 Chrome DevTools 中测试,你可以看看 JS bundle chunks 的层次结构;他们在顶端。参见图 12-12 。
图 12-12
没有为 JS 包设置以特定顺序显示的层次结构
我们想把这些包裹移到底部。为此,我们可以使用 Quicklink ( https://github.com/GoogleChromeLabs/quicklink
)。Quicklink 通过使用技术来决定首先加载什么,试图使后续页面的导航加载得更快。让我们安装它。
$ yarn add -D quicklink webpack-route-manifest
在我们的案例中,我们将使用 React CRA 水疗中心。我们将使用 React HOC,在这里我们希望为我们延迟加载的页面添加预取功能。为此,我们只需使用一个空的 option 对象,并用 Quicklink HOC 包装我们的组件。
<Route exact path="/MyPage" component={withQuicklink(MyPage, options)} />
看看这里显示的重构后的src/AppRouter.tsx
:
// src/AppRouter.tsx
import React, { FunctionComponent, lazy, Suspense } from 'react'
import { BrowserRouter as Router, Route, Switch, Redirect } from 'react-router-dom'
import { RecoilRoot } from 'recoil'
// @ts-ignore
// eslint-disable-next-line import/extensions
import { withQuicklink } from 'quicklink/dist/react/hoc.js'
import App from './App'
import ScrollToTop from './components/ScrollToTop/ScrollToTop'
// Lazy loading
const MyPage = lazy(() => import('./pages/MyPage/MyPage'))
const options = {
origins: []
}
const AppRouter: FunctionComponent = () => {
return (
<Router>
<ScrollToTop />
<RecoilRoot>
<Suspense fallback={<span>Loading...</span>}>
<Switch>
<Route exact path="/" component={App} />
<Route exact path="/MyPage" component={withQuicklink(MyPage, options)} />
<Redirect to="/" />
</Switch>
</Suspense>
</RecoilRoot>
</Router>
)
}
export default AppRouter
如你所见,在实现了逻辑之后,HOC 工作了,现在我们的块在底部,如图 12-13 所示。
图 12-13
为“关于页面”组件设置的层次结构
清理未使用的事件处理程序
下面是如何清理任何未使用的事件处理程序。
在 useEffect 挂钩中设置副作用
如果我们想使用浏览器 API 滚动到每个页面更新的顶部,我们甚至不需要编写一个类。我们可以将代码包装在 React 函数的useEffect
钩子中。
// src/components/ScrollToTop/ScrollToTop.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
export default function ScrollToTop() {
const { pathname, search } = useLocation() useEffect(
() => () => {
try {
window.scroll({
top: 0,
left: 0,
behavior: 'smooth',
})
} catch (error) {
// older browsers fallback
window.scrollTo(0, 0)
}
},
[pathname, search]
)
return null
}
Note
副作用需要包在useEffect
里。如果处理不当,副作用会在每次渲染时出现,并导致内存泄漏。
我们还需要重构src/AppRouter.tsx
,将组件包含在 React 路由标记中。
// src/AppRouter.tsx
import ScrollToTop from './components/ScrollToTop/ScrollToTop'
const AppRouter: FunctionComponent = () => {
return (
<Router>
<ScrollToTop />
...
</Router>
)
}
这个特殊的副作用不需要任何清理,因为我们没有附加任何事件。
清除副作用
组件卸载后留下事件处理程序会导致内存泄漏。幸运的是,一旦组件被自动卸载,React 组件可以清理基于 React 的事件处理程序。
但是,如果我们需要使用浏览器滚动 API 事件监听器,我们可以,如下所示:
window.addEventListener('scroll', scrollHandler)
您必须自己手动删除该事件,因为 React 不会为您删除它。
window.removeEventListener('scroll', scrollHandler)
您可以在 React 文档( https://reactjs.org/docs/hooks-effect.html
)中了解更多相关信息。
我最后的笔记
很高兴知道可以做多少来优化我们的应用。在我们这一方,只要付出一点努力,我们就可以提高应用的性能,改善用户体验。即使在一个小应用上,结果也是显而易见的。此外,优化您的应用可以让您了解正在发生的事情、它的工作原理以及需要改进的地方。
也就是说,优化我们的应用就是测试、调整和再次测试结果,以微调一切。在一些用例中,做所有或任何优化工作都是没有意义的。我们添加的每个功能都有一个权衡。
你需要试验,每个特性都需要逐个检查。最好的方法是记录内存分析、限制网络连接、检查捆绑包、离线,并尝试不同的网络速度,以找出最佳的用户体验。
请始终记住,您的开发和生产版本是互不相同的,所以不要假设它们的工作方式是一样的。这不是放之四海而皆准的事情。
摘要
在本章中,我向您展示了如何将您的应用消耗的内存量降至最低,减少捆绑文件大小,只加载一次资源,减少查看内容的等待时间,提高性能,并确保您的应用随时随地工作,即使在离线时也是如此。
我们还安装了 Analyzer Bundle,并查看了您可以配置的其他设置,以使您的应用更好,而不是使用默认设置。
我们将这个过程分解为以下几个方面:使用PureComponent
,延迟加载,预存储,预缓存,代码分割,树抖动,减小媒体大小,预取,以及清除副作用。
这个主题足够写一整本书,但我的目的是给你一个好的起点,并涵盖你需要了解的最重要的方面。
例如,我没有介绍网络流量技术。另外,如果渲染一个大的列表,可以使用react-window
( https://github.com/bvaughn/react-window
),这样只会渲染一个大数据集的一部分(刚好够填满视口);react-infinite-scroll-component
;或者react-paginate
。这个清单还在继续。
这一章非常适合作为本书的最后一章,因为它是一个高级主题,你在这里学到了如何对你构建的应用进行最后的润色。我想再次感谢你购买这本书,并祝贺你完成这一章!请随时给我留言,让我知道这本书是如何帮助你的。你也可以在网上留下书评,并把这本书推荐给朋友。
作为购买这本书的奖励材料,前往 https://elielrom.com/ReactQuestions
领取一本免费电子书,里面有最常见的面试问题,包括答案。