React 实现井字棋游戏 (tic-tac-toe) 教程 (1) <译自官方文档>
React 实现井字棋游戏 (tic-tac-toe) 教程 (2) <译自官方文档>
3-状态提升
至此,我们已经拥有了编写井字棋游戏的基本构件。但现在,状态(state)是被包裹在各个 Square 组件内的。为了完成这个游戏,我们还需要做这两件事:检查是否已经有玩家胜出;以及在小方格中轮流填入“X”和“O”。为了检查是否已经有玩家获胜,我们需要把9个小方格的状态值都集中到一个地方,而不是让它们分散在各个 Square 组件内部。
你可能会想到,让 Board 组件去查询各个 Square 组件的当前状态值。当然,单纯从技术上讲,用 React 是能做到这个的,但我们并不鼓励这么干。因为这会让代码变得不易理解,更脆弱,也更难重构。
所以,最佳的方案,是把状态值都存储到 Board 组件,而非各个 Square 组件中。这样,Board 组件就可以告诉各个 Square 组件应该显示什么。这就跟之前,我们让每个小方格显示各自序号所用的方法是一样的。
当你需要从多个子组件中聚集数据,或者想让两个子组件互相通信的时候,你应该把状态提升到父组件之中。父组件可以通过props把状态值传回其子组件。如此,子组件互相之间、子组件和父组件之间都能保持同步。
在重构 React 组件时,像这样提升状态的做法是非常常见的。借着这次机会,我们也来试一下。在 Board 组件中,添加 constructor 函数,并设置初始状态:一个包含9个 null 的数组,它们分别对应9个小方格。
code
class Board extends React.Component {
constructor() {
super();
this.state = {
squares: Array(9).fill(null),
};
}
renderSquare(i) {
return <Square value={i} />;
}
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>
);
}
}
待会儿,我们会填入一些东西,让它变成类似这样:
code
[
'O', null, 'X',
'X', 'X', 'O',
'O', null, null,
]
现在,Board 组件的renderSquare
方法是这样子的:
code
renderSquare(i) {
return <Square value={i} />;
}
修改它,把value
属性传给 Square 组件:
code
renderSquare(i) {
return <Square value={this.state.squares[i]} />;
}
现在,我们来改变小方块被点击后的行为。Board 组件存储着填小方块的东西,这意味着我们需要想办法让 Square 组件更新 Board 的状态。因为状态是组件私有的,所以我们不能直接从 Square 组件修改 Board 组件的状态。
通常的方法是这样的:从 Board 组件向 Square 组件传一个函数,让它在小方块被点击时执行。再次修改 Board 组件中的renderSquare
方法,让它变成这样:
code
renderSquare(i) {
return (
<Square
value={this.state.squares[i]}
onClick={() => this.handleClick(i)}
/>
);
}
为了提高可读性, 我们把这个被返回的元素分开写成多行。再用括号括住它.这样能防止 JavaScript 在 return 后面加个分号而打断代码语句。
现在,我们从 Board 组件向 Square 组件传递了两个属性:value
和onClick
,后者是 Square 组件可以呼叫的函数。我们继续对 Square 组件做如下改动:
- 把 Square 组件的
render
函数中的this.state.value
替换为this.props.value
; - 把 Square 组件的
render
函数中的this.setState()
替换为this.props.onClick()
; - 从 Square 组件中删去
constructor
函数,因为它已经不包含任何状态了。
做了以上改动后,这个组件成了这样子:
code
class Square extends React.Component {
render() {
return (
<button className="square" onClick={() => this.props.onClick()}>
{this.props.value}
</button>
);
}
}
现在,当小方格被点击时,会呼叫从 Board 组件传来的onClick
函数。主要过程如下:
- built-in DOM
<button>
component 中的onClick
属性通知React设置一个点击事件监听器; - 当按钮被点击,React 将会呼叫在 Square 组件中
render()
方法里定义的onClick
事件处理器; - 该事件处理器呼叫
this.props.onClick()
。Square 组件的 props 由 Board 组件规定; - Board 组件将
onClick={() => this.handleClick(i)}
传给了 Square 组件,所以,当被呼叫时,Board 组件中运行this.handleClick(i)
; - 我们目前还没有在 Board 组件中定义
handleClick()
方法,所以代码会出错。
需要注意的是, DOM <button>
组件中的onClick
对 React 有着特别的意义。我们本可以把 Square 组件中的onClick
和 Board 组件中的handleClick
叫成别的名字。然而,React app 中有约定俗成的方式:对于处理器属性用on*
的格式命名;对于具体实现,则用handle*
的格式命名。
请试着点击小方格。应该会收到报错信息,因为我们还没有定义handleClick
。现在,把它加到 Board 组件的类中。
code
class Board extends React.Component {
constructor() {
super();
this.state = {
squares: Array(9).fill(null),
};
}
handleClick(i) {
const squares = this.state.squares.slice();
squares[i] = 'X';
this.setState({squares: squares});
}
renderSquare(i) {
return (
<Square
value={this.state.squares[i]}
onClick={() => this.handleClick(i)}
/>
);
}
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>
);
}
}
我们用.slice()
来拷贝一份squares
数组的副本,再对副本进行操作。不要直接改变原数组。查看这一部分来了解不可改变性( immutability)的重要性。
如果现在再点击小方格,格子里应该又会出现“X”了。但此时,状态值是存储在 Board 组件里的,而不是像之前,存在各个 Square 组件中。这让我们的游戏编写工作得以继续进行。注意,无论 Board 组件中的状态值何时改变,Square 组件总能自动重新渲染。
Square 组件不再保有自己的状态,而是改为从父组件,即 Board 组件那里接收;同时,当它被点击的时候,会通知其父组件。我们把这种组件叫做受控组件。
为什么不可变性很重要
在之前的示例代码中,我们建议使用.slice()
运算符来拷贝一份squares
数组,再在其副本上进行数据改动,以防止原有的数组被修改。现在,我们来具体谈谈它的内涵,和这么做的重要性。
通常来说,修改数据的方法有两种。第一种方法是通过直接改动变量的值来修改(mutate)原有数据,第二种方法是使用一份改动后的副本,以此替换(replace)原有数据。
改动(mutate)原数据
code
var player = {score: 1, name: 'Jeff'};
player.score = 2;
// 现在 player 是 {score: 2, name: 'Jeff'}
不改动(mutate)原数据
code
var player = {score: 1, name: 'Jeff'};
var newPlayer = Object.assign({}, player, {score: 2});
// 现在 player 没有改变, 而 newPlayer 是 {score: 2, name: 'Jeff'}
// 或者使用对象展开符:
// var newPlayer = {...player, score: 2};
最终的结果是一样的。但是,不直接改动基础数据的方法却能带来一些额外的好处:它有助于提升组件或者整个应用的性能。
更简单的 撤销/重做 和 穿越功能
不可改变性 也能让一些复杂的特性实现起来更容易。例如,在本教程后期,我们将要实现在棋局的不同阶段间穿越的功能。避免数据的变动(mutation),能让我们保持对旧版本数据的引用。如果我们需要的话,就能在它们之间切换。
追踪变动
对于被直接改动(mutate)的对象,我们难以判断它们是否被修改,因为所以改动都直接在原对象上进行的。这要求比较当前对象和之前的拷贝的副本,遍历整个对象数,比较每个变量与值。这个过程可能会变得越来越复杂。
而判断不可变对象是否被改动则是相当容易的。 如果被引用的对象与之前的不同,则对象已更改。就这么简单。
在React中 确定何时重新渲染
在React中,当建立纯组件时,不可改变性带来的好处最明显。对于不可改变的数据,我们能很容易地确认改动是否发生,借此,我们就可以确定组件何时要求被重新渲染。
想要了解shouldComponentUpdate()
以及如何构建纯组件,请查看优化性能。
函数式声明组件
我们已经移除了 Square 组件的 constructo r函数。事实上,对于 Square 这样,仅仅由render
方法构成的组件,React 有一种更简单的声明组件的语法,叫函数式声明组件。不必用extends React.Component
来定义组件,你仅仅只需写一个函数,它接受属性,返回需要被渲染的东西即可。
用下面这个函数替换掉整个 Square 的类:
code
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
你需要把两个this.props
都换成props
。你的 app 中,很多组件都能写成函数式声明的组件。这样的组件更容易写,而且 React 以后也会继续优化它们。
整理代码时,我们把onClick={() => props.onClick()}
也换成onClick={props.onClick}
。因为对于本案例来说,把函数传下来就已经足够了。注意,写成onClick={props.onClick()}
是不行的,因为它会立即调用props.onClick
,而不是如我们所想的把它传下来。