「 ReactJS 笔记 」 L01 井字棋

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

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

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

棋盘

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

游戏信息

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

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

所以首先是造棋盘:

// 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 基本一样.
如果不了解大概看一下就行

// 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 落子.
顺便给棋格绑定上点击事件, 棋盘的组件大概可以这样初步完成

// 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;
// App.styl
// ...接上一部分样式
.game-info
  width 400px
  height 600px
  max-height 100vh
  margin-left 60px
.player-info
  font-size 22px

棋盘落子以及棋手信息

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

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

// 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 填充数组;

代码修改如下

// 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, 是这样的一个思路, 把所有的胜利条件都所列了出来, 然后去循环胜利条件, 如果所有胜利条件都循环结束, 没有出现胜利的玩家则继续游戏.

// 计算赢家
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;
}

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

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

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

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

// 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 文件如下

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

棋盘组件

// 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

游戏信息组件

// 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

操作历史组件

// 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

单条历史记录

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

样式文件如下:

// 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