0%

用 React 实现一个井字棋游戏(一)

@pftom

查看代码

代码准备

我们将在这篇教程中学习制作一个小游戏。你可能不想看这个教程,因为你不是做游戏的 – 但是试着给它一个机会。在这篇教程中你学到的技术是构建一个 React 应用的基础,熟练掌握这些技术会让你更加深入的理解 React。

这篇教程被分为如下几个部分:

你不需要一口气学完整篇教程,但是只要你学习了其中的 1-2 个部分就可以收获很多价值,所以如果有时间和精力,你应该尝试着多学几个部分。

我们将要做什么

在这篇教程中,我们将为你展示用 React 构建一个交互式的 “井字棋” 游戏。

你可以在这里看到我们的构建成果:最终结果。如果你现在对代码还不是很理解,如果说你还不熟悉代码语法,别担心!这篇教程的目的就是帮助你理解 React 和它的语法。

我们推荐你在继续阅读教程之前先检查一下这个 “井字棋” 游戏是怎么运作的。你能看到的其中一个特点就是在游戏棋盘右边有一个有序列表。这个列表向你展示了发生在这个游戏上的移动历史,随着游戏继续,这个历史列表也会变动。

当你对这个 “井字棋” 游戏的运作有一定了解了之后,你就可以关闭它了。在这篇教程中,我们将会以一个简单的代码模板开始。我们的下一步是教会你构建这个游戏。

前提条件

我们假定你对 HTML 和 JavaScript 有一定了解,但即使你是从其他语言转过来的,你也可以跟着学习。我们也假定你对编程概念如 functions,objects,arrays 有一定了解,最好还知道一点关于 classes 的知识。

如果你想回顾一下 JavaScript 的知识,我们推荐你阅读这篇指南。请注意,我们会使用一些 ES6 的语法 – JavaScript 最近的一个版本。在这篇教程,我们会使用 arrow functionsclasseslet,和 const。你可以使用 Babel REPL 来检查 ES6 代码编译之后的结果。

开发环境准备

1.确保在你的机器上安装了 Node.js

2.使用 Create React App 来创建一个新项目。

npx create-react-app my-app
cd my-app
npm start

注意第一行的 npx 不是拼写错误 – 它是 npm 5.2+ 附带的 package 运行工具

Create React App 是一个学习 React 的舒适环境,而且是使用 React 构建一个新的单页应用的最佳方式。

Create React App 不会处理后端逻辑或操纵数据库;它只是创建一个前端构建流水线(build pipeline),所以你可以使用它来配合任何你想使用的后端。它在内部使用 BabelWebpack,但你无需了解它们的任何细节。

当你准备好部署到生产环境时,执行 npm run build 会在 build 文件夹内生成你应用的优化版本。你能从它的 README 和用户指南了解 Create React App 的更多信息。

单页面应用(single-page application),是一个应用程序,它可以加载单个 HTML 页面,以及运行应用程序所需的所有必要资源(例如 JavaScript 和 CSS)。与页面或后续页面的任何交互,都不再需要再访问服务器加载资源,即页面不会重新加载。

你可以使用 React 来构建单页应用程序,但不是必须如此。React 还可用于增强现有网站的小部分,使其增加额外交互。用 React 编写的代码,可以与服务器端渲染的标记(例如 PHP)或其他客户端库同时使用。实际上,这也正是 Facebook 内部使用 React 的方式。

3.删除刚刚创建的 my-app 项目 src/ 目录下的所有文件。

注意:**不要删除整个 src 文件夹,只需要删除它里面的所有源文件。**我们将在接下来的步骤中在 src 中创建我们所需要构建的游戏初始模板代码文件。

cd my-app
cd src

# 如果你在使用 Mac 或者 Linux:
rm -f *

# 或者,你在使用 Windows:
del *

# 然后,切回到项目目录文件夹下
cd ..

4.在 src/ 文件夹下创建一个名为 index.css 的文件,然后在里面加入下面的 CSS 代码并保存。

src/index.css查看完整代码
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}

ol,
ul {
padding-left: 30px;
}

.board-row:after {
clear: both;
content: "";
display: table;
}

.status {
margin-bottom: 10px;
}

.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}

.square:focus {
outline: none;
}

.kbd-navigation .square:focus {
background: #ddd;
}

.game {
display: flex;
flex-direction: row;
}

.game-info {
margin-left: 20px;
}

5.在 src/ 文件夹下创建一个名为 index.js 的文件,然后在里面加入如下的 JS 代码并保存。

src/index.js查看完整代码
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";

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

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

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

class Game extends React.Component {
render() {
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div className="game-info">
<div>{/* status */}</div>
<ol>{/* TODO */}</ol>
</div>
</div>
);
}
}

// ========================================

ReactDOM.render(<Game />, document.getElementById("root"));

6.在 src/ 文件下的 index.js 文件内容的顶部加上下面三行代码:

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

现在,如果你在项目文件夹下面运行 npm start 然后再在浏览器中打开 http://localhost:3000 ,你应该能看到一个空的 “井字棋” 游戏区域。

我们推荐你根据这些指导来配置你编辑器的语法高亮。

帮帮忙,我遇到了问题!

如果你遇到了问题,可以访问我们的社区支持网站。Gitter 是一个很棒的反馈平台,你可以较快的在这上面得到帮助。如果你的问题一直没有得到解答,请给我们提交 Issue,我们将会帮助你解决。

通过 Props 传递数据

什么是 React?

React 是一个用于构建用户界面的声明式的,高效的且灵活的 JavaScript 库。它允许你通过组合一些小且独立的 “组件” 来构建复杂的 用户界面。

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 的标签意思。我们使用组件(Components)来告诉 React 我们想在屏幕上看到什么。当我们的数据改变时,React 将会高效的更新并重新渲染我们的组件。

在上面的代码中,ShoppingList 是一个 React 组件类,或者说是 React 组件类型。组件接受 props 作为参数,然后通过 render 方法来展示它所返回的视图层级结构。

render 方法返回一个你希望在屏幕上看到内容的描述。React 处理这些描述,并展示其结果。实际上,render 返回一个 React element ,这是对将要渲染内容的一个轻量级描述。大多数的 React 开发者使用被称之为 “JSX” 的特殊语法来编写这些视图层级结构,因为它让编写更简单。<div /> 语法在编译时被转换为 React.createElement('div')。上面的代码例子和下面是等价的:

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

查看完整的编译代码。

如果你对这感到很好奇,createElement 在 React 的 API 文档里面有更加详细的描述。

JSX 能使用 JavaScript 的全部功能。你能在 JSX 的大括号里面放入任何 JavaScript 表达式。每个 React element 也是一个 JavaScript 对象,你可以将它保存在一个变量中,也可以在程序中传递它。

上面的 ShoppingList 组件只渲染了一些内建的 DOM 组件,像 <div /><li /> 等。但是你也能组合和渲染自定义组件。比如,我们我们现在可以通过 <ShoppingList /> 来引用整个购物列表。每个 React 组件都是被封装好的,可以独立的运行;这使得你能使用简单的组件来构建复杂的用户界面。

了解初始代码

现在打开项目文件夹里面 src/index.js 代码。这份初始代码是我们将要构建的内容骨架。我们已经添加了 CSS 样式,所以你只需要关注学习 React 以及如何用它编写 “井字棋” 游戏。

通过查看代码,我们可以注意到这份代码里面有三个组件:

  • Square
  • Board
  • Game

Square 组件渲染了单一的一个 <button> ,Board 组件渲染了 9 个 Square 组件。Game 组件渲染了一个 Board 和一些被注释的占位值,在后续的讲解中,我们将会修改这些占位值。目前这份代码中还没有交互式的组件。

让我们来尝试修改一些代码,从 Board 组件向 Square 组件传递一些数据。

我们强烈推荐你在学习这篇教程的时候跟着动手敲一遍代码,而不是简单的复制/粘贴。这会帮助你形成肌肉记忆并且会有更深层次的理解。

修改 Board 的 renderSquare 方法中的代码,传递 value 属性给 Square:

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

修改 Square 的 render 方法来展示传进来的 value ,将 {/* TODO */} 替换成 {this.props.value}

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

当修改完保存之后,如果你的程序运行着,那么你应该能看到如下的变化。

改之前是这样的:

修改之后你将会看到每个 Square 都渲染展示了一个数字:

最终 src/index.js 的代码为下面的样子:

src/index.js查看完整代码
// ...

class Square extends React.Component {
render() {
[tuture-del] return <button className="square">{/* TODO */}</button>;
[tuture-add] return <button className="square">{this.props.value}</button>;
}
}

class Board extends React.Component {
renderSquare(i) {
[tuture-del] return <Square />;
[tuture-add] return <Square value={i} />;
}

render() {
// ...

查看此时的整个项目代码

恭喜你!你刚刚从父组件 Board 传递了 props 给子组件 Sqaure。在 React 中,父组件通过传递 props 给子组件来完成数据的流动。

编写交互式组件

让我们在点击 Square 组件时给它填充 “X”。首先,将 Square 组件中的 render() 函数的中返回的 button 标签修改成下面这样:

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

如果你现在点击 Square,你应该能在浏览器中看到弹出框。

注意为了减少打字量和避免 this 的混淆行为,在下面和之后的代码中我们将使用箭头函数作为事件处理函数:

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

注意到我们使用了 onClick={() => alert('click')},我们给 onClick 属性传递了一个函数。只有到 button 被点击时,React 才会调用这个函数。忘记 () => 而只写了 onClick={alert('click')}是一个很容易犯的错误,这会导致每次组件重新渲染时都会调用 alert 函数。

在下一步中,我们希望 Square 能 ”记住“ 自己被点击了,同时能填充一个 “X” 标记。为了 “记住” 事情,组件需要 state

React 组件通过在 constructor 里面设置 this.state 的来获得 state。当在组件中定义 this.state 时,它应该被认为是这个组件的私有属性。让我们在 this.state 中保存 Square 的当前值,然后在 Square 被点击时修改它。

我们修改代码如下:

src/index.js查看完整代码
// ...
import "./index.css";

class Square extends React.Component {
[tuture-add] constructor(props) {
[tuture-add] super(props);
[tuture-add] this.state = {
[tuture-add] value: null
[tuture-add] };
[tuture-add] }
[tuture-add]
render() {
[tuture-del] return <button className="square">{this.props.value}</button>;
[tuture-add] return (
[tuture-add] <button className="square" onClick={() => this.setState({ value: "X" })}>
[tuture-add] {this.state.value}
[tuture-add] </button>
[tuture-add] );
}
}

// ...

让我们来看一看上面的代码做了什么事。

首先,我们在 Square 类中加入了一个 constructor 来初始化 state。

注意JavaScript 类中,当在子类中定义 constructor 时,你总是需要调用 super 函数。所有有 constructor 的 React 组件类应该在其中首先调用 super(props) 函数。

然后我们修改了 Square 的 render 方法来展示被点击时的当前的 state 值:

  • <button> 标签里面使用 {this.state.value} 来替换 {this.props.value}
  • 使用 onClick={() => this.setState({value: 'X'}) 来替换 onClick={...} 事件处理函数。

在 Square 的 render 方法中,通过在 onClick 处理函数中调用 this.setState 方法,我们告诉 React ,每当 <button> 被点击时就重新渲染 Square 组件。在更新之后,Square 的 this.state.value 将会变成 'X' ,所以我们能在游戏板上看到 X。点击任何 Square,都会出现 X

当你在组件中调用 this.setState 时,React 将会自动更新这个组件中的所有子组件。

查看此时的所有项目代码

开发工具

ChromeFirefox 的 React Devtools 扩展插件能让你在开发者工具中查看 React 组件树。

React DevTools 能让你查看 React 组件的 props 和 state。

当安装完 React DevTools 之后,你使用鼠标右键点击页面上的任意元素,然后在弹出的菜单栏中选择 “检查” 来打开开发者工具,然后你会看到在开发者工具的标签栏最右边会出现 React 标签(“⚛️ Components” and “⚛️ Profiler”)

状态提升

我们目前已经有了 “井字棋” 游戏的基础骨架。为了完成这个游戏,我们需要在游戏板上交替出现 “X” 和 “O”,并且我们需要一种方式来决定胜者。

目前,每个 Square 组件都保留着游戏的 state。为了检查胜者,我们需要在一个地方维护这 9 个 Square 的值。

我们也许可以想到 Board 可以找每个 Square 要它们的 State。尽管这个方法在 React 中是可行的,但我们不建议你这么做,因为这会使代码编写难于理解,造成隐含的 BUG,并且让重构变得困难。然而,最好的方法是将游戏的 state 保存在 Board 组件中而不是每个 Square 组件中。Board 组件能通过传递 props 告诉每个 Square 该展示什么,就像我们之前给 Square 传递数字一样

为了从多个子组件中收集数据,或者使得两个子组件能相互通信,你需要在它们共有的父组件上面定义共享的 state。父组件能够通过传递 props 将 state 传回给子组件;这使得子组件之间相互同步并且子组件和父组件也保持同步。

将状态提升到父组件在 React 的组件重构中时很常见的方式 – 让我们抓住这个机会来尝试它。

在 Board 中添加 constructor,然后设置初始 state 包含一个有 9 个 null 值的数组,对应着 9 个 Square。

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

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

当我们之后填充游戏板时,this.state.squares 数组看起来会像下面这样:

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

Board 的 renderSquare 方法目前看起来是这样的:

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

一开始,我们从 Board 向下传递 props 值给 Square 来展示从 0-9 的数字。之后,我们通过 Square 的自带 state 将数字设置成 “X”。这就是为什么 Square 目前忽略了通过 Board 传下来的 value 值。

现在,我们将再次使用传递 props 的机制。我们通过修改 Board 组件来指导每个 Square 当前的值('X',‘O’, 或者null)。我们已经在 Board 的 constructor 中定义了squares数组,现在我们将修改 Board 的renderSquare` 方法来读取它。

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

现在每个 Square 将会接收 value props,它的值可能是 'X''O' 或者 null

接下来,当 Square 被点击时,我们需要作出相应的改变。现在 Board 组件维护着 Square 的填充值数组 squares。我们需要创造一种在 Square 中更新 Board 的 state 值的方式。因为 state 被认为是它所在定义的组件的私有属性,我们不能直接在 Square 中更新 Board 的 state 值。

取而代之的是,我们从 Board 给 Square 传递一个函数,当 Square 被点击时,我们调用这个函数。我们修改 Board 的 renderSquare 方法如下:

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

注意为了可读性,我们将返回的代码分割成多行代码,然后在两端加上了圆括号,这样 JavaScript 就不会在 return 后面加上分号来破坏我们的代码。

我们现在从 Board 给 Square 传递了两个 props:valueonClickonClick 参数是是一个函数,当 Square 被点击时就会调用它。我们将对 Square 做如下的修改:

  • 在 Square 的 render 方法中,用 this.props.value 来替换 this.state.value
  • 在 Square 的 render 方法中,用 this.props.onClick() 来替换 this.setState
  • 删除 Square 中的 constructor ,因为 Square 不再追踪游戏的 state。

在做出上面的改变修改之后,Square 组件变成了下面这样:

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

当 Square 被点击时,由 Board 提供的 onClick 函数就会调用。下面是对我们上面所做的一个回顾:

  1. 在内建 DOM 元素 <button> 组件上的 onClick 属性告知 React 要设定一个事件监听器。
  2. 当 button 被点击时,React 将会调用在 Square 的 render() 方法里定义的 onClick 事件处理函数。
  3. 事件处理函数会调用 this.props.onClick()。Square 的 onClick props 是由 Board 传下来的。
  4. 因为 Board 将 onClick={() => this.handleClick(i)} 传给 Square,所以当 Square 被点击时,就会调用 this.handleClick(i)` 方法。
  5. 我们目前还没有定义 handleClick(i) ,所以当点击 Square 之后,我们的程序就崩掉了,你应该可以看到有着像 “this.handleClick is not a function” 这样的红色的错误提示出现在屏幕上。

注意button DOM 元素的 onClick 属性对 React 来说有特殊的意义,因为它是内建的组件。对于自定义组件,像 Square ,起名字取决于你。我们可以给 Square 的 onClick props 和 Board 的 handleClick 方法起任何名字,代码都会如期运行。在 React 中,我们约定使用 on[Event] props 来代表事件,handle[Event] 来代表事件处理方法。

当我们尝试点击 Square 时,程序会崩溃,因为我们目前还没有在 Board 定义 handleClick 方法,我们将马上加上它:

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

最后我们的代码看起来像下面这样:

src/index.js查看完整代码
// ...
import "./index.css";

class Square extends React.Component {
[tuture-del] constructor(props) {
[tuture-del] super(props);
[tuture-del] this.state = {
[tuture-del] value: null
[tuture-del] };
[tuture-del] }
[tuture-del]
render() {
return (
[tuture-del] <button className="square" onClick={() => this.setState({ value: "X" })}>
[tuture-del] {this.state.value}
[tuture-add] <button className="square" onClick={() => this.props.onClick()}>
[tuture-add] {this.props.value}
</button>
);
}
}

class Board extends React.Component {
[tuture-add] constructor(props) {
[tuture-add] super(props);
[tuture-add] this.state = {
[tuture-add] squares: Array(9).fill(null)
[tuture-add] };
[tuture-add] }
[tuture-add]
[tuture-add] handleClick(i) {
[tuture-add] const squares = this.state.squares.slice();
[tuture-add] squares[i] = "X";
[tuture-add] this.setState({ squares: squares });
[tuture-add] }
[tuture-add]
renderSquare(i) {
[tuture-del] return <Square value={i} />;
[tuture-add] return (
[tuture-add] <Square
[tuture-add] value={this.state.squares[i]}
[tuture-add] onClick={() => this.handleClick(i)}
[tuture-add] />
[tuture-add] );
}

render() {
// ...

查看目前所有的项目代码

在经过上面的修改之后,我们现在应该又可以点击 Square 然后填充 “X” 了,就像我们之前的效果一样。然而,现在游戏的 state 统一保存在 Board 组件中而不是各个 Square 组件中。当 Board 的 state 改变时,Square 组件将会自动重新渲染。将所有的 Square 的 state 保存在 Board 组件中能让我们在之后判断获胜者。

因为 Square 组件不再维护 state,它从 Board 组件接收 value ,然后在被点击时,通知 Board 修改 state。用 React 的术语说,Square 组件现在是一个受控组件。Board 组件现在完全控制着它们。

注意到在 handleClick 方法中,我们在数组上调用了 .slice() 方法来创建一个 squares 的拷贝而不是直接修改它。我们将在下部分讲解为何我们要创建 squares 的拷贝。

图雀社区 微信公众号

扫一扫关注上方公众号,拉学习群和答疑解惑

  • 本文作者: 图雀社区
  • 本文链接: https://tuture.co/2019/11/13/175b717/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!