React 16.6.x 全新全译

全文翻译自React 16.6.0 英文文档,适当精简了生产环境不经常使用的内容,并对部分较为复杂的概念进行了更加翔实的解读,以及与 Vue2 进行了一些特性方面的比较。本文首先会介绍React 16带来的一系列变化与新特性,然后解读 React 官方文档Docs当中Quick StartAdvanced Guides的内容,最后基于项目上的使用实践,开源了一个较为完整的脚手架项目Rhino,适合已经具备组件式前端框架开发经验的同学快速上手。

2017 年 9 月 Facebook 释出React v16.0.x,宣布使用对商业使用更加友好的 MIT license 开源许可,并带来了全新的render()函数返回类型、更加健壮的错误处理机制、全新的FragmentPortal 特性,并完全重写了类库的核心架构,带来更为优异服务器端渲染性能的同时,有效缩小了类库代码本身的体积,更重要的意义在于杜绝了 Preact 等衍生框架对 React 社区所造成的分裂。

快速开始

如果使用 npm 作为依赖管理工具,可以通过下面命令安装 React。

1
npm install --save react react-dom

一个使用 ES6 的最简单例子是这样的:

1
2
3
4
import React from "react";
import ReactDOM from "react-dom";

ReactDOM.render(<h1>Hello, React 16.6.0 !</h1>, document.getElementById("app"));

当然,也可以使用独立的 React 发布包,直接在<script>标签当中包含使用。

1
2
3
4
5
6
<!-- Development Versions-->
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<!-- Production Versions -->
<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>

JSX 语法

JSX 是一个具有JavaScript 编程特性类 HTML 标签语言,目前TypeScriptVue2都已经对 JSX 语法提供了良好的支持,广泛的应用于现代化前端应用开发当中。

向 JSX 中嵌入表达式

开发人员可以通过花括号语法{}嵌入 JavaScript 表达式(例如2+2user.nameauth(user))。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function auth(user) {
return user.name + " " + user.password;
}

const user = {
name: "hank",
password: "12345"
};

const element = (
<h1>
User Info:
{auth(user)}.
</h1>
);

ReactDOM.render(element, document.getElementById("app"));

通过使用圆括号语法(),可以方便的书写多行 JSX。

JSX 本身也是一种表达式

编译之后,JSX 表达式会被转换为标准的 JavaScript 对象,这意味可以在 JSX 内使用iffor等 JavaScript 语句、指定一个变量、接收其作为参数、甚至是从一个函数当中返回它们。

1
2
3
4
5
6
7
8
9
10
11
function getUserInfo(user) {
if (user) {
return (
<h1>
User Info:
{auth(user)}
</h1>
);
}
return <h1>No Info.</h1>;
}

指定 JSX 的属性

可以使用引号"指定一个字符串字面量作为 JSX 的属性。

1
2
const element1 = <div className="dashboard" />;
const element2 = <div tabIndex="0" />;

也可以使用花括号表达式{}嵌入 JavaScript 表达式到 JSX 属性当中,此时无需在花括号外使用引号。

1
const element = <img src={user.avatarUrl} />;

相比于静态的 HTML,由于 JSX 编程性上更加接近于 JavaScript,React DOM 使用驼峰风格(camelCase)的属性名称来代替原生 HTML 属性风格,例如:HTML 中的classtabindex变为 JSX 中的className以及tabIndex

使用 JSX 指定子元素

如果 JSX 标签内容为空,可以使用/>直接进行关闭。

1
const element = <img src={user.avatarUrl} />;

当然,JSX 标签可能也会包含子元素 ,如同下面这样:

1
2
3
4
5
6
const element = (
<div>
<h1>Hello!</h1>
<h2>React 16.6.0!</h2>
</div>
);

JSX 可以预防脚本注入攻击

React DOM 默认会在 JSX 渲染之前,避免任何值嵌入。因此可以确保不会被注入显式编写在应用之外的内容。为了避免 XSS 跨站脚本攻击,任何内容在渲染之前都会被转换为字符串。

1
2
3
const text = response.dangerInput;
// 这里是安全的
const element = <h1>{text}</h1>;

JSX 最终会被转换为对象

Babel 会将 JSX 编译为一个React.createElement()函数调用,因此下面代码中的element1element2是等效的。

1
const element1 = <h1 className="demo">你好, React 16.6.0!</h1>;
1
const element2 = React.createElement("h1", { className: "demo" }, "你好, React 16.6.0!");

虽然React.createElement()会执行各类检查帮助你编写准确无误的代码,但是本质上其建立的对象是下面的样子:

1
2
3
4
5
6
7
8
// 为了方便理解,下面对象经过了简化处理
const element = {
type: "h1",
props: {
className: "demo",
children: "你好, React 16.6.0!"
}
};

这些对象被称为React elements,React 读取这些对象并通过它们去构建 DOM,并负责维护其状态,其名称乃至于功能都与 Vue2 模板编译所使用的createElement()函数一致。

元素 Elements

元素 Elements是 React 应用的最小组成部份,不同于浏览器的 DOM 元素,React 元素是一个非常易于建立的普通对象。React 的组件(Components)和元素(Elements)是非常容易混淆的两个概念,事实上React 的组件是由元素所组成的,元素是 React 的 JSX 模板的最小组成部分

渲染一个 React 元素到 DOM

通过ReactDOM.render()方法渲染一个 React 元素到 HTML 的 DOM 根结点。

1
2
3
const element = <h1>Hello, React 16.6.0 !</h1>;

ReactDOM.render(element, document.getElementById("app"));

更新已经被渲染的 React 元素

React 元素是不可变的,建立后就不能修改其属性以及子元素。React 元素就像电影中的一个关键帧,总是在确切的时间点展现 UI。

更新 UI 总是需要去建立一个新的 React 元素,然后再通过ReactDOM.render()渲染出来。例如下面代码,每间隔 1 秒钟使用setInterval()回调函数执行ReactDOM.render()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function timer() {
const element = (
<div>
<h1>你好, React 16.6.0 !</h1>
<h2>
现在时间是
{new Date().toLocaleTimeString()}。
</h2>
</div>
);
ReactDOM.render(element, document.getElementById("app"));
}

setInterval(timer, 1000);

React 元素是按需更新的

React DOM 会比较当前 React 元素与其之前的状态,然后只对发生变化的 DOM 局部执行更新操作。

Components 组件

组件 Components可以将 UI 拆分为独立且可复用的片断,React 组件接收props作为输入参数,并在最后返回 React 元素。

函数式组件与类组件

定义 React 组件最简单的方法是通过 JavaScript 函数。

1
2
3
4
// 接收props参数
function Welcome(props) {
return <h1>你好, {props.name}!</h1>; // 返回React元素
}

当然也可以通过 ES6 的class关键字定义一个等效组件,从而获取更多的有趣特性。

1
2
3
4
5
class Welcome extends React.Component {
render() {
return <h1>你好, {this.props.name}!</h1>;
}
}

渲染一个组件

首先定义一个 React 组件,然后将组件赋值给一个 React 元素,最后再使用ReactDOM.render()方法渲染该 React 元素到页面上。

1
2
3
4
5
6
7
8
9
10
// 定义一个函数式组件
function Welcome(props) {
return <h1>你好, {props.name}!</h1>;
}

// 将上面定义的组件赋予一个React元素
const element = <Welcome name="Hank" />;

// 渲染这个元素
ReactDOM.render(element, document.getElementById("app"));

React 组件的名称通常约定为大写格式。

组合使用多个组件

我们可以在一个组件返回的 JSX 当中组合引用其它的组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Welcome(props) {
return <h1>你好, {props.name}!</h1>;
}

function App() {
return (
<div>
<Welcome name="Hank" />
<Welcome name="Jack" />
<Welcome name="Candy" />
</div>
);
}

ReactDOM.render(<App />, document.getElementById("app"));

组件的抽取

可以将一个较大的组件分割为更加细粒度的组件,便于复用与维护,例如下面这个函数式组件Comment

1
2
3
4
5
6
7
8
9
10
11
12
function Comment(props) {
return (
<div className="Comment">
<div className="UserInfo">
<img className="Avatar" src={props.author.avatarUrl} alt={props.author.name} />
<div className="UserInfo-name">{props.author.name}</div>
</div>
<div className="Comment-text">{props.text}</div>
<div className="Comment-date">{formatDate(props.date)}</div>
</div>
);
}

可以将其拆分为AvatarUserInfoComment三个具有包含关系的组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Avatar(props) {
return <img className="Avatar" src={props.user.avatarUrl} alt={props.user.name} />;
}

function UserInfo(props) {
return (
<div className="UserInfo">
<Avatar user={props.user} />
<div className="UserInfo-name">{props.user.name}</div>
</div>
);
}

function Comment(props) {
return (
<div className="Comment">
<UserInfo user={props.author} />
<div className="Comment-text">{props.text}</div>
<div className="Comment-date">{formatDate(props.date)}</div>
</div>
);
}

Props 是只读的

无论是以函数式还是class类的方式声明组件,都不能对它们的props进行修改。

1
2
3
4
5
6
7
function pure(firstname, lastname) {
return firstname + lastname; // 没有对props进行修改
}

function impure(firstname, lastname) {
firstname = "nothing"; // 对props进行了修改,不建议这样做
}

重要原则:组件外部只能通过 props 改变组件本身的行为。

State 状态

首先,我们来改写之前定时器timer的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
function timer() {
const element = (
<div>
<h1>你好, React 16.6.0 !</h1>
<h2>
现在时间是
{new Date().toLocaleTimeString()}。
</h2>
</div>
);
ReactDOM.render(element, document.getElementById("app"));
}
setInterval(timer, 1000);

然后,将 JSX 元素elementtimer()函数中提取出来,并抽象为一个 JSX 组件Clock,然后通过props向组件传递当前date参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Clock(props) {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {props.date.toLocaleTimeString()}.</h2>
</div>
);
}

function timer() {
ReactDOM.render(<Clock date={new Date()} />, document.getElementById("app"));
}

setInterval(timer, 1000);

但是,我们希望Clock组件的更新总是来自于其内部状态,而非通过组件外部的props进行传入,如同下面代码这样:

1
ReactDOM.render(<Clock />, document.getElementById("app"));

这就引出了 React 组件当中的另一个重要概念statestateprops非常类似,但是其属于组件私有,只能由组件自身进行控制。另外,前面章节有提到类组件具有比函数式组件更丰富的特性,而state就是这些特性当中的一个,因为它只能由类组件进行使用。

将函数式组件转换为类组件

首先,需要建立一个继承自React.ComponentES6 的 Class 类,并添加一个render()方法;然后将组件内容移动至该方法当中,并将函数式组件中传入的props参数,修改为通过this.props进行引用。

1
2
3
4
5
6
7
8
9
10
class Clock extends React.Component {
render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.props.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

向类组件添加 state

首先,将render()函数中的this.props.date替换为this.state.date

1
2
3
4
5
6
7
8
9
10
class Clock extends React.Component {
render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

然后,定义一个constructor()构造方法去初始化this.state

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Clock extends React.Component {
// 注意这里是如何将props传递到构造函数中的
constructor(props) {
super(props); // 类组件总是会使用props作为参数调用基类构造器。
this.state = { date: new Date() };
}

render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

最后,从<Clock />元素移除作为propsdate属性。

1
ReactDOM.render(<Clock />, document.getElementById("app"));

最终结果看起来是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = { date: new Date() };
}

render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

ReactDOM.render(<Clock />, document.getElementById("app"));

接下来,我们需要利用 React 组件提供的生命周期方法,每间隔 1 秒对当前显示的时间进行更新。

生命周期钩子

组件式前端框架通常都会拥有自己特定的生命周期函数,从过去的BackboneEmber到更为现代化的Vue2Angular6皆是如此,同样作为组件式框架的React也不例化。总体上,React的生命周期可以分为如下四个阶段:

装载Mounting),组件实例被创建并插入 DOM 时会按下面顺序调用方法:

  • constructor()
  • static getDerivedStateFromProps()
  • render()
  • componentDidMount()

更新Updating),修改propsstate时会触发组件的更新,此时会依照如下顺序调用方法:

  • static getDerivedStateFromProps()
  • shouldComponentUpdate()
  • render()
  • getSnapshotBeforeUpdate()
  • componentDidUpdate()

卸载Unmounting),当组件从 DOM 中被删除时调用该方法:

  • componentWillUnmount()

错误处理Error Handling),在生命周期方法、子组件构造函数、组件渲染过程中出现错误时会调用下列方法:

  • static getDerivedStateFromError()
  • componentDidCatch()
lifecycle

多组件应用程序开发当中,非常重要的一点在于:在组件被销毁的时候去释放组件占用的资源。即当组件被渲染至 DOM 的时候,需要初始化Clock组件中的定时器,React 生命周期中称为mounting挂载;然后在组件被销毁时,清除组件定时器所占用的资源,React 生命周期中称为unmounting卸载。下面详细讲解一下React 中提供的两个比较重要的生命周期钩子(lifecycle hookscomponentDidMount()componentWillUnmount()

componentDidMount()钩子

React 组件被渲染到 HTML DOM 后被执行,可以用来初始化之前例子中的定时器。

1
2
3
4
5
6
7
componentDidMount() {
// 设置timer ID到this指针
this.timerID = setInterval(
() => this.tick(),
1000
);
}

componentWillUnmount()钩子

React 组件从 HTML DOM 卸载之前得到执行,可以用来销毁定义在组件this上的定时器。

1
2
3
componentWillUnmount() {
clearInterval(this.timerID);
}

抽取 timer()函数

timer()函数会通过this.setState()定时更新组件本身的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
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = { date: new Date() };
}

componentDidMount() {
this.timerID = setInterval(() => this.timer(), 1000);
}

componentWillUnmount() {
clearInterval(this.timerID);
}

timer() {
this.setState({
date: new Date()
});
}

render() {
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}

ReactDOM.render(<Clock />, document.getElementById("app"));

深入 State

除了在类组件的构造函数之外,不允许直接修改state,而必须通过组件提供的setState()方法执行修改操作。

1
2
3
4
5
// 错误
this.state.comment = "你好";

// 正确
this.setState({ comment: "你好" });

React 中this.propsthis.state的更新都是异步的,当在同一个组件中多次应用时,不能依赖它们去计算下一个状态(可能会造成错误),例如下面代码可能会错误的更新计数器:

1
2
3
this.setState({
counter: this.state.counter + this.props.increment
});

要修复这个问题,setState()可以接收一个函数作为参数,该函数第 1 个参数是之前的state,第 2 个参数是props

1
2
3
this.setState((prevState, props) => ({
counter: prevState.counter + props.increment
}));

通过setState()对 state 的更新操作都会合并到当前的 State 状态,例如下面代码的state中可以包含多个独立值:

1
2
3
4
5
6
7
constructor(props) {
super(props);
this.state = {
users: [],
groups: []
};
}

然后可以在组件里,分别使用setState()对这些值进行单独更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
componentDidMount() {
fetchUser().then(response => {
this.setState({
users: response.users
});
});

fetchGroup().then(response => {
this.setState({
groups: response.groups
});
});
}

单向数据流

React 当中的state通常被认为是局部的或者封装的,除了拥有并设置它的组件之外,其它任何组件都不能对其进行访问。因此,父子组件之间,可以通过将state赋值给props的方式,将父组件的内部状态传递给子组件。

1
2
3
4
5
<FormattedDate date={this.state.date} />;

function FormattedDate(props) {
return <h2>It is {props.date.toLocaleTimeString()}.</h2>;
}

上面代码当中的FormattedDate组件通过其 props 接收了父组件传递过来的状态this.state.date。因此,可以认为 React 组件之间的数据流向是从父组件至子组件,即一个由上至下的关系,通常被称为单向数据流

可以将一个组件树中的props想象成一个瀑布流,每个单独组件的state如同一个个的独立水源,在任意时间节点加入到瀑布流当中,然后共同向下流动。

下面代码中,在一个App组件内部渲染了多个Clock组件,每个组件的时间都会独立进行更新,互相不受影响。

1
2
3
4
5
6
7
8
9
10
11
function App() {
return (
<div>
<Clock />
<Clock />
<Clock />
</div>
);
}

ReactDOM.render(<App />, document.getElementById("app"));

事件机制

React 事件机制与原生 JavaScript 事件机制语法上有以下不同:

  1. React 事件名称使用驼峰命名 camelCase
  2. JSX 可以直接使用函数作为事件处理器。
1
2
3
4
5
6
7
8
9
// 原生JavaScript事件
<button onclick="activateLasers()">
Activate Lasers
</button>

// React事件
<button onClick={activateLasers}>
Activate Lasers
</button>

(3)React 不能通过返回false阻止事件默认行为,而必须显式调用preventDefault()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<a href="#" onclick="console.log('这个链接已经被点击!'); return false">
Click me
</a>;

function ActionLink() {
function handleClick(e) {
e.preventDefault();
console.log("这个链接已经被点击!");
}

return (
<a href="#" onClick={handleClick}>
点击目标
</a>
);
}

上面代码中,传入事件处理函数handleClick(e)的参数e是 React 遵循W3C UI Events 事件规范实现的合成事件,因此毋需担心跨浏览器兼容性问题。

使用 ES6 的 class 定义一个类组件时,通用的做法是以类方法的形式定义事件处理函数,例如下面代码定义了一个可以点击切换【打开】和【关闭】状态的按钮。

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

// 使用bind()方法,将this作用域绑定至回调函数
this.handleClick = this.handleClick.bind(this);
}

handleClick() {
this.setState(prevState => ({
isToggleOn: !prevState.isToggleOn
}));
}

render() {
return <button onClick={this.handleClick}>{this.state.isToggleOn ? "打开" : "关闭"}</button>;
}
}

ReactDOM.render(<Toggle />, document.getElementById("app"));

大家注意理解上面类组件constructor()构造函数当中,this.handleClick.bind(this)的含义。使用bind()是为了将类组件的this作用域绑定至指定函数,从而方便的在该函数内部使用this操作 React 类组件上的其它方法。

绑定组件 this 到事件处理函数

当然,如果你认为bind()使用起来又臭又长,这里有 2 种方式可以绕开它:

(1)通过实验性的类属性转换语法(Class properties transform)正确绑定this到事件回调函数当中,不过需要额外安装babel-plugin-transform-class-properties插件支持。

1
2
3
4
5
6
7
8
9
10
class Button extends React.Component {
// 本质是将一个箭头函数赋值给一个变量
handleClick = () => {
console.log("这是:", this);
};

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

(2)或者通过箭头函数的方式直接调用事件处理函数。

1
2
3
4
5
6
7
8
9
10
11
12
class Button extends React.Component {
handleClick() {
console.log("这是:", this);
}

render() {
return (
// 这样的书写语法可以确保this正确的绑定到handleClick
<button onClick={e => this.handleClick(e)}>点击我</button>
);
}
}

这种方式的缺点在于每次不同的事件回调函数被建立时,都会触发 React 组件的重绘(比如下面的 Button),如果此时事件回调传递props到子级组件,则这些组件全部都会发生重绘,从而对页面性能造成影响。因此,React 官方更加推荐通过组件构造器调用bind()类属性语法这两种方式。

向事件处理函数传递参数

通常情况下,我们都需要传递参数到事件处理函数,例如传递每一行的id,下面使用的arrow functionsFunction.prototype.bind两种写法都是等效的。

1
2
<button onClick={(e) => this.deleteRow(id, e)}>删除行</button>
<button onClick={this.deleteRow.bind(this, id)}>删除行</button>

第 1 个参数e表示的是 React 事件对象,紧随其后的第 2 个参数即用来表示id

条件渲染

React 中的条件渲染类似于 JavaScript 中的条件运算,可以通过if等条件运算符去动态展示元素、组件的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function User(props) {
return <h1>欢迎回来!</h1>;
}

function Guest(props) {
return <h1>请登录!</h1>;
}

// 通过Greeting组件条件渲染上面定义的2个组件
function Greeting(props) {
const isLogged = props.isLogged;
if (isLogged) {
return <User />;
}
return <Guest />;
}

ReactDOM.render(
<Greeting isLogged={false} />, // 尝试将false改成true,会得到不同的展示结果
document.getElementById("app")
);

元素变量

可以将 React 元素赋值给一个变量,这样可以方便的在组件内部进行条件渲染。下面例子中的<LoginControl />组件会根据自身的状态,有条件的渲染<LoginButton /><LogoutButton />以及之前例子中的<Greeting />组件。

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
// 无状态组件
function LoginButton(props) {
return <button onClick={props.onClick}> 登入 </button>;
}

// 无状态组件
function LogoutButton(props) {
return <button onClick={props.onClick}> 登出 </button>;
}

// 带有状态的组件
class LoginControl extends React.Component {
constructor(props) {
super(props);
this.handleLoginClick = this.handleLoginClick.bind(this);
this.handleLogoutClick = this.handleLogoutClick.bind(this);
this.state = { isLogged: false };
}

handleLoginClick() {
this.setState({ isLogged: true });
}
handleLogoutClick() {
this.setState({ isLogged: false });
}

render() {
let button = null;
const isLogged = this.state.isLogged;

if (isLogged) {
button = <LogoutButton onClick={this.handleLogoutClick} />;
} else {
button = <LoginButton onClick={this.handleLoginClick} />;
}

return (
<div>
<Greeting isLogged={isLogged} /> {button}
</div>
);
}
}

ReactDOM.render(<LoginControl />, document.getElementById("app"));

声明一个变量和使用if关键字是进行条件渲染非常好的方式,但有些时候可能需要使用到更简短的语法,接下来介绍几种行内的条件渲染方式:

内联条件渲染-&&运算符

将 JSX 表达式嵌入到一个花括号{}运算符中(表达式中包含了 JavaScript 逻辑和&&操作符),可以方便的将 React 元素包含到条件渲染判断语句当中。

1
2
3
4
5
6
7
8
9
10
11
12
function Mailbox(props) {
const unreadMessages = props.unreadMessages;
return (
<div>
<h1>Hello!</h1>
{unreadMessages.length > 0 && <h2>You have {unreadMessages.length} unread messages.</h2>}
</div>
);
}

const messages = ["React", "Re: React", "Re:Re: React"];
ReactDOM.render(<Mailbox unreadMessages={messages} />, document.getElementById("app"));

JavaScript 当中true && 表达式的结果总是表达式,而false && 表达式的结果总是false。换而言之,如果条件判断结果为true,则&&运算符右侧的 React 元素将会出现在输出当中,如果为false则 React 会自动跳过不进行任何渲染。

内联条件渲染-三目运算符

另外一种使用内联条件渲染的方式是通过三目运算符condition ? true : false,下面例子中使用它对一小块文本进行了条件渲染。

1
2
3
4
5
6
7
8
render() {
const isLogged = this.state.isLogged;
return (
<div>
用户 <b>{isLogged ? '已经' : '没有'}</b> 登录.
</div>
);
}

三目运算符也可以用于进行多行的条件渲染:

1
2
3
4
5
6
7
8
9
10
11
12
render() {
const isLogged = this.state.isLogged;
return (
<div>
{isLogged ? (
<LogoutButton onClick={this.handleLogoutClick} />
) : (
<LoginButton onClick={this.handleLoginClick} />
)}
</div>
);
}

如同 JavaScript 一样,条件渲染的使用完全依照开发团队的习惯和实际工作的需求,但是无论如何都不要书写过于复杂的条件渲染语句,否则可以考虑将条件渲染过程抽象为一个具体的组件。

阻止组件的渲染

极少的情况下,开发人员需要将组件隐藏起来,即便它已经被其它组件渲染出来,如果需要这样做,可以让组件render()函数返回null而非 JSX 的内容。

下面的例子中,组件<WarningBanner />的渲染依赖于一个名为warn的 props 值,如果其值为false则该组件不会渲染。

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
function WarningBanner(props) {
if (!props.warn) {
return null;
}

return <div className="warning">警告信息!</div>;
}

class Page extends React.Component {
constructor(props) {
super(props);
this.state = { showWarning: true };
this.handleToggleClick = this.handleToggleClick.bind(this);
}

handleToggleClick() {
this.setState(prevState => ({
showWarning: !prevState.showWarning
}));
}

render() {
return (
<div>
<WarningBanner warn={this.state.showWarning} />
<button onClick={this.handleToggleClick}>{this.state.showWarning ? "隐藏" : "显示"}</button>
</div>
);
}
}

ReactDOM.render(<Page />, document.getElementById("app"));

React 组件的render()方法返回null值并不会影响组件生命周期钩子函数的触发,诸如componentWillUpdate()componentDidUpdate()依然会被正常调用。

List 和 Key

通常情况下,在 JavaScript 当中我们会像下面代码这样循环一个数组列表。

1
2
3
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(number => number * 2);
console.log(doubled); // [2, 4, 6, 8, 10]

React 当中循环一个组件列表的方式与上面非常相似,下面代码会渲染出一个从 1 至 5 编号的无序列表。

1
2
3
4
const numbers = [1, 2, 3, 4, 5];
const listItems = numbers.map(number => <li>{number}</li>);

ReactDOM.render(<ul>{listItems}</ul>, document.getElementById("app"));

列表组件

接下来,我们将上面例子中的列表循环封装到一个组件当中去,该组件将会接收一个numbers数组作为props

1
2
3
4
5
6
7
8
function NumberList(props) {
const numbers = props.numbers;
const listItems = numbers.map(number => <li>{number}</li>);
return <ul>{listItems}</ul>;
}

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(<NumberList numbers={numbers} />, document.getElementById("app"));

但是,当你执行这段代码时,会得到这个警告信息:Warning: Each child in an array or iterator should have a unique "key" prop.。这里,通过添加key={number.toString()}可以修复该问题。

1
2
3
4
5
6
7
8
function NumberList(props) {
const numbers = props.numbers;
const listItems = numbers.map(number => (
// 列表循环中的`key`是一个特殊的字符串属性
<li key={number.toString()}>{number}</li>
));
return <ul>{listItems}</ul>;
}

列表循环的 key

key属性用来帮助 React 鉴别具体哪一项内容发生了变化,可以给列表循环当中的每个具体项一个确切的、稳定的标识。

1
2
const numbers = [1, 2, 3, 4, 5];
const listItems = numbers.map(number => <li key={number.toString()}>{number}</li>);

最佳实践是使用字符串类型的键值来作为列表循环当中每项的唯一标识,通常情况下可以使用列表的id值来作为key

1
const todoItems = todos.map(todo => <li key={todo.id}>{todo.text}</li>);

如果没有稳定的id值,可以考虑使用循环列表每项的索引值index作为key

1
const todoItems = todos.map((todo, index) => <li key={index}>{todo.text}</li>);

在列表项顺序可能发生变化的场景下,React 官方并不推荐使用索引作为key,因为会带来性能方面的负面影响,并引发组件状态的问题。

key 的使用位置

属性key只作用于数组循环上下文的内部,通常情况下是在 ES6 提供的map()遍历方法内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function ListItem(props) {
// 这里不需要指定key值
return <li>{props.value}</li>;
}

function NumberList(props) {
const numbers = props.numbers;
const listItems = numbers.map(number => (
// key必须放置在数组循环的作用域范围内(即map()方法内部)
<ListItem key={number.toString()} value={number} />
));
return <ul>{listItems}</ul>;
}

const numbers = [1, 2, 3, 4, 5];
ReactDOM.render(<NumberList numbers={numbers} />, document.getElementById("app"));

key 必须唯一

key值必须保持在数组循环作用域范围内的唯一,而非全局上下文范围内的唯一,因此在不同的数组循环内使用相同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
function Blog(props) {
const sidebar = (
<ul>
{props.posts.map(post => (
<li key={post.id}> {post.title} </li>
))}
</ul>
);
const content = props.posts.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.content}</p>
</div>
));
return (
<div>
{sidebar}
<hr />
{content}
</div>
);
}

const posts = [{ id: 1, title: "你好", content: "欢迎使用React 16.6.0!" }, { id: 2, title: "安装方式", content: "可以通过npm和yarn安装React" }];
ReactDOM.render(<Blog posts={posts} />, document.getElementById("app"));

key仅仅只是作为 React 内部的标记,并不会被渲染到组件和 DOM 当中,如果在组件内部需要使用到key的属性值,可以考虑也同时将其传递给组件的props

1
2
3
4
const content = posts.map(post => (
// post.id同时赋值给了key属性和id props。
<Post key={post.id} id={post.id} title={post.title} />
));

嵌入 map()至 JSX

JSX 允许通过花括号{}嵌入任意表达式,因此可以将map()嵌入至 JSX 行内使用。

1
2
3
4
5
6
7
8
9
10
function NumberList(props) {
const numbers = props.numbers;
return (
<ul>
{numbers.map(number => (
<ListItem key={number.toString()} value={number} />
))}
</ul>
);
}

某些情况下,这样的内联风格可以得到更加整洁的代码,但如果滥用也可能会影响代码的可读性,因此需要根据实际场景权衡后再使用。但是,仍然需要注意的一点:如果map()循环体的嵌套过深,可以考虑将其抽象为组件

React 表单

HTML 表单与 React 表单的工作方式有些不同,因为 React 需要去保持一些内部状态。例如,下面的 HTML 表单将会接收一个 name 字段:

1
2
3
4
5
6
<form>
<label>
名称:<input type="text" name="name" />
</label>
<input type="submit" value="Submit" />
</form>

HTML 表单在用户点击提交请求之后会跳转到一个新的页面,React 当中虽然能够完成同样的工作,但是通常情况会使用一个 JavaScript 事件处理函数去操控表单提交行为,从而获取和控制用户在表单当中的输入行为,这种标准方式在 React 当中被称为受控组件

受控组件

HTML 表单元素<input><textarea><select>会根据用户输入维护自己的状态,React 当中这些变化的状态会由组件的state来维护,并只能使用setState()进行更新。接下来,我们融合 HTML 原生表单和 React 组件state的行为,让 React 组件在渲染表单元素的同时,也能够控制其输入状态。这种输入状态受到 React 控制的 HTML 表单就被称为受控组件(Controlled Components

下面的代码,将会使用受控组件来重写本章开头的 HTML 表单示例:

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
class InputForm extends React.Component {
constructor(props) {
super(props);
this.state = { value: "" };

this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}

handleChange(event) {
// toUpperCase()可以让用户输入的英文总是大写
this.setState({ value: event.target.value.toUpperCase() });
}

handleSubmit(event) {
alert("当前提交的名称:" + this.state.value);
event.preventDefault();
}

render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
名称:
<input type="text" value={this.state.value} onChange={this.handleChange} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
}

上面例子中,当value属性设置到表单元素时,其值总是this.state.value的值,从而让 React 的state成为表单输入的内容的单一来源。伴随每次用户的输入handleChange都会通过this.setState()this.state.value进行更新,从而完成 HTML 表单到 React 状态的双向绑定

受控组件中的每个状态变化都会关联对应的事件处理函数。

textarea 标签

HTML 的<textarea>标签,通过标签内部的字符串来定义其文本内容,如同下面这样:

1
2
3
<textarea>
Hello there, this is some text in a text area
</textarea>

React 当中的<textarea>依然会通过一个value属性来代替标签内部的字符串,其用法和上面的<input>标签相似。

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
class TextareaForm extends React.Component {
constructor(props) {
super(props);
this.state = {
value: "这是一句默认显示在输入域中的内容。"
};

this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}

handleChange(event) {
this.setState({ value: event.target.value });
}

handleSubmit(event) {
alert("当前提交的内容: " + this.state.value);
event.preventDefault();
}

render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
输入内容:
<textarea value={this.state.value} onChange={this.handleChange} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
}

select 标签

HTML 中的<select>用来建立一个下拉列表,下面列表描述了一系列汽车品牌,并且通过selected属性默认选中了奔驰

1
2
3
4
5
6
<select>
<option value="benz" selected>奔驰</option>
<option value="volkswagen">大众</option>
<option value="peugeot">标致</option>
<option value="renault">雷诺</option>
</select>

React 中使用value属性代替了上面列表中selected默认选中的功能,因为只需要在一个位置进行更新,所以能够更加方便的使用受控组件

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
class FavoriteCarForm extends React.Component {
constructor(props) {
super(props);
this.state = {value: "coconut"'};

this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}

handleChange(event) {
this.setState({value: event.target.value});
}

handleSubmit(event) {
alert("选择你最喜欢的汽车是: " + this.state.value);
event.preventDefault();
}

render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
选择你最喜欢的汽车:
<select value={this.state.value} onChange={this.handleChange}>
<option value="benz">奔驰</option>
<option value="volkswagen">大众</option>
<option value="peugeot">标致</option>
<option value="renault">雷诺</option>
</select>
</label>
<input type="submit" value="确定" />
</form>
);
}
}

你也可以传递一个数组到value属性当中,从而能够在<select>标签中选择多个属性。

1
<select multiple={true} value={['B', 'C']}>

总体而言,React 当中<input type="text"><textarea><select>的工作方式都非常类似,他们都能够接收一个value属性。

操作多个输入域

当需要操作多个输入域的时候,你可以为这些输入域添加name属性,然后通过事件处理函数参数所提供的event.target.name来判断各自的行为。

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
class Reservation extends React.Component {
constructor(props) {
super(props);
this.state = {
isGoing: true,
numberOfGuests: 2
};
this.handleInputChange = this.handleInputChange.bind(this);
}

handleInputChange(event) {
const target = event.target;
const value = target.type === "checkbox" ? target.checked : target.value;
const name = target.name;
// 注意ES6被计算属性名称语法的使用
this.setState({
[name]: value
});
}

render() {
return (
<form>
<label>
Is going:
<input name="isGoing" checked={this.state.isGoing} onChange={this.handleInputChange} type="checkbox" />
</label>
<br />
<label>
Number of guests:
<input name="numberOfGuests" value={this.state.numberOfGuests} onChange={this.handleInputChange} type="number" />
</label>
</form>
);
}
}

设置属性值为 null

将输入域控件的value属性设置为undefined或者null,可以控制其编辑状态。

1
2
3
4
5
6
7
const mountedNode = document.getElementById("app");

ReactDOM.render(<input value="你好" />, mountedNode);

setTimeout(function() {
ReactDOM.render(<input value={null} />, mountedNode);
}, 5000);

非受控组件

通常情况下,使用受控组件是比较冗长乏味的,因为需要编写大量事件函数去处理状态的变化,并将结果传递给 React 组件进行展示,这对于旧系统向 React 的技术迁移极不友好。这种场景下,其实可以考虑使用非受控组件uncontrolled components),这是一种处理表单输入的替代技术,后面的章节将会对其进行说明。

状态提升

当多个组件需要反映相同的状态数据时,通常建议将状态提升到这些组件的共同父级组件当中。下面,通过一个沸腾水温计算器的例子来进行说明。

首先,我们建立一个BoilingVerdict组件,该组件接收一个摄氏温度作为 props,并打印出超过 100 度的沸腾水温。

1
2
3
4
5
6
function BoilingVerdict(props) {
if (props.celsius >= 100) {
return <p>水将会沸腾!</p>;
}
return <p>水不会沸腾!</p>;
}

然后,再建立一个Calculator组件,用来输入温度并将其状态保持在this.state.temperature当中,并将这个输入值渲染到至BoilingVerdict组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Calculator extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = { temperature: "" };
}

handleChange(e) {
this.setState({ temperature: e.target.value });
}

render() {
const temperature = this.state.temperature;
return (
<fieldset>
<legend>请输入摄氏温度:</legend>
<input value={temperature} onChange={this.handleChange} />

<BoilingVerdict celsius={parseFloat(temperature)} />
</fieldset>
);
}
}

添加第 2 个输入域

接下来,需要再添加一个输入域来输入华氏温度,并保持它们的状态同步。

首先,从Calculator组件抽象一个TemperatureInput组件,并增加一个名称为scale的 props(值为 c 或者 f),

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
const scaleNames = {
c: "摄氏",
f: "华氏"
};

class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = { temperature: "" };
}

handleChange(e) {
this.setState({ temperature: e.target.value });
}

render() {
const temperature = this.state.temperature;
const scale = this.props.scale;
return (
<fieldset>
<legend>
请输入
{scaleNames[scale]}
温度:
</legend>
<input value={temperature} onChange={this.handleChange} />
</fieldset>
);
}
}

然后,修改一下Calculator组件,使其能够分别渲染scalecf的两个TemperatureInput组件。

1
2
3
4
5
6
7
8
9
10
class Calculator extends React.Component {
render() {
return (
<div>
<TemperatureInput scale="c" />
<TemperatureInput scale="f" />
</div>
);
}
}

进行到这一步,我们已经拥有两个输入域,但是输入其中的一个,并不会导致另一个同步更新,这并不符合本章节开头的需求。而且由于温度状态位于TemperatureInput组件内部,Calculator组件无法直接对其进行显示。

编写转换函数

我们的例子中,还需要两个对摄氏/华氏温度进行相互转换的函数。

1
2
3
4
5
6
7
function toCelsius(fahrenheit) {
return ((fahrenheit - 32) * 5) / 9;
}

function toFahrenheit(celsius) {
return (celsius * 9) / 5 + 32;
}

以及一个对输入温度进行合法性校验的函数,不合法时返回空字符串,合法则返回值精确到小数点第 3 位。

1
2
3
4
5
6
7
8
9
10
11
12
function tryConvert(temperature, convert) {
const input = parseFloat(temperature);
if (Number.isNaN(input)) {
return "";
}
const output = convert(input);
const rounded = Math.round(output * 1000) / 1000;
return rounded.toString();
}

tryConvert("abc", toCelsius); // 返回空字符串
tryConvert("10.22", toFahrenheit); // 返回'50.396'

根据上面改造之后,TemperatureInput组件已经可以独立的保持输入值在各自的state当中。但是我们希望保持两个输入域的同步,比如输入华氏温度的时候,摄氏温度会自动展示被转换后的温度值。

完整 Demo

React 当中,多个组件之间state的共享,需要将这些state放置到共同的父级组件,这种方式被称为state 状态提升。这个例子中,我们需要将TemperatureInput组件里需要共享的state移动到Calculator组件内,然后通过TemperatureInput组件上的props属性向下分发这些共享数据,最终实现两个TemperatureInput组件内的输入值的同步更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}

handleChange(e) {
// 调用父组件通过props传入进来的事件处理函数,从而实现在子组件更新父组件的state
this.props.onTemperatureChange(e.target.value);
}

render() {
const temperature = this.props.temperature;
const scale = this.props.scale;
return (
<fieldset>
<legend>Enter temperature in {scaleNames[scale]}:</legend>
// 当输入域的值发生变化时,触发本组件内的handleChange事件处理函数
<input value={temperature} onChange={this.handleChange} />
</fieldset>
);
}
}
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
class Calculator extends React.Component {
constructor(props) {
super(props);
this.handleCelsiusChange = this.handleCelsiusChange.bind(this);
this.handleFahrenheitChange = this.handleFahrenheitChange.bind(this);
this.state = { temperature: "", scale: "c" };
}

// 通过props传递给子组件的事件处理函数,让子组件具备修改父组件state的能力
handleCelsiusChange(temperature) {
this.setState({ scale: "c", temperature });
}
handleFahrenheitChange(temperature) {
this.setState({ scale: "f", temperature });
}

render() {
const scale = this.state.scale; // 温度单位
const temperature = this.state.temperature; // 温度值
const celsius = scale === "f" ? tryConvert(temperature, toCelsius) : temperature; // 摄氏温度
const fahrenheit = scale === "c" ? tryConvert(temperature, toFahrenheit) : temperature; // 华氏温度

return (
<div>
<TemperatureInput scale="c" temperature={celsius} onTemperatureChange={this.handleCelsiusChange} />

<TemperatureInput scale="f" temperature={fahrenheit} onTemperatureChange={this.handleFahrenheitChange} />

<BoilingVerdict celsius={parseFloat(celsius)} />
</div>
);
}
}

通常情况下,更新state将会触发组件的重绘,如果多个组件需要共享同一个state,可以考虑将这些state抬升到其共同的父组件当中,然后通过至上而下的数据流来完成state的同步更新。

相比于 Angular、Vue2 原生提供的双向绑定机制,React 当中state状态提升涉及到书写更多的样板代码,但优点在于更加容易探测到一些潜在的 bug,以及在状态变化过程中切入一些处理逻辑,比如上面例子中体现的数字精度控制和输入数据类型校验。

事实上,Vue2 的响应式更新机制是属于组件级别的,而且已经取消了组件内部的state属性,有效避免组件间state互相污染的问题,因此 FB 认为这是优点的说法比较牵强,否则也不会在 Redux 之后有 MobX 的出现。

组合与继承

React 组件拥有强大的组合模型,我们推荐通过组合而非继承来完成组件的复用。

内容包含

默认情况下,许多组件并不了解其子元素的情况(比如侧边栏和对话框组件),这样的情况推荐使用propschildren属性将组件内部嵌套的元素内容直接渲染至组件的输出当中,例如下面就定义了一个使用该属性的组件:

1
2
3
function Border(props) {
return <div className={"border-" + props.color}>{props.children}</div>;
}

接下来,使用 JSX 语法向这个组件内放入任意内容。

1
2
3
4
5
6
7
8
function Dialog() {
return (
<Border color="blue">
<h1 className="title">标题</h1>
<p className="message">内容</p>
</Border>
);
}

最后添加一个额外的样式,为组件的渲染内容呈现一个蓝色的边框。

1
2
3
.blue-border {
border: 10px solid blue;
}

<border />组件内的 JSX 元素内容最终会被渲染到组件内{props.children}所在的位置,最后的结果看起来是下面这样的:

React 当中{props.children}的作用类似于 Vue2 当中的<slot />元素,本质都是为了将嵌入组件的内容,在组件渲染时以合适的方式进行展示。当在需要嵌入多段内容的情况下,Vue2 通过具名插槽<slot name="">来解决这个问题,而 React 解决该问题的方式与 Vue2 类似。

1
2
3
4
5
6
7
8
9
10
11
12
function Box(props) {
return (
<div className="box">
<div className="left"> {props.left} </div>
<div className="right"> {props.right} </div>
</div>
);
}

function App() {
return <Box left={<Contacts />} right={<Chat />} />;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.box {
width: 100%;
height: 100%;
}
.left {
float: left;
width: 30%;
height: 100%;
}
.right {
float: left;
width: 70%;
height: 100%;
}

React 组件本质是一个对象,因此可以将其作为props的属性值进行传递,上面代码的执行结果如下:

特殊化

某些场景下,需要对某个组件进行特殊化处理,比如将Dialog组件具象成为一个WelcomeDialog组件,通常情况大家会首先想到使用继承,但是 React 当中依然可以通过使用组合解决这个问题。即在WelcomeDialog组件内渲染Dialog组件,并通过props属性配置Dialog的行为。

1
2
3
4
5
6
7
8
9
10
11
12
function Dialog(props) {
return (
<Border color="blue">
<h1 className="title"> {props.title} </h1>
<p className="message"> {props.message} </p>
</Border>
);
}

function WelcomeDialog() {
return <Dialog title="欢迎" message="感谢访问!" />;
}

Facebook 开发团队内部已经使用 React 实现了数以千计的组件,但是并未出现需要推荐使用继承结构的用例。通过搭配使用props组合,可以灵活的定制各类组件。另外需要特别注意的是,React 组件可以接受任意类型的props,包括原生的对象或者回调函数,甚至是一个 React 组件对象本身。

而对于非 UI 相关的功能性复用,建议分离到单独的 JavaScript 模块当中,以功能函数、对象或类的方式进行实现。

React 编程思想

React 特别适用于大规模的 JavaScript 应用程序,并且已经在 Facebook 和 Instagram 相关产品上进行了实践。React 最优秀的特性来自于其提出的组件化思想,即将 DOM 页面分片断进行开发,通过 DOM 片断进行业务逻辑和功能层面的复用。组件的拆分可以遵从设计模式中的单一职责原则(single responsibility principle),即一个组件理想状态下只完成一件事情,下面是 React 官网提供的一个商品表格的示例:

组件嵌套结构

1
2
3
4
5
FilterableProductTable
└── SearchBar
└── ProductTable
└── ProductCategoryRow
└── ProductRow

组件功能说明

FilterableProductTable橙色,包含所有组件。 SearchBar蓝色,接收用户输入。 ProductTable绿色,基于用户输入显示和过滤数据集合。 ProductCategoryRow青色,显示分类的标题。 ProductRow红色,显示每款商品。

完整示例代码

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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/* ProductCategoryRow */
class ProductCategoryRow extends React.Component {
render() {
const category = this.props.category;
return (
<tr>
<th colSpan="2">{category}</th>
</tr>
);
}
}

/* ProductRow */
class ProductRow extends React.Component {
render() {
const product = this.props.product;
const name = product.stocked ? product.name : <span style={{ color: "red" }}> {product.name} </span>;

return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
}
}

/* ProductTable */
class ProductTable extends React.Component {
render() {
const filterText = this.props.filterText;
const inStockOnly = this.props.inStockOnly;

const rows = [];
let lastCategory = null;

this.props.products.forEach(product => {
if (product.name.indexOf(filterText) === -1) {
return;
}
if (inStockOnly && !product.stocked) {
return;
}
if (product.category !== lastCategory) {
rows.push(<ProductCategoryRow category={product.category} key={product.category} />);
}
rows.push(<ProductRow product={product} key={product.name} />);
lastCategory = product.category;
});

return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}
}

/* SearchBar */
class SearchBar extends React.Component {
constructor(props) {
super(props);
this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
this.handleInStockChange = this.handleInStockChange.bind(this);
}

handleFilterTextChange(e) {
this.props.onFilterTextChange(e.target.value);
}

handleInStockChange(e) {
this.props.onInStockChange(e.target.checked);
}

render() {
return (
<form>
<input type="text" placeholder="Search..." value={this.props.filterText} onChange={this.handleFilterTextChange} />
<p>
<input type="checkbox" checked={this.props.inStockOnly} onChange={this.handleInStockChange} /> Only show products in stock
</p>
</form>
);
}
}

/* FilterableProductTable */
class FilterableProductTable extends React.Component {
constructor(props) {
super(props);
this.state = {
filterText: "",
inStockOnly: false
};

this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
this.handleInStockChange = this.handleInStockChange.bind(this);
}

handleFilterTextChange(filterText) {
this.setState({
filterText: filterText
});
}

handleInStockChange(inStockOnly) {
this.setState({
inStockOnly: inStockOnly
});
}

render() {
return (
<div>
<SearchBar filterText={this.state.filterText} inStockOnly={this.state.inStockOnly} onFilterTextChange={this.handleFilterTextChange} onInStockChange={this.handleInStockChange} />
<ProductTable products={this.props.products} filterText={this.state.filterText} inStockOnly={this.state.inStockOnly} />
</div>
);
}
}

/* JSON API */
const PRODUCTS = [{ category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football" }, { category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball" }, { category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball" }, { category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch" }, { category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5" }, { category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7" }];

ReactDOM.render(<FilterableProductTable products={PRODUCTS} />, document.getElementById("app"));

React 拥有 2 种不同类型的模型数据Model):propsstate

深入 JSX

本质上而言,JSX 其实是React.createElement(component, props, ...children)函数的语法糖。

1
2
3
4
5
6
7
8
9
10
11
// JSX
<MyButton color="blue" shadowSize={2}>
点击我
</MyButton>

// 等效的React.createElement()
React.createElement(
MyButton,
{ color: 'blue', shadowSize: 2 },
'点击我'
)
1
2
3
4
5
6
7
8
9
// 使用自关闭标签的JSX
<div className="sidebar" />

// 上面JSX会被编译为如下代码
React.createElement(
'div',
{ className: 'sidebar' },
null
)

指定 React 的元素类型

React 当中,可以将组件赋值给一个变量或者常量,如果代码中使用名为<Test>的组件,则组件对应的Test变量必须位于当前组件的作用域内。此外,定义组件时必须显式引入React库,即使当前组件没有直接对其进行引用。

1
2
3
4
5
6
7
import React from "react"; // 这样的引用是必须的
import CustomButton from "./CustomButton";

function WarningButton() {
return <CustomButton color="red" />;
// return React.createElement(CustomButton, {color: 'red'}, null);
}

当一个模块需要export多个 React 组件时,可以将这些组件定义为一个对象的属性之后导出,然后 JSX 内使用时通过.操作符进行引用。

1
2
3
4
5
6
7
8
9
10
11
12
import React from "react";

const MyComponents = {
DatePicker: function DatePicker(props) {
return <div>Imagine a {props.color} datepicker here.</div>;
}
};

function BlueDatePicker() {
// 通过MyComponents.DatePicker引用上面对象MyComponents内定义的DatePicker组件
return <MyComponents.DatePicker color="blue" />;
}

用户自定义组件的名称首字母必须大写,以便于在字面上与原生的<v><span>进行有效区分。

1
2
3
4
5
6
7
8
9
10
11
12
import React from "react";

// 正确!自定义组件首字母大写。
function Hello(props) {
// 正确!原生HTML标签是小写的。
return <div>Hello {props.toWhat}</div>;
}

function HelloWorld() {
// 正确! React能够正确识别首字母大写的自定义组件。
return <Hello toWhat="World" />;
}

不能以 React 元素的方式使用 JavaScript 表达式,例如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
import React from 'react';
import { PhotoStory, VideoStory } from './stories';

const components = {
photo: PhotoStory,
video: VideoStory
};

function Story(props) {
// 错误,JSX类型不能是一个表达式。
return <components[props.storyType] story={props.story} />;
}

解决上面问题,需要将表达式赋值给一个首字母大写的变量,参见下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from "react";
import { PhotoStory, VideoStory } from "./stories";

const components = {
photo: PhotoStory,
video: VideoStory
};

function Story(props) {
// 正确!JSX类型可以是一个首字母大写的变量。
const SpecificStory = components[props.storyType];
return <SpecificStory story={props.story} />;
}

JSX 中的 props

以 JavaScript 表达式的方式

开发人员可以通过{}传递任意 JavaScript 表达式到prpps

1
2
// MyComponent组件的props.foo的值为10
<MyComponent foo={1 + 2 + 3 + 4} />

iffor语句并不属于 JavaScript 中的表达式,因此可以直接用于 JSX。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function contextSwitching(props) {
let name;
if (props.context == "internet") {
name = <i>Uinika</i>;
} else if (props.context === "reallife") {
name = <i>Hank</i>;
}
return (
<p>
在{props.context}
里我叫
{name}
</p>
);
}

字符串字面量

可以向 props 传递字符串字面量,下面的两个 JSX 是等效的。

1
2
3
<MyComponent message="Hello React16!" />

<MyComponent message={'Hello React16!'} />

传递的字符串变量可以是非 HTML 转义的,因此下面的两个 JSX 表达式仍然是等效的。

1
2
3
<MyComponent message="&lt;5" />

<MyComponent message={'<5'} />

props 默认为 true

如果没有向组件的 props 传递值(声明 props 但并未进行赋值),则该props 的值默认为true,下面的两行代码因此是等效的:

1
2
3
<MyTextBox autocomplete />

<MyTextBox autocomplete={true} />

通常情况并不建议缺省 props 的值,因为这样容易与 ES6 的对象快捷声明特性,语法上发生混淆。

props 对象扩展运算

如果你的props是一个对象,可以考虑使用 ES6 的对象扩展运算符...,将所有的props一次性传入组件。

1
2
3
4
5
6
7
8
function Component1() {
return <Hello firstName="Hank" lastName="Zen" />;
}

function Component2() {
const props = { firstName: "Hank", lastName: "Zen" };
return <Hello {...props} />;
}

你还可以让组件使用特定的props,然后通过对象扩展运算符传递其它所有props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Button = props => {
const { kind, ...all } = props;
const className = kind === "primary" ? "btn-primary" : "btn-default";
return <button className={className} {...all} />;
};

const App = () => {
return (
<div>
<Button kind="primary" onClick={() => console.log("被点击了!")}>
Hello React 16.2!
</Button>
</div>
);
};

上面例子中的{ kind, ...all }只会获取 props 中的kind属性,然后将props中其它属性全部赋值给...all

对象扩展运算符是非常有用的工具,但是容易将一些不必要的props传递给组件,因此建议酌情根据需要进行使用。

JSX 的 children

JSX 表达式开始、结束标签内的内容会以特殊的 props 形式传递:props.children,React 有几种不同的方式去传递这些children

字符串字面量

在 JSX 开始和结束标签内直接书写字符串,props.children的值就是这段字符串内容。字符串的内容可以是非 HTML 转义的,因此编写 JSX 就像编写 HTML 一样。

1
2
3
4
5
// MyComponent组件的props.children就是"Hello React 16!"
<MyComponent>Hello React 16!</MyComponent>

// JSX内可以直接书写HTML字符实体
<div>Hank &amp; Github.</div>

JSX 会自动移除开始和结束行的空格,标签附近的新的行也会被同时移除,标签内部内容当中出现的空格会被缩进为一个空格,所以下面 JSX 代码的渲染结果都相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<p>Hello React 16!</p>

<p>
Hello React 16!
</p>

<p>
Hello
React 16!
</p>

<p>

Hello React 16!
</p>

嵌套的 JSX

JSX 开始结束标签内依然可以使用其它标签作为子元素,从而能够以嵌套的使用各类 React 组件和 HTML 元素。

1
2
3
4
<MyContainer>
<MyFirstComponent />
<MySecondComponent />
</MyContainer>

React16 带来的一个重要新特性之一是:组件可以直接返回一个数组元素

1
2
3
4
5
6
7
8
9
render() {
// 毋需将多个列表元素包裹到一个元素当中返回,这样可以防止破坏HTML页面语义化
return [
// 一定要记得为每个列表元素添加唯一的key
<li key="A">First item</li>,
<li key="B">Second item</li>,
<li key="C">Third item</li>,
];
}

JavaScript 表达式作为子元素

React 可以通过{}运算符使用任意 JavaScript 表达式作为 JSX 子元素,例如下面两个表达式就是等效的:

1
2
3
<MyComponent>foo</MyComponent>

<MyComponent>{'foo'}</MyComponent>

这在渲染任意长度的 JSX 表达式列表时非常有用,请参见下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
function Item(props) {
return <li>{props.message}</li>;
}

function List() {
const todos = ['工作', '生活', '运动''早睡早起'];
return (
<ul>
{todos.map((message) => <Item key={message} message={message} />)}
</ul>
);
}

JavaScript 表达式可以与其它类型子元素混用,这在为模板绑定数据的时候非常有用。

1
2
3
function Hello(props) {
return <div>Hello {props.addressee}!</div>;
}

函数作为子元素

props属性一样,props.children可以传递任意类型的数据,组件会在渲染前解析props.children中的内容。例如,可以通过props.children向一个自定义组件传递回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
// Calls the children callback numTimes to produce a repeated component
function Repeat(props) {
let items = [];
for (let i = 0; i < props.numTimes; i++) {
items.push(props.children(i));
}
return <div>{items}</div>;
}

function ListOfTenThings() {
return <Repeat numTimes={10}>{index => <div key={index}>This is item {index} in the list</div>}</Repeat>;
}

上面的方法日常开发中并不常用,但是在一些需要对 JSX 功能进行扩展的的场景下还是非常有用的。

boolean、null、undefined 会被忽略

booleannullundefined都是合法的子元素,这些类型的内容不会被渲染,因此下面例子中的 JSX 会渲染相同的结果:

1
2
3
4
5
6
7
8
9
10
11
<div />

<div></div>

<div>{false}</div>

<div>{null}</div>

<div>{undefined}</div>

<div>{true}</div>

这对于条件运算是非常有用的,下面的 JSX 当showHeadertrue时只会渲染出一个<Header />

1
2
3
4
<div>
{showHeader && <Header />}
<Content />
</div>

值得注意的是,数字0布尔运算中通常被判断为假值)会被 React 原样渲染,例如当下面代码中的props.messages是一个空数组的时候,数值0将会被展示到页面上。

1
<div>{props.messages.length && <MessageList messages={props.messages} />}</div>

解决这个问题,需要显式的使用布尔运算符&&,将上面的代码修改成下面这样:

1
<div>{props.messages.length > 0 && <MessageList messages={props.messages} />}</div>

与此相反,如果需要将falsetruenullundefined之类的值展示到页面,需要首先将这些值转换为字符串。

1
<div>My JavaScript variable is {String(myVariable)}.</div>

PropTypes 类型检查

从伴随应用程序规模的增长,需要进行大量的类型检查工作,因此 React 内建了组件props类型检查机制。但是从 React v15.5 开始,React.PropTypes被迁移到单独的prop-types包。

1
2
3
4
5
6
7
8
9
10
11
12
import React from "react";
import PropTypes from "prop-types";

class Component extends React.Component {
render() {
return <div>{this.props.text}</div>;
}
}

Component.propTypes = {
text: PropTypes.string.isRequired
};

PropTypes对象上暴露了一系列校验器,用来确保当前组件接收的数据是合法的,例如上面代码中的PropTypes.string.isRequired,当props的值非法时,浏览器控制台将会接收到警告信息。

出于性能方面的考量,PropTypes 类型检查只工作在开发模式下。

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
import PropTypes from "prop-types";

MyComponent.propTypes = {
// 可以将PropTypes声明为JS数据类型。
optionalArray: PropTypes.array,
optionalBool: PropTypes.bool,
optionalFunc: PropTypes.func,
optionalNumber: PropTypes.number,
optionalObject: PropTypes.object,
optionalString: PropTypes.string,
optionalSymbol: PropTypes.symbol,

// 所有能够被React渲染的内容,例如:numbers, strings, elements, array以及fragment。
optionalNode: PropTypes.node,

// 一个React元素。
optionalElement: PropTypes.element,

// 通过instanceof操作符将prop声明为一个类的实例。
optionalMessage: PropTypes.instanceOf(Message),

// 确保props是指定枚举类型中的值。
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
}),

// 可以链式调用isRequired,指定props缺省时会打印警告信息。
requiredFunc: PropTypes.func.isRequired,

// 任意数据类型
requiredAny: PropTypes.any.isRequired,

// 自定义的校验器,校验失败返回一个Error对象(不要直接console.warn或者throw)。
customProp: function(props, propName, componentName) {
if (!/matchme/.test(props[propName])) {
return new Error("不合法的prop `" + propName + "` 被应用到" + " `" + componentName + "`. 校验失败.");
}
},

// 在arrayOf和objectOf上指定自定义的校验器,校验失败同样返回一个Error对象,校验器会在array或object的每个属性上得到调用,校验器第1个参数是array或object本身,第2个参数是当前项的key值。
customArrayProp: PropTypes.arrayOf(function(propValue, key, componentName, location, propFullName) {
if (!/matchme/.test(propValue[key])) {
return new Error("不合法的prop `" + propFullName + "` 被应用到" + " `" + componentName + "`. 校验失败.");
}
})
};

需要单一的子元素

通过PropTypes.element可以指定当前组件只能拥有一个单一的子元素,否则将会出现告警信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
import PropTypes from "prop-types";

class MyComponent extends React.Component {
render() {
// 只能拥有一个单一的子元素,否则将会打印告警信息。
const children = this.props.children;
return <div>{children}</div>;
}
}

MyComponent.propTypes = {
children: PropTypes.element.isRequired
};

默认的 props 值

可以通过 React 组件的defaultProps属性为props指定默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Demo extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}

// 为props指定默认值
Demo.defaultProps = {
name: "React!"
};

// 渲染出"Hello, React!":
ReactDOM.render(<Demo />, document.getElementById("app"));

如果你使用了 Babel 的transform-class-properties插件,就可以方便的通过 React 组件类的静态属性来声明默认值,这个语法在 ES6 规范中还没有稳定,因此需要在 Babel 进行编译后才能在浏览器中正常工作。

1
2
3
4
5
6
7
8
9
10
class Demo extends React.Component {
// 通过静态属性来声明默认值
static defaultProps = {
name: "React!"
};

render() {
return <div>Hello, {this.props.name}</div>;
}
}

上面代码中的defaultProps属性用来确保this.props.name总是会拥有一个缺省值,propTypes 检查发生在defaultProps属性被解析之后,因此类型检查机制依然可以应用到defaultProps上面

开发环境下,还可以通过FlowTypeScript进行静态的数据类型检查,可以方便的在代码运行之前检测到数据类型方面的问题。

Refs 和 DOM

React 组件数据流当中,父组件向下与子组件沟通的唯一方式是通过props,传入新的props值然后子组件被重新渲染。某些场景下(管理输入聚焦、文本选择、多媒体回放,触发命令式动画,整合第 3 方 DOM 类库。),需要在 React 组件数据流范围之外对子元素(即可能是 React 组件,也可能是 DOM 元素)进行修改,为此 React 提供了ref组件属性来满足这种需求。

添加关于 DOM 元素的 ref 属性

React 提供的ref属性可以添加到任意组件,ref属性接收一个回调函数,该函数会在组件mountedunmounted后执行。

ref属性应用于 HTML 元素的时候,ref回调函数会接收到该元素对应的 DOM 对象,例如下面的代码就通过ref存储一个 DOM 结点的引用。

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 CustomTextInput extends React.Component {
constructor(props) {
super(props);
this.focusTextInput = this.focusTextInput.bind(this);
}

focusTextInput() {
// 使用原生DOM API显式的聚焦到文本输入框
this.textInput.focus();
}

render() {
// 使用ref回调函数,保存一个文本输入域DOM元素的引用到实例属性,比如this.textInput
return (
<div>
<input
type="text"
ref={input => {
this.textInput = input;
}}
/>
<input type="button" value="Focus the text input" onClick={this.focusTextInput} />
</div>
);
}
}

React 会在组件挂载的时候调用ref上的回调函数,然后在组件卸载时将该ref赋值为null;因此,ref上的回调函数先于componentDidMountcomponentDidUpdate生命周期函数执行

通过ref回调来设置类上的某个属性是 React 操作局部 DOM 的常见方式,这里推荐使用上面例子中的行内箭头函数ref={input => this.textInput = input}

将 ref 属性引用到当前类组件

ref属性用于自定义类组件的时候,ref回调函数的参数将会接收到被挂载组件的实例,接下来我们为前面的CustomTextInput组件模拟组件挂载后被点击的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class AutoFocusTextInput extends React.Component {
componentDidMount() {
this.textInput.focusTextInput();
}

render() {
return (
<CustomTextInput
ref={input => {
this.textInput = input;
}}
/>
);
}
}
  • 上面代码只能工作在CustomTextInput以类组件进行声明的时候。
1
class CustomTextInput extends React.Component { // ... }

ref 与函数式组件

因为函数式组件并不拥有实例对象,因此不可以在ref回调函数中使用this进行赋值操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function MyFunctionalComponent() {
return <input />;
}

class Parent extends React.Component {
render() {
// 下面的代码将不会工作!
return (
<MyFunctionalComponent
ref={input => {
this.textInput = input;
}}
/>
);
}
}

但是可以在ref回调函数中通过变量来引用当前组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function CustomTextInput(props) {
// 变量textInput必须先进行声明,以便后续的ref回调函数能够访问到它。
let textInput = null;

function handleClick() {
textInput.focus();
}

return (
<div>
<input
type="text"
ref={input => {
textInput = input;
}}
/>
<input type="button" value="Focus the text input" onClick={handleClick} />
</div>
);
}

暴露子组件的 DOM 引用到父组件

极少的情况下(触发子组件的 focus 事件以及尺寸和位置),开发人员需要在父组件访问子组件的 DOM 节点(虽然 React 并不推荐这么做,因为这样会破坏组件的封装性)。

虽然你可以添加一个 ref 到子组件,但这并不是一个理想的解决方案,因为你只会获取到组件实例而非 DOM 节点,而且这样也无法用于函数类型组件。因此,这里推荐在子组件内暴露一个特殊的prop,使子组件能够通过该prop接收到一个任意名称的函数(例如下面函数中的 inputRef),然后通过ref属性将该函数关联到 DOM 节点,最终使得父组件能够通过一个中间层级组件传递其ref回调函数至 DOM 节点,并且这种方式能够同时应用在类组件和函数组件当中,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 中间层级组件
function CustomTextInput(props) {
return (
<div>
<input ref={props.inputRef} />
</div>
);
}

class Parent extends React.Component {
render() {
return <CustomTextInput inputRef={el => (this.inputElement = el)} />;
}
}

在上面的例子当中,Parent组件通过CustomTextInput组件的prop.inputRef来传递ref回调函数,而CustomTextInput组件又将该回调函数传递给<input>。因此,Parent组件中的this.inputElement将会被设置为CustomTextInput组件内<input>所对应的 DOM 结点(非常重要)。

除了可以同时更加广泛的用于函数式组件和类组件,这种模式的另一个优点在于适用于任意嵌套深度的组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 子组件
function CustomTextInput(props) {
return (
<div>
<input ref={props.inputRef} />
</div>
);
}

// 父组件
function Parent(props) {
return (
<div>
My input: <CustomTextInput inputRef={props.inputRef} />
</div>
);
}

// 祖父组件
class Grandparent extends React.Component {
render() {
return <Parent inputRef={el => (this.inputElement = el)} />;
}
}

上面例子中,Grandparent组件需要操纵CustomTextInput组件的 DOM,只需要通过Parentprops进行一次赋值传递,从而让Grandparent组件中的this.inputElement被设置为CustomTextInput组件当中的<input>元素的 DOM。

出于更全面的考虑,React 官方并不建议直接暴露 DOM 节点对象,但是可以作为一种应急的处理方式。而且这种方式,需要向子组件添加一些功能代码,如果不希望对子组件造成污染,另一个选择是使用ReactDOM.findDOMNode(component)方法。

遗留 API:字符串类型的 ref 属性

如果使用早期版本的 React,你可能会熟悉在组件上使用字符串类型的ref属性,例如<input type="text" ref="textInput" />元素可以通过this.refs.textInput获取其 DOM 节点,但是目前 React 官方不建议这样做,因为存在一些悬而末决的问题,并且可能在未来 React 发布版本中被移除,所以建议通过上面回调函数的模式去使用ref

附加说明

如果ref属性是通过行内函数进行定义的,那么在组件更新的时候它将会被调用两次(第 1 次值为null,第 2 次为 DOM 元素),这是因为组件渲染时会建立函数对象的新实例,React 需要清除旧的ref然后设置新的。我们可以通过将ref回调函数定义为类组件方法避免该问题,但是大部份情况下这并不会对开发和用户体验造成影响。

非受控组件

大多数情况下,我们推荐使用受控组件去实现表单,即表单数据由 React 组件所控制。另一种方式是使用非受控组件,即表单数据由 DOM 对象所控制。

使用非受控组件,可以通过一个ref从 DOM 获取表单值,代替为组件的每次状态更新编写事件处理器,下面示例将会接收一个用户输入的字符串然后弹出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class NameForm extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}

handleSubmit(event) {
alert("被提交的字符串:" + this.input.value);
event.preventDefault();
}

render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
输入的字符串:
<input type="text" ref={input => (this.input = input)} />
</label>
<input type="submit" value="提交" />
</form>
);
}
}

非受控组件能够更加容易的整合 React 以及非 React 代码,而且代码更加精简与小巧,言外之意 React 官方推荐通常情况应使用非受控组件。

默认值

在 React 组件的渲染生命周期中,form元素上的value属性将会重写 DOM 上的value属性值。使用非受控组件的时候,通常会希望 React 指定一个能够避免后续非受控更新的初始值,这里需要使用defaultValue来代替原生的value属性。

1
2
3
4
5
6
7
8
9
10
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
名称:<input defaultValue="Hank" ref={(input) => this.input = input} type="text" />
</label>
<input type="submit" value="提交" />
</form>
);
}

同样的,<input type="checkbox"><input type="radio">支持defaultChecked<select><textarea>支持defaultValue

文件上传

React 中的<input type="file">总是属于非受控组件,因为其值只能被用户设置,而非编程控制。

我们可以通过 JavaScript 原生的File API对上传文件进行操作,下面的例子体现了如何通过引用 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
28
29
30
class FileInput extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(event) {
event.preventDefault();
alert(`Selected file - ${this.fileInput.files[0].name}`);
}

render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
上传文件:
<input
type="file"
ref={input => {
this.fileInput = input;
}}
/>
</label>
<br />
<button type="submit">提交文件</button>
</form>
);
}
}

ReactDOM.render(<FileInput />, document.getElementById("app"));

Fragments 片断

React 组件有时需要返回多个元素,新特性React.Fragment可以在不增加冗余 DOM 节点的情况下,聚合一系列(多个)子元素到 DOM 上去。

1
2
3
4
5
6
7
8
9
render() {
return (
<React.Fragment>
<ChildA />
<ChildB />
<ChildC />
</React.Fragment>
);
}

动机

当组件需要返回一个列表时,通用的处理方式如下:

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
// Table组件需要Columns组件渲染单个的表格的数据
class Table extends React.Component {
render() {
return (
<table>
<tr>
<Columns />
</tr>
</table>
);
}
}

// Columns组件需要返回多个<td>元素,让生成的HTML合法可用;但是过去React返回的元素必须拥有一个根元素,因此不得不加上<div>标签。
class Columns extends React.Component {
render() {
return (
<div>
<td>Hello</td>
<td>World</td>
</div>
);
}
}

// 最终渲染的结果如下,
<table>
<tr>
<div>
<td>Hello</td>
<td>World</td>
</div>
</tr>
</table>;

冗余的<div>元素嵌套在<tr>元素下并不合乎 HTML 规范,因此 React 引入React.Fragment新特性解决这个通点。

用法

使用<React.Fragment>改写上面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Columns extends React.Component {
render() {
return (
<React.Fragment>
<td>Hello</td>
<td>World</td>
</React.Fragment>
);
}
}

// 最终的输出结果没有冗余的<div>标签
<table>
<tr>
<td>Hello</td>
<td>World</td>
</tr>
</table>;

快捷语法

React 16 当中,我们可以使用新添加的fragment快捷语法<></>

1
2
3
4
5
6
7
8
9
10
class Columns extends React.Component {
render() {
return (
<>
<td>Hello</td>
<td>World</td>
</>
);
}
}

Babel 之类的编译工具可能暂不支持fragment快捷语法,因此未受支持的场合可以继续使用<React.Fragment>

带 key 属性的 fragment

<React.Fragment>可以拥有一个key属性,用于映射一个集合到 fragment 数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Production(props) {
return (
<dl>
{props.items.map(item => (
// 如果没有提供key属性, React将会提示关于key的警告信息
<React.Fragment key={item.id}>
<dt>{item.name}</dt>
<dd>{item.info}</dd>
</React.Fragment>
))}
</dl>
);
}

key是可以传入Fragment的唯一属性,未来 React 官方可能会增加更为丰富的属性,比如对事件提供支持。

Portals 传送门

Portal([ˈpɔ:tl] 入口,门户,传送门)用于渲染子元素到一个 DOM 节点,该 DOM 节点可以位于已存在的父元素 DOM 继承树之外。

1
ReactDOM.createPortal(child, container);

参数child是任意可渲染的 React 子元素(element、string、fragment),而参数container则是一个指定的 DOM 元素。

用法

通常,当你从一个组件的 render()方法返回 HTML 元素的时候,这些元素将会被挂载到相邻父节点 DOM 下面。

1
2
3
4
5
6
7
8
render() {
// React将会渲染this.props.children到div下面
return (
<div>
{this.props.children}
</div>
);
}

但是,有时需要插入一个子元素到 DOM 上的不同位置。

1
2
3
4
5
6
7
render() {
// React不会建立新的div,只会渲染this.props.children到domNode(可以是任意可用的DOM节点,不管其位于DOM中哪个位置)。
return ReactDOM.createPortal(
this.props.children,
domNode,
);
}

Portals 可以应用在父组件设置overflow: hiddenz-index样式,子组件需要在视觉上打破其容器(即在指定位置进行层叠展示,例如:对话框、提示信息、浮动卡片)的场景下。

事件冒泡

Portal 可以用于 DOM 树任意位置,其行为类似于普通 React 组件。无论子元素是否是一个 Protal,其上下文特性都是相同的(因为 Protal 仍然存在于 React 组件树当中,而无论其在 DOM 中的真实位置如何),这其中就包括了事件冒泡。

下面例子中,Portal 内触发的事件将会冒泡至 React 组件树的祖先元素,即使它们并不是 DOM 结构意义上的祖先元素:

1
2
3
4
5
6
<html>
<body>
<div id="app-root" />
<div id="modal-root" />
</body>
</html>

上面的 HTML 结构当中,父组件中的#app-root应用根节点)将会响应兄弟节点#modal-root模态框节点)上的捕获或者冒泡事件。

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
/* 下面2个容器都是DOM节点上的兄弟元素。 */
const appRoot = document.getElementById("app-root");
const modalRoot = document.getElementById("modal-root");

/* 封装了按钮元素的Child组件 */

function Child() {
// button元素上的click事件将会冒泡至Parent组件。
return (
<div className="modal">
<button>点击我!</button>
</div>
);
}

/* 模态框Modal组件 */
class Modal extends React.Component {
constructor(props) {
super(props);
this.el = document.createElement("div");
}

componentDidMount() {
// 当Modal的子元素被挂载以后,Portal元素将会被插入到DOM树上,这意味这些子元素将会被添加到真实的DOM结点上。
modalRoot.appendChild(this.el);
}
componentWillUnmount() {
modalRoot.removeChild(this.el);
}

render() {
// Modal组件返回Portal
return ReactDOM.createPortal(this.props.children, this.el);
}
}

/* 模态框Parent组件 */
class Parent extends React.Component {
constructor(props) {
super(props);
this.state = { clicks: 0 };
this.handleClick = this.handleClick.bind(this);
}

handleClick() {
// 当Child组件的button被点击时,该事件处理函数将会被触发并更新Parent组件的state,即使button并非DOM结构上的直接子孙元素。
this.setState(prevState => ({
clicks: prevState.clicks + 1
}));
}

render() {
return (
<div onClick={this.handleClick}>
<p>当前点击次数: {this.state.clicks}</p>
<Modal>
<Child />
</Modal>
</div>
);
}
}

ReactDOM.render(<Parent />, appRoot);

最终生成的 HTML DOM 结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<body>
<div id="app-root">
<div>
<p>当前点击次数: 0</p>
</div>
</div>
<div id="modal-root">
<div>
<div class="modal">
<button>点击我!</button>
</div>
</div>
</div>
</body>

从父组件内的 Portal 获取事件冒泡,能够让开发更加灵活和抽象,但是这些抽象并不依赖于 Portal。例如渲染<Modal />组件时,Parent组件能够捕获它的事件,无论其是否通过 Portal 实现。

Web Components

ReactWeb Components分别用来解决不同问题,Web Components为组件复用提供了强大的封装机制,而React则侧重于保持数据与 DOM 的同步,两者相互补充;开发人员可以自由的对两者进行混合使用,尽管开发人员大部分情况只需要使用React,但是不排除第三方组件使用到Web Components

在 React 中使用 Web Components

Web Components通常需要暴露出命令式 API,例如一个 Video 作为Web Components,可能需要暴露play()pause()两个 API,操作这些命令式 API 需要通过一个引用直接与 DOM 节点进行交互。如果你正在使用第三方提供的Web Components,最好的解决方式是使用 React 组件包裹Web Components

Web Components产生的事件可能不会在 React 的渲染树上正确的进行传播,开发人员将需要在 React 组件当中手动的添加事件处理函数。

1
2
3
4
5
6
7
8
9
class HelloMessage extends React.Component {
render() {
return (
<div>
Hello <x-search>{this.props.name}</x-search>!
</div>
);
}
}

一个比较常见的混淆是Web Components使用了class去替代className

1
2
3
4
5
6
7
8
function BrickFlipbox() {
return (
<brick-flipbox class="demo">
<div>front</div>
<div>back</div>
</brick-flipbox>
);
}

在 Web Components 中使用 React

下面的示例代码不能工作在使用 Babel 转译的环境,你可以点击这里查看相关 issue。也可以在加载 Web Components 之前,通过名为custom-elements-es5-adapterpolyfill解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
class XSearch extends HTMLElement {
connectedCallback() {
const mountPoint = document.createElement("span");
this.attachShadow({ mode: "open" }).appendChild(mountPoint);

const name = this.getAttribute("name");
const url = "https://www.google.com/search?q=" + encodeURIComponent(name);
ReactDOM.render(<a href={url}>{name}</a>, mountPoint);
}
}
customElements.define("x-search", XSearch);

错误边界

早期 React 版本当中的 JavaScript 错误经常会破坏 React 的内部状态,从而导致整个 Web 应用程序崩溃。为了解决这一问题,新版本的 React 16 引入了一个全新的错误边界或译为错误分界线)特性。

错误边界是一种用于在 React 组件当中捕捉并打印 JavaScript 错误,并显示回调 UI 界面的错误处理机制,可以广泛应用于组件渲染函数生命周期方法类组件构造器当中

错误边界不能用于事件处理函数异步处理代码服务器端渲染错误边界机制本身抛出的错误一类的场景。

使用 React 16 新增的生命周期方法componentDidCatch(error, info)即可以使一个 React 组件具备错误边界捕获能力。

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

componentDidCatch(error, info) {
// 显示UI界面回调
this.setState({ hasError: true });
// 打印错误信息
logErrorToMyService(error, info);
}

render() {
if (this.state.hasError) {
// 可以渲染任意自定义的UI界面回调
return <h1>提示:发生错误了!</h1>;
}
return this.props.children;
}
}

然后可以像 React 常规组件那样使用它。

1
2
3
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>

componentDidCatch()方法类似于 JavaScript 的catch{}语法,但是对于 React 组件而言,只有类组件能够拥有捕获错误边界的能力。不过实际开发场景下,大部分情况只需定义一个通用的错误边界组件,然后在 Web 应用程序其它组件内进行复用。

错误边界组件只能捕获其子组件当中发生的错误,并不能捕获错误边界组件自身产生的问题,例如其自身渲染错误提示信息失败时,此错误会传播到最近的父级错误边界组件处理,此特性与 JavaScript 的catch{}较为相似。

componentDidCatch(error, info)的参数

error是抛出的错误信息;info是一个使用componentStack作为 key 的对象,抛出错误时该属性包含组件堆栈的相关信息。

1
2
3
4
5
6
7
8
9
10
componentDidCatch(error, info) {

/* 组件堆栈信息示例:
in ComponentThatThrows (created by App)
in ErrorBoundary (created by App)
in div (created by App)
in App
*/
logComponentStackToMyService(info.componentStack);
}

错误边界放置位置

错误边界组件的放置位置完全取决于开发人员的使用习惯,可以放置在顶级路由组件的最外层向用户展示错误信息,也可以用来包裹单独的组件,有效防止单个组件错误引发整个 Web 应用崩溃。

错误捕获的新行为

从 React16 开始,没有被任何错误边界捕获的错误将导致整个 React 组件树都被卸载。

Facebook 内部对该决定进行了讨论,在我们的经验中,离开损坏的 UI 比完全删除它的用户体验更加糟糕。例如,像 Messenger 这样的产品中,用户可以看到被破坏的 UI,这可能会导致有人向错误的人发送消息。类似地,支付应用程序显示错误的数量比不提供任何东西的用户体验更加糟糕。

这种变化意味着从老版本迁移到 React 16 时,可能会发现应用程序中存在被忽略的崩溃性错误,因此添加错误边界可以在出现问题时提供更好的用户体验。

例如,Facebook 的 Messenger 将侧边栏、信息面板、对话日志、消息输入内容封装到单独的错误边界中。如果这些 UI 区域中的某个组件崩溃,剩下的部分仍然能够正常响应用户的交互。

组件堆栈记录

React 16 可以自动打印开发时产生的错误至浏览器控制台,除了错误信息和 JavaScript 堆栈之外,还提供了组件堆栈记录,让开发人员能够更加清晰的了解组件树中发生的故障。该特性只用于开发,生产中必须禁用

如果 Web 应用是由create-react-app搭建,或是手动安装了babel-plugin-transform-react-jsx-source插件,组件堆栈记录当中还能够展示文件名行号

堆栈记录当中组件名称的展示依赖于 JavaScript 原生的Function.name属性,如果使用 IE11 等还未支持该属性的浏览器,就需要单独安装Function.name Polyfill进行兼容,或者在组件定义时显式的设置displayName属性。

使用 try/catch

try/catch只对命令式代码有效,但是 React 组件都是声明式的,并且能够指定渲染的内容。

1
2
3
4
5
6
7
try {
showButton();
} catch (error) {
// ...
}

<Button />;

错误边界保留了 React 的声明特性,让代码按照预期的方式执行。例如在componentdidupdate()生命周期方法内使用setState()出现错误,这些错误仍将正确传播到最近的错误边界

使用事件处理器

错误边界不能捕捉到事件处理函数内发生的错误,因为 React 并不需要处理事件函数内产生的错误。不同于render()和其它组件生命周期函数,组件内的事件处理函数不会在组件渲染期间得到执行。因此当有错误被抛出的时候,React 会将其显示到屏幕上。

如果需要在事件处理函数内捕捉错误信息,建议使用 JavaScript 传统的try/catch语句。

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

handleClick = () => {
try {
// 抛出错误的代码
} catch (error) {
this.setState({ error });
}
};

render() {
if (this.state.error) {
return <h1>捕捉到错误!</h1>;
}
return <div onClick={this.handleClick}>点击我!</div>;
}
}

Reconciliation 调和

Reconciliation[,rek(ə)nsɪlɪ'eɪʃ(ə)n] n.调和)是 React 提供的一种比较算法,能够让 React 组件的更新可以预测,并且提供了更优秀的 DOM 渲染性能。

使用 React 的过程中,可以通过render()函数来创建 React 元素。当stateprops更新时将返回不同的 React 元素树,此时 React 需要考量如何更加高效的将变化反映到 UI 上去。

对于将一棵树状数据结构同步到另外一棵树的最小操作数算法问题,虽然有一些通用的解决方案,比如art 算法参见《关于树的编辑深度与相关问题的研究》)复杂度为O(n3),其中n是 React 元素树上元素的个数。如果在 React 中使用该算法,显示 1000 个元素将需要 10 亿次比较操作,性能开销极为昂贵。

因此,React 根据如下 2 个假设实现了一套启发式O(n)算法:

  1. 不同类型的 2 个元素会产生不同的树。
  2. 通过名为keyprops来提示哪些子元素在不同渲染过程中是稳定的。

在实践中,上述假设对于几乎所有用例都是有效的。

Diffing 算法

比较两颗树的时候,React 首先会比较其根元素,根据根元素的类型来判断行为的不同。

不同类型的元素

每当根元素类型不同时,React 都会推倒旧的树并从头构建新的树。从<a><img><Article><Comment><Button><div>,这些情况都会导致推倒重建。

React 推倒旧树意味其 DOM 节点将被销毁,组件实例执行componentWillUnmount()方法。构建新树意味新的 DOM 节点被插入,组件实例执行componentWillMount()以及componentDidMount()方法,此时旧树上关联的state将会完全消失。

下面例子当中,旧的Counter组件会被卸载,其状态也将被销毁,然后新的Counter组件将会被挂载至 DOM。

1
2
3
4
5
6
7
8
9
/* 旧树 */
<div>
<Counter />
</div>

/* 新树 */
<span>
<Counter />
</span>

相同类型的 DOM 元素

比较两个相同类型的 DOM 元素时,React 首先检查 2 个元素的属性,并保证相同的底层 DOM 节点,然后只更新属性发生更改的那一部分。

下面例子当中,React 只会更新className发生改变了的组件所对应的 DOM 节点。

1
2
3
4
5
/* 旧DOM */
<div className="before" title="stuff" />

/* 新DOM */
<div className="after" title="stuff" />

当更新style属性时,React 依然会只更新style发生改变的那部分 DOM 节点。

下面例子中,React 只会修改color样式,而不是fontWeight样式。

1
2
3
4
5
/* 旧DOM */
<div style={{color: 'red', fontWeight: 'bold'}} />

/* 新DOM */
<div style={{color: 'green', fontWeight: 'bold'}} />

处理 DOM 节点之后,React 将会递归的处理其它子元素

相同类型的 React 组件元素

当 React 组件更新的时候,组件实例保持不变,因此state在渲染时也将被实例所维护。React 更新组件实例的props使之匹配新的元素,并调用该组件实例上的componentWillReceiveProps()componentWillUpdate()方法。最后render()方法会被调用,比较算法将会递归的展示新的渲染结果。

递归处理子元素

默认情况下,递归 DOM 节点的子节点时,每当出现差异,React 都只遍历子元素列表。

例如,当添加一个子元素到无序列表尾部时,React 将会首先匹配两颗树的<li>first</li>,然后是<li>second</li>,最后插入<li>third</li>

1
2
3
4
5
6
7
8
9
10
11
12
/* 旧DOM */
<ul>
<li>first</li>
<li>second</li>
</ul>

/* 新DOM */
<ul>
<li>first</li>
<li>second</li>
<li>third</li> // 增加的项
</ul>

如果需要插入元素到无序列表<li>子元素开头的位置,那么将会得到比较差的性能,例如需要转换下面的 2 颗 DOM 树:

1
2
3
4
5
6
7
8
9
10
11
12
/* 旧DOM */
<ul>
<li>first</li>
<li>second</li>
</ul>

/* 新DOM */
<ul>
<li>zero</li> // 增加的项
<li>first</li>
<li>second</li>
</ul>

React 将会改变每个子元素,并保持<li>first</li><li>second</li>不变,这样性能将是一个问题。

Keys

为了解决上面遗留的问题,React 通过旧 DOM 树上的key属性去匹配原始 DOM 树上的元素,从而有效的区分出需要更新的部分。

现在,为上面的示例代码添加上不同的key属性,让 React 明确的知道哪个 DOM 结点发生了更新。

1
2
3
4
5
6
7
8
9
10
11
12
/* 旧DOM */
<ul>
<li key="1">first</li>
<li key="2">second</li>
</ul>

/* 新DOM */
<ul>
<li key="0">zero</li> // 增加的项
<li key="1">first</li>
<li key="2">second</li>
</ul>

日常开发场景当中,key属性值的 ID 在其同胞元素中必须是唯一的并非全局唯一),因此可以手动进行设置,或是使用工具生成 Hash,再或者是通过绑定的动态数据。

1
<li key={item.id}>{item.name}</li>

万不得已的时候,如果每个数据项不需要再进行排序,那么可以使用其索引值index作为key,但是负作用是重新排序的时候会变得非常缓慢。另外,使用数组索引作为key,重新排序还会引发组件状态方面的问题,即移动其中一项并改变它时,会导致受控输入类组件的状态被混淆,并以不被期待的方式更新。

权衡

重新渲染当前上下文意味着调用当前所有组件的render()方法,这并不意味 React 将会卸载或者重新挂载这些组件,React 只会按照上述规则对 DOM 结构进行局部的更新。

为了让大部分用例运行更加快速,社区经常对 React 的策略进行改进。在当前实现中,React 子树的每项 DOM 元素都只是在其兄弟元素之间移动,而非在整个页面的 DOM 结构(会造成惊人的性能开销)。

由于 React 依赖于启发式算法,使用的时候需要注意以下两点:

  1. 该算法不会尝试匹配不同组件类型的子树,如果两个自定义组件类型具有非常相似的输出,那么可以考虑将其归为一个相同类型。
  2. key值应该是稳定的、可预测的、唯一的;不稳定的键(比如math.random()生成的键)会导致许多组件实例和 DOM 节点被不必要的重新创建,这将会导致性能的下降,并让子组件丢失状态。

Context 组件树上下文

Context 提供了一种在组件树当中传递数据的方式,而毋需手动在每层组件通过props进行传递。

下面的例子代码当中,为了按钮组件的样式而手动传递了一个名为themeprops

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class App extends React.Component {
render() {
return <Toolbar theme="dark" />;
}
}

function Toolbar(props) {
// Toolbar组件必须接收一个theme属性,并且将其它传递给ThemedButton。这个过程将会比较痛苦,因为应用当中的每个按钮都需要这个theme属性。
return (
<div>
<ThemedButton theme={props.theme} />
</div>
);
}

function ThemedButton(props) {
return <Button theme={props.theme} />;
}

使用context,我们可以避免向一些中间层级的组件传递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
26
27
28
29
30
31
/* Context可以让开发人员向组件树透传一个值,而不需要在每层组件进行声明。*/

// 为当前theme建立一个默认值为light的context
const ThemeContext = React.createContext("light");

class App extends React.Component {
render() {
// 通过Provider传递当前theme到下面组件树(App➜Toolbar➜ThemedButton)。任意组件都可以进行读取,无论其所处的深度如何。
// 这个例子中,我们可以传递dark作为当前值。
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
}

// 位于中间层级的Toolbar组件并不需要显式的传递theme到子级组件。
function Toolbar(props) {
return (
<div>
<ThemedButton />
</div>
);
}

function ThemedButton(props) {
// 使用一个Consumer去读取当前的context中的theme,React将会查找最近theme的Provider并使用它的值。
// 这个例子当中,当前的theme值为dark。
return <ThemeContext.Consumer>{theme => <Button {...props} theme={theme} />}</ThemeContext.Consumer>;
}

React.createContext

1
const { Provider, Consumer } = React.createContext(defaultValue);

通过createContext()这个 API 获取{ Provider, Consumer }对象,当 React 渲染一个 Context 的Consumer时,它将会从闭合的Provider当中读取当前的 Context 值。当渲染一个没有匹配ProviderConsumer时,defaultValue参数用于提供一个默认值,从而有助于对组件进行独立测试。

Provider

1
<Provider 以={/* 某个值 */}>

一个 React 组件允许Consumer去订阅 Context 的变化。value的值会被传递到Provider的子级Consumer当中,一个Provider能够连接到多个ConsumerProvider可以被嵌套以覆盖组件树上更深层的位置。

Consumer

1
2
3
<Consumer>
{value => /* 基于context的值进行渲染 */}
</Consumer>

上面代码定义了一个订阅 Context 变化的组件。

需要一个函数作为组件的子元素,该函数会接收当前 Context 值并返回一个 React 节点。这个传入函数的参数将会等同于当前组件树上 Context 相临的 Provider 值,如果 Context 相应的 Provider 不存在,那么该参数的值将会等于传递至createContext()defaultValue值。

无论 Provider 的值如何变化,所有 Consumer 都会重新进行渲染。这种变化取决于使用Object.is类似算法所进行的新旧值比较(当传递对象作为值时,可能会导致一些问题,参见注意事项)。

一个完整的例子

首先定义一个 Store,并将其代码放置到一个单独的文件store.js当中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from "react";

export const Flux = {
Store: {
/** Search Form */
form: {},
/** Result Table */
table: [],
/** Pop Modal */
modal: {
add: false,
auth: false,
history: false
}
},
setStore: () => {}
};

export const Context = React.createContext(Flux);

然后添加Provider,将<SearchForm /><ResultTable />两个子组件的状态全部提升至index.jsx组件,并且引入上面定义的 Store 对象并且定义其对应的钩子函数,便于两个子组件当中的数据进行双向绑定。

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
import React from "react";
import "./style.scss";
import SearchForm from "./search-form";
import ResultTable from "./result-table";
import { Context, Flux } from "./store";

export default class Demo extends React.Component {
constructor(props) {
super(props);
this.state = {
Store: Flux.Store,
setStore: newState => {
this.setState(oldState => ({
Store: {
...newState,
...oldState
}
}));
}
};
}

render() {
return (
<div id="demo">
<Context.Provider value={this.state}>
<SearchForm />
<ResultTable />
</Context.Provider>
</div>
);
}
}

接下来,就可以在两个子组件内,通过Consumer获取 Context 传入的Store对象以及setStore()钩子函数,完成跨组件的双向绑定。

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
import React from "react";
import ModalAuth from "./modal-auth";
import ModalHistory from "./modal-history";
import { Context } from "../store";

export default class ResultTable extends React.Component {
render() {
return (
<Context.Consumer>
{({ Store, setStore }) => (
<React.Fragment>
{/* 修改Store中的属性值 */}
<Table
onClick={() => {
setStore({
modal: {
add: true
}
});
}}
/>
{/* 使用Store里的属性值 */}
<h1>{Store.modal.add}</h1>
<ModalAuth />
<ModalHistory />
</React.Fragment>
)}
</Context.Consumer>
);
}
}

虽然 React 在16.0版本以后重写了Context API,并移除出了官方文档中的不建议使用标识,但是受限于<Consumer>必需在组件render()函数内进行传值,笔者依然建议开发人员在进行跨组件通信时,选用 Reflux、Redux、Mobx 等专用的状态管理工具。

Accessibility 可访问性

Web 可访问性(Web accessibility)也被称为a11y,用于构建适宜所有人群访问的页面。JSX 支持所有aria-*的 HTML 属性,这些特性在 React 当中全部采用小写:

1
<input aria-label={labelText} aria-required="true" type="text" onChange={onchangeHandler} value={inputValue} name="name" />

语义化 HTML

语义化的 HTML 是 Web 应用程序可访问性的基础。JSX 当中添加<div>元素会破坏 DOM 的语义结构,特别是在使用了列表元素<ol><ul><dl><table>的情况下,此时应该使用 React 片段(Fragment)将多个元素组合到一起。

通常情况下使用<></>语法:

1
2
3
4
5
6
7
8
function ListItem({ item }) {
return (
<>
<dt>{item.term}</dt>
<dd>{item.description}</dd>>
</>
);
}

进行列表遍历操作时需要使用到key属性,此时可以使用<Fragment>

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

function Glossary(props) {
return (
<dl>
{props.items.map(item => (
// Without the `key`, React will fire a key warning
<Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</Fragment>
))}
</dl>
);
}

可访问表单的<label>

一些 HTML 表单控件(例如<input><textarea>)需要添加<label></label>作为可访问标签,HTML 中的for属性在 JSX 当中会写为htmlFor,例如下面的代码:

1
2
<label htmlFor="namedInput">Name:</label>
<input id="namedInput" type="text" name="name"/>

输入焦点管理

React 应用在运行期间会不断对 DOM 进行修改,这可能会导致键盘焦点丢失或定位到未知元素,此时可以通过 JavaScript 代码进行修正。

首先,在类组件的 JSX 当中添加一个ref属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CustomTextInput extends React.Component {
constructor(props) {
super(props);
this.textInput = React.createRef();
}

focus() {
// 使用DOM原生API聚焦输入域,
this.textInput.current.focus();
}

render() {
// 建立一个ref保存textInput的DOM元素
return <input type="text" ref={this.textInput} />;
}
}

有时候,父级组件需要去设置一个聚焦到子级组件的元素上,我们可以通过子级组件上的一个特殊prop暴露 DOM 的ref给父级组件,从而将父级的ref传递到子级的 DOM。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function CustomTextInput(props) {
return (
<div>
<input ref={props.inputRef} />
</div>
);
}

class Parent extends React.Component {
constructor(props) {
super(props);
this.inputElement = React.createRef();
}
render() {
return (
<CustomTextInput inputRef={this.inputElement} />
);
}
}
/>

// 现在可以按需设置聚焦事件了
this.inputElement.current.focus();

当使用高阶组件去继承组件时,推荐通过使用 React 的forwardRef()函数转发ref到被包裹的组件,如果第三方高阶组件没有实现ref 转发,上面的模式依然可以作为一种回退。

尽管上述内容对于可访问性非常重要,但也应该审慎进行应用,总是在聚焦事件发生中断时去修复键盘的焦点。

代码切割

在与 Webpack 共同使用的场景下,伴随 Web 应用的增长,打包文件的体积也会快速的增长,因为需要引入代码拆分特性,切分并且懒加载脚本代码,从而优化前端的用户性能与体验。

import()

引入代码拆分最简单的方式是通过 Webpack 提供的import()语法,Babel 上可以通过babel-plugin-syntax-dynamic-import添加支持。

1
2
3
4
5
6
7
8
// 过去
import { add } from "./math";
console.info(add(16, 26));

// 现在
import("./math").then(math => {
console.info(math.add(156, 98));
});

import()语法目前还处于 ECMAScript 提案阶段,不久的将来可能会成为标准。

react-loadable

react-loadable是一个封装良好的、能够实现动态导入的高阶组件,能够对 React 应用程序中的组件进行动态的拆分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 过去 */
import OtherComponent from "./OtherComponent";

const MyComponent = () => <OtherComponent />;

/* 现在 */
import Loadable from "react-loadable";

const LoadableOtherComponent = Loadable({
loader: () => import("./OtherComponent"),
loading: () => <div>Loading...</div>
});

const MyComponent = () => <LoadableOtherComponent />;

基于路由进行切割

基于路由进行代码拆分是一种相对合理的打包策略,下面示例代码中通过react-routerreact-loadable展示了如何通过路由完成代码的切割。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Loadable from "react-loadable";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";

const Loading = () => <div>Loading...</div>;

const Home = Loadable({
loader: () => import("./routes/Home"),
loading: Loading
});

const About = Loadable({
loader: () => import("./routes/About"),
loading: Loading
});

const App = () => (
<Router>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
</Switch>
</Router>
);

整合 jQuery

如果需要整合 React 与 jQuery,可以在组件的 DOM 根元素上添加ref属性,并在componentDidMount()当中调用该ref并将其传递给 jQuery 插件,最后在componentWillUnmount()移除 DOM 上绑定的事件。同时,为了防止 React 组件加载之后修改 DOM 节点,需要先在render()方法中返回一个空的<div />,这样 React 就不会对其进行更新,封装的 jQuery 插件就可以任意修改该节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SomePlugin extends React.Component {
componentDidMount() {
this.$el = $(this.el);
this.$el.somePlugin();
}

componentWillUnmount() {
this.$el.somePlugin("destroy");
}

render() {
return <div ref={el => (this.el = el)} />;
}
}

高阶组件

高阶组件本质是一个函数,能够接受一个组件并返回一个新的组件。

1
const EnhancedComponent = higherOrderComponent(WrappedComponent);

Render Props

Render Props 是一种将组件的 Props 设置为函数,从而通过传入参数共享数据并动态决定所需要渲染组件的模式。下面是一个动态获取当前鼠标位置的示例代码,MouseTracker是用于渲染的根组件,Picture实时获取鼠标的坐标位置并使组件渲染的图片与鼠标实时联动,MousePosition用于获取鼠标的当前位置,并将状态通过render(this.state)传递给Picture组件。

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
/** Picture Component */
class Picture extends React.Component {
render() {
const mouse = this.props.mouse;
return <img src="/images/picture.png" style={{ position: "absolute", left: mouse.x, top: mouse.y }} />;
}
}

/** Mouse Component */
class MousePosition extends React.Component {
constructor(props) {
super(props);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.state = { x: 0, y: 0 };
}

handleMouseMove(event) {
this.setState({
x: event.clientX,
y: event.clientY
});
}

render() {
return (
<div style={{ height: "100%" }} onMouseMove={this.handleMouseMove}>
{/* 使用render prop动态决定需要渲染的组件,代替直接去渲染静态<Mouse>组件。*/}
{this.props.render(this.state)}
</div>
);
}
}

class MouseTracker extends React.Component {
render() {
return (
<div>
<h1>移动鼠标!</h1>
{/* 为Mouse组件设置的render props是一个函数*/}
<MousePosition render={mouse => <Picture mouse={mouse} />} />
</div>
);
}
}

Render Props 本质是一种 Context 机制之外的组件间状态共享机制。

严格模式与性能优化

严格模式用于在开发模式下检查 React 应用中潜在的问题,目前能够识别的问题如下:

  • 识别具有不安全生命周期的组件。
  • 有关废弃的字符串 ref 用法的警告。
  • 关于已弃用的 findDOMNode 用法的警告。
  • 检测意外的副作用。
  • 检测遗留的 context API。

只需在要进行严格检查的组件上添加父组件<React.StrictMode>即可开启严格模式,具体使用请参见以下代码:

1
2
3
4
5
6
7
8
9
10
11
class StrictCheck extends React.Component {
render() {
return (
<div>
<React.StrictMode>
<SomeCompenent />
</React.StrictMode>
</div>
);
}
}

Webpack 编译打包的时候(生产环境)可以通过添加下面代码来优化编译过程。

1
2
3
4
5
6
new webpack.DefinePlugin({
"process.env": {
NODE_ENV: JSON.stringify("production")
}
}),
new webpack.optimize.UglifyJsPlugin();

虽然 React 只是按需更新 DOM 节点,但是诸如多次输入事件不断触发时,会造成组件的render()函数被不停的渲染,这里可以通过shouldComponentUpdate避免这个问题。React 生命周期函数shouldComponentUpdate会在组件重绘前执行,该函数默认返回true,如果遇到组件不需要更新的情况,可以让该函数返回false从而避免组件被重绘。

1
2
3
shouldComponentUpdate(nextProps, nextState) {
return true;
}

React Router 4

Rails、Express、Ember、Angular 使用的是静态路由机制(Static Routing),即将路由作为 Web 应用初始化的一部分,React Router 4 之前的版本也采用相同的机制。

动态路由Dynamic Routing)是指的 Web 应用程序渲染的时候发生的路由,而非正在运行的 Web 应用程序之外的配置和约定,这意味着 React Router 当中的一切都是组件。

基本组件

React Router 拥有 3 种组件:路由组件、路由匹配的组件、导航组件,这些组件都可以通过react-router-dom引入。

Routers

Web 应用程序的核心是路由组件,react-router-dom提供了<BrowserRouter><HashRouter>两种路由组件,它们都会去建立一个特殊的history对象。如果拥有一台能够响应请求的服务器,那么可以使用<BrowserRouter>;如果使用静态文件服务器,则可以选用<HashRouter>

Route Matching

路由匹配组件主要包含<Route><Switch>这两个组件。

<Route>

路由的匹配是通过<Route>组件的path prop 与当前位置路径的比较来完成的,如果比较成功则渲染组件内容,如果失败则渲染为空。没有path prop 的<Route>总是会得到匹配。

开发人员可以在任意需要渲染内容的位置包含<Route>,通常情况需要通过<Switch>组件将一组路由放置到一起。

<Switch>

<Switch>并非仅用来组织多个<Route>的,其拥有更多的潜在用途。比如<Switch>会迭代其全部子<Route>元素,并且只渲染匹配当前地置的第一个组件,这在具有多个同名路由、路由之间的动画过渡、没有路由匹配当前地址等场景下非常有用。

Route Rendering Props

开发人员可以通过componentrenderchildren三个 props 选项,指定<Route>如何渲染一个组件。其中componentrender较为常用。

  • component用于指定一个已经存在的 React 组件(React.Component组件或无状态的函数式组件)。

  • render接收一个内联函数,仅用于需要传递当前作用域内变量到组件的场景(因为不能在函数式组件当前使用当前作用域内的变量)。

不能在一个传递了作用域内变量的内联函数当中使用component,因为将会发生不必要的组件卸载和重复挂载。

React Router 提供<Link>组件用于在 Web 应用中建立链接,无论在哪里渲染<Link>组件,都会在 HTML 中生成一个<a>标签。其中<NavLink>是一种特殊的<Link>,访问路径匹配时可以为自身添加active等状态。必要的情况下,也可以通过<Redirect>强制使用其prop进行导航。

代码分割

React 通过webpackbabel-plugin-syntax-dynamic-importreact-loadable完成代码分割。webpack已经内建了动态引入支持,如果你正在使用 Babel(用来将 JSX 转换为 JavaScript),那么可以使用babel-plugin-syntax-dynamic-import插件。该插件只是简单的允许 Babel 去解析动态引入,让 Webpack 能够方便的以代码分割的方式进行打包。因此,你的.babelrc可能是这样的:

1
2
3
4
5
6
7
8
{
"presets": [
"react"
],
"plugins": [
"syntax-dynamic-import"
]
}

react-loadable是一个用来进行动态加载的高优先级组件,它能自动处理各种边界状况,让代码分割工作变得简单,下面是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
import Loadable from "react-loadable";
import Loading from "./Loading";

const LoadableComponent = Loadable({
loader: () => import("./Dashboard"),
loading: Loading
});

export default class LoadableDashboard extends React.Component {
render() {
return <LoadableComponent />;
}
}

loader选项是一个用来加载确切组件的函数,loading是一个处于加载状态真实组件的占位符组件。

构建产品化的 React 应用

生产环境下,React需要结合大量的第三方包协助开发,如何基于这些第三方包来组织一个合理的项目结构,对于新接触React的开发开发人员是一个需要逐步摸索的过程。这里笔者结合自己的实践经验,分享了组织React产品化项目的一些心得,并以此作为全文的收尾章节。

项目结构

整体的项目构建上,笔者选用了Webpack + Gulp的工具栈,并没有采用create-react-app所使用的npm script + webpack-plugin方式,这样做的目的一方面是照顾开发团队的使用习惯,另一方面是让Webpack完成转译和代码打包的工作,而将自动化任务分离出来交给Gulp完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
├── config
│   ├── base.js
│   ├── common.js
│   ├── develop.js
│   ├── product.js
│   └── style.js
├── gulpfile.js
├── package.json
├── README.md
├── server
│   ├── app.js
│   ├── common
│   ├── dashboard
│   └── login
└── sources
├── app.js
├── assets
├── common
├── dashboard
├── index.html
├── layout
├── login
├── router.js
└── store.js

config目录是Webpack相关的配置,server目录是Express构建的用于组装模拟数据的Web服务端代码,sources目录则是React前端项目相关的代码。

程序入口点

单页面应用程序通常会拥有一个全局唯一的入口点app.js,主要用于挂载视图DOM,以及配置路由、热加载、权限拦截、全局状态管理等。在笔者项目当中,前端路由选用了React Router 4,UI组织库指定为ant.design,CSS代码则使用node-sass预处理。

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
// library
import React from "react";
import ReactDOM from "react-dom";
import Router from "./router.js";
import Auth from "./common/utils/auth.js";
import { LocaleProvider } from "antd";
import CN from "antd/lib/locale-provider/zh_CN";
import "babel-polyfill";
import { Provider } from "mobx-react";
import Store from "./store";
import DevTools from "mobx-react-devtools";
// css
import "./common/styles/base.scss";
import "./common/styles/reset.scss";
import "./common/styles/awesome/css/fontawesome-all.min.css";
import "animate.css/animate.min.css";
// theme
import "antd/dist/antd.less";
import "./common/styles/theme.less";

ReactDOM.render(
<LocaleProvider locale={CN}>
<Provider GlobalStore={Store}>
<Router>
<DevTools />
</Router>
</Provider>
</LocaleProvider>,
document.getElementById("app")
);

Auth.initializer();
Auth.interceptor();

路由配置

笔者将前端路由的具体配置分离到了单独的router.js文件,并且通过React Loadable来实现基于组件的代码分割和懒加载,与此同时还配置了全局的页面加载动效。

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
import { HashRouter, Route, Link, Switch } from "react-router-dom";
import React from "react";
import Loadable from "react-loadable";
import Loading from "./common/components/loading";
import { hot } from "react-hot-loader";

const Login = Loadable({
loader: () => import("./login/index.jsx"),
loading: Loading
});

export default hot(module)(() => (
<HashRouter>
<Switch>
<Route exact path="/" component={Login} />
<Route exact path="/login" component={Login} />
<Route
path="/layout"
component={Loadable({
loader: () => import("./layout/index.jsx"),
loading: Loading
})}
/>
</Switch>
</HashRouter>
));

权限认证

项目当中,权限认证相关的功能都会被封装到一个auth.js进行集中处理,包括权限信息的初始化、HTTP权限状态的拦截、路由权限的处理。

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
import React from "react";
import Http from "./http.js";
import Encrypt from "./encrypt.js";
import { Route, Redirect } from "react-router-dom";
import { storage } from "../utils/helper";
import queryString from "querystringify";

export default {
/** Handle url search */
initializer() {
const searchInfo = queryString.parse(location.search).info;
if (searchInfo) {
const query = JSON.parse(Base64.decode(searchInfo));
storage.set("token", query.token);
storage.set("username", query.username);
storage.set("permissions", query.permissions);
}
},

/** Http interceptor */
interceptor() {
Http.fetch.interceptors.request.use(
function(config) {
const token = Encrypt.token.get();
if (token) config.headers.Authorization = token;
return config;
},
function(error) {
return Promise.reject(error);
}
);
Http.fetch.interceptors.response.use(
function(response) {
const head = response.data.head;
const body = response.data.body;
if (head && typeof head === "object" && head.hasOwnProperty("status")) {
if (head.status === "TIMEOUT") {
window.location.href = body.url;
storage.empty();
}
}
return response;
},
function(error) {
return Promise.reject(error);
}
);
},

/** Check if current route is authed */
authRoute({ component: Component, ...rest }) {
return (
<Route
{...rest}
render={props =>
Encrypt.token.get() ? (
<Component {...props} />
) : (
<Redirect
to={{
pathname: Http.url.login,
state: { from: props.location }
}}
/>
)
}
/>
);
}
};

整合Mobx

状态管理框架方面,笔者选用了轻量好用的Mobx方案,并且通过建立全局store并将其分离至单独的store.js文件便于管理和维护,下面代码仅将全局全局过渡动画的状态位纳入Mobx管理。

1
2
3
4
5
6
7
8
9
10
11
12
import { observable, computed, action } from "mobx";
import { Tag } from "antd";
import React from "react";
import Loading from "./common/components/loading";

class Store {
/** 全局过渡动画 */
@observable
loading = true;
}

export default new Store();

由于在app.js当中已经完成了mobx-react所提供的Provider配置,因此子组件仅需注入该Store即可通过this.props.GlobalStore访问上面定义的全局动画状态位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from "react";
import "./style.scss";
import { Link } from "react-router-dom";
import { observer, inject } from "mobx-react";

@inject("GlobalStore")
@observer
export default class GlobalLayout extends React.Component {

render() {
return (
<h1>{this.props.GlobalStore.loading}</h1>
);
}
}

完整的脚手架项目,请参见笔者Github当中提供的开源脚手架项目Rhino

React 16.6.x 全新全译

http://www.uinio.com/Web/React/

作者

Hank

发布于

2016-12-23

更新于

2017-02-27

许可协议