井字游戏(Tic-Tac-Toe)
在本教程中,您将构建一个小型井字游戏。 本教程不假设任何现有的 React 知识。 您将在本教程中学习的技术是构建任何 React 应用程序的基础,充分理解它将使您对 React 有更深入的了解。
注意
本教程专为喜欢边做边学并希望快速尝试制作有形东西的人而设计。 如果您喜欢逐步学习每个概念,请从描述 UI 开始.
本教程分为几个部分:
- 本教程的设置将为您提供一个遵循本教程的起点。
- Overview 将教你 React 的基础知识:组件、props和states。
- 完成游戏将教会你 React 开发中最常用的技术。
- 添加时间旅行将使您更深入地了解 React 的独特优势。
你要做什么?
在本教程中,您将使用 React 构建一个交互式井字游戏。
你可以在这里看到完成后的样子:
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 [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 = '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;
}
如果代码对您来说还没有意义,或者您不熟悉代码的语法,请不要担心! 本教程的目标是帮助您理解 React 及其语法。
我们建议您在继续本教程之前查看上面的井字游戏。 您会注意到的一项功能是游戏面板右侧有一个编号列表。 此列表为您提供了游戏中发生的所有动作的历史记录,并随着游戏的进行而更新。
玩完完成的井字游戏后,请继续滚动。 在本教程中,您将从一个更简单的模板开始。 我们的下一步是为您进行设置,以便您可以开始构建游戏。
本教程的设置
在下面的实时代码编辑器中,单击右上角的 Fork 以使用网站 CodeSandbox 在新选项卡中打开编辑器。 CodeSandbox 允许您在浏览器中编写代码并立即查看您的用户将如何看待您创建的应用程序。 新选项卡应显示一个空方块和本教程的起始代码。
App.js
export default function Square() {
return <button className="square">X</button>;
}
注意
您也可以使用本地开发环境遵循本教程。 为此,您需要:
- 安装 Node.js
- 在您之前打开的 CodeSandbox 选项卡中,按左上角的按钮打开菜单,然后在该菜单中选择“文件”>“导出到 ZIP”以将文件存档下载到本地
- 解压缩档案,然后打开终端并 cd 到您解压缩的目录
- 使用 npm install 安装依赖项
- 运行npm start启动本地服务器,按照提示在浏览器中查看运行代码
如果你被卡住了,不要让它阻止你! 请改为在线进行操作,稍后再尝试本地设置。
概览
现在你已经设置好了,让我们来了解一下 React!
检查起始代码
在 CodeSandbox 中,您将看到三个主要部分:
- 文件部分包含 App.js、index.js、styles.css 等文件列表和一个名为 public 的文件夹
- 代码编辑器,您将在其中看到所选文件的源代码
- 浏览区部分,您将在其中看到您编写的代码将如何显示
应在“文件”部分选择 App.js 文件。 代码编辑器中该文件的内容应该是:
export default function Square() {
return <button className="square">X</button>;
}
浏览区部分应该显示一个带有 X 的正方形,如下所示:
现在让我们看一下起始代码中的文件。
App.js
App.js 中的代码创建了一个组件。 在 React 中,组件是代表用户界面一部分的一段可重用代码。 组件用于呈现、管理和更新应用程序中的 UI 元素。 让我们逐行查看组件,看看发生了什么:
export default function Square() {
return <button className="square">X</button>;
}
第一行定义了一个名为 Square 的函数。 JavaScript 关键字export使此函数可以在此文件之外访问。 default 关键字告诉其他使用您的代码的文件它是您文件中的主要功能。
第二行返回一个按钮。JavaScript 关键字return意味着后面的任何内容都作为值返回给函数的调用者。 <button> 是一个 JSX 元素。 JSX 元素是 JavaScript 代码和 HTML 标记的组合,用于描述您想要显示的内容。 className="square" 是一个按钮属性或props,它告诉 CSS 如何设置按钮的样式。 X 是按钮内显示的文本,</button> 关闭 JSX 元素以指示不应将任何后续内容放置在按钮内。
styles.css
单击 CodeSandbox 的文件部分中标记为 styles.css 的文件。 该文件定义了 React 应用程序的样式。 前两个 CSS 选择器(* 和 body)定义应用程序大部分的样式,而 .square 选择器定义 className 属性设置为 square 的任何组件的样式。 在您的代码中,这将与 App.js 文件中的 Square 组件中的按钮相匹配。
index.js
单击 CodeSandbox 的文件部分中标记为 index.js 的文件。 在本教程中您不会编辑此文件,但它是您在 App.js 文件中创建的组件与 Web 浏览器之间的桥梁。
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import './styles.css';
import App from './App';
第 1-5 行将所有必要的部分组合在一起:
- React
- React 与网络浏览器对话的库 (React DOM)
- 组件的样式
- 您在 App.js 中创建的组件。
文件的其余部分将所有部分组合在一起,并将最终产品注入public文件夹中的 index.html 中。
构建棋盘
让我们回到 App.js。 这是您将在本教程的其余部分度过的地方。
目前棋盘只有一个方块,但你需要九个! 如果你只是尝试复制粘贴你的方块来制作两个像这样的方块:
export default function Square() {
return <button className="square">X</button><button className="square">X</button>;
}
你就会出错
Console
X /src/App.js: Adjacent JSX elements must be wrapped in an enclosing tag. Did you want a JSX fragment <>...</>?
React 组件需要返回单个 JSX 元素,而不是像两个按钮那样的多个相邻的 JSX 元素。 要解决此问题,您可以使用片段(<> 和 </>)来包装多个相邻的 JSX 元素,如下所示:
export default function Square() {
return (
<>
<button className="square">X</button>
<button className="square">X</button>
</>
);
}
现在你应该看到:
不错!现在你只需要复制粘贴几次来添加九个方块,然后……
不好了! 这些方块都在一条直线上,而不是像我们的板子需要的那样在网格中。 要解决此问题,您需要使用 div 将方块分组到行中并添加一些 CSS 类。 当你这样做的时候,你会给每个方块一个数字,以确保你知道每个方块的显示位置。
在 App.js 文件中,将 Square 组件更新为如下所示:
export default function Square() {
return (
<>
<div className="board-row">
<button className="square">1</button>
<button className="square">2</button>
<button className="square">3</button>
</div>
<div className="board-row">
<button className="square">4</button>
<button className="square">5</button>
<button className="square">6</button>
</div>
<div className="board-row">
<button className="square">7</button>
<button className="square">8</button>
<button className="square">9</button>
</div>
</>
);
}
styles.css 中定义的 CSS 使用 board-row 的 className 为 div 设置样式。 现在您已经使用样式化的 div 将组件分组到行中,您就拥有了井字游戏棋盘:
井字游戏棋盘
但是现在问题来了。 您名为 Square 的组件实际上不再是正方形了。 让我们通过将名称更改为 Board 来解决这个问题:
export default function Board() {
//...
}
此时您的代码应如下所示:
export default function Board() {
return (
<>
<div className="board-row">
<button className="square">1</button>
<button className="square">2</button>
<button className="square">3</button>
</div>
<div className="board-row">
<button className="square">4</button>
<button className="square">5</button>
<button className="square">6</button>
</div>
<div className="board-row">
<button className="square">7</button>
<button className="square">8</button>
<button className="square">9</button>
</div>
</>
);
}
注意
Psssst……要输入的内容太多了! 可以从该页面复制和粘贴代码。 但是,如果您准备好迎接一点挑战,我们建议您只复制您自己至少手动输入过一次的代码。
使用props传递数据
接下来,当用户单击方块时,您需要将方块的值从空更改为“X”。 到目前为止,您是如何构建棋盘的,您需要复制粘贴更新方块的代码九次(每个方块一次)! React 的组件架构允许您创建可重用的组件,而不是复制粘贴,以避免混乱、重复的代码。
首先,您要将定义第一个方块 (<button className="square">1</button>) 的行从 Board 组件复制到新的 Square 组件中:
function Square() {
return <button className="square">1</button>;
}
export default function Board() {
// ...
}
然后,您将更新 Board 组件以使用 JSX 语法呈现该 Square 组件:
// ...
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>
</>
);
}
请注意与浏览器 div 有何不同,您自己的组件 Board 和 Square 必须以大写字母开头。
让我们来看看:
不好了! 你失去了你以前有编号的方块。 现在每个方块都写着“1”。 要解决此问题,您将使用 props 将每个方块应具有的值从父组件 (Board) 传递到子组件 (Square)。
更新 Square 组件以读取您将从 Board的prop传递的value:
function Square({ value }) {
return <button className="square">1</button>;
}
function Square({ value }) 表示可以向 Square 组件传递一个名为 value 的 prop。
现在您想在每个正方形内显示该值而不是 1。 尝试这样做:
function Square({ value }) {
return <button className="square">value</button>;
}
糟糕,这不是你想要的:
你想从你的组件中渲染名为 value 的 JavaScript 变量,而不是“value”这个词。 要从 JSX “逃逸到 JavaScript”,你需要大括号。 在 JSX 中的值周围添加大括号,如下所示:
function Square({ value }) {
return <button className="square">{value}</button>;
}
现在,您应该看到一个棋盘:
这是因为 Board 组件还没有将 value prop 传递给它渲染的每个 Square 组件。 要修复它,您将向 Board 组件呈现的每个 Square 组件添加 value 属性:
export default function Board() {
return (
<>
<div className="board-row">
<Square value="1" />
<Square value="2" />
<Square value="3" />
</div>
<div className="board-row">
<Square value="4" />
<Square value="5" />
<Square value="6" />
</div>
<div className="board-row">
<Square value="7" />
<Square value="8" />
<Square value="9" />
</div>
</>
);
}
现在您应该再次看到数字网格:
您更新后的代码应如下所示:
App.js
function Square({ value }) {
return <button className="square">{value}</button>;
}
export default function Board() {
return (
<>
<div className="board-row">
<Square value="1" />
<Square value="2" />
<Square value="3" />
</div>
<div className="board-row">
<Square value="4" />
<Square value="5" />
<Square value="6" />
</div>
<div className="board-row">
<Square value="7" />
<Square value="8" />
<Square value="9" />
</div>
</>
);
}
制作一个交互式组件
当您单击它时,让我们用 X 填充 Square 组件。 在 Square 内部声明一个名为 handleClick 的函数。 然后,将 onClick 添加到从 Square 组件返回的按钮 JSX 元素的属性中:
function Square({ value }) {
function handleClick() {
console.log('clicked!');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}
如果您现在单击一个方块,您应该会看到一条日志,上面写着“ clicked!” 在 CodeSandbox 中浏览器部分底部的控制台选项卡中。 多次单击方块将记录“ clicked!” 再次。 具有相同消息的重复控制台日志不会在控制台中创建更多行。 相反,您会在第一次“ clicked!”旁边看到一个递增的计数器日志。
注意
如果您使用本地开发环境学习本教程,则需要打开浏览器的控制台。 例如,如果您使用 Chrome 浏览器,则可以使用键盘快捷键 Shift + Ctrl + J(在 Windows/Linux 上)或 Option + ⌘ + J(在 macOS 上)查看控制台。
下一步,您希望 Square 组件“记住”它已被单击,并用“X”标记填充它。 为了“记住”事物,组件使用状态。
React 提供了一个名为 useState 的特殊函数,您可以从组件中调用它来让它“记住”事物。 让我们将 Square 的当前值存储在 state 中,并在单击 Square 时更改它。
在文件顶部导入 useState。 从 Square 组件中移除 value 属性。 相反,在调用 useState 的 Square 组件的开头添加一个新行。 让它返回一个名为 value 的状态变量:
import { useState } from 'react';
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
//...
value 存储值,而 setValue 是可用于更改值的函数。 传递给 useState 的 null 用作此状态变量的初始值,因此此处的值开始时等于 null。
由于 Square 组件不再接受 props,您将从 Board 组件创建的所有九个 Square 组件中删除 value prop:
// ...
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>
</>
);
}
现在您将更改 Square 以在单击时显示“X”。 替换 console.log("clicked!"); 使用 setValue('X'); 的事件处理程序。 现在你的 Square 组件看起来像这样:
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
setValue('X');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}
通过从 onClick 处理程序调用此 set 函数,您告诉 React 在单击其 <button> 时重新渲染该 Square。 更新后,方块的值将为“X”,因此您会在游戏板上看到“X”。
如果你点击任何一个方块,一个“X”应该出现:
请注意,每个 Square 都有自己的状态:存储在每个 Square 中的值完全独立于其他 Square。 当您在组件中调用 set 函数时,React 也会自动更新其中的子组件。
完成上述更改后,您的代码将如下所示:
import { useState } from 'react';
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
setValue('X');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}
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>
</>
);
}
React开发者工具
React DevTools 让您可以检查 React 组件的属性和状态。 您可以在 CodeSandbox 的浏览器部分底部找到 React DevTools 选项卡:
要检查屏幕上的特定组件,请使用 React DevTools 左上角的按钮:
注意
对于本地开发,React DevTools 可作为 Chrome、Firefox 和 Edge 浏览器扩展使用。 安装后,对于使用React的网站,组件选项卡将出现在您的浏览器开发人员工具中。
完成游戏
至此,您已经拥有井字游戏的所有基本构建块。 要玩完整的游戏,您现在需要在棋盘上交替放置“X”和“O”,并且您需要一种确定获胜者的方法。
状态提升
目前,每个 Square 组件都维护着游戏状态的一部分。 要检查井字游戏的赢家,Board需要以某种方式了解 9 个方块组件中每个组件的状态。
你会如何处理? 起初,您可能会猜测Board需要“询问”每个 Square 的状态。 尽管这种方法在 React 中在技术上是可行的,但我们不鼓励这样做,因为代码变得难以理解、容易出现错误并且难以重构。 相反,最好的方法是将游戏的状态存储在父 Board 组件中,而不是每个 Square 中。 Board 组件可以通过传递一个props来告诉每个 Square 要显示什么,就像您将数字传递给每个 Square 时所做的那样。
要从多个子组件收集数据,或让两个子组件相互通信,请改为在其父组件中声明共享状态。 父组件可以通过道具将该状态传回给子组件。 这使子组件彼此同步并与父组件保持同步。
重构 React 组件时,将状态提升到父组件中很常见。
让我们借此机会尝试一下。 编辑 Board 组件,使其声明一个名为 squares 的状态变量,该变量默认为对应于 9 个方块的 9 个空值的数组:
// ...
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
// ...
);
}
Array(9).fill(null) 创建一个包含九个元素的数组,并将每个元素设置为 null。 围绕它调用的 useState() 声明了一个 squares 状态变量,该变量最初设置为该数组。 数组中的每个条目对应于一个正方形的值。 当您稍后填写棋盘时,方块数组将如下所示:
['O', null, 'X', 'X', 'X', 'O', 'O', null, null]
现在,您的 Board 组件需要将 value 属性向下传递给它呈现的每个 Square 组件:
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
<>
<div className="board-row">
<Square value={squares[0]} />
<Square value={squares[1]} />
<Square value={squares[2]} />
</div>
<div className="board-row">
<Square value={squares[3]} />
<Square value={squares[4]} />
<Square value={squares[5]} />
</div>
<div className="board-row">
<Square value={squares[6]} />
<Square value={squares[7]} />
<Square value={squares[8]} />
</div>
</>
);
}
接下来,您将编辑 Square 组件以从 Board 组件接收 value prop。 这将需要删除 Square 组件自己的有状态跟踪值和按钮的 onClick 属性:
function Square({value}) {
return <button className="square">{value}</button>;
}
此时您应该看到一个空的井字棋盘:
你的代码应该是这样的:
import { useState } from 'react';
function Square({ value }) {
return <button className="square">{value}</button>;
}
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
<>
<div className="board-row">
<Square value={squares[0]} />
<Square value={squares[1]} />
<Square value={squares[2]} />
</div>
<div className="board-row">
<Square value={squares[3]} />
<Square value={squares[4]} />
<Square value={squares[5]} />
</div>
<div className="board-row">
<Square value={squares[6]} />
<Square value={squares[7]} />
<Square value={squares[8]} />
</div>
</>
);
}
每个 Square 现在都会收到一个 value prop,对于空方块,该 prop 为“X”、“O”或 null。
接下来,您需要更改单击 Square 时发生的情况。 Board 组件现在维护填充了哪些方块。 您需要为 Square 创建一种方法来更新 Board 的状态。 由于状态对于定义它的组件是私有的,因此您不能直接从 Square 更新 Board 的状态。
相反,您会将一个函数从 Board 组件传递到 Square 组件,并且当单击一个正方形时,您将让 Square 调用该函数。 您将从单击 Square 组件时将调用的函数开始。 您将调用该函数 onSquareClick:
function Square({ value }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
接下来,您将把 onSquareClick 函数添加到 Square 组件的属性中:
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
现在,您将把 onSquareClick 属性连接到 Board 组件中的一个函数,您将其命名为 handleClick。 要将 onSquareClick 连接到 handleClick,您需要将一个函数传递给第一个 Square 组件的 onSquareClick 属性:
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={handleClick} />
//...
);
}
最后,您将在 Board 组件内定义 handleClick 函数来更新保存您的板状态的 squares 数组:
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick() {
const nextSquares = squares.slice();
nextSquares[0] = "X";
setSquares(nextSquares);
}
return (
// ...
)
}
handleClick 函数使用 JavaScript slice() Array 方法创建正方形数组 (nextSquares) 的副本。 然后,handleClick 更新 nextSquares 数组以将 X 添加到第一个([0] 索引)方块。
调用 setSquares 函数让 React 知道组件的状态已经改变。 这将触发重新渲染使用正方形状态的组件(Board)及其子组件(构成Board的Square组件)。
注意
JavaScript 支持闭包,这意味着内部函数(例如 handleClick)可以访问外部函数(例如 Board)中定义的变量和函数。 handleClick 函数可以读取方块状态并调用 setSquares 方法,因为它们都是在 Board 函数内部定义的。
现在您可以将 X 添加到棋盘上……但只能添加到左上角的方块。 您的 handleClick 函数被硬编码为更新左上角 (0) 的索引。 让我们更新 handleClick 以能够更新任何方块。 将参数 i 添加到 handleClick 函数,该函数采用应更新的方块的索引:
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick(i) {
const nextSquares = squares.slice();
nextSquares[i] = "X";
setSquares(nextSquares);
}
return (
// ...
)
}
现在又有新问题了!
尝试在 JSX 中直接将 square 的 onSquareClick 属性设置为 handleClick(0) ,如下所示:
<Square value={squares[0]} onSquareClick={handleClick(0)} />
handleClick(0) 调用将成为渲染板组件的一部分。 因为 handleClick(0) 通过调用 setSquares 改变了棋盘组件的状态,所以您的整个棋盘组件将再次重新呈现。 但是 handleClick(0) 现在是板组件渲染的一部分,因此您创建了一个无限循环:
Console
X Too many re-renders. React limits the number of renders to prevent an infinite loop.
为什么这个问题没有早点发生?
当您传递 onSquareClick={handleClick} 时,您是将 handleClick 函数作为props向下传递。 你不是在召唤它! 但是现在您立即调用该函数——注意 handleClick(0) 中的括号——这就是它运行得太早的原因。 在用户点击之前,您不想调用 handleClick!
要解决此问题,您可以创建一个调用 handleClick(0) 的 handleFirstSquareClick 函数,调用 handleClick(1) 的 handleSecondSquareClick 函数,等等。 您无需调用它们,而是将这些函数作为 props 向下传递,例如 onSquareClick={handleFirstSquareClick}。 这将解决无限循环。
但是,定义九个不同的函数并为每个函数命名太冗长了。 相反,让我们这样做:
export default function Board() {
// ...
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
// ...
);
}
注意新的 () => 语法。 这里,() => handleClick(0) 是一个箭头函数,它是定义函数的一种更短的方式。 单击正方形时,=>“箭头”之后的代码将运行,调用 handleClick(0)。
现在您需要更新其他八个方块以从您传递的箭头函数调用 handleClick。 确保 handleClick 的每次调用的参数对应于正确方块的索引:
export default function Board() {
// ...
return (
<>
<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>
</>
);
};
现在您可以再次通过单击将 X 添加到棋盘上的任何方块:
但是这次所有的状态管理都由 Board 组件处理!
此时你的代码应该是这样的:
import { useState } from 'react';
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick(i) {
const nextSquares = squares.slice();
nextSquares[i] = 'X';
setSquares(nextSquares);
}
return (
<>
<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>
</>
);
}
现在您的状态处理在 Board 组件中,父 Board 组件将道具传递给子 Square 组件,以便它们可以正确显示。 单击 Square 时,子 Square 组件现在会要求父 Board 组件更新棋盘的状态。 当 Board 的状态发生变化时,Board 组件和每个子 Square 组件都会自动重新渲染。 保留 Board 组件中所有方块的状态将允许它确定未来的赢家。
让我们回顾一下当用户单击您的面板左上角的方块以向其添加 X 时会发生什么:
单击左上角的方块会运行该按钮作为其 onClick 属性从方块接收的功能。 Square 组件从 Board 接收该函数作为其 onSquareClick 属性。 Board 组件直接在 JSX 中定义了该函数。 它使用参数 0 调用 handleClick。
handleClick 使用参数 (0) 将 squares 数组的第一个元素从 null 更新为 X。
Board 组件的 squares 状态已更新,因此 Board 及其所有子项都将重新呈现。 这会导致索引为 0 的 Square 组件的 value prop 从 null 变为 X。
最后,用户看到左上角的方块在单击后从空变为有一个 X。
注意
DOM <button> 元素的 onClick 属性对 React 有特殊意义,因为它是一个内置组件。 对于像 Square 这样的自定义组件,命名由您决定。 您可以为 Square 的 onSquareClick 属性或 Board 的 handleClick 函数指定任何名称,代码的工作方式相同。 在 React 中,通常使用 on[Event] 名称表示事件的 props,并使用 handle[Event] 表示处理事件的函数定义。
为何不可变如此重要
注意如何在 handleClick 中调用 .slice() 来创建 squares 数组的副本而不是修改现有数组。 为了解释原因,我们需要讨论不变性以及为什么学习不变性很重要。
通常有两种更改数据的方法。 第一种方法是通过直接更改数据的值来改变数据。 第二种方法是用具有所需更改的新副本替换数据。 如果你改变 squares 数组,它会是这样的:
const squares = [null, null, null, null, null, null, null, null, null];
squares[0] = 'X';
// Now `squares` is ["X", null, null, null, null, null, null, null, null];
如果你在不改变 squares 数组的情况下更改数据,它会是什么样子:
const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
// Now `squares` is unchanged, but `nextSquares` first element is 'X' rather than `null`
最终结果是相同的,但通过不直接改变(更改基础数据),您可以获得几个好处。
不变性使复杂的功能更容易实现。 在本教程的后面,您将实现一个“时间旅行”功能,让您回顾游戏的历史并“跳回”过去的动作。 此功能并非特定于游戏 - 撤消和重做某些操作的能力是应用程序的常见要求。 避免直接数据突变可以让您保持数据的先前版本完整,并在以后重用它们(或重置为它们)。
不变性还有另一个好处。 默认情况下,当父组件的状态发生变化时,所有子组件都会自动重新渲染。 这甚至包括未受更改影响的子组件。 尽管重新渲染本身不会引起用户注意(您不应该主动尝试避免它!),但出于性能原因,您可能希望跳过重新渲染显然不受其影响的树的一部分。 不变性使得组件比较其数据是否已更改的成本非常低。 您可以在memo API 参考文档中了解更多关于 React 如何选择何时重新渲染组件的信息。
轮流
现在是时候修复这个井字游戏的一个主要缺陷了:棋盘上无法标记“O”。
默认情况下,您会将第一步设置为“X”。 让我们通过向 Board 组件添加另一个状态来跟踪这一点:
function Board() {
const [xIsNext, setXIsNext] = useState(true);
const [squares, setSquares] = useState(Array(9).fill(null));
// ...
}
每次玩家移动时,xIsNext(布尔值)将被翻转以确定下一个玩家,游戏状态将被保存。 您将更新 Board 的 handleClick 函数以翻转 xIsNext 的值:
export default function Board() {
const [xIsNext, setXIsNext] = useState(true);
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick(i) {
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = "X";
} else {
nextSquares[i] = "O";
}
setSquares(nextSquares);
setXIsNext(!xIsNext);
}
return (
//...
);
}
现在,当您点击不同的方块时,它们会在 X 和 O 之间交替,这是它们应该做的!
但是等等,有一个问题。 尝试多次点击同一个方块:
X 被 O 覆盖! 虽然这会给游戏带来非常有趣的变化,但我们现在将坚持原来的规则。
当您用 X 或 O 标记一个方块时,您并不是首先检查该方块是否已经具有 X 或 O 值。 您可以通过提早返回来解决此问题。 您将检查方块是否已经有 X 或和 O。如果方块已被填满,您将提前返回 handleClick 函数——在它尝试更新棋盘状态之前。
function handleClick(i) {
if (squares[i]) {
return;
}
const nextSquares = squares.slice();
//...
}
现在您只能在空白方块中添加 X 或 O! 此时您的代码应该如下所示:
import { useState } from 'react';
function Square({value, onSquareClick}) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
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="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>
</>
);
}
宣布赢家
现在您显示了下一个轮到哪个玩家,您还应该显示游戏何时获胜并且没有更多的轮次。 为此,您将添加一个名为 calculateWinner 的辅助函数,该函数接受一个包含 9 个正方形的数组,检查获胜者并根据需要返回“X”、“O”或 null。 不要太担心 calculateWinner 函数; 它不是特定于 React 的:
export default function Board() {
//...
}
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;
}
注意
在 Board 之前或之后定义 calculateWinner 并不重要。 让我们把它放在最后,这样您就不必在每次编辑组件时都滚动过去。
您将在 Board 组件的 handleClick 函数中调用 calculateWinner(squares) 来检查玩家是否获胜。 您可以在检查用户是否单击了已经具有 X 或 O 的方块的同时执行此检查。我们希望在这两种情况下尽早返回:
function handleClick(i) {
if (squares[i] || calculateWinner(squares)) {
return;
}
const nextSquares = squares.slice();
//...
}
为了让玩家知道游戏何时结束,您可以显示“获胜者:X”或“获胜者:O”等文字。 为此,您将向 Board 组件添加一个状态部分。 如果游戏结束,状态将显示获胜者,如果游戏正在进行,您将显示下一个轮到哪个玩家:
export default function Board() {
// ...
const winner = calculateWinner(squares);
let status;
if (winner) {
status = "Winner: " + winner;
} else {
status = "Next player: " + (xIsNext ? "X" : "O");
}
return (
<div>
<div className="status">{status}</div>
<div className="board-row">
// ...
)
}
恭喜! 您现在有一个可以运行的井字游戏。 你也刚刚学习了 React 的基础知识。 所以你是这里真正的赢家。 代码应该如下所示:
import { useState } from 'react';
function Square({value, onSquareClick}) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
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);
}
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>
</>
);
}
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;
}
添加时间旅行
作为最后的练习,让我们能够“回退”到游戏中之前的动作。
存储游戏步骤
如果你改变squares,实现时间旅行将非常困难。
但是,您在每次移动后都使用 slice() 创建了 squares 数组的新副本,并将其视为不可变的。 这将允许您存储方块数组的每个过去版本,并在已经发生的回合之间导航。
您将把过去的方块数组存储在另一个名为 history 的数组中,您将把它存储为一个新的状态变量。 history 数组表示所有棋盘状态,从第一步到最后一步,其形状如下:
[
// 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 的新顶级组件来显示过去的动作列表。 这就是你将放置包含整个游戏历史的历史状态的地方。
将历史状态放入 Game 组件将使您可以从其子 Board 组件中删除 squares 状态。 就像您将状态从 Square 组件“提升”到 Board 组件一样,您现在将把它从 Board 提升到顶级 Game 组件。 这使 Game 组件可以完全控制 Board 的数据,并让它指示 Board 从历史记录中呈现之前的回合。
首先,添加一个默认导出的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>
);
}
请注意,您正在删除 function Board() { 声明之前的 export default 关键字,并将它们添加到 function Game() { 声明之前。 这会告诉您的 index.js 文件使用 Game 组件而不是 Board 组件作为顶级组件。 Game 组件返回的额外 div 为稍后添加到棋盘的游戏信息腾出了空间。
向 Game 组件添加一些状态以跟踪下一个玩家和移动历史:
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
// ...
请注意 [Array(9).fill(null)] 是一个包含单个项目的数组,它本身是一个包含 9 个空值的数组。
要呈现当前移动的方块,您需要从历史记录中读取最后一个方块数组。 你不需要为此使用 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} />
//...
)
}
让我们让 Board 组件完全由它接收到的 props 控制。 更改 Board 组件以获取三个道具:xIsNext、squares 和一个新的 onPlay 函数,每当玩家移动时,Board 可以使用更新的 squares 数组调用该函数。 接下来,删除调用 useState 的 Board 函数的前两行:
function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
//...
}
// ...
}
现在,您将用对新的 onPlay 函数的一次调用替换 Board 组件中 handleClick 中的 setSquares 和 setXIsNext 调用,以便 Game 组件可以在用户单击正方形时更新 Board:
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 组件传递给它的propa控制。 您需要在 Game 组件中实现 handlePlay 函数才能让游戏再次运行。
调用 handlePlay 时应该做什么? 请记住,Board 曾经使用更新的数组调用 setSquares; 现在它将更新后的方块数组传递给 onPlay。
handlePlay 函数需要更新 Game 的状态以触发重新渲染,但是您不再调用的 setSquares 函数——您现在正在使用历史状态变量来存储此信息。 您需要通过将更新的 squares 数组作为新的历史记录条目附加来更新历史记录。 您还想切换 xIsNext,就像 Board 过去所做的那样:
export default function Game() {
//...
function handlePlay(nextSquares) {
setHistory([...history, nextSquares]);
setXIsNext(!xIsNext);
}
//...
}
这里,[...history, nextSquares] 创建一个新数组,其中包含历史记录中的所有项目,然后是 nextSquares。 (您可以将 ...history 传播语法理解为“枚举历史记录中的所有项目”。)
例如,如果 history 是 [[null,null,null], ["X",null,null]] 并且 nextSquares 是 ["X",null,"O"],那么新的 [...history, nextSquares ] 数组将是 [[null,null,null], ["X",null,null], ["X",null,"O"]]。
此时,您已将状态移动到 Game 组件中,UI 应该完全正常工作,就像重构之前一样。 这是此时代码的样子:
App.js
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;
}
显示移动历史
由于您正在记录井字游戏的历史,因此您现在可以将其作为过去动作列表显示给玩家。
像 <button> 这样的 React 元素是常规的 JavaScript 对象; 你可以在你的应用程序中传递它们。 要在 React 中呈现多个项目,您可以使用 React 元素数组。
您已经有一组历史移动状态,所以现在您需要将其转换为一组 React 元素。 在 JavaScript 中,要将一个数组转换为另一个数组,可以使用数组映射方法:
[1, 2, 3].map((x) => x * 2) // [2, 4, 6]
您将使用 map 将您的移动历史转换为代表屏幕上按钮的 React 元素,并且您将显示一个按钮列表以“跳转”到过去的移动。 让我们映射 Game 组件中的历史:
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);
}
function jumpTo(nextMove) {
// TODO
}
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
description = 'Go to move #' + move;
} else {
description = 'Go to game start';
}
return (
<li>
<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>
);
}
您可以在下面看到您的代码应该是什么样子。 请注意,您应该会在开发人员工具控制台中看到一条错误消息:警告:数组或迭代器中的每个子项都应该有一个唯一的“key” 属性。 检查 Game
的渲染方法。 您将在下一节中修复此错误。
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);
}
function jumpTo(nextMove) {
// TODO
}
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
description = 'Go to move #' + move;
} else {
description = 'Go to game start';
}
return (
<li>
<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;
}
当您在传递给 map 的函数中遍历 history 数组时,squares 参数遍历 history 的每个元素,move 参数遍历每个数组索引:0、1、2,...。 (在大多数情况下,您需要实际的数组元素,但在这种情况下,您不使用square来呈现移动列表。)
对于 tic-tac-toe 游戏历史中的每一步,您创建一个列表项 <li>,其中包含一个按钮 <button>。 该按钮有一个 onClick 处理程序,它调用一个名为 jumpTo 的函数(您尚未实现)。
现在,您应该会看到游戏中发生的动作列表和开发人员工具控制台中的错误。
让我们讨论一下“key”错误的含义。
设置一个key
当你渲染一个列表时,React 会存储一些关于每个渲染列表项的信息。 当你更新一个列表时,React 需要确定发生了什么变化。 您可以添加、删除、重新排列或更新列表的项目。
想象一下从
<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>
到
<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>
除了更新的计数之外,阅读本文的人可能会说你交换了 Alexa 和 Ben 的顺序,并在 Alexa 和 Ben 之间插入了 Claudia。 然而,React 是一个计算机程序,无法知道您的意图,因此您需要为每个列表项指定一个key属性,以将每个列表项与其兄弟项区分开来。 如果您要显示来自数据库的数据,Alexa、Ben 和 Claudia 的数据库 ID 可以用作key。
<li key={user.id}>
{user.name}: {user.taskCount} tasks left
</li>
重新渲染列表时,React 获取每个列表项的key并搜索前一个列表的项以查找匹配的key。 如果当前列表有一个之前不存在的key,React 会创建一个组件。 如果当前列表缺少前一个列表中存在的key,React 会销毁前一个组件。 如果两个键匹配,则移动相应的组件。
Key告诉 React 每个组件的身份,这允许 React 在重新渲染之间保持状态。 如果组件的key发生变化,组件将被销毁并以新状态重新创建。
key 是 React 中一个特殊的保留属性。 创建元素时,React 提取键属性并将key直接存储在返回的元素上。 尽管 key 看起来像是作为 props 传递的,但 React 会自动使用 key 来决定要更新哪些组件。 组件无法询问其父组件指定的key。
强烈建议您在构建动态列表时分配适当的key。 如果您没有合适的key,您可能需要考虑重组您的数据,以便您这样做。
如果没有指定key,React会报错,默认使用数组索引作为key。 在尝试重新排序列表项或插入/删除列表项时,使用数组索引作为key是有问题的。 显式传递 key={i} 可以消除错误,但与数组索引有相同的问题,在大多数情况下不推荐使用。
key不需要是全局唯一的; 它们只需要在组件及其同级组件之间是唯一的。
实现时间旅行
在井字游戏的历史中,过去的每一步都有一个唯一的 ID 与之相关联:它是动作的序号。 移动永远不会被重新排序、删除或插入中间,因此使用移动索引作为key是安全的。
在 Game 函数中,您可以将键添加为 <li key={move}>,如果您重新加载渲染的游戏,React 的“键”错误应该会消失:
const moves = history.map((squares, move) => {
//...
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
在您可以实现 jumpTo 之前,您需要 Game 组件来跟踪用户当前正在查看的步骤。 为此,定义一个名为 currentMove 的新状态变量,默认为 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);
}
//...
}
您现在将对游戏的 handlePlay 函数进行两处更改,该函数在您单击一个方块时调用。
- 如果你“回退”然后从那一点开始采取新的行动,你只想保留那一点的历史。 不是在历史中的所有项目(...传播语法)之后添加 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];
// ...
}
如果您点击游戏历史中的任何一步,井字棋盘应立即更新以显示该步骤发生后棋盘的样子。
App.js
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 [currentMove, setCurrentMove] = useState(0);
const currentSquares = history[currentMove];
function handlePlay(nextSquares) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
setXIsNext(!xIsNext);
}
function jumpTo(nextMove) {
setCurrentMove(nextMove);
setXIsNext(nextMove % 2 === 0);
}
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;
}
最后的清理
如果仔细查看代码,您可能会注意到当 currentMove 为偶数时 xIsNext === true,而当 currentMove 为奇数时 xIsNext === false。 换句话说,如果您知道 currentMove 的值,那么您总能算出 xIsNext 应该是什么。
您没有理由将这两者都存储在状态中。 事实上,总是尽量避免冗余状态。 简化您在状态中存储的内容有助于减少错误并使您的代码更易于理解。 更改 Game,使其不再将 xIsNext 存储为单独的状态变量,而是根据 currentMove 计算出来:
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);
}
// ...
}
您不再需要 xIsNext 状态声明或对 setXIsNext 的调用。 现在,xIsNext 不可能与 currentMove 不同步,即使您在编写组件代码时出错也是如此。
打包
恭喜! 您已经创建了一个井字游戏:
- 让你玩井字游戏,
- 指示玩家何时赢得游戏,
- 随着游戏的进行存储游戏的历史,
- 允许玩家回顾游戏的历史并查看游戏面板的以前版本。
干得好! 我们希望您现在觉得您对 React 的工作原理已经有了很好的了解。
在这里查看最终结果:
App.js
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 [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 = '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;
}
如果您有额外的时间或想练习新的 React 技能,这里有一些您可以改进井字游戏的想法,按难度递增的顺序列出:
- 仅针对当前着手,显示“您在着手#...”而不是按钮
- 重写 Board 以使用两个循环来制作正方形而不是对它们进行硬编码。
- 添加一个切换按钮,让您可以按升序或降序对移动进行排序。
- 当有人获胜时,突出显示导致获胜的三个方块(当没有人获胜时,显示一条关于结果为平局的消息)。
- 在移动历史列表中以 (col, row) 格式显示每个移动的位置。
在本教程中,您已经接触了 React 概念,包括元素、组件、属性和状态。 现在您已经了解了这些概念在构建游戏时是如何工作的,请查看Thinking in React以了解相同的 React 概念在构建应用程序的 UI 时是如何工作的。
网友评论