React学习笔记:官方Demo

TicTacToe游戏

看到React官方Demo更新了,React的笔记也好久没写了,虽然说从今年四月份开始自学React也半年了,但中间各种个人原因基本上四月份到九月份都是停放着没动过了,而且四月份的那几篇笔记也就是翻译了几篇官网的文章,理解也不够深刻。实际上满打满算也就学了两个月,正好趁着这次教程更新,笔记系列重新写过,接下来一段时间好好弄弄ReactJS和React Native的学习。

新版的教程编写的是一个简单的TicTacToe游戏,笔者依照对原文的理解,自己从头开发了一遍教程的小程序,实际代码与官网有细微区别,但大体差不多。此外涉及一些细节为什么这么做的深层次原因这里不做详细解释(如为什么把状态都放到顶层组件中,为什么使用无状态函数式组件等),不然这篇笔记就太长了,有兴趣的读者可以自己去官网或者找在资料寻找答案。

先直接看程序效果。

首先,整个游戏(Game)肯定是一个大组件,这个游戏组件不仅包括整个大棋盘,还包括整个游戏的一些状态和动作(稍微复杂一点的设计会把右边的游戏信息也设计成一个组件甚至多个组件,不过这里参照官网,就一并放在Game组件中了)。

其次,棋盘(Board)也可以设计成一个组件,棋盘需要维护棋盘中的每一个方格(Square),最后就是方格,是我们最小的组件,小方格需要负责显示棋子并且是下棋事件的直接响应者。

  • Game
  • Board
  • Square

在React老版的教程中,有推荐大规模的应用采取从小到大的组件编写方式,小规模的话从大到小编写,笔者自己的经验是一律从小到大编写应用,因为这样直观上更容易关注当前在做的工作(这个因人而异,或者实际项目中听项目经理的)。

无状态函数式组件

1
2
3
4
5
6
// Square组件
let Square = (props) => (
<button className="square" onClick={props.handleClick}>
{props.player}
</button>
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Board组件
let Board = (props) => {
const squares = props.squares.map((val, idx) => {
return (
<Square key={val + idx} player={val}
handleClick={
() => {
props.handleClick(idx);
}
}/>
);
});
return (
<div className="board">
{squares}
</div>
);
}

这里采用了无状态函数式组件(Stateless Functional Component)编写Square和Board组件,它们被分离成了独立的可重用的UI组件,这样的组件几乎没有任何业务逻辑,自身的事件接口也通过React组件属性的方式完全交给了父级组件。

可以看到Square和Board都只有最基本的渲染绘制UI的逻辑,没有业务逻辑在这里。Square接收两个参数,player用于显示当前下棋者的棋子类型(O还是X),handleClick用于处理点击该Square(即下棋)的事件,这个逻辑也由上层传入。

Game组件

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
class Game extends React.Component {
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
let curIsX = false;
let history = [Array(9).fill(null)];
let winner = null;
let curStep = 1;
this.state = {
curIsX,
history,
winner,
curStep,
};
}
render() {
const {curIsX, history, winner, curStep} = this.state;
const boardState = history[curStep - 1];
let historySteps = history.map((val, idx) => (
<li key={idx}>
<a href="javascript:;" onClick={() => {
this.jumpTo(idx);
}}>
# {idx === 0 ? "Game Start" : `Step ${idx}`}
</a>
</li>
));
let status = `Next Player: ${curIsX ? 'X' : 'O'}`;
status = winner ? `Winner is ${winner}` : status;
return (
<div>
<Board squares={boardState}
handleClick={this.handleClick}/>
<div style={{float: 'left'}}>
<h5>{status}</h5>
<ol style={{paddingLeft: 20}}>
{historySteps}
</ol>
</div>
</div>
);
}
// ...
}

构造方法中一共有四个状态:curIsX用于判断当前棋手(X或者O);history用来记录历史状态;winner用来记录赢家(未结束则为null);curStep用于记录当前处理历史状态的具体位置;一般情况下是history最新状态;但是当执行回到历史的某个状态的逻辑;curStep就指向该状态的位置。

注意history初始状态只有一个元素,该元素是一个长度为9的一维数组,所有值均为空,一次代表棋盘上的9个小方格的状态。

具体到render方法中,我们看到Board组件获取历史的最新状态,并将该状态交给了Board,此外还讲handleClick方法交给了Board,之前的代码中我们已经看到Board会进一步把handleClick交给Square。

然后我们还绘制了历史状态,并给历史状态加了点击事件,分别响应不同参数的jumpTo方法。

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
// ...
handleClick(index) {
let {curIsX, history, winner, curStep} = this.state;
if (curStep === 10 || winner !== null) {
throw new Error('Game Over.');
return;
}
let boardState = JSON.parse(JSON.stringify(history[curStep - 1]));
if(boardState[index] != null){
return;
}
boardState[index] = curIsX ? 'X' : 'O';
winner = Game.calcWinner(boardState);
curIsX = curIsX ? false : true;
history.splice(curStep++, 10, boardState);
this.setState({
curIsX,
history,
winner,
curStep,
});
}
jumpTo(index) {
let {curIsX, history, winner, curStep} = this.state;
const boardState = history[index];
winner = Game.calcWinner(boardState);
curIsX = index % 2 === 1
curStep = index + 1;
this.setState({
curIsX,
winner,
curStep,
});
}
static calcWinner(boardState) {
const patterns = [
[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 pattern of patterns) {
if (boardState[pattern[0]] === boardState[pattern[1]]
&& boardState[pattern[0]] === boardState[pattern[2]]
&& boardState[pattern[0]] != null) {
console.log(`The winner is ${boardState[pattern[0]]}.`);
return boardState[pattern[0]];
}
}
return null;
}
// ...

除了handleClick和jumpTo两个方法以外,还有一个静态方法calcWinner,该方法用于判断当前步骤是否产生赢家,如有则返回赢家,否则返回空值。下面来依次解释三个方法。

handleClick负责处理一次下棋事件,首先判断游戏是否结束,包括棋盘是否已经全部下满(当前步骤已经到了第10步)和是否已经有赢家,如果是则抛出错误(Game Over),否则进行下一步逻辑;之后深度复制一个棋盘当前状态,将当前棋子写入对应的位置后,加入历史状态的下一个位置中(curStep),并且清楚这之后的所有历史状态(即清空未来状态,这么做主要是为了处理紧接在jumpTo函数之后的下棋事件);然后更新下一步棋的棋手(curIsX);最后更新状态。

jumpTo接收一个参数,代表跳转到的历史状态的下标,然后获取该历史状态,计算是否有赢家(有可能会跳到一个已经有赢家的历史步骤,即有赢家的一局的最后一步),并更新下一步棋的棋手(curIsX);最后更新状态。

最后的calcWinner最直观,该方法接受一个棋盘状态作为参数,方法中初始设定好了所有可能赢的状态,将所有赢棋的状态和棋盘状态遍历匹配后看是否有结果,有的话返回赢家,否则返回空值。

所有代码

以上是所有代码;此外还有本站的一个示例网页

对比之前的那篇Think in React,这次的TicTacToe游戏Demo更侧重无状态函数式组件的使用,以及组件状态存储位置的抬升(Lifting State Up),状态不可变的使用等。

参考

  1. Components and Props - React
  2. Think in React - React
  3. React Demo - OuyangChao GitHub