状态提升
至此,我们已经拥有了编写井字棋游戏的基本构件。但现在,状态(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*
的格式命名;对于其implementation,用handle*
的格式命名。
(fixme)
请试着点击小方格。应该会收到报错信息,因为我们还没有定义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组件那里接收;同时,当它被点击的时候,会通知其父组件。我们把这种组件叫做受控组件。