教程:井字棋

在本教程中,您将构建一个小型井字棋游戏。本教程不假设您具备任何现有的 React 知识。您将在教程中学到的技术对于构建任何 React 应用都至关重要,充分理解它将使您对 React 有深入的了解。

注意

本教程面向喜欢实践学习并希望快速尝试制作一些切实可行的东西的人。如果您更喜欢一步一步地学习每个概念,请从描述 UI开始。

本教程分为几个部分

  • 教程设置将为您提供一个起点来学习本教程。
  • 概述将教你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>;
}

注意

您也可以使用本地开发环境来学习本教程。为此,您需要:

  1. 安装 Node.js
  2. 在您之前打开的 CodeSandbox 标签页中,按左上角的按钮打开菜单,然后在该菜单中选择下载沙箱以将文件的存档下载到本地。
  3. 解压存档,然后打开终端并cd 到您解压的目录。
  4. 使用npm install安装依赖项。
  5. 运行npm start启动本地服务器,并按照提示在浏览器中查看运行的代码。

如果您遇到困难,请不要因此而停止!改为在线学习,稍后再尝试本地设置。

概述

现在您已设置完毕,让我们来概述一下 React!

检查启动代码

在 CodeSandbox 中,您将看到三个主要部分:

CodeSandbox with starter code
  1. 包含文件列表的*文件*部分,例如App.jsindex.jsstyles.css和一个名为public的文件夹。
  2. 您将在其中看到所选文件源代码的*代码编辑器*。
  3. 您将在其中看到所写代码的显示方式的*浏览器*部分。

在*文件*部分中应选择App.js文件。*代码编辑器*中该文件的内容应为:

export default function Square() {
return <button className="square">X</button>;
}

*浏览器*部分应显示一个带有 X 的正方形,如下所示:

x-filled square

现在让我们看一下启动代码中的文件。

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>;
}

您将收到此错误。

控制台
/src/App.js: 相邻的 JSX 元素必须包装在封闭标签中。您是否想要一个 JSX 片段 <>...</>

React 组件需要返回单个 JSX 元素,而不是像两个按钮那样返回多个相邻的 JSX 元素。要解决此问题,您可以使用片段(<></>) 来包装多个相邻的 JSX 元素,如下所示:

export default function Square() {
return (
<>
<button className="square">X</button>
<button className="square">X</button>
</>
);
}

现在您应该看到:

two x-filled squares

太棒了!现在您只需要复制粘贴几次即可添加九个方块……

nine x-filled squares in a line

哦,不!方块都在一行中,而不是像棋盘那样需要网格状排列。要解决此问题,您需要使用 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 将样式应用于 classNameboard-row 的 div。既然您已使用带样式的 div 将组件分组到行中,那么您就有了井字棋棋盘。

tic-tac-toe board filled with numbers 1 through 9

但是现在你遇到一个问题。你的名为 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 不同,你自己的组件 BoardSquare 必须以大写字母开头。

让我们来看一下

one-filled board

哦,不!你丢失了之前的编号方格。现在每个方格都显示“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>;
}

糟糕,这不是你想要的结果。

value-filled board

你想渲染来自组件的 JavaScript 变量 value,而不是单词“value”。要从 JSX “转义到 JavaScript”,你需要花括号。在 JSX 中的 value 周围添加花括号,如下所示

function Square({ value }) {
return <button className="square">{value}</button>;
}

目前,你应该看到一个空的棋盘。

empty board

这是因为 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>
</>
);
}

现在你应该再次看到一个数字网格。

tic-tac-toe board filled with numbers 1 through 9

更新后的代码应该如下所示

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!" 日志旁边有一个递增的计数器。

注意

如果你使用本地开发环境学习本教程,则需要打开浏览器的控制台。例如,如果你使用 Chrome 浏览器,可以使用键盘快捷键 Shift + Ctrl + J(在 Windows/Linux 上)或 Option + ⌘ + J(在 macOS 上)查看控制台。

下一步,你希望 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。更新后,Squarevalue 将为 'X',因此您将在游戏板上看到“X”。点击任何方格,“X”都应该出现。

adding xes to board

每个 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 in CodeSandbox

要检查屏幕上特定组件,请使用 React DevTools 左上角的按钮。

Selecting components on the page with React DevTools

注意

对于本地开发,React DevTools 可作为 ChromeFirefoxEdge 浏览器扩展程序使用。安装它后,“组件”选项卡将出现在使用 React 的网站的浏览器开发者工具中。

完成游戏

至此,您已经拥有了井字棋游戏的所有基本构建块。要完成一个完整的游戏,您现在需要轮流在棋盘上放置“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) 创建一个包含九个元素的数组,并将每个元素设置为 nulluseState() 周围的调用声明一个 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>;
}

此时,您应该会看到一个空的井字棋棋盘。

empty board

并且您的代码应该如下所示

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组件)的重新渲染。

注意

JavaScript支持闭包,这意味着内部函数(例如handleClick)可以访问外部函数(例如Board)中定义的变量和函数。handleClick函数可以读取squares状态并调用setSquares方法,因为它们都在Board函数内部定义。

现在你可以向棋盘添加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),导致无限循环。

控制台
重新渲染次数过多。React限制了渲染次数以防止无限循环。

为什么这个问题之前没有发生?

当你传递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了。

filling the board with 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 时会发生什么。

  1. 点击左上角方格会运行 buttonSquare 接收到的 onClick prop 属性对应的函数。 Square 组件从 Board 接收该函数作为其 onSquareClick prop 属性。 Board 组件直接在 JSX 中定义了该函数。它调用 handleClick,参数为 0
  2. handleClick 使用参数 (0) 更新 squares 数组的第一个元素,将其从 null 更改为 X
  3. Board 组件的 squares 状态已更新,因此 Board 及其所有子组件都重新渲染。这导致索引为 0Square 组件的 value prop 属性从 null 变为 X

最终,用户会看到点击后左上角方格已从空变为 X

注意

DOM <button> 元素的 onClick 属性对 React 具有特殊意义,因为它是一个内置组件。对于像 Square 这样的自定义组件,命名由你决定。你可以为 SquareonSquareClick prop 属性或 BoardhandleClick 函数指定任何名称,代码仍然可以正常工作。在 React 中,通常使用 onSomething 来命名表示事件的 props 属性,并使用 handleSomething 来命名处理这些事件的函数定义。

为什么不变性很重要

注意,在 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(一个布尔值)将被翻转以确定下一个玩家是谁,并保存游戏状态。你将更新 BoardhandleClick 函数来翻转 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 (
//...
);
}

现在,当你点击不同的方格时,它们将在 XO 之间交替出现,这正是你所期望的!

但是等等,出现了一个问题。尝试多次点击同一个方格。

O overwriting an X

XO 覆盖了!虽然这会给游戏增加一个非常有趣的转折,但我们现在还是坚持原来的规则。

当你用XO标记方格时,你并没有首先检查该方格是否已经有XO的值。你可以通过提前返回来修复这个问题。你将检查该方格是否已经有XO。如果方格已被填充,你将在handleClick函数中提前返回——在它尝试更新棋盘状态之前。

function handleClick(i) {
if (squares[i]) {
return;
}
const nextSquares = squares.slice();
//...
}

现在你只能在空方格中添加XO了!这是你的代码在这一点上应该的样子。

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)来检查是否有玩家获胜。你可以在同时检查用户是否点击了已经有XO的方格时执行此检查。我们希望在这两种情况下都提前返回。

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的数据,并可以指示Boardhistory渲染之前的回合。

首先,添加一个带有export defaultGame组件。让它渲染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组件调用以更新游戏。将xIsNextcurrentSquareshandlePlay作为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:xIsNextsquares和一个新的onPlay函数,Board可以在玩家移动时使用更新的squares数组调用该函数。接下来,移除Board函数中调用useState的前两行。

function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
//...
}
// ...
}

现在用对新onPlay函数的单个调用替换Board组件中handleClick中的setSquaressetXIsNext调用,以便当用户点击方块时,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>
);
}

您可以在下面看到代码应该是什么样子。请注意,您应该在开发者工具控制台中看到一条错误消息,提示

控制台
警告:数组或迭代器中的每个子元素都应该具有唯一的“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 参数遍历每个数组索引:012……(在大多数情况下,您需要实际的数组元素,但要渲染走法列表,您只需要索引。)

对于井字游戏历史记录中的每一次走法,您创建一个列表项 <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);
}
//...
}

现在,你需要对GamehandlePlay函数进行两处修改,此函数在点击方块时调用。

  • 如果你“回到过去”,然后从那个点开始新的移动,你只想保留到那一点的历史记录。不要在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 技能,以下是一些你可以对井字棋游戏进行改进的想法,按难度递增的顺序排列:

  1. 仅对于当前移动,显示“你处于第…步”而不是按钮。
  2. 重写Board,使用两个循环来创建方块,而不是硬编码它们。
  3. 添加一个切换按钮,允许你按升序或降序排列移动。
  4. 当有人获胜时,突出显示导致获胜的三个方块(如果没有获胜者,则显示关于结果为平局的消息)。
  5. 在移动历史列表中以 (行,列) 的格式显示每次移动的位置。

在本教程中,你已经接触到 React 的概念,包括元素、组件、props 和状态。既然你已经了解了在构建游戏时这些概念是如何工作的,请查看React 思维,了解在构建应用程序的 UI 时这些相同的 React 概念是如何工作的。