《大前端三问》 -- React 组件入门

React 作为前端框架,其管理的一定就是一个个 UI 控件,而在 React 的语言体系中,我们将之称为 component – 组件。

狭义上来说,组件一般是 UI 组件,负责展示及和用户的交互。而广义上,组件是带有一定业务含义的,其不仅有与用户的交互,更重要的是数据与界面之间的交互。

React 通过自定义元素的方式实现组件化,组件元素被描述成纯粹的 JSON 对象,意味着可以使用方法或是类来构建。React 组件基本上由 3 个部分组成 —— 属性(props)、状态(state)以及生命周期方法。通过 JSX,我们通常将要渲染的组件组成一棵组件树,就像搭乐高玩具一样一步步组成最终我们想要的 UI 界面。

1 组件

1.1 定义 React 组件

定义一个 React 组件 - Welcome.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react';
import PropType from 'prop-types';
class Welcome extends React.Component {
constructor(props) {
super(props);
this.state = { username: props.username};
}
static defaultProps = {
username: 'no name'
};
render() {
const { username } = this.state;
return (
<h1>Hello, {username}!</h1>2019-12-24 16:28
);
}
}
export default Welcome;
  • 默认配置

React 为 props 提供了默认配置,通过 defaultProps 静态变量的方式来定义。当组件被调用的时候,默认值保证渲染后始终有值。

1.2 无状态函数组件

无状态函数是官方比较推崇的一种组件构造方式,适用于页面内部的简单 UI 组件,看起来就是一个只传入 props 并返回一个 JSX UI 组件的函数。

其没有 state,帮称“无状态”,也就是说这类的组件完全由其外层容器决定,而且无状态组件也没有生命周期方法。这样一来,其状态对于其父容器来说是固定的,这就更符合 React“单向数据流”的设计理念。

无状态函数组件 - Star.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import PropType from 'prop-types';
import './star.css';
// 默认入参相当于 defaultProps,但是如果设置了 defaultProps 优先级更高
// PS:更建议使用 defaultProps
const Star = ({ selected = true, onClick = f => f }) => {
return (
<div className={(selected ? 'star selected' : 'star unselected')}
onClick={onClick}>
</div>
);
};
Star.propType = {
selected: PropType.bool,
onClick: PropType.func
}
export default Star;
  • 组件可以组合;
  • 可以导入 CSS 或图片资源 (基于 Webpack);

1.3 PropTypes

众所周知,JavaScript 不是强类型语言,我们对在没有保证的环境下写 JavaScript 已经习以为 常了。强类型还是弱类型,正是一个开发时的约束问题。React 对此作了妥协,便有了 propTypes。使用propTypes可以指定传入组件 props的类型,就像上面Star示例一样。当传入错误的类型时,浏览器开发工具就会在控制台输入一个错误。

propTypes官方示例:

propTypes官方示例
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
MyComponent.propTypes = {
// 你可以将属性声明为 JS 原生类型,默认情况下
// 这些属性都是可选的。
optionalArray: PropTypes.array,
optionalBool: PropTypes.bool,
optionalFunc: PropTypes.func,
optionalNumber: PropTypes.number,
optionalObject: PropTypes.object,
optionalString: PropTypes.string,
optionalSymbol: PropTypes.symbol,
// 任何可被渲染的元素(包括数字、字符串、元素或数组)
// (或 Fragment) 也包含这些类型。
optionalNode: PropTypes.node,
// 一个 React 元素。
optionalElement: PropTypes.element,
// 一个 React 元素类型(即,MyComponent)。
optionalElementType: PropTypes.elementType,
// 你也可以声明 prop 为类的实例,这里使用
// JS 的 instanceof 操作符。
optionalMessage: PropTypes.instanceOf(Message),
// 你可以让你的 prop 只能是特定的值,指定它为
// 枚举类型。
optionalEnum: PropTypes.oneOf(['News', 'Photos']),
// 一个对象可以是几种类型中的任意一个类型
optionalUnion: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.instanceOf(Message)
]),
// 可以指定一个数组由某一类型的元素组成
optionalArrayOf: PropTypes.arrayOf(PropTypes.number),
// 可以指定一个对象由某一类型的值组成
optionalObjectOf: PropTypes.objectOf(PropTypes.number),
// 可以指定一个对象由特定的类型值组成
optionalObjectWithShape: PropTypes.shape({
color: PropTypes.string,
fontSize: PropTypes.number
}),
// An object with warnings on extra properties
optionalObjectWithStrictShape: PropTypes.exact({
name: PropTypes.string,
quantity: PropTypes.number
}),
// 你可以在任何 PropTypes 属性后面加上 `isRequired` ,确保
// 这个 prop 没有被提供时,会打印警告信息。
requiredFunc: PropTypes.func.isRequired,
// 任意类型的数据
requiredAny: PropTypes.any.isRequired,
// 你可以指定一个自定义验证器。它在验证失败时应返回一个 Error 对象。
// 请不要使用 `console.warn` 或抛出异常,因为这在 `onOfType` 中不会起作用。
customProp: function(props, propName, componentName) {
if (!/matchme/.test(props[propName])) {
return new Error(
'Invalid prop `' + propName + '` supplied to' +
' `' + componentName + '`. Validation failed.'
);
}
},
// 你也可以提供一个自定义的 `arrayOf` 或 `objectOf` 验证器。
// 它应该在验证失败时返回一个 Error 对象。
// 验证器将验证数组或对象中的每个值。验证器的前两个参数
// 第一个是数组或对象本身
// 第二个是他们当前的键。
customArrayProp: PropTypes.arrayOf(function(propValue, key, componentName, location, propFullName) {
if (!/matchme/.test(propValue[key])) {
return new Error(
'Invalid prop `' + propFullName + '` supplied to' +
' `' + componentName + '`. Validation failed.'
);
}
})
};

2 组件的生命周期

在软件设计中,生命周期(life cycle)是一个广泛关注的问题,基本上任何对象都有其生命周期。而React组件的生命周期一般分为两部分:

  1. 组件挂载与卸载阶段;(即从为组件分配内存,到将之放到渲染树中的阶段的回调)
  2. 接收到新数据时的渲染阶段(render);

2.1 生命周期主要回调api

这个阶段的主要回调api有:

挂载与卸载阶段主要回调
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ----- 挂载阶段 ---
constructor() // 组件实例被创建
static getDerivedStateFromProps()
render()
// componentWillMount() // 官网上注明此方法已过时、即将废弃
componentDidMount() // 组件已插入到DOM中
// ----- 卸载阶段 ---
componentWillUnmount() // 组件从DOM中移除
// 这里注意下组件卸载没有 didUnMount这样的方法,
// 我觉和是组件只能监听到在DOM中的回调,当从从DOM中移除后就没回调了,因此只有这个 willUnMount 方法
// ------ 数据更新阶段 ------
// componentWillReceiveProps() // 官网上注明此方法已过时、即将废弃
static getDerivedStateFromProps()
shouldComponentUpdate()
// componentWillUpdate() // 官网上注明此方法已过时、即将废弃
render() // 输出 React组件树
getSnapshotBeforeUpdate() // 不常用
componentDidUpdate() // 只有当新数据更新而导致的渲染结束后才会调用
React生命周期流程图
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
【 组件挂载与卸载阶段 】 【 数据更新阶段 】
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ | ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─
ReactDOM.render() │ | setState() │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ | └ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ | │
▼ | ▼
┌───────────────────────────┐ | ┌──────────────────────────────────────────────┐
│ constructor() │ | │ componentWillReceiveProps(nextProps) //(过时)│
└───────────────────────────┘ | └──────────────────────────────────────────────┘
│ | │
▼ | ▼
┌─────────────────────────────┐ | ┌────────────────────────────────────────────┐
│componentWillMount() //(过时)│ | │ shouldComponentUpdate(nextProps,nextState) │
└─────────────────────────────┘ | └────────────────────────────────────────────┘
│ | │ true
▼ | ▼
┌───────────────────────────┐ | ┌────────────────────────────────────────────────────┐
│ render() │ | │ componentWillUpdate(nextProps, nextState) //(过时) │
└───────────────────────────┘ | └────────────────────────────────────────────────────┘
│ | │
▼ | ▼
┌───────────────────────────┐ | ┌───────────────────────────────────────────┐
│ componentDidMount() │ | │ render() │
└───────────────────────────┘ | └───────────────────────────────────────────┘
| │
| ▼
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ | ┌───────────────────────────────────────────┐
ReactDom.unmountComponentAtNode() | │ componentDidUpdate() │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ | └───────────────────────────────────────────┘
┌───────────────────────────┐
│ componentWillUnMount() │
└───────────────────────────┘

2.2 常用的React组件模板

已去掉了最新React版本中被注释为”过时”的及不常用的方法回调

常用的React组件模板
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
import React, { Component, PropTypes } from 'react';
class App extends Component {
static defaultProps = {
xx: 'xx'
};
static propType = {
xx: PropType.bool,
}
// 组件实例被创建时调用
constructor (props) {
super(props);
this.state = { xx: 'xx' }; // 初始化 state
// 不可在这里调用 setState
// 不要这样调用 this.state = { color: props.color };
// 如此做毫无必要(你可以直接使用 this.props.color),同时还产生了 bug(更新 prop 中的 color 时,并不会影响 state)
}
// 组件已插入到DOM中
componentDidMount () {
// 订阅方法添加于此
// 网络请求添加于此
}
// 组件卸载及销毁之前直接调用
componentWillUnmount() {
// 常在此方法中执行必要的清理操作:
// - 清除timer
// - 取消网络请求
// - 取消订阅
}
// 新数据来时,判断是否需要渲染 render() 更新界面, 不能在此方法中进行复杂的运算。
shouldComponentUpdate (nextProps, nextState) {
// return true/false;
// 此方法仅作为性能优化的方式而存在
}
// 只有当新数据更新而导致的渲染结束后才会调用
componentDidUpdate (prevProps, prevState) {
// 常用于在UI更新后对比更新前后的 props,来判断是否需要进一步处理
}
render() {
return (
<div>This is a demo.</div>
);
}
}

2.3 setState的批处理与异步特性

出于性能考虑,React 可能会把多个 setState () 调用合并成一个调用。所以,下面这段代码可能会出现意料之外的问题

下面这段代码可能会出现意料之外的问题
1
2
3
4
// 因为 setState () 可能会异步调用,所以 this.state 的值不能确定
this.setState({
counter: this.state.counter + this.props.increment,
});

所以, 如果要基于现在有 state 来改变 state 状态,可以让 setState () 接收一个函数而不是一个对象 。这个函数用上一个 state 作为第一个参数,将此次更新被应用时的 props 做为第二个参数:

解决上面 setState () 异步调用导致的问题
1
2
3
this.setState((state, props) => ({
counter: state.counter + props.increment
}));

3 组件的设计原则

3.1 单向数据流

如果你把一个以组件构成的树想象成一个 props 的数据瀑布的话,那么每一个组件的 state 就像是在任意一点上给瀑布增加额外的水源,但是它只能向下流动。

3.2 尽量使用无状态函数组件

这点前面介绍无状态组件时已经说过了,无状态组件没有 state,也没有生命周期回处理,其状态对于其父容器来说是固定的,这就更符合 React“单向数据流”的设计理念。

3.3 使用 class-fields 语法

像下面的 Button 当点击时,其回调函数 handleClick 中的 this 有可能会改变。因为在 JS 中,一般函数中的 this 指的是调用此方法的对象,因为 Button 的回调是系统回调,所以此 this 有可能会变成全局 window 对象。

没有绑定 this 的有问题用法
1
2
3
4
5
6
7
8
9
handleClick() {
this.setState(state => ({
isToggleOn: !state.isToggleOn
}));
}
<Button onClick={this.handleClick}>
Click me
</Button>

为了解决此问题,我们一般会使用键头函数:

使用键头函数作为回调函数,解决 this 指向问题
1
2
3
<Button onClick={(e) => this.handleClick(e)}>
Click me
</Button>

使用键头函数可以保证函数中的 this 指向其外层定义对象。但是这样有个问题,就是如果回调方法为 props 传入的话,每次渲染 Button 控件时都会创建不同的回调函数,此时组件会造成额外的开销。

所以,回调函数使用 class fields 语法会是更好的选择,其可以解决 this 引用问题,也可以不需要在控件重绘时再重复生成回调函数。用法如下:

定义回调函数使用 class-fields 语法
1
2
3
4
5
6
7
8
9
10
11
// 定义回调函数使用 class-fields 语法
// 其实相当于指键头函数的定义放在了函数定义阶段
handleClick = (event) => (event) {
this.setState(state => ({
isToggleOn: !state.isToggleOn
}));
}
<Button onClick={this.handleClick}>
Click me
</Button>
  • 但是如果需要向函数传递参数,还是建议在指定回调时使用键头函数
使用键头函数传递参数
1
2
3
4
5
6
7
handleClick (e, userId) { // 这里在自身函数中再调用传入的回调函数
this.props.handleClick && this.props.handleClick(userId)
}
<Button onClick={(e) => this.handleClick(e, userId)}>
Click me
</Button>

4 React.Children

组件有一个特殊的 this.props.children 来表示组件中的子元素,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// StartBox.js
//children 为 props 的特有属性,表示组件中的子元素
const StarBox = ({ isShow = true, children }) => {
return (
<div>
{
isShow? children : null
}
</div>
);
};
// app.js
class App extends React.Component {
render() {
return (
<StarBox isShow={false}>
<Star/>
<Star/>
<Star/>
</StarBox>
)
}
}

App.js 中的 isShow={false} 时,其组件中定义的 3 个 Star 子组件都不会显示。因为在 StarBox 组件中作了判断,isShow 为 false 时,并没有输出 children,而这个 children 就是在外层父组件中传入的 3 个 Star 元素。这里的 children 是个数组。

4.1 校验单个子元素

在有些场合你可以使用

使用 React.Childre 校验单个子元素
1
React.Children.only(this.props.children);

方法来校验单个子元素。也就是说,如果像上例中输出子元素是一个数组,则会报错

校验非单个子元素会报错
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// StartBox.js
//children 为 props 的特有属性,表示组件中的子元素
const StarBox = ( props ) => {
return (
<div>
{ React.Children.only(props.children) }
</div>
);
};
// app.js
class App extends React.Component {
render() {
return (
<StarBox>
<Star/> {/* 这里注意了,只能是一个 Star 了,如果有多个子元素 children 会变成数组,从而不能通过 Children.only 校验 */}
</StarBox>
)
}
}

但是上面的例子也要注意的就是 Star 自身可以是复杂的组合组件,这是没问题的。

5 Fragments

我们知道,React 的渲染方式为在 render () 方法中返回声明式元素,如:

return 只能返回单个 root 元素
1
2
3
4
5
6
7
8
9
10
11
class Table extends React.Component {
render() {
return (
<table>
<tr>
<Columns />
</tr>
</table>
);
}
}

即在 return 时,只能返回单个 Root 元素,如果上面的 Columns 组件像下面这样返回,将会报错:

错误的返回
1
2
3
4
5
6
7
8
class Columns extends React.Component {
render() {
return (
<td>Hello</td> // 报错了,return 时只能返回单个元素
<td>World</td>
);
}
}

但是像上面这个场景,在 2 个 <td> 元素外加个 div 层显示也是不行的。这时候就要 Fragments 出场了!

使用 React.Fragment 包裹并列子组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
return (
<React.Fragment>
<td>Hello</td>
<td>World</td>
</React.Fragment>
);
// 你也可以像下面一样使用短语法,和上面效果是一样的
return (
<>
<td>Hello</td>
<td>World</td>
</>
);

这样就可以得到我们想要的正确输出:

使用 Fragment 得到的正确输出
1
2
3
4
5
6
<table>
<tr>
<td>Hello</td>
<td>World</td>
</tr>
</table>

6 Context

Context 设计目的是为了共享那些对于一个组件树而言是 “全局” 的数据 。一般来说,React 推崇的是自顶向下的单方向数据流,数据从 root component 利用 props 属性呈树型地往下传递。而 Contex 则看起来与此理念相悖。但是在有些场合,这种 “ 全局 ” 变量又显得那么重要,比如要给每个控件增加一个全局的主题属性,如果没有全局变量的话,这将变得非常复杂 (当然,也可以使用 redux 这种单一数据源的框架来解决)。

6.1 使用 Context 全局变量

6.1.1 React.createContext

我们就用上面说的设置全局主题的示例。我们首先应该要创建一个 Context 对象,使用 React.createContext (defaultValue)api:

ThemeContext 全局对象
1
const ThemeContext = React.createContext (themes.dark/* 默认值 */);

然后这里涉及 2 个问题:一个是全局对象的读取,另一个是全局对象的修改。一种简单的操作是全局对象只放在顶层容器组件中可修改,其值与顶层容器组件的 state 绑定。

6.1.2 Context.Provider

容器组件 Context.Provider<Context.Provider value={this.state.value}> 可以向下传递 Context 全局对象,同时,让此 Context 值与 state 绑定。

顶层容器向下传递 Context 对象及 state 绑定 context 值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
constructor(props) {
// ...
this.state = {
ThemeConst.Light,
};
}
_toggleTheme = () => {
this.setState(state => ({
theme: (state.theme === ThemeConst.Dark)? ThemeConst.Light: ThemeConst.Dark,
}));
}
<ThemeContext.Provider value={this.state.theme}>
<ThemedButton onClick={this._toggleTheme}> Click to change theme </ThemedButton>
</ThemeContext.Provider>

在上面的代码示例中,context 传递给了 ThemeButton,那在展示组件中怎样将其值取出呢?

6.1.3 contextType

我们看看此 button 的代码。在 ThemedButton 中,我们可以直接利用 this.context 取出全局变量,从而决定自己的展示。

在底层展示组件中直接利用 this.context 取出全局变量
1
2
3
4
5
6
7
8
9
class ThemedButton extends React.Component {
render() {
let theme = this.context;
return (
<button {...this.props} style={{backgroundColor: theme.Background}} />
);
}
}
ThemedButton.contextType = ThemeContext;

6.2 子组件中修改全局变量

在上面的示例中,子组件只可以被动地接受由 Context.provider 传下来的全局变量。那子组件是否有方法可以修改全局变量的值呢?

答案是有的,就是利用 Context.Consumer

首先我们在定义 Context 时将之定义为一个对象,其中包含一个可以改变自身值的函数 toggleTheme:

定义 Context 对象,包含一个可以改变自身值的回调函数 toggleTheme
1
2
3
4
const ThemeContext = React.createContext({
theme: ThemeConst.Dark, // 默认值
toggleTheme: () => {}
});

这个回调固然是要在根容器组件中定义:

根容器组件中定义
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
constructor(props) {
//... 省
this.state = { // 与全局对象一样,此 State 将用于绑定全局 Context。回调函数也在此定义
theme: ThemeConst.Light,
toggleTheme: this._toggleTheme
};
}
// 改变 Context 全局变量的回调函数定义。
// 注意:这里改变的是 State,因为 State 已与 Context 绑定了
_toggleTheme = () => {
this.setState(state => ({
theme: state.theme === ThemeConst.Dark? ThemeConst.Light: ThemeConst.Dark
}));
}
render() {
return (
<div>
<ThemeContext.Provider value={this.state}>
<ThemeToggleButton> Click to change theme </ThemeToggleButton>
</ThemeContext.Provider>
</div>
)
}

6.2.1 ThemeContext.Consumer

在子展示组件中,使用 ThemeContext.Consumer 订阅了 Context 的修改。其内包含一个回调,当 Context 改变时此订阅将会得到通知,子组件也将得到刷新

ThemeToggleButton 使用 ThemeContext.Consumer 与全局 Context 绑定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ThemeToggleButton extends React.Component {
render() {
return (
<ThemeContext.Consumer>
{
(ThemeContext) => (
<button
onClick={ThemeContext.toggleTheme}
style={{ backgroundColor: ThemeContext.theme.background }}>
{this.props.children}
</button>
)
}
</ThemeContext.Consumer>
);
}
}

可以想象,这一套和 Redux 的单一数据流很像,没错,Redux 就是基于 Context 这一套实现滴~

7 小结

1
2

8 引用

  1. 《谈一谈 Normalize.css》- 会飞的贼xmy
坚持原创技术分享,您的支持将鼓励我继续创作!