我们一起用 React 开发一个井字棋(tic-tac-toe)。游戏规则,在九宫格内输入棋子“X”或“O”,首先在纵向、横向或斜方向上三个同一棋子连成一线为胜出。 通过这个简单的棋盘游戏我们学习下面这些知识点;
环境搭建 create-react-app
介绍React的基础知识:组件、props和state
React开发过程最常用的技术
时间旅行 深刻了解React的独特优势
1、环境搭建
环境搭建流程如下:
1、确保安装了较新版本的Node.js。
2、按照Create React App安装指南创建一个新项目,步骤如下:
2.1 执行指令:npx create-react-app tic-tac-toe(项目名称)
2.2 执行指令:cd tic-tac-toe
2.3 执行指令:npm install
3、在工程项目中src/文件夹下创建index.css文件
4、保留原工程项目中src/文件夹下的index.js文件</pre>
1.1、新建index.css文件,如下
index.css
App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
1.2、修改index.js文件
index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App"; // 将导入的App组件删除
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
<App /> {/* 将引入的app组件删除 */}
</React.StrictMode>,
rootElement
);
1.3、创建三个新组件
Square:渲染单独的一个棋子组件
Borad:渲染棋盘组件
Game:渲染默认值的一个棋盘
Game.js
import React from 'react';
import Board from './Board';
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>
);
}
}
export default Game;
Board.js
import React from 'react';
import Square from './Square';
class Board extends React.Component {
// 返回一个组件,React元素
renderSquare(i) {
return <Square />;
}
render() {
const status = '下一个棋手: 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>
);
}
}
export default Board;
Square.js
import React from 'react';
class Square extends React.Component {
render() {
return (
<button className="square">
{/* TODO */}
</button>
);
}
}
export default Square;
上面新增加的三个组件,只是初始化的文件,在后面的开发流程中增加新的代码。
对index.js文件做如下修改
import React from "react";
import ReactDOM from "react-dom";
import './index.css';
import Game from './Game';
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
{/* 加载Game组件 */}
<Game /> {/* Game组件是组装的React元素 */}
</React.StrictMode>,
{/* rootElement 是整个项目唯一的一个挂载点,这对应的单页面工程只有一个页面 页面都是由数据控制 */}
rootElement
);
关于index.js文件中存在的知识点
1、引入react-dom包的作用: react-dom的 package 提供了可在应用顶层使用的 DOM(DOM-specific)方法,如果有需要,你可以把这些方法用于 React 模型以外的地方。不过一般情况下,大部分组件都不需要使用这个模块。
2、ReactDOM.render()就是react-dom包中的方法,它用来渲染一个React元素,并返回对该组件的引用。</pre>
ReactDOM.render()
会控制你传入容器节点里的内容。当首次调用时,容器节点里的所有 DOM 元素都会被替换,后续的调用则会使用 React 的 DOM 差分算法(DOM diffing algorithm)进行高效的更新。
对React做一个简单的总结
React 是一个声明式,高效且灵活的用于构建用户界面的 JavaScript 库。使用 React 可以将一些简短、独立的代码片段组合成复杂的 UI 界面,这些代码片段被称作“组件”。</pre>
下面是我们对新增加的这三个组件进行特定业务的编码
2、Square、Board等组件的开发
2.1、单纯测试props属性
在 Board 组件的 renderSquare
方法中,我们将代码改写成下面这样,传递一个名为 value
的 prop 到 Square 当中:
class Board extends React.Component {
renderSquare(i) {
// 在父组件中给子组件传递一个props属性value。
return <Square value={i} />;
}
}
修改 Square 组件中的 render
方法,把 {/* TODO */}
替换为 {this.props.value}
,以显示上文中传入的值:
class Square extends React.Component {
render() {
return (
<button className="square">
{/* 在子组件中直接取props对象的属性值,就可以得到父组件传递的数据 */}
{this.props.value}
</button>
);
}
}
经过上面代码的修改,页面修改前后展现的对比。如下图:
成功地把一个 prop 从父组件 Board “传递”给了子组件 Square。在 React 应用中,数据通过 props 的传递,从父组件流向子组件。
2.2、给组件添加交互功能
棋盘上的每一个格子对一个就是一个"Square"组件,给Square组件增加一个点击事件。当点击小格子时,页面会弹出一个信息提示框。
2.2.1、给Square组件增加点击事件
修改 Square.js文件
import React from 'react';
class Square extends React.Component {
render() {
return (
<button className="square" onClick={()=> {alert('棋盘中的小格子被触发')}}>
{/* 在子组件中直接取props对象的属性值,就可以得到父组件传递的数据 */}
{ this.props.value }
</button>
);
}
}
export default Square;
2.2.2、点击Square组件,格子显示“X”
我们希望 Square 组件可以“记住”它被点击过,然后用 “X” 来填充对应的方格。我们用 state 来实现所谓“记忆”的功能。
可以通过在 React 组件的构造函数中设置 this.state 来初始化 state。this.state 应该被视为一个组件的私有属性。我们在 this.state 中存储当前每个方格(Square)的值,并且在每次方格被点击的时候改变这个值。</pre>
state是组件内部的属性。组件本身是一个状态机,它可以在constructor中通过this.state直接定义它的值,然后根据这些值来渲染不同的UI。当state的值发生改变时,可通过this.setState方法让组件再次调用render方法,来渲染新的UI。
修改 Square.js文件
import React from 'react';
class Square extends React.Component {
constructor(props){
super(props);
// 初始化棋盘格子中要显示的值“value”
this.state = {
value: null
};
}
render() {
return (
// state是组件内部的“状态”属性,从外部不能改变,只能通过setState方法更新
<button className="square" onClick={()=> this.setState({value: 'X'}) }>
{ this.state.value }
</button>
);
}
}
export default Square;
现在再点击棋盘中的格子,在格子里就会展示“X”,如图所示:
3、增加游戏规则
井字棋游戏规则:两位选手,各执祺子为“X”或“O”。在九宫格内,任一选手的棋子在纵向、横向或斜方向上能排成一条直线,即为胜方。
剩下的功能就是需要交替在棋盘上放置“X”或“O”,并且判断出胜者。
Square为格子组件,Board为棋盘组件,Board组件是Square组件的父组件。棋子Square上的数据都通过state保存到Board上。
3.1、将棋子的数据保存到Board组件中state上
为Board组件添加构造函数,将Board组件的初始状态设置为长度为9的空数组。
class Board extends React.Component {
constructor(props){
super(props);
this.state = {
// fill() 方法用一个固定值填充一个数组中从起始索引到终止索引内的全部元素
squares: Array.fill(null)
}
}
// 返回一个组件,React元素
renderSquare(i) {
// 在父组件中给子组件传递一个props属性value。
return <Square value={this.state.squares[i]} />;
}
// ....省略其它源码
}
让我们再一次使用 prop 的传递机制。我们通过修改 Board 来指示每一个 Square 的当前值('X'
, 'O'
, 或者 null
)。我们在 Board 的构造函数中已经定义好了 squares
数组,这样,我们就可以通过修改 Board 的 renderSquare
方法来读取这些值了。
renderSquare(i) {
return <Square value={this.state.squares[i]} />;
}
这样,每个 Square 就都能接收到一个
value
prop 了,这个 prop 的值可以是'X'
、'O'
。
上面的代码,在Board组件中对Square组件进行初始化,用Board组件来维护那些被填充的格子。 我们需要想办法让 Square 去更新 Board 的 state。由于 state 对于每个组件来说是私有的,因此我们不能直接通过 Square 来更新 Board 的 state。
state是组件的内部状态,对外是私有,从外部是不能改变state的状态,只能在组件内部使用setState方法来更新
在Board组件中向Square组件传递一个props的函数,当Square组件被点击时就调用这个函数。
renderSquare(i) {
return (
<Square
value={this.state.squares[i]}
onClick={() => this.handleClick(i)}
/>
);
}
现在我们从 Board 组件向 Square 组件中传递两个 props 参数:value
和 onClick
。onClick
prop 是一个 Square 组件点击事件监听函数。接下来,我们需要修改 Square 的代码:
Square.js
class Square extends React.Component {
render() {
return (
<button
className="square"
onClick={() => this.props.onClick()}
>
{this.props.value}
</button>
);
}
}
1、将Square组件的render方法中的this.state.value替换为this.props.value;
2、将Square组件的render方法中的this.setState替换为this.props.onClic();
3、删掉Square组件中的构造函数constructor,因为该组件不再保存游戏的state;</pre>
Board组件和Square组件间的交互流程如下:
每一个 Square 被点击时,Board 提供的 onClick 函数就会触发。我们回顾一下这是怎么实现的:
1、向 DOM 内置元素 <button> 添加 onClick prop,让 React 开启对点击事件的监听。
2、当 button 被点击时,React 会调用 Square 组件的 render() 方法中的 onClick 事件处理函数。
3、事件处理函数触发了传入其中的 this.props.onClick() 方法。这个方法是由 Board 传递给 Square 的。
4、由于 Board 把 onClick={() => this.handleClick(i)} 传递给了 Square,所以当 Square 中的事件处理函数触发时,其实就是触发的 Board 当中的 this.handleClick(i) 方法。</pre>
给Board组件中增加handleClick()方法
import React from 'react';
import Square from './Square';
class Board extends React.Component {
constructor(props){
super(props);
this.state = {
squares: Array(9).fill(null)
};
}
HandleClick(i){
// .slice() 方法创建了 squares 数组的一个副本,而不是直接在现有的数组上进行修改。
const squares = this.state.squares.slice();
squares[i] = "X";
this.setState({
squares: squares
});
}
// 返回一个组件,React元素
renderSquare(i) {
// 在父组件中给子组件传递一个props属性value。
return <Square value={this.state.squares[i]} onClick={() => this.HandleClick(i)} />;
}
// 代码未贴完....
基于上面的组件交互,每当Board的state发生变化时,这些Square组件就会渲染一次。把所有Square组件中的state保存到Board组件中可以让我们在将来判断出游戏的胜利者。
3.2、选手轮流落子
现在棋盘上还只能展示一个棋子的标识(“X”),现在要把“O”棋子也加入到棋盘中。我们先把“X"棋子设置为先手棋。我们可以通过修改Board组件的构造函数中的初始state来设置默认的第一步棋子;在state中设置一个默认属性xIsNext来标识棋子是“X”或“O”。
代码如下: Board.js
class Board extends React.Component {
constructor(props) {
super(props);
this.state = {
squares: Array(9).fill(null),
xIsNext: true,
};
}
棋子没移动一步,也就是Square组件点击一次,Square组件的事件会触发父组件Board中的state的变化,也就是说页面会重新渲染一次。同样,我们也可以将xIsNext的值进行修改。xIsNext的值的变化来决定落入棋盘的棋子。
代码如下: Board.js
handleClick(i) {
const squares = this.state.squares.slice();
squares[i] = this.state.xIsNext ? 'X' : 'O';
this.setState({
squares: squares,
xIsNext: !this.state.xIsNext,
});
}
现在页面上的棋盘效果如图所示:
页面中对两棋手落子的判断开发如下
代码如下 Board.js
render() {
const status = '下一个棋手: ' + (this.state.xIsNext ? 'X' : 'O');
// 后面代码未贴。。。
4、判断游戏的胜负
在两选手的轮流落子开发完毕后,我们就要对游戏的胜负裁决进行业务逻辑编码。判断游戏胜出的编码如下:
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;
}
对游戏胜败的判断逻辑如下,基于棋子落入棋盘中能形成胜出的位置,以数据的形式写入到一个数组中,然后去判断两个棋子落入棋盘的位置。能否和数组中的数据匹配上。如果匹配上就返回胜出的棋子,如果匹配不上就返回一个null值。
代码如下 Board.js
render() {
// 根据判断函数来返回胜出的棋子
const winner = calculateWinner(this.state.squares);
// 声明变量status 来标识游戏当前的状态
let status;
if(winner) {
status = "胜出者为:" + winner;
}else {
status = '下一个棋手: ' + (this.state.xIsNext ? 'X' : 'O');
}
// 其它代码没有变化
最后,修改 handleClick
事件,当有玩家胜出时,或者某个 Square 已经被填充时,该函数不做任何处理直接返回。
代码如下 Board.js
HandleClick(i){
// .slice() 方法创建了 squares 数组的一个副本,而不是直接在现有的数组上进行修改。
const squares = this.state.squares.slice();
// 如果棋手胜出,或有棋子填充Square,则不作任何处理直接返回
if (calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? "X" : "O";
this.setState({
squares: squares,
xIsNext: !this.state.xIsNext
});
}
至此,井字游戏开发完毕,还有高级功能未在此添加,有待后续追加。效果如图所示: