# 介绍 React
## 要做什么
今天,我们将要构建一个交互式的 tic-tac-toe 游戏。我们假定你熟悉 HTML 和 JavaScript ,但是你应该可以即使没有用过它们,应该也可以跟着做一下。
如果你喜欢,可以从这里检出最终代码:[Final Result](https://s.codepen.io/ericnakagawa/debug/ALxakj)。试试这个游戏。也可以点击移动列表中的一个链接到 "back in time" 并查看这个移动做出之后的面板是什么样子的。
## 什么是 React
React 是一个声明式的、高效、灵活的 JavaScript 库,用来构建用户界面。
React 有一些不同种类的组件,但是我们将从 React.Component 子类开始:
~~~
class ShoppingList extends React.Component {
render() {
return (
<div className="shopping-list">
<h1>Shopping List for {this.props.name}</h1>
<ul>
<li>Instagram</li>
<li>WhatsApp</li>
<li>Oculus</li>
</ul>
</div>
);
}
}
// 用法: <ShoppingList name="Mark" />
~~~
这里将接触到一些有趣的类似 XML 标签的用法。你的组件告知 React 你想渲染什么 —— 然后在你的数据发生改变时, React 将会高效的更新并渲染前挡的部分。
这里,ShoppingList 是一个 React 组件类,或者 React.Component 类型。组件可以携带参数,称为 props,并返回一个层级视图来通过 render 方法进行显示。
render 方法返回一个你想要渲染内容的描述,然后 React 获得这个描述并渲染到屏幕。特别是,render 返回一个 React 元素,它是一个将要渲染的内容的轻量描述。多数 React 开发者使用一个特定的称为 JSX 的语法,可以简化这个构造的编写。 <div /> 语法在构建时被转换为 React.createElement('div')。上面的示例等效于:
~~~
return React.createElement('div', {className: 'shopping-list'},
React.createElement('h1', ...),
React.createElement('ul', ...)
);
~~~
可以在 JSX 中的花括号里放入任何 JavaScript 表达式。每个 React 元素是一个真正的 JavaScript 对象 ,可以将他们保存到一个变量中或者在程序中传递。
ShoppingList 组件只渲染内建的 DOM 组件,但是你同样可以方便的组成自定义的 React 组件,通过编写 <ShoppingList /> 。每个组件都是封装的,所以它可以独立地操作,使你可以使用简单的组件构建复杂的 UIs 。
## 开始
从这个例子中的代码开始这个游戏:[初始代码](https://codepen.io/ericnakagawa/pen/vXpjwZ?editors=0010)。
这里摘录如下:
初始代码(JSX):
~~~
class Square extends React.Component {
render(){
return (
<button className="square">
{/* TODO */}
</button>
)
}
}
class Board extends React.Component {
renderSquare(i){
return <Square />
}
render(){
const status = 'Next player: X'
return (
<div>
<div className="status">{status}</div>
<div className="board-row">
{this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</div>
</div>
)
}
}
class Game extends React.Component {
render(){
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div className="game-info">
<div>{/* status */}</div>
<ol>{/* TODO */}</ol>
</div>
</div>
)
}
}
ReactDOM.render(
<Game />,
document.getElementById('mount-node')
)
function calculateWinner(squares) {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
]
for(let i = 0; i < lines.length; i++){
const [a, b, c] = lines[i]
if(squares[a] && squares[a] === squares[b] && squares[a] === squares[c]){
return squares[a]
}
}
return null
}
~~~
它包含了我们将要构建内容的外壳。我们已经提供了样式表,所以你只需要关心 JavaScript 。
样式表:
~~~
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
ol, ul {
padding-left: 30px;
}
.board-row:after {
clear: both;
content: "";
display: table;
}
.status {
margin-bottom: 10px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.square:focus {
outline: none;
}
.kbd-navigation .square:focus {
background: #ddd;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
~~~
特别注意,我们有三个组件:
* Square
* Board
* Game
Square 组件渲染了一个单独的 <div>,Board 渲染了 9 个正方形, Game 组件渲染了一个面板,带有一些我们将会进行填充的占位符。此时,没有一个组件可以进行交互。
(在 JS 文件的末尾,我们还定义了一个工具函数 calculateWinner ,稍后以供使用)
## 通过 Props 传递数据
简单尝试一下,试着传递一些数据从 Board 组件到 Square 组件。在 Board 的 renderSquare 方法中,改变代码以返回 `<Square value ={i} />` 然后改变 Square 的 render 方法(使用 {this.props.value} 替代 {/* TODO */})来显示这个值。
替换前:
![](https://box.kancloud.cn/1566a4f8490d6b4b1ed36cd2c11fe4b6_254x288.png)
替换后:
![](https://box.kancloud.cn/685df774da6da48f451356f33f4be8b2_270x296.png)
## 一个交互式组件
使 Square 组件在你点击它时填充一个 “X”。试着改变 Square 类 render() 函数返回的标签为:
~~~
<button className="square" onClick={() => alert('click')}>
~~~
这里使用了 JavaScript 中新的箭头函数语法。如果你点击一个正方形,应该可以在浏览器中得到一个 alert 了。
React 组件可以通过在 constructor 构造函数中设置 this.state 拥有一个状态,被认为是这个组件私有的。保存当前正方形的值到 state 中,并在正方形被点击的时候修改它。首先,添加一个构造函数到类中来初始化 state:
~~~
class Square extends React.Component {
constructor(){
super()
this.state = {
value: null
}
}
render(){
return (
<button className="square" onClick={()=>alert('click')}>
{this.props.value}
</button>
)
}
}
....
~~~
在 JavaScript 类中,当定义子类的构造函数时,你需要显式调用 super()。
现在修改 render 方法来显示 this.state.value 替换原来的 this.props.value ,并修改事件处理程序为 `()=>this.setState({value: 'X'})` 替换 alert :
~~~
class Square extends React.Component {
constructor(){
super()
this.state = {
value: null
}
}
render(){
return (
<button className="square" onClick={()=>this.setState({value:'X'})}>
{this.state.value}
</button>
)
}
}
...
~~~
无论何时 this.setState 被调用,都会预定一个该组件的更新,使 React 合并传递的 state 更新并重新渲染组件以及它的后代。当组件重新渲染, this.state.value 会变成 "X", 所以你将在网格中看到一个 X 。
如果你点击任何正方形,一个 X 都会显示在其中。
## 开发者工具
[Chrome](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) 和 [Firefox](https://addons.mozilla.org/en-US/firefox/addon/react-devtools/) 的 React 开发者工具扩展,让你可以在你的浏览器开发工具中检查一个 React 组件树。
![](https://box.kancloud.cn/311315e0a6cc5faa7e5af9bf38c31a9b_364x457.png)
它使你可以检查任何树中组件的 props 和 state。
由于多个 frames ,它在 CodePen 中不能很好的工作,但是如果你在登入到 CodePen 并确认你的邮件(为了防止垃圾邮件),你可以打开 Change View > Debug 以在新标签页打开你的代码,然后开发者工具就可以工作了。你现在还不想这样做也没关系,但是应该知道它的存在。
## 提升状态
现在我们已经有了一个 tic-tac-toe 游戏基本的构建块。但是现在,state 是封装在每个 Square 组件中。要使游戏完整的运行,现在需要检查一个玩家是否赢得了游戏,并在 squares 中替换成 X 和 O 。要检查是否某人获胜,我们需要有 9 个正方形中的值在一个地方,而不是分开在 Square 组件中。
你可能认为 Board 应该只是查询每个 Square 当前的状态是什么。尽管从技术上说在 React 中可以这样做,但是不建议这样,因为这往往使代码难以理解、更加脆弱而且难以重构。
最好的方案是在 Board 组件中保存这个 state 而不是在每个 Square 中 —— 而且 Board 组件可以告知每个 Square 要显示什么,就像之前我们使每个正方形显示它的索引那样。
当你想要从多个子级合计数据或者使两个子组件之间互相通讯,向上移动 state 使它存活在父组件中。父组件然后可以通过 props 传递 state 回到子组件,所以子组件总是互相之间同步,包括其父组件。
像这样向上推动 state 在重构 React 组件时是非常常见的,所以利用这个机会尝试一下。为 Board 添加一个初始状态,包括一个有 9 个 null 的数组,对应到 9 个 正方形:
~~~
class Board extends React.Component {
constructor(){
super()
this.state = {
squares: Array(9).fill(null)
}
}
....
}
~~~
稍后我们填充它,那么一个 board 应该看起来像:
~~~
[
'O', null, 'X',
'X', 'X', 'O',
'O', null, null,
]
~~~
传递每个正方形的值,如下:
~~~
renderSquare(i) {
return <Square value={this.state.squares[i]} />;
}
~~~
然后修改 Square 来再次使用 this.props.value 。现在我们需要改变当一个正方形被点击时的动作。 Board 组件现在保存了哪些正方形被填充,意味着我们需要一个 Square 的方法来更新 Board 的 state 。由于组件状态是私有的,我们不能直接从 Square更新 Board 的状态。这里通常的方式是传递一个函数从 Board 到 Square 在正方形被点击的时候调用。修改 renderSquare :
~~~
return <Square value={this.state.squares[i]} onClick={() => this.handleClick(i)} />
~~~
现在我们传递两个 props 从 Board 到 Square:value 和 onClick 。这是一个 Square 可以调用的函数。那么我们通过修改 Square 中的 render :
~~~
<button className="square" onClick={() => this.props.onClick()}>
~~~
这意味着当正方形被点击,它调用父组件传递来的 onClick 函数。onClick 这里没有任何特别的意义,但是通常命名处理程序 props 以 on 开头,而它们的实现以 handle 开头。试试点击一个正方形 —— 你可能会得到一个 error ,因为我们还没有定义 handleClick 。添加它到 Board 类:
~~~
handleClick(i) {
const squares = this.state.squares.slice();
squares[i] = 'X';
this.setState({squares: squares});
}
~~~
我们调用 slice() 来拷贝 squares 数组而不是改变现有数组。向后跳一下了解[为什么不变化是非常重要的](https://facebook.github.io/react/tutorial/tutorial.html#why-immutability-is-important)。
现在你应该可以点击正方形再次填充它们了,但是状态是保存在 Board 组件而不是每个 Square,那么应该继续构建这个游戏。注意,无论如何 Board 的状态发生改变, Square 组件都会自动重新渲染。
Square 不再保留它的自己的状态;它从父组件 Board 接收它的值,并在被点击时通知它的父组件。我们称这样的组件为约束组件。
## 为什么不变化是非常重要的
在前面的代码示例中,我建议使用 slice() 操作符来在做出改变之前拷贝正方形数组,并阻止改变存在的数组。讨论一下这有什么意义,为什么这是要了解的一个重要概念。
通常有两种方式来改变数据。第一个方法是直接通过改变变量的值来修改数据。第二种方法是使用新的拷贝对象替代数据并包含预期的修改。
**通过变化改变数据**
~~~
var player = {score: 1, name: 'Jeff'};
player.score = 2;
// Now player is {score: 2, name: 'Jeff'}
~~~
**不发生变化改变数据**
~~~
var player = {score: 1, name: 'Jeff'};
var newPlayer = Object.assign({}, player, {score: 2});
// Now player is unchanged, but newPlayer is {score: 2, name: 'Jeff'}
// Or if you are using object spread, you can write:
// var newPlayer = {score: 2, ...player};
~~~
最终结果相同,但是不直接修改数据(或者改变底层的数据)我们现在有一个额外的好处,可以帮助我们增加组件和整个应用的性能。
**跟踪修改**
确定一个改变的对象是否被修改是复杂的,因为修改是直接来自于对象本身。之后需要对比当前对象和之前的一个拷贝,遍历整个对象树,对比每个变量和值。这个过程可能变得越来越复杂。
确定一个未变化的对象被修改则容易的多。如果被引用的对象和之前的引用不同,那么对象则发生了改变。就是这样。
**在 React 中决定何时重新渲染**
不可变在 React 中最大的好处是,当你构造一个简单的纯组件时。由于不可变数据更容易确定是否被修改,它还可以帮助决定一个组件何时需要被重新渲染。
要了解如何构造纯组件,查看 [shouldComponentUpdate()](https://facebook.github.io/react/docs/update.html)。另外,看一下 [Immutable.js](https://facebook.github.io/immutable-js/) 库来严格执行不可变数据。
## 功能组件
回到我们的项目,你现在可以删除 Square 的构造函数了;我们不需要它了。事实上,React 支持一个简单的语法,对于类似 Square 只由一个 render 方法构成的组件类型,称为 无状态功能组件。不用定义一个类继承 React.Component,只要简单的编写一个函数,带有 props 和 返回要被渲染的内容即可:
~~~
function Square(props){
return(
<button className="square" onClick={()=>props.onClick()}>
{props.value}
</button>
)
}
~~~
需要在出现 this.props 的地方修改为 props。许多应用中的组件都可以写为功能组件:这些组件倾向于被容易的编写,而且 React 会在将来对它们做更多优化。
## 逐个处理
我们游戏中一个明显的缺陷是只能显示 X。现在来修复它。
我们设置默认第一步移动是 X。在 Board 的 constructor 中修改我们的开始状态。
~~~
constructor(){
super()
this.state = {
squares: Array(9).fill(null),
xIsNext: true,
}
}
~~~
每次移动我们应该翻转布尔值来切换 xIsNext 并保存状态。现在更新我们的 handleClick 函数来翻转 xIsNext 的值。
~~~
handleClick(i){
let squares = this.state.squares.slice()
squares[i] = this.state.xIsNext ? 'X' : 'O'
this.setState({
squares: squares,
xIsNext: !this.state.xIsNext,
})
}
~~~
现在 X 和 O 轮流出现。接下来,修改 Board 的 render 方法中的 “status” 文本所以它可以显示接下来是什么。
## 声明一个赢家
看下何时可以赢得游戏。一个 calculateWinner(squares) 辅助函数在文件底部提供了,其中有 9 个值的一个列表。可以在 Board 的 render 函数中调用它来检查是否某人赢得了游戏,并在某人获胜时使 status 文本显示 “Winner:[X/O]”:
~~~
render(){
const winner = calculateWinner(this.state.squares)
let status
if(winner){
status = 'Winner: ' + winner
}else{
status = 'Next player: ' + (this.state.xIsNext?'X':'O')
}
return (
<div>
<div className="status">{status}</div>
<div className="board-row">
{this.renderSquare(0)}
{this.renderSquare(1)}
{this.renderSquare(2)}
</div>
<div className="board-row">
{this.renderSquare(3)}
{this.renderSquare(4)}
{this.renderSquare(5)}
</div>
<div className="board-row">
{this.renderSquare(6)}
{this.renderSquare(7)}
{this.renderSquare(8)}
</div>
</div>
)
}
~~~
现在可以修改 handleClick 来在某人赢得游戏时或者如果正方形已经被填充时忽略点击并前返回:
~~~
handleClick(i){
const squares = this.state.squares.slice()
if(calculateWinner(squares) || squares[i]) return
squares[i] = this.state.xIsNext ? 'X' : 'O'
this.setState({
squares: squares,
xIsNext: !this.state.xIsNext,
})
}
~~~
恭喜!现在游戏可以正常使用了。现在你已经了解了基础的 React 知识。所以这里,你才是真正的赢家。
## 保存一个历史记录
我们可以使其能够重新检视旧的 board 状态,以查看任何一步移动后可能看起来如何。在每次做出移动时我们已经创建了一个新的数组,意味着我们可以同时简单的保存过去的 board 状态。
准备像下面这样在 state 中保存一个对象:
~~~
history = [
{
squares: [null x 9]
},
{
squares: [... x 9]
},
...
]
~~~
我们希望顶层的 Game 组件负责显示移动的列表。所以就像我们之前从 Square 向 Board 提升 state 那样,现在再次提升,从 Board 到 Game —— 所以我们可以在顶层组件中获得我们所需要的所有信息。
首先,设置 Game 的初始状态:
~~~
class Game extends React.Component {
constructor(){
super()
this.state = {
history:[{
squares: Array(9).fill(null)
}],
xIsNext: true
}
}
...
}
~~~
然后从 Board 中移除构造函数并修改 Board,然后它通过 props 获得正方形,并具有 Game 组件中指定的 onClick prop,如我们早先对 Square 所做的那样。你可以传递每个正方形的位置到点击处理程序,所以我们仍然知道哪个正方形被点击了:
~~~
return <Square value={this.props.squares[i]} onClick={() => this.props.onClick(i)} />;
~~~
Game 的 render 查找最近的历史记录,并带入以计算游戏状态:
~~~
render(){
const history = this.state.history
const current = history[history.length - 1]
const winner = calculateWinner(current.squares)
let status
if(winner){
status = 'Winner: ' + winner
}else{
status = 'Next player: ' + (this.state.xIsNext?'X':'O')
}
return (
<div className="game">
<div className="game-board">
<Board squares={current.squares} onClick={(i)=> this.handleClick(i)} />
</div>
<div className="game-info">
<div>{status}</div>
<ol>{/* TODO */}</ol>
</div>
</div>
)
}
~~~
它的 handleClick 可以推入一个新的记录到栈中,连接新的历史记录来生成一个新的 history 数组:
~~~
handleClick(i){
const history = this.state.history
const current = history[history.length - 1]
const squares = current.squares.slice()
if(calculateWinner(squares) || squares[i]) return
squares[i] = this.state.xIsNext ? 'X' : 'O'
this.setState({
history: history.concat([{
squares: squares
}]),
xIsNext: !this.state.xIsNext,
})
}
~~~
此时, Board 只需要 renderSquare 和 render ;初始化的 state 和点击处理程序都来自 Game。
## 显示移动
显示游戏中至今为止之前的移动。我们最先学习了 React 的元素是一级 JS 对象,我们可以保存它们或者传递它们。要在 React 中渲染多次,我们传递一个 React 元素的数组。构造这个数组最常用的方式是对你的数据数组做映像。我们在 Game 的 render 方法中去做:
对于 history 中的每个步骤,我们创建一个列表项 <li> 并包含一个 link ,并不跳转到什么地方(href="#") ,但是有一个点击处理程序,我们稍后会实现它。通过这个代码,你应该看到游戏中已经做出的一个移动的列表,然而可能有一个警告信息:
>[warning] Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of "Game".
是说数组或迭代器中的每个子元素都应该有一个不重复的 “key” 属性。检查 Game 的 render 方法。
下面讨论一下这个警告的意思。
## keys
当你渲染一些项的列表, React 总是保存每个项的信息到列表中。如果你渲染一个有状态的组件,这个状态需要被保存 —— 无论你如何实现你的组件, React 都会保存一个到后台原生视图的引用。
当你更新这个列表, React 需要确定什么被改变了。你可能添加、移除、重新排列或者更新了列表中的项。
试想从:
~~~
<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>
~~~
到:
~~~
<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>
~~~
对于人眼来说,看起来好像 Alexa 和 Ben 交换了位置,并且 Claudia 被添加 —— 但是 React 只是一个计算机程序,并不知道你希望它做什么。结果是,React 询问你指定一个 key 属性到每个元素上,这是一个字符串,用于从它的同辈中区别每个组件。在这里,alexa、ben、claudia可以作为不错的 keys ;如果项对应数据库中的对象,数据库的 ID 通常是一个好的选择:
~~~
<li key={user.id}>{user.name}: {user.taskCount} tasks left</li>
~~~
key 是一个特别的属性可以被 React 保留(连同 ref ,一个高级功能)。当一个元素被创建, React 取下 key 属性并直接保存到返回的元素上。虽然它看起来是 props 的一部分,但是它不能被通过 this.props.key 引用。React 在决定更新哪个子项的时候自动使用;并没有哪种方式可以让一个组件查询它自己的 key 。
当一个列表被渲染, React 携带每个元素的新版本,并查找之前列表中一个带有匹配的 key 的项。当一个 key 被添加到组中,一个组件被创建;当一个 key 被移除,一个组件被销毁。 keys 告知 React 每个组件的标识,所以它可以在重新渲染时维护状态。如果你改变了一个组件的 key ,它将被完全销毁并使用一个新的状态重新创建。
强烈建议你无论何时构建动态列表时分配适当的 keys 。如果你没有一个方便的合适的 key ,可能需要考虑重新构造你的数据。
如果没有指定任何 key,React 将警告你,并回滚使用数组索引作为一个 key —— 如果你有重新排序元素或者添加/删除非列表底部的其它元素时这并不是一个正确的选择。明确传递 key={i} 可以消除这个警告,但是也会有同样问题,所以多数情况并不建议这样。
组件 keys 不是必须全局唯一,只需要相对于最近的同辈之间唯一即可。
## 实现时间漫游
对于我们的 move 列表,对于每一步我们已经有一个不重复的 ID:这一步发生时移动的数字。添加 key 为 `<li key={move}>` ,然后 key 的警告会被消除。
点击任何的 move 链接会抛出一个错误,因为 jumpTo 还未定义。添加一个新的 key 到 Game 的 state 来表示我们正在查看哪一步。首先,添加 stepNumber:0 到初始状态,然后用 jumpTo 更新这个状态。
~~~
constructor(){
super()
this.state = {
history:[{
squares: Array(9).fill(null)
}],
stepNumber: 0,
xIsNext: true
}
}
~~~
我们也希望更新 xIsNext 。如果 move 数字的索引是一个偶数,设置 xIsNext 为 true 。
~~~
jumpTo(step){
this.setState({
stepNumber: step,
xIsNext: (step % 2) ? false : true
})
}
~~~
然后当一个新的 move 通过增加 stepNuber:history.length 到被 handleClick 更新的状态 被做出时,更新 stepNumber。现在你可以修改 render 来读取 history 中的步骤:
~~~
const current = history[this.state.stepNumber];
~~~
现在如果你点击任何 move 链接,board 应该立即更新显示游戏看起来在此时应该的样子。你可能还想在读取当前 board 状态时更新 handleClick 关注 stepNumber,你才可以回到彼时然后点击 board 创建一个新的记录。(提示:最简单的是在 handleClick 中很靠上的位置从 history 中 slice() 额外的元素)
## 收尾
现在,你的游戏已经:
* 正常的玩 tic-tac-toe
* 指示何时一个玩家赢得游戏
* 保存游戏中步骤的历史记录
* 允许玩家跳回查看游戏中之前的步骤
非常好,我们希望你对于 React 工作有了一个恰当的理解。
如果你有额外的时间或者想要实践新的技能,这里有一些可以进行的改进,难度递增的被列出来:
* 显示移动位置格式为 “(1,3)”,取代“6”
* 在移动列表中粗体显示当前的选项
* 重写 Board 来使用两个循环以产生正方形,取代硬编码
* 添加切换按钮使你以正序或倒序排列步骤
* 当某人获胜,高亮显示他获胜的三个正方形