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;
}
基本效果:
接下来只需要考虑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>
);
}
});
数字就被打上去了。
交互的组件
当点击方块时,打出“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>
);
}
});
基本效果:
无论何时,this.setState
只要被调用,组件将马上更新并根据状态渲染它的后代。
通过开发者工具看组件树
插播一个广告:React为开发者提供了适用于火狐及Chrome的扩展工具。有了它可以很方便看到你构建的组件库。
当然Google商店现在得FQ才行。在安装之后,勾选“允许访问本地网址”,便可激活。
解除状态
现在,井字棋已经有了个雏形。但是State被锁定在每个单独小的方块中。
为了让游戏能够正常进行,还需要做一些事情:
- 判断胜负
-
X
和O
的交替
为了判断胜负,我们需要将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。来看看效果吧~
什么,真要完了吗?还有一半的内容。
储存历史步数
现在我们尝试做一个历史步数管理。方便你悔棋或复盘(井字棋还得复盘?!)
每走一步,就刷新一次状态,那就把这个状态存到一个数组对象(比如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;
}
效果如下:
结束的升华
到目前为止,实现了一个井字棋游戏,有了以下基本功能
- 你可以自己跟自己玩井字过三关
- 判断谁赢了
- 记录棋局
- 还允许悔棋
挺好,挺好。
但是,你还可以改进:
- 通过(X,Y)来取代数字坐标
- 对右方的被选中的当前记录进行加粗显示
- 用两个循环重写Board组件,替代掉原来生硬的代码结构
- 对你的历史记录进行升降序排列
- 高亮显示获胜的结果
- 加个人工智能什么的。
这些内容本系列笔记的第2第3篇。