教程:井字棋

在本教程中,你将构建一个简单的井字棋游戏。本教程不假设你具备任何 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 属性。

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

function Square({ value }) 表示可以向 Square 组件传递一个名为 value 的属性。

现在,您想在每个方块中显示 value 而不是 1。尝试这样做。

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

哎呀,这不是您想要的。

value-filled board

您想渲染来自组件的名为 value 的 JavaScript 变量,而不是单词“value”。要从 JSX 中“转义到 JavaScript”,您需要使用大括号。像这样在 JSX 中的 value 周围添加大括号。

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

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

empty board

这是因为 Board 组件还没有将 value 属性传递给它渲染的每个 Square 组件。要解决此问题,您需要将 value 属性添加到由 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>
    </>
  );
}

创建一个交互式组件

让我们在您点击 Square 组件时用 X 填充它。在 Square 内部声明一个名为 handleClick 的函数。然后,将 onClick 添加到从 Square 返回的按钮 JSX 元素的属性中。

function Square({ value }) {
function handleClick() {
console.log('clicked!');
}

return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}

如果您现在点击一个方块,您应该会在 CodeSandbox 中“浏览器”部分底部的“控制台”选项卡中看到一条显示 "clicked!" 的日志。多次点击该方块将再次记录 "clicked!"。重复的具有相同消息的控制台日志不会在控制台中创建更多行。相反,您将在第一个 "clicked!" 日志旁边看到一个递增的计数器。

注意

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

下一步,您希望 Square 组件“记住”它被点击过,并在其中填充一个“X”标记。为了“记住”事情,组件使用 *状态*。

React 提供了一个名为 useState 的特殊函数,您可以从组件中调用它以让它“记住”事情。让我们将 Square 的当前值存储在状态中,并在点击 Square 时更改它。

在文件顶部导入 useState。从 Square 组件中删除 value 属性。相反,在 Square 的开头添加一行调用 useState 的代码。让它返回一个名为 value 的状态变量。

import { useState } from 'react';

function Square() {
const [value, setValue] = useState(null);

function handleClick() {
//...

value 存储值,setValue 是一个可用于更改值的函数。传递给 useStatenull 被用作此状态变量的初始值,因此此处的 value 最初等于 null

由于 Square 组件不再接受属性,因此您需要从 Board 组件创建的所有九个 Square 组件中删除 value 属性。

// ...
export default function Board() {
return (
<>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
</>
);
}

现在,您将更改 Square 以便在点击时显示“X”。将 console.log("clicked!"); 事件处理程序替换为 setValue('X');。现在您的 Square 组件如下所示。

function Square() {
const [value, setValue] = useState(null);

function handleClick() {
setValue('X');
}

return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}

通过在 onClick 处理程序中调用 set 函数,您是在告诉 React 只要它的 <button> 被点击,就重新渲染该 Square。更新后,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 和 state。您可以在 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 需要“询问”每个 SquareSquare 的状态。尽管这种方法在 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) 创建一个包含 9 个元素的数组,并将每个元素设置为 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>
);
}

接下来,您将向 Square 组件的 props 中添加 onSquareClick 函数。

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) 将是渲染棋盘组件的一部分。因为 handleClick(0) 通过调用 setSquares 更改了棋盘组件的状态,所以您的整个棋盘组件将再次重新渲染。但这会再次运行 handleClick(0),从而导致无限循环。

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

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

当您传递 onSquareClick={handleClick} 时,您是将 handleClick 函数作为 prop 传递的。您没有调用它!但现在您正在 立即调用该函数——请注意 handleClick(0) 中的括号——这就是它运行得太早的原因。您不希望 在用户点击之前调用 handleClick

您可以通过创建一个函数来解决这个问题,例如调用 handleClick(0)handleFirstSquareClick 函数,一个调用 handleClick(1)handleSecondSquareClick 函数,等等。您可以将这些函数作为 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 属性中的函数。 Square 组件是从 BoardonSquareClick 属性中接收到的该函数。 Board 组件直接在 JSX 中定义了该函数。 它调用了 handleClick 函数,并传入了参数 0
  2. handleClick 函数使用参数 (0) 将 squares 数组的第一个元素从 null 更新为 X
  3. Board 组件的 squares 状态已更新,因此 Board 及其所有子组件都会重新渲染。 这会导致索引为 0Square 组件的 value 属性从 null 变为 X

最终,用户会看到左上角的方块在被点击后从空白变为显示 X

注意

DOM 中的 <button> 元素的 onClick 属性对 React 来说具有特殊含义,因为它是内置组件。 对于像 Square 这样的自定义组件,命名取决于您。 您可以为 SquareonSquareClick 属性或 BoardhandleClick 函数起任何名字,代码都能正常工作。 在 React 中,通常使用 onSomething 来命名表示事件的属性,并使用 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 读取最后一个方块数组。您不需要为此使用 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 数组调用该函数。接下来,删除调用 useStateBoard 函数的前两行

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

现在,将 Board 组件的 handleClick 中的 setSquaressetXIsNext 调用替换为对新 onPlay 函数的单个调用,以便 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 进行 map 操作

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 是一个计算机程序,不知道您的意图,因此您需要为每个列表项指定一个*键*属性,以区分每个列表项与其兄弟姐妹。如果您的数据来自数据库,则可以使用 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.slice(0, currentMove + 1) 中的所有项目之后添加 nextSquares,而不是在 history 中的所有项目(... 展开语法)之后添加它,以便您只保留旧历史记录的那一部分。
  • 每次移动时,您都需要更新 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. 仅针对当前的移动,显示“你 berada di move #…”,而不是按钮。
  2. 重写 Board,使用两个循环来制作方块,而不是硬编码它们。
  3. 添加一个切换按钮,让你可以按升序或降序对移动进行排序。
  4. 当有人获胜时,突出显示导致获胜的三个方块(当没有人获胜时,显示一条关于结果为平局的消息)。
  5. 在移动历史记录列表中以 (行,列) 的格式显示每次移动的位置。

在本教程中,你已经接触了 React 的概念,包括元素、组件、属性和状态。现在你已经看到了这些概念在构建游戏时的工作原理,请查看 Thinking in React,看看相同的 React 概念在构建应用程序 UI 时的工作原理。