「 ReactJS 笔记 」 L01 井字棋

学习新东西的时候只看肯定是不够的, 所以既然 ReactJS 官方提供了一个学习的案例, 那么就一边写Demo, 一边读文档.

Tips: 我是直接看案例然后按照自己的理解去写的, 并没有按照官方案例亦步亦趋, 所以可能流程会不一样.

井字棋这个 demo 主要分为两部分:

棋盘

  • 点击棋盘空白格子可以落子, 落子之后交换棋手.
  • 棋格已经落子 / 有棋手获胜时不可落子

游戏信息

  • 棋手信息
    • 提示当前落子的棋手信息
    • 棋手获得胜利后, 显示胜利方
  • 对局历史记录
    • 展示对局历史信息
    • 点击历史信息进行操作回溯

先从棋盘开始, 一共有9个格子, 每个格子有 3 个状态 (空, X, O)
我想到的就直接用 flex 布局了, 不需要考虑 rowcol, 这样也可以直接用循环直接输出 9 个棋格, 同时也可以从循环里边取出棋格的状态

所以首先是造棋盘:

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
// App.js
import React, { Component } from 'react'; // 引入 React 以及React下的 Component 类
import './App.styl'; // 引入样式文件

class App extends Component {
// 声明棋盘状态
state = {
squares: [0, 1, 2, 3, 4, 5, 6, 7, 8] // 这里是棋盘9个格子的状态
}
//
/*
* 当然你也可以使用 constructor,
* 如果不理解 ES6 类的继承可以阅读 https://yogwang.github.io/2020/JS-class-constructor/
constructor(){
super()
this.state={
squares: [0, 1, 2, 3, 4, 5, 6, 7, 8]
}
}
*/
render () {
const { squares } = this.state
return (
<div id='app'>
<div className='game-bar'>
// 棋盘
<div className='board'>
// 循环遍历棋格状态 并且返回单元格, 记得添加key, 这点于Vue一样
{squares.map((info, index) => (
<div className='square' key={index}>{info}</div>
))}
</div>
</div>
</div>
)
}
}
export default App;

样式这边我用的预编译是 Stylus, 省略了括号, 冒号以及分号, 其它和 SCSS 基本一样.
如果不了解大概看一下就行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// App.styl
$square = 60px /* 定义一个格子的宽度 */
// 棋盘样式
.board
width $square * 3 /* 一行3个格子的宽度 */
border 1px solid black
display flex /* flex 布局 */
flex-wrap wrap /* 超出宽度可换行 */
// 棋格样式
.square
width $square
height @width /* 高度同宽度 */
line-height @height /* 文字行高同容器高度 这里应该要减去 border 的高度, 但是不碍事 */
font-size 25px
text-align center
border 1px solid black
box-sizing border-box /* 修改盒模型 */
cursor pointer

完成之后大概是这样的一个样子

棋盘

然后是游戏信息的展示, 因为是交替落子, 所以单数步数是 棋手X 落子的话, 双数步数就一定是 棋手O 落子.
顺便给棋格绑定上点击事件, 棋盘的组件大概可以这样初步完成

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
// App.js
import React, { Component } from 'react';
import './App.styl';

class App extends Component {
state = {
squares: [0, 1, 2, 3, 4, 5, 6, 7, 8],
step:0, // 增加计步器
}
// 获取当前Player
getCurPlayer () {
const { step } = this.state // 获取当前步数
return step % 2 ? "O" : "X" // 按照步数奇偶返回棋手
}
// 方块被点击
handleSquareClick (position, player) {
const { squares, step } = this.state // 获取当前棋盘信息以及步数

// 既然获取到了棋格信息那么就顺便输出一下当前信息
console.log(`当前步数: ${step}, 棋手: ${player}, 点击位置: ${position}`)

const newStep = step + 1 // 步数 +1
const newSquares = squares
newSquares[position] = player // 修改对应棋格信息

// 修改State, 并且触发渲染
this.setState({
squares: newSquares,
step: newStep
})
}
render () {
const { squares } = this.state
const player = this.getCurPlayer()
return (
<div id='app'>
<div className='game-bar'>
<div className='board'>
{squares.map((info, index) => (
/*
* 增加onClick事件, 这里和Vue不一样, 需要使用箭头函数, 或者使用 .bind(this) 绑定this
* 具体原因请看上一篇 [React L00 起步]
*/
<div className='square' key={index} onClick={() => this.handleSquareClick(index,player)}>{info}</div>
))}
</div>
</div>
<div className='game-info'>
// 棋手信息
<div className='player-info'>Current player: {player}</div>
</div>
</div>
)
}
}
export default App;
1
2
3
4
5
6
7
8
9
// App.styl
// ...接上一部分样式
.game-info
width 400px
height 600px
max-height 100vh
margin-left 60px
.player-info
font-size 22px

棋盘落子以及棋手信息

现在棋盘可以被修改了, 棋手信息也有了, 同样也可以获取到每次操作的落子信息.

那么就可以先把历史记录加上, 并且展示

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
57
58
59
60
// App.js
import React, { Component } from 'react';
import './App.styl';

class App extends Component {
state = {
squares: [0, 1, 2, 3, 4, 5, 6, 7, 8],
step:0,
history:[], // 增加历史记录
}
// 获取当前Player
getCurPlayer () {
const { step } = this.state
return step % 2 ? "O" : "X"
}
// 方块被点击
handleSquareClick (position, player) {
const { squares, step, history } = this.state
const newStep = step + 1
const newSquares = squares
newSquares[position] = player
const newHistory = history.concat([{
step:step,
player:player,
position:position
}])

this.setState({
squares: newSquares,
step: newStep,
history: newHistory
})
}
render () {
const { squares,history } = this.state
const player = this.getCurPlayer()
return (
<div id='app'>
<div className='game-bar'>
<div className='board'>
{squares.map((info, index) => (
<div className='square' key={index} onClick={() => this.handleSquareClick(index,player)}>{info}</div>
))}
</div>
</div>
<div className='game-info'>
<div className='player-info'>Current player: {player}</div>
// 历史信息
<ol className='history'>
// 遍历循环历史信息记录
{history.map((record, index) => (
<li key={index}>Player_{record.player} to {record.position}</li>
))}
</ol>
</div>
</div>
)
}
}
export default App;

现在的游戏界面应该如下
添加并展示历史记录

这个时候呢, 就会有几个问题:

  • 棋格可以重复落子
  • 没有获胜判断
  • 棋盘默认显示数字, 很不美观

其实还有一个问题, 在历史回溯的时候会很麻烦, 这个我实在游戏基本都完成了之后才发现的. 这个之后再说

所以在落子的时候增加当前棋格不为空 的判断, 并且以 null 填充数组;

代码修改如下

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
57
58
59
60
// App.js
import React, { Component } from 'react';
import './App.styl';

class App extends Component {
state = {
squares: new Array(9).fill(null), // 创建一个长度为 9 的数组,并且填充 null
step:0,
history:[],
}
// 获取当前Player
getCurPlayer () {
const { step } = this.state
return step % 2 ? "O" : "X"
}
// 方块被点击
handleSquareClick (position, player) {
const { squares, step, history } = this.state
if (squares[position]) return // 增加棋格不会空的判断
const newStep = step + 1
const newSquares = squares
newSquares[position] = player
const newHistory = history.concat([{
step:step,
player:player,
position:position
}])
this.setState({
squares: newSquares,
step: newStep,
history: newHistory
})
}
render () {
const { squares, history } = this.state
const player = this.getCurPlayer()
return (
<div id='app'>
<div className='game-bar'>
<div className='board'>
{squares.map((info, index) => (
<div className='square' key={index} onClick={() => this.handleSquareClick(index,player)}>{info}</div>
))}
</div>
</div>
<div className='game-info'>
<div className='player-info'>Current player: {player}</div>
// 历史信息
<ol className='history'>
// 遍历循环历史信息记录
{history.map((record, index) => (
<li key={index}>Player_{record.player} to {record.position}</li>
))}
</ol>
</div>
</div>
)
}
}
export default App;

添加不为空判断

好了, 棋盘的大致都完成了, 现在来写获胜判断.
因为我想者是每次循环一次所有棋格, 然后找出获胜的玩家, 但是这里循环有点傻,
所以我是看了一下官方的demo, 是这样的一个思路, 把所有的胜利条件都所列了出来, 然后去循环胜利条件, 如果所有胜利条件都循环结束, 没有出现胜利的玩家则继续游戏.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 计算赢家
calculateWinner () {
// 获胜条件
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],
];
const { squares } = this.state // 取得所有棋盘数据
// 循环判断胜利条件
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;
}

然后补完一下棋盘, 胜利时在当前玩家信息提示获胜玩家.

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// App.js
import React, { Component } from 'react';
import './App.styl';
// 获胜条件
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],
];
class App extends Component {
state = {
squares: new Array(9).fill(null),
step:0,
history:[],
}
// 获取当前Player
getCurPlayer () {
const { step } = this.state
return step % 2 ? "O" : "X"
}
// 方块被点击
handleSquareClick (position, player) {
const { squares, step, history } = this.state
if (this.calculateWinner() || squares[position]) return // 增加是否有赢家的判断
const newStep = step + 1
const newSquares = squares
newSquares[position] = player
const newHistory = history.concat([{
step:step,
player:player,
position:position
}])
this.setState({
squares: newSquares,
step: newStep,
history: newHistory
})
}
// 计算赢家
calculateWinner () {
const { squares } = this.state
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;
}
render () {
const { squares,history } = this.state
const player = this.getCurPlayer()
const winner = this.calculateWinner() // 获取赢家
return (
<div id='app'>
<div className='game-bar'>
<div className='board'>
{squares.map((info, index) => (
<div className='square' key={index} onClick={() => this.handleSquareClick(index,player)}>{info}</div>
))}
</div>
</div>
<div className='game-info'>
<div className='player-info'>
{winner ? `Player_${winner} is Winner!!!` : `Current player: ${player}`}
// 判断是否有玩家获胜, 如果有则输出获胜信息, 没有则输出当前玩家信息
</div>
<ol className='history'>
{history.map((record, index) => (
<li key={index}>Player_{record.player} to {record.position}</li>
))}
</ol>
</div>
</div>
)
}
}
export default App;

整个游戏流程到现在基本就完成了, 但是点击历史记录回溯操作的功能还没有加上来.
这个时候我就有点卡壳了, 不知道在当前逻辑下怎么回退操作. 就去看了官方的案例,
它是每一步在历史记录内插入当前棋盘数据, 然后回退的时候直接读取历史棋盘数据, 并且没有在历史记录中展示每一步的具体操作内容.

所以想了想还是继续原先的历史记录, 并且按照历史记录来生成棋盘数据(这边参考了山地人老师的思路),
那这样的话, 就不需要手动去修改棋盘数据了, 只要调用一下生成棋盘的函数就可以了. 具体代码修改如下

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// App.js
import React, { Component } from 'react';
import './App.styl';
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],
];
class App extends Component {
state = {
squares: new Array(9).fill(null),
step: 0,
history: [],
}
// 获取当前Player
getCurPlayer () {
const { step } = this.state
return step % 2 ? "O" : "X"
}
// 方块被点击
handleSquareClick (position, player) {
const { squares, step, history } = this.state
if (this.calculateWinner() || squares[position]) return
const newStep = step + 1
// 移除了棋盘数据操作,
const newHistory = [
...history.slice(0, step),
{
step: step,
player: player,
position: position
}
]
this.setState({
step: newStep,
history: newHistory,
squares: this.calculateSquares(newHistory, newStep), // 调用 calculateSquares 产生数据
})
}
// 计算赢家
calculateWinner () {
const { squares } = this.state
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;
}
// 生成棋盘数据
calculateSquares (history, step) {
const newSquares = new Array(9).fill(null) // 创建空棋盘数组
// 循环插入历史数据
for (let i = 0; i < step; i += 1) {
const tempHistory = history[i]
newSquares[tempHistory.position] = tempHistory.player
}
return newSquares // 返回棋盘数据
}
// 回退历史步骤
handleGoBack (step) {
const { history } = this.state
const tempHistory = history.slice(0, step) // 根据传入步数截取历史记录
this.setState({
step: step, // 重设步数
squares: this.calculateSquares(tempHistory, step), // 生成棋盘数据
})
}
render () {
const { squares, history } = this.state
const player = this.getCurPlayer()
const winner = this.calculateWinner()
return (
<div id='app'>
<div className='game-bar'>
<div className='board'>
{squares.map((info, index) => (
<div className='square' key={index} onClick={() => this.handleSquareClick(index, player)}>{info}</div>
))}
</div>
</div>
<div className='game-info'>
<div className='player-info'>
{winner ? `Player_${winner} is Winner!!!` : `Current player: ${player}`}
</div>
<ol className='history'>
{history.map((record, index) => (
<li key={index} onClick={() => this.handleGoBack(record.step + 1)}>Player_{record.player} to {record.position}</li>
))}
</ol>
</div>
</div>
)
}
}
export default App;

至此为止, 基本的游戏功能都完成了, 只不过整个项目都是写在一个文件中的, 并没有进行组件化操作.
你们可以自行决定怎么拆分, 也可以继续阅读下去.


组件化

我认为可以把棋盘和游戏信息展示进行拆分;

  • 棋盘
    这部分我觉得单独一个组件就可以了, 不用继续分解了, 因为单个棋格就一个 div 元素 加上 onClick 事件, 组件化的结果并没有简化代码, 反而增加代码量和不方便阅读.

  • 游戏信息
    可以继续拆分成玩家信息和历史记录, 然后再对应组件内进行状态维护和样式的编辑.

拆分后的 App.js 文件如下

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// App.js
import React, { Component } from 'react';
import Board from './components/Board'
import GameInfo from './components/GameInfo'
import './App.styl';

// 获胜条件
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],
];
class App extends Component {
// 定义属性
state = {
squares: new Array(9).fill(null),
player: "",
history: [],
step: 0,
}
// 计算当前Player
getCurPlayer () {
const { step } = this.state
return step % 2 ? "O" : "X" // 能否被2整除
}
// 生成棋盘数据
calculateSquares (history, step) {
// 创建新数组
const newSquares = new Array(9).fill(null)
// 循环插入历史数据
for (let i = 0; i < step; i += 1) {
const tempHistory = history[i]
newSquares[tempHistory.position] = tempHistory.player
}
return newSquares
}
// 计算赢家
calculateWinner () {
const { squares } = this.state
// 循环判断胜利条件
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;
}
// 方块被点击
handleSquareClick (position, player) {
const { squares, step, history } = this.state
// 如果已经获胜或棋格不为空则跳出
if (this.calculateWinner() || squares[position]) return
const newStep = step + 1 // 新步数
// 插入新历史记录
const newHistory = [
...history.slice(0, step),
{
step: step,
player: player,
position: position
}
]
this.setState({
step: newStep,
history: newHistory,
squares: this.calculateSquares(newHistory, newStep), // 生成棋盘数据
})
}
// 回退历史步骤
handleGoBack (step = 0) {
const { history } = this.state
const tempHistory = history.slice(0, step)
this.setState({
step: step,
squares: this.calculateSquares(tempHistory, step), // 生成棋盘数据
})
}
// 重新开始
handleRestart () {
this.setState({
history: []
})
this.handleGoBack()
}
render () {
const { squares, history } = this.state
const player = this.getCurPlayer()
const winner = this.calculateWinner()
return (
<div id='app'>
<div className='game-bar'>
<Board squares={squares} onClick={i => this.handleSquareClick(i, player)} />
</div>
<GameInfo
player={player}
winner={winner}
history={history}
goBack={step => this.handleGoBack(step)} />
</div>
)
}
}
export default App;

棋盘组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Board.js
import React, { Component } from 'react';
import './Board.styl';

class Board extends Component {
// 生成棋格
renderSquare (status, index) {
return (<div className='square' onClick={() => this.props.onClick(index)} key={index}>{status}</div>)
}
render () {
const { squares } = this.props // 获取棋盘数据
return (
<div className='board'>
// 循环棋盘数据生成棋格
{squares.map((status, index) => this.renderSquare(status, index))}
</div>
)
}
}
export default Board

游戏信息组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// GameInfo.js
import React, { Component } from 'react'
import History from './History'

class GameInfo extends Component {
render () {
const { player, winner, history } = this.props // 从父组件获取棋手, 胜利方, 历史记录数据
// 当前棋手信息, 如果已经有棋手胜利 返回胜利信息
const playerInfo = winner ? `Player_${winner} is Winner!!!` : `Current player: ${player}`
return (
<div className='game-info'>
<div className='player-info'>{playerInfo}</div>
<History history={history} onClick={step => this.props.goBack(step)}></History>
</div>
)
}
}
export default GameInfo

操作历史组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// History.js
import React, { Component } from 'react';
import Record from './Record'
import './History.styl';

class History extends Component {
render () {
const { history } = this.props
return (
<ol className='history'>
<li onClick={() => this.props.onClick(0)}>Go to game start</li> // 回到游戏开始
{
history.map((record, index) => (
<Record info={record} key={index} onClick={() => this.props.onClick(record.step + 1)}></Record>
))
}
</ol>
)
}
}
export default History

单条历史记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, { Component } from 'react'

class Record extends Component {
// 计算坐标 返回二维坐标 [0,0]
calculatePosition (position) {
const posY = (position / 3 >> 0) + 1 // 取行号
const posX = position % 3 + 1 // 取列号
return `[${posY},${posX}]`
}
render () {
const { info } = this.props // 继承历史记录
return (
<li onClick={() => this.props.onClick()}>
Player_{info.player} to {this.calculatePosition(info.position)}
</li>
)
}
}
export default Record

样式文件如下:

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
// App.styl
body
font-family consolas
margin 0
#app
width 100vw
height 100vh
display flex
justify-content center
align-items center
.game-bar
height 600px
max-height 100vh
.game-info
width 400px
height 600px
max-height 100vh
.player-info
font-size 22px

// Board.styl
$square = 60px
.board
width $square * 3
border 1px solid black
margin-right 50px
display flex
flex-wrap wrap
.square
width $square
height $square
line-height @height
font-size 25px
text-align center
border 1px solid black
box-sizing border-box
cursor pointer
&:hover
background #ddd

// History.styl
.history
padding 0
counter-reset number -1
li
margin 5px 0
counter-increment number
list-style-type none
cursor pointer
&:before
content counter(number) '.'
margin-right 10px
display inline-bloack
&:hover
color red
text-decoration underline

完善后棋盘

以上

附录

一些卡壳的地方

  • 如何创建 State 状态
  • Constructor 是怎么一回事
  • 组件化后子组件如何触发父组件事件
  • 为何 onClick 中的事件会自动执行
  • React中怎么使用 Stylus
  • 组件多层嵌套的情况下怎么获取其它的属性状态,类似 Vuex 的Store