0%

已完成 0/29 关

教学模式

你可以随时调整。

此设置调整教师的教学方式,无需重新生成课程。

第 2 关 含代码 约 35 分钟

教程:从零完整实现井字棋游戏与时间旅行

教程:从零完整实现井字棋游戏与时间旅行

井字棋(Tic-Tac-Toe)是 React 官方最为经典的实战教程。在本关中,我们将完整且不加删减地实现整个游戏,从最基础的九宫格棋盘开始,经历数据传递、状态提升、胜负计算,最终实现能够倒流历史记录的“时间旅行”功能。

准备好了吗?让我们一步一步动手搭建!


阶段一:构建棋盘骨架

井字棋是由 9 个小方格组成的 3x3 棋盘。在 React 里,最自然的拆分方式是:

  • 一个代表单个方格的 Square 组件;
  • 一个组织 9 个方格排成 3 行的 Board 组件。

1. 编写 Square 组件

单个方块本质上就是一个 HTML 按钮:

function Square() {
  return <button className="square">1</button>;
}

2. 编写 Board 组件组织 3x3 布局

我们用 Board 组件来渲染三行方格,每行用一个具有 board-row 类名的 div 包裹:

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
      <div className="board-row">
        <Square />
        <Square />
        <Square />
      </div>
    </>
  );
}

配合简单的 CSS 样式(每个 square 宽高 34px,float 排列),页面上就会出现一个整齐的 3x3 空白九宫格。


阶段二:通过 Props 向 Square 传递数据

现在每个格子都写死了数字 1。我们希望父组件 Board 能够告诉每个 Square 它应该展示什么。

1. 让 Square 接收 value prop

修改 Square 函数,通过参数解构接收 value:

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

2. 从 Board 向每个 Square 传递不同的值

export default function Board() {
  return (
    <>
      <div className="board-row">
        <Square value="0" />
        <Square value="1" />
        <Square value="2" />
      </div>
      <div className="board-row">
        <Square value="3" />
        <Square value="4" />
        <Square value="5" />
      </div>
      <div className="board-row">
        <Square value="6" />
        <Square value="7" />
        <Square value="8" />
      </div>
    </>
  );
}

刷新页面,你会看到 9 个格子上分别清晰地标出了 0 到 8 的序号。


阶段三:给组件添加交互

现在我们希望点击某个格子时,格子内部显示 "X"。

初学者往往会想:“既然格子要记住自己的点击,那我们在 Square 内部加一个 useState 不就行了吗?” 我们先试一下这种做法:

import { useState } from 'react';

function Square() {
  const [value, setValue] = useState(null);

  function handleClick() {
    setValue('X');
  }

  return (
    <button className="square" onClick={handleClick}>
      {value}
    </button>
  );
}

现在点击任何一个格子,它确实会变成 X!但是这会带来一个致命的架构问题: 每个 Square 的状态是各自独立的私有记忆。当玩家下棋时:

  1. 谁来协调两个人轮流落子(X 走一步,O 走一步)?
  2. 谁来检查 9 个格子的整体布局以判断是否有横、竖、斜三子连成一线?

Square 自己做不到这一点,因为它只看得到自己,看不到邻居。


阶段四:核心转折——状态提升(Lifting State Up)

为了让整个棋盘协同工作,我们必须把状态从 9 个独立的 Square 中抽取出来,提升到它们的父组件 Board 中统一管理!

1. 在 Board 中用数组管理 9 个格子的状态

我们在 Board 中声明一个长度为 9 的数组状态 squares,初始值全部为 null: Array(9).fill(null) 创建出 [null, null, null, null, null, null, null, null, null]。

同时,增加一个布尔值状态 xIsNext,用来记录下一步是不是该轮到 ‘X’ 下了。

import { useState } from 'react';

export default function Board() {
  const [xIsNext, setXIsNext] = useState(true);
  const [squares, setSquares] = useState(Array(9).fill(null));

  function handleClick(i) {
    // 如果该位置已经有棋子,直接返回,不允许重复落子
    if (squares[i]) {
      return;
    }
    // 浅拷贝一份数组,保持不可变性
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    setSquares(nextSquares);
    setXIsNext(!xIsNext);
  }

  return (
    <>
      <div className="status">下一位玩家: {xIsNext ? 'X' : 'O'}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

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

重要细节避坑: 为什么传给 Square 的是 onSquareClick={() => handleClick(0)},而不是 onSquareClick={handleClick(0)}?

  • 如果写 handleClick(0),会在组件渲染时立即执行该函数,导致调用 setSquares 触发重新渲染,从而陷入死循环!
  • () => handleClick(0) 是一个箭头函数定义,只有在按钮被真正点击时,它才会执行。

为什么要用 squares.slice() 拷贝数组? 在 JavaScript 中,直接 squares[i] = 'X' 叫做“变异(Mutation)”原有数组。而使用 .slice() 制作一份完整副本,把副本传给 setSquares,保持了数据的不可变性(Immutability)。这不仅让 React 的状态追踪极易维护,也为我们马上要做的“时间旅行撤销历史”奠定了绝对基础!


阶段五:声明胜者

现在两个玩家可以轮流落子了。接下来我们需要在有人连成三子时宣布获胜。

1. 胜负判断算法

在 3x3 棋盘中,获胜的一共有 8 种可能(3 条横线、3 条竖线、2 条对角线):

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]; // 返回 'X' 或 'O'
    }
  }
  return null;
}

2. 在 Board 中结合胜者信息

在 handleClick 中,如果有玩家获胜,点击棋盘应该不再产生响应:

function handleClick(i) {
  if (squares[i] || calculateWinner(squares)) {
    return;
  }
  const nextSquares = squares.slice();
  nextSquares[i] = xIsNext ? 'X' : 'O';
  setSquares(nextSquares);
  setXIsNext(!xIsNext);
}

在界面顶部更新状态文案:

const winner = calculateWinner(squares);
let status;
if (winner) {
  status = '获胜者: ' + winner;
} else {
  status = '下一位玩家: ' + (xIsNext ? 'X' : 'O');
}

阶段六:保存历史记录与实现“时间旅行”

如果我们要让玩家能回退到过去的任意一步(比如“回到第 3 步查看战局”),我们就需要保存每走一步之后整个棋盘的数组快照。

1. 再次状态提升:创建顶级 Game 组件

我们创建一个顶层 Game 组件,把整个游戏的棋局历史 history 存放在这里。 history 是一个“数组的数组”,形如:

[
  [null, null, null, null, null, null, null, null, null], // 第 0 步开局
  ['X', null, null, null, null, null, null, null, null],  // 第 1 步
  ['X', null, null, null, 'O', null, null, null, null],  // 第 2 步
]

并且使用 currentMove 记录当前正在查看第几步。

2. 生成历史跳转列表

使用 history.map 把每一步映射成一个 <button>:

const moves = history.map((squares, move) => {
  let description;
  if (move > 0) {
    description = '前往步骤 #' + move;
  } else {
    description = '前往游戏开局';
  }
  return (
    <li key={move}>
      <button onClick={() => jumpTo(move)}>{description}</button>
    </li>
  );
});

完整代码清单

至此,我们的井字棋游戏全部完工!以下是完整的最终代码,请仔细品味每个组件的分工:

import { useState } from 'react';

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

function Board({ xIsNext, squares, onPlay }) {
  function handleClick(i) {
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    const nextSquares = squares.slice();
    if (xIsNext) {
      nextSquares[i] = 'X';
    } else {
      nextSquares[i] = 'O';
    }
    onPlay(nextSquares);
  }

  const winner = calculateWinner(squares);
  let status;
  if (winner) {
    status = '获胜者: ' + winner;
  } else {
    status = '下一位玩家: ' + (xIsNext ? 'X' : 'O');
  }

  return (
    <>
      <div className="status">{status}</div>
      <div className="board-row">
        <Square value={squares[0]} onSquareClick={() => handleClick(0)} />
        <Square value={squares[1]} onSquareClick={() => handleClick(1)} />
        <Square value={squares[2]} onSquareClick={() => handleClick(2)} />
      </div>
      <div className="board-row">
        <Square value={squares[3]} onSquareClick={() => handleClick(3)} />
        <Square value={squares[4]} onSquareClick={() => handleClick(4)} />
        <Square value={squares[5]} onSquareClick={() => handleClick(5)} />
      </div>
      <div className="board-row">
        <Square value={squares[6]} onSquareClick={() => handleClick(6)} />
        <Square value={squares[7]} onSquareClick={() => handleClick(7)} />
        <Square value={squares[8]} onSquareClick={() => handleClick(8)} />
      </div>
    </>
  );
}

export default function Game() {
  const [history, setHistory] = useState([Array(9).fill(null)]);
  const [currentMove, setCurrentMove] = useState(0);
  const xIsNext = currentMove % 2 === 0;
  const currentSquares = history[currentMove];

  function handlePlay(nextSquares) {
    const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
    setHistory(nextHistory);
    setCurrentMove(nextHistory.length - 1);
  }

  function jumpTo(nextMove) {
    setCurrentMove(nextMove);
  }

  const moves = history.map((squares, move) => {
    let description;
    if (move > 0) {
      description = '前往步骤 #' + move;
    } else {
      description = '前往游戏开局';
    }
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{description}</button>
      </li>
    );
  });

  return (
    <div className="game">
      <div className="game-board">
        <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
      </div>
      <div className="game-info">
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

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

学习总结

你完成了一个真正的 React 应用程序!在这个过程中你亲身体验了:

  1. 组件化思考:将大界面分解为 Square、Board、Game 三层体系;
  2. 状态提升:当多个子组件需要协同或由父级判定时,把状态提升到共同父组件;
  3. 不可变数据更新:使用 slice() 与展开语法复制数组,让状态变更清晰可逆,直接催生了“时间旅行”功能!
下一站:用 React 思考:将设计原型拆解为组件与数据流 →
© 2026 React 官方教程