教程:井字游戏

在本教程中,你将构建一个小型井字游戏。本教程不假设任何现有的 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:

  • 教程的设置 将为你提供学习本教程的起点。

    ¥Setup for the tutorial will give you a starting point to follow the tutorial.

  • 概述 将教你 React 的基础知识:组件、属性和状态。

    ¥Overview will teach you the fundamentals of React: components, props, and state.

  • 完成游戏 将教你 React 开发中最常用的技术。

    ¥Completing the game will teach you the most common techniques in React development.

  • 添加时光旅行 将让你更深入地了解 React 的独特优势。

    ¥Adding time travel will give you a deeper insight into the unique strengths of React.

你在构建什么?

¥What are you building?

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

¥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

    ¥Install Node.js

  2. 在你之前打开的 CodeSandbox 选项卡中,按左上角按钮打开菜单,然后在该菜单中选择“下载沙盒”以将文件的存档下载到本地

    ¥In the CodeSandbox tab you opened earlier, press the top-left corner button to open the menu, and then choose Download Sandbox in that menu to download an archive of the files locally

  3. 解压存档,然后打开一个终端和 cd 到你解压的目录

    ¥Unzip the archive, then open a terminal and cd to the directory you unzipped

  4. 使用 npm install 安装依赖

    ¥Install the dependencies with npm install

  5. 运行 npm start 启动本地服务器,按照提示在浏览器中查看运行代码

    ¥Run npm start to start a local server and follow the prompts to view the code running in a browser

如果你被卡住了,不要让它阻止你!请改为在线进行操作,稍后再尝试本地设置。

¥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. 文件部分包含文件列表,例如 App.jsindex.jsstyles.css 和一个名为 public 的文件夹

    ¥The Files section with a list of files like App.js, index.js, styles.css and a folder called public

  2. 代码编辑器,你将在其中看到所选文件的源代码

    ¥The code editor where you’ll see the source code of your selected file

  3. 浏览器部分,你将在其中看到你编写的代码将如何显示

    ¥The browser section where you’ll see how the code you’ve written will be displayed

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

¥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 a 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" 是一个按钮属性或属性,它告诉 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 的文件部分中标记为 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 的文件部分中标记为 index.js 的文件。你不会在本教程中编辑此文件,但它是你在 App.js 文件中创建的组件与 Web 浏览器之间的桥梁。

¥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)

    ¥React’s library to talk to web browsers (React DOM)

  • 组件的样式

    ¥the styles for your components

  • 你在 App.js 中创建的组件。

    ¥the component you created in 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

React 组件需要返回单个 JSX 元素,而不是像两个按钮那样的多个相邻的 JSX 元素。要解决此问题,你可以使用片段(<></>)来封装多个相邻的 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.

首先,你要将定义第一个方块 (<button className="square">1</button>) 的行从 Board 组件复制到新的 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”。要解决此问题,你将使用属性将每个方块应具有的值从父组件 (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 的属性。

¥function Square({ value }) indicates the Square component can be passed a prop called 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 元素的属性中:

¥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 的浏览器部分底部的控制台选项卡中看到一条日志,上面写着 "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 属性。而是,在调用 useStateSquare 的开头添加一个新行。让它返回一个名为 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

¥value stores the value and setValue is a function that can be used to change the value. The null passed to useState is used as the initial value for this state variable, so value here starts off equal to null.

由于 Square 组件不再接受属性,你将从 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”。将 console.log("clicked!"); 事件处理程序替换为 setValue('X');。现在你的 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”。单击任何一个 Square,“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

每个 Square 都有自己的状态:每个 Square 中存储的 value 完全独立于其他 Square。当你在组件中调用 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 组件的属性和状态。你可以在 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 可用作 Chrome火狐Edge 浏览器扩展。安装它,组件选项卡将出现在你的浏览器开发者工具中,用于使用 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 组件可以通过传递一个属性来告诉每个 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.

要从多个子组件收集数据,或者让两个子组件相互通信,请在其父组件中声明共享状态。父组件可以通过属性将该状态传递回子组件。这使得子组件之间以及与其父组件保持同步。

¥To collect data from multiple children, or to have two child components communicate with each other, declare the shared state in their parent component instead. The parent component can pass that state back down to the children via props. This keeps the child components in sync with each other and with their parent.

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

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

让我们借此机会尝试一下。编辑 Board 组件,使其声明一个名为 squares 的状态变量,该变量默认为对应于 9 个方块的 9 个空值数组:

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

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

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

¥Array(9).fill(null) creates an array with nine elements and sets each of them to null. The useState() call around it declares a squares state variable that’s initially set to that array. Each entry in the array corresponds to the value of a square. When you fill the board in later, the squares array will look like this:

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

每个 Square 现在都会收到一个 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 a 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 以便能够更新任何方块。将参数 i 添加到 handleClick 函数,该函数采用要更新的方块索引:

¥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 函数作为属性向下传递。你不是在调用它!但是现在你立即调用该函数 - 注意 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!

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

¥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 组件将属性传递给子 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. 单击左上角的方块运行 buttonSquare 作为其 onClick 属性接收的函数。Square 组件从 Board 接收该函数作为其 onSquareClick 属性。Board 组件直接在 JSX 中定义了该函数。它以参数 0 调用 handleClick

    ¥Clicking on the upper left square runs the function that the button received as its onClick prop from the Square. The Square component received that function as its onSquareClick prop from the Board. The Board component defined that function directly in the JSX. It calls handleClick with an argument of 0.

  2. handleClick 使用参数 (0) 将 squares 数组的第一个元素从 null 更新为 X

    ¥handleClick uses the argument (0) to update the first element of the squares array from null to X.

  3. Board 组件的 squares 状态已更新,因此 Board 及其所有子级都将重新渲染。这会导致索引为 0Square 组件的 value 属性从 null 更改为 X

    ¥The squares state of the Board component was updated, so the Board and all of its children re-render. This causes the value prop of the Square component with index 0 to change from null to X.

最后,用户看到左上角的方块在点击后从空变为有一个 X

¥In the end the user sees that the upper left square has changed from empty to having a 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 a X or an O you aren’t first checking to see if the square already has a X or O value. You can fix this by returning early. You’ll check to see if the square already has a 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 a X or and O. We’d like to return early in both cases:

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

为了让玩家知道游戏何时结束,你可以显示诸如“获胜者:X”or” 获胜者:O”.为此,你需要将 status 部分添加到 Board 组件。如果游戏结束,状态将显示获胜者,如果游戏正在进行,你将显示下一个轮到哪个玩家:

¥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 组件将使你可以从其子 Board 组件中删除 squares 状态。就像你从 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>{/*TODO*/}</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 作为属性传递给 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 组件完全由它接收到的属性控制。更改 Board 组件以获取三个属性:xIsNextsquares 和一个新的 onPlay 函数,Board 可以在玩家移动时使用更新的方块数组调用这些函数。接下来,删除调用 useStateBoard 函数的前两行:

¥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 组件传递给它的属性控制。你需要在 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>{/*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;
}

显示过去的动作

¥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 元素,并显示按钮列表以 “跳转” 到过去的动作。让我们在游戏组件中对 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
Warning: Each child in an array or iterator should have a unique “key” prop. Check the render method of `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 参数遍历每个数组索引:0, 1, 2, ….(在大多数情况下,你需要实际的数组元素,但要渲染移动列表,你只需要索引。)

¥As you iterate through 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.)

对于井字游戏历史中的每一步,你创建一个包含按钮 <button> 的列表项 <li>。该按钮有一个 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).

现在,你应该会看到游戏中发生的动作列表和开发者工具控制台中的错误。让我们讨论一下 “键” 错误的含义。

¥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 的顺序,并在 Alexa 和 Ben 之间插入了 Claudia。然而,React 是一个计算机程序,不知道你的意图,因此你需要为每个列表项指定一个键属性,以区分每个列表项与其同级列表项。如果你的数据来自数据库,Alexa、Ben 和 Claudia 的数据库 ID 可以用作键。

¥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 获取每个列表项的键并搜索前一个列表的项以查找匹配的键。如果当前列表有一个之前不存在的键,React 会创建一个组件。如果当前列表缺少前一个列表中存在的键,React 会销毁前一个组件。如果两个键匹配,则移动相应的组件。

¥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 看起来像是作为属性传递的,但 React 会自动使用 key 来决定要更新哪些组件。组件无法询问其父级指定的 key

¥key is a special and reserved property in React. When an element is created, React extracts the key property and stores the key directly on the returned element. Even though key may look like it is passed as props, React automatically uses key to decide which components to update. There’s no way for a component to ask what key its parent specified.

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

¥It’s strongly recommended that you assign proper keys whenever you build dynamic lists. If you don’t have an appropriate key, you may want to consider restructuring your data so that you do.

如果没有指定键,React 会报错,默认使用数组索引作为键。在尝试重新排序列表项或插入/删除列表项时,使用数组索引作为键是有问题的。显式传递 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 函数中,你可以将键添加为 <li key={move}>,如果你重新加载渲染的游戏,React 的 “键” 错误应该会消失:

¥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) 中的所有条目之后添加它,这样你就只保留旧历史的那部分。

    ¥If you “go back in time” and then make a new move from that point, you only want to keep the history up to that point. Instead of adding nextSquares after all items (... spread syntax) in history, you’ll add it after all items in history.slice(0, currentMove + 1) so that you’re only keeping that portion of the old history.

  • 每次移动时,你都需要更新 currentMove 以指向最新的历史条目。

    ¥Each time a move is made, you need to update currentMove to point to the latest history entry.

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 的调用。现在,xIsNext 没有机会与 currentMove 不同步,即使你在编写组件代码时出错也是如此。

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

封装起来

¥Wrapping up

恭喜!你已经创建了一个井字游戏:

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

  • 让你玩井字游戏,

    ¥Lets you play tic-tac-toe,

  • 指示玩家何时赢得游戏,

    ¥Indicates when a player has won the game,

  • 随着游戏的进行存储游戏的历史,

    ¥Stores a game’s history as a game progresses,

  • 允许玩家回顾游戏的历史并查看游戏面板的以前版本。

    ¥Allows players to review a game’s history and see previous versions of a game’s board.

干得好!我们希望你现在觉得你对 React 的工作原理有了很好的了解。

¥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. 仅对于当前移动,显示 “你在移动 #…” 而不是按钮。

    ¥For the current move only, show “You are at move #…” instead of a button.

  2. 重写 Board 以使用两个循环来制作方块而不是对它们进行硬编码。

    ¥Rewrite Board to use two loops to make the squares instead of hardcoding them.

  3. 添加一个切换按钮,让你可以按升序或降序对移动进行排序。

    ¥Add a toggle button that lets you sort the moves in either ascending or descending order.

  4. 当有人获胜时,高亮导致获胜的三个方块(当没有人获胜时,显示一条关于结果为平局的消息)。

    ¥When someone wins, highlight the three squares that caused the win (and when no one wins, display a message about the result being a draw).

  5. 在移动历史列表中以 (row, col) 格式显示每个移动的位置。

    ¥Display the location for each move in the format (row, col) in the move history list.

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

¥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.


React 中文网 - 粤ICP备13048890号