初识React

React简介

前端UI的本质问题是如何将来源于服务器端的动态数据和用户的交互行为高效地反映到复杂的用户界面上。React通过引入虚拟DOM、状态、单向数据流等设计理念,形成以组件为核心,用组件搭建UI的开发模式,理顺了UI的开发过程,完美地将数据、组件状态和UI映射到一起,极大地提高了开发大型Web应用的效率。

主要特点:

(1)声明式视图层

用JavaScript(JSX)语法来声明视图层,可以在视图层中随意使用各种状态数据。

(2)简单的更新流程

只需要定义UI状态,React会负责把它渲染成最终的UI。

(3)灵活的渲染实现

React先把视图渲染成虚拟DOM。虚拟DOM只是普通的JavaScript对象,结合其他依赖库可把这个对象渲染成不同终端上的UI。使用Node在服务器上渲染,使用React Native在手机上渲染。

(4)高效的DOM操作

基于React优异的差异比较算法,React可以尽量减少虚拟DOM到真实DOM的渲染次数,以及每次渲染需要改变的真实DOM节点数。

React基础

JSX

JSX简介

JSX是一种用于描述UI的JavaScript扩展语法,React使用这种语法描述组件的UI。

JSX语法

  1. 基本语法

JSX的基本语法和XML语法相同,都是使用成对的标签构成一个树状结构的数据,例如:

1
2
3
4
5
const element = (
<div>
<h1>Hello, world!</h1>
</div>
)
  1. 标签类型

在JSX语法中,使用的标签类型有两种:DOM类型的标签(div、span等)和React组件类型的标签。在使用DOM类型的标签时,标签的首字母必须小写;当使用React组件类型的标签时,组件名称的首字母必须大写。React通过首字母的大小写判断渲染的是一个DOM标签还是一个React组件类型的标签。

1
2
3
4
5
6
7
8
9
10
11
12
// DOM类型标签
const element = <h1>Hello, world!</h1>

// React组件类型标签
const element = <HelloWorld/>

// 两者可以相互嵌套
cosnt element = (
<div>
<HelloWorld/>
</div>
)
  1. Javascript表达式

在JSX中使用Javascript表达式需要将表达式”{}”包起来。表达式在JSX中的使用场景主要有两个:通过表达式给标签属性赋值和通过表达式定义子组件。

1
2
3
4
5
6
7
8
9
10
// 通过表达式给标签属性赋值
const element = <MyComponent foo={1+2}/>

// 通过表达式定义子组件{map虽然是函数,但是它的返回值是JavaScript表达式}
const todos = ['item1', 'item2', 'item3'];
const element = (
<ul>
{todos.map(message => <Item key={message} message={message}/>)}
</ul>
);

JSX中只能用Javascript表达式,不能使用多行Javascript语句。不过,JSX中可以使用三目运算符或逻辑与(&&)运算符代替if语句的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 正确
let complete;
const element = (
<div>
{
complete ? <CompletedList/> : null
}
</div>
)

// 正确
let complete;
const element = (
<div>
{
complete && <CompletedList/>
}
</div>
)
  1. 标签属性

当JSX标签是DOM类型的标签时,对应DOM标签支持的属性JSX也支持,例如id、class、style、onclick等。但是,部分属性的名称会有所改变,主要的变化有:class要写成className,事件属性名采用驼峰格式,例如onclick要写成onClick。原因是,class是JavaScript的关键字,所以改成className;React对DOM标签支持的事件重新做了封装,封装时采用了更常用的驼峰命名法命名事件。

  1. 注释

JSX中的注释需要用大括号“{}”将/**/包裹起来。

1
2
3
4
5
const element = (
<div>
{/* 这是一个注释 */}
</div>
)

JSX不是必需的

JSX语法对使用React来说并不是必需的,实际上,JSX语法只是React.createElement(component, props, ...children)的语法糖,所有的JSX语法最终都会被转换成对这个方法的调用。

1
2
3
4
5
// JSX语法
const element = (<div className='foo'>Hello, React</div>)

// 转换后
const element = React.createElement('div', {className:'foo'}, 'Hello, React')

组件

组件定义

类组件

使用class定义组件需要满足两个条件:

(1)class继承自React.Component。

(2)class内部必须定义render方法,render方法返回代表该组件UI的React元素。

使用create-react-app bbs新建一个简易BBS项目,在这个项目定义一个组件PostList,用于展示BBS的帖子列表。

image-20230208003530072

PostList定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// PostList.js
import React, {Component} from "react";

class PostList extends Component{
render(){
return(
<div>
帖子列表:
<li>大家一起来使用react</li>
<li>读孙子兵法</li>
<li>看等离子电视</li>
</div>
);
}
}

export default PostList;

定义组件后,将PostList作为默认模块导出,从而可以在其他JS文件中导入PostList使用。使用ReactDOM.render()将PostList挂载到页面的DOM节点上。

1
2
3
4
5
6
// index.js
import React from "react";
import ReactDOM from "react-dom";
import PostList from "./PostList";

ReactDOM.render(<PostList/>, document.getElementById("root"))

进入bbs目录,执行npm start,浏览器显示效果:

image-20230208004144550

使用ReactDOM.render()需要先导入react-dom库,这个库会完成组件所代表的虚拟DOM节点到浏览器的DOM节点的转换。

组件的props

组件的props用于把父组件中的数据或方法传递给子组件,供子组件使用。props是一个简单结构的对象,它包含的属性正是由组件作为JSX标签使用时的属性组成。例如下面是一个使用User组件作为JSX标签的声明:

1
<User name='react' age='4' address='America'>

此时User组件的props结构如下:

1
2
3
4
5
props = {
name: 'React',
age: '4',
address: 'America'
}

现在使用props定义PostItem组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// PostItem.js
import React, {Component} from "react";

class PostItem extends Component{
render() {
const {title, author, data} = this.props
return(
<li>
<div>{title}</div>
<div>创建人:{author}</div>
<div>创建时间:<span>{data}</span></div>
</li>
)
}
}

然后在PostList中使用PostItem组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// PostList.js
import React, {Component} from "react";
import PostItem from "./PostItem";

// 真实项目中,帖子数据一般从服务器端获取
// 这里定义常量data存储列表数据
const data = [
{title:'这是你要的嘛?', author:"南陵笑笑生", date:'2023-02-09 1:24'},
{title:'我欲乘风归', author:"南陵笑笑生", date:'2023-02-09 1:24'},
{title:'月下共舞?', author:"南陵笑笑生", date:'2023-02-09 1:24'},
];
class PostList extends Component{
render(){
return(
<div>
{data.map(item=>
<PostItem
title={item.title}
author={item.author}
data={item.date}
/>
)}
</div>
);
}
}

export default PostList;

npm start 运行:

image-20230209012759949

组件的state

组件的state是组件内部的状态,state的变化最终将反映到组件UI的变化上。在组件的构造方法constructor中通过this.state定义组件的初始状态,并通过调用this.setState方法改变组件状态(也是改变组件状态的唯一方式),进而组件UI也会随之重新渲染。

下面来改造一下BBS项目。为每一个帖子增加一个“点赞”按钮,每点击一次,该帖子的点赞数增加1。点赞数是会发生变化的,它的变化也会影响到组件UI,因此我们将点赞数vote作为PostItem的一个状态定义到它的state内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// PostItem.js
import React, {Component} from "react";

class PostItem extends Component {
constructor(props) {
super(props);
this.state = {
vote: 0
};
}
// 处理点赞逻辑
handleClick() {
let vote = this.state.vote;
vote ++;
this.setState({
vote: vote
});
}

render() {
const {title, author, data} = this.props
return (
<li>
<div>{title}</div>
<div>创建人:{author}</div>
<div>创建时间:<span>{data}</span></div>
<div>
<button onClick={() => {
this.handleClick();
}}>点赞
</button>
&nbsp;
<span>
{this.state.vote}
</span>

</div>
</li>
)
}
}

export default PostItem;

这里有三个需要注意的地方:

(1)在组件的构造方法constructor内,首先要调用super(props),这一步实际上是调用了React.Component这个class的constructor方法,用来完成React组件的初始化工作。

(2)在constructor中,通过this.state定义了组件的状态。

(3)在render方法中,我们为标签定义了处理点击事件的响应函数,在响应函数内部会调用this.setState更新组件的点赞数。

实现效果:

image-20230209013643019

React组件可以看作一个函数,函数的输入是props和state,函数的输出是组件的UI。

1
UI = Component(props, state)

React组件正是由props和state两种类型的数据驱动渲染出组件UI。props是组件对外的接口,组件通过props接收外部传入的数据(包括方法)state是组件对内的接口,组件内部状态的变化通过state来反映。另外,props是只读的,你不能在组件内部修改props;state是可变的,组件状态的变化通过修改state来实现。

有状态和无状态组件

state用来反映组件内部状态的变化,如果一个组件的内部状态是不变的,当然就用不到state,这样的组件称之为无状态组件,例如PostList。反之,一个组件的内部状态会发生变化,就需要使用state来保存变化,这样的组件称之为有状态组件,例如PostItem。

定义无状态组件除了使用ES 6 class的方式外,还可以使用函数定义,也就是我们在本节开始时所说的函数组件。一个函数组件接收props作为参数,返回代表这个组件UI的React元素结构。例如,下面是一个简单的函数组件:

1
2
3
function Welcome(props){
return <h1>hello, {props.name}</h1>
}

在开发React应用时,一定要先认真思考哪些组件应该设计成有状态组件,哪些组件应该设计成无状态组件。并且,应该尽可能多地使用无状态组件,无状态组件不用关心状态的变化,只聚焦于UI的展示,因而更容易被复用。

有状态组件主要关注处理状态变化的业务逻辑,无状态组件主要关注组件UI的渲染。

下面让我们回过头来看一下BBS项目的组件设计。当前的组件设计并不合适,主要体现在:(1)帖子列表通过一个常量data保存在组件之外,但帖子列表的数据是会改变的,新帖子的增加或原有帖子的删除都会导致帖子列表数据的变化。(2)每一个PostItem都维持一个vote状态,但除了vote以外,帖子其他的信息(如标题、创建人等)都保存在PostList中,这显然也是不合理的。我们对这两个组件进行重新设计,将PostList设计为有状态组件,负责帖子列表数据的获取以及点赞行为的处理,将PostItem设计为无状态组件,只负责每一个帖子的展示。此时,PostList和PostItem重构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// PostItem.js
import React from "react";

function PostItem(props){
const handleClick = ()=>{
props.onVote(props.post.id)
}
const {post} = props;
console.log(props);
return(
<li>
<div>{post.title}</div>
<div>创建人:<span>{post.author}</span></div>
<div>创建时间:<span>{post.date}</span></div>
<div>
<button onClick={handleClick}>点赞</button>
&nbsp;
<span>{post.vote}</span>
</div>
</li>
)

}

export default PostItem;

// PostList.js
import React, {Component} from "react";
import PostItem from "./PostItem";

class PostList extends Component {
constructor(props) {
super(props);
this.state = {
posts: []
}
this.timer = null; // 定时器
this.handleVote = this.handleVote.bind(this); //ES6 class中手动绑定方法的this指向
}

componentDidMount() {
// 用setTimeout模拟异步从服务端获取数据
this.timer = setTimeout(() => {
this.setState({
posts: [
{id: 1, title: '这是你要的嘛?', author: "南陵笑笑生", date: '2023-02-09 1:24', vote: 0},
{id: 2, title: '我欲乘风归', author: "南陵笑笑生", date: '2023-02-09 1:24', vote: 0},
{id: 3, title: '月下共舞?', author: "南陵笑笑生", date: '2023-02-09 1:24', vote: 0},
]
});
}, 1000);
}

componentWillUnmount() {
if (this.timer) {
clearTimeout(this.timer); //清除定时器
}
}

handleVote(id) {
// 根据帖子id进行过滤,找到待修改vote属性的帖子,返回新的posts对象
const posts = this.state.posts.map(item => {
const newItem = item.id === id ? {...item, vote: ++item.vote} : item;
return newItem
});
// 使用新的posts对象设置state
this.setState({
posts
});
}

render() {
return (
<div>
帖子列表:
<ul>
{this.state.posts.map(item =>
<PostItem post={item} onVote={this.handleVote}/>
)};
</ul>
</div>
);
}
}

export default PostList;

这里主要的修改有:

(1)帖子列表数据定义为PostList组件的一个状态。

(2)在componentDidMount生命周期方法中(关于组件的生命周期将在2.3节详细介绍)通过setTimeout设置一个延时,模拟从服务器端获取数据,然后调用setState更新组件状态。

(3)将帖子的多个属性(ID、标题、创建人、创建时间、点赞数)合并成一个post对象,通过props传递给PostItem。

(4)在PostList内定义handleVote方法,处理点赞逻辑,并将该方法通过props传递给PostItem。

(5)PostItem定义为一个函数组件,根据PostList传递的post属性渲染UI。当发生点赞行为时,调用props.onVote方法将点赞逻辑交给PostList中的handleVote方法处理。

这样修改后,PostItem只关注如何展示帖子,至于帖子的数据从何而来以及点赞逻辑如何处理,统统交给有状态组件PostList处理。组件之间解耦更加彻底,PostItem组件更容易被复用。

属性校验和默认属性

React提供了PropTypes这个对象,用于校验组件属性的类型。PropTypes包含组件属性所有可能的类型,我们通过定义一个对象(对象的key是组件的属性名,value是对应属性的类型)实现组件属性类型的校验。例如:

1
2
3
4
5
6
import PropTypes from "prop-types";

PostItem.propTypes = {
post: PropTypes.object,
onVote: PropTypes.func
}

PropTypes可以校验的组件属性类型:

image-20230210230057226

当使用PropTypes.object或PropTypes.array校验属性类型时,我们只知道这个属性是一个对象或一个数组,至于对象的结构或数组元素的类型是什么样的,依然无从得知。这种情况下,更好的做法是使用PropTypes.shape或PropTypes.arrayOf。例如:

1
2
3
4
5
style:PropTypes.shape({
color:PropTypes.string,
fontSize: PropTypes.number
}),
sequence:PropTypes.arrayOf(PropTypes.number)

表示style是一个对象,对象有color和fontSize两个属性,color是字符串类型,fontSize是数字类型;sequence是一个数组,数组的元素是数字。

如果属性是组件的必需属性,也就是当使用某个组件时,必须传入的属性,就需要在PropTypes的类型属性上调用isRequired。

在BBS项目中,对于PostItem组件,post和onVote都是必需属性,PostItem的propTypes定义如下:

1
2
3
4
5
6
7
8
9
10
PostItem.propTypes = {
post: PropTypes.shape({
id: PropTypes.number,
title: PropTypes.string,
author: PropTypes.string,
date: PropTypes.string,
vote: PropTypes.number
}).isRequired,
onVote: PropTypes.func.isRequired
}

React还提供了为组件属性指定默认值的特性,这个特性通过组件的defaultProps实现。当组件属性未被赋值时,组件会使用defaultProps定义的默认属性。例如:

1
2
3
4
5
6
7
function Welcome(props){
return <h1 className='foo'>Hello, {props.name}</h1>;
}

Welcome.defaultProps = {
name: 'Stranger'
};

组件样式

为组件添加样式的方法主要有两种:外部CSS样式表和内联样式。

  1. 外部CSS样式表

这种方式和我们平时开发Web应用时使用外部CSS文件相同,CSS样式表中根据HTML标签类型、ID、class等选择器定义元素的样式。唯一的区别是,React元素要使用className 来代替class作为选择器。例如,为Welcome组件的根节点设置一个className=’foo’的属性:

1
2
3
function Welcome(props){
return <h1 className='foo'>Hello, {props.name}</h1>
}

然后在CSS样式表中通过class选择器定义Welcome组件的样式:

1
2
3
4
5
6
7
8
/*style.css*/

.foo {
width: 100%;
height: 50px;
background-color: blue;
font-size: 20px;
}

样式表的引入方式有两种,一种是在使用组件的HTML页面中通过标签引入:

1
<link rel="stylesheet" type="text/css" href="style.css">

另一种是把样式表文件当作一个模块,在使用该样式表的组件中,像导入其他组件一样导入样式表文件:

1
2
3
4
import './css/style.css'; // 要保证相对路径设置正确
function Welcome(props){
return <h1 className='foo'>Hello, {props.name}</h1>;
}

第一种引入样式表的方式常用于该样式表文件作用于整个应用的所有组件(一般是基础样式表);第二种引入样式表的方式常用于该样式表作用于某个组件(相当于组件的私有样式),全局的基础样式表也可以使用第二种方式引入,一般在应用的入口JS文件中引入。

image-20230210233246812

  1. 内联样式

内联样式实际上是一种CSS in JS的写法:将CSS样式写到JS文件中,用JS对象表示CSS样式,然后通过DOM类型节点的style属性引用相应样式对象。依然使用Welcome组件举例:

1
2
3
4
5
6
7
8
9
function Welcome(props) {
return <h1
style={{
width: "100%",
height: "50px",
backgroundColor:"blue",
fontSize:"20px"
}}>Hello, {props.name}</h1>;
}

style使用了两个大括号,这可能会让你感到迷惑。其实,第一个大括号表示style的值是一个JavaScript表达式,第二个大括号表示这个JavaScript表达式是一个对象。换一种写法就容易理解了:

1
2
3
4
5
6
7
8
9
function Welcome(props) {
const style = {
width: "100%",
height: "50px",
backgroundColor:"blue",
fontSize:"20px"
}
return <h1 style={style}>Hello, {props.name}</h1>;
}

当使用内联样式时,还有一点需要格外注意:样式的属性名必须使用驼峰格式 的命名。所以,在Welcome组件中,background-color写成backgroundColorfont-size写成fontSize

下面为BBS项目增加一些样式。创建style.css、PostList.css和PostItem.css三个样式文件,三个样式表分别在index.html、PostList.js、PostItem.js中引入。样式文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/*style.css*/
body{
margin: 0;
padding: 0;
font-family: sans-serif;
}

ul {
list-style: none;
}

h2 {
text-align: center;
}

/*PostList.css*/
.container {
width: 900px;
margin: 20px auto;
}

/*PostItem.css*/

.item {
border-top: 1px solid darkolivegreen;
padding: 1px;
}

.title {
font-size: 16px;
font-weight: bold;
line-height: 24px;
color: #5f723a;
}

.like {
width: 100%;
height: 20px;
}

.like img{
width: 20px;
height: 20px;
}

.like span{
width: 20px;
height: 20px;
vertical-align: middle;
display: table-cell;
}


style.css放置在public文件夹下,PostList.css和PostItem.css放置在src文件夹下。create-react-app将public下的文件配置成可以在HTML页面中直接引用,因此我们将style.css放置在public文件夹下。而PostList.css和PostItem.css是以模块的方式在JS文件中被导入的,因此放置在src文件夹下。

这里将PostItem中的点赞按钮换成了图标,图标也可以作为一个模块被JS文件导入,如PostItem.js所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// PostItem.js
import React from "react";
import PropTypes from "prop-types";
import "./PostItem.css";
import like from "./image/like-react.png"; // 图标作为模块被导入

PostItem.propTypes = {
post: PropTypes.shape({
id: PropTypes.number,
title: PropTypes.string,
author: PropTypes.string,
date: PropTypes.string,
vote: PropTypes.number
}).isRequired,
onVote: PropTypes.func.isRequired
}

function PostItem(props) {
const handleClick = () => {
props.onVote(props.post.id)
}
const {post} = props;
console.log(props);
return (
<li className='item'>
<div className='title'>{post.title}</div>
<div>创建人:<span>{post.author}</span></div>
<div>创建时间:<span>{post.date}</span></div>
<div className='like'>
<span><img src={like} onClick={handleClick}/></span>
<span>{post.vote}</span>
</div>
</li>
)
}

export default PostItem;

image-20230211000652402

组件和元素

React组件和元素这两个概念非常容易混淆。React元素是一个普通的JavaScript对象,这个对象通过DOM节点或React组件描述界面是什么样子的。JSX语法就是用来创建React元素的(不要忘了,JSX语法实际上是调用了React.createElement方法)。例如:

1
2
3
4
5
6
// Button是一个自定义的React组件
<div className='foo'>
<Button color='blue'>
ok
</Button>
</div>

上面的JSX代码会创建下面的React元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
type: 'div'
props: {
className: 'foo'
children: {
type: 'Button',
props: {
color: 'blue',
children: 'ok'
}
}
}
}

React组件是一个class或函数,它接收一些属性作为输入,返回一个React元素。React组件是由若干React元素组建而成的。

组件的生命周期

组件从被创建到被销毁的过程称为组件的生命周期。React组件的生命周期可以被分为三个阶段:挂载阶段、更新阶段、卸载阶段。

挂载阶段

这个阶段组件被创建,执行初始化,并被挂载到DOM中,完成组件的第一次渲染。依次调用的生命周期方法有:

  • constructor
  • componentWillMount
  • render
  • conponentDidMount

constructor

这是ES 6 class的构造方法,组件被创建时,会首先调用组件的构造方法。这个构造方法接收一个props参数,props是从父组件中传入的属性对象,如果父组件中没有传入属性而组件自身定义了默认属性,那么这个props指向的就是组件的默认属性。你必须在这个方法中首先调用super(props)才能保证props被传入组件中。constructor通常用于初始化组件的state以及绑定事件处理方法等工作。

componentWillMount

在组件被挂载到DOM前调用,且只会被调用一次。在实际项目中很少会用到,因为可以在该方法中执行的工作都可以提前到constructor中。这个方法中调用this.setState不会引起组件的重新渲染。

render

这是定义组件时唯一必要的方法(组件的其他生命周期方法都可以省略)。在这个方法中,根据组件的props和state返回一个React元素,用于描述组件的UI,通常React元素使用JSX语法定义。需要注意的是,render并不负责组件的实际渲染工作,它只是返回一个UI的描述,真正的渲染出页面DOM的工作由React自身负责。render是一个纯函数,在这个方法中不能执行任何有副作用的操作,所以不能在render中调用this.setState,这会改变组件的状态。

componentDidMount

在组件被挂载到DOM后调用,且只会被调用一次。这时候已经可以获取到DOM结构,因此依赖DOM节点的操作可以放到这个方法中。这个方法通常还会用于向服务器端请求数据。在这个方法中调用this.setState会引起组件的重新渲染。

更新阶段

组件被挂载到DOM后,组件的props或state可以引起组件更新。props引起的组件更新,本质上是由渲染该组件的父组件引起的,也就是当父组件的render方法被调用时,组件会发生更新过程,这个时候,组件props的值可能发生改变,也可能没有改变,因为父组件可以使用相同的对象或值为组件的props赋值。但是,无论props是否改变,父组件render方法每一次调用,都会导致组件更新。State引起的组件更新,是通过调用this.setState修改组件state来触发的。组件更新阶段,依次调用的生命周期方法有:

  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate
  • render
  • componentDidUpdate

componentWillReceiveProps(nextProps)

只在props引起的组件中调用,State引起的组件更新不会触发该方法的执行。方法的参数nextProps是父组件传递给当前组件的新的props。但如上文所述,父组件render方法的调用并不能保证传递给子组件的props发生变化,也就是说nextProps的值可能和子组件当前props的值相等,往往需要比较nextProps和this.props来决定是否执行props发生变化后的逻辑 ,比如根据新的props调用this.setState触发组件的重新渲染。

shouldComponentUpdate(nextProps, nextState)

决定组件是否继续执行更新过程。当方法返回true时(true也是这个方法的默认返回值),组件会继续更新过程;当方法返回false时,组件的更新过程停止,后续的componentWillUpdate、render、componentDidUpdate也不会再被调用。一般通过比较nextProps、nextState和组件当前的props、state决定这个方法的返回结果。这个方法可以用来减少组件不必要的渲染,从而优化组件的性能。

componentWillUpdate(nextProps, nextState)

这个方法在组件render调用前执行,可以作为组件更新发生前执行某些工作的地方,一般也很少用到。

componentDidUpdate(prevProps, prevState)

组件更新后被调用,可以作为操作更新后的DOM的地方。这个方法的两个参数prevProps、prevState代表组件更新前的props和state。

卸载阶段

组件从DOM中被卸载的过程,这个过程中只有一个生命周期方法:

  • componmentWillUnmount

这个方法在组件被卸载前调用,可以在这里执行一些清理工作,比如清除组件中使用的定时器,清除componentDidMount中手动创建的DOM元素等,以避免引起内存泄漏。

只有类组件才具有生命周期方法,函数组件是没有生命周期方法的,因此永远不要在函数组件中使用生命周期方法。

列表和Keys

在组件中渲染列表数据是非常常见的场景,例如,BBS项目PostList组件就需要根据列表数据posts进行渲染,运行BBS项目,然后打开Chrome浏览器的控制台,可以看到下图的警告信息。

image-20230211003227532

警告信息提示我们,应该为列表中的每个元素添加一个名为key的属性。那么这个属性有什么作用呢?原来,React使用key属性来标记列表中的每个元素,当列表数据发生变化时,React就可以通过key知道哪些元素发生了变化,从而只重新渲染发生变化的元素,提高渲染效率。

一般使用列表数据的ID作为key值,例如可以使用帖子的ID作为每一个PostItem的key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// PostList.js
import React, {Component} from "react";
import PostItem from "./PostItem";
import './PostList.css'; // 要保证相对路径设置正确

class PostList extends Component {
...

render() {
return (
<div>
<h2>话题列表</h2>
<ul>
{this.state.posts.map(item =>
<PostItem
{/*将id赋值给key属性作为唯一标识*/}
key={item.id}
post={item}
onVote={this.handleVote}/>
)}
</ul>
</div>
);
}
}

export default PostList;

再次运行程序,你会发现之前的警告消息已经不存在了。

如果列表包含的元素没有ID,也可以使用元素在列表中的位置索引作为key值,例如:

1
2
3
4
5
6
7

{this.state.posts.map((item, index) =>
<PostItem
key={index}
post={item}
onVote={this.handleVote}/>
)}

但并不推荐使用索引作为key,因为一旦列表中的数据发生重排,数据的索引也会发生变化,不利于React的渲染优化。

事件处理

在React元素中绑定事件有两点需要注意:

  • 在React中,事件的命名采用驼峰命名方式,而不是DOM元素中的小写字母命名方式。例如,onclick要写成onClick,onchange要写成onChange等。
  • 处理事件的响应函数要以对象的形式赋值给事件属性,而不是DOM中的字符串形式。例如,在DOM中绑定一个点击事件这样写:
1
2
3
<button onclick="clickButton()">
Click
</button>

而在React元素中绑定一个点击事件变成这种形式:

1
2
3
<button onClick={clickButton}>	//clickButton是一个函数
Click
</button>

React中的事件是合成事件,并不是原生的DOM事件。React根据W3C规范定义了一套兼容各个浏览器的事件对象。在DOM事件中,可以通过处理函数返回false来阻止事件的默认行为,但在React事件中,必须显式地调用事件对象的preventDefault方法来阻止事件的默认行为。

React事件处理函数的写法主要有三种方式,不同的写法解决this指向问题的方式也不同。

  1. 使用箭头函数

直接在React元素中采用箭头函数定义事件的处理函数,例如:

1
2
3
4
5
6
7
8
9
10
11
12
class MyComponment extends React.Componment {
constructor(props){
super(props);
this.state = {number: 0};
}

render() {
return (
<button onClick={(event)=>{console.log(this.state.number);}}></button>
)
}
}

因为箭头函数中的this指向的是函数定义时的对象,所以可以保证this总是指向当前组件的实例对象。当事件处理逻辑比较复杂时,如果把所有的逻辑直接写在onClick的大括号内,就会导致render函数变得臃肿,不容易直观地看出组件的UI结构,代码可读性也不好。这时,可以把逻辑封装成组件的一个方法,然后在箭头函数中调用这个方法。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyComponment extends React.Componment {
constructor(props){
super(props);
this.state = {number: 0};
}

// 每点击一次Button,state中的number增加1
handleClick(event){
const number = ++this.state.number;
this.setState({
number: number
});
}

render() {
return (
<button onClick={(event)=>{this.handleClick(event);}}></button>
)
}
}

直接在render方法中为元素事件定义事件处理函数,最大的问题是,每次render调用时,都会重新创建一个新的事件处理函数,带来额外的性能开销,组件所处层级越低,这种开销就越大,因为任何一个上层组件的变化都可能会触发这个组件的render方法。当然,在大多数情况下,这点性能损失是可以不必在意的。

  1. 使用组件方法

直接将组件的方法赋值给元素的事件属性,同时在类的构造函数中,将这个方法的this绑定到当前对象。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MyComponment extends React.Componment {
constructor(props){
super(props);
this.state = {number: 0};
this.handleClick = this.handleClick.bind(this);
}

// 每点击一次Button,state中的number增加1
handleClick(event){
const number = ++this.state.number;
this.setState({
number: number
});
}

render() {
return (
<button onClick={this.handleClick}></button>
)
}
}

这种方式的好处是每次render不会重新创建一个回调函数,没有额外的性能损失。但在构造函数中,为事件处理函数绑定this,尤其是存在多个事件处理函数需要绑定时,这种模板式的代码还是会显得烦琐。

有些开发者还习惯在为元素的事件属性赋值时,同时为事件处理函数绑定this,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MyComponment extends React.Componment {
constructor(props){
super(props);
this.state = {number: 0};
}

// 每点击一次Button,state中的number增加1
handleClick(event){
const number = ++this.state.number;
this.setState({
number: number
});
}

render() {
return (
{/*事件属性赋值和this绑定同时进行*/}
<button onClick={this.handleClick.bind(this)}></button>
)
}
}

使用bind会创建一个新的函数,因此这种写法依然存在每次render都会创建一个新函数的问题。但在需要为处理函数传递额外参数时,这种写法就有了用武之地。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class MyComponment extends React.Componment {
constructor(props){
super(props);
this.state = {number: 0};
}

// 每点击一次Button,state中的number增加1
handleClick(event){
const number = ++this.state.number;
this.setState({
number: number
});
}

render() {
return (
<ul>
{this.state.list.map(
(item)=>{
{/*bind除了绑定this,还绑定item作为参数,供handleClick使用*/}
<li className={this.state.current===item ? 'current':''} onClick={this.handleClick.bind(this, item)}>{item}</li>
}
)}
</ul>
)
}
}
  1. 初始化语法(property initializer syntax)

使用ES 7的property initializers会自动为class中定义的方法绑定this。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyComponment extends React.Componment {
constructor(props){
super(props);
this.state = {number: 0};
}

// ES7的属性初始化语法,实际上就是使用了箭头函数
handleClick = (event) => {
const number = ++this.state.number;
this.setState({
number: number
});
}

render() {
return (
<button onClick={this.handleClick}></button>
)
}
}

这种方式既不需要在构造函数中手动绑定this,也不需要担心组件重复渲染导致的函数重复创建问题。

表单

受控组件

如果一个表单元素的值是由React来管理的,那么它就是一个受控组件。React组件渲染表单元素,并在用户和表单元素发生交互时控制表单元素的行为,从而保证组件的state成为界面上所有元素状态的唯一来源。

  1. 文本框

文本框包含类型为text的input元素和textarea元素。它们受控的主要原理是,通过表单元素的value属性设置表单元素的值,通过表单元素的onChange事件监听值的变化,并将变化同步到React组件的state中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// LoginForm.js
import React from "react";

class LoginForm extends React.Component {
constructor(props) {
super(props);
this.state = {name: '123', password: '123'}
this.handleChange = this.handleChange.bind(this)
this.handleSubmit = this.handleSubmit.bind(this)
}

// 监听两个用户和密码的两个input值的变化
handleChange(event) {
const target = event.target;
this.state(
{[target.name]: target.value}
);
}

// 表单提交的响应函数
handleSubmit(event) {
console.log('login successful');
event.preventDefault(); // 阻止默认事件
}

render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
用户名:
{/* 通过value设置input显示内容, 通过onchange监听value变化*/}
<input type="text" name="name" value={this.state.name} onChange={this.handleChange}/>
</label>
<label>
密码:
<input type="password" name="password" value={this.state.password} onChange={this.handleChange}/>
</label>
<input type="submit" value="登陆"/>
</form>
)
}
}

export default LoginForm;

用户名和密码两个表单元素的值是从组件的state中获取的,当用户更改表单元素的值时,onChange事件会被触发,对应的handleChange处理函数会把变化同步到组件的state,新的state又会触发表单元素重新渲染,从而实现对表单元素状态的控制。

这个例子还包含一个处理多个表单元素的技巧:通过为两个input元素分别指定name属性,使用同一个函数handleChange处理元素值的变化,在处理函数中根据元素的name属性区分事件的来源。这样的写法显然比为每一个input元素指定一个处理函数简洁得多。

  1. 列表

列表select元素是最复杂的表单元素,它可以用来创建一个下拉列表:

1
2
3
4
5
<select>
<option value="react">React</option>
<option value="redux">Redux</option>
<option selected value="mobx">Mobx</option>
</select>

通过指定selected属性可以定义哪一个选项(option)处于选中状态,所以上面的例子中,Mobx这一选项是列表的初始值,处于选中状态。在React中,对select的处理方式有所不同,它通过在select上定义value属性来决定哪一个option元素处于选中状态。这样,对select的控制只需要在select这一个元素上修改即可,而不需要关注option元素。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// ReactStackForm.js
import React from "react";

class ReactStackForm extends React.Component {
constructor(props) {
super(props);
this.state = {value: 'mobx'}
}
// 表单提交的响应函数
handleSubmit = (event) => {
this.setState({value: event.target.value})
}
// 监听下拉列表的变化
handleChanged = (event) =>{
this.setState({value: event.target.value})
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
选择元素:
{/* select的value属性定义当前哪个option元素处于选中状态*/}
<select value={this.state.value} onChange={this.handleChanged}>
<option value="react">React</option>
<option value="redux">Redux</option>
<option value="mobx">Mobx</option>
</select>
</label>
<input type="submit" value="Submit"/>
</form>
);
}
}

export default ReactStackForm;
  1. 复选框和单选框

复选框是类型为checkbox的input元素,单选框是类型为radio的input元素,它们的受控方式不同于类型为text的input元素。通常,复选框和单选框的值是不变的,需要改变的是它们的checked状态,因此React控制的属性不再是value属性,而是checked属性。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// ReactStacksForm.js
import React from "react";

class ReactStackForm extends React.Component {
constructor(props) {
super(props);
this.state = {react: false, redux:false, mobx: false};
}
// 表单提交的响应函数
handleSubmit = (event) => {
event.preventDefault();
}
// 监听复选框变化,设置复选框的checked状态
handleChanged = (event) =>{
this.setState({[event.target.name]: event.target.checked});
}
render() {
console.log(this.state)
return (
<form onSubmit={this.handleSubmit}>
{/*设置3个复选框*/}
<label>React
<input type="checkbox"
name="react"
value="react"
checked={this.state.react}
onChange={this.handleChanged}
/>
</label>
<label>Redux
<input type="checkbox"
name="redux"
value="redux"
checked={this.state.redux}
onChange={this.handleChanged}
/>
</label>
<label>Mobx
<input type="checkbox"
name="mobx"
value="mobx"
checked={this.state.mobx}
onChange={this.handleChanged}
/>
</label>
<input type="submit" value="Submit"/>
</form>
);
}
}

export default ReactStackForm;

下面为BBS项目添加表单元素,让每一个帖子的标题支持编辑功能。修改后的PostItem如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// PostItem.js
import React, {Component} from "react";
import PropTypes from "prop-types";
import "./PostItem.css";
import like from "./image/like-react.png"; // 图标作为模块被导入


class PostItem extends Component {
constructor(props) {
super(props);
this.state = {
editing: false, // 帖子是否是否处于编辑状态
post: props.post
};
}

componentWillReceiveProps(nextProps) {
// 父组件更新post后,更新PostItem的state
if (this.props.post !== nextProps.post) {
this.setState({
post: nextProps.post
});
}
}


// 处理点赞事件
handleClick = (event) => {
this.props.onVote(this.props.post.id);
}

// 保存/编辑按钮点击后的逻辑
handleEditPost = (event) => {
const editing = this.state.editing;
// 当前处于编辑态,调用父组件传递的onSave方法保存帖子
if (editing) {
this.props.onSave({
...this.state.post,
date: this.getFormatDate(),
})
}
}

// 处理标题textarea值的变化
handleTitleChange = (event) => {
const newPost = {...this.state.post, title: event.target.value};
this.setState({
post: newPost
});
}

getFormatDate() {
//
}


render() {
return (
<li className='item'>
<div className='title'>
{this.state.editing ? <form>
<textarea value={this.state.post.title} onChange={this.handleTitleChange}/>
</form> : this.state.post.title}
</div>
<div>创建人: <span>{this.state.post.author}</span></div>
<div>创建时间: <span>{this.state.post.date}</span></div>
<div className='like'>
<span><img src={like} onClick={this.handleClick}/></span>
<span>{this.state.post.vote}</span>
</div>
<div>
<button onClick={this.handleEditPost}>
{this.state.editing ? "保存" : "编辑"}
</button>
</div>
</li>
);
}
}

PostItem.propTypes = {
post: PropTypes.shape({
id: PropTypes.number,
title: PropTypes.string,
author: PropTypes.string,
date: PropTypes.string,
vote: PropTypes.number
}).isRequired,
onVote: PropTypes.func.isRequired
}

export default PostItem;

当点击编辑状态的button时,帖子的标题会使用textarea展示,此时标题处于可编辑状态,当再次点击button时,会执行保存操作,PostItem通过onSave属性调用父组件PostList的handleSave方法,将更新后的Post(标题和时间)保存到PostList的state中。PostList中的修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class PostList extends Component {
/*省略中间代码 */

// 保存帖子
handleSave(post){
// 根据post的id,过滤出当前要更新的post
const posts = this.state.map(
item => {
const newItem = item.id === post.id ? post : item;
return newItem;
}
)
this.setState({
posts
})
}

render() {
return (
<div>
<h2>话题列表</h2>
<ul>
{this.state.posts.map(item =>
<PostItem
key={item.id}
post={item}
onVote={this.handleVote}
onSave={this.handleSave}
/>
)}
</ul>
</div>
);
}
}

export default PostList;

非受控组件

使用受控组件虽然保证了表单元素的状态也由React统一管理,但需要为每个表单元素定义onChange事件的处理函数,然后把表单状态的更改同步到React组件的state,这一过程是比较烦琐的,一种可替代的解决方案是使用非受控组件。非受控组件指表单元素的状态依然由表单元素自己管理,而不是交给React组件管理。使用非受控组件需要有一种方式可以获取到表单元素的值,React中提供了一个特殊的属性ref,用来引用React组件或DOM元素的实例,因此我们可以通过为表单元素定义ref属性获取元素的值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import {Component} from "react";

class SimpleForm extends Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}

handleSubmit(event) {
// 通过this.input 获取到input元素的值
alert('This title you submitted was ' + this.input.value);
event.preventDefault();
}

render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
title:
{/* this.input 指向当前的input元素*/}
<input type="text" ref={(input) => this.input = input}/>
</label>
<input type="submit" value="submit"/>
</form>
)
}
}

ref的值是一个函数,这个函数会接收当前元素作为参数,即例子中的input参数指向的是当前元素。在函数中,我们把input赋值给了this.input,进而可以在组件的其他地方通过this.input获取这个元素。

在使用非受控组件时,我们常常需要为相应的表单元素设置默认值,但是无法通过表单元素的value属性设置,因为非受控组件中,React无法控制表单元素的value属性,这也就意味着一旦在非受控组件中定义了value属性的值,就很难保证后续表单元素的值的正确性。这种情况下,我们可以使用defaultValue属性指定默认值:

1
2
3
4
5
6
7
8
9
10
11
12
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
title:
{/* this.input 指向当前的input元素*/}
<input defaultValue="Something" type="text" ref={(input) => this.input = input}/>
</label>
<input type="submit" value="submit"/>
</form>
)
}

上面的例子,defaultValue设置的默认值为something,而后续值的更改则由自己控制。类似地,select元素和textarea元素也支持通过defaultValue设置默认值,则支持通过defaultChecked属性设置默认值。非受控组件看似简化了操作表单元素的过程,但这种方式破坏了React对组件状态管理的一致性,往往容易出现不容易排查的问题,因此非特殊情况下,不建议大家使用。

React 16新特性

React 16是Facebook在2017年9月发布的React最新版本。截止2023年,React的最新版本为React 18。

render新的返回类型

React 16之前,render方法必须返回单个元素。现在,render方法支持两种新的返回类型:数组(由React元素组成)和字符串。

错误处理

React 16之前,组件在运行期间如果执行出错,就会阻塞整个应用的渲染,这时候只能刷新页面才能恢复应用。React 16引入了新的错误处理机制,默认情况下,当组件中抛出错误时,这个组件会从组件树中卸载,从而避免整个应用的崩溃。这种方式比起之前的处理方式有所进步,但用户体验依然不够友好。React 16还提供了一种更加友好的错误处理方式——错误边界(Error Boundaries)。错误边界是能够捕获子组件的错误并对其做优雅处理的组件。优雅的处理可以是输出错误日志、显示出错提示等,显然这比直接卸载组件要更加友好。

定义了componentDidCatch(error, info)这个方法的组件将成为一个错误边界,现在我们创建一个组件ErrorBoundary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ErrorBoundary.js
import React from "react";

class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {hasError: false};
}

componentDidCatch(error, errorInfo) {
// 显示错误UI
this.setState({hasError: true})
// 同时打印错误信息
console.log(error, errorInfo);
}

render() {
if (this.state.hasError) {
return <h1>貌似发生了一些错误!</h1>;
}
return this.props.children;
}

}

在App中使用ErrorBoundary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import React, {Component} from "react";
import {ErrorBundary} from "./ErrorBoundary";

class App extends Component {
constructor(props) {
super(props);
this.state = {
user: {name: "react"}
}
}

// 将user置为null,模拟异常
onClick = () => {
this.setState({user: null});
}

render() {
return (
<div>
<ErrorBundary>
<Profile user={this.state.user}/>
</ErrorBundary>
<button onClick={this.onClick}>更新</button>
</div>
)
}
}

点击更新按钮后,Profile接收到的属性user为null,程序会抛出TypeError,这个错误会被ErrorBoundary捕获,并在界面上显示出错提示。

Portals

React 16的Portals特性让我们可以把组件渲染到当前组件树以外的DOM节点上,这个特性典型的应用场景是渲染应用的全局弹框,使用Portals后,任意组件都可以将弹框组件渲染到根节点上,以方便弹框的显示。Portals的实现依赖ReactDOM的一个新的API: