React16 高级教程(八)
原文:Pro React 16
十六、使用引用和门户
在正常情况下,组件不直接与文档对象模型(DOM)中的元素交互。正常的交互是通过 props 和事件处理程序进行的,这使得在不知道组件所处理的内容的情况下组合应用和组件协同工作成为可能。
有些情况下,组件需要与 DOM 中的元素进行交互,React 为此提供了两个特性。 refs 特性 references 的简称——提供了对 HTML 元素的访问,这些元素是在被添加到 DOM 后由组件呈现的。门户特性提供了对应用内容之外的 HTML 元素的访问。
应该谨慎使用这些特性,因为它们破坏了应用中组件之间的隔离,这使得编写、测试和维护更加困难。这些特性导致了“兔子洞”,它们解决了一个问题,但是引入了另一个问题,这导致了另一个解决方案和另一个问题,等等。如果使用不当,这些特性会产生重复 React 提供的核心功能的组件,这很少是有益的结果。表 16-1 将参考和门户放在上下文中。
表 16-1
将引用和门户放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | Refs 是对 DOM 中由组件呈现的元素的引用。门户允许在应用内容之外呈现内容。 |
| 它们为什么有用? | 如果不直接访问 DOM,HTML 元素的一些特性是不容易管理的,比如聚焦一个元素。这些特性对于与其他框架和库的集成也很有用。 |
| 它们是如何使用的? | 引用是使用特殊的ref属性创建的,并且可以使用React.createRef方法或使用回调函数来创建。门户是使用ReactDOM.createPortal方法创建的。 |
| 有什么陷阱或限制吗? | 这些特性很容易被滥用,以至于它们破坏了组件隔离,并被用来复制 React 提供的特性。 |
| 有其他选择吗? | 参考和门户是许多项目中不需要的高级功能。 |
表 16-2 总结了本章内容。
表 16-2
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 访问为组件创建的 HTML 元素对象 | 使用引用 | 1–9, 11, 12, 18, 19 |
| 在不使用状态数据和事件处理程序的情况下使用表单元素 | 使用不受控制的表单组件 | 10, 13–15 |
| 防止更新过程中的数据丢失 | 使用getSnapshotBeforeUpdate方法 | 16, 17 |
| 访问子组件的内容 | 使用参考属性或参考转发 | 20–23 |
| 将内容投影到特定的 DOM 元素中 | 使用门户网站 | 24–26 |
为本章做准备
为了创建本章的示例项目,打开一个新的命令提示符,导航到一个方便的位置,并运行清单 16-1 中所示的命令。
小费
你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。
npx create-react-app refs
Listing 16-1Creating the Example Project
运行清单 16-2 中所示的命令,导航到refs文件夹添加引导包。
cd refs
npm install bootstrap@4.1.2
Listing 16-2Adding the Bootstrap CSS Framework
在这一章中,我创建了一个依赖 jQuery 的例子。在refs文件夹中运行清单 16-3 中所示的命令,将 jQuery 包添加到项目中。
npm install jquery@3.3.1
Listing 16-3Installing jQuery
为了在应用中包含引导 CSS 样式表,将清单 16-4 中所示的语句添加到index.js文件中,该文件可以在src文件夹中找到。
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import 'bootstrap/dist/css/bootstrap.css';
ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();
Listing 16-4Including Bootstrap in the index.js File in the src Folder
在src文件夹中添加一个名为Editor.js的文件,并添加清单 16-5 中所示的代码。
import React, { Component } from "react";
export class Editor extends Component {
constructor(props) {
super(props);
this.state = {
name: "",
category: "",
price: ""
}
}
handleChange = (event) => {
event.persist();
this.setState(state => state[event.target.name] = event.target.value);
}
handleAdd = () => {
this.props.callback(this.state);
this.setState({ name: "", category:"", price:""});
}
render() {
return <React.Fragment>
<div className="form-group p-2">
<label>Name</label>
<input className="form-control" name="name"
value={ this.state.name } onChange={ this.handleChange }
autoFocus={ true } />
</div>
<div className="form-group p-2">
<label>Category</label>
<input className="form-control" name="category"
value={ this.state.category } onChange={ this.handleChange } />
</div>
<div className="form-group p-2">
<label>Price</label>
<input className="form-control" name="price"
value={ this.state.price } onChange={ this.handleChange } />
</div>
<div className="text-center">
<button className="btn btn-primary" onClick={ this.handleAdd }>
Add
</button>
</div>
</React.Fragment>
}
}
Listing 16-5The Contents of the Editor.js File in the src Folder
Editor组件呈现一系列input元素,这些元素的值是使用状态数据属性设置的,它们的更改事件由handleChange方法处理。有一个button元素,它的 click 事件调用handleAdd方法,该方法使用状态数据调用一个函数 prop,然后该函数被重置。
接下来,将名为ProductTable.js的文件添加到src文件夹中,并添加清单 16-6 中所示的代码。
import React, { Component } from "react";
export class ProductTable extends Component {
render() {
return <table className="table table-sm table-striped">
<thead><tr><th>Name</th><th>Category</th><th>Price</th></tr></thead>
<tbody>
{
this.props.products.map(p =>
<tr key={ p.name }>
<td>{ p.name }</td>
<td>{ p.category }</td>
<td>${ Number(p.price).toFixed(2) }</td>
</tr>
)
}
</tbody>
</table>
}
}
Listing 16-6The Contents of the ProductTable.js File in the src Folder
ProductTable组件呈现一个表格,该表格包含在products属性中接收的每个对象的一行。接下来,用清单 16-7 中所示的代码替换App.js文件的内容。
import React, { Component } from "react";
import { Editor } from "./Editor"
import { ProductTable } from "./ProductTable";
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
products: []
}
}
addProduct = (product) => {
if (this.state.products.indexOf(product.name) === -1) {
this.setState({ products: [...this.state.products, product ]});
}
}
render() {
return <div>
<Editor callback={ this.addProduct } />
<h6 className="bg-secondary text-white m-2 p-2">Products</h6>
<div className="m-2">
{
this.state.products.length === 0
? <div className="text-center">No Products</div>
: <ProductTable products={ this.state.products } />
}
</div>
</div>
}
}
Listing 16-7Replacing the Contents of the App.js File in the src Folder
使用命令提示符,运行refs文件夹中清单 16-8 所示的命令来启动开发工具。
npm start
Listing 16-8Starting the Development Tools
一旦项目的初始准备工作完成,一个新的浏览器窗口将打开并显示 URL http://localhost:3000,它显示如图 16-1 所示的内容。填写表单并单击 Add 按钮,您将看到表格中显示了一个新条目。
图 16-1
运行示例应用
创建参考
当组件需要访问 DOM 以使用特定 HTML 元素的特性时,可以使用 Refs。有一些 HTML 特性是不能通过使用 props 来实现的,其中之一就是要求一个元素获得焦点。第一次呈现内容时,autoFocus属性可用于聚焦一个元素,但一旦用户单击它,焦点将切换到button元素,这意味着用户不能开始键入以创建另一个项目,直到他们重新聚焦,无论是通过单击input元素还是通过使用 Tab 键。
当点击添加按钮触发的事件被处理时,可以使用 ref 来访问 DOM 并调用input元素上的focus方法,如清单 16-9 所示。
不要急着用引用
能够访问 DOM 是 web 开发人员的自然期望,refs 看起来是一个使 React 开发更容易的特性,特别是如果您是从 Angular 这样的框架开始使用 React。
很容易被 refs 冲昏头脑,最终得到一个组件,它复制了应该由 React 执行的内容处理特性。过度使用引用的组件很难管理,可能会依赖于特定的浏览器功能,并且很难在不同的平台上运行。
仅在万不得已的情况下才使用 refs,并且始终考虑是否可以使用 state 和 props 功能获得相同的结果。
import React, { Component } from "react";
export class Editor extends Component {
constructor(props) {
super(props);
this.state = {
name: "",
category: "",
price: ""
}
this.nameRef = React.createRef();
}
handleChange = (event) => {
event.persist();
this.setState(state => state[event.target.name] = event.target.value);
}
handleAdd = () => {
this.props.callback(this.state);
this.setState({ name: "", category:"", price:""},
() => this.nameRef.current.focus());
}
render() {
return <React.Fragment>
<div className="form-group p-2">
<label>Name</label>
<input className="form-control" name="name"
value={ this.state.name } onChange={ this.handleChange }
autoFocus={ true } ref={ this.nameRef } />
</div>
<div className="form-group p-2">
<label>Category</label>
<input className="form-control" name="category"
value={ this.state.category } onChange={ this.handleChange } />
</div>
<div className="form-group p-2">
<label>Price</label>
<input className="form-control" name="price"
value={ this.state.price } onChange={ this.handleChange } />
</div>
<div className="text-center">
<button className="btn btn-primary" onClick={ this.handleAdd }>
Add
</button>
</div>
</React.Fragment>
}
}
Listing 16-9Using a Ref in the Editor.js File in the src Folder
引用是使用React.createRef方法创建的,该方法在构造函数中被调用,因此结果可以在整个组件中使用。ref 使用特殊的ref属性与一个元素相关联,通过一个表达式为元素选择 ref。
...
<input className="form-control" name="name"
value={ this.state.name } onChange={ this.handleChange }
autoFocus={ true } ref={ this.nameRef } />
...
由createRef方法返回的 ref 对象只定义了一个名为current的属性,该属性返回代表 DOM 中元素的HTMLElement对象。状态数据更新完成后,我使用handleAdd方法中的current属性来调用focus方法,如下所示:
...
this.setState({ name: "", category:"", price:""},
() => this.nameRef.current.focus());
...
结果是,当添加按钮触发的更新完成时,name input元素将重新获得焦点,允许用户开始输入下一个新产品,而不必手动选择该元素,如图 16-2 所示。
图 16-2
使用引用
使用参照创建不受控制的形状构件
示例应用使用我在第十五章中介绍的受控表单组件技术,其中 React 负责每个表单元素的内容,使用状态数据属性存储其值,使用事件处理程序响应更改。
表单元素已经具有存储值和响应更改的能力,但是这些功能不被受控表单组件使用。另一种技术是创建一个不受控制的表单组件,其中 ref 用于访问表单元素,浏览器负责管理元素的值并响应更改。在清单 16-10 中,我删除了用于管理由Editor组件呈现的input元素的状态数据,并使用 refs 来创建不受控制的表单组件。
import React, { Component } from "react";
export class Editor extends Component {
constructor(props) {
super(props);
// this.state = {
// name: "",
// category: "",
// price: ""
// }
this.nameRef = React.createRef();
this.categoryRef = React.createRef();
this.priceRef = React.createRef();
}
// handleChange = (event) => {
// event.persist();
// this.setState(state => state[event.target.name] = event.target.value);
// }
handleAdd = () => {
this.props.callback({
name: this.nameRef.current.value,
category: this.categoryRef.current.value,
price: this.priceRef.current.value
});
this.nameRef.current.value = "";
this.categoryRef.current.value = "";
this.priceRef.current.value = "";
this.nameRef.current.focus();
}
render() {
return <React.Fragment>
<div className="form-group p-2">
<label>Name</label>
<input className="form-control" name="name"
autoFocus={ true } ref={ this.nameRef } />
</div>
<div className="form-group p-2">
<label>Category</label>
<input className="form-control" name="category"
ref={ this.categoryRef } />
</div>
<div className="form-group p-2">
<label>Price</label>
<input className="form-control" name="price" ref={ this.priceRef } />
</div>
<div className="text-center">
<button className="btn btn-primary" onClick={ this.handleAdd }>
Add
</button>
</div>
</React.Fragment>
}
}
Listing 16-10Creating Uncontrolled Form Components in the Editor.js File in the src Folder
在用户单击 Add 按钮之前,input元素值是不需要的。在单击按钮时调用的handleAdd方法中,每个input元素的引用用于读取value属性。用户看到的结果与前面的示例一样,但是在幕后,React 不再负责管理元素值或响应变更事件。
为非受控元素设置初始值
React 不对不受控制的元素负责,但它仍然可以提供一个初始值,然后由浏览器管理。要设置该值,请使用defaultValue或defaultChecked属性,但是请记住,您指定的值将仅在元素首次呈现时使用,并且不会在元素发生变化时更新元素。
使用回调函数创建引用
前面的例子展示了如何在表单元素中使用 refs,但是结果与我在本章开始时使用的受控表单组件没有太大的不同。有一种替代技术可以用来创建引用,并且可以产生更简洁的组件,如清单 16-11 所示,称为回调引用。
import React, { Component } from "react";
export class Editor extends Component {
constructor(props) {
super(props);
this.formElements = {
name: { },
category: { },
price: { }
}
}
setElement = (element) => {
if (element !== null) {
this.formElements[element.name].element = element;
}
}
handleAdd = () => {
let data = {};
Object.values(this.formElements)
.forEach(v => {
data[v.element.name] = v.element.value;
v.element.value = "";
});
this.props.callback(data);
this.formElements.name.element.focus();
}
render() {
return <React.Fragment>
<div className="form-group p-2">
<label>Name</label>
<input className="form-control" name="name"
autoFocus={ true } ref={ this.setElement } />
</div>
<div className="form-group p-2">
<label>Category</label>
<input className="form-control" name="category"
ref={ this.setElement } />
</div>
<div className="form-group p-2">
<label>Price</label>
<input className="form-control" name="price"
ref={ this.setElement } />
</div>
<div className="text-center">
<button className="btn btn-primary" onClick={ this.handleAdd }>
Add
</button>
</div>
</React.Fragment>
}
}
Listing 16-11Using Callback Refs in the Editor.js File in the src Folder
input元素的ref属性的值被设置为一个方法,该方法在呈现内容时被调用。指定的方法不是处理一个ref对象,而是直接接收HTMLElement对象,而不是一个具有current属性的引用对象。在清单中,setElement方法接收元素,使用name值将这些元素添加到formElements对象中,这样我就可以区分这些元素。
如果元素被卸载,您为回调 ref 提供的函数也将被调用,参数为null。对于这个例子,如果元素被删除,我不需要做任何整理,所以我只需要检查setElement方法中的空值。
...
setElement = (element) => {
if (element !== null) {
this.formElements[element.name].element = element;
}
}
...
一旦有了 refs 的函数,就可以很容易地以编程方式生成表单,如清单 16-12 所示,因为 refs 不必单独创建和分配给元素。
import React, { Component } from "react";
export class Editor extends Component {
constructor(props) {
super(props);
this.formElements = {
name: { label: "Name", name: "name" },
category: { label: "Category", name: "category" },
price: { label: "Price", name: "price" }
}
}
setElement = (element) => {
if (element !== null) {
this.formElements[element.name].element = element;
}
}
handleAdd = () => {
let data = {};
Object.values(this.formElements)
.forEach(v => {
data[v.element.name] = v.element.value;
v.element.value = "";
});
this.props.callback(data);
this.formElements.name.element.focus();
}
render() {
return <React.Fragment>
{
Object.values(this.formElements).map(elem =>
<div className="form-group p-2" key={ elem.name }>
<label>{ elem.label }</label>
<input className="form-control"
name={ elem.name }
autoFocus={ elem.name === "name" }
ref={ this.setElement } />
</div>)
}
<div className="text-center">
<button className="btn btn-primary" onClick={ this.handleAdd }>
Add
</button>
</div>
</React.Fragment>
}
}
Listing 16-12Generating a Form Programmatically in the Editor.js File in the src Folder
使用formElements对象的属性生成input元素,其中每个属性被分配一个具有label和name属性的对象,这些属性在render方法中用于配置元素。
定义和管理表单所需的代码更简洁,但效果是一样的,填写表单并点击添加按钮会显示一个新的对象,如图 16-3 所示。
图 16-3
以编程方式创建表单元素和引用
验证不受控制的表单组件
表单元素通过 HTML 约束验证 API 具有内置的验证支持,可以使用 refs 访问该 API。验证 API 使用如下对象描述元素的验证状态:
...
{
valueMissing: true, tooShort: false, rangeUnderflow: false
}
...
当我指定元素必须有值但为空时,valueMissing属性将是true。当元素值中的字符少于验证规则指定的字符数时,tooShort属性将为true。对于小于指定最小值的数值,rangeUnderflow属性将是true。
为了处理这种类型的验证对象,我在src文件夹中添加了一个名为ValidationMessages.js的文件,并用它来定义清单 16-13 中所示的函数。
export function GetValidationMessages(elem) {
let errors = [];
if (!elem.checkValidity()) {
if (elem.validity.valueMissing) {
errors.push("Value required");
}
if (elem.validity.tooShort) {
errors.push("Value is too short");
}
if (elem.validity.rangeUnderflow) {
errors.push("Value is too small");
}
}
return errors;
}
Listing 16-13The Contents of the ValidationMessages.js File in the src Folder
GetValidationMessages函数接收一个 HTML 元素对象,并通过调用元素的checkValidity方法请求浏览器进行数据验证。如果元素的值是valid,则checkValidity方法返回true,否则返回false。如果元素的值不是valid,则检查元素的validity属性,以获得具有true值的valueMissing、tooShort和rangeUnderflow属性,并用于创建可以显示给用户的错误数组。
小费
HTML 验证特性包括比我在本章中使用的更广泛的验证检查和有效性属性。参见 https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation 了解可用功能的详细描述。
我在src文件夹中添加了一个名为ValidationDisplay.js的文件,并使用它来定义一个组件,该组件将显示单个元素的验证消息,如清单 16-14 所示。
import React, { Component } from "react";
export class ValidationDisplay extends Component {
render() {
return this.props.errors
? this.props.errors.map(err =>
<div className="small bg-danger text-white mt-1 p-1"
key={ err } >
{ err }
</div>)
: null
}
}
Listing 16-14The Contents of the ValidationDisplay.js File in the src Folder
该组件接收一个应该显示的错误消息数组,如果没有错误消息要显示,则返回null表示没有内容。在清单 16-15 中,我已经更新了Editor组件,以便在使用表单数据之前将验证属性应用于表单元素并执行验证检查。
import React, { Component } from "react";
import { ValidationDisplay } from "./ValidationDisplay";
import { GetValidationMessages } from "./ValidationMessages";
export class Editor extends Component {
constructor(props) {
super(props);
this.formElements = {
name: { label: "Name", name: "name",
validation: { required: true, minLength: 3 }},
category: { label: "Category", name:"category",
validation: { required: true, minLength: 5 }},
price: { label: "Price", name: "price",
validation: { type: "number", required: true, min: 5 }}
}
this.state = {
errors: {}
}
}
setElement = (element) => {
if (element !== null) {
this.formElements[element.name].element = element;
}
}
handleAdd = () => {
if (this.validateFormElements()) {
let data = {};
Object.values(this.formElements)
.forEach(v => {
data[v.element.name] = v.element.value;
v.element.value = "";
});
this.props.callback(data);
this.formElements.name.element.focus();
}
}
validateFormElement = (name) => {
let errors = GetValidationMessages(this.formElements[name].element);
this.setState(state => state.errors[name] = errors);
return errors.length === 0;
}
validateFormElements = () => {
let valid = true;
Object.keys(this.formElements).forEach(name => {
if (!this.validateFormElement(name)) {
valid = false;
}
})
return valid;
}
render() {
return <React.Fragment>
{
Object.values(this.formElements).map(elem =>
<div className="form-group p-2" key={ elem.name }>
<label>{ elem.label }</label>
<input className="form-control"
name={ elem.name }
autoFocus={ elem.name === "name" }
ref={ this.setElement }
onChange={ () => this.validateFormElement(elem.name) }
{ ...elem.validation} />
<ValidationDisplay
errors={ this.state.errors[elem.name] } />
</div>)
}
<div className="text-center">
<button className="btn btn-primary" onClick={ this.handleAdd }>
Add
</button>
</div>
</React.Fragment>
}
}
Listing 16-15Applying Validation in the Editor.js File in the src Folder
我在描述元素的对象中包含了每个元素的验证属性,如下所示:
...
name: { label: "Name", name: "name", validation: { required: true, minLength: 3 }},
...
required属性表示需要一个值,而minLength属性指定该值应该至少包含三个字符。当通过render方法创建input元素时,这些属性被应用于它们。
...
<input className="form-control" name={ elem.name }
autoFocus={ elem.name === "name" } ref={ this.setElement }
onChange={ () => this.validateFormElement(elem.name) }
{ ...elem.validation} />
...
我不必担心我在第十五章中描述的原始/肮脏元素的问题,因为直到调用checkValidity方法才执行验证,这将发生在对change事件的响应中,我使用onChange事件属性和validateFormElement方法处理该事件,结果是只有当用户开始键入时,元素的验证才开始,如图 16-4 所示。
图 16-4
验证元素
当用户点击添加按钮时,handleAdd方法调用validateFormElements按钮,该按钮验证所有元素并确保表单数据在问题解决之前不会被使用,如图 16-5 所示。更改的效果会立即显示出来,因为每次编辑都会触发一个change事件,导致元素的值被再次验证。
图 16-5
验证所有元素
了解参考文献和生命周期
在 React 调用组件的render方法之前,Refs 不会被赋值。如果您使用的是createRef方法,那么在组件呈现其内容之前,current属性不会被赋值。类似地,回调引用不会调用它们的方法,直到组件已经呈现。
引用的分配在组件生命周期中似乎很晚,但是引用提供了对 DOM 元素的访问,这些元素直到呈现阶段才创建,这意味着 React 直到调用render方法才创建引用所引用的元素。与 ref 相关联的元素只能在componentDidMount和componentDidUpdate生命周期方法中访问,因为它们发生在渲染已经完成并且 DOM 已经被填充或更新之后。
使用 refs 的一个后果是,当 React 替换它在 DOM 中呈现的元素时,组件不能依赖状态特性来保留它的上下文。React 试图最小化 DOM 变化,但是你不能依赖于在应用的整个生命周期中使用相同的元素。如第十三章所述,改变组件呈现的顶层元素会导致 React 替换其在 DOM 中的元素,如清单 16-16 所示。
import React, { Component } from "react";
import { ValidationDisplay } from "./ValidationDisplay";
import { GetValidationMessages } from "./ValidationMessages";
export class Editor extends Component {
constructor(props) {
super(props);
this.formElements = {
name: { label: "Name", name: "name",
validation: { required: true, minLength: 3 }},
category: { label: "Category", name:"category",
validation: { required: true, minLength: 5 }},
price: { label: "Price", name: "price",
validation: { type: "number", required: true, min: 5 }}
}
this.state = {
errors: {},
wrapContent: false
}
}
setElement = (element) => {
if (element !== null) {
this.formElements[element.name].element = element;
}
}
handleAdd = () => {
if (this.validateFormElements()) {
let data = {};
Object.values(this.formElements)
.forEach(v => {
data[v.element.name] = v.element.value;
v.element.value = "";
});
this.props.callback(data);
this.formElements.name.element.focus();
}
}
validateFormElement = (name) => {
let errors = GetValidationMessages(this.formElements[name].element);
this.setState(state => state.errors[name] = errors);
return errors.length === 0;
}
validateFormElements = () => {
let valid = true;
Object.keys(this.formElements).forEach(name => {
if (!this.validateFormElement(name)) {
valid = false;
}
})
return valid;
}
toggleWrap = () => {
this.setState(state => state.wrapContent = !state.wrapContent);
}
wrapContent(content) {
return this.state.wrapContent
? <div className="bg-secondary p-2">
<div className="bg-light">{ content }</div>
</div>
: content;
}
render() {
return this.wrapContent(
<React.Fragment>
<div className="form-group text-center p-2">
<div className="form-check">
<input className="form-check-input"
type="checkbox"
checked={ this.state.wrapContent }
onChange={ this.toggleWrap } />
<label className="form-check-label">Wrap Content</label>
</div>
</div>
{
Object.values(this.formElements).map(elem =>
<div className="form-group p-2" key={ elem.name }>
<label>{ elem.label }</label>
<input className="form-control"
name={ elem.name }
autoFocus={ elem.name === "name" }
ref={ this.setElement }
onChange={ () => this.validateFormElement(elem.name) }
{ ...elem.validation} />
<ValidationDisplay
errors={ this.state.errors[elem.name] } />
</div>)
}
<div className="text-center">
<button className="btn btn-primary" onClick={ this.handleAdd }>
Add
</button>
</div>
</React.Fragment>)
}
}
Listing 16-16Rendering a Different Top-Level Element in the Editor.js File in the src Folder
我添加了一个wrapContent state 属性,该属性是使用一个受控的复选框设置的,它包装了组件呈现的内容,并确保 React 用新的元素替换 DOM 中组件的现有元素。要查看效果,请在“名称”字段中输入文本,并选中“换行”复选框,如图 16-6 所示。
图 16-6
替换元素
您输入文本的input元素已被破坏,其内容已丢失。让用户更加困惑的是,检测到的任何验证错误都是组件状态数据的一部分,这意味着它们将显示在新的input元素旁边,即使它们描述的数据值不再可见。
为了帮助避免这个问题,有状态组件生命周期包括了getSnapshotBeforeUpdate方法,在更新阶段在render和componentDidUpdate方法之间调用,如图 16-7 所示。
图 16-7
快照流程
这个getSnapshotBeforeUpdate方法允许组件在调用render方法之前检查其当前内容并生成一个定制的快照对象。一旦更新完成,就会调用componentDidUpdate方法并提供快照对象,这样组件就可以修改现在在 DOM 中的元素。
警告
如果组件被卸载并重新创建,快照无助于保留上下文,当祖先的内容改变时会发生这种情况。在这些情况下,componentWillUnmount方法可用于访问引用,数据可通过上下文保存,如第十五章所述。
在清单 16-17 中,我使用了快照特性来捕捉更新前输入到 input 元素中的值,并在更新后恢复这些值。
import React, { Component } from "react";
import { ValidationDisplay } from "./ValidationDisplay";
import { GetValidationMessages } from "./ValidationMessages";
export class Editor extends Component {
constructor(props) {
super(props);
this.formElements = {
name: { label: "Name", name: "name",
validation: { required: true, minLength: 3 }},
category: { label: "Category", name:"category",
validation: { required: true, minLength: 5 }},
price: { label: "Price", name: "price",
validation: { type: "number", required: true, min: 5 }}
}
this.state = {
errors: {},
wrapContent: false
}
}
// ...other methods omitted for brevity...
getSnapshotBeforeUpdate(props, state) {
return Object.values(this.formElements).map(item =>
{return { name: [item.name], value: item.element.value }})
}
componentDidUpdate(oldProps, oldState, snapshot) {
snapshot.forEach(item => {
let element = this.formElements[item.name].element
if (element.value !== item.value) {
element.value = item.value;
}
});
}
}
Listing 16-17Taking a Snapshot in the Editor.js File in the src Folder
getSnapshotBeforeUpdate方法接收组件在更新被触发之前的属性和状态,并在更新后返回一个将被传递给componentDidUpdate方法的对象。在这个例子中,我不需要访问 props 或 state,因为我需要保存的数据包含在input元素中。React 并没有要求快照对象使用特定的格式,而且getSnapshotBeforeUpdate方法可以以任何有用的格式返回数据。在这个例子中,getSnapshotBeforeUpdate方法返回一个带有name和value属性的对象数组。
React 完成更新后,它调用componentDidUpdate并提供快照作为参数,以及旧的属性和状态数据。在示例中,我处理对象数组并设置输入元素的值。结果是当复选框被切换时,输入到input元素中的数据被保留,如图 16-8 所示。
图 16-8
使用快照数据
每次更新都会调用getSnapshotBeforeUpdate和componentDidUpdate方法,即使 React 没有替换 DOM 中的组件元素,这就是为什么我只在更新完成后元素的值与快照值不同时才应用快照值。
了解参考文献兔子洞
在前面的例子中使用 HTML5 约束验证 API 有一个意想不到的后果。只有当用户编辑文本字段的内容时,才执行验证,而不是当值以编程方式设置时。当我使用快照数据来设置新创建的input元素的值时,它将通过验证,即使该值之前没有通过验证。其效果是,用户可以通过在name或category输入元素中输入错误的值,选中 wrap content 复选框,然后单击 Add 按钮来绕过验证。
这是一个可以解决的问题,但潜在的问题是使用 refs 直接访问 DOM 会出现一系列小冲突,每个冲突都可以通过添加几行代码来解决。但是这些修复通常会带来其他问题或妥协,需要额外的工作,结果是由复杂组件构成的脆弱的应用。
在某些项目中,直接使用 DOM 可能是必不可少的,避免复制已经存在于 DOM 中的数据和特性可能会有好处。但是只有在需要的时候才使用引用,因为它们可以制造和解决一样多的问题。
对其他库或框架使用引用
一些项目被转移到逐渐 React,因此组件必须与在另一个库或框架中编写的现有特性进行互操作。最常见的例子是 jQuery,在 React 和 Angular 这样的框架出现之前,它是 web 应用开发最流行的选择,现在仍然广泛用于简单的项目。例如,如果您有大量用 jQuery 编写的特性,那么您可以使用 refs 将它们应用于组件呈现的 HTML 元素。为了演示,我将使用 jQuery 将带有无效元素的表单元素分配给一个将应用 Bootstrap 样式的类。我在src文件夹中添加了一个名为jQueryColorizer.js的文件,并添加了清单 16-18 中所示的代码。
注意
这个例子需要添加到清单 16-3 中的项目的 jQuery 包。如果没有安装 jQuery,应该在继续之前安装。
var $ = require('jquery');
export function ColorInvalidElements(rootElement) {
$(rootElement)
.find("input:invalid").addClass("border-danger")
.removeClass("border-success")
.end()
.find("input:valid").removeClass("border-danger")
.addClass("border-success");
}
Listing 16-18The Contents of the jQueryColorizer.js in the src Folder
jQuery 语句定位分配给invalid伪类的所有input元素,并将它们添加到border-danger类,并将valid伪类中的任何input元素添加到border-success类。HTML 约束验证 API 使用valid和invalid类来指示元素的验证状态。在清单 16-19 中,我添加了一个 ref 并使用它从App组件中调用 jQuery 函数。
混合框架
使用 refs 来合并其他框架很困难,而且容易出现问题。像任何引用的使用一样,应该谨慎地使用,并且只有在无法重写 React 中的功能时才使用。您可能觉得通过构建现有的代码可以节省时间,但是我的经验是,节省下来的时间将会花在尝试解决一系列小问题上,这些小问题是由于两个框架以不同的方式工作而产生的。
如果您不得不在 React 旁边使用另一个库或框架,那么您应该密切关注框架处理 DOM 的方式。您会发现 React 和其他框架希望完全控制它们创建的内容,当以框架开发人员没有预料到的方式添加、删除或更改元素时,可能会出现意想不到的结果。
import React, { Component } from "react";
import { Editor } from "./Editor"
import { ProductTable } from "./ProductTable";
import { ColorInvalidElements } from "./jQueryColorizer";
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
products: []
}
this.editorRef = React.createRef();
}
addProduct = (product) => {
if (this.state.products.indexOf(product.name) === -1) {
this.setState({ products: [...this.state.products, product ]});
}
}
colorFields = () => {
ColorInvalidElements(this.editorRef.current);
}
render() {
return <div>
<div className="text-center m-2">
<button className="btn btn-primary" onClick={ this.colorFields }>
jQuery
</button>
</div>
<div ref={ this.editorRef} >
<Editor callback={ this.addProduct } />
</div>
<h6 className="bg-secondary text-white m-2 p-2">Products</h6>
<div className="m-2">
{
this.state.products.length === 0
? <div className="text-center">No Products</div>
: <ProductTable products={ this.state.products } />
}
</div>
</div>
}
}
Listing 16-19Invoking a Function in the App.js File in the src Folder
结果是,单击 jQuery 按钮会调用colorFields方法,该方法使用 ref 为 jQuery 函数提供它需要的 HTML 元素。jQuery 函数将边框应用于输入元素以指示它们的验证状态,如图 16-9 所示。(在本书的印刷版本中,边框颜色的差异不会很明显,这是一个最好在浏览器中运行以查看效果的示例。)
图 16-9
通过 ref 为 jQuery 提供元素
使用参照访问元件
在清单 16-19 中,我在Editor元素周围添加了一个div元素。当 React 将内容呈现到 DOM 中时,Editor元素不会成为 HTML 文档的一部分,添加div元素可以确保 jQuery 能够访问应用的内容。
引用确实与组件一起工作,如果我将ref属性应用于Editor元素,那么引用的current属性的值将被分配给在呈现App组件内容时创建的Editor对象。
对组件引用允许访问组件的状态数据和方法。使用 refs 来调用子组件的方法是很诱人的,因为它产生的开发体验更类似于传统上使用对象的方式。
通过引用操作一个组件是不好的做法。它产生了紧密耦合的组件,最终与 React 背道而驰。开始时,状态数据、属性和事件特性可能感觉不太自然,但是您会习惯于它们,结果是一个充分利用 React 的应用,并且更容易编写、测试和维护。
访问子组件的内容
React 对refs属性进行了特殊处理,这意味着当一个组件需要引用由它的一个后代呈现的 DOM 元素时,必须小心。最简单的方法是使用不同的名称传递 ref 对象或回调函数,在这种情况下,React 将像传递任何其他属性一样传递 ref。为了演示,我在src文件夹中添加了一个名为FormField.js的文件,并用它来定义清单 16-20 中所示的组件。
注意
访问子组件的内容应该小心,因为它会创建更难编写和测试的紧密耦合的组件。在可能的情况下,应该使用 props 在组件之间进行通信。
import React, { Component } from "react";
export class FormField extends Component {
constructor(props) {
super(props);
this.state = {
fieldValue: ""
}
}
handleChange = (ev) => {
this.setState({ fieldValue: ev.target.value});
}
render() {
return <div className="form-group">
<label>{ this.props.label }</label>
<input className="form-control" value={ this.state.fieldValue }
onChange={ this.handleChange } ref={ this.props.fieldRef } />
</div>
}
}
Listing 16-20The Contents of the FormField.js File in the src Folder
该组件呈现一个受控的input元素,并使用一个名为fieldRef的属性将从父元素接收到的ref与该元素相关联。在清单 16-21 中,我已经替换了由App组件呈现的内容,以使用FormField组件并为其提供一个引用。
import React, { Component } from "react";
import { FormField } from "./FormField";
export default class App extends Component {
constructor(props) {
super(props);
this.fieldRef = React.createRef();
}
handleClick = () => {
this.fieldRef.current.focus();
}
render() {
return <div className="m-2">
<FormField label="Name" fieldRef={ this.fieldRef } />
<div className="text-center m-2">
<button className="btn btn-primary"
onClick={ this.handleClick }>
Focus
</button>
</div>
</div>
}
}
Listing 16-21Replacing the Contents of the App.js File in the src Folder
App组件创建一个引用,并使用fieldRef属性将其传递给FormField组件,然后使用ref将其应用于input元素。结果是点击由App组件呈现的焦点按钮,将聚焦由其子组件呈现的输入元素,如图 16-10 所示。
图 16-10
访问孩子的内容
使用引用转发
React 提供了一种将引用传递给子对象的替代方法,称为引用转发,它允许使用ref来代替常规属性。在清单 16-22 中,我为FormField组件使用了引用转发。
import React, { Component } from "react";
export const ForwardFormField = React.forwardRef((props, ref) =>
<FormField { ...props } fieldRef={ ref } />
)
export class FormField extends Component {
constructor(props) {
super(props);
this.state = {
fieldValue: ""
}
}
handleChange = (ev) => {
this.setState({ fieldValue: ev.target.value});
}
render() {
return <div className="form-group m-2">
<label>{ this.props.label }</label>
<input className="form-control" value={ this.state.fieldValue }
onChange={ this.handleChange } ref={ this.props.fieldRef } />
</div>
}
}
Listing 16-22Using Ref Forwarding in the FormField.js File in the src Folder
向React.forwardRef方法传递一个接收 props 和ref值并呈现内容的函数。在这种情况下,我接收到了ref值,并将其转发给了fieldRef prop,这是FormField组件期望接收的 prop 名称。我将来自forwardRef方法的结果导出为ForwardFormField,我已经在App组件中使用了它,如清单 16-23 所示。
import React, { Component } from "react";
import { ForwardFormField } from "./FormField";
export default class App extends Component {
constructor(props) {
super(props);
this.fieldRef = React.createRef();
}
handleClick = () => {
this.fieldRef.current.focus();
}
render() {
return <div>
<ForwardFormField label="Name" ref={ this.fieldRef } />
<div className="text-center m-2">
<button className="btn btn-primary"
onClick={ this.handleClick }>
Focus
</button>
</div>
</div>
}
}
Listing 16-23Using Ref Forwarding in the App.js File in the src Folder
这个例子产生了与图 16-10 所示相同的效果,优点是App组件不需要任何关于ref如何在子组件中处理的特殊知识。
使用门户网站
门户允许组件将其内容呈现到特定的 DOM 元素中,而不是作为其父内容的一部分呈现。该特性允许组件脱离普通的 React 组件模型,但是要求在应用之外创建和管理目标元素,这意味着您不能使用门户将内容呈现到不同的组件中。因此,这个特性在有限的情况下很有用,比如为用户创建对话框或模型警告,或者将 React 集成到另一个框架或库创建的内容中。在清单 16-24 中,我在index.html文件中添加了新的 HTML 元素,这样在示例应用呈现的内容之外就有了一个 DOM 元素,我可以将它作为门户的目标。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div class="container">
<div class="row">
<div class="col">
<div id="root"></div>
</div>
<div class="col">
<div id="portal" class="m-2">
<h6 class="bg-info text-white text-center p-2">
This is the portal target
</h6>
</div>
</div>
</div>
</div>
</body>
</html>
Listing 16-24Adding Elements in the index.html File in the public Folder
新元素被分配给引导 CSS 网格类,以便门户目标元素显示在应用呈现的内容旁边,如图 16-11 所示。
图 16-11
向 HTML 文档中添加元素
我在src文件夹中添加了一个名为PortalWrapper.js的文件,并使用它来定义清单 16-25 中所示的组件,该组件在 DOM 中定位目标元素并使用它来创建门户。
import React, { Component } from "react";
import ReactDOM from "react-dom";
export class PortalWrapper extends Component {
constructor(props) {
super(props);
this.portalElement = document.getElementById("portal");
}
render() {
return ReactDOM.createPortal(
<div className="border p-3">{ this.props.children }</div>
, this.portalElement);
}
}
Listing 16-25The Contents of the PortalWrapper.js File in the src Folder
使用props.children属性创建容器来定义PortalWrapper组件,但是使用ReactDOM.createPortal方法返回其内容,该方法的参数是要呈现的内容和 DOM 目标元素。在这个例子中,我使用 DOM API 的getElementById方法来定位添加到清单 16-24 中的 HTML 文件的目标元素。在清单 16-26 中,我使用了App组件中的门户。
对门户使用引用
不能使用门户通过引用将内容呈现给元素。在呈现过程中使用门户,直到呈现完成时才给 ref 分配元素,这意味着在生命周期中不能通过 ref 为ReactDOM.createPortal方法访问元素。如果你需要在应用的不同部分的组件之间进行协调,或者使用第三部分中描述的一个包,使用上下文,如第十四章所述。
import React, { Component } from "react";
import { ForwardFormField } from "./FormField";
import { PortalWrapper } from "./PortalWrapper";
export default class App extends Component {
constructor(props) {
super(props);
this.fieldRef = React.createRef();
this.portalFieldRef = React.createRef();
}
focusLocal = () => {
this.fieldRef.current.focus();
}
focusPortal = () => {
this.portalFieldRef.current.focus();
}
render() {
return <div>
<PortalWrapper>
<ForwardFormField label="Name" ref={ this.portalFieldRef } />
</PortalWrapper>
<ForwardFormField label="Name" ref={ this.fieldRef } />
<div className="text-center m-2">
<button className="btn btn-primary m-1"
onClick={ this.focusLocal }>
Focus Local
</button>
<button className="btn btn-primary m-1"
onClick={ this.focusPortal }>
Focus Portal
</button>
</div>
</div>
}
}
Listing 16-26Using a Portal in the App.js File in the src Folder
PortalWrapper元素用于应用新组件作为ForwardFormField的容器。门户显示的内容被视为是App组件内容的一部分,这样即使门户的内容是在应用之外呈现的,事件也会像平常一样冒泡,并且可以分配引用。App组件不知道一个门户正在被使用,点击聚焦本地和聚焦门户按钮使用相同的引用技术聚焦每个ForwardFormField组件呈现的input元素,如图 16-12 所示。
图 16-12
使用门户网站
摘要
在本章中,我描述了直接使用 DOM 的 React 特性。我解释了 refs 如何提供对组件呈现的内容的访问,以及这如何使不受控制的表单元素成为可能。我还演示了一个门户,它允许在应用的组件层次结构之外呈现内容。这些特性是非常宝贵的,但应该谨慎使用,因为它们破坏了正常的 React 开发模型,并导致紧密耦合的组件。在下一章,我将向您展示如何在 React 组件上执行单元测试。
十七、单元测试
在本章中,我将向您展示如何测试 React 组件。我介绍了一个使测试变得更容易的包,并演示了如何用它来单独测试组件并测试它们与其子组件的交互。表 17-1 将单元测试放在上下文中。
表 17-1
将单元测试放在上下文中
|问题
|
回答
|
| --- | --- |
| 这是什么? | React 组件需要特殊的测试支持,以便可以隔离和检查它们与应用其他部分的交互。 |
| 为什么有用? | 独立的单元测试能够评估组件提供的基本逻辑,而不受与应用其余部分的交互的影响。 |
| 如何使用? | 用create-react-app创建的项目配置了基本的测试工具,这些工具补充了简化组件工作过程的包。 |
| 有什么陷阱或限制吗? | 有效的单元测试可能是困难的,并且可能需要花费时间和精力来达到单元测试容易编写和运行的程度,并且您确信您已经隔离了应用的正确部分来进行测试。 |
| 还有其他选择吗? | 单元测试不是一项要求,也不是在所有项目中都采用。 |
决定是否进行单元测试
单元测试是一个有争议的话题。本章假设您确实想进行单元测试,并向您展示如何设置工具并将它们应用到 React 应用中。这不是对单元测试的介绍,我也没有努力说服持怀疑态度的读者单元测试是值得的。如果想了解单元测试,这里有一篇好文章: https://en.wikipedia.org/wiki/Unit_testing 。
我喜欢单元测试,我也在自己的项目中使用它——但并不是所有的项目,也不像你所期望的那样始终如一。我倾向于专注于为我知道很难编写的特性和功能编写单元测试,这些特性和功能很可能是部署中的错误来源。在这些情况下,单元测试有助于我思考如何最好地实现我需要的东西。我发现仅仅考虑我需要测试什么就有助于产生关于潜在问题的想法,这是在我开始处理实际的错误和缺陷之前。
也就是说,单元测试是一种工具,而不是宗教,只有你自己知道你需要多少测试。如果你不觉得单元测试有用,或者如果你有更适合你的不同的方法论,那么不要仅仅因为它是时髦的就觉得你需要单元测试。(然而,如果你没有更好的方法论,你根本没有在测试,那么你很可能是在让用户发现你的 bug,这很少是理想的。)
表 17-2 总结了本章内容。
表 17-2
章节总结
|问题
|
解决办法
|
列表
| | --- | --- | --- | | 对 React 组件执行单元测试 | 使用 Jest(或其他可用的测试框架)和 Enzyme 来创建测试 | 9–11 | | 隔离组件进行测试 | 使用浅渲染进行测试 | Twelve | | 测试组件及其后代 | 使用完全渲染进行测试 | Thirteen | | 测试组件的行为 | 测试使用 Enzyme 特性来处理属性、状态、方法和事件 | 14–17 |
为本章做准备
对于这一章,我将使用一个新的项目。打开一个新的命令提示符,导航到一个方便的位置,运行清单 17-1 中所示的命令来创建一个名为testapp的项目。
小费
你可以从 https://github.com/Apress/pro-react-16 下载本章以及本书其他章节的示例项目。
npx create-react-app testapp
Listing 17-1Creating the Example Project
运行清单 17-2 中所示的命令,导航到testapp文件夹添加引导包。
cd testapp
npm install bootstrap@4.1.2
Listing 17-2Adding the Bootstrap CSS Framework
为了在应用中包含引导 CSS 样式表,将清单 17-3 中所示的语句添加到index.js文件中,该文件可以在testapp/src文件夹中找到。
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import 'bootstrap/dist/css/bootstrap.css';
ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: http://bit.ly/CRA-PWA
serviceWorker.unregister();
Listing 17-3Including Bootstrap in the index.js File in the src Folder
create-react-app工具创建包含基本测试工具的项目,但是有一些有用的附加工具使得测试更加容易。运行testapp文件夹中清单 17-4 中所示的命令,将测试包添加到项目中。
npm install --save-dev enzyme@3.8.0
npm install --save-dev enzyme-adapter-react-16@1.7.1
Listing 17-4Adding Packages to the Example Project
表 17-3 描述了已经添加到项目中的包。
表 17-3
单元测试包
|名字
|
描述
|
| --- | --- |
| enzyme | Enzyme 是 Airbnb 创建的一个测试包,通过探索组件呈现的内容并检查其属性和状态,可以轻松测试组件。 |
| enzyme-adapter-react-16 | Enzyme 需要一个适用于所用 React 特定版本的适配器。这个包适用于本书中使用的 React 版本。 |
创建组件
我需要一些简单的组件来演示如何对 React 应用进行单元测试。我在src文件夹中添加了一个名为Result.js的文件,并用它来定义清单 17-5 中所示的组件。
import React from "react";
export const Result = (props) => {
return <div className="bg-light text-dark border border-dark p-2 ">
{ props.result || 0 }
</div>
}
Listing 17-5The Contents of the Result.js File in the src Folder
Result是一个简单的功能组件,显示通过其结果属性接收的计算结果。接下来,我在src文件夹中添加了一个名为ValueInput.js的文件,并用它来定义清单 17-6 中所示的组件。
import React, { Component } from "react";
export class ValueInput extends Component {
constructor(props) {
super(props);
this.state = {
fieldValue: 0
}
}
handleChange = (ev) => {
this.setState({ fieldValue: ev.target.value },
() => this.props.changeCallback(this.props.id, this.state.fieldValue));
}
render() {
return <div className="form-group p-2">
<label>Value #{this.props.id}</label>
<input className="form-control"
value={ this.state.fieldValue}
onChange={ this.handleChange } />
</div>
}
}
Listing 17-6The Contents of the ValueInput.js File in the src Folder
这是一个有状态的组件,它呈现输入元素,并在发生变化时调用回调函数。清单 17-7 展示了我对App组件所做的修改,删除了占位符内容,使用了新的组件。
import React, { Component } from "react";
import { ValueInput } from "./ValueInput";
import { Result } from "./Result";
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
title: this.props.title || "Simple Addition" ,
fieldValues: [],
total: 0
}
}
updateFieldValue = (id, value) => {
this.setState(state => {
state.fieldValues[id] = Number(value);
return state;
});
}
updateTotal = () => {
this.setState(state => ({
total: state.fieldValues.reduce((total, val) => total += val, 0)
}))
}
render() {
return <div className="m-2">
<h5 className="bg-primary text-white text-center p-2">
{ this.state.title }
</h5>
<Result result={ this.state.total } />
<ValueInput id="1" changeCallback={ this.updateFieldValue } />
<ValueInput id="2" changeCallback={ this.updateFieldValue } />
<ValueInput id="3" changeCallback={ this.updateFieldValue } />
<div className="text-center">
<button className="btn btn-primary" onClick={ this.updateTotal}>
Total
</button>
</div>
</div>
}
}
Listing 17-7Completing the Example Application in the App.js File in the src Folder
App 创建三个ValueInput组件,并对它们进行配置,以便用户输入的值存储在fieldValues状态数组中。一个按钮被配置为点击事件调用updateTotal方法,该方法对来自ValueInput组件的值求和,并更新由Result组件显示的状态数据值。
运行示例应用
使用命令提示符导航到testapp文件夹并运行清单 17-8 中所示的命令来启动 React 开发者工具。
npm start
Listing 17-8Starting the Development Tools
一个新的浏览器窗口将会打开,您将会看到示例应用,如图 17-1 所示。在字段中输入数值,然后单击总计按钮显示结果。
图 17-1
运行示例应用
运行占位符单元测试
用create-react-app创建的项目包含 Jest test runner,这是一个执行单元测试并报告结果的工具。作为项目设置过程的一部分,创建了一个名为App.test.js的文件,其中包含以下代码:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});
这是一个基本的单元测试,封装在it函数中。该函数的第一个参数是测试的描述。第二个参数是测试本身,它是一个执行一些工作的函数。在这种情况下,单元测试将App组件呈现为一个div元素,然后卸载它。打开一个新的命令提示符,导航到testapp文件夹,运行清单 17-9 中所示的命令来执行单元测试。(测试工具的设计使得您可以让它们与开发工具一起运行。)
npm run test
Listing 17-9Running a Unit Test
该命令定位项目中定义的所有测试并执行它们。目前只有一个测试,会产生以下结果:
...
PASS src/App.test.js
√ renders without crashing (24ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.077s
Ran all test suites related to changed files.
Watch Usage
› Press a to run all tests.
› Press f to run only failed tests.
› Press p to filter by a filename regex pattern.
› Press t to filter by a test name regex pattern.
› Press q to quit watch mode.
› Press Enter to trigger a test run.
...
测试运行后,测试工具进入观察模式。当文件改变时,测试被定位和执行,结果被再次显示。为了查看单元测试失败时会发生什么,将清单 17-10 中所示的语句添加到App组件的render方法中。
...
render() {
throw new Error("something went wrong");
return <div className="m-2">
<h5 className="bg-primary text-white text-center p-2">
{ this.state.title }
</h5>
<Result result={ this.state.total } />
<ValueInput id="1" changeCallback={ this.updateFieldValue } />
<ValueInput id="2" changeCallback={ this.updateFieldValue } />
<ValueInput id="3" changeCallback={ this.updateFieldValue } />
<div className="text-center">
<button className="btn btn-primary" onClick={ this.updateTotal}>
Total
</button>
</div>
</div>
}
...
Listing 17-10Making a Test Fail in the App.js File in the src Folder
调用 render 方法时将会抛出一个错误,这是单元测试所期待的行为。当您保存更改时,单元测试将再次执行,但这一次它将失败,并向您提供所检测到的问题的详细信息。
...
renders without crashing
something went wrong
27 |
28 | render() {
> 29 | throw new Error("something went wrong");
| ^
30 | return <div className="m-2">
31 | <h5 className="bg-primary text-white text-center p-2">
32 | Simple Addition
...
组件抛出的错误会在单元测试中上升到it函数,并被视为测试失败。要将应用恢复到其工作状态,从App组件中注释掉throw语句,如清单 17-11 所示。
...
render() {
//throw new Error("something went wrong");
return <div className="m-2">
<h5 className="bg-primary text-white text-center p-2">
{ this.state.title }
</h5>
<Result result={ this.state.total } />
<ValueInput id="1" changeCallback={ this.updateFieldValue } />
<ValueInput id="2" changeCallback={ this.updateFieldValue } />
<ValueInput id="3" changeCallback={ this.updateFieldValue } />
<div className="text-center">
<button className="btn btn-primary" onClick={ this.updateTotal}>
Total
</button>
</div>
</div>
}
...
Listing 17-11Removing the throw Statement in the App.js File in the src Folder
当您保存更改时,测试将再次运行并通过这次测试。
使用浅层渲染测试组件
浅层渲染将组件与其子组件隔离开来,允许它自己进行测试。这是一种测试组件基本功能的有效技术,而不会受到与其内容交互的影响。为了使用浅层渲染测试 App 组件,我将名为appContent.test.js的文件添加到了src文件夹中,并添加了清单 17-12 中所示的代码。
小费
Jest 将在文件名以test.js或spec.js结尾的文件中或者在名为__tests__(在tests前后有两个下划线)的文件夹中的任何文件中找到测试。
import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from "enzyme";
import App from "./App";
import { ValueInput } from "./ValueInput";
Enzyme.configure({ adapter: new Adapter() });
it("Renders three ValueInputs", () => {
const wrapper = shallow(<App />);
const valCount = wrapper.find(ValueInput).length;
expect(valCount).toBe(3)
});
Listing 17-12The Contents of the appContent.test.js File in the src Folder
这是本章中第一个真正的单元测试,所以我将解释每个部分,并向您展示它们是如何组合在一起的。
第一条语句配置酶包并应用适配器,该适配器允许酶与 React 的正确版本一起工作。
...
Enzyme.configure({ adapter: new Adapter() });
...
向Enzyme.configure方法传递一个配置对象,该对象的adapter属性被赋予适配器包的导入内容。如果你需要测试 React 的不同版本,你可以在 https://airbnb.io/enzyme 看到可用的适配器列表。
下一步是单元测试的定义。不需要导入it方法,因为它是由 Jest 测试包全局定义的。
...
it("Renders three ValueInputs", () => {
...
第一个论点应该是对测试目的的有意义的描述。在这种情况下,测试检查App是否呈现了三个ValueInput组件。
下一条语句设置组件,这是使用从enzyme包导入的浅层函数完成的。
...
const wrapper = shallow(<App />);
...
shallow函数接受组件元素。一个组件被实例化,并经历第十三章中描述的生命周期,其内容被呈现。但是,由于这是浅层呈现,子组件不用于呈现,将它们的元素留在来自App组件的输出中。这意味着在呈现内容时使用了App组件的属性和状态数据,但是没有处理子组件,结果如下:
...
<div className="m-2">
<h5 className="bg-primary text-white text-center p-2">
Simple Addition
</h5>
<Result result={0} />
<ValueInput id="1" changeCallback={[Function]} />
<ValueInput id="2" changeCallback={[Function]} />
<ValueInput id="3" changeCallback={[Function]} />
<div className="text-center">
<button className="btn btn-primary" onClick={[Function]}>
Total
</button>
</div>
</div>
...
输出显示在一个包装器对象中,可以对其进行检查以进行测试。Enzyme 包提供了一组方法,可以用来检查从 DOM 呈现的内容,这些方法以流行的 jQuery DOM 操纵包提供的 API 为模型。最有用的方法在表 17-4 中描述,全套特征在 https://airbnb.io/enzyme 中描述。
表 17-4
检测成分含量的有用的酶方法
|名字
|
描述
|
| --- | --- |
| find(selector) | 该方法查找 CSS 选择器匹配的所有元素,它将匹配元素类型、属性和类。 |
| findWhere(predicate) | 该方法查找与指定谓词匹配的所有元素。 |
| first(selector) | 返回选择器匹配的第一个元素。如果省略选择器,那么将返回任何类型的第一个元素。 |
| children() | 创建包含当前元素的子元素的新选择。 |
| hasClass(class) | 如果元素是指定类的成员,此方法返回 true。 |
| text() | 此方法从元素中返回文本内容。 |
| html() | 该方法从组件返回深度呈现的内容,以便处理所有的后代组件。 |
| debug() | 此方法从组件返回浅层呈现的内容。 |
这些方法可用于浏览组件呈现的内容并检查内容。清单 17-12 中的测试使用find选择器来选择由App组件呈现的所有ValueInput元素,并使用结果的length属性来确定找到了多少元素。
...
const valCount = wrapper.find(ValueInput).length;
...
测试的最后一步是将结果与预期结果进行比较,这是使用 Jest 提供的全局expect函数来完成的。
...
expect(valCount).toBe(3)
...
测试的结果被传递给expect函数,然后对结果调用一个匹配器方法。Jest 支持大量的匹配,在 https://jestjs.io/docs/en/expect 描述,最有用的在表 17-5 中显示。
表 17-5
有用的期望匹配器
|名字
|
描述
|
| --- | --- |
| toBe(value) | 此方法断言结果与指定的值相同(但不必是同一个对象)。 |
| toEqual(object) | 此方法断言结果是与指定值相同的对象。 |
| toMatch(regexp) | 此方法断言结果匹配指定的正则表达式。 |
| toBeDefined() | 这个方法断言结果已经被定义。 |
| toBeUndefined() | 此方法断言结果尚未定义。 |
| toBeNull() | 该方法断言结果为空。 |
| toBeTruthy() | 这个方法断言结果是真实的。 |
| toBeFalsy() | 这个方法断言结果是假的。 |
| toContain(substring) | 此方法断言结果包含指定的子字符串。 |
| toBeLessThan(value) | 此方法断言结果小于指定值。 |
| toBeGreaterThan(value) | 此方法断言结果大于指定值。 |
Jest 跟踪哪些匹配失败,并在项目中的所有测试都运行后报告结果。清单 17-12 中的匹配器检查由App呈现的内容中有三个ValueInput组件。
文件一保存,Jest 就运行清单 17-12 中的测试,产生以下结果:
...
PASS src/App.test.js
PASS src/App.shallow.test.js
Test Suites: 2 passed, 2 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 2.672s
Ran all test suites.
Watch Usage: Press w to show more.
...
项目中现在有两个测试,并且都在运行。您可以让测试自动运行,也可以使用按下 W 键时显示的选项按需运行一个或多个测试。
使用完全渲染测试组件
完全渲染处理所有派生组件。派生组件元素保留在呈现的内容中,这意味着App组件在完全呈现后将产生以下内容:
...
<App>
<div className="m-2">
<h5 className="bg-primary text-white text-center p-2">
Simple Addition
</h5>
<Result result={0}>
<div className="bg-light text-dark border border-dark p-2 ">0</div>
</Result>
<ValueInput id="1" changeCallback={[Function]}>
<div className="form-group p-2">
<label>Value #1</label>
<input className="form-control" value={0} onChange={[Function]} />
</div>
</ValueInput>
<ValueInput id="2" changeCallback={[Function]}>
<div className="form-group p-2">
<label>Value #2</label>
<input className="form-control" value={0} onChange={[Function]} />
</div>
</ValueInput>
<ValueInput id="3" changeCallback={[Function]}>
<div className="form-group p-2">
<label>Value #3</label>
<input className="form-control" value={0} onChange={[Function]} />
</div>
</ValueInput>
<div className="text-center">
<button className="btn btn-primary" onClick={[Function]}>Total</button>
</div>
</div>
</App>
...
使用mount方法进行完全渲染,如清单 17-13 所示。
import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow, mount } from "enzyme";
import App from "./App";
import { ValueInput } from "./ValueInput";
Enzyme.configure({ adapter: new Adapter() });
it("Renders three ValueInputs", () => {
const wrapper = shallow(<App />);
const valCount = wrapper.find(ValueInput).length;
expect(valCount).toBe(3)
});
it("Fully renders three inputs", () => {
const wrapper = mount(<App title="tester" />);
const count = wrapper.find("input.form-control").length
expect(count).toBe(3);
});
it("Shallow renders zero inputs", () => {
const wrapper = shallow(<App />);
const count = wrapper.find("input.form-control").length
expect(count).toBe(0);
})
Listing 17-13Fully Rendering a Component in the appContent.test.js File in the src Folder
第一个新测试使用酶mount函数来完全渲染App及其后代。mount返回的包装器支持表 17-5 中描述的方法,全套特性在 https://airbnb.io/enzyme/docs/api/mount.html 中描述。我使用 find 方法来定位已经分配给form-control类的input元素,并使用expect来确保有三个这样的元素。第二个新测试定位相同的元素,但是使用浅层呈现,并检查内容中是否没有input元素。
保存对文件的更改后,将运行测试并产生以下结果:
...
PASS src/App.test.js
PASS src/appContent.test.js
Test Suites: 2 passed, 2 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 3.109s
Ran all test suites.
Watch Usage: Press w to show more.
...
用属性、状态、方法和事件进行测试
组件呈现的内容可以根据用户输入或应用状态的更新而改变。为了帮助测试组件的行为,Enzyme 提供了表 17-6 中描述的方法。
表 17-6
测试行为的酶方法
|名字
|
描述
|
| --- | --- |
| instance() | 该方法返回组件对象,以便可以调用其方法。 |
| prop(key) | 此方法返回指定属性的值。 |
| props() | 这个方法返回组件的所有属性。 |
| setProps(props) | 此方法用于指定新的属性,这些属性在组件更新之前与组件的现有属性合并。 |
| state(key) | 此方法用于获取指定的状态值。如果没有指定值,则返回组件的所有状态数据。 |
| setState(state) | 此方法更改组件的状态数据,然后重新呈现组件。 |
| simulate(event, args) | 此方法将事件调度到组件。 |
| update() | 此方法强制组件重新呈现其内容。 |
最简单的行为测试是确保组件反映了它的属性。我在src文件夹中创建了一个名为appBehavior.test.js的文件,并用它来定义清单 17-14 中所示的测试。
import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from "enzyme";
import App from "./App";
Enzyme.configure({ adapter: new Adapter() });
it("uses title prop", () => {
const titleVal = "test title"
const wrapper = shallow(<App title={ titleVal } />);
const firstTitle = wrapper.find("h5").text();
const stateValue = wrapper.state("title");
expect(firstTitle).toBe(titleVal);
expect(stateValue).toBe(titleVal);
});
Listing 17-14Testing a Prop in the appBehavior.test.js File in the src Folder
当App组件被传递给shallow方法时,它被配置了一个title属性。该测试通过定位h5元素并获取其文本内容,以及读取title状态属性的值,来检查 prop 是否用于覆盖默认值。只有当h5元素和state属性的内容与title属性的值相同时,测试才通过。
测试方法的效果
instance方法用于获取组件对象,然后组件对象可用于调用其方法。在清单 17-15 中,我定义了一个调用updateField和updateTotal方法的测试,并检查对组件状态数据的影响。
import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from "enzyme";
import App from "./App";
Enzyme.configure({ adapter: new Adapter() });
it("uses title prop", () => {
const titleVal = "test title"
const wrapper = shallow(<App title={ titleVal } />);
const firstTitle = wrapper.find("h5").text();
const stateValue = wrapper.state("title");
expect(firstTitle).toBe(titleVal);
expect(stateValue).toBe(titleVal);
});
it("updates state data", () => {
const wrapper = shallow(<App />);
const values = [10, 20, 30];
values.forEach((val, index) =>
wrapper.instance().updateFieldValue(index + 1, val));
wrapper.instance().updateTotal();
expect(wrapper.state("total"))
.toBe(values.reduce((total, val) => total + val), 0);
});
Listing 17-15Invoking Methods in the appBehavior.test.js File in the src Folder
新的测试 shallow 呈现了一个App组件,然后在调用updateTotal方法之前用一个值数组调用updateFieldValue方法。state方法用于获取总state属性的值,该值与传递给updateFieldValue方法的值的总和进行比较。
测试事件的影响
simulate方法用于向组件的事件处理程序发送事件。这种类型的测试必须小心,因为很容易测试 React 调度事件的能力,而不是组件处理事件的能力。在大多数情况下,调用将在响应事件时执行的方法更有用。清单 17-16 定位由App组件呈现的button元素,并触发一个click事件,以确保它导致总数被计算。
import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow } from "enzyme";
import App from "./App";
Enzyme.configure({ adapter: new Adapter() });
it("uses title prop", () => {
const titleVal = "test title"
const wrapper = shallow(<App title={ titleVal } />);
const firstTitle = wrapper.find("h5").text();
const stateValue = wrapper.state("title");
expect(firstTitle).toBe(titleVal);
expect(stateValue).toBe(titleVal);
});
it("updates state data", () => {
const wrapper = shallow(<App />);
const values = [10, 20, 30];
values.forEach((val, index) =>
wrapper.instance().updateFieldValue(index + 1, val));
wrapper.instance().updateTotal();
expect(wrapper.state("total"))
.toBe(values.reduce((total, val) => total + val), 0);
})
it("updates total when button is clicked", () => {
const wrapper = shallow(<App />);
const button = wrapper.find("button").first();
const values = [10, 20, 30];
values.forEach((val, index) =>
wrapper.instance().updateFieldValue(index + 1, val));
button.simulate("click")
expect(wrapper.state("total"))
.toBe(values.reduce((total, val) => total + val), 0);
})
Listing 17-16Simulating an Event in the appBehavior.test.js File in the src Folder
新的测试模拟了click事件,该事件的处理程序调用组件的updateTotal方法。为了确保事件已经被处理,读取了total状态数据属性的值。
测试组件之间的交互
导航由组件呈现的内容的能力可以与表 17-6 中描述的方法相结合,以测试组件之间的交互,如清单 17-17 所示。
import React from "react";
import Adapter from 'enzyme-adapter-react-16';
import Enzyme, { shallow, mount } from "enzyme";
import App from "./App";
import { ValueInput } from "./ValueInput";
Enzyme.configure({ adapter: new Adapter() });
it("uses title prop", () => {
const titleVal = "test title"
const wrapper = shallow(<App title={ titleVal } />);
const firstTitle = wrapper.find("h5").text();
const stateValue = wrapper.state("title");
expect(firstTitle).toBe(titleVal);
expect(stateValue).toBe(titleVal);
});
it("updates state data", () => {
const wrapper = shallow(<App />);
const values = [10, 20, 30];
values.forEach((val, index) =>
wrapper.instance().updateFieldValue(index + 1, val));
wrapper.instance().updateTotal();
expect(wrapper.state("total"))
.toBe(values.reduce((total, val) => total + val), 0);
})
it("updates total when button is clicked", () => {
const wrapper = shallow(<App />);
const button = wrapper.find("button").first();
const values = [10, 20, 30];
values.forEach((val, index) =>
wrapper.instance().updateFieldValue(index + 1, val));
button.simulate("click")
expect(wrapper.state("total"))
.toBe(values.reduce((total, val) => total + val), 0);
})
it("child function prop updates state", () => {
const wrapper = mount(<App />);
const valInput = wrapper.find(ValueInput).first();
const inputElem = valInput.find("input").first();
inputElem.simulate("change", { target: { value: "100"}});
wrapper.instance().updateTotal();
expect(valInput.state("fieldValue")).toBe("100");
expect(wrapper.state("total")).toBe(100);
})
Listing 17-17Testing Component Interaction in the appBehavior.test.js File in the src Folder
新的测试定位由第一个ValueInput呈现的输入元素,并触发它的 change 事件,提供一个参数,该参数将为组件的处理程序提供它需要的值。instance方法用于调用App组件的updateTotal方法,state方法用于检查App和ValueInput组件的状态数据是否已被正确更新。
摘要
在本章中,我向您展示了如何在 React 组件上执行单元测试。我向您展示了如何使用 Jest 运行测试,以及如何使用 Enzyme 包提供的浅渲染和完全渲染来执行这些测试。我解释了如何检查组件呈现的内容,如何调用其方法,如何探索其状态,以及如何管理其属性。总的来说,这些特性允许单独测试一个组件,也允许结合其子组件进行测试。在本书的下一部分,我将描述如何补充 React 的核心特性来创建完整的 web 应用。