在本教程中,您将构建一个小型井字棋游戏。本教程不假设您具备任何现有的 React 知识。您将在教程中学到的技术对于构建任何 React 应用都至关重要,充分理解它将使您对 React 有深入的了解。
本教程分为几个部分
- 教程设置将为您提供一个起点来学习本教程。
- 概述将教你React 的基础知识:组件、props 和状态。
- 完成游戏将教你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 允许您在浏览器中编写代码,并预览用户将如何看到您创建的应用。新标签页应该显示一个空方块和本教程的启动代码。
export default function Square() { return <button className="square">X</button>; }
概述
现在您已设置完毕,让我们来概述一下 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
的函数。export
JavaScript 关键字使此函数可在该文件外部访问。default
关键字告诉使用您的代码的其他文件,它是您文件中主要的函数。
export default function Square() {
return <button className="square">X</button>;
}
第二行返回一个按钮。return
JavaScript 关键字表示其后的任何内容都将作为值返回给函数的调用者。<button>
是一个JSX 元素。JSX 元素是 JavaScript 代码和 HTML 标签的组合,用于描述您想要显示的内容。className="square"
是一个按钮属性或prop,它告诉 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 用于与 Web 浏览器通信的库 (React DOM)。
- 组件的样式。
- 您在
App.js
中创建的组件。
文件的其余部分将所有组件组合在一起,并将最终产品注入 public
文件夹中的 index.html
。
构建棋盘
让我们回到 App.js
。您将在本教程的剩余部分在此处进行操作。
目前棋盘只有一个方块,但您需要九个!如果您只是尝试复制粘贴您的方块以创建两个方块,如下所示:
export default function Square() {
return <button className="square">X</button><button className="square">X</button>;
}
您将收到此错误。
<>...</>
?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 将样式应用于 className
为 board-row
的 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> </> ); }
通过 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
传递的 value
prop。
function Square({ value }) {
return <button className="square">1</button>;
}
function Square({ value })
表示 Square 组件可以传递一个名为 value
的 prop。
现在你想显示 value
而不是 1
在每个方格内。尝试这样做
function Square({ value }) {
return <button className="square">value</button>;
}
糟糕,这不是你想要的结果。
你想渲染来自组件的 JavaScript 变量 value
,而不是单词“value”。要从 JSX “转义到 JavaScript”,你需要花括号。在 JSX 中的 value
周围添加花括号,如下所示
function Square({ value }) {
return <button className="square">{value}</button>;
}
目前,你应该看到一个空的棋盘。
这是因为 Board
组件还没有将 value
prop 传递给它渲染的每个 Square
组件。要解决这个问题,你将把 value
prop 添加到 Board
组件渲染的每个 Square
组件。
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>
</>
);
}
现在你应该再次看到一个数字网格。
更新后的代码应该如下所示
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 元素的 props 中。
function Square({ value }) {
function handleClick() {
console.log('clicked!');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}
如果你现在点击一个方格,你应该会看到一条显示 "clicked!"
的日志,位于 CodeSandbox 的“浏览器”部分底部的“控制台”选项卡中。多次点击方格会再次记录 "clicked!"
。带有相同消息的重复控制台日志不会在控制台中创建更多行。相反,你会看到你的第一个 "clicked!"
日志旁边有一个递增的计数器。
下一步,你希望 Square 组件“记住”它被点击了,并用“X”标记填充它。“记住”事物,组件使用 *状态*。
React 提供了一个名为 useState
的特殊函数,你可以从你的组件中调用它,以使其“记住”事物。让我们将 Square
的当前值存储在状态中,并在点击 Square
时更改它。
在文件顶部导入 useState
。从 Square
组件中删除 value
prop。相反,在 Square
的开头添加一行,调用 useState
。让它返回一个名为 value
的状态变量。
import { useState } from 'react';
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
//...
value
存储值,而 setValue
是一个可用于更改值的函数。null
传递给 useState
用作此状态变量的初始值,因此这里的 value
最初等于 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 只要点击 Square
组件的 <button>
,就重新渲染该 Square
。更新后,Square
的 value
将为 'X'
,因此您将在游戏板上看到“X”。点击任何方格,“X”都应该出现。
每个 Square 都有自己的状态:存储在每个 Square 中的 value
与其他 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 组件的 props 和状态。您可以在 CodeSandbox 的“浏览器”部分底部找到 React DevTools 选项卡。
要检查屏幕上特定组件,请使用 React DevTools 左上角的按钮。
完成游戏
至此,您已经拥有了井字棋游戏的所有基本构建块。要完成一个完整的游戏,您现在需要轮流在棋盘上放置“X”和“O”,并且需要一种方法来确定获胜者。
提升状态
当前,每个 Square
组件维护游戏状态的一部分。要检查井字棋游戏的获胜者,Board
需要以某种方式知道所有 9 个 Square
组件的状态。
您将如何处理这个问题?起初,您可能会猜想 Board
需要“询问”每个 Square
的状态。尽管这种方法在 React 中从技术上讲是可行的,但我们不建议这样做,因为代码将变得难以理解、容易出现错误且难以重构。相反,最佳方法是将游戏状态存储在父 Board
组件中,而不是在每个 Square
中。Board
组件可以通过传递 prop(就像您之前向每个 Square 传递数字一样)来告诉每个 Square
显示什么。
要收集来自多个子组件的数据,或让两个子组件相互通信,请改为在父组件中声明共享状态。父组件可以通过 props 将该状态传递回子组件。这使子组件彼此之间以及与父组件保持同步。
在重构 React 组件时,将状态提升到父组件是很常见的。
让我们借此机会尝试一下。编辑 Board
组件,使其声明一个名为 squares
的状态变量,其默认值为一个包含 9 个 null 的数组,对应于 9 个方格。
// ...
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
// ...
);
}
Array(9).fill(null)
创建一个包含九个元素的数组,并将每个元素设置为 null
。useState()
周围的调用声明一个 squares
状态变量,该变量最初设置为该数组。数组中的每个条目都对应于一个方格的值。当您稍后填充棋盘时,squares
数组将如下所示
['O', null, 'X', 'X', 'X', 'O', 'O', null, null]
现在您的 Board
组件需要将 value
prop 传递到它渲染的每个 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 组件自己对 value
的有状态跟踪以及按钮的 onClick
prop。
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
组件的props中
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
现在,你将onSquareClick
prop连接到Board
组件中的一个函数,我们将它命名为handleClick
。为了将onSquareClick
连接到handleClick
,你将把一个函数传递给第一个Square
组件的onSquareClick
prop
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()
数组方法创建squares
数组的副本(nextSquares
)。然后,handleClick
更新nextSquares
数组,将X
添加到第一个([0]
索引)方格。
调用setSquares
函数可以让React知道组件的状态已更改。这将触发使用squares
状态(Board
)的组件及其子组件(构成棋盘的Square
组件)的重新渲染。
现在你可以向棋盘添加X了……但是只能添加到左上角的方格。你的handleClick
函数被硬编码为更新左上角方格(0
)的索引。让我们更新handleClick
,使其能够更新任何方格。向handleClick
函数添加一个参数i
,它接受要更新的方格的索引。
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 (
// ...
)
}
接下来,你需要将i
传递给handleClick
。你可以尝试直接在JSX中将方格的onSquareClick
prop设置为handleClick(0)
,但这行不通。
<Square value={squares[0]} onSquareClick={handleClick(0)} />
这就是它不起作用的原因。handleClick(0)
调用将是渲染`Board`组件的一部分。因为handleClick(0)
通过调用setSquares
改变了`Board`组件的状态,你的整个`Board`组件将再次重新渲染。但这会再次运行handleClick(0)
,导致无限循环。
为什么这个问题之前没有发生?
当你传递onSquareClick={handleClick}
时,你将handleClick
函数作为prop传递。你并没有调用它!但是现在你_立即_调用了该函数——注意handleClick(0)
中的括号——这就是它过早运行的原因。你_不希望_在用户点击之前调用handleClick
!
你可以通过创建一个像handleFirstSquareClick
这样的函数来调用handleClick(0)
,创建一个像handleSecondSquareClick
这样的函数来调用handleClick(1)
,等等。你将把这些函数(而不是调用它们)作为prop传递,例如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
组件将 props 传递给子 Square
组件,以便正确显示它们。点击一个 Square
时,子 Square
组件现在会请求父 Board
组件更新棋盘状态。当 Board
的状态改变时,Board
组件和所有子 Square
组件都会自动重新渲染。将所有方格的状态保存在 Board
组件中,将来可以方便地确定胜者。
让我们回顾一下用户点击棋盘左上角方格添加 X
时会发生什么。
- 点击左上角方格会运行
button
从Square
接收到的onClick
prop 属性对应的函数。Square
组件从Board
接收该函数作为其onSquareClick
prop 属性。Board
组件直接在 JSX 中定义了该函数。它调用handleClick
,参数为0
。 handleClick
使用参数 (0
) 更新squares
数组的第一个元素,将其从null
更改为X
。Board
组件的squares
状态已更新,因此Board
及其所有子组件都重新渲染。这导致索引为0
的Square
组件的value
prop 属性从null
变为X
。
最终,用户会看到点击后左上角方格已从空变为 X
。
为什么不变性很重要
注意,在 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
组件的handleClick
函数中调用calculateWinner(squares)
来检查是否有玩家获胜。你可以在同时检查用户是否点击了已经有X
或O
的方格时执行此检查。我们希望在这两种情况下都提前返回。
function handleClick(i) {
if (squares[i] || calculateWinner(squares)) {
return;
}
const nextSquares = squares.slice();
//...
}
为了让玩家知道游戏何时结束,你可以显示诸如“获胜者:X”或“获胜者:O”之类的文本。为此,你将向Board
组件添加一个status
部分。如果游戏结束,状态将显示获胜者;如果游戏正在进行,则将显示下一个玩家的回合。
export default function Board() {
// ...
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">
// ...
)
}
恭喜!你现在已经有了一个可以运行的井字棋游戏。你刚学会了 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
数组的新副本,并将其视为不可变的。这将允许你存储squares
数组的每个过去版本,并在已经发生的回合之间进行导航。
你将把过去的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
的新顶级组件来显示过去的移动列表。你将在那里放置包含整个游戏历史记录的history
状态。
将history
状态放入Game
组件中,您可以移除其子组件Board
组件中的squares
状态。就像您从Square
组件中“提升状态”到Board
组件一样,您现在将它从Board
提升到顶层的Game
组件。这使得Game
组件完全控制Board
的数据,并可以指示Board
从history
渲染之前的回合。
首先,添加一个带有export default
的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个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} />
//...
)
}
让我们使Board
组件完全由它接收的props控制。更改Board
组件以接收三个props:xIsNext
、squares
和一个新的onPlay
函数,Board
可以在玩家移动时使用更新的squares数组调用该函数。接下来,移除Board
函数中调用useState
的前两行。
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
组件传递给它的props控制。您需要在Game
组件中实现handlePlay
函数才能使游戏再次运行。
调用时handlePlay
应该做什么?请记住,Board以前使用更新后的数组调用setSquares
;现在它将更新的squares
数组传递给onPlay
。
handlePlay
函数需要更新Game
的状态以触发重新渲染,但是您不再有可以调用的setSquares
函数——您现在使用history
状态变量来存储此信息。您需要通过将更新的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"]]
。
此时,您已将状态移动到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; }
显示之前的走法
由于您正在记录井字游戏历史,您现在可以向玩家显示之前的走法列表。
像 <button>
这样的 React 元素是普通的 JavaScript 对象;您可以在应用程序中传递它们。要在 React 中渲染多个项目,可以使用 React 元素数组。
您已经在状态中拥有一个 history
走法数组,因此现在需要将其转换为 React 元素数组。在 JavaScript 中,要将一个数组转换为另一个数组,可以使用 数组 map
方法:
[1, 2, 3].map((x) => x * 2) // [2, 4, 6]
您将使用 map
将您的 history
走法转换为表示屏幕上按钮的 React 元素,并显示一个按钮列表以“跳转”到之前的走法。让我们在 Game 组件中遍历 history
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>
);
}
您可以在下面看到代码应该是什么样子。请注意,您应该在开发者工具控制台中看到一条错误消息,提示
您将在下一节中修复此错误。
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
……(在大多数情况下,您需要实际的数组元素,但要渲染走法列表,您只需要索引。)
对于井字游戏历史记录中的每一次走法,您创建一个列表项 <li>
,其中包含一个按钮 <button>
。该按钮具有一个 onClick
处理程序,该处理程序调用名为 jumpTo
的函数(您尚未实现)。
目前,您应该看到游戏中发生的走法列表以及开发者工具控制台中的错误。让我们讨论一下“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 作为键。
<li key={user.id}>
{user.name}: {user.taskCount} tasks left
</li>
重新渲染列表时,React 会获取每个列表项的键,并在之前的列表项中搜索匹配的键。如果当前列表具有之前不存在的键,则 React 会创建一个组件。如果当前列表缺少之前列表中存在的键,则 React 会销毁之前的组件。如果两个键匹配,则相应的组件将被移动。
键告诉 React 每个组件的身份,这允许 React 在重新渲染之间维护状态。如果组件的键发生变化,则该组件将被销毁并使用新状态重新创建。
key
是 React 中一个特殊且保留的属性。创建元素时,React 会提取 key
属性并将键直接存储在返回的元素上。即使 key
看起来像是作为 props 传递的,React 也自动使用 key
来决定更新哪些组件。组件无法询问其父级指定的 key
是什么。
强烈建议您在构建动态列表时分配正确的键。如果您没有合适的键,您可能需要考虑重新构建您的数据以便这样做。
如果没有指定键,React 将报告错误并默认使用数组索引作为键。当尝试重新排序列表项或插入/删除列表项时,使用数组索引作为键是有问题的。显式传递 key={i}
会消除错误,但与数组索引具有相同的问题,在大多数情况下不推荐。
键不需要全局唯一;它们只需要在组件及其同级之间唯一即可。
实现时光倒流
在井字游戏历史记录中,每一步都有一个唯一的 ID 与之关联:它是走法的顺序号。走法永远不会重新排序、删除或插入到中间,因此使用走法索引作为键是安全的。
在 Game
函数中,您可以将键添加为 <li key={move}>
,如果您重新加载渲染的游戏,React 的“key”错误应该会消失。
const moves = history.map((squares, move) => {
//...
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: ' + 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 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
的新状态变量,默认为 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];
// ...
}
如果你点击游戏历史中的任何步骤,井字棋棋盘应该立即更新,显示该步骤发生后棋盘的样子。
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 的工作原理有了相当好的理解。
查看最终结果:
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
,使用两个循环来创建方块,而不是硬编码它们。 - 添加一个切换按钮,允许你按升序或降序排列移动。
- 当有人获胜时,突出显示导致获胜的三个方块(如果没有获胜者,则显示关于结果为平局的消息)。
- 在移动历史列表中以 (行,列) 的格式显示每次移动的位置。
在本教程中,你已经接触到 React 的概念,包括元素、组件、props 和状态。既然你已经了解了在构建游戏时这些概念是如何工作的,请查看React 思维,了解在构建应用程序的 UI 时这些相同的 React 概念是如何工作的。