快速学习React-(第二天)-完成井字棋游戏
存储落子历史
如果你改变了 squares 数组,实现时间旅行将非常困难。
但是,你在每次落子后都使用 slice() 创建 squares 数组的新副本,并将其视为不可变的。这将允许你存储 squares 数组的每个过去的版本,并在已经发生的轮次之间“来回”。
把过去的 squares 数组存储在另一个名为 history 的数组中,把它存储为一个新的 state 变量。history 数组表示所有棋盘的 state,从第一步到最后一步,其形状如下:
[// Before first move[null, null, null, null, null, null, null, null, null],// After first move[null, null, null, null, 'X', null, null, null, null],// After second move[null, null, null, null, 'X', null, null, null, 'O'],// ...]再一次“状态提升”
你现在将编写一个名为 Game 的新顶级组件来显示过去的着法列表。这就是放置包含整个游戏历史的 history state 的地方。
将 history state 放入 Game ,这使 Game 组件可以完全控制 Board 的数据,并使它让 Board 渲染来自 history 的之前的回合。
首先,添加一个带有 export default 的 Game 组件。让它渲染 Board 组件和一些标签。
要删除 function Board() { 声明之前的 export default 关键字,并将它们添加到 function Game() { 声明之前。这会告诉你的 index.js 文件使用 Game 组件而不是你的 Board 组件作为顶层组件。
function Board() {// ...
}export default function Game() {return (<div className="game"><div className="game-board"><Board /></div><div className="game-info"><ol>{/*TODO*/}</ol></div></div>);
}向 Game 组件添加一些 state 以跟踪下一个玩家和落子历史:
export default function Game() {const [xIsNext, setXIsNext] = useState(true);const [history, setHistory] = useState([Array(9).fill(null)]);// ...要渲染当前落子的方块,你需要从 history 中读取最后一个 squares 数组。你不需要 useState——你已经有足够的信息可以在渲染过程中计算它:
export default function Game() {const [xIsNext, setXIsNext] = useState(true);const [history, setHistory] = useState([Array(9).fill(null)]);const currentSquares = history[history.length - 1];接下来,在 Game 组件中创建一个 handlePlay 函数,Board 组件将调用该函数来更新游戏。将 xIsNext、currentSquares 和 handlePlay 作为 props 传递给 Board 组件:
export default function Game() {const [xIsNext, setXIsNext] = useState(true);const [history, setHistory] = useState([Array(9).fill(null)]);const currentSquares = history[history.length - 1];function handlePlay(nextSquares) {// TODO}return (<div className="game"><div className="game-board"><Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />//...)
}现在,删除调用 useState 的 Board 函数的前两行将 Board 组件里面的 handleClick 中的 setSquares 和 setXIsNext 调用替换为对新 onPlay 函数的一次调用,这样 Game 组件就可以在用户单击方块时更新 Board:
export default function Board() {const [xIsNext, setXIsNext] = useState(true);const [squares, setSquares] = useState(Array(9).fill(null));function handleClick(i) {if (calculateWinner(squares) || squares[i]) {return;}const nextSquares = squares.slice();if (xIsNext) {nextSquares[i] = 'X';} else {nextSquares[i] = 'O';}setSquares(nextSquares);setXIsNext(!xIsNext);}......
}
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);}//...
}Board 组件完全由 Game 组件传递给它的 props 控制。你需要在 Game 组件中实现 handlePlay 函数才能使游戏重新运行。
handlePlay 被调用应该做什么?请记住,Board 以前使用更新后的数组调用 setSquares;现在它将更新后的 squares 数组传递给 onPlay。
handlePlay 函数需要更新 Game 的 state 以触发重新渲染,但是你没有可以再调用的 setSquares 函数——你现在正在使用 history state 变量来存储这些信息。你需要追加更新后的 squares 数组来更新 history 作为新的历史入口。你还需要切换 xIsNext,就像 Board 过去所做的那样:
export default function Game() {//...function handlePlay(nextSquares) {setHistory([...history, nextSquares]);setXIsNext(!xIsNext);}//...
}在这里,[...history, nextSquares] 创建了一个新数组,其中包含 history 中的所有元素,后跟 nextSquares。(你可以将 ...history 展开语法理解为“枚举 history 中的所有元素”。)
例如,如果 history 是 [[null,null,null], ["X",null,null]],nextSquares 是 ["X",null,"O"],那么新的 [...history, nextSquares] 数组就是 [[null,null,null], ["X",null,null], ["X",null,"O"]]。
此时,你已将 state 移至 Game 组件中,并且 UI 应该完全正常工作,就像重构之前一样。这是此时代码的样子:
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: ' + winner;} else {status = 'Next player: ' + (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 [xIsNext, setXIsNext] = useState(true);const [history, setHistory] = useState([Array(9).fill(null)]);const currentSquares = history[history.length - 1];function handlePlay(nextSquares) {setHistory([...history, nextSquares]);setXIsNext(!xIsNext);}return (<div className="game"><div className="game-board"><Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} /></div><div className="game-info"><ol>{/*TODO*/}</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;
}
选择Key
你需要为每个列表项指定一个 key 属性,以将每个列表项与其兄弟项区分开来。如果你的数据来自数据库,数据库 ID 可以用作 key:
<li key={user.id}>{user.name}: {user.taskCount} tasks left
</li>- key 的作用:作为列表项的“身份标识”,帮助 React 在重新渲染时识别每个列表项:
- 新 key 出现 → 创建新组件;
- 旧 key 消失 → 销毁对应组件;
- key 匹配 → 复用原有组件(保持状态)。
- key 与状态的关系:key 决定组件是否复用:
- key 不变 → 组件复用,状态保留;
- key 变化 → 组件销毁重建,状态重置。
- key 的特殊性:
- 是 React 内部使用的保留属性,不算作子组件的 props,子组件无法获取;
- 只需在同级列表项中唯一,无需全局唯一。
- 使用规范:
- 动态列表必须指定合适的 key(优先用数据自带的唯一标识,如
user.id); - 禁止用数组索引作 key(会在增删排序时出问题),即使显式传
key={i}也不推荐; - 若没有合适的 key,应调整数据结构以提供唯一标识。
- 动态列表必须指定合适的 key(优先用数据自带的唯一标识,如
实现时间旅行
在井字棋游戏的历史中,过去的每一步都有一个唯一的 ID 与之相关联:它是动作的序号。落子永远不会被重新排序、删除或从中间插入,因此使用落子的索引作为 key 是安全的。
在 Game 函数中,你可以将 key 添加为 <li key={move}>,如果你重新加载渲染的游戏,React 的“key”错误应该会消失:
import { useState } from 'react';// TODO}//histroy是一个数组const moves = history.map((squares, move) => {let description;if (move > 0) {description = 'Go to move #' + move;} else {description = 'Go to game start';}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;
}在你可以实现 jumpTo 之前,你需要 Game 组件来跟踪用户当前正在查看的步骤。为此,定义一个名为 currentMove 的新 state 变量,默认为 0:
export default function Game() {const [xIsNext, setXIsNext] = useState(true);const [history, setHistory] = useState([Array(9).fill(null)]);const [currentMove, setCurrentMove] = useState(0);const currentSquares = history[history.length - 1];//...
}接下来,更新 Game 中的 jumpTo 函数来更新 currentMove。如果你将 currentMove 更改为偶数,你还将设置 xIsNext 为 true。
export default function Game() {// ...function jumpTo(nextMove) {setCurrentMove(nextMove);setXIsNext(nextMove % 2 === 0);}//...
}你现在将对 Game 的 handlePlay 函数进行两处更改,该函数在你单击方块时调用。
- 如果你“回到过去”然后从那一点开始采取新的行动,你只想保持那一点的历史。不是在
history中的所有项目(...扩展语法)之后添加nextSquares,而是在history.slice(0, currentMove + 1)中的所有项目之后添加它,这样你就只保留旧历史的那部分。 - 每次落子时,你都需要更新
currentMove以指向最新的历史条目。
function handlePlay(nextSquares) {const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];setHistory(nextHistory);setCurrentMove(nextHistory.length - 1);setXIsNext(!xIsNext);
}最后,你将修改 Game 组件以渲染当前选定的着法,而不是始终渲染最后的着法:
export default function Game() {const [xIsNext, setXIsNext] = useState(true);const [history, setHistory] = useState([Array(9).fill(null)]);const [currentMove, setCurrentMove] = useState(0);const currentSquares = history[currentMove];//修改了这里 而不是 = history[history.length - 1];}