教程:井字游戏

在本教程中,你将构建一个小型井字棋游戏。本教程不假设你已有任何 React 知识。你将在本教程中学到的技术是构建任何 React 应用的基础,完全理解它将使你对 React 有深入的理解。

🌐 You will build a small tic-tac-toe game during this tutorial. This tutorial does not assume any existing React knowledge. The techniques you’ll learn in the tutorial are fundamental to building any React app, and fully understanding it will give you a deep understanding of React.

注意

本教程适合那些喜欢边做边学的人,并希望快速尝试制作一些具体的东西。如果你更喜欢逐步学习每个概念,请从描述用户界面开始。

🌐 This tutorial is designed for people who prefer to learn by doing and want to quickly try making something tangible. If you prefer learning each concept step by step, start with Describing the UI.

本教程分为几个部分:

🌐 The tutorial is divided into several sections:

你在构建什么?

🌐 What are you building?

在本教程中,你将使用 React 构建交互式井字游戏。

🌐 In this tutorial, you’ll build an interactive tic-tac-toe game with React.

你可以在此处查看完成后的样子:

🌐 You can see what it will look like when you’re finished here:

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 及其语法。

🌐 If the code doesn’t make sense to you yet, or if you are unfamiliar with the code’s syntax, don’t worry! The goal of this tutorial is to help you understand React and its syntax.

我们建议你在继续教程之前先看看上面的井字游戏。你会注意到的一个特点是游戏棋盘的右侧有一个编号列表。这个列表会显示游戏中发生的所有走棋历史,并会随着游戏的进行不断更新。

🌐 We recommend that you check out the tic-tac-toe game above before continuing with the tutorial. One of the features that you’ll notice is that there is a numbered list to the right of the game’s board. This list gives you a history of all of the moves that have occurred in the game, and it is updated as the game progresses.

一旦你玩过完成的井字棋游戏后,继续向下滚动。在本教程中,你将从一个更简单的模板开始。我们的下一步是为你做好准备,以便你可以开始构建游戏。

🌐 Once you’ve played around with the finished tic-tac-toe game, keep scrolling. You’ll start with a simpler template in this tutorial. Our next step is to set you up so that you can start building the game.

教程的设置

🌐 Setup for the tutorial

在下面的实时代码编辑器中,点击右上角的 Fork,在一个新标签页中使用 CodeSandbox 网站打开编辑器。CodeSandbox 让你可以在浏览器中编写代码,并预览用户将如何看到你创建的应用。新标签页中应该显示一个空方块和本教程的起始代码。

🌐 In the live code editor below, click Fork in the top-right corner to open the editor in a new tab using the website CodeSandbox. CodeSandbox lets you write code in your browser and preview how your users will see the app you’ve created. The new tab should display an empty square and the starter code for this tutorial.

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

注意

你也可以使用本地开发环境来按照本教程操作。为此,你需要:

🌐 You can also follow this tutorial using your local development environment. To do this, you need to:

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

如果你卡住了,不要让这阻止你!改为在网上跟着做,并稍后再尝试本地设置。

🌐 If you get stuck, don’t let this stop you! Follow along online instead and try a local setup again later.

概述

🌐 Overview

现在你已经设置好了,让我们来了解一下 React!

🌐 Now that you’re set up, let’s get an overview of React!

检查起始代码

🌐 Inspecting the starter code

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

🌐 In CodeSandbox you’ll see three main sections:

CodeSandbox with starter code
  1. Files 部分包含一个文件列表,如 App.jsindex.jsstyles.css,位于 src 文件夹中,以及一个名为 public 的文件夹
  2. 你将看到所选文件源代码的_代码编辑器_
  3. _浏览器_部分,你将在这里看到你编写的代码将如何显示

App.js 文件应在 Files 部分中选择。该文件在 code editor 中的内容应为:

🌐 The App.js file should be selected in the Files section. The contents of that file in the code editor should be:

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

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

🌐 The browser section should be displaying a square with an X in it like this:

x-filled square

现在让我们看一下起始代码中的文件。

🌐 Now let’s have a look at the files in the starter code.

App.js

App.js 中的代码创建了一个 组件。在 React 中,组件是一段可重用的代码,表示用户界面的一部分。组件用于渲染、管理和更新应用中的 UI 元素。让我们逐行查看组件,看看发生了什么:

🌐 The code in App.js creates a component. In React, a component is a piece of reusable code that represents a part of a user interface. Components are used to render, manage, and update the UI elements in your application. Let’s look at the component line by line to see what’s going on:

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

第一行定义了一个名为 Square 的函数。export JavaScript 关键字使这个函数在文件外部可访问。default 关键字告诉使用你代码的其他文件,这是你文件中的主函数。

🌐 The first line defines a function called Square. The export JavaScript keyword makes this function accessible outside of this file. The default keyword tells other files using your code that it’s the main function in your file.

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

第二行返回一个按钮。return JavaScript 关键字意味着后面的内容会作为值返回给函数的调用者。<button> 是一个 JSX 元素。JSX 元素是 JavaScript 代码和 HTML 标签的组合,用来描述你希望显示的内容。className="square" 是按钮的属性或 prop,用于告诉 CSS 如何为按钮设置样式。X 是显示在按钮内的文本,</button> 用于关闭 JSX 元素,以表示后续内容不应放在按钮内部。

🌐 The second line returns a button. The return JavaScript keyword means whatever comes after is returned as a value to the caller of the function. <button> is a JSX element. A JSX element is a combination of JavaScript code and HTML tags that describes what you’d like to display. className="square" is a button property or prop that tells CSS how to style the button. X is the text displayed inside of the button and </button> closes the JSX element to indicate that any following content shouldn’t be placed inside the button.

styles.css

在 CodeSandbox 的 Files 部分点击标记为 styles.css 的文件。该文件定义了你 React 应用的样式。前两个 CSS 选择器*body)定义了应用中大部分内容的样式,而 .square 选择器定义了任何组件中 className 属性设置为 square 时的样式。在你的代码中,这将匹配 App.js 文件中 Square 组件的按钮。

🌐 Click on the file labeled styles.css in the Files section of CodeSandbox. This file defines the styles for your React app. The first two CSS selectors (* and body) define the style of large parts of your app while the .square selector defines the style of any component where the className property is set to square. In your code, that would match the button from your Square component in the App.js file.

index.js

点击 CodeSandbox 的 Files 部分中标记为 index.js 的文件。在教程过程中你不会编辑这个文件,但它是你在 App.js 文件中创建的组件与网页浏览器之间的桥梁。

🌐 Click on the file labeled index.js in the Files section of CodeSandbox. You won’t be editing this file during the tutorial but it is the bridge between the component you created in the App.js file and the web browser.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';

import App from './App';

第 1-5 行将所有必要的部分组合在一起:

🌐 Lines 1-5 bring all the necessary pieces together:

  • React
  • React 与网络浏览器对话的库 (React DOM)
  • 组件的样式
  • 你在 App.js 中创建的组件。

文件的其余部分将所有部分组合在一起,并将最终产品注入到 public 文件夹中的 index.html

🌐 The remainder of the file brings all the pieces together and injects the final product into index.html in the public folder.

构建面板

🌐 Building the board

让我们回到 App.js。这里将是你完成剩余教程的地方。

🌐 Let’s get back to App.js. This is where you’ll spend the rest of the tutorial.

目前棋盘只有一个方格,但你需要九个!如果你只是尝试复制粘贴你的方格来做两个方格像这样:

🌐 Currently the board is only a single square, but you need nine! If you just try and copy paste your square to make two squares like this:

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

你会得到这个错误:

🌐 You’ll get this error:

Console
/src/App.js:相邻的 JSX 元素必须用一个父标签封装。你是否想使用 JSX Fragment <>...</>

React 组件需要返回一个单一的 JSX 元素,而不是多个相邻的 JSX 元素,比如两个按钮。为了解决这个问题,你可以使用 Fragments<></>)来封装多个相邻的 JSX 元素,如下所示:

🌐 React components need to return a single JSX element and not multiple adjacent JSX elements like two buttons. To fix this you can use Fragments (<> and </>) to wrap multiple adjacent JSX elements like this:

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

现在你应该看到:

🌐 Now you should see:

two x-filled squares

太好了!现在你只需要复制粘贴几次来添加九个方块,然后…

🌐 Great! Now you just need to copy-paste a few times to add nine squares and…

nine x-filled squares in a line

哦不!这些方块全都排在一条线上,而不是像我们棋盘需要的那样排列成网格。要解决这个问题,你需要将方块分组为带有 div 的行,并添加一些 CSS 类。同时,你还需要给每个方块一个编号,以确保你知道每个方块显示的位置。

🌐 Oh no! The squares are all in a single line, not in a grid like you need for our board. To fix this you’ll need to group your squares into rows with divs and add some CSS classes. While you’re at it, you’ll give each square a number to make sure you know where each square is displayed.

App.js 文件中,将 Square 组件更新为如下所示:

🌐 In the App.js file, update the Square component to look like this:

export default function Square() {
return (
<>
<div className="board-row">
<button className="square">1</button>
<button className="square">2</button>
<button className="square">3</button>
</div>
<div className="board-row">
<button className="square">4</button>
<button className="square">5</button>
<button className="square">6</button>
</div>
<div className="board-row">
<button className="square">7</button>
<button className="square">8</button>
<button className="square">9</button>
</div>
</>
);
}

styles.css 中定义的 CSS 为具有 board-rowclassName div 添加了样式。现在你已经将你的组件用带样式的 div 分组成行,你的井字棋盘就完成了:

🌐 The CSS defined in styles.css styles the divs with the className of board-row. Now that you’ve grouped your components into rows with the styled divs you have your tic-tac-toe board:

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

但你现在有一个问题。你名为 Square 的组件,实际上不再是一个正方形了。让我们通过将名称改为 Board 来解决这个问题:

🌐 But you now have a problem. Your component named Square, really isn’t a square anymore. Let’s fix that by changing the name to Board:

export default function Board() {
//...
}

此时你的代码应如下所示:

🌐 At this point your code should look something like this:

export default function Board() {
  return (
    <>
      <div className="board-row">
        <button className="square">1</button>
        <button className="square">2</button>
        <button className="square">3</button>
      </div>
      <div className="board-row">
        <button className="square">4</button>
        <button className="square">5</button>
        <button className="square">6</button>
      </div>
      <div className="board-row">
        <button className="square">7</button>
        <button className="square">8</button>
        <button className="square">9</button>
      </div>
    </>
  );
}

注意

嘘……那要打很多字!从这个页面复制粘贴代码是可以的。不过,如果你想挑战一下,我们建议只复制你自己至少手动输入过一次的代码。

🌐 Psssst… That’s a lot to type! It’s okay to copy and paste code from this page. However, if you’re up for a little challenge, we recommend only copying code that you’ve manually typed at least once yourself.

通过属性传递数据

🌐 Passing data through props

接下来,当用户点击方块时,你需要将方块的值从空更改为“X”。根据你目前构建棋盘的方式,你需要将更新方块的代码复制粘贴九次(每个方块一次)!而不是复制粘贴,React 的组件架构允许你创建一个可重用的组件,以避免杂乱的重复代码。

🌐 Next, you’ll want to change the value of a square from empty to “X” when the user clicks on the square. With how you’ve built the board so far you would need to copy-paste the code that updates the square nine times (once for each square you have)! Instead of copy-pasting, React’s component architecture allows you to create a reusable component to avoid messy, duplicated code.

首先,你将从你的 Board 组件中复制定义第一个方块(<button className="square">1</button>)的那一行到一个新的 Square 组件中:

🌐 First, you are going to copy the line defining your first square (<button className="square">1</button>) from your Board component into a new Square component:

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

export default function Board() {
// ...
}

然后你将更新 Board 组件以使用 JSX 语法渲染那个 Square 组件:

🌐 Then you’ll update the Board component to render that Square component using JSX syntax:

// ...
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 必须以大写字母开头。

🌐 Note how unlike the browser divs, your own components Board and Square must start with a capital letter.

让我们来看看:

🌐 Let’s take a look:

one-filled board

哦不!你之前拥有的编号方块丢失了。现在每个方块都显示“1”。为了解决这个问题,你将使用props从父组件(Board)传递每个方块应该拥有的值到它的子组件(Square)。

🌐 Oh no! You lost the numbered squares you had before. Now each square says “1”. To fix this, you will use props to pass the value each square should have from the parent component (Board) to its child (Square).

更新 Square 组件以读取你将从 Board 传递的 value 属性:

🌐 Update the Square component to read the value prop that you’ll pass from the Board:

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

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

现在你想在每个方格内显示 value 而不是 1。试着这样做:

🌐 Now you want to display that value instead of 1 inside every square. Try doing it like this:

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

糟糕,这不是你想要的:

🌐 Oops, this is not what you wanted:

value-filled board

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

🌐 You wanted to render the JavaScript variable called value from your component, not the word “value”. To “escape into JavaScript” from JSX, you need curly braces. Add curly braces around value in JSX like so:

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

现在,你应该看到一个空面板:

🌐 For now, you should see an empty board:

empty board

这是因为 Board 组件尚未将 value 属性传递给它渲染的每个 Square 组件。要解决此问题,你需要将 value 属性添加到 Board 组件渲染的每个 Square 组件中:

🌐 This is because the Board component hasn’t passed the value prop to each Square component it renders yet. To fix it you’ll add the value prop to each Square component rendered by the Board component:

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

现在你应该再次看到数字网格:

🌐 Now you should see a grid of numbers again:

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

你更新后的代码应如下所示:

🌐 Your updated code should look like this:

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

制作一个交互组件

🌐 Making an interactive component

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

🌐 Let’s fill the Square component with an X when you click it. Declare a function called handleClick inside of the Square. Then, add onClick to the props of the button JSX element returned from the Square:

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

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

如果你现在点击一个方块,你应该会在 CodeSandbox 的 Browser 部分底部的 Console 标签中看到一条显示 "clicked!" 的日志。多次点击该方块将再次记录 "clicked!"。相同消息的重复控制台日志不会在控制台中创建更多行。相反,你会在第一次 "clicked!" 日志旁看到一个递增的计数器。

🌐 If you click on a square now, you should see a log saying "clicked!" in the Console tab at the bottom of the Browser section in CodeSandbox. Clicking the square more than once will log "clicked!" again. Repeated console logs with the same message will not create more lines in the console. Instead, you will see an incrementing counter next to your first "clicked!" log.

注意

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

🌐 If you are following this tutorial using your local development environment, you need to open your browser’s Console. For example, if you use the Chrome browser, you can view the Console with the keyboard shortcut Shift + Ctrl + J (on Windows/Linux) or Option + ⌘ + J (on macOS).

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

🌐 As a next step, you want the Square component to “remember” that it got clicked, and fill it with an “X” mark. To “remember” things, components use state.

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

🌐 React provides a special function called useState that you can call from your component to let it “remember” things. Let’s store the current value of the Square in state, and change it when the Square is clicked.

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

🌐 Import useState at the top of the file. Remove the value prop from the Square component. Instead, add a new line at the start of the Square that calls useState. Have it return a state variable called value:

import { useState } from 'react';

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

function handleClick() {
//...

value 存储值,setValue 是一个可以用来改变该值的函数。传递给 useStatenull 被用作此状态变量的初始值,因此这里的 value 一开始等于 null

由于 Square 组件不再接受 props,因此你需要从 Board 组件创建的九个 Square 组件中移除 value 属性:

🌐 Since the Square component no longer accepts props anymore, you’ll remove the value prop from all nine of the Square components created by the Board component:

// ...
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”。用 setValue('X'); 替换 console.log("clicked!"); 事件处理程序。现在你的 Square 组件看起来像这样:

🌐 Now you’ll change Square to display an “X” when clicked. Replace the console.log("clicked!"); event handler with setValue('X');. Now your Square component looks like this:

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”:

🌐 By calling this set function from an onClick handler, you’re telling React to re-render that Square whenever its <button> is clicked. After the update, the Square’s value will be 'X', so you’ll see the “X” on the game board. Click on any Square, and “X” should show up:

adding xes to board

每个方格都有自己的状态:存储在每个方格中的 value 完全独立于其他方格。当你在组件中调用 set 函数时,React 也会自动更新里面的子组件。

🌐 Each Square has its own state: the value stored in each Square is completely independent of the others. When you call a set function in a component, React automatically updates the child components inside too.

完成上述更改后,你的代码将如下所示:

🌐 After you’ve made the above changes, your code will look like this:

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 Developer Tools

React DevTools 让你可以检查 React 组件的 props 和 state。你可以在 CodeSandbox 的 浏览器 部分底部找到 React DevTools 标签:

🌐 React DevTools let you check the props and the state of your React components. You can find the React DevTools tab at the bottom of the browser section in CodeSandbox:

React DevTools in CodeSandbox

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

🌐 To inspect a particular component on the screen, use the button in the top left corner of React DevTools:

Selecting components on the page with React DevTools

注意

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

🌐 For local development, React DevTools is available as a Chrome, Firefox, and Edge browser extension. Install it, and the Components tab will appear in your browser Developer Tools for sites using React.

完成游戏

🌐 Completing the game

到目前为止,你已经拥有了井字棋游戏的所有基本构建模块。要完成游戏,你现在需要轮流在棋盘上放置“X”和“O”,并且你需要一种方法来判断胜者。

🌐 By this point, you have all the basic building blocks for your tic-tac-toe game. To have a complete game, you now need to alternate placing “X”s and “O”s on the board, and you need a way to determine a winner.

提升状态

🌐 Lifting state up

目前,每个 Square 组件维护游戏状态的一部分。要检查井字棋游戏的获胜者,Board 需要以某种方式知道每个 9 个 Square 组件的状态。

🌐 Currently, each Square component maintains a part of the game’s state. To check for a winner in a tic-tac-toe game, the Board would need to somehow know the state of each of the 9 Square components.

你会如何处理这个问题?一开始,你可能会猜测 Board 需要向每个 Square “询问” 那个 Square 的状态。虽然在技术上这种方法在 React 中是可行的,但我们不建议这样做,因为代码会变得难以理解,容易出现错误,并且难以重构。相反,最好的方法是将游戏的状态存储在父组件 Board 中,而不是存储在每个 Square 中。Board 组件可以通过传递一个 prop 来告诉每个 Square 显示什么,就像你向每个 Square 传递数字时所做的那样。

🌐 How would you approach that? At first, you might guess that the Board needs to “ask” each Square for that Square’s state. Although this approach is technically possible in React, we discourage it because the code becomes difficult to understand, susceptible to bugs, and hard to refactor. Instead, the best approach is to store the game’s state in the parent Board component instead of in each Square. The Board component can tell each Square what to display by passing a prop, like you did when you passed a number to each Square.

要从多个子组件收集数据,或者让两个子组件相互通信,请改为在它们的父组件中声明共享状态。父组件可以通过 props 将该状态传回给子组件。这可以保持子组件彼此之间以及与父组件的同步。

重构 React 组件时,将状态提升到父组件中很常见。

🌐 Lifting state into a parent component is common when React components are refactored.

让我们抓住这个机会来试一试。编辑 Board 组件,使其声明一个名为 squares 的状态变量,默认值为一个包含 9 个 null 的数组,对应 9 个方格:

🌐 Let’s take this opportunity to try it out. Edit the Board component so that it declares a state variable named squares that defaults to an array of 9 nulls corresponding to the 9 squares:

// ...
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
// ...
);
}

Array(9).fill(null) 创建一个包含九个元素的数组,并将每个元素设置为 null。围绕它的 useState() 调用声明了一个最初设置为该数组的 squares 状态变量。数组中的每个条目对应棋盘上的一个方格的值。当你稍后填充棋盘时,squares 数组将如下所示:

['O', null, 'X', 'X', 'X', 'O', 'O', null, null]

现在你的 Board 组件需要将 value 属性传递给它渲染的每个 Square

🌐 Now your Board component needs to pass the value prop down to each Square that it renders:

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 属性。这将需要移除 Square 组件自身对 value 的状态跟踪以及按钮的 onClick 属性:

🌐 Next, you’ll edit the Square component to receive the value prop from the Board component. This will require removing the Square component’s own stateful tracking of value and the button’s onClick prop:

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

此时你应该看到一个空的井字面板:

🌐 At this point you should see an empty tic-tac-toe board:

empty board

你的代码应该是这样的:

🌐 And your code should look like this:

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

每个方格现在将接收一个 value 属性,该属性对于空方格将是 'X''O'null

🌐 Each Square will now receive a value prop that will either be 'X', 'O', or null for empty squares.

接下来,你需要更改当点击 Square 时发生的事情。Board 组件现在维护哪些方格被填充。你需要创建一种方法让 Square 更新 Board 的状态。由于状态对定义它的组件是私有的,你不能直接从 Square 更新 Board 的状态。

🌐 Next, you need to change what happens when a Square is clicked. The Board component now maintains which squares are filled. You’ll need to create a way for the Square to update the Board’s state. Since state is private to a component that defines it, you cannot update the Board’s state directly from Square.

相反,你将从 Board 组件向 Square 组件传递一个函数,并且当点击一个方块时,你会让 Square 调用该函数。你将从 Square 组件在被点击时调用的函数开始。你将把该函数命名为 onSquareClick

🌐 Instead, you’ll pass down a function from the Board component to the Square component, and you’ll have Square call that function when a square is clicked. You’ll start with the function that the Square component will call when it is clicked. You’ll call that function onSquareClick:

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

接下来,你将把 onSquareClick 函数添加到 Square 组件的属性中:

🌐 Next, you’ll add the onSquareClick function to the Square component’s props:

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

现在你将把 onSquareClick 属性连接到 Board 组件中的一个函数,你将其命名为 handleClick。要将 onSquareClick 连接到 handleClick,你需要将一个函数传递给第一个 Square 组件的 onSquareClick 属性:

🌐 Now you’ll connect the onSquareClick prop to a function in the Board component that you’ll name handleClick. To connect onSquareClick to handleClick you’ll pass a function to the onSquareClick prop of the first Square component:

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 数组:

🌐 Lastly, you will define the handleClick function inside the Board component to update the squares array holding your board’s state:

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] 索引) 方格。

🌐 The handleClick function creates a copy of the squares array (nextSquares) with the JavaScript slice() Array method. Then, handleClick updates the nextSquares array to add X to the first ([0] index) square.

调用 setSquares 函数会让 React 知道组件的状态已更改。这将触发使用 squares 状态(Board)的组件以及其子组件(构成棋盘的 Square 组件)的重新渲染。

🌐 Calling the setSquares function lets React know the state of the component has changed. This will trigger a re-render of the components that use the squares state (Board) as well as its child components (the Square components that make up the board).

注意

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

🌐 JavaScript supports closures which means an inner function (e.g. handleClick) has access to variables and functions defined in an outer function (e.g. Board). The handleClick function can read the squares state and call the setSquares method because they are both defined inside of the Board function.

现在你可以在棋盘上添加 X……但只能添加到左上角的方格。你的 handleClick 函数被硬编码为更新左上角方格的索引(0)。让我们更新 handleClick,使其能够更新任何方格。向 handleClick 函数添加一个参数 i,该参数接收要更新的方格的索引:

🌐 Now you can add X’s to the board… but only to the upper left square. Your handleClick function is hardcoded to update the index for the upper left square (0). Let’s update handleClick to be able to update any square. Add an argument i to the handleClick function that takes the index of the square to update:

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 中将 square 的 onSquareClick 属性设置为 handleClick(0),像这样,但它不起作用:

🌐 Next, you will need to pass that i to handleClick. You could try to set the onSquareClick prop of square to be handleClick(0) directly in the JSX like this, but it won’t work:

<Square value={squares[0]} onSquareClick={handleClick(0)} />

这就是为什么这行不通的原因。handleClick(0) 调用将成为渲染棋盘组件的一部分。因为 handleClick(0) 通过调用 setSquares 改变了棋盘组件的状态,你的整个棋盘组件将再次重新渲染。但是这将再次运行 handleClick(0),导致无限循环:

🌐 Here is why this doesn’t work. The handleClick(0) call will be a part of rendering the board component. Because handleClick(0) alters the state of the board component by calling setSquares, your entire board component will be re-rendered again. But this runs handleClick(0) again, leading to an infinite loop:

Console

为什么这个问题没有早点发生?

🌐 Why didn’t this problem happen earlier?

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

🌐 When you were passing onSquareClick={handleClick}, you were passing the handleClick function down as a prop. You were not calling it! But now you are calling that function right away—notice the parentheses in handleClick(0)—and that’s why it runs too early. You don’t want to call handleClick until the user clicks!

你可以通过创建一个像 handleFirstSquareClick 的函数来调用 handleClick(0),一个像 handleSecondSquareClick 的函数来调用 handleClick(1),依此类推来解决这个问题。你会像 onSquareClick={handleFirstSquareClick} 那样将这些函数作为 props 传递(而不是调用)。这将解决无限循环的问题。

🌐 You could fix this by creating a function like handleFirstSquareClick that calls handleClick(0), a function like handleSecondSquareClick that calls handleClick(1), and so on. You would pass (rather than call) these functions down as props like onSquareClick={handleFirstSquareClick}. This would solve the infinite loop.

然而,定义九个不同的函数并给每个函数命名太繁琐了。相反,我们来这样做:

🌐 However, defining nine different functions and giving each of them a name is too verbose. Instead, let’s do this:

export default function Board() {
// ...
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
// ...
);
}

注意新的 () => 语法。在这里,() => handleClick(0) 是一个箭头函数,这是一种定义函数的更简短方式。当方块被点击时,=> “箭头” 后面的代码将运行,调用 handleClick(0)

🌐 Notice the new () => syntax. Here, () => handleClick(0) is an arrow function, which is a shorter way to define functions. When the square is clicked, the code after the => “arrow” will run, calling handleClick(0).

现在你需要更新其他八个方块,以便从你传递的箭头函数中调用 handleClick。确保每次调用 handleClick 的参数对应正确方块的索引:

🌐 Now you need to update the other eight squares to call handleClick from the arrow functions you pass. Make sure that the argument for each call of the handleClick corresponds to the index of the correct square:

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 添加到面板上的任何方块:

🌐 Now you can again add X’s to any square on the board by clicking on them:

filling the board with X

但这一次,所有的状态管理都是由 Board 组件处理的!

🌐 But this time all the state management is handled by the Board component!

这是你的代码应该是这样的:

🌐 This is what your code should look like:

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 组件中,将来可以让它确定获胜者。

🌐 Now that your state handling is in the Board component, the parent Board component passes props to the child Square components so that they can be displayed correctly. When clicking on a Square, the child Square component now asks the parent Board component to update the state of the board. When the Board’s state changes, both the Board component and every child Square re-renders automatically. Keeping the state of all squares in the Board component will allow it to determine the winner in the future.

让我们回顾一下当用户点击你棋盘左上角的方格以添加一个 X 时会发生什么:

🌐 Let’s recap what happens when a user clicks the top left square on your board to add an X to it:

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

最后,用户会看到左上方的方格在点击后从空白变成了有一个 X

🌐 In the end the user sees that the upper left square has changed from empty to having an X after clicking it.

注意

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

🌐 The DOM <button> element’s onClick attribute has a special meaning to React because it is a built-in component. For custom components like Square, the naming is up to you. You could give any name to the Square’s onSquareClick prop or Board’s handleClick function, and the code would work the same. In React, it’s conventional to use onSomething names for props which represent events and handleSomething for the function definitions which handle those events.

为什么不可变性很重要

🌐 Why immutability is important

注意在 handleClick 中,你调用 .slice() 来创建 squares 数组的副本,而不是修改现有数组。为了说明原因,我们需要讨论不可变性以及为什么学习不可变性很重要。

🌐 Note how in handleClick, you call .slice() to create a copy of the squares array instead of modifying the existing array. To explain why, we need to discuss immutability and why immutability is important to learn.

通常有两种更改数据的方法。第一种方法是通过直接更改数据的值来_改变_数据。第二种方法是用一个具有所需更改的新副本替换数据。如果你对 squares 数组进行了更改,它将看起来像这样:

🌐 There are generally two approaches to changing data. The first approach is to mutate the data by directly changing the data’s values. The second approach is to replace the data with a new copy which has the desired changes. Here is what it would look like if you mutated the squares array:

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 数组的情况下修改数据,它看起来会是这样的:

🌐 And here is what it would look like if you changed data without mutating the squares array:

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`

结果是一样的,但通过不直接改变(改变底层数据),你可以获得几个好处。

🌐 The result is the same but by not mutating (changing the underlying data) directly, you gain several benefits.

不可变性使得实现复杂功能变得容易得多。在本教程的后面,你将实现一个“时间旅行”功能,它让你可以查看游戏的历史记录并“回到”过去的步骤。这个功能并不仅限于游戏——能够撤销和重做某些操作是应用的常见需求。避免直接修改数据可以让你保持数据的以前版本完整,并在以后重用它们。

🌐 Immutability makes complex features much easier to implement. Later in this tutorial, you will implement a “time travel” feature that lets you review the game’s history and “jump back” to past moves. This functionality isn’t specific to games—an ability to undo and redo certain actions is a common requirement for apps. Avoiding direct data mutation lets you keep previous versions of the data intact, and reuse them later.

不可变性还有另一个好处。默认情况下,当父组件的状态发生变化时,所有子组件都会自动重新渲染。这甚至包括那些未受到变化影响的子组件。虽然重新渲染本身对用户来说并不明显(你不应该主动尝试去避免它!),但出于性能原因,你可能希望跳过显然未受影响的组件树部分的重新渲染。不可变性使得组件比较其数据是否发生变化的成本非常低。你可以在 memo API参考中了解更多关于 React 选择何时重新渲染组件的信息。

🌐 There is also another benefit of immutability. By default, all child components re-render automatically when the state of a parent component changes. This includes even the child components that weren’t affected by the change. Although re-rendering is not by itself noticeable to the user (you shouldn’t actively try to avoid it!), you might want to skip re-rendering a part of the tree that clearly wasn’t affected by it for performance reasons. Immutability makes it very cheap for components to compare whether their data has changed or not. You can learn more about how React chooses when to re-render a component in the memo API reference.

轮流

🌐 Taking turns

现在是修复这个井字游戏中的一个重大缺陷的时候了:不能在棋盘上标记“O”。

🌐 It’s now time to fix a major defect in this tic-tac-toe game: the “O”s cannot be marked on the board.

你将默认将第一次移动设置为“X”。让我们通过向 Board 组件添加另一个状态来跟踪这一点:

🌐 You’ll set the first move to be “X” by default. Let’s keep track of this by adding another piece of state to the Board component:

function Board() {
const [xIsNext, setXIsNext] = useState(true);
const [squares, setSquares] = useState(Array(9).fill(null));

// ...
}

每次玩家移动时,xIsNext(一个布尔值)将被翻转以确定下一位玩家,同时游戏状态将被保存。你需要更新 BoardhandleClick 函数以翻转 xIsNext 的值:

🌐 Each time a player moves, xIsNext (a boolean) will be flipped to determine which player goes next and the game’s state will be saved. You’ll update the Board’s handleClick function to flip the value of xIsNext:

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 之间交替!

🌐 Now, as you click on different squares, they will alternate between X and O, as they should!

但是等等,有一个问题。试着多次点击同一个方格:

🌐 But wait, there’s a problem. Try clicking on the same square multiple times:

O overwriting an X

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

🌐 The X is overwritten by an O! While this would add a very interesting twist to the game, we’re going to stick to the original rules for now.

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

🌐 When you mark a square with an X or an O you aren’t first checking to see if the square already has an X or O value. You can fix this by returning early. You’ll check to see if the square already has an X or an O. If the square is already filled, you will return in the handleClick function early—before it tries to update the board state.

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

现在你只能在空格中添加 XO!到目前为止,你的代码应该是这样的:

🌐 Now you can only add X’s or O’s to empty squares! Here is what your code should look like at this point:

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

宣布获胜者

🌐 Declaring a winner

既然玩家可以轮流,你会希望在游戏获胜且没有更多回合可进行时显示结果。为此,你将添加一个名为 calculateWinner 的辅助函数,该函数接收一个包含 9 个方格的数组,检查是否有获胜者,并根据情况返回 'X''O'null。不要太担心 calculateWinner 函数;它与 React 无关:

🌐 Now that the players can take turns, you’ll want to show when the game is won and there are no more turns to make. To do this you’ll add a helper function called calculateWinner that takes an array of 9 squares, checks for a winner and returns 'X', 'O', or null as appropriate. Don’t worry too much about the calculateWinner function; it’s not specific to 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 都没关系。我们把它放在最后,这样你在编辑组件时就不必每次都滚动过去。

🌐 It does not matter whether you define calculateWinner before or after the Board. Let’s put it at the end so that you don’t have to scroll past it every time you edit your components.

你将在 Board 组件的 handleClick 函数中调用 calculateWinner(squares) 来检查玩家是否获胜。你可以在检查用户是否点击了已经有 XO 的方格的同时进行此检查。我们希望在这两种情况下都能提前返回:

🌐 You will call calculateWinner(squares) in the Board component’s handleClick function to check if a player has won. You can perform this check at the same time you check if a user has clicked a square that already has an X or an O. We’d like to return early in both cases:

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

为了让玩家知道游戏何时结束,你可以显示诸如“获胜者:X”或“获胜者:O”的文本。为此,你需要在Board组件中添加一个status部分。如果游戏结束,状态将显示获胜者;如果游戏正在进行,你将显示下一位玩家的回合:

🌐 To let the players know when the game is over, you can display text such as “Winner: X” or “Winner: O”. To do that you’ll add a status section to the Board component. The status will display the winner if the game is over and if the game is ongoing you’ll display which player’s turn is next:

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 的基础知识。所以真正的赢家是——你。以下是代码应有的样子:

🌐 Congratulations! You now have a working tic-tac-toe game. And you’ve just learned the basics of React too. So you are the real winner here. Here is what the code should look like:

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

添加时光旅行

🌐 Adding time travel

作为最后的练习,让我们实现能够“回到过去”,回到游戏中的前几步。

🌐 As a final exercise, let’s make it possible to “go back in time” to the previous moves in the game.

存储移动历史

🌐 Storing a history of moves

如果你修改了 squares 数组,实现时间旅行将会非常困难。

🌐 If you mutated the squares array, implementing time travel would be very difficult.

然而,你在每次移动后都使用 slice() 来创建 squares 数组的新副本,并将其视为不可变的。这将允许你存储 squares 数组的每个过去版本,并在已经发生的回合之间进行切换。

🌐 However, you used slice() to create a new copy of the squares array after every move, and treated it as immutable. This will allow you to store every past version of the squares array, and navigate between the turns that have already happened.

你将把过去的 squares 个数组存储在另一个名为 history 的数组中,你会将其存储为一个新的状态变量。history 数组表示从第一步到最后一步的所有棋盘状态,其形状如下:

🌐 You’ll store the past squares arrays in another array called history, which you’ll store as a new state variable. The history array represents all board states, from the first to the last move, and has a shape like this:

[
// 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'],
// ...
]

再次提升状态

🌐 Lifting state up, again

你现在将编写一个名为 Game 的新顶层组件来显示过去的移动列表。你将在那里放置包含整个游戏历史的 history 状态。

🌐 You will now write a new top-level component called Game to display a list of past moves. That’s where you will place the history state that contains the entire game history.

history 状态放入 Game 组件中,将允许你从其子组件 squares 中移除 Board 状态。就像你之前将状态从 Square 组件“提升”到 Board 组件一样,你现在将从 Board 提升到顶层 Game 组件。这使得 Game 组件可以完全控制 Board 的数据,并让它指示 Board 渲染来自 history 的之前的回合。

🌐 Placing the history state into the Game component will let you remove the squares state from its child Board component. Just like you “lifted state up” from the Square component into the Board component, you will now lift it up from the Board into the top-level Game component. This gives the Game component full control over the Board’s data and lets it instruct the Board to render previous turns from the history.

首先,添加一个带有 export defaultGame 组件。让它渲染 Board 组件和一些标记:

🌐 First, add a Game component with export default. Have it render the Board component and some markup:

function Board() {
// ...
}

export default function Game() {
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div className="game-info">
<ol></ol>
</div>
</div>
);
}

请注意,你正在在 function Board() { 声明之前移除 export default 关键字,并在 function Game() { 声明之前添加它们。这告诉你的 index.js 文件使用 Game 组件作为顶层组件,而不是你的 Board 组件。Game 组件返回的额外 div 为你稍后将添加到棋盘上的游戏信息腾出了空间。

🌐 Note that you are removing the export default keywords before the function Board() { declaration and adding them before the function Game() { declaration. This tells your index.js file to use the Game component as the top-level component instead of your Board component. The additional divs returned by the Game component are making room for the game information you’ll add to the board later.

Game 组件添加一些状态,以跟踪下一位玩家是谁以及棋步历史:

🌐 Add some state to the Game component to track which player is next and the history of moves:

export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
// ...

注意 [Array(9).fill(null)] 是一个只有一个元素的数组,而这个元素本身是一个包含 9 个 null 的数组。

🌐 Notice how [Array(9).fill(null)] is an array with a single item, which itself is an array of 9 nulls.

要渲染当前移动的方格,你需要从 history 中读取最后的方格数组。你不需要 useState——在渲染时你已经有足够的信息来计算它:

🌐 To render the squares for the current move, you’ll want to read the last squares array from the history. You don’t need useState for this—you already have enough information to calculate it during rendering:

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 组件:

🌐 Next, create a handlePlay function inside the Game component that will be called by the Board component to update the game. Pass xIsNext, currentSquares and handlePlay as props to the Board component:

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 可以使用更新后的方格数组调用该函数。接下来,删除 Board 函数中调用 useState 的前两行代码:

🌐 Let’s make the Board component fully controlled by the props it receives. Change the Board component to take three props: xIsNext, squares, and a new onPlay function that Board can call with the updated squares array when a player makes a move. Next, remove the first two lines of the Board function that call useState:

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

现在将 Board 组件中 handleClick 中的 setSquaressetXIsNext 调用替换为对你新的 onPlay 函数的一次调用,以便 Game 组件在用户点击方块时可以更新 Board

🌐 Now replace the setSquares and setXIsNext calls in handleClick in the Board component with a single call to your new onPlay function so the Game component can update the Board when the user clicks a square:

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 函数才能使游戏重新正常运行。

🌐 The Board component is fully controlled by the props passed to it by the Game component. You need to implement the handlePlay function in the Game component to get the game working again.

handlePlay 被调用时应该做什么?记住,Board 以前是用更新后的数组调用 setSquares;现在它将更新后的 squares 数组传递给 onPlay

🌐 What should handlePlay do when called? Remember that Board used to call setSquares with an updated array; now it passes the updated squares array to onPlay.

handlePlay 函数需要更新 Game 的状态以触发重新渲染,但你现在没有可以调用的 setSquares 函数——你现在使用 history 状态变量来存储这些信息。你需要通过将更新后的 squares 数组作为新的历史条目附加来更新 history。你还想切换 xIsNext,就像 Board 以前做的一样:

🌐 The handlePlay function needs to update Game’s state to trigger a re-render, but you don’t have a setSquares function that you can call any more—you’re now using the history state variable to store this information. You’ll want to update history by appending the updated squares array as a new history entry. You also want to toggle xIsNext, just as Board used to do:

export default function Game() {
//...
function handlePlay(nextSquares) {
setHistory([...history, nextSquares]);
setXIsNext(!xIsNext);
}
//...
}

这里,[...history, nextSquares] 创建一个新的数组,包含 history 中的所有项目,然后是 nextSquares。(你可以将 ...history 展开语法 读取为“列出 history 中的所有项目”。)

🌐 Here, [...history, nextSquares] creates a new array that contains all the items in history, followed by nextSquares. (You can read the ...history spread syntax as “enumerate all the items in history”.)

例如,如果 history[[null,null,null], ["X",null,null]] 并且 nextSquares["X",null,"O"],那么新的 [...history, nextSquares] 数组将是 [[null,null,null], ["X",null,null], ["X",null,"O"]]

🌐 For example, if history is [[null,null,null], ["X",null,null]] and nextSquares is ["X",null,"O"], then the new [...history, nextSquares] array will be [[null,null,null], ["X",null,null], ["X",null,"O"]].

此时,你已经将状态移动到 Game 组件中,UI 应该完全正常工作,就像重构之前一样。此时代码应该如下所示:

🌐 At this point, you’ve moved the state to live in the Game component, and the UI should be fully working, just as it was before the refactor. Here is what the code should look like at this point:

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

显示过去的动作

🌐 Showing the past moves

由于你正在记录井字游戏的历史,因此你现在可以向玩家显示过去的动作列表。

🌐 Since you are recording the tic-tac-toe game’s history, you can now display a list of past moves to the player.

<button> 这样的 React 元素是普通的 JavaScript 对象;你可以在应用中随意传递它们。要在 React 中渲染多个项目,你可以使用一个包含 React 元素的数组。

🌐 React elements like <button> are regular JavaScript objects; you can pass them around in your application. To render multiple items in React, you can use an array of React elements.

你已经在状态中有一个 history 移动的数组,所以现在你需要将它转换为一个 React 元素的数组。在 JavaScript 中,要将一个数组转换为另一个数组,你可以使用 数组 map 方法:

🌐 You already have an array of history moves in state, so now you need to transform it to an array of React elements. In JavaScript, to transform one array into another, you can use the array map method:

[1, 2, 3].map((x) => x * 2) // [2, 4, 6]

你将使用 map 将你的 history 操作转换为在屏幕上表示按钮的 React 元素,并显示一个按钮列表以“跳转”到过去的操作。让我们在 Game 组件中对 history 使用 map

🌐 You’ll use map to transform your history of moves into React elements representing buttons on the screen, and display a list of buttons to “jump” to past moves. Let’s map over the history in the Game component:

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

你可以在下面看到你的代码应该是什么样子。请注意,你应该会在开发者工具的控制台中看到一条错误信息,内容为:

🌐 You can see what your code should look like below. Note that you should see an error in the developer tools console that says:

Console
警告:数组或迭代器中的每个子元素都应该有一个唯一的“key”属性。检查 Game 的渲染方法。

你将在下一节中修复此错误。

🌐 You’ll fix this error in the next section.

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,……(在大多数情况下,你需要实际的数组元素,但要渲染移动列表,你只需要索引。)

🌐 As you iterate through the history array inside the function you passed to map, the squares argument goes through each element of history, and the move argument goes through each array index: 0, 1, 2, …. (In most cases, you’d need the actual array elements, but to render a list of moves you will only need indexes.)

在井字棋游戏的历史中的每一次移动中,你创建一个列表项 <li>,其中包含一个按钮 <button>。该按钮有一个 onClick 处理器,它调用一个名为 jumpTo 的函数(你还没有实现这个函数)。

🌐 For each move in the tic-tac-toe game’s history, you create a list item <li> which contains a button <button>. The button has an onClick handler which calls a function called jumpTo (that you haven’t implemented yet).

目前,你应该能看到游戏中发生的动作列表以及开发者工具控制台中的一个错误。让我们讨论一下“key”错误的含义。

🌐 For now, you should see a list of the moves that occurred in the game and an error in the developer tools console. Let’s discuss what the “key” error means.

选择一个键

🌐 Picking a key

当你渲染一个列表时,React 会存储关于每个已渲染列表项的一些信息。当你更新列表时,React 需要确定发生了什么变化。你可能已经添加、删除、重新排列或更新了列表的项目。

🌐 When you render a list, React stores some information about each rendered list item. When you update a list, React needs to determine what has changed. You could have added, removed, re-arranged, or updated the list’s items.

想象一下从

🌐 Imagine transitioning from

<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>

to

<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>

除了更新后的计数之外,一个人阅读这个可能会说你交换了 Alexa 和 Ben 的顺序,并将 Claudia 插入到 Alexa 和 Ben 之间。然而,React 是一个计算机程序,并不知道你的意图,所以你需要为每个列表项指定一个 key 属性,以将每个列表项与其同级项区分开来。如果你的数据来自数据库,可以使用 Alexa、Ben 和 Claudia 的数据库 ID 作为 key。

🌐 In addition to the updated counts, a human reading this would probably say that you swapped Alexa and Ben’s ordering and inserted Claudia between Alexa and Ben. However, React is a computer program and does not know what you intended, so you need to specify a key property for each list item to differentiate each list item from its siblings. If your data was from a database, Alexa, Ben, and Claudia’s database IDs could be used as keys.

<li key={user.id}>
{user.name}: {user.taskCount} tasks left
</li>

当列表重新渲染时,React 会获取每个列表项的 key,并在前一个列表的项中搜索匹配的 key。如果当前列表有一个之前不存在的 key,React 会创建一个组件。如果当前列表缺少之前存在的 key,React 会销毁之前的组件。如果两个 key 匹配,相应的组件会被移动。

🌐 When a list is re-rendered, React takes each list item’s key and searches the previous list’s items for a matching key. If the current list has a key that didn’t exist before, React creates a component. If the current list is missing a key that existed in the previous list, React destroys the previous component. If two keys match, the corresponding component is moved.

键告诉 React 每个组件的身份,这允许 React 在重新渲染之间保持状态。如果组件的键发生变化,组件将被销毁并使用新的状态重新创建。

🌐 Keys tell React about the identity of each component, which allows React to maintain state between re-renders. If a component’s key changes, the component will be destroyed and re-created with a new state.

key 是 React 中的一个特殊保留属性。当创建一个元素时,React 会提取 key 属性并将 key 直接存储在返回的元素上。即使 key 看起来像是通过 props 传递的,React 也会自动使用 key 来决定更新哪些组件。组件无法询问父组件指定了什么 key

强烈建议在构建动态列表时分配适当的键。 如果你没有合适的键,你可能需要考虑重构你的数据以便拥有合适的键。

如果未指定 key,React 会报告错误,并默认使用数组索引作为 key。当尝试重新排序列表项或插入/删除列表项时,使用数组索引作为 key 会有问题。显式传递 key={i} 可以消除错误,但它和数组索引存在相同的问题,在大多数情况下并不推荐使用。

🌐 If no key is specified, React will report an error and use the array index as a key by default. Using the array index as a key is problematic when trying to re-order a list’s items or inserting/removing list items. Explicitly passing key={i} silences the error but has the same problems as array indices and is not recommended in most cases.

键不需要在全局范围内唯一;它们只需要在组件及其同级之间唯一。

🌐 Keys do not need to be globally unique; they only need to be unique between components and their siblings.

实现时光旅行

🌐 Implementing time travel

在井字棋游戏的历史中,每一个过去的移动都有一个唯一的ID与之关联:它就是移动的顺序编号。移动永远不会被重新排列、删除或插入到中间,所以使用移动索引作为键是安全的。

🌐 In the tic-tac-toe game’s history, each past move has a unique ID associated with it: it’s the sequential number of the move. Moves will never be re-ordered, deleted, or inserted in the middle, so it’s safe to use the move index as a key.

Game 函数中,你可以将 key 添加为 <li key={move}>,如果你重新加载渲染的游戏,React 的 “key” 错误应该会消失:

🌐 In the Game function, you can add the key as <li key={move}>, and if you reload the rendered game, React’s “key” error should disappear:

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

🌐 Before you can implement jumpTo, you need the Game component to keep track of which step the user is currently viewing. To do this, define a new state variable called currentMove, defaulting to 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

🌐 Next, update the jumpTo function inside Game to update that currentMove. You’ll also set xIsNext to true if the number that you’re changing currentMove to is even.

export default function Game() {
// ...
function jumpTo(nextMove) {
setCurrentMove(nextMove);
setXIsNext(nextMove % 2 === 0);
}
//...
}

你现在将对 GamehandlePlay 函数进行两处修改,该函数在你点击一个方块时被调用。

🌐 You will now make two changes to the Game’s handlePlay function which is called when you click on a square.

  • 如果你“回到过去”,然后从那个点做一个新的操作,你只想保留到那个点为止的历史。你不会在 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 组件以渲染当前选中的动作,而不是总是渲染最终动作:

🌐 Finally, you will modify the Game component to render the currently selected move, instead of always rendering the final move:

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

// ...
}

如果你单击游戏历史记录中的任何一步,井字面板应立即更新以显示面板在该步骤发生后的样子。

🌐 If you click on any step in the game’s history, the tic-tac-toe board should immediately update to show what the board looked like after that step occurred.

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

最后清理

🌐 Final cleanup

如果你仔细查看代码,你可能会注意到当 currentMove 为偶数时是 xIsNext === true,当 currentMove 为奇数时是 xIsNext === false。换句话说,如果你知道 currentMove 的值,那么你总是可以弄清楚 xIsNext 应该是什么。

🌐 If you look at the code very closely, you may notice that xIsNext === true when currentMove is even and xIsNext === false when currentMove is odd. In other words, if you know the value of currentMove, then you can always figure out what xIsNext should be.

没有理由在状态中存储这两个。实际上,应始终尽量避免冗余状态。简化状态中存储的内容可以减少错误,并使代码更易于理解。修改 Game,使其不再将 xIsNext 作为单独的状态变量存储,而是根据 currentMove 来确定它:

🌐 There’s no reason for you to store both of these in state. In fact, always try to avoid redundant state. Simplifying what you store in state reduces bugs and makes your code easier to understand. Change Game so that it doesn’t store xIsNext as a separate state variable and instead figures it out based on the 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 的调用了。现在,即使在编写组件时出现错误,也不会有 xIsNextcurrentMove 不同步的情况。

🌐 You no longer need the xIsNext state declaration or the calls to setXIsNext. Now, there’s no chance for xIsNext to get out of sync with currentMove, even if you make a mistake while coding the components.

封装起来

🌐 Wrapping up

恭喜!你已经创建了一个井字棋游戏,它可以:

🌐 Congratulations! You’ve created a tic-tac-toe game that:

  • 让你玩井字游戏,
  • 指示玩家何时赢得游戏,
  • 随着游戏的进行存储游戏的历史,
  • 允许玩家回顾游戏的历史并查看游戏面板的以前版本。

干得好!我们希望你现在觉得自己对 React 的工作原理有了一个不错的理解。

🌐 Nice work! We hope you now feel like you have a decent grasp of how React works.

在这里查看最终结果:

🌐 Check out the final result here:

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 技能,这里有一些你可以改进井字游戏的想法,按难度递增的顺序列出:

🌐 If you have extra time or want to practice your new React skills, here are some ideas for improvements that you could make to the tic-tac-toe game, listed in order of increasing difficulty:

  1. 仅针对当前步骤,显示“你在第 # 步…”而不是按钮。
  2. Board 重写为使用两个循环来生成平方,而不是硬编码它们。
  3. 添加一个切换按钮,让你可以按升序或降序对移动进行排序。
  4. 当有人获胜时,高亮导致获胜的三个方块(当没有人获胜时,显示一条关于结果为平局的消息)。
  5. 在移动历史列表中以 (row, col) 格式显示每个移动的位置。

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

🌐 Throughout this tutorial, you’ve touched on React concepts including elements, components, props, and state. Now that you’ve seen how these concepts work when building a game, check out Thinking in React to see how the same React concepts work when building an app’s UI.