《React进阶之路》笔记

愿新冠疫情早日结束,加油!

1.基础篇

前端UI的本质问题是如何将来源于服务器的动态数据和用户的交互行为高效地反映到复杂的用户界面上。

React的特点可以归结为以下4点:

  • 声明式的视图层。不同于一般的HTML模版,React采用的是JavaScript(jsx)语法来声明视图层,因此可以在视图层中随意使用各种状态数据。
  • 简单的更新流程。声明式的视图定义方式有助于简化视图层的更新流程。你只需要定义UI状态,React会负责把它渲染成最终的UI。当状态数据发生变化时,React也会根据最新的状态渲染出最新的UI,从状态到UI这一单向数据流让React组件的更新流程清晰简洁。
  • 灵活的渲染实现。React并不是把视图直接渲染成最终的终端界面,而是把它们渲染成虚拟DOM。虚拟DOM只是普通的JavaScript对象,你可以结合其他依赖库把这个对象渲染成不同端上的UI。例如使用react-dom在浏览器上渲染,使用Node在服务器端渲染,使用ReactNative在手机渲染。
  • 高效的DOM操作。直接操作虚拟DOM这个JavaScript对象比直接操作一个真实DOM效率提升很多,并且基于React优异的差异比较算法,React可以尽量减少虚拟DOM到实际DOM的渲染次数,以及每次渲染需要改变的真实DOM节点数。

2.JSX

2.1 语法

标签类型

在jsx语法中,使用的标签类型有两种:DOM类型的标签(div、span等等)React组件类型的标签。当使用DOM类型的标签时,标签的首字母必须小写;当使用React组件类型的标签时,组件名称的首字母必须大写。如:

1
2
3
4
5
6
7
8
9
const element1 = <h1>Hello, world!</h1>;

const element2 = <HelloWorld/>;

const element3 = (
<div>
<HelloWorld/>
</div>
)

JavaScript表达式

在jsx中使用JavaScript表达式需要将表达式用大括号{}包起来。表达式在jsx中的使用场景主要有两个:通过表达式给标签属性复制和通过表达式定义子组件。如:

1
2
3
4
5
6
7
const element1 = <MyComponent foo={1 + 2}/>;

const element2 = (
<ul>
{[1, 2, 3].map(item => <Item key={item} val={item}/>)}
</ul>
)

注意,jsx中只能使用JavaScript表达式,而不能使用多行JavaScript语句。例如下面的写法都是错误的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const element1 = <MyComponent foo={const val = 1 + 2; return val;}/>;

let bool
const element2 = (
<div>
{
if (bool) {
return <MyComponent/>
} else {
return null;
}
}
</div>
)

不过,jsx中可以使用三目运算符或逻辑与(&&)运算符代替if语句的作用。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let bool;
const element1 = (
<div>
{
bool ? <MyComponent/> : null;
}
</div>
);

const element2 = (
<div>
{
bool && <MyComponent/>
}
</div>
)

标签属性

当jsx标签时DOM类型的标签时,对应DOM标签支持的属性jsx也支持,例如id、class、style、onclick等等。但是部分属性的名称会有所改变,主要的变化有:class要写成className、事件属性名采用驼峰格式,例如onclick要写成onClick。

注释

/**/

jsx不是必需的

jsx语法对使用React来说并不是必需的,实际上,jsx语法只是React.createElement(component, props, ...children)的语法糖,使用jsx创建界面元素更加清晰简洁,所有的jsx语法最终都会被转换成对这个方法对调用。如:

1
2
// jsx
const element = <div className="foo">Hello, React</div>;

转换后:

1
const element = React.createElement('div', {className: 'foo'}, 'Hello, React')

2.2 组件

组件定义

组件是 React 的核心概念,是 React 应用程序的基石。组件将应用的UI拆分成独立的、可复用的模块, React 应用程序正是由一个一个组件搭建而成的。

定义一个组件有两种方式:使用ES6 class(类组件)使用函数(函数组件)

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

  • 1.class继承自React.Component
  • 2.class内部必须定义render方法,render方法返回代表该组件UI的React元素。

组件的props

props是一个简单结构的对象,它包含的属性正是由组件作为jsx标签使用时的属性组成。

组件的state

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

需要注意的地方:

  • 1.在组件的构造方法constructor内,首先要调用super(props),这一步实际上是调用了React.Component这个class的constructor方法,用来完成React组件的初始化工作。
  • 在constructor中,通过this.state定义了组件的状态
  • 在render方法中,我们为标签定义了处理点击事件的响应函数,在响应函数内部会调用this.setState更新组件的状态

有状态组件和无状态组件

定义无状态组件推荐用函数定义组件。

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

React应用组件设计的一般思路时:通过定义少数的有状态组件管理整个应用的状态变化,并且将状态通过props传递给其余的无状态组件,由无状态组件完成页面绝大部分UI的渲染工作。总之,有状态组件主要关注处理状态变化的业务逻辑,无状态组件主要关注组件UI的渲染。

属性校验和默认属性

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

1
2
3
4
5
6
7
8
9
10
import PropTypes from 'prop-types';

class PostItem extends React.Component {
// ...
}

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

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

类型 PropTypes对应属性
String PropTypes.string
Number PropTypes.number
Boolean PropTypes.bool
Function PropTypes.func
Object PropTypes.object
Array PropTypes.array
Symbol PropTypes.symbol
Element(React元素) PropTypes.element
Node(可被渲染的节点:数字、字符串、React元素或由这些类型的数据组成的数组) PropTypes.node

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

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
function Welcome(props) {
return <h1>hello, {props.name}</h1>
}
Welcome.defaultProps = {
name: 'Stranger'
}

组件和元素

jsx语法就是用来创建React元素的。如

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元素组建而成的。

2.3 组件的生命周期

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

挂载阶段

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

  • constructor:组件被创建时,会首先调用组件的构造方法。这个构造方法接收一个props参数,props时从父组件中传入的属性对象,如果父组件中没有传入属性而组件自身定义了默认属性,那么这个props指向的就是组件的默认属性。你必须在这个方法中首先调用super(props)才能保证props被传入组件中,constructor通常用于初始化组件的state以及绑定事件处理方法等工作。
  • componentWillMount:这个方法在组件被挂载到DOM前调用,且只会被调用一次。这个方法在实际项目中很少会用到,因为可以在该方法中执行的工作都可以提前到constructor中。在这个方法中调用this.setState不会引起组件的重新渲染。
  • render:这是定义组件时唯一必要的方法(组件的其他生命周期方法都可以省略)。在这个方法中,根据组件的props和state返回一个React元素,用于描述组件的UI,通常React元素使用jsx语法定义。需要注意的是,render并不负责组件的实际渲染工作,它只是返回一个UI的描述,真正的渲染页出页面的工作由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:这个方法只在props引起的组件更新过程中,才会被调用。State引起的组件更新并不会触发该方法的执行。方法的参数nextProps是父组件传递给当前组件的新的props。但是nextProps的值有可能和子组件当前props的值相等,因此往往需要比较nextProps和this.props来决定是否执行props发生变化后的逻辑。
    • 在componentWillReceiveProps中调用setState,只有在组件render及其之后的方法中,this.state指向的才是更新后的state。在render之前的方法shouldComponentUpdate、componentWillUpdate中,this.state依然指向的是更新前的state。
    • 通过调用setState更新组件状态并不会出发componentWillReceiveProps的调用,否则可能会进入一个死循环,componentWillReceiveProps -> this.setState -> componentWillReceiveProps -> this.setState
  • shouldComponentUpdate(nextProps, nextState):这个方法决定组件是否继续执行更新过程。当方法返回true 时( true 也是这个方法的默认返回值),组件会继续更新过程;当方法返回 false 时,组件的更新过程停止,后续的 componentWillUpdate、 render、 componentDidUpdate 也不会再被调用。一般通过比较nextProps、nextState 和组件当前的 props、state 决定这个方法的返回结果。这个方法可以用来减少组件不必要的渲染,从而优化组件 的性能。
  • componentWillUpdate(nextProps, nextStat):这个方法在组件 render 调用前执行,可以作为组件更新发生前执行某些工作的地方,一般也很少用到。
  • render
  • componentDidUpdate(prevProps, prevState):组件更新后被调用,可以作为操作更新后的 DOM的地方。这个方法的两个参数 prevProps、prevState 代表组件更新前的 props和state。
  • componentWillReceiveProps(nextProps)

卸载阶段

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

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

2.4 列表和Keys

React使用key属性来标记列表中的每个元素,当列表数据发生变化时,React就可以通过key知道哪些元素发生了变化,从而只重新渲染发生变化的元素,提高渲染效率。

如果列表包含的元素没有ID,也可以使用元素在列表中的位置索引作为key值。单并不推荐使用索引作为key,因为一旦列表中的数据发生重排,数据的索引也会发生变化,不利于React的渲染优化。

2.5 事件处理

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

  • 在React中,事件的命名采用驼峰命名方式,而不是DOM元素中的小写字母命名方式。

  • 处理事件的响应函数要以对象的形式赋值给事件属性,而不是DOM中的字符串形式。

    例如在DOM中绑定一个点击事件这样写:

    1
    <button onclick="clickButton()">Click</button>

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

    1
    <button onClick={clickButton}>Click</button>

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

在React组件中处理事件最容易出错的地方时事件处理函数中this的指向问题,因为ES6 class并不会为方法自动绑定this到当前对象。React事件处理函数的写法主要有三种方式,不同的写法解决this指向问题的方式也不同。

1.使用箭头函数

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

2.使用组件方法(推荐)

在类的构造函数中,将这个方法的this绑定到当前对象。

3.属性初始化语法(property initializer syntax)

定义属性方法时直接使用箭头函数。

2.6 表单

受控组件

如果一个表单元素的值是由React来管理的,那么它就是一个受控组件。

非受控组件

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

ref的值是一个函数,这个函数会接收当前元素作为参数。

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

1
2
3
4
5
render () {
return (
<input defaultValue="default" ref={(input) => this.input = input}>
)
}

3.React 16新特性

React 16 Facebook 2017 月发布的 React 最新版本 React 16 基于代号为“Fiber ”的 新架构实现,几乎对 React 的底层代码进行了重写,但对外的 API 基本不变。

3.1 render新的返回类型

React 16之前,render方法必须返回单个元素。现在,render方法支持两种新的返回类型:数组(由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
class ListComponent extends Component {
render () {
return {
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
}
}
}

class StringComponent extends Component {
render () {
return 'a string';
}
}

export default class App extends Component {
render () {
return [
<ul>
<ListComponent />
</ul>,
<StringComponent/>
]
}
}

3.2 错误处理

React 16之前,组件在运行期间如果执行出错,就会阻塞整个应用的渲染,这时候只能刷新页 面才能恢复应用。React 16引入了新的错误处理机制,默认情况下, 当组件中抛出错误时,这个组 件会从组件树中卸载,从而避免整个应用的崩溃。

这种方式比起之前的处理方式有所进步,但用户 体验依然不够友好。 React 16还提供了一种更加友好的错误处理方式——错误边界(Error Boundaries)。。错误边界是能够捕获子组件的错误井对其做优雅处理的组件。优雅的处理可以是输 出错误日志、显示出错提示等,显然这比直接卸载组件要更加友好。

定义了componentDidCatch(error, info)这个方法的组件将成为一个错误边界,如

1
2
3
4
5
6
7
8
9
10
11
class Error extends Component {
constructor (props) {
super(props);
this.state = { hasError: false };
}
componentDidCatch (error, info) {
this.setState({ hasError: true });
console.log(error, info);
}
//...
}

3.3 Portals

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

1
ReactDOM.createPortal(child, container)

其中:

  • child:可以被渲染的React节点,例如React元素、由React元素组成的数组、字符串等,
  • container:是一个DOM元素,child将被挂载到这个DOM节点。

如我们创建一个Modal组件,Modal使用ReactDOM.createPortal()在根节点上创建一个弹框:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Modal extends Component {
constructor (props) {
super(props);
this.container = document.createElement('div');
document.body.appendChild(this.container);
}
componentWillUnmount () {
document.body.removeChild(this.container);
}
render () {
return ReactDOM.createPortal(
<div className="modal">
<span className="close" onClick={this.props.onClose}>&times;</span>
<div className="content">
{this.props.children}
</div>
</div>,
this.container
)
}
}

在App中使用Modal:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class App extends Component {
constructor (props) {
super(props);
this.state = { showModal: true };
}

// 关闭弹框
closeModal = () => {
this.setState({ showModal: false });
}

render () {
return (
<div>
<h2>Dashboard</h2>
{
this.state.showModal && (
<Modal onClose={this.closeModal}>Modal Dialog</Modal>
)
}
</div>
)
}
}

3.4 自定义DOM属性

React 16 之前会忽略不识别的 HTML和SVG 属性,现在 React 会把不识别的属性传递给 DOM 元素。

如:

1
<div custom-attribute="something"/>

在React 16之前,浏览器渲染出的DOM节点为:

1
<div />

而React 16渲染出的DOM节点为:

1
<div custom-attribute="something"/>

4 深入理解组件

4.1 组件state

设计合适的state

组件state必须能代表一个组件UI呈现的完整状态集,即组件的任何UI改变都可以从state的变化中反映出来;同时,state还必须代表一个组件UI呈现的最小状态集,即state中所有状态都用于反映组件UI的变化,没有任何多余的状态,也不应该存在通过其他状态计算出来的中间状态。

state所代表的一个组件UI呈现的完整状态集又可以分成两类数据:用作渲染组件时使用到的数据的来源以及作组件UI展现形式的判断依据。

组件中用到的一个变量是不是应该作为 state 可以通过下面的4条依据进行判断:

  • 这个变量是否通过props从父组件中获取?如果是,那么它不是一个状态。
  • 这个变量是否在组件的整个生命周期中都保持不变?如果是,那么它不是一个状态
  • 这个变量是否可以通过其他状态(state)或者属性(props)计算得到?如果是,那么它不是一个状态
  • 这个变量是否在组件的render方法中使用?如果不是,那么它不是一个状态。这种情况下,这个变量更适合定义为组件的一个普通属性。

正确修改state

  • 1.不能直接修改state
  • 2.state的更新是异步的
  • 3.state的更新是一个合并的过程

state与不可变对象

React 官方建议把 state 当作不可变对象,一 方面,直接修改 this.state ,组件并不会重新 render; 另一方面,当 state 中包含的所有状态都应该是不可变对象 state 中的某个状态发生变化时, 应该 重新创建这个状态对象,而不是直接修改原来的状态 那么,当状态发生变化时,如何创建新的状 态呢?根据状态的类型可以分成以下三种情况:

  • 1.状态的类型是不可变类型(数字、字符串、布尔值、null、undefined)

    这种情况最简单,因为状态是不可变类型 所以直接给要修改的状态赋一个新值即可。例如要修改 count (数字类型)、 title 〈字符串类型)、 success (布尔类型)三个状态:

    1
    2
    3
    4
    5
    this.setState({
    count: 1,
    title: 'React',
    success: true
    })
  • 2.状态的类型是数组

    注意,不要使用 push pop shift unshift splice 等方法修改数组类型的状态,因为这些方法 都是在原数组的基础上修改的,而 concat slice filter 会返回一个新的数组。

  • 3.状态的类型是普通对象(不包含字符串/数组)

    • 1.使用ES6的Object.assign方法
    • 2.使用对象扩展语法(...,object spread properties)

总结下,创建新的状态对象的关键是,避免使用会直接修改原对象的方法,而是使用可以返回 个新对象的方法。当然,也可以使用一些 Immutable的JS库 (如 Immutable.js) 实现类似的效果。

为什么 React 推荐组件的状态是不可变对象呢?一 方面是因为对不可变对象的修改会返回一个新对象,不需要担心原有对象在不小心的情况下被修改导致的错误,方便程序的管理和调试; 另一方面是出于性能考虑,当对象组件状态都是不可变对象时,在组件的 shouldComponentUpdate 方法中仅需要 比较前后两次状态对象的引用就可以判断状态是否真的改变,从而避免不必要的 render 调用。

4.2 组件与服务器通信

组件从服务器上获取数据,情况复杂。

组件挂载阶段通信

React组件的正常运转本质上是组件不同生命周期方法的有序执行,因此组件与服务器的通信 也必定依赖组件的生命周期方法。通常是componentDidMount或componentWillMount。

componentWillMount 会在组件被挂载前调用,因此从时间上来讲,在 componentWillMount 执行服务器通信要早于在 componentDidMount 中执行,执行得越早意味着服务器数据越能更快地 返回组件。 这也是很多人青睐在 componentWillMount 中执行服务器通信的重要原因。但实际上, componentWillMount componentDidMount 执行的时间差微乎其微,完全可以忽略不计。

componentDidMount 是执行组件与服务器通信的最佳地方,原因主要有两个:

  • 在 componentDidMount 中执行服务器通信可以保证获取到数据时,组件已经处于挂载状 态,这时即使要直接操作 DOM 也是安全的,而 componentWillMount 无法保证这 点。
  • 当组件在服务器端渲染时 , componentWillMount 会被调 用两次, 一次是在服务器端,另一次是在浏览器端,而 componentDidMount 能保证在任何情况下 只会被调用一次,从而不会发送多余的数据请求。

有些开发人员会在组件的构造函数中执行服务器通信, 一般情况下,这种方式也可以正常工 作。但是,构造函数的意义是执行组件的初始化工作,如设置组件的初始状态,并不适合做数据请 求这类有 “副作用”的工作。因此,不推荐在构造函数中执行服务器通信。

组件更新阶段通信

组件在更新阶段常常需要再次与服务器通信,获取服务器上的最新数据。 例如,组件需要 以props 中的某个属性作为与服务器通信时的请求参数,当这个属性值发生更新时,组件自然需要重新与服务 器通信。

4.3 组件通信

父子组件通信

props参数和回调方法。

兄弟组件通信

兄弟组件不能直接相互传送数据,需要通过状态提升的方式实现兄弟组件的通信,即把组件 之间需要共享的状态保存到距离它们最近的共同父组件内,任意一个兄弟组件都可以通过父组件传递的回调函数来修改共享状态,父组件中共享状态的变化也会通过 props 向下传递给所有兄弟组件, 从而完成兄弟组件之间的通信。

Context

当组件所处层级太深时,往往需要经过很多层的 props 传递才能将所需的数据或者回调函数传 递给使用组件。这时,以 props作为桥梁的组件通信方式便会显得很烦琐。

幸好,React提供了一个context上下文,让任意层级的子组件都可以获取父组件中的状态和方法。创建context的方式是:在提供context的组件内新增一个getChildContext方法,返回context对象,然后在组件的childContextTypes属性上定义context对象的属性的类型信息。如

延伸

前面介绍的3种组件通信方式都是依赖 React 组件 自身的语法特性。其实,还有更多的方式可 以来实现组件通信。我们可以使用消息队列来实现组件通信:改变数据的组件发起一个消息,使用 数据的组件监昕这个消息,井在响应函数中触发 setState 来改变组件状态。本质上,这是观察者模式的 实现,我们可以通过引入 EventEmitter或 Postal.js 等消息队列库完成这一过程。当应用更加复杂时,还可以引入专 门的状态管理库实现组件通信和组件状态的管理,例如 Redux和 MobX 是当 前非常受欢迎的两种状态管理库。

4.4 特殊的ref

在DOM元素上使用ref

在DOM 元素上使用 ref 是最常见的使用场景。 ref 接收一个回调函数作为值,在组件被挂载或 卸载时,因调函数会被调用,在组件被挂载时,回调函数会接收当前 DOM 元素作为参数:在组件 被卸载时,回调函数会接收 null 作为参数。

在组件上使用ref

React 组件也可以定义 ref,此时 ref 的回调函数接收的参数是当前组件的实例,这提供了一种在组件外部操作组件的方式。

注意,只能为类组件定义 ref 属性,而不能为函数组件定义 ref 属性

函数组件虽然不能定义 ref 属性,但这并不影响在函数组件内部使用 ref 来引用其他 DOM 元素或组件,例如下面的例子是可以正常工作的。

1
2
3
4
5
6
7
8
9
10
11
12
function MyFunctionalComponent () {
let textInput = null;
function handleClick () {
textInput.focus();
}
return (
<div>
<input ref={input => textInput = input}/>
<button onClick={handleClick}>获取焦点</button>
</div>
)
}

父组件访问子组件的DOM节点

可以采用一种间接的方式获取 组件的 DOM 元素:在子组件的 DOM 元素上定义 ref, ref 的值是父组件传递给子组件的 回调 函数,回调函数可以通过一个自定义的属性传递,例如 inputRef,这样父组件的回调函数中就能获取 到这个 DOM 元素。

5 虚拟DOM和性能优化

5.1 虚拟DOM

软件开发中遇到的所有问题都可以通过增加一层抽象而得以解决。

虚拟DOM只是用来描述真实DOM都JavaScript对象而已。

5.2 Diff算法

React采用声明式的API描述UI结构,每次组件的状态或属性更新,组件的render方法都会返回一个新的虚拟DOM对象,用来表述新的UI结构。如果每次render都直接使用新的虚拟DOM来生成这真实DOM结构,那么会带来大量对真实DOM的操作,影响程序执行效率。

事实上,React会通过比较两次虚拟DOM结构的变化找出差异部分,更新到真实DOM上,从而减少最终要在真实DOM上执行的操作,提高程序执行效率。这一过程就是React的调和过程(Reconciliation),其中的关键上比较两个树形结构的Diff算法。

在Diff算法中,比较的两方是新的虚拟DOM和旧的虚拟DOM,而不是虚拟DOM和真实DOM,只不过Diff的结果会更新到真实DOM上。

正常情况下,比较两个树形结构差异的算法的时间复杂度上O(N^3),这个效率显然是无法接受的。React通过总结DOM的实际使用场景提出了两个在绝大多数实践场景下都成立的假设,基于这两个假设,React实现了在O(N)事件复杂度内完成两棵虚拟DOM树的比较,这两个假设是:

  • 如果两个元素的类型不同,那么它们将生成两棵不同的树;
  • 为列表中的元素设置key属性,用key标识对应的元素在多次render过程中是否发生变化。

React比较两棵树树丛树的根节点开始比较的,根节点的类型不同,React执行的操作也不同。

1.当根节点是不同类型时

根节点类型的变化时一个很大的变化,React会认为新的树和旧的树完全不同,不会继续比较其他属性和子节点,而是把整棵树拆掉重建(包括虚拟DOM和真实DOM树)。这里需要注意,虚拟DOM的节点类型分为两类:一类是DOM元素类型,比如div、p等;一类是React组件类型,比如自定义的React组件。在旧的虚拟DOM树被拆除的过程中,旧的DOM元素类型的节点会被销毁,旧的React组件实例的componentWillUnmount会被调用;在重建的过程中,新的DOM元素会被插入DOM树中,新的组件实例的componentWillMount和componentDidMount方法会被调用。重建后的新的虚拟DOM树又会被整体更新到真实DOM树中。这种情况下,需要大量DOM操作,更新效率最低。

2.当根节点上相同的DOM元素类型时

当两个根节点上相同类型的DOM元素,React会保留根节点,而比较根节点的属性,然后只更新那些变化了的属性。

3.当根节点上相同的组件类型时

当两个根节点上相同类型的组件,对应的组件实例不会被销毁,只是会执行更新操作,同步变化的属性到虚拟DOM树上,这一过程组件实例的componentWillReceiveProps()和componentWillUpdate()会被调用。

注意,对于组件类型的节点,React上无法直接知道如何更新真实DOM树的,需要在组件更新并且render方法执行完成后,根据render返回的虚拟DOM结构决定如何更新真实DOM树。

比较完根节点后,React会以同样的原则继续递归比较子节点,每一个子节点相对于其层级一下的节点来说又是一个根节点。如此递归比较,知道比较完两棵树上的所有节点,计算得到最终的差异,更新到DOM树中。

列表key属性的意义:

1
2
3
4
5
6
7
8
9
10
<ul>
<li>1</li>
<li>2</li>
</ul>

<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>

当一个节点有多个子节点时,默认情况下,React只会按照顺序逐一比较两棵树上对应的子节点。如上节点,最终只会插入一个新的节点<li>3</li>

但是如下情况

1
2
3
4
5
6
7
8
9
10
<ul>
<li>1</li>
<li>2</li>
</ul>

<ul>
<li>3</li>
<li>1</li>
<li>2</li>
</ul>

即如果在子节点的开始位置新增一个节点,情况就会截然不同,这种比较方式会导致每一个节点都被修改。

为了解决这种低效的更新方式,React提供了一个key属性。当一组子节点定义了key,React会根据key来匹配子节点,在每次渲染之后,只要子节点的key值没有变化,React就认为这是同一个节点。如:

1
2
3
4
5
6
7
8
9
10
<ul>
<li key="1">1</li>
<li key="2">2</li>
</ul>

<ul>
<li key="3">3</li>
<li key="1">1</li>
<li key="2">2</li>
</ul>

如此一来,React只需要执行一次插入新节点的操作。这里同时也暴露了另一个问题,尽量不要使用元素在列表中的索引值作为key,因为列表中的元素顺序一旦发生改变,就可能导致大量的key失效,进而引起大量的修改操作。

5.3 性能优化

使用生产环境版本的库

一般三方库都会根据process.env.NODE_ENV这个环境变量决定在开发环境和生存环境下执行的代码有哪些不同。

webpack定义方式:

1
2
3
4
5
6
7
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify('production')
}
})
]

避免不必要的组件渲染

shouldComponentUpdate方法,根据自身的业务逻辑来避免组件不必要的渲染。当进行对象的比较时,最精确的比较方式是便利对象的每一层级的属性分别比较,也就是进行深比较(deep compare),但shouldComponentUpdate被频繁调用,如果props和state的对象层级很深,深比较对性能的影响就比较大。一种折中的方案是,只比较对象的第一层级的属性,也就是执行浅比较(shallow compare)。

React中提供了一个PureComponent组件,这个组件会使用浅比较来比较新旧props和state。

使用key

5.4 性能检测工具

React Developer Tools for Chrome

Chrome Performance Tab

why-did-you-update

一个js库,会比较组件的state和props的变化,从而发现组件render方法不必要的调用。使用如:

1
2
3
4
if (process.env.NODE_ENV !== "production") {
const {whyDidYouUpdate} = require('why-did-you-update');
whyDidYouUpdate(React)
}

6 高阶组件

高阶组件主要用来实现组件逻辑的抽象和复用。

6.1 基本概念

在JavaScript中,高阶函数是以函数为参数,并且返回值也是函数的函数。类似地,高阶组件(简称HOC)接受React组件作为参数,并且返回一个新的React组件。高阶组件本质上也是一个函数,并不是一个组件。高阶组件的函数形式如下:

1
const EnhancedComponent = higherOrderComponent(WrappedComponent);

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, {Component} from 'react'

function withPersistentData(WrappedComponent) {
return class extends Component {
componentWillMount () {
let data = localStorage.getItem('data');
this.setState({data});
}

render () {
<WrappedComponent data={this.state.data} {...this.props}/>
}
}
}

// use
class MyComponent extends Component {
render () {
return <div>{...this.props.data}</div>
}
}

const MyComponentWithPersistentData = withPersistentData(MyComponent);

高阶组件的主要功能是封装并分离组件的通用逻辑,让通用逻辑在组件间更好地被复用。高阶组件的这种实现方式本质上是装饰者设计模式。

6.2 使用场景

主要有以下4种:

操纵props

被包装组件接收props前,高阶组件可以先拦截到props,对props执行增删改的操作,然后将处理后的props再传递给被包装的组件。

通过ref访问组件实例

高阶组件通过ref获取被包装组件实例的引用,然后高阶组件就具备了直接操作被包装组件的属性或方法的能力。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function withRef(wrappedComponent) {
return class extends React.Component {
constructor(props) {
super(props);
this.someMethod = this.someMethod.bind(this);
}
someMethod () {
this.wrappedInstance.someMethodInWrappedComponent();
}
render () {
return <WrappedComponent ref={instance => this.wrappedInstance = instance} {...this.props}/>
}
}
}

组件状态提升

无状态组件更容易被复用。高阶组件可以通过被包装组件的状态及相应的状态处理方法提升到高阶组件自身内部实现被包装组件的无状态化。一个典型的场景是,利用高阶组件将原本受控组件需要自己维护的状态统一提升到高阶组件中:

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
function withControlledState(WrappedComponent) {
return class extends React.Component {
constructor (props) {
super(props);
this.state = {
value: ''
};
this.handleValueChange = this.handleValueChange.bind(this);
}
handleValueChange (event) {
this.setState({
value: event.target.value
})
}
render () {
const newProps = {
controlledProps: {
value: this.state.value,
onChange: this.handleValueChange,
}
};
return <WrappedComponent {...this.props} {...newProps}/>
}
}
}

这个例子把受控组件 value 属性用到的状态和处理 value 变化的回调函数都提升到高阶组件中, 我们再使用受控组件时,就可以这样使用:

1
2
3
4
5
6
7
class SimpleControlledComponent extends React.Component {
render () {
return <input name="simple" {...this.props.controlledProps}/>
}
}

const ComponentWithControlledState = withControlledState(SimpleControlledComponent);

用其他元素包装组件

通常用于为组件增加布局或修改样式。如

1
2
3
4
5
6
7
8
9
10
11
function withRedBackground(WrappedComponent) {
return class extends React.Component {
render () {
return (
<div style={{backgroundColor: 'red'}}>
<WrappedComponent {...this.props}/>
</div>
)
}
}
}

6.3 参数传递

如6.1例子的改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, {Component} from 'react'

function withPersistentData(WrappedComponent, key) {
return class extends Component {
componentWillMount () {
let data = localStorage.getItem(key);
this.setState({data});
}

render () {
<WrappedComponent data={this.state.data} {...this.props}/>
}
}
}

// use
class MyComponent extends Component {
render () {
return <div>{...this.props.data}</div>
}
}

const MyComponentWithPersistentData = withPersistentData(MyComponent);

但是实际情况中,我们很少使用这种方式传递参数。而是采用更加灵活、更具通用性的函数形式:

1
HOC(...params)(WrappedComponent)

其中HOC(...params)的返回值是一个高阶组件,高阶组件需要的参数是先传递给HOC韩素的。改写后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, {Component} from 'react'

function withPersistentData (key) => (WrappedComponent) => {
return class extends Component {
componentWillMount () {
let data = localStorage.getItem(key);
this.setState({data});
}

render () {
<WrappedComponent data={this.state.data} {...this.props}/>
}
}
}

// use
class MyComponent extends Component {
render () {
return <div>{...this.props.data}</div>
}
}

const MyComponentWithPersistentData = withPersistentData(MyComponent);

react-redux中的connect函数就是这样的形式

我们还可以定义一个工具函数compose:

1
2
3
4
5
function compose (...funcs) {
if (funcs.length === 0) return arg => arg;
if (funcs.length === 1) return funcs[0];
return funcs.reduce((a, b) => (...args) => a(b(args)))
}

调用compose(f, g, h)等价于(...args) => f(g(h(...args)))。用compose函数可以把高阶组件嵌套的写法打平。

6.4 继承方式实现高阶组件

前面介绍的高阶组件的实现方式都是由高阶组件处理通用逻辑,然后将相关属性传递给被包 装组件,我们称这种实现方式为属性代理。除了属性代理外,还可以通过继承方式实现高阶组件:通过继承被包装组件实现逻辑的复用。继承方式实现的高阶组件常用于渲染劫持。

例如当用户处于登录状态,允许组件渲染;否则渲染一个空组件。如:

1
2
3
4
5
6
7
8
9
10
11
function withAuth(WrappedComponent) {
return class extends WrappedComponent {
render () {
if (this.props.loggedIn) {
return super.render();
} else {
return null;
}
}
}
}

继承方式实现的高阶组件对被包装组件具有侵入性,当组合多个高阶组件使用时,很容易因为子类组件忘记 通过super调用父类组件方法而导致逻辑丢失。因此在使用高阶组件时,应尽量通过代理方式实现高阶组件。

6.5 注意事项

  • 1.为了在开发和调试阶段更好地区别包装了不同组件的高阶组件,需要对高阶组件的 名称做自定义处理。常用的处理方式是,把被包装组件的显示名称也包到高阶组件的显示名称中。

    如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function getDisplayName (WrappedComponent) {
    return WrappedComponent.displayName || WrappedComponent.name || 'Component'
    }
    function withPersistentData(WrappedComponent) {
    return class extends Component {
    static displayName = `HOC(${getDisplayName(WrappedComponent)})`;
    render () {
    // ...
    }
    }
    }
  • 2.不要在组件的render方法中使用高阶组件,尽量也不要在组件的其他 命周期方 用高阶组件。因为调用高阶组件,每次都会返回一个新的组件,于是每次render,前一次高阶组件创建的组件都会被卸载(unmount),然后重新挂载(mount)本次创建的新组件,既影响效率,又丢失了组件及其子组件的状态。如

    1
    2
    3
    4
    render () {
    const EnhancedComponent = enhance(MyComponent);
    return <EnhancedComponent/>
    }

    所以,高阶组件最适合使用的地方是在组件定义的外部,这样就不会受到组件生命周期的影响。

  • 3.如果需要使用被包装组件的静态方法,那么必须手动复制这些静态方法 因为高阶组件 返回的新组件不包含被包装组件的静态方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    WrappedComponent.staticMethod = function () {
    // ...
    }
    function withHOC (WrappedComponent) {
    class Enhance extends React.Component {
    // ...
    }
    Enhance.staticMethod = WrappedComponent.staticMethod;
    return Enhance;
    }
  • 4.Refs不会被传递给包装组件。尽管在定义高阶组件时,我们会把所有的属性都传递给 被包装组件 但是 ref 井不会传递给被包装组件。如果在高阶组件的返回组件中定义了 ref,那么它 指向的是这个返回的新组件 而不是内部被包装的组件。如果希望获取被包装组件的引用,那么可 以自定义一个属性,属性的值是一个函数 传递给被包装组件的 ref 。如

    1
    2
    3
    4
    5
    function FocusInput({inputRef, ...rest}) {
    return <input ref={inputRef} {...rest}/>;
    }

    const EnhanceInput = enhance(FocusInput);
  • 与父组件的区别。高阶组件在一些方面和父组件很相似。例如 我们完全可以把高阶组 件中的逻辑放到一个父组件中去执行,执行完成的结果再传递给子组件,但是高阶组件强调的是逻辑的抽象。高阶组件是一个函数,函数关注的是逻辑;父组件是一个组件,组件主要关注的是 UI/DOM。如果逻辑是与 DOM 直接相关的,那么这部分逻辑适合放到父组件中实现;如果逻辑是 DOM不直接相关的,那么这部分逻辑适合使用高阶组件抽象, 如数据校验、请求发送等。