React的井字过三关(1)

时间:2023-12-12 17:35:08

React的井字过三关(1)

本文系React官方教程的Tutorial: Intro To React的笔记。由笔者用ES5语法改写。

在本篇笔记中,尝试用React构建一个可交互的井字棋游戏。


开始

先布局:

status反映游戏信息。九宫格采用flex布局。右侧有一处游戏信息。

<div id="container">
<div class="game">
<div class="board">
<div class="status">Next player: X</div>
<div class="board-row">
<button class="square"></button>
<button class="square"></button>
<button class="square"></button>
</div>
<div class="board-row">
<button class="square"></button>
<button class="square"></button>
<button class="square"></button>
</div>
<div class="board-row">
<button class="square"></button>
<button class="square"></button>
<button class="square"></button>
</div>
</div>
<div class="info">
<div></div>
<ol></ol>
</div>
</div>
</div>

再把css写一下:

/*Simple CSS-Reset*/
*{
margin:0;
padding:0;
} body{
font: 30px "Century Gothic", Futura, sans-serif;
margin: 20px;
} ul{
list-style: none;
} a{
text-decoration: none;
} ol, ul{
padding-left: 30px;
} /*major*/
#container{
width: 500px;
margin:0 auto;
} .game{
display: flex;
flex-direction: row;
} .status{
margin-bottom: 20px;
text-align: center;
} .board-row:after{
clear: both;
content: "";
display: table;
} .square{
background: #fff;
border: 1px solid #999;
float: left;
font-size: 36px;
font-weight: bold;
line-height: 100px;
height: 100px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 100px;
} #container .square:focus {
background: #ddd;
outline: none;
} .info {
margin-left: 30px;
font-size:20px;
}

基本效果:

React的井字过三关(1)

接下来只需要考虑javascript实现就可以了。

整个应用分为三个组件:

  • Square(方块)
  • Board(九宫格面板)
  • Game(整个游戏)

接下来就是把这个结构用React写出来。

var Game=React.createClass({
render:function(){
return (
<div className="game">
<Board />
<div className="info">
<div></div>
<ol></ol>
</div>
</div>
);
}
}); var Board=React.createClass({
renderSquare:function(i){
return <Square />
},
render:function(){
return (
<div clasName="board">
<div className="status">Next player: X</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>
);
}
}); var Square=React.createClass({
render:function(){
return (
<button className="square"></button>
);
}
}); ReactDOM.render(
<Game />,
document.getElementById('container')
);

通过props传递数据

现在尝试从Board组件中传递一些数据给Square组件:

var Board=React.createClass({
renderSquare:function(i){
return <Square value={i} />
},
...

Square内部:

var Square=React.createClass({
render:function(){
return (
<button className="square">{this.props.value}</button>
);
}
});

React的井字过三关(1)

数字就被打上去了。


交互的组件

当点击方块时,打出“X”。

先把Square设置初始的state.value为null。当点击小方框,触发一个changeState方法。把当下的State改为X.

然后把渲染方法改为:

var Square=React.createClass({
getInitialState:function(){
return {
value:null
}
},
changeState:function(){
this.setState({
value:'X'
})
},
render:function(){
return (
<button className="square" onClick={this.changeState}>{this.state.value}</button>
);
}
});

基本效果:

React的井字过三关(1)

无论何时,this.setState只要被调用,组件将马上更新并根据状态渲染它的后代。


通过开发者工具看组件树

插播一个广告:React为开发者提供了适用于火狐及Chrome的扩展工具。有了它可以很方便看到你构建的组件库。

当然Google商店现在得FQ才行。在安装之后,勾选“允许访问本地网址”,便可激活。

React的井字过三关(1)


解除状态

现在,井字棋已经有了个雏形。但是State被锁定在每个单独小的方块中。

为了让游戏能够正常进行,还需要做一些事情:

  • 判断胜负
  • XO的交替

为了判断胜负,我们需要将9个方块的value放到一块。

你可能会想,为什么Board组件为什么不查询每个组件的状态并进行计算?——这在理论上是可行的。但是React不鼓励这样做——这样导致代码难读,脆弱,变得难以重构。

相反,最好的解决方案就是把state放到Board组件上,而不是每个方块里。Board组件可以告诉每个小方块要显示什么——通过之前加索引值的方法。

当你先从各种各样的子代中把它们的数据统一起来,那就把state放到它们的父级组件上吧!然后通过props把数据全部传下去。子组件就会根据这些props同步地展示内容。

在React里,组件做不下去的时候,把state向上放是很常见的处理办法。正好借此机会来试一下:设置Board组件的状态——为一个9个元素的数组(全部是null),以此对应九个方块:

var Board=React.createClass({
getInitialState:function(){
return (
squares:Array(9).fill(null),
)
},
...

到了后期,这个状态可以指代一个棋局,比如这样:

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

然后把这个状态数组分配到每个小方块中(还记得renderSquare方法吗?):

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

再次把Square的组件改为{this.props.value}。现在需要改变点击事件的方法。当点击小方块,通过回调props传入到Square中,直接把Board组件state相应的值给改了:

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

这里的onClick不是一个事件。而是方块组件的一个props。现在方块组件Square接受到这个props方法,就把它绑定到真正的onClick上面:

<button className="square" onClick={() => this.props.onClick()}>{this.props.value}</button>

补白:ES6的箭头函数

x => x * x

以上的意思是:

function (x) {
return x * x;
}

箭头函数相当于匿名函数,并且简化了函数定义。

React在此引入箭头函数处理的是this的问题。

如果不用箭头函数写是:

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

选择自己喜欢的就好。

现在根据就差定义面板组件中handleClick函数了。显然点击一下就刷新Board的状态。以下两种方法都可以。

handleClick:function(i){
this.setState(function(prev){
//console.log(prev.squares)
var arr=prev.squares;
arr.squares[i]='X';
return {
squares:prev.arr
};
})
},
handleClick:function(i){
var squares=this.state.squares.slice();
squares[i]='X';
this.setState({
squares:squares
})
},

把状态往上放,使得每个小方框不再拥有自己的状态。面板组件会分配props给他们。只要状态改变,下面的组件就会更新。


为什么不突变的数据很重要(Why Immutability Is Important)

在handleClick里面,用了一个slice()方法把原来的数组克隆出来。有效防止了数组被破坏。

“不突变的对象”这是一个重要的概念,值得React文档重开一章来强调。

有两种改变数据的办法,一个是直接改变(突变,mutate),一种是存到一个变量里面。二者的结果是相同,但是后者有额外的好处。

跟踪变化

查找一个突变对象(mutate)的数据变化是非常麻烦的。 这就要求比较当前对象之前的副本,还要遍历整个对象树,比较每个变量和价值。 这个过程变得越来越复杂。

而确定一个不突变的对象的数据变化相当容易。 如果这个对象和之前相比不同,那么数据就已改变了。就这么简单。

决定React何时重新渲染

最大的好处:在构建简单纯粹的组件时, 因为不突变的数据可以更容易地确定是否更改了,也有助于确定一个组件是否需要被重新渲染。


功能组件

回到之前的项目,现在你不再需要Square组件中的构造函数了。 事实上,对于一个简单而无状态的功能性组件类型,比如Square,一个渲染方法足够了,它只干一件事:根据上面传下来的props来决定渲染什么,怎么渲染,完全没必要再开一个扩展组件。

var Square=React.createClass({
render:function(){
return (
<button className="square" onClick={() => this.props.onClick()}>{this.props.value}</button>
);
}
});

可以说这个square组件做到这里就完结了。不用再理他了。


决定次序

目前这个App最大的问题就是整个游戏竟然只有X玩家,简直不能忍,还能不能好好的OOXX了?

对这个App来说谁先走肯定是状态。这个状态决定handleClick渲染的是X还是O:

首先,我们定义X玩家先走。

var Board=React.createClass({
getInitialState:function(){
return {
squares:Array(9).fill(null),
turnToX:true//为ture时轮到X走
}
},
...

每点击一次,将造成这个开关的轮换。

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

现在棋是走起来了。


判断胜负

鉴于井字棋很简单,获胜的最终画面只有8个。所以判断胜负用穷举法就可以了。也就是说,当squares数组出现8个情况,就宣告胜者并终止游戏。这里妨自己写写判断胜负的引擎:

function judgeWinner(square){
var win=[
[0,1,2],
[0,3,6],
[0,4,8],
[1,4,7],
[2,5,8],
[2,4,6],
[3,4,5],
[6,7,8]
];
for(var i=0;i<win.length;i++){
var winCase=win[i];
if(squares[winCase[0]]==squares[winCase[1]]&&squares[winCase[1]]==squares[winCase[2]]){//三子一线
return squares(winCase[0]);//返回胜利一方的标识
}
}
return false;
}

这个方法在Board渲染前执行就可以了。

...
render:function(){
var winner=judgeWinner(this.state.squares);//每次渲染都判断获胜者
var status='';
if(winner!==null){
status='获胜方是:'+winner
}else{
var player=this.state.turnToX?'X':'O';
status='轮到'+player+'走'
}
return (
<div clasName="board">
<div className="status">{status}</div>
...

好啦!现在已经把这游戏给做出来了。你可以在电脑上自己跟自己下井字棋,一个React新手,走到这一步已是winner。来看看效果吧~

React的井字过三关(1)

什么,真要完了吗?还有一半的内容。


储存历史步数

现在我们尝试做一个历史步数管理。方便你悔棋或复盘(井字棋还得复盘?!)

每走一步,就刷新一次状态,那就把这个状态存到一个数组对象(比如history)中。调用这个历史对象的是Game组件,要做这一步,就得把状态进一步往上放(满满的都是套路啊)。

在Game当中设置状态也是一个大工程。但是基本上和在Board里写状态差不多。

  • 首先,用一个history状态存放每一步生成的squares数组。turnToX也提到Game组件中。
  • 找出最新的状态history[history.length-1]lastHistory
  • 在handleClick方法中添加落子判断:胜负已分或是已经落子则不响应。
  • 在Game渲染函数中写好status,然后放到指定位置。
  • 把handleClick函数传到Board组件去!
var Game=React.createClass({
getInitialState:function(){
return {
history:[
{squares:Array(9).fill(null)}
],
turnToX:true
}
},
handleClick:function(i){//这里的i是棋盘的点位。
var history=this.state.history;
var lastHistory=history[history.length-1];
var winner=judgeWinner(lastHistory.squares);
var squares=lastHistory.squares.slice(); if(winner||squares[i]){//如果胜负已分,或者该位置已经落子,则不会响应!
return false;
}
squares[i]=this.state.turnToX?'X':'O';//决定该位置是X还是O this.setState({
history:history.concat([{squares:squares}]),
turnToX:!this.state.turnToX
});//然后把修改后的squares桥接到状态中去
},
render:function(){
var history=this.state.history;
var lastHistory=history[history.length-1];
var winner=judgeWinner(lastHistory.squares); var status='';
if(winner){
status='获胜方是'+winner;
}else{
var player=this.state.turnToX?'X':'O';
status='轮到'+player+'走';
} return (
<div className="game">
<Board lastHistory={lastHistory.squares} onClick={(i)=>this.handleClick(i)} />
<div className="info">
<div>{status}</div>
<ol></ol>
</div>
</div>
);
}
});

那么Board组件里面的各种状态完全不需要了,只保留render和renderSquare函数足矣。

var Board=React.createClass({
renderSquare:function(i){
return <Square value={this.props.lastHistory[i]} onClick={() => this.props.onClick(i)} />
},
render:function(){
return (
<div clasName="board">
<div className="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>
);
}
});

展示历史步数

在之前入门学习中已经有了深刻体会:渲染一串React元素,最好用的方法是数组。

恰好我们的history也是一个数组。而且Game的架构设计中还有一个ol——那么会做了吧?

				...
var arr=[];
var _this=this;
history.map(function(step,move){
var content='';
if(move){
content='Move#'+move;
}else{
content='游戏开始~';
}
arr.push(<li key={move}><a onClick={()=>_this.jumpTo(move)} href="javascript:;">{content}</a></li>);
});
...

在这个a标记里,还加了个this.jumpToMove。当点击之后j将把该索引值的旧状态作为最后一个状态。

好了,现在话分两头,插播一段关于Key值的论述。


论Key值的重要性

任何一个数组,都必须有key值。

当你渲染一串组件,React总是会把一些信息安置到每个单独组件里头去。比如你渲染一串涉及state的组件时,这个state是得存起来的。不管你如何实现你的组件。React都会在背后存一个参照。

你对这些组件增删改查。React通过这些参照信息得知哪些数据需要发生变动。

...
<li>苏三说:xxx</li>
<li>李四说:ooo</li>
..

如上你想修改li的内容,React无法判断哪个li是苏三的,哪个li是李四的。这时就要一个key值(字符串)。对于同辈元素,key是唯一的。

<li key="苏三">苏三说:xxx</li>
<li key="李四">李四说:OOO</li>

key值是React保留的一个特殊属性,它拥有比ref更先进的特性。当创建一个元素,React直接把一个key值传到被return的元素中去。尽管看起来也是props之一,但是this.props.key这样的查询是无效的。

重新渲染一串组件,React通过key来查找需要渲染的匹配元素。可以这么说,key被添加到数组,那这个组件就创建了;key被移除,组件就被销毁。key就是每个组件的身份标志,在重新渲染的时候就可以保持状态。倘若你改变一个组件的key,它将完全销毁,并重新创建一个新的状态。

因此:强制要求你插入到页面的数组元素有key,如果你不方便插入,那么一定是你的设计出了问题。


来场说走就走的时间旅行

由于添加了悔棋这一设定,而悔棋是不可预测的。所以井字棋组件初始需要多一个状态:stepNumber:0。另一方面,悔棋导致turnToX需要重新设定。

jumpTo:function(step){
this.setState({
stepNumber:step,
turnToX:step%2?false:true
})
},

留意到this.state.stepNumber其实可以取代history.length-1——那就在render方法和handleClick方法中全部把它替换了。

最后一个问题还是出在handleClick,虽然可以回退,但是状态最终不能实时更新。用history=history.slice(0,this.state.stepNumber+1);把它剪切一下就行了。

那么全部功能就完成了。嗯,应该是完成了。

var Game=React.createClass({
getInitialState:function(){
return {
history:[
{squares:Array(9).fill(null)}
],
turnToX:true,
stepNumber:0
}
},
handleClick:function(i){
var history=this.state.history;
history=history.slice(0,this.state.stepNumber+1);
var lastHistory=history[this.state.stepNumber];
var winner=judgeWinner(lastHistory.squares);
var squares=lastHistory.squares.slice(); if(winner||squares[i]){
return false;
}
squares[i]=this.state.turnToX?'X':'O'; this.setState({
history:history.concat([{squares:squares}]),
turnToX:!this.state.turnToX,
stepNumber:history.length
});
console.log(this.state.history)
},
jumpTo:function(step){
this.setState({
stepNumber:step,
turnToX:step%2?false:true
});
},
render:function(){
var history=this.state.history;
var lastHistory=history[this.state.stepNumber];
var winner=judgeWinner(lastHistory.squares); var status='';
if(winner){
status='获胜方是'+winner;
}else{
var player=this.state.turnToX?'X':'O';
status='轮到'+player+'走';
} var arr=[];
var _this=this;
history.map(function(step,move){
var content='';
if(move){
content='Move#'+move;
}else{
content='游戏开始~';
}
arr.push(<li key={move}><a onClick={()=>_this.jumpTo(move)} href="javascript:;">{content}</a></li>);
}); return (
<div className="game">
<Board lastHistory={lastHistory.squares} onClick={(i)=>this.handleClick(i)} />
<div className="info">
<div>{status}</div>
<ol>{arr}</ol>
</div>
</div>
);
}
}); var Board=React.createClass({
renderSquare:function(i){
return <Square value={this.props.lastHistory[i]} onClick={() => this.props.onClick(i)} />
},
render:function(){
return (
<div clasName="board">
<div className="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>
);
}
}); var Square=React.createClass({
render:function(){
return (
<button className="square" onClick={() => this.props.onClick()}>{this.props.value}</button>
);
}
}); ReactDOM.render(
<Game />,
document.getElementById('container')
);
/**********************************/
function judgeWinner(squares){
var win=[
[0,1,2],
[0,3,6],
[0,4,8],
[1,4,7],
[2,5,8],
[2,4,6],
[3,4,5],
[6,7,8]
]; for(var i=0;i<win.length;i++){
var winCase=win[i];
if(squares[winCase[0]]==squares[winCase[1]]&&squares[winCase[1]]==squares[winCase[2]]){//三子一线
return squares[winCase[0]];//返回胜利一方的标识
}
}
return null;
}

效果如下:

React的井字过三关(1)


结束的升华

到目前为止,实现了一个井字棋游戏,有了以下基本功能

  • 你可以自己跟自己玩井字过三关
  • 判断谁赢了
  • 记录棋局
  • 还允许悔棋

挺好,挺好。

但是,你还可以改进:

  • 通过(X,Y)来取代数字坐标
  • 对右方的被选中的当前记录进行加粗显示
  • 用两个循环重写Board组件,替代掉原来生硬的代码结构
  • 对你的历史记录进行升降序排列
  • 高亮显示获胜的结果
  • 加个人工智能什么的。

这些内容本系列笔记的第2第3篇。