入门教程:简介 React

本教程适用于 React 初学者。

在我们开始之前

我们将在本教程中构建一个小游戏。你可能想跳过本教程,因为你没有建立游戏 - 但给它一个机会。 您将在本教程中学习的技术是构建任何 React 应用程序的基础,  掌握本教程将使您对 React 有深入的了解。

提示:

本教程专为喜欢边学边做的人而设计。 如果您更喜欢从头开始学习概念,请查看我们的 一步一步学习指南。 您可能会发现本教程和指南是相互补充。

本教程分为几个部分:

  • 教程设置 将为您提供 一个教程的起点
  • 概述 将教你 React 的基本原理:组件(components),属性(props)和状态(state)。
  • 完成游戏 将教你 React开发中最常用的技巧
  • 添加过程回放 将让您更深入地了解 React 的独特优势。

您不必一次完成所有部分以获得本教程的精髓。试着尽你所能学习 - 即使是一节或两节。

您可以按照教程中的说法复制和粘贴代码, 但我们建议手工输入。 这将有助于您加深记忆和更强的理解力。

我们正在构建什么

在本教程中,我们将构建一个交互式的 tic-tac-toe(井字棋) 游戏。

如果您愿意,可以在这里查看最终结果:最终结果。如果你对不理解代码,或者你对语法不熟悉,不要担心。我们将在本教程中逐步学习如何构建这个游戏。

我们建议您在继续本教程之前玩一下井字游戏。 您会注意到的一个功能是游戏棋盘右侧有一个编号列表。 此列表为您提供游戏中发生的所有动作的历史记录,并随着游戏的进行而更新。

一旦熟悉它,你就可以关闭井字游戏。 我们将从本教程中的一个更简单的模板开始。 我们的下一步是为您安排,以便您可以开始构建游戏。

预备知识(Prerequisites)

我们需要熟悉 HTML 和 JavaScript ,但是即使你以前没有使用过它们,也应该能够跟随本教程完成这个示例。我们还假设您已经熟悉了编程概念,如函数,对象,数组,以及一定程度的类。

如果您需要刷新你的 JavaScript 知识,我们建议您阅读本指南。 请注意,我们还使用了 ES6 的一些功能,最近版本的 JavaScript 。 在本教程中,我们使用的是箭头函数classeslet,和 const语句。 您可以使用 Babel REPL 来检查ES6代码编译的内容。

教程的设置

有两种方法可以完成本教程:你可以直接在浏览器中编写代码,或者你可以在你的机器上建立一个本地开发环境。你可以根据自己感觉舒适的方式选择其中一个选项。

设置选项1:在浏览器中编写代码

这是上手最快的方法!

我们将在本指南中使用一个名为 CodePen 的在线编辑器。您可以先打开此 开始代码 。它应该显示一个空的 tic-tac-toe 字段。我们将在本教程中编辑该代码。

你现在可以跳过选项2,关于设置本地开发环境的介绍,并直接转到 概述

设置选项2:本地开发环境

这完全是可选的,对于本教程来说不是必须的!


可选:使用首选文本编辑器在本地按照本教程设置项目

此设置需要更多的前期工作,但允许您使用您选择的编辑器完成教程。 以下是要遵循的步骤:

  1. 确保您安装了最新版本的 Node.js
  2. 按照 Create React App 安装说明 创建一个新项目。
npm install -g create-react-app
create-react-app my-app
  1. 删除新项目 src/ 文件夹中的所有文件(不要删除文件夹,只删除里面的文件)。
cd my-app
rm -f src/*
  1. src/ 文件夹中添加一个名为 index.css 的文件,这个文件中包含的 CSS代码

  2. src/ 文件夹中添加一个名为 index.js 的文件,这个文件中包含的 JS代码

  3. 然后在 src/ 文件夹 index.js 的文件中,在其顶部添加以下 3 行代码:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';

现在,如果您在项目文件夹中运行 npm start,并在浏览器中打开 http://localhost:3000 ,你应该看到一个空的 tic-tac-toe 字段。

我们建议你按照 这些说明 为你的编辑器配置设置语法高亮。

求助,我被卡住了!(Help, I’m Stuck!)

如果你被卡住了, 查看 社区支持资源。特别是,Reactiflux 聊天室 是获得快速帮助的好方法。如果您在任何地方找不到好的答案,请提出 issue ,我们将帮助你。

概述(Overview)

现在您已经设置好了,让我们来看看React!

什么是 React ?(What is React?)

React是一个声明式的,高效的,并且灵活的用于构建用户界面的 JavaScript 库。它允许您使用”components(组件)“(小巧而独立的代码片段)组合出各种复杂的UI。

React有几种不同的组件,但是我们将从 React.Component 子类开始:

class ShoppingList extends React.Component {
  render() {
    return (
      <div className="shopping-list">
        <h1>Shopping List for {this.props.name}</h1>
        <ul>
          <li>Instagram</li>
          <li>WhatsApp</li>
          <li>Oculus</li>
        </ul>
      </div>
    );
  }
}

// Example usage: <ShoppingList name="Mark" />

我们能在一秒钟内理解有趣的类 XML 的标签。你的组件告诉 React 你要呈现的内容 - 然后,当数据更改时,React 将有效地更新并渲染正确的组件。

在这里,ShoppingList 是一个 React 组件类 ,或 React 组件类型。组件接收参数,称为 props(属性),并通过 render 方法返回一个显示的视图层次结构。

render 方法返回您要渲染的内容描述,然后 React 接受该描述并将其渲染到屏幕上。特别是,render 返回一个 React 元素,这是一个渲染内容的轻量级描述。大多数 React 开发人员使用一种名为 JSX 的特殊语法,可以更容易地编写这些结构。<div /> 语法在构建时被转换为 React.createElement('div')。上面的例子等价于:

return React.createElement('div', {className: 'shopping-list'},
  React.createElement('h1', /* ... h1 children ... */),
  React.createElement('ul', /* ... ul children ... */)
);

查看完整版本

如果你好奇,createElement()API参考 中有更详细的描述,但是我们不会在本教程中直接使用它。相反,我们将继续使用 JSX 。

您可以将任何 JavaScript 表达式放在 JSX 中的大括号内。每个 React 元素都是一个真正的 JavaScript 对象,您可以将其存储在变量中或传递给程序。

ShoppingList 组件仅渲染内置的 DOM 组件,但你可以通过使用 <ShoppingList /> 轻松地编写自定义的 React 组件。每个组件都是封装的,因此它可以独立操作,这允许你从简单的组件构建复杂的UI。

查看入门代码

从这个例子开始:起始代码

它包含了我们今天正在构建的应用外壳。 我们提供了风格,所以您只需要担心 JavaScript 。

特别是我们有三个组成部分:

  • Square(方格)
  • Board(棋盘)
  • Game(游戏)

Square(方格) 组件渲染为一个单独的 <button>, Board(棋盘) 会渲染为 9 个 Square(方格) ,而 Game(游戏) 组件会渲染为一个 Board(棋盘),还有一些占位符,我们稍后会填充。到目前为止,没有一个组件是交互的。

通过 Props 传递数据(Passing Data Through Props)

初次尝试,让我们试试从 Board(棋盘) 组件传递一些数据到 Square(方格) 组件。

在 Board(棋盘) 组件的 renderSquare 方法中,更改代码以将 value prop(属性) 传递给 Square(方格) 组件:

class Board extends React.Component {
  renderSquare(i) {
    return <Square value={i} />;
  }

然后修改 Square(方格) 的render方法,通过使用 {this.props.value} 替换 {/* TODO */} 来显示该值:

class Square extends React.Component {
  render() {
    return (
      <button className="square">
        {this.props.value}
      </button>
    );
  }
}

之前:

React Devtools

之后: 您应该可以在渲染输出中的每个正方形中看到一个数字。

React Devtools

查看当前代码

制作交互式组件

当您点击 Square(方格) 组件 时,填入 “X”。 尝试将 Square(方格) 的 render() 函数中返回的按钮标签更改为类似这个样子:

class Square extends React.Component {
  render() {
    return (
      <button className="square" onClick={() => alert('click')}>
        {this.props.value}
      </button>
    );
  }
}

如果现在点击一个正方形,你应该在浏览器中会看到一个 alert 信息。

注意:

为了节省代码并避免 this 的混乱行为,我们将在这里和下面的事件处理程序中使用 箭头函数语法

class Square extends React.Component {
 render() {
   return (
     <button className="square" onClick={() => alert('click')}>
       {this.props.value}
     </button>
   );
 }
}

请注意如何使用 onClick={() => alert('click')} ,我们将传递 一个函数 作为 onClick prop(属性)。它只在点击后触发。忘记 () => 并直接编写 onClick={alert('click')} 是一个常见错误,并且每次组件重新渲染时都会触发 alert 。

下一步,我们希望 Square(方格) 组件 “记住” 它被点击,并标记为 “X” 。 为了 “记住” 这些事情,组件使用 state(状态)

React 组件可以通过在构造函数中设置 this.state 来拥有 state(状态) ,构造函数应该被认为是组件的私有。 让我们在 Square(方格) 组件的 state(状态) 中存储当前值,并在单击方格时更改它。

首先,在类中添加一个构造函数来初始化 state(状态):

class Square extends React.Component {
  constructor() {
    super();
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button className="square" onClick={() => alert('click')}>
        {this.props.value}
      </button>
    );
  }
}

注意:

JavaScript classes(类)中, 在定义子类的构造函数时,你需要始终调用 super 。所有具有 constructor 的 React 组件类都应该以 super(props) 调用启动它。

现在更改 Square(方格) 组件的 render 方法以显示当前 state(状态) 的值,并在点击时切换:

  • <button> 标签中的 this.props.value 替换为 this.state.value
  • () => this.setState({value: 'X'}) 替换事件处理程序中的 () => alert()
  • classNameonClick 道具放在不同的行上以提高可读性。

在这些更改之后, Square(方格) 的render方法返回的 <button> 标记如下所示:

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button
        className="square"
        onClick={() => this.setState({value: 'X'})}
      >
        {this.state.value}
      </button>
    );
  }
}

通过 Square(方格) 的 render 方法中的 onClick 处理程序调用this.setState, 只要单击 <button> ,我们就告诉 React 重新渲染 Square(方格) 。 更新后, Square(方格) 的 this.state.value 的值将是 'X' , 所以我们会在游戏棋盘上看到X。如果你点击任何 Square(方格),就会出现一个X

当你在一个组件中调用 setState 时, React 也会自动更新其子组件。

查看当前代码

开发者工具(Developer Tools)

ChromeFirefox 的 React Devtools extension 可让您在浏览器的开发者工具中检查 React 组件树。

React Devtools

React DevTools 允许您检查任何组件中的 props(属性) 和 state(状态) 。

安装完成后,您可以右键单击页面上的任何元素,点击 “检查(Inspect)” 打开开发者工具, “React” 选项卡将显示在最右侧。

但是,请注意,在 CodePen 中使用需要一些额外的步骤:

  1. 登录 或 注册并确认你的电子邮件(防止垃圾邮件)。
  2. 点击 “Fork” 按钮。
  3. 单击 “Change View(更改视图)” ,然后选择 “Debug mode(调试模式)”。
  4. 在新 tab 页中打开的,现在 开发者工具 应该有一个 React 选项卡。

完成游戏

我们现在拥有了井字游戏的基本构建模块。 要完成一个完整的游戏,我们现在需要在棋盘上交替放置“X”和“O”, 我们需要一种方法来确定胜利者。

State(状态) 提升

目前,每个 Square(方格) 组件都维持游戏的状态。为了检查获胜者,我们将在一个地方保存这 9 个 Square(方格) 的状态值。

你可能会认为 Board(棋盘) 应该可以获取到每个 Square(方格) 的当前 state(状态) 。尽管在 React 中这样做在技术上是可行的,但是我们不鼓励这么做,因为它往往使代码难以理解,更脆弱,更难重构。相反,最好的解决办法是将 state(状态) 存储在 Board(棋盘) 组件中,而不是在每个 Square(方格) 组件中 。 Board(棋盘) 组件可以通过传递props(属性)来告诉每个 Square(方格) 组件要显示什么,就像我们将数字索引传递给每个Square(方格) 时所做的那样

要从多个子级收集数据 或 使两个子组件之间相互通信,您需要在其父组件中声明共享 state(状态) 。父组件可以使用props(属性) 将 state(状态) 传递回子节点;这可以使子组件彼此同步并与父组件保持同步。

当重构 React 组件时,提升 state(状态) 是非常常见的,所以让我们借此机会尝试一下。在 Board(棋盘) 组件中添加一个构造函数,并设置其初始 state(状态) 为包含一个具有 9 个空值的数组,对应 9 个方格:

class Board extends React.Component {
  constructor() {
    super();
    this.state = {
      squares: Array(9).fill(null),
    };
  }

  renderSquare(i) {
    return <Square value={i} />;
  }

  render() {
    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

我们稍后会填充,以使棋盘看起来像这样:

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

Board(棋盘) 组件的 renderSquare 方法目前看起来像这样:

  renderSquare(i) {
    return <Square value={i} />;
  }

在一开始的时候,我们从 Board(棋盘) 传递 value prop(属性) 来显示每个 Square(方格) 中 0 到 8 的数字。 与前面的步骤不同,这次我们用由Square(方格)自己的状态确定的 “X” 标记替换数字。 这就是为什么 Square(方格) 目前忽略了从 Board(棋盘) 传递给它的 value prop(属性)。

我们现在再次使用 prop(属性) 传递机制。 我们将修改 Board(棋盘) ,以指示每个 Square(方格) 的当前值(“X”,“O’”或“null”)。 我们已经在 Board(棋盘) 的构造函数中定义了 squares 数组, 我们将修改 Board(棋盘) 的 renderSquare 方法来读取它:

  renderSquare(i) {
    return <Square value={this.state.squares[i]} />;
  }

查看当前代码

每个 Square(方格) 现在都会收到一个 value prop(属性),它要么是 'X',要么是'O',对于空 Square(方格) 则是null

接下来,我们需要修改当点击方格的时候会发生的事情。Board(棋盘) 组件现在存储了那些已经填充的方格,这意味着我们需要一些方法来使 Square(方格) 组件 更新 Board(棋盘) 组件的 state(状态) 。由于组件的 state(状态) 被认为是私有的,我们不能从 Square(方格) 组件直接更新 Board(棋盘) 的 state(状态) 。

通常的模式是将一个函数从 Board(棋盘) 组件 传递到 Square(方格) 组件上,该函数在方格被点击时调用。 再次修改 Board(棋盘) 组件中 renderSquare ,修改为:

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

注意:

我们将返回的元素拆分成多行以提高可读性,并在其两边加上括号,这样 JavaScript 就不会在 return 后插入分号并且破坏我们的代码了。

现在我们把 Board(棋盘) 组件中 2 个的 props(属性) 传递给 Square(方格) 组件:valueonClickonClick prop(属性) 是一个函数,Square(方格) 组件 可以调用该函数。我们对 Square(方格) 组件进行以下更改:

  • 在 Square(方格) 组件的 render 中用 this.props.value 替换 this.state.value
  • 在 Square(方格) 组件的 render 中用 this.props.onClick() 替换 this.setState()
  • 从 Square(方格) 组件删除 constructor(构造函数) 定义,因为它不再需要 state(状态) 。

在这些修改之后,整个 Square(方格) 组件看起来像这样:

class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
        onClick={() => this.props.onClick()}
      >
        {this.props.value}
      </button>
    );
  }
}

现在当方格被点击时,它调用由 Board(棋盘) 组件传递的 onClick 函数。让我们回顾一下这里发生了什么:

  1. 内置 DOM 的 <button> 组件上的 onClick prop(属性) 告诉 React 设置一个 click 事件侦听器。
  2. 当点击按钮时,React 将调用在 Square(方格) 组件 render() 方法中定义的 onClick 事件处理程序。
  3. 这个事件处理程序调用 this.props.onClick() 。 Square(方格) 组件的 onClick props(属性) 由 Board(棋盘) 组件指定。
  4. Board(棋盘) 组件将 onClick={() => this.handleClick(i)} 传递给 Square(方格) 组件,所以当被调用时,它会在 Board(棋盘) 组件 上运行 this.handleClick(i)
  5. 我们还没有在 Board(棋盘) 组件 上定义 handleClick() 方法,所以导致代码崩溃。

注意:

DOM <button> 元素的 onClick 属性对于 React 来说有特殊意义,因为它是一个内置组件。 对于像 Square(方格) 这样的自定义组件,命名取决于您自己。我们可以用不同的方式命名 Square(方格) 的onClick prop(属性) 或 Board(棋盘) 的 handleClick 方法。在React中,对于表示事件的 props(属性) 使用 on[Event] 命名,对处理事件的方法使用 handle[Event]

当我们尝试点击一个 Square(方格) 时, 我们应该收到一个错误,因为我们还没有定义 handleClick 。 我们现在将handleClick添加到 Board(棋盘) 类:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
    };
  }

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = 'X';
    this.setState({squares: squares});
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

  render() {
    const status = 'Next player: X';

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

查看当前的代码

在这些修改之后,我们就能够再次点击 Squares(方格) 来填充它们。 但是,现在 state(状态) 已经存储在 Board(棋盘) 组件中而不是单个 Square(方格) 组件中。 当 Board(棋盘) 的状态发生变化时, Square(方格) 组件会自动重新渲染。 保持 Board(棋盘) 组件中所有 Square(方格) 组件的 state(状态) 将允许它在将来确定胜利者。

由于 Square(方格) 组件不再保存 state(状态) , Square(方格) 组件从 Board(棋盘) 组件接收值,并在单击它们时通知 Board(棋盘) 组件。 在 React 术语中, Square(方格) 组件现在是 受控组件 。 Board(棋盘) 会完全控制他们。

注意在 handleClick 中,我们调用 .slice() 来创建 squares 数组的副本来修改,而不是修改现有的数组。 我们将在下一节中解释为什么我们要创建 squares 数组的副本。

不可变数据的重要性

在前面的代码示例中,我们建议在数组进行更改之前使用 .slice() 运算符来创建 squares 数组的副本以进行修改,而不是修改现有数组。 我们现在将讨论 不可变性 以及为什么不可变性 很重要。

通常有两种方式来更改数据。第一种方法是通过直接更改变量的值来 改变 数据。第二种方法是用包含所需更改对象的新副本来替换数据。

通过赋值改变数据(Data change with mutation)

var player = {score: 1, name: 'Jeff'};
player.score = 2;
// 现在 player 是 {score: 2, name: 'Jeff'}

不通过赋值改变数据(Data change without mutation)

var player = {score: 1, name: 'Jeff'};

var newPlayer = Object.assign({}, player, {score: 2});
// 现在 player 没改变, 但是 newPlayer 是 {score: 2, name: 'Jeff'}

// 或者如果你使用对象扩展语法,可以写成:
// var newPlayer = {...player, score: 2};

最终结果是相同的,但通过不直接改变数据(或更改底层数据)有一个额外的好处,可以帮助我们增强组件和整体应用性能。

复杂的功能变得简单

不可变性(Immutability) 可以使复杂功能更容易实现。在本教程的后面,我们将实现一个 “过程回放” 功能,允许我们查看井字游戏的历史记录并 “跳回” 以前的动作(悔棋)。 此功能并非只限于特定游戏 — 撤消和重做某些操作的功能是应用程序中的常见要求。避免直接修改数据可以让我们保留游戏历史的先前版本,并在以后重复使用。

检测变更(Detecting Changes)

检测可变对象的变化很困难,因为它们是直接修改的。该检测需要将可变对象与其自身的先前副本进行比较, 以及要遍历的整个对象树。

检测不可变对象中的更改要容易得多。 如果被引用的不可变对象与前一个不同,则该对象已更改。仅此而已。

确定何时重新渲染(Determining When to Re-render in React)

React 中不可变数据最大好处在于当您构建简单的 纯(pure)组件 时。由于不可变性(Immutability) 可以更容易地确定是否已经进行了更改,这也有助于确定组件何时需要重新渲染。

要了解有关 shouldComponentUpdate() 的更多信息,以及如何构建 纯(pure)组件 ,请查看优化性能

函数式组件(Functional Components)

我们现在将 Square(方格) 改为 函数式组件(Functional Components)

在 React 中,**函数式组件(Functional Components) 是一种更简单的方法来编写只包含 render 方法且没有自己 state(状态) 的组件。 而不是定义一个 React.Component 的扩展类, 我们可以编写一个函数,它将 props 作为输入,并返回应该渲染的内容。 函数式组件写入比类更乏味, 并且可以用这种方式表达许多组件。

用这个函数替换 Square(方格) 类:

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

我们将两次出现的 this.props 都改为 props

查看当前代码

注意:

当我们将 Square 修改为一个函数式组件时,我们还将 onClick={() => this.props.onClick()} 更改为更短的onClick = {props.onClick}(注意两边都去掉了括号)。 在类中,我们使用箭头函数来访问正确的this值,但在函数式组件中我们不需要担心this

轮流下棋(Taking Turns)

我们现在需要在我们的井字游戏中修复一个明显的缺陷:无法在棋盘上标记”O”。

我们默认将第一步下棋动作为 “X” 。 我们可以通过修改 Board(棋盘) 构造函数中的初始 state(状态) 来设置此默认值:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }

每次下棋,我们应该切换 xIsNext 布尔值,并保存 state(状态) 。现在更新 Board(棋盘) 组件的 handleClick 函数来反转 xIsNext 的值。

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

修改后,“X” 和 “O” 可以轮流切换了。接下来,修改 Board(棋盘) 组件 render 方法中的 “status” 文本,以便它还显示下一个下棋的是谁。

  render() {
    const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');

    return (
      // 其余的代码没有修改

这些更改后, Board(棋盘) 组件应该是这样的:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

  render() {
    const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

查看当前代码

宣布获胜者(Declaring a Winner)

现在我们展示下一个玩家的转换, 我们还应该展示何时赢得比赛,并且结束比赛。 我们可以通过将此帮助函数添加到文件末尾来确定获胜者:

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(棋盘) 组件的 render 函数中调用 calculateWinner(squares) 以检查玩家是否赢了。 如果玩家获胜,我们可以显示诸如 “Winner: X” 或 “Winner: O” 之类的文本。 我们将使用以下代码替换 Board(棋盘) 的render函数中的 status 声明:、

  render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      // 其余的代码没有修改

现在,如果有人已经赢得了比赛,或者当一个方格已经被填充时,你需要提前返回,并且忽略该点击。 你可以修改 Board(棋盘) 组件的 handleClick

  handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

查看当前代码

恭喜! 你现在已经有了一个能正常工作的 tic-tac-toe 游戏。 并且现在你知道了 React 的基础知识。 所以 你可能是真正的赢家

添加过程回放

作为最后的练习,让我们添加过程回放功能,以回到之前的游戏动作。

存储历史记录

如果我们改变了 square 数组,实现过程回放将非常困难。

但是,我们使用 slice() 在每次移动后创建 squares 数组的新副本, 并将其视为不可变的。 这将允许我们存储 square 数组的每个过去版本, 并在已经发生的玩家轮换之间切换。

我们将过去的square数组存储在另一个名为history的数组中。 history数组代表所有 Board(棋盘) 的 state(状态), 从第一步到最后一步, 并有这样的数据:

history = [
  // Before first move
  {
    squares: [
      null, null, null,
      null, null, null,
      null, null, null,
    ]
  },
  // After first move
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, null,
    ]
  },
  // After second move
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, 'O',
    ]
  },
  // ...
]

现在我们需要决定哪个组件应该拥有 history state(状态)。

再次 State(状态) 提升

我们希望 Game(游戏) 组件显示过去动作列表。 它需要访问 history 来做到这一点, 所以我们将 history state(状态) 放在顶级 Game(游戏) 组件中。

history state(状态) 放入 Game(游戏) 组件可以让我们从子 Board(棋盘) 组件中删除 squares state(状态) 。 就像我们将 Square(方格)组件 中的 “state(状态)提升” 到 Board(棋盘)组件一样,我们现在将其从 Board(棋盘) 组件 提升到顶级 Game(游戏) 组件中。 这使 Game(游戏) 组件可以完全控制 Board(棋盘) 的数据, 并让它根据 history 显示 Board(棋盘) 的纪录。

首先,通过添加一个构造函数来设置 Game(游戏) 组件的初始 state(状态) :

class Game extends React.Component {
  constructor() {
    super();
    this.state = {
      history: [{
        squares: Array(9).fill(null),
      }],
      xIsNext: true,
    };
  }

  render() {
    return (
      <div className="game">
        <div className="game-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <ol>{/* TODO */}</ol>
        </div>
      </div>
    );
  }
}

接下来,我们将让 Board(棋盘) 组件从Game(游戏) 组件接收 squaresonClick prop(属性) 。 因为我们现在在 Board(棋盘) 中有一个单击处理程序用于许多 Square(方格) , 我们需要将每个 Square(方格) 的索引位置传递给 onClick 处理程序,以指示单击了哪个 Square(方格) 。 以下是转换 Board(棋盘) 组件所需的步骤:

  • 删除 Board(棋盘) 组件中的 constructor (构造函数)。
  • 在 Board(棋盘) 组件的 renderSquare 中用 this.props.squares[i] 替换 this.state.squares[i]
  • 在 Board(棋盘) 组件的 renderSquare 中用 this.props.onClick(i) 替换 this.handleClick(i)

现在整个 Board(棋盘) 组件看起来像这样:

class Board extends React.Component {
  handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

  render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

Game(游戏) 组件的 render 应该可以获取最近的历史记录,并可以接管计算游戏状态:

  render() {
    const history = this.state.history;
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);

    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={(i) => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{/* TODO */}</ol>
        </div>
      </div>
    );
  }

由于 Game(游戏) 组件现在渲染游戏的状态, 我们可以从 Board(棋盘) 的render方法中删除相应的代码。 重构后, Board(棋盘) 的render函数如下所示:

  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }

最后,我们需要将 handleClick 方法从 Board(棋盘) 组件移动到 Game(游戏) 组件。 我们还需要修改 handleClick,因为 Game(游戏) 组件的 state(状态) 有结构上的不同。 在 Game(游戏) 的handleClick方法中, 我们将新的历史条目连接到history

  handleClick(i) {
    const history = this.state.history;
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      history: history.concat([{
        squares: squares,
      }]),
      xIsNext: !this.state.xIsNext,
    });
  }

注意:

与你所熟悉的数组 push() 方法不同,concat() 方法不会改变原始数组,所以我们更喜欢它。

到目前位置, Board(棋盘) 组件只需要 renderSquarerender方法。 这个游戏的 state(状态) 和 handleClick 方法应该都在 Game(游戏) 组件中。

查看当前代码

显示过去的动作

由于我们正在记录井字游戏的历史,我们现在可以将其作为过去的动作列表显示给玩家。

我们之前了解到 React 元素是一等的JavaScript对象; 我们可以在我们的应用程序中传递它们。 要在React中渲染多个项目,我们可以使用 React 元素数组。

在JavaScript中,数组有一个map()方法 ,它通常用于映射数据到其他数据,例如:

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

使用map方法,我们可以将我们的下棋动作历史映射到屏幕中按钮表示的 React 元素上, 并显示一个按钮列表以“跳转”到过去的动作。

让我们在 Game(游戏) 组件的 render 方法中对 history 进行 map

  render() {
    const history = this.state.history;
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={(i) => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }

查看当前代码

对于游戏历史中的每一步动作, 我们创建一个列表项<li>,其中包含一个按钮<button>。 该按钮有一个onClick处理程序,它调用一个名为 this.jumpTo()的方法。 我们还没有实现jumpTo()方法。 现在,我们应该看到游戏中发生的每一步动作列表,以及一条警告:

警告: Each child in an array or iterator should have a unique “key” prop. Check the render method of “Game”.(数组或迭代器中的每个子元素都应该有一个唯一的 “key” prop。请检查 “Game” 的渲染方法。)

我们来谈谈这个警告是什么意思。

选择一个 Key

当我们渲染列表时,React 总是存储有关每个渲染列表项的一些信息。 当您更新该列表时,React 需要确定发生了什么变化。您可以在列表中添加,删除,重新排列或更新项目。

想象列表从

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

更新为

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

用眼睛看,看来 Alexa 和 Ben 很有可能交换了地方,而 Claudia 被添加进来 - 但是 React 只是一个计算机程序,不知道你打算做什么。因此,React 要求您在列表中的每个元素上指定一个 key(健) 属性(property),一个字符串来区分每个组件与其兄弟组件。在这种情况下, alexa, ben, claudia 可能是作为 key(健) 的明智选择;如果项目对应于数据库中的对象,数据库 ID 通常是一个不错的选择:

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

key 是由 React 保留的特殊属性(以及 ref,更高级的功能)。创建元素时,React 将拉离 key 属性并将其直接存储在返回的元素上。虽然它可能看起来像是 props(属性) 的一部分,但是它不能通过 this.props.key 引用。React 在决定哪些子元素要更新时自动使用该 key;组件无法查询自己的 key

当一个列表重新渲染时, React 使用新版本中的每个元素,并在上一个列表中查找具有匹配 key(健) 的元素。当一个 key(健) 被添加到集合中时,创建一个组件;当一个 key(健) 被删除时,一个组件将会被销毁。 key(健) 用来告诉 React 关于每个组件的身份标识 ,因此他可以维持 state(状态) 到重新渲染。如果更改组件的 key(健) ,它将被完全毁灭,并重新建立一个新的 state(状态) 。

强烈建议你在构建动态列表时分配适当的 key(健) 。 如果你没有适当的值来作为 key(健) ,您可能需要考虑重组你的数据,以便你这样做。

如果您没有指定任何 key(健) ,React 会警告你,并回到使用数组索引作为 key(健) - 这不是正确的选择,如果您重新排序列表中的元素 或 在列表的底部的任何位置添加/删除项目。明确地传递 key={i} 可以使警告消失,但有问题同样存在,因此在大多数情况下不推荐这么使用。

组件 key(健) 不需要是全局唯一的,只要相对于直系兄弟元素是唯一的就行。

实现过程回放

在井字游戏的历史记录中,每个过去的动作都有一个与之相关的唯一ID:它是动作的连续编号。 动作永远不会重新排序,删除或从中间插入, 所以使用动作索引作为 key 是安全的。

在 Game(游戏) 组件的render方法中,我们可以将 key 添加为<li key = {move}>,并且 React 关于 key 的警告应该消失:

    const moves = history.map((step, move) => {
      const desc = move ?
        'Move #' + move :
        'Game start';
      return (
        <li key={move}>
          <a href="#" onClick={() => this.jumpTo(move)}>{desc}</a>
        </li>
      );
    });

查看当前代码

单击任何列表项上的按钮会引发错误,因为 jumpTo 方法未定义。 在我们实现 jumpTo 之前, 我们将 stepNumber 添加到 Game(游戏) 组件的 state(状态) 中,以指示我们当前正在查看的步骤。

首先,在 Game(游戏) 组件 constructor(构造函数)的初始 state(状态) 中添加 stepNumber: 0

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [{
        squares: Array(9).fill(null),
      }],
      stepNumber: 0,
      xIsNext: true,
    };
  }

接下来,我们将在 Game(游戏) 组件中定义 jumpTo 方法来更新该 state(状态) 。我们也想更新 xIsNext 。如果步骤编号的索引为偶数,则将 xIsNext 设置为 true

在 Game(游戏) 类中添加一个名为 jumpTo 的方法:

  handleClick(i) {
    // this method has not changed
  }

  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) === 0,
    });
  }

  render() {
    // this method has not changed
  }

我们现在将对 Game(游戏)的 handleClick 方法进行一些更改,当您单击一个 Square(方格) 时会触发该方法。

我们添加的 stepNumber state(状态) 反映了现在向用户显示的动作。 在我们采取新举措后, 我们需要通过添加 stepNumber:history.length 作为 this.setState 参数的一部分来更新 stepNumber 。 这样可以确保我们不会在制作新动作后显示相同的动作。

我们还将用 this.state.history.slice(0, this.state.stepNumber + 1) 替换 this.state.history 读取。 这确保了如果我们进行了过程回放,然后从那一步开始新的动作,我们抛弃了所有后续的记录。

  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      history: history.concat([{
        squares: squares
      }]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext,
    });
  }

最后,我们可以修改 Game(游戏) 组件的render方法,始终根据 stepNumber 渲染最后一次动作以呈现当前选定的动作:

  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

    // the rest has not changed

如果我们点击游戏历史中的任何一步,棋盘应该立即更新,以显示该步骤发生后棋面的情况。

查看当前代码

总结(Wrapping Up)

现在,你已经完成了一个 tic-tac-toe 游戏:

  • 让你玩 tic-tac-toe ,
  • 指示一个玩家是否赢得比赛,
  • 存储游戏中的历史动作,
  • 让玩家及时跳回去看某一步的游戏棋盘。

我们希望你现在觉得你对 React 的工作流程有一个很好的把握。

查看最终结果:最终结果

如果你有额外的时间或想要练习你的新技能,这里有一些可以做出改进的想法,按照难度越来越高的顺序列出:

  1. 在动作的历史记录列表中以(列,行)的格式显示每个下棋动作的位置。
  2. 在步骤列表中加粗显示当前选中的项目。
  3. 重写 Board(棋盘) 使用两个循环来制作方格,而不是对它们进行硬编码。
  4. 添加一个切换按钮,您可以按升序或降序对步骤列表进行排序。
  5. 当有人赢了,突出显示导致游戏胜利的三个方块。
  6. 当没有人获胜时,显示关于结果为平局的消息。

在本教程中,我们已经触及了许多 React 概念,包括元素,组件,props 和 state 。 有关这些主题的更深入的解释,请查看 完整的文档。 要了解有关定义组件的更多信息,请查看React.Component API 参考