教程:从零完整实现井字棋游戏与时间旅行
井字棋(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 的状态是各自独立的私有记忆。当玩家下棋时:
- 谁来协调两个人轮流落子(X 走一步,O 走一步)?
- 谁来检查 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 应用程序!在这个过程中你亲身体验了:
- 组件化思考:将大界面分解为 Square、Board、Game 三层体系;
- 状态提升:当多个子组件需要协同或由父级判定时,把状态提升到共同父组件;
- 不可变数据更新:使用
slice()与展开语法复制数组,让状态变更清晰可逆,直接催生了“时间旅行”功能!