React的井字過三關(1) 本文系React官方教程的Tutorial: Intro To React的筆記。由筆者用ES5語法改寫。 在本篇筆記中,嘗試用React構建一個 可交互的 井字棋游戲。 開始 先佈局: status反映游戲信息。九宮格採用flex佈局。右側有一處游戲信息。 再把css ...
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)}