一步一步來吧!
簡述
在做出五子棋以前,可以先從比較簡單的圈圈叉叉開始,所以這一篇是參考 React 官方教學 做的筆記,不然官方的敘述實在看得我頭很痛rrrr。
分析結構
第一步先從結構分析開始,假設我們有以下 Component:
Game
整個遊戲
Board
棋盤
Square
每個格子
那結構大概就會長這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| function Square() { return ( <button>格子</button> ); }
function Board() { return ( <div> <div className="board-row"> <Square /> <Square /> <Square /> </div> <div className="board-row"> <Square /> <Square /> <Square /> </div> <div className="board-row"> <Square /> <Square /> <Square /> </div> </div> ); }
export default function Game() { return <Board />; }
|
這邊為了可讀性先把 props 都拿掉了,後面會在一步一步解釋。
規劃 state
在圈圈叉叉這遊戲裡面會需要:
所以會這樣子做:
1 2 3 4
| function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); const [xIsNext, setXIsNext] = useState(true); }
|
因為是九宮格,所以會用這樣的陣列來存:
1 2 3 4 5
| const squares = [ null, null, null, null, null, null, null, null, null, ]
|
每當我們想改變棋盤的時候,就會像這樣:
1 2 3 4 5 6
| const squares = [ "O", null, null, null, null, null, null, null, null, ]
|
接著:
1 2 3 4 5 6
| const squares = [ "O", null, null, null, "X", null, null, null, null, ]
|
渲染出 Square
在有了這些基本規劃後,我們先來處理畫面的問題。
首先要把 squares
當作 props 給 <Square />
用,讓它根據 state 來顯示目前棋盤的樣子。
所以 <Square />
的部分會寫成這樣:
1 2 3 4 5 6
| function Square({ value }) { return ( <button className="square">{value}</button> ); }
|
而 <Board />
的部分則是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); const [xIsNext, setXIsNext] = useState(true);
const renderSquare = (i) => { return <Square value={squares[i]} />; };
return ( <div> <div className="status">{status}</div> <div className="board-row"> {renderSquare(0)} {renderSquare(1)} {renderSquare(2)} </div> <div className="board-row"> {renderSquare(3)} {renderSquare(4)} {renderSquare(5)} </div> <div className="board-row"> {renderSquare(6)} {renderSquare(7)} {renderSquare(8)} </div> </div> ); }
|
這邊寫成 renderSquare
是為了讓 code 比較簡潔,不然原本得這樣寫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| return ( <div> <div className="status">{status}</div> <div className="board-row"> {<Square value={squares[0]} />} {<Square value={squares[1]} />} {<Square value={squares[2]} />} </div> <div className="board-row"> {<Square value={squares[3]} />} {<Square value={squares[4]} />} {<Square value={squares[5]} />} </div> <div className="board-row"> {<Square value={squares[6]} />} {<Square value={squares[7]} />} {<Square value={squares[8]} />} </div> </div> );
|
做到這一步以後,就能確保 <Square />
會根據 props 來顯示值,可以試著先把 useState
的初始值設為 "A"
,就會看到這個結果:
這邊沒問題的話就可以進到下個步驟了。
更新棋盤的 State
接著,我們希望當 <Square />
的按鈕 click
時去修改棋盤的 state,讓它顯示 O 或 X,也就是說 state 必須變成這樣:
1 2 3 4 5
| const newSquares = [ null, null, null, null, null, null, null, null, "O", ]
|
不過我們都知道子層沒辦法直接去改放在父層的 state,因此這邊一樣要透過 props 來傳入修改 state 的 function 進去。
這邊先在 <Board />
寫好用來變更 state 的 function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); const [xIsNext, setXIsNext] = useState(true);
const handleChangeSquaresState = (i) => { const newSquares = squares.slice(); newSquares[i] = "O"; setSquares(newSquares); }; }
|
如果你偏好 map
的話也可以這樣寫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); const [xIsNext, setXIsNext] = useState(true);
const handleChangeSquaresState = (i) => { setSquares( squares.map((item, index) => { if (i === index) return "O"; return item; }) ); }; }
|
接著就可以把寫好的東西傳給 <Square />
了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); const [xIsNext, setXIsNext] = useState(true);
const handleChangeSquaresState = (i) => { const newSquares = squares.slice(); newSquares[i] = "O"; setSquares(newSquares); }; const renderSquare = (i) => { return ( <Square value={squares[i]} // 傳進去 handleChangeSquaresState={() => handleChangeSquaresState(i)} /> ); }; }
|
這邊是讓我卡比較久的地方,因為有用到「Clousre」的概念,所以這邊多做個解釋。
首先,我們在渲染 <Square />
時是這樣做的:
1 2 3 4 5 6 7 8 9 10 11
| return ( {renderSquare(0)} {renderSquare(1)} {renderSquare(2)} {renderSquare(3)} {renderSquare(4)} {renderSquare(5)} {renderSquare(6)} {renderSquare(7)} {renderSquare(8)} );
|
所以經過 renderSquare
處理後就等於:
1 2 3 4 5 6 7 8 9 10 11 12 13
| <Square value={squares[0]} handleChangeSquaresState={() => handleChangeSquaresState(0)} />
<Square value={squares[1]} // 傳進去 handleChangeSquaresState={() => handleChangeSquaresState(1)} />
...
|
意思說我們在父層就先透過 Closure 把 i
的值給傳入了,因此 <Square />
才能直接接受這個 props,並當成 Even handler:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function Square({ value, handleChangeSquaresState }) { return ( <button className="square" // 這邊 onClick 的值就等於: // () => handleChangeSquaresState(1) // () => handleChangeSquaresState(2) // 以此類推 onClick={handleChangeSquaresState} > {value} </button> ); }
|
這就是為什麼就算 <Square />
不傳任何參數父層也知道要去改陣列的哪一個值。(這個很重要,不懂的話就多想想看)
做到目前這裡,應該會有這樣的結果:
根據 state 顯示 O 或 X
接著要處理 O 跟 X 輪流顯示的問題,所以會用到一開始提的另一個 state,xIsNext
。
這邊假設由 X 先下棋,所以初始值設為 true
:
1
| const [xIsNext, setXIsNext] = useState(true);
|
接著調整一下 handleChangeSquaresState
,讓他根據目前 xIsNext
來決定資料要長怎樣:
1 2 3 4 5 6 7 8
| const handleChangeSquaresState = (i) => { const newSquares = squares.slice(); newSquares[i] = xIsNext ? "X" : "O"; setSquares(newSquares); setXIsNext(!xIsNext); };
|
做到這邊就可以正確顯示 O 或 X 了,不過要注意一個問題,就是重複點擊:
所以這部分得要多做一層處理:
1 2 3 4 5 6 7 8
| const handleChangeSquaresState = (i) => { if (squares[i]) return; const newSquares = squares.slice(); newSquares[i] = xIsNext ? "X" : "O"; setSquares(newSquares); setXIsNext(!xIsNext); };
|
這樣就解決剛剛的 bug 了。
顯示目前的 player
接著來加上一段顯示目前 player 的訊息,在 <Board />
裡面加上這段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); const [xIsNext, setXIsNext] = useState(true); const status = `Next player is ${xIsNext ? "X" : "O"}`;
return ( <div> // 加上 DOM 元素 <div className="status">{status}</div> <div className="board-row"> {renderSquare(0)} {renderSquare(1)} {renderSquare(2)} </div> <div className="board-row"> {renderSquare(3)} {renderSquare(4)} {renderSquare(5)} </div> <div className="board-row"> {renderSquare(6)} {renderSquare(7)} {renderSquare(8)} </div> </div> ); }
|
做到這邊後會長這樣:
判斷輸贏
接下來是最後一步,判斷誰輸誰贏。這邊要注意一下判斷的時機點該放在哪裡?
其實會有兩個地方:
- 第一個是 state 更新完畢時,畫面要根據 state 顯示贏家
- 第二個是在改變 state 以前,要先判斷是不是已經有贏家了?
我們先來做第一個部分,首先先處理好判斷輸贏的 function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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; }
|
接著要在 <Board />
每一次 re-render 後根據計算結果顯示對應的訊息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); const [xIsNext, setXIsNext] = useState(true); const winner = calculateWinner(squares); let status; if (winner) { status = `Winner is ${winner}`; } else if (!winner && !squares.includes(null)) { status = `draw`; } else { status = `Next player is ${xIsNext ? "X" : "O"}`; } }
|
這邊做完後,會像這樣
確實能判斷輸贏了,不過同時也注意到會有個 bug,就是已經贏了卻還能繼續下棋,所以一開始才特別說其實有兩個時機點。
接著來處理第二個地方,在改變 state 以前的判斷。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); const [xIsNext, setXIsNext] = useState(true);
const handleChangeSquaresState = (i) => { const winner = calculateWinner(squares); if (winner || squares[i]) return; const newSquares = squares.slice(); newSquares[i] = xIsNext ? "X" : "O"; setSquares(newSquares); setXIsNext(!xIsNext); }; }
|
這樣子就能在正確的時機點停止遊戲了。
做到這邊,這個遊戲就已經完成囉,恭喜恭喜!
最後附上完整的原始碼:Codesandbox