Skip to main content

游戏完善

状态提升​

在上一节中,我们为游戏添加了交互,但是目前交互只能放下X棋子,当前,每个 Square 组件都维护了游戏的状态(state)。我们可以把所有 9 个 Square 的值放在一个地方,这样我们就可以判断出胜者了。

你可能会想,我们也可以在棋盘 Board 组件中收集每个格子 Square 组件中的 state。虽然技术上来讲是可以实现的,但是代码如此编写会让人很难理解,并且我们以后想要维护重构时也会非常困难。所以,最好的解决方式是直接将所有的 state 状态数据存储在 Board 父组件当中。之后 Board 组件可以将这些数据通过 props 传递给各个 Square 子组件,正如上文我们把数字传递给每一个 Square 一样。

当你遇到需要同时获取多个子组件数据,或者两个组件之间需要相互通讯的情况时,需要把子组件的 state 数据提升至其共同的父组件当中保存。之后父组件可以通过 props 将状态数据传递到子组件当中。这样应用当中所有组件的状态数据就能够更方便地同步共享了。

像这种将组件的 state 提升到父组件的情形在重构 React 组件时经常会遇到 —— 借此我们来实践一下。

为 Board 组件添加构造函数,将 Board 组件的初始状态设置为长度为 9 的空值数组:

class Board extends React.Component {
constructor(props) {
super(props);
this.state = {
squares: Array(9).fill(null),
// 创建九个格子的状态,并初始化为null
}
}
renderSquare(i) {
return <Square value={i} />;
}
···

当我们玩游戏时,this.state.squares 数组的值可能如下所示,有的是空的,有的是划上的:

[
'O', null, 'X',
'X', 'X', 'O',
'O', null, null,
]

Board 组件当前的 renderSquare 方法看起来像下面这样:

  renderSquare(i) {
return <Square value={i} />;
}

开始时,我们依次使把 0 到 8 的值通过 prop 从 Board 向下传递,从而让它们显示出来。上一步与此不同,我们根据 Square 自己内部的 state,使用了 “X” 来代替之前的数字。因此,Square 忽略了当前从 Board 传递给它的那个 value prop。

让我们再一次使用 prop 的传递机制。我们通过修改 Board 来指示每一个 Square 的当前值('X', 'O', 或者 null)。我们在 Board 的构造函数中已经定义好了 squares 数组,这样,我们就可以通过修改 Board 的 renderSquare 方法来读取这些值了。

  renderSquare(i) {
return <Square value={this.state.squares[i]} />;
}

这样,每个 Square 就都能接收到一个 value prop 了,这个 prop 的值可以是 'X'、 'O'、 或 null(null 代表空方格)。

那么接下来我们肯定要修改 Square 的监听函数。

这样,每个 Square 就都能接收到一个 value prop 了,这个 prop 的值可以是 'X'、 'O'、 或 null(null 代表空方格)。

相反,从 Board 组件向 Square 组件传递一个函数,当 Square 被点击的时候,这个函数就会被调用。接着,我们将 Board 组件的 renderSquare 方法改写为如下效果:

  renderSquare(i) {
return <Square value={this.state.squares[i]} onClick={()=>this.handleClick(i)} />;
}
tip

为了提高可读性,我们把返回的 React 元素拆分成了多行,同时在最外层加了小括号,这样 JavaScript 解析的时候就不会在 return 的后面自动插入一个分号从而破坏代码结构了。

现在我们从 Board 组件向 Square 组件中传递两个 props 参数:value 和 onClick。onClick prop 是一个 Square 组件点击事件监听函数。接下来,我们需要修改 Square 的代码:

  • 将 Square 组件的 render 方法中的 this.state.value 替换为 this.props.value 。
  • 将 Square 组件的 render 方法中的 this.setState() 替换为 this.props.onClick() 。
  • 删掉 Square 组件中的构造函数 constructor,因为该组件不需要再保存游戏的 state。
    进行上述修改之后,代码会变成下面这样:
class Square extends React.Component {
render() {
return (
<button
className="square"
onClick={() => this.props.onClick()}
>
{this.props.value}
</button>
);
}
}

每一个 Square 被点击时,Board 提供的 onClick 函数就会触发。我们回顾一下这是怎么实现的:

  1. 向 DOM 内置元素 <button> 添加 onClick prop,让 React 开启对点击事件的监听。
  2. 当 button 被点击时,React 会调用 Square 组件的 render() 方法中的 onClick 事件处理函数。
  3. 事件处理函数触发了传入其中的 this.props.onClick() 方法。这个方法是由 Board 传递给 Square 的。
  4. 由于 Board 把 onClick={() => this.handleClick(i)} 传递给了 Square,所以当 Square 中的事件处理函数触发时,其实就是触发的 Board 当中的 handleClick(i) 方法。
  5. 现在我们还尚未定义 handleClick() 方法,所以代码还不能正常工作。如果此时点击 Square,你会在屏幕上看到红色的错误提示,提示内容为:“this.handleClick is not a function”。
tip

因为 DOM 元素 <button> 是一个内置组件,因此其 onClick 属性在 React 中有特殊的含义。而对于用户自定义的组件来说,命名就可以由用户自己来定义了。我们给 Square 的 onClick 和 Board 的 handleClick 赋予任意的名称,代码依旧有效。在 React 中,有一个命名规范,通常会将代表事件的监听 prop 命名为 on[Event],将处理事件的监听方法命名为 handle[Event] 这样的格式。

这时候我们点击 Square 的时候,浏览器会报错,因为我们还没有定义 handleClick 方法。我们现在来向 Board 里添加 handleClick 方法:

class Board extends React.Component {
constructor(props) {
super(props);
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>
);
}
}

现在,我们可以通过点击 Square 来填充那些方格,效果与之前相同。但是,当前 state 没有保存在单个的 Square 组件中,而是保存在了 Board 组件中。每当 Board 的 state 发生变化的时候,这些 Square 组件都会重新渲染一次。把所有 Square 的 state 保存在 Board 组件中可以让我们在将来判断出游戏的胜者。

因为 Square 组件不再持有 state,因此每次它们被点击的时候,Square 组件就会从 Board 组件中接收值,并且通知 Board 组件。在 React 术语中,我们把目前的 Square 组件称做“受控组件”。在这种情况下,Board 组件完全控制了 Square 组件。

注意,我们调用了 .slice() 方法创建了 squares 数组的一个副本,而不是直接在现有的数组上进行修改。在下一节,我们会介绍为什么我们需要创建 square 数组的副本。

为什么不可变性在 React 中非常重要​

在上一节内容当中,我们建议使用 .slice() 函数对 squares 数组进行拷贝,而非直接修改现有的数组。接下来我们来学习不可变性以及不可变性的重要性。

一般来说,有两种改变数据的方式。第一种方式是直接修改变量的值,第二种方式是使用新的一份数据替换旧数据。

直接修改数据​

var player = {score: 1, name: 'Jeff'};
player.score = 2;
// player 修改后的值为 {score: 2, name: 'Jeff'}

新数据替换旧数据​

var player = {score: 1, name: 'Jeff'};

var newPlayer = Object.assign({}, player, {score: 2});
// player 的值没有改变, 但是 newPlayer 的值是 {score: 2, name: 'Jeff'}

// 使用对象展开语法,就可以写成:
// var newPlayer = {...player, score: 2};

不直接修改(或改变底层数据)这种方式和前一种方式的结果是一样的,这种方式有以下几点好处:

简化复杂的功能​

不可变性使得复杂的特性更容易实现。在后面的章节里,我们会实现一种叫做“时间旅行”的功能。“时间旅行”可以使我们回顾井字棋的历史步骤,并且可以“跳回”之前的步骤。这个功能并不是只有游戏才会用到——撤销和恢复功能在开发中是一个很常见的需求。不直接在数据上修改可以让我们追溯并复用游戏的历史记录。

跟踪数据的改变​

如果直接修改数据,那么就很难跟踪到数据的改变。跟踪数据的改变需要可变对象可以与改变之前的版本进行对比,这样整个对象树都需要被遍历一次。

跟踪不可变数据的变化相对来说就容易多了。如果发现对象变成了一个新对象,那么我们就可以说对象发生改变了。

确定在 React 中何时重新渲染​

不可变性最主要的优势在于它可以帮助我们在 React 中创建 pure components。我们可以很轻松的确定不可变数据是否发生了改变,从而确定何时对组件进行重新渲染。

查阅性能优化章节,以了解更多有关 shouldComponentUpdate() 函数及如何构建 pure components 的内容。

函数组件​

接下来我们把 Square 组件重写为一个函数组件。

如果你想写的组件只包含一个 render 方法,并且不包含 state,那么使用函数组件就会更简单。我们不需要定义一个继承于 React.Component 的类,我们可以定义一个函数,这个函数接收 props 作为参数,然后返回需要渲染的元素。函数组件写起来并不像 class 组件那么繁琐,很多组件都可以使用函数组件来写。

把 Square 类替换成下面的函数:

function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}

我们把两个 this.props 都替换成了 props。

tip

当我们把 Square 修改成函数组件时,我们同时也把 onClick={() => this.props.onClick()} 改成了更短的 onClick={props.onClick}(注意两侧都没有括号)。

轮流落子​

现在井字棋还有一个明显的缺陷有待完善:目前还不能在棋盘上标记 “O”。

我们将 “X” 默认设置为先手棋。你可以通过修改 Board 组件的构造函数中的初始 state 来设置默认的第一步棋子:

class Board extends React.Component {
constructor(props) {
super(props);
this.state = {
squares: Array(9).fill(null),
xIsNext: true,
};
}

棋子每移动一步,xIsNext(布尔值)都会反转,该值将确定下一步轮到哪个玩家,并且游戏的状态会被保存下来。我们将通过修改 Board 组件的 handleClick 函数来反转 xIsNext 的值:

  handleClick(i) {
const 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” 的值,这样就可以显示下一步是哪个玩家的了。

 render() {
const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');

return (
// 其他部分没有改变

现在你整个的 Board 组件的代码应该是下面这样的:

class Board extends React.Component {
constructor(props) {
super(props);
this.state = {
squares: Array(9).fill(null),
xIsNext: true,
};
}

handleClick(i) {
const squares = this.state.squares.slice();
squares[i] = this.state.xIsNext ? 'X' : 'O';
this.setState({
squares: squares,
xIsNext: !this.state.xIsNext,
});
}

renderSquare(i) {
return (
<Square
value={this.state.squares[i]}
onClick={() => this.handleClick(i)}
/>
);
}

render() {
const 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>
);
}
}

判断出胜者​

至此我们就可以看出下一步会轮到哪位玩家,与此同时,我们还需要显示游戏的结果来判定游戏结束。拷贝如下 calculateWinner 函数并粘贴到文件底部:

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;
}

传入长度为 9 的数组,此函数将判断出获胜者,并根据情况返回 “X”,“O” 或 “null”。

接着,在 Board 组件的 render 方法中调用 calculateWinner(squares) 检查是否有玩家胜出。一旦有一方玩家胜出,就把获胜玩家的信息显示出来,比如,“胜者:X” 或者“胜者:O”。现在,我们把 Board 的 render 函数中的 status 的定义修改为如下代码:

  render() {
const winner = calculateWinner(this.state.squares);
let status;
if (winner) {
status = 'Winner: ' + winner;
} else {
status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
}

return (
// 其他部分没有修改

最后,修改 handleClick 事件,当有玩家胜出时,或者某个 Square 已经被填充时,该函数不做任何处理直接返回。

  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,
});
}