主题
教程:井字游戏
¥Tutorial: Tic-Tac-Toe
构建游戏
¥Building a game
你将在本教程中构建一个小型井字游戏。本教程确实假设你已经具备 React 知识。你将在本教程中学习的技术是构建任何 React 应用的基础,完全理解它将使你深入了解 React 和 Zusand。
¥You will build a small tic-tac-toe game during this tutorial. This tutorial does assume existing React knowledge. The techniques you'll learn in the tutorial are fundamental to building any React app, and fully understanding it will give you a deep understanding of React and Zustand.
本教程是为那些通过实践经验学习效果最好并希望快速创建有形内容的人而设计的。它从 React 的井字游戏教程中汲取灵感。
¥[!NOTE] This tutorial is crafted for those who learn best through hands-on experience and want to swiftly create something tangible. It draws inspiration from React's tic-tac-toe tutorial.
本教程分为几个部分:
¥The tutorial is divided into several sections:
本教程的设置将为你提供遵循本教程的起点。
¥Setup for the tutorial will give you a starting point to follow the tutorial.
概述将教你 React 的基础知识:组件、属性和状态。
¥Overview will teach you the fundamentals of React: components, props, and state.
完成游戏将教你 React 开发中最常用的技术。
¥Completing the game will teach you the most common techniques in React development.
添加时间旅行将让你更深入地了解 React 的独特优势。
¥Adding time travel will give you a deeper insight into the unique strengths of React.
你正在构建什么?
¥What are you building?
在本教程中,你将使用 React 和 Zustand 构建一个交互式井字游戏。
¥In this tutorial, you'll build an interactive tic-tac-toe game with React and Zustand.
你可以在此处看到完成后的样子:
¥You can see what it will look like when you're finished here:
jsx
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
const useGameStore = create(
combine(
{
history: [Array(9).fill(null)],
currentMove: 0,
},
(set, get) => {
return {
setHistory: (nextHistory) => {
set((state) => ({
history:
typeof nextHistory === 'function'
? nextHistory(state.history)
: nextHistory,
}))
},
setCurrentMove: (nextCurrentMove) => {
set((state) => ({
currentMove:
typeof nextCurrentMove === 'function'
? nextCurrentMove(state.currentMove)
: nextCurrentMove,
}))
},
}
},
),
)
function Square({ value, onSquareClick }) {
return (
<button
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
backgroundColor: '#fff',
border: '1px solid #999',
outline: 0,
borderRadius: 0,
fontSize: '1rem',
fontWeight: 'bold',
}}
onClick={onSquareClick}
>
{value}
</button>
)
}
function Board({ xIsNext, squares, onPlay }) {
const winner = calculateWinner(squares)
const turns = calculateTurns(squares)
const player = xIsNext ? 'X' : 'O'
const status = calculateStatus(winner, turns, player)
function handleClick(i) {
if (squares[i] || winner) return
const nextSquares = squares.slice()
nextSquares[i] = player
onPlay(nextSquares)
}
return (
<>
<div style={{ marginBottom: '0.5rem' }}>{status}</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
{squares.map((_, i) => (
<Square
key={`square-${i}`}
value={squares[i]}
onSquareClick={() => handleClick(i)}
/>
))}
</div>
</>
)
}
export default function Game() {
const history = useGameStore((state) => state.history)
const setHistory = useGameStore((state) => state.setHistory)
const currentMove = useGameStore((state) => state.currentMove)
const setCurrentMove = useGameStore((state) => state.setCurrentMove)
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)
}
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
fontFamily: 'monospace',
}}
>
<div>
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div style={{ marginLeft: '1rem' }}>
<ol>
{history.map((_, historyIndex) => {
const description =
historyIndex > 0
? `Go to move #${historyIndex}`
: 'Go to game start'
return (
<li key={historyIndex}>
<button onClick={() => jumpTo(historyIndex)}>
{description}
</button>
</li>
)
})}
</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
}
function calculateTurns(squares) {
return squares.filter((square) => !square).length
}
function calculateStatus(winner, turns, player) {
if (!winner && !turns) return 'Draw'
if (winner) return `Winner ${winner}`
return `Next player: ${player}`
}
构建棋盘
¥Building the board
让我们从创建 Square
组件开始,它将成为我们 Board
组件的构建块。此组件将代表我们游戏中的每个方块。
¥Let's start by creating the Square
component, which will be a building block for our Board
component. This component will represent each square in our game.
Square
组件应将 value
和 onSquareClick
作为 props。它应该返回一个 <button>
元素,样式看起来像一个正方形。按钮显示值 prop,可以是 'X'
、'O'
或 null
,具体取决于游戏的状态。单击按钮时,它会触发作为 prop 传入的 onSquareClick
函数,从而允许游戏响应用户输入。
¥The Square
component should take value
and onSquareClick
as props. It should return a <button>
element, styled to look like a square. The button displays the value prop, which can be 'X'
, 'O'
, or null
, depending on the game's state. When the button is clicked, it triggers the onSquareClick
function passed in as a prop, allowing the game to respond to user input.
这是 Square
组件的代码:
¥Here's the code for the Square
component:
jsx
function Square({ value, onSquareClick }) {
return (
<button
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
backgroundColor: '#fff',
border: '1px solid #999',
outline: 0,
borderRadius: 0,
fontSize: '1rem',
fontWeight: 'bold',
}}
onClick={onSquareClick}
>
{value}
</button>
)
}
让我们继续创建 Board 组件,它将由排列成网格的 9 个方块组成。此组件将作为我们游戏的主要游戏区域。
¥Let's move on to creating the Board component, which will consist of 9 squares arranged in a grid. This component will serve as the main playing area for our game.
Board
组件应返回一个网格样式的 <div>
元素。网格布局是使用 CSS 网格实现的,具有三列和三行,每行占用可用空间的相等部分。网格的整体大小由宽度和高度属性决定,确保其为正方形且大小合适。
¥The Board
component should return a <div>
element styled as a grid. The grid layout is achieved using CSS Grid, with three columns and three rows, each taking up an equal fraction of the available space. The overall size of the grid is determined by the width and height properties, ensuring that it is square-shaped and appropriately sized.
在网格内,我们放置了九个 Square 组件,每个组件都有一个代表其位置的值 prop。这些 Square 组件最终将保存游戏符号('X'
或 'O'
)并处理用户交互。
¥Inside the grid, we place nine Square components, each with a value prop representing its position. These Square components will eventually hold the game symbols ('X'
or 'O'
) and handle user interactions.
这是 Board
组件的代码:
¥Here's the code for the Board
component:
jsx
export default function Board() {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
<Square value="1" />
<Square value="2" />
<Square value="3" />
<Square value="4" />
<Square value="5" />
<Square value="6" />
<Square value="7" />
<Square value="8" />
<Square value="9" />
</div>
)
}
此 Board 组件通过在 3x3 网格中排列九个方块来设置我们游戏板的基本结构。它将方块整齐地定位,为将来添加更多功能和处理玩家互动奠定了基础。
¥This Board component sets up the basic structure for our game board by arranging nine squares in a 3x3 grid. It positions the squares neatly, providing a foundation for adding more features and handling player interactions in the future.
提升状态
¥Lifting state up
每个 Square
组件都可以维护游戏状态的一部分。为了检查井字游戏的赢家,Board
组件需要以某种方式了解 9 个 Square
组件中的每一个的状态。
¥Each Square
component could maintain a part of the game's state. To check for a winner in a tic-tac-toe game, the Board
component would need to somehow know the state of each of the 9 Square
components.
你会如何处理?首先,你可能会猜测 Board
组件需要向每个 Square
组件询问该 Square
的组件状态。虽然这种方法在 React 中在技术上是可行的,但我们不鼓励这样做,因为代码变得难以理解、容易出现错误并且难以重构。相反,最好的方法是将游戏的状态存储在父 Board
组件中,而不是每个 Square
组件中。Board
组件可以通过传递 prop 来告诉每个 Square
组件显示什么,就像你向每个 Square
组件传递数字时所做的那样。
¥How would you approach that? At first, you might guess that the Board
component needs to ask each Square
component for that Square
's component state. Although this approach is technically possible in React, we discourage it because the code becomes difficult to understand, susceptible to bugs, and hard to refactor. Instead, the best approach is to store the game's state in the parent Board
component instead of in each Square
component. The Board
component can tell each Square
component what to display by passing a prop, like you did when you passed a number to each Square
component.
要从多个子组件收集数据,或者让两个或多个子组件相互通信,请在其父组件中声明共享状态。父组件可以通过 props 将该状态传递回子组件。这可使子组件彼此同步并与父组件保持同步。
¥[!IMPORTANT] To collect data from multiple children, or to have two or more child components communicate with each other, declare the shared state in their parent component instead. The parent component can pass that state back down to the children via props. This keeps the child components in sync with each other and with their parent.
让我们借此机会尝试一下。编辑 Board
组件,使其声明一个名为 squares 的状态变量,该变量默认为与 9 个方块相对应的 9 个空值数组:
¥Let's take this opportunity to try it out. Edit the Board
component so that it declares a state variable named squares that defaults to an array of 9 nulls corresponding to the 9 squares:
jsx
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
const useGameStore = create(
combine({ squares: Array(9).fill(null) }, (set) => {
return {
setSquares: (nextSquares) => {
set((state) => ({
squares:
typeof nextSquares === 'function'
? nextSquares(state.squares)
: nextSquares,
}))
},
}
}),
)
export default function Board() {
const squares = useGameStore((state) => state.squares)
const setSquares = useGameStore((state) => state.setSquares)
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
{squares.map((square, squareIndex) => (
<Square key={squareIndex} value={square} />
))}
</div>
)
}
Array(9).fill(null)
创建一个包含九个元素的数组,并将每个元素设置为 null
。useGameStore
声明最初设置为该数组的 squares
状态。数组中的每个条目对应一个正方形的值。当你稍后填写板子时,方块数组将如下所示:
¥Array(9).fill(null)
creates an array with nine elements and sets each of them to null
. The useGameStore
declares a squares
state that's initially set to that array. Each entry in the array corresponds to the value of a square. When you fill the board in later, the squares array will look like this:
js
const squares = ['O', null, 'X', 'X', 'X', 'O', 'O', null, null]
每个方块现在将收到一个 value
属性,对于空方块,它将是 'X'
、'O'
或 null
。
¥Each Square will now receive a value
prop that will either be 'X'
, 'O'
, or null
for empty squares.
接下来,你需要更改单击 Square
组件时发生的情况。Board
组件现在维护填充了哪些方块。你需要为 Square
组件创建一种方法来更新 Board
的组件状态。由于状态对于定义它的组件来说是私有的,因此你无法直接从 Square
组件更新 Board
的组件状态。
¥Next, you need to change what happens when a Square
component is clicked. The Board
component now maintains which squares are filled. You'll need to create a way for the Square
component to update the Board
's component state. Since state is private to a component that defines it, you cannot update the Board
's component state directly from Square
component.
相反,你将从 Board 组件向 Square
组件传递一个函数,当单击一个方块时,你将让 Square
组件调用该函数。你将从单击 Square
组件时将调用的函数开始。你将调用该函数 onSquareClick
:
¥Instead, you'll pass down a function from the Board component to the Square
component, and you'll have Square
component call that function when a square is clicked. You'll start with the function that the Square
component will call when it is clicked. You'll call that function onSquareClick
:
现在你将 onSquareClick
prop 连接到 Board
组件中的一个函数,你将该函数命名为 handleClick
。要将 onSquareClick
连接到 handleClick
,你需要将内联函数传递给第一个 Square 组件的 onSquareClick
prop:
¥Now you'll connect the onSquareClick
prop to a function in the Board
component that you'll name handleClick
. To connect onSquareClick
to handleClick
you'll pass an inline function to the onSquareClick
prop of the first Square component:
jsx
<Square key={squareIndex} value={square} onSquareClick={() => handleClick(i)} />
最后,你将在 Board
组件内定义 handleClick
函数来更新保存棋盘状态的方块数组。
¥Lastly, you will define the handleClick
function inside the Board
component to update the squares array holding your board's state.
handleClick
函数应采用方块的索引进行更新并创建 squares
数组 (nextSquares
) 的副本。然后,如果尚未填充,handleClick
通过将 X
添加到指定索引 (i
) 处的方块来更新 nextSquares
数组。
¥The handleClick
function should take the index of the square to update and create a copy of the squares
array (nextSquares
). Then, handleClick
updates the nextSquares
array by adding X
to the square at the specified index (i
) if is not already filled.
jsx
export default function Board() {
const squares = useGameStore((state) => state.squares)
const setSquares = useGameStore((state) => state.setSquares)
function handleClick(i) {
if (squares[i]) return
const nextSquares = squares.slice()
nextSquares[i] = 'X'
setSquares(nextSquares)
}
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
{squares.map((square, squareIndex) => (
<Square
key={squareIndex}
value={square}
onSquareClick={() => handleClick(squareIndex)}
/>
))}
</div>
)
}
请注意,在 `handleClick` 函数中,你调用 `.slice()` 来创建正方形数组的副本,而不是修改现有数组。
¥[!IMPORTANT] Note how in handleClick
function, you call .slice()
to create a copy of the squares array instead of modifying the existing array.
轮流
¥Taking turns
现在是时候修复这个井字游戏中的一个主要缺陷了:'O'
不能在板上使用。
¥It's now time to fix a major defect in this tic-tac-toe game: the 'O'
s cannot be used on the board.
你将默认将第一步设置为 'X'
。让我们通过向 useGameStore
钩子添加另一个状态来跟踪这一点:
¥You'll set the first move to be 'X'
by default. Let's keep track of this by adding another piece of state to the useGameStore
hook:
jsx
const useGameStore = create(
combine({ squares: Array(9).fill(null), xIsNext: true }, (set) => {
return {
setSquares: (nextSquares) => {
set((state) => ({
squares:
typeof nextSquares === 'function'
? nextSquares(state.squares)
: nextSquares,
}))
},
setXIsNext: (nextXIsNext) => {
set((state) => ({
xIsNext:
typeof nextXIsNext === 'function'
? nextXIsNext(state.xIsNext)
: nextXIsNext,
}))
},
}
}),
)
每次玩家移动时,xIsNext
(布尔值)都会翻转以确定哪个玩家接下来移动,并且游戏的状态将被保存。你将更新 Board 的 handleClick
函数以翻转 xIsNext
的值:
¥Each time a player moves, xIsNext
(a boolean) will be flipped to determine which player goes next and the game's state will be saved. You'll update the Board's handleClick
function to flip the value of xIsNext
:
jsx
export default function Board() {
const xIsNext = useGameStore((state) => state.xIsNext)
const setXIsNext = useGameStore((state) => state.setXIsNext)
const squares = useGameStore((state) => state.squares)
const setSquares = useGameStore((state) => state.setSquares)
const player = xIsNext ? 'X' : 'O'
function handleClick(i) {
if (squares[i]) return
const nextSquares = squares.slice()
nextSquares[i] = player
setSquares(nextSquares)
setXIsNext(!xIsNext)
}
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
{squares.map((square, squareIndex) => (
<Square
key={squareIndex}
value={square}
onSquareClick={() => handleClick(squareIndex)}
/>
))}
</div>
)
}
宣布获胜者或平局
¥Declaring a winner or draw
现在玩家可以轮流了,你需要显示游戏何时获胜或平局,并且没有更多轮次可进行。为此,你需要添加三个辅助函数。第一个辅助函数名为 calculateWinner
,它接受一个包含 9 个方格的数组,检查获胜者并根据需要返回 'X'
、'O'
或 null
。第二个辅助函数称为 calculateTurns
,它采用相同的数组,通过仅过滤掉 null
个项目来检查剩余的转弯,并返回它们的数量。最后一个助手名为 calculateStatus
,它接收剩余的回合、获胜者和当前玩家 ('X' or 'O'
):
¥Now that the players can take turns, you'll want to show when the game is won or drawn and there are no more turns to make. To do this you'll add three helper functions. The first helper function called calculateWinner
that takes an array of 9 squares, checks for a winner and returns 'X'
, 'O'
, or null
as appropriate. The second helper function called calculateTurns
that takes the same array, checks for remaining turns by filtering out only null
items, and returns the count of them. The last helper called calculateStatus
that takes the remaining turns, the winner, and the current player ('X' or 'O'
):
js
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
}
function calculateTurns(squares) {
return squares.filter((square) => !square).length
}
function calculateStatus(winner, turns, player) {
if (!winner && !turns) return 'Draw'
if (winner) return `Winner ${winner}`
return `Next player: ${player}`
}
你将在 Board 组件的 handleClick
函数中使用 calculateWinner(squares)
的结果来检查玩家是否获胜。你可以在检查用户是否单击了已经具有 'X'
或 'O'
的方块的同时执行此检查。我们希望在两种情况下都提前返回:
¥You will use the result of calculateWinner(squares)
in the Board component's handleClick
function to check if a player has won. You can perform this check at the same time you check if a user has clicked a square that already has a 'X'
or and 'O'
. We'd like to return early in both cases:
js
function handleClick(i) {
if (squares[i] || winner) return
const nextSquares = squares.slice()
nextSquares[i] = player'
setSquares(nextSquares)
setXIsNext(!xIsNext)
}
为了让玩家知道游戏何时结束,你可以显示文本,例如 'Winner: X'
或 'Winner: O'
。为此,你需要向 Board
组件添加一个 status
部分。如果游戏结束,状态将显示获胜者或平局,如果游戏正在进行,你将显示下一个轮到哪个玩家:
¥To let the players know when the game is over, you can display text such as 'Winner: X'
or 'Winner: O'
. To do that you'll add a status
section to the Board
component. The status will display the winner or draw if the game is over and if the game is ongoing you'll display which player's turn is next:
jsx
export default function Board() {
const xIsNext = useGameStore((state) => state.xIsNext)
const setXIsNext = useGameStore((state) => state.setXIsNext)
const squares = useGameStore((state) => state.squares)
const setSquares = useGameStore((state) => state.setSquares)
const winner = calculateWinner(squares)
const turns = calculateTurns(squares)
const player = xIsNext ? 'X' : 'O'
const status = calculateStatus(winner, turns, player)
function handleClick(i) {
if (squares[i] || winner) return
const nextSquares = squares.slice()
nextSquares[i] = player
setSquares(nextSquares)
setXIsNext(!xIsNext)
}
return (
<>
<div style={{ marginBottom: '0.5rem' }}>{status}</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
{squares.map((square, squareIndex) => (
<Square
key={squareIndex}
value={square}
onSquareClick={() => handleClick(squareIndex)}
/>
))}
</div>
</>
)
}
恭喜!你现在有一个可以运行的井字游戏。而且你刚刚学习了 React 和 Zustand 的基础知识。所以你才是真正的赢家。代码应该是这样的:
¥Congratulations! You now have a working tic-tac-toe game. And you've just learned the basics of React and Zustand too. So you are the real winner here. Here is what the code should look like:
jsx
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
const useGameStore = create(
combine({ squares: Array(9).fill(null), xIsNext: true }, (set) => {
return {
setSquares: (nextSquares) => {
set((state) => ({
squares:
typeof nextSquares === 'function'
? nextSquares(state.squares)
: nextSquares,
}))
},
setXIsNext: (nextXIsNext) => {
set((state) => ({
xIsNext:
typeof nextXIsNext === 'function'
? nextXIsNext(state.xIsNext)
: nextXIsNext,
}))
},
}
}),
)
function Square({ value, onSquareClick }) {
return (
<button
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
backgroundColor: '#fff',
border: '1px solid #999',
outline: 0,
borderRadius: 0,
fontSize: '1rem',
fontWeight: 'bold',
}}
onClick={onSquareClick}
>
{value}
</button>
)
}
export default function Board() {
const xIsNext = useGameStore((state) => state.xIsNext)
const setXIsNext = useGameStore((state) => state.setXIsNext)
const squares = useGameStore((state) => state.squares)
const setSquares = useGameStore((state) => state.setSquares)
const winner = calculateWinner(squares)
const turns = calculateTurns(squares)
const player = xIsNext ? 'X' : 'O'
const status = calculateStatus(winner, turns, player)
function handleClick(i) {
if (squares[i] || winner) return
const nextSquares = squares.slice()
nextSquares[i] = player
setSquares(nextSquares)
setXIsNext(!xIsNext)
}
return (
<>
<div style={{ marginBottom: '0.5rem' }}>{status}</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
{squares.map((square, squareIndex) => (
<Square
key={squareIndex}
value={square}
onSquareClick={() => handleClick(squareIndex)}
/>
))}
</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
}
function calculateTurns(squares) {
return squares.filter((square) => !square).length
}
function calculateStatus(winner, turns, player) {
if (!winner && !turns) return 'Draw'
if (winner) return `Winner ${winner}`
return `Next player: ${player}`
}
添加时间旅行
¥Adding time travel
作为最后的练习,让我们可以“回到过去”并重新审视游戏中的先前动作。
¥As a final exercise, let's make it possible to “go back in time” and revisit previous moves in the game.
如果直接修改了方块数组,实现这个时间旅行功能会非常困难。但是,由于你使用 slice()
在每次移动后创建了正方形数组的新副本,并将其视为不可变的,因此你可以存储正方形数组的每个过去版本并在它们之间导航。
¥If you had directly modified the squares array, implementing this time-travel feature would be very difficult. However, since you used slice()
to create a new copy of the squares array after every move, treating it as immutable, you can store every past version of the squares array and navigate between them.
你将在名为 history
的新状态变量中跟踪这些过去的方块数组。此 history
数组将存储所有棋盘状态,从第一步到最新一步,如下所示:
¥You'll keep track of these past squares arrays in a new state variable called history
. This history
array will store all board states, from the first move to the latest one, and will look something like this:
js
const history = [
// First move
[null, null, null, null, null, null, null, null, null],
// Second move
['X', null, null, null, null, null, null, null, null],
// Third move
['X', 'O', null, null, null, null, null, null, null],
// and so on...
]
这种方法允许你轻松地在不同的游戏状态之间导航并实现时间旅行功能。
¥This approach allows you to easily navigate between different game states and implement the time-travel feature.
再次提升状态
¥Lifting state up, again
接下来,你将创建一个名为 Game
的新顶层组件来显示过去移动的列表。你将在此处存储包含整个游戏历史记录的 history
状态。
¥Next, you will create a new top-level component called Game
to display a list of past moves. This is where you will store the history
state that contains the entire game history.
通过将 history
状态放置在 Game
组件中,你可以从 Board
组件中删除 squares
状态。你现在将状态从 Board
组件提升到顶层 Game
组件。此更改允许 Game
组件完全控制 Board
的组件数据并指示 Board
组件从 history
渲染先前的回合。
¥By placing the history
state in the Game
component, you can remove the squares
state from the Board
component. You will now lift the state up from the Board
component to the top-level Game
component. This change allows the Game
component to have full control over the Board
's component data and instruct the Board
component to render previous turns from the history
.
首先,添加一个带有 export default
的 Game
组件并将其从 Board
组件中删除。代码应该是这样的:
¥First, add a Game
component with export default
and remove it from Board
component. Here is what the code should look like:
jsx
function Board() {
const xIsNext = useGameStore((state) => state.xIsNext)
const setXIsNext = useGameStore((state) => state.setXIsNext)
const squares = useGameStore((state) => state.squares)
const setSquares = useGameStore((state) => state.setSquares)
const winner = calculateWinner(squares)
const turns = calculateTurns(squares)
const player = xIsNext ? 'X' : 'O'
const status = calculateStatus(winner, turns, player)
function handleClick(i) {
if (squares[i] || winner) return
const nextSquares = squares.slice()
nextSquares[i] = player
setSquares(nextSquares)
setXIsNext(!xIsNext)
}
return (
<>
<div style={{ marginBottom: '0.5rem' }}>{status}</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
{squares.map((square, squareIndex) => (
<Square
key={squareIndex}
value={square}
onSquareClick={() => handleClick(squareIndex)}
/>
))}
</div>
</>
)
}
export default function Game() {
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
fontFamily: 'monospace',
}}
>
<div>
<Board />
</div>
<div style={{ marginLeft: '1rem' }}>
<ol>{/* TODO */}</ol>
</div>
</div>
)
}
向 useGameStore
钩子添加一些状态以跟踪移动历史记录:
¥Add some state to the useGameStore
hook to track the history of moves:
js
const useGameStore = create(
combine({ history: [Array(9).fill(null)], xIsNext: true }, (set) => {
return {
setHistory: (nextHistory) => {
set((state) => ({
history:
typeof nextHistory === 'function'
? nextHistory(state.history)
: nextHistory,
}))
},
setXIsNext: (nextXIsNext) => {
set((state) => ({
xIsNext:
typeof nextXIsNext === 'function'
? nextXIsNext(state.xIsNext)
: nextXIsNext,
}))
},
}
}),
)
请注意 [Array(9).fill(null)]
如何创建一个包含单个项目的数组,而该项目本身就是一个包含 9 个空值的数组。
¥Notice how [Array(9).fill(null)]
creates an array with a single item, which is itself an array of 9 null values.
要渲染当前移动的方块,你需要从 history
状态读取最新的方块数组。你不需要为此添加额外的状态,因为你已经拥有足够的信息在渲染期间进行计算:
¥To render the squares for the current move, you'll need to read the most recent squares array from the history
state. You don't need an extra state for this because you already have enough information to calculate it during rendering:
jsx
export default function Game() {
const history = useGameStore((state) => state.history)
const setHistory = useGameStore((state) => state.setHistory)
const xIsNext = useGameStore((state) => state.xIsNext)
const setXIsNext = useGameStore((state) => state.setXIsNext)
const currentSquares = history[history.length - 1]
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
fontFamily: 'monospace',
}}
>
<div>
<Board />
</div>
<div style={{ marginLeft: '1rem' }}>
<ol>{/*TODO*/}</ol>
</div>
</div>
)
}
接下来,在 Game
组件内创建一个 handlePlay
函数,该函数将由 Board
组件调用来更新游戏。将 xIsNext
、currentSquares
和 handlePlay
作为 props 传递给 Board
组件:
¥Next, create a handlePlay
function inside the Game
component that will be called by the Board
component to update the game. Pass xIsNext
, currentSquares
and handlePlay
as props to the Board
component:
jsx
export default function Game() {
const history = useGameStore((state) => state.history)
const setHistory = useGameStore((state) => state.setHistory)
const currentMove = useGameStore((state) => state.currentMove)
const setCurrentMove = useGameStore((state) => state.setCurrentMove)
const currentSquares = history[history.length - 1]
function handlePlay(nextSquares) {
// TODO
}
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
fontFamily: 'monospace',
}}
>
<div>
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div style={{ marginLeft: '1rem' }}>
<ol>{/*TODO*/}</ol>
</div>
</div>
)
}
让我们让 Board
组件完全由它收到的 props 控制。为此,我们将修改 Board
组件以接受三个 props:xIsNext
、squares
和一个新的 onPlay
函数,当玩家移动时,Board
组件可以使用更新的方块数组调用该函数。
¥Let's make the Board
component fully controlled by the props it receives. To do this, we'll modify the Board
component to accept three props: xIsNext
, squares
, and a new onPlay
function that the Board
component can call with the updated squares array when a player makes a move.
jsx
function Board({ xIsNext, squares, onPlay }) {
const winner = calculateWinner(squares)
const turns = calculateTurns(squares)
const player = xIsNext ? 'X' : 'O'
const status = calculateStatus(winner, turns, player)
function handleClick(i) {
if (squares[i] || winner) return
const nextSquares = squares.slice()
nextSquares[i] = player
setSquares(nextSquares)
}
return (
<>
<div style={{ marginBottom: '0.5rem' }}>{status}</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
{squares.map((square, squareIndex) => (
<Square
key={squareIndex}
value={square}
onSquareClick={() => handleClick(squareIndex)}
/>
))}
</div>
</>
)
}
Board
组件现在完全由 Game
组件传递给它的 props 控制。要让游戏再次运行,你需要在 Game
组件中实现 handlePlay
函数。
¥The Board
component is now fully controlled by the props passed to it by the Game
component. To get the game working again, you need to implement the handlePlay
function in the Game
component.
handlePlay
在调用时应该做什么?以前,Board
组件使用更新的数组调用 setSquares
;现在它将更新的正方形数组传递给 onPlay
。
¥What should handlePlay
do when called? Previously, the Board
component called setSquares
with an updated array; now it passes the updated squares array to onPlay
.
handlePlay
函数需要更新 Game
组件的状态以触发重新渲染。你无需使用 setSquares
,而是通过将更新的方块数组附加为新的 history
条目来更新 history
状态变量。你还需要切换 xIsNext
,就像 Board
组件以前所做的那样。
¥The handlePlay
function needs to update the Game
component's state to trigger a re-render. Instead of using setSquares
, you'll update the history
state variable by appending the updated squares array as a new history
entry. You also need to toggle xIsNext
, just as the Board
component used to do.
js
function handlePlay(nextSquares) {
setHistory(history.concat([nextSquares]))
setXIsNext(!xIsNext)
}
此时,你已将状态移至 Game
组件中,并且 UI 应该可以完全正常工作,就像重构之前一样。此时代码应该是这样的:
¥At this point, you've moved the state to live in the Game
component, and the UI should be fully working, just as it was before the refactor. Here is what the code should look like at this point:
jsx
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
const useGameStore = create(
combine({ history: [Array(9).fill(null)], xIsNext: true }, (set) => {
return {
setHistory: (nextHistory) => {
set((state) => ({
history:
typeof nextHistory === 'function'
? nextHistory(state.history)
: nextHistory,
}))
},
setXIsNext: (nextXIsNext) => {
set((state) => ({
xIsNext:
typeof nextXIsNext === 'function'
? nextXIsNext(state.xIsNext)
: nextXIsNext,
}))
},
}
}),
)
function Square({ value, onSquareClick }) {
return (
<button
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
backgroundColor: '#fff',
border: '1px solid #999',
outline: 0,
borderRadius: 0,
fontSize: '1rem',
fontWeight: 'bold',
}}
onClick={onSquareClick}
>
{value}
</button>
)
}
function Board({ xIsNext, squares, onPlay }) {
const winner = calculateWinner(squares)
const turns = calculateTurns(squares)
const player = xIsNext ? 'X' : 'O'
const status = calculateStatus(winner, turns, player)
function handleClick(i) {
if (squares[i] || winner) return
const nextSquares = squares.slice()
nextSquares[i] = player
onPlay(nextSquares)
}
return (
<>
<div style={{ marginBottom: '0.5rem' }}>{status}</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(3, 1fr)',
width: 'calc(3 * 2.5rem)',
height: 'calc(3 * 2.5rem)',
border: '1px solid #999',
}}
>
{squares.map((square, squareIndex) => (
<Square
key={squareIndex}
value={square}
onSquareClick={() => handleClick(squareIndex)}
/>
))}
</div>
</>
)
}
export default function Game() {
const history = useGameStore((state) => state.history)
const setHistory = useGameStore((state) => state.setHistory)
const xIsNext = useGameStore((state) => state.xIsNext)
const setXIsNext = useGameStore((state) => state.setXIsNext)
const currentSquares = history[history.length - 1]
function handlePlay(nextSquares) {
setHistory(history.concat([nextSquares]))
setXIsNext(!xIsNext)
}
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
fontFamily: 'monospace',
}}
>
<div>
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div style={{ marginLeft: '1rem' }}>
<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
}
function calculateTurns(squares) {
return squares.filter((square) => !square).length
}
function calculateStatus(winner, turns, player) {
if (!winner && !turns) return 'Draw'
if (winner) return `Winner ${winner}`
return `Next player: ${player}`
}
显示过去的动作
¥Showing the past moves
由于你正在记录井字游戏的历史记录,因此你现在可以向玩家显示过去的动作列表。
¥Since you are recording the tic-tac-toe game's history, you can now display a list of past moves to the player.
你已经有一个 history
动作数组,因此现在你需要将其转换为 React 元素数组。在 JavaScript 中,要将一个数组转换为另一个数组,可以使用 Array .map()
方法:
¥You already have an array of history
moves in store, so now you need to transform it to an array of React elements. In JavaScript, to transform one array into another, you can use the Array .map()
method:
你将使用 map
将 history
动作转换为代表屏幕上按钮的 React 元素,并显示按钮列表以跳转到过去的动作。让我们在 Game
组件中使用 map
覆盖 history
:
¥You'll use map
to transform your history
of moves into React elements representing buttons on the screen, and display a list of buttons to jump to past moves. Let's map
over the history
in the Game
component:
jsx
export default function Game() {
const history = useGameStore((state) => state.history)
const setHistory = useGameStore((state) => state.setHistory)
const xIsNext = useGameStore((state) => state.xIsNext)
const setXIsNext = useGameStore((state) => state.setXIsNext)
const currentSquares = history[history.length - 1]
function handlePlay(nextSquares) {
setHistory(history.concat([nextSquares]))
setXIsNext(!xIsNext)
}
function jumpTo(nextMove) {
// TODO
}
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
fontFamily: 'monospace',
}}
>
<div>
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div style={{ marginLeft: '1rem' }}>
<ol>
{history.map((_, historyIndex) => {
const description =
historyIndex > 0
? `Go to move #${historyIndex}`
: 'Go to game start'
return (
<li key={historyIndex}>
<button onClick={() => jumpTo(historyIndex)}>
{description}
</button>
</li>
)
})}
</ol>
</div>
</div>
)
}
在实现 jumpTo
函数之前,你需要 Game
组件来跟踪用户当前正在查看的步骤。为此,定义一个名为 currentMove
的新状态变量,它将从 0
开始:
¥Before you can implement the jumpTo
function, you need the Game
component to keep track of which step the user is currently viewing. To do this, define a new state variable called currentMove
, which will start at 0
:
js
const useGameStore = create(
combine(
{ history: [Array(9).fill(null)], currentMove: 0, xIsNext: true },
(set) => {
return {
setHistory: (nextHistory) => {
set((state) => ({
history:
typeof nextHistory === 'function'
? nextHistory(state.history)
: nextHistory,
}))
},
setCurrentMove: (nextCurrentMove) => {
set((state) => ({
currentMove:
typeof nextCurrentMove === 'function'
? nextCurrentMove(state.currentMove)
: nextCurrentMove,
}))
},
setXIsNext: (nextXIsNext) => {
set((state) => ({
xIsNext:
typeof nextXIsNext === 'function'
? nextXIsNext(state.xIsNext)
: nextXIsNext,
}))
},
}
},
),
)
接下来,更新 Game
组件内的 jumpTo
函数以更新该 currentMove
。如果你要将 currentMove
更改为偶数,你还将 xIsNext
设置为 true
。
¥Next, update the jumpTo
function inside Game
component to update that currentMove
. You’ll also set xIsNext
to true
if the number that you’re changing currentMove
to is even.
js
function jumpTo(nextMove) {
setCurrentMove(nextMove)
setXIsNext(currentMove % 2 === 0)
}
现在,你将对 Game
组件中的 handlePlay
函数进行两处更改,该函数在你单击某个方块时调用。
¥You will now make two changes to the handlePlay
function in the Game
component, which is called when you click on a square.
如果你 "回到过去" 然后从该点开始新的移动,你只想保留到该点的历史记录。你无需在历史记录中的所有项目后添加
nextSquares
(使用 Array.concat()
方法),而是将其添加到history.slice(0, currentMove + 1)
中的所有项目后,以仅保留旧历史记录的那部分。¥If you "go back in time" and then make a new move from that point, you only want to keep the history up to that point. Instead of adding
nextSquares
after all items in the history (using the Array.concat()
method), you'll add it after all items inhistory.slice(0, currentMove + 1)
to keep only that portion of the old history.每次移动时,你都需要更新
currentMove
以指向最新的历史记录条目。¥Each time a move is made, you need to update
currentMove
to point to the latest history entry.
js
function handlePlay(nextSquares) {
const nextHistory = history.slice(0, currentMove + 1).concat([nextSquares])
setHistory(nextHistory)
setCurrentMove(nextHistory.length - 1)
setXIsNext(!xIsNext)
}
最后,你将修改 Game
组件以渲染当前选定的动作,而不是始终渲染最终动作:
¥Finally, you will modify the Game
component to render the currently selected move, instead of always rendering the final move:
jsx
export default function Game() {
const history = useGameStore((state) => state.history)
const setHistory = useGameStore((state) => state.setHistory)
const currentMove = useGameStore((state) => state.currentMove)
const setCurrentMove = useGameStore((state) => state.setCurrentMove)
const xIsNext = useGameStore((state) => state.xIsNext)
const setXIsNext = useGameStore((state) => state.setXIsNext)
const currentSquares = history[currentMove]
function handlePlay(nextSquares) {
const nextHistory = history.slice(0, currentMove + 1).concat([nextSquares])
setHistory(nextHistory)
setCurrentMove(nextHistory.length - 1)
setXIsNext(!xIsNext)
}
function jumpTo(nextMove) {
setCurrentMove(nextMove)
setXIsNext(currentMove % 2 === 0)
}
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
fontFamily: 'monospace',
}}
>
<div>
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div style={{ marginLeft: '1rem' }}>
<ol>
{history.map((_, historyIndex) => {
const description =
historyIndex > 0
? `Go to move #${historyIndex}`
: 'Go to game start'
return (
<li key={historyIndex}>
<button onClick={() => jumpTo(historyIndex)}>
{description}
</button>
</li>
)
})}
</ol>
</div>
</div>
)
}
最终清理
¥Final cleanup
如果仔细查看代码,你会发现当 currentMove
为偶数时,xIsNext
是 true
,当 currentMove
为奇数时,xIsNext
是 false
。这意味着如果你知道 currentMove
的值,你始终可以确定 xIsNext
应该是什么。
¥If you look closely at the code, you'll see that xIsNext
is true
when currentMove
is even and false
when currentMove
is odd. This means that if you know the value of currentMove
, you can always determine what xIsNext
should be.
无需在状态中单独存储 xIsNext
。最好避免冗余状态,因为它可以减少错误并使你的代码更易于理解。相反,你可以根据 currentMove
计算 xIsNext
:
¥There's no need to store xIsNext
separately in the state. It’s better to avoid redundant state because it can reduce bugs and make your code easier to understand. Instead, you can calculate xIsNext
based on currentMove
:
jsx
export default function Game() {
const history = useGameStore((state) => state.history)
const setHistory = useGameStore((state) => state.setHistory)
const currentMove = useGameStore((state) => state.currentMove)
const setCurrentMove = useGameStore((state) => state.setCurrentMove)
const xIsNext = currentMove % 2 === 0
const currentSquares = history[currentMove]
function handlePlay(nextSquares) {
const nextHistory = history.slice(0, currentMove + 1).concat([nextSquares])
setHistory(nextHistory)
setCurrentMove(nextHistory.length - 1)
}
function jumpTo(nextMove) {
setCurrentMove(nextMove)
}
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
fontFamily: 'monospace',
}}
>
<div>
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div style={{ marginLeft: '1rem' }}>
<ol>
{history.map((_, historyIndex) => {
const description =
historyIndex > 0
? `Go to move #${historyIndex}`
: 'Go to game start'
return (
<li key={historyIndex}>
<button onClick={() => jumpTo(historyIndex)}>
{description}
</button>
</li>
)
})}
</ol>
</div>
</div>
)
}
你不再需要 xIsNext
状态声明或对 setXIsNext
的调用。现在,即使你在编写组件代码时犯了错误,xIsNext
也不会与 currentMove
不同步。
¥You no longer need the xIsNext
state declaration or the calls to setXIsNext
. Now, there’s no chance for xIsNext
to get out of sync with currentMove
, even if you make a mistake while coding the components.
总结
¥Wrapping up
恭喜!你已经创建了一个井字游戏:
¥Congratulations! You’ve created a tic-tac-toe game that:
让你玩井字游戏,
¥Lets you play tic-tac-toe,
表示玩家何时赢得游戏或何时抽牌,
¥Indicates when a player has won the game or when is drawn,
在游戏进行过程中存储游戏历史记录,
¥Stores a game’s history as a game progresses,
允许玩家查看游戏历史并查看游戏板的先前版本。
¥Allows players to review a game’s history and see previous versions of a game’s board.
干得好!我们希望你现在感觉自己已经很好地掌握了 React 和 Zustand 的工作原理。
¥Nice work! We hope you now feel like you have a decent grasp of how React and Zustand works.