在本教學中,你將建構一個小型井字遊戲。本教學不假設你具備任何 React 知識。你在教學中學到的技巧是建構任何 React 應用程式的基礎,充分理解它將讓你深入了解 React。
本教學分為幾個部分:
- **教學準備**將提供你一個**起點**來 mengikuti 教學。
- **概觀**將教你 React 的**基礎知識**:元件、props 和狀態。
- **完成遊戲**將教你 React 開發中最**常用的技巧**。
- **新增時光旅行**將讓你**更深入地了解** React 的獨特優勢。
你正在建構什麼?
在本教學中,你將使用 React 建構一個互動式井字遊戲。
你可以在這裡看到完成後的樣子:
import { useState } from 'react'; function Square({ value, onSquareClick }) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } function Board({ xIsNext, squares, onPlay }) { function handleClick(i) { if (calculateWinner(squares) || squares[i]) { return; } const nextSquares = squares.slice(); if (xIsNext) { nextSquares[i] = 'X'; } else { nextSquares[i] = 'O'; } onPlay(nextSquares); } const winner = calculateWinner(squares); let status; if (winner) { status = 'Winner: ' + winner; } else { status = 'Next player: ' + (xIsNext ? 'X' : 'O'); } return ( <> <div className="status">{status}</div> <div className="board-row"> <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> <Square value={squares[1]} onSquareClick={() => handleClick(1)} /> <Square value={squares[2]} onSquareClick={() => handleClick(2)} /> </div> <div className="board-row"> <Square value={squares[3]} onSquareClick={() => handleClick(3)} /> <Square value={squares[4]} onSquareClick={() => handleClick(4)} /> <Square value={squares[5]} onSquareClick={() => handleClick(5)} /> </div> <div className="board-row"> <Square value={squares[6]} onSquareClick={() => handleClick(6)} /> <Square value={squares[7]} onSquareClick={() => handleClick(7)} /> <Square value={squares[8]} onSquareClick={() => handleClick(8)} /> </div> </> ); } export default function Game() { const [history, setHistory] = useState([Array(9).fill(null)]); const [currentMove, setCurrentMove] = useState(0); const xIsNext = currentMove % 2 === 0; const currentSquares = history[currentMove]; function handlePlay(nextSquares) { const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; setHistory(nextHistory); setCurrentMove(nextHistory.length - 1); } function jumpTo(nextMove) { setCurrentMove(nextMove); } const moves = history.map((squares, move) => { let description; if (move > 0) { description = 'Go to move #' + move; } else { description = 'Go to game start'; } return ( <li key={move}> <button onClick={() => jumpTo(move)}>{description}</button> </li> ); }); return ( <div className="game"> <div className="game-board"> <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} /> </div> <div className="game-info"> <ol>{moves}</ol> </div> </div> ); } 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; }
如果程式碼對你來說還沒有意義,或者你對程式碼的語法不熟悉,別擔心!本教學的目標是幫助你理解 React 及其語法。
我們建議你在繼續學習本教學之前,先查看上面的井字遊戲。你會注意到其中一個功能是遊戲棋盤右側有一個編號列表。此列表提供了遊戲中所有已發生移動的歷史記錄,並會隨著遊戲進行而更新。
玩過完成的井字遊戲後,請繼續向下捲動。在本教學中,你將從一個更簡單的範本開始。我們的下一步是讓你做好準備,開始建構遊戲。
教學準備
在下面的即時程式碼編輯器中,點擊右上角的**Fork**,使用 CodeSandbox 網站在新分籤中開啟編輯器。CodeSandbox 可讓你瀏覽器中編寫程式碼,並預覽使用者將如何看到你建立的應用程式。新的分頁應顯示一個空的正方形和本教學的起始程式碼。
export default function Square() { return <button className="square">X</button>; }
概觀
現在你已經準備好了,讓我們來了解一下 React!
檢查起始程式碼
在 CodeSandbox 中,你將看到三個主要區塊:

- **檔案**區塊,其中包含 `App.js`、`index.js`、`styles.css` 等檔案列表,以及一個名為 `public` 的資料夾。
- **程式碼編輯器**,你將在其中看到所選檔案的原始碼。
- **瀏覽器**區塊,你將在其中看到你撰寫的程式碼將如何顯示。
在檔案區段中,應該選取`App.js`檔案。該檔案在程式碼編輯器中的內容應為
export default function Square() {
return <button className="square">X</button>;
}
瀏覽器區段應顯示一個帶有 X 的正方形,如下所示

現在讓我們看一下起始程式碼中的檔案。
`App.js`
`App.js`中的程式碼建立了一個*元件*。在 React 中,元件是一段可重複使用的程式碼,代表使用者介面的一部分。元件用於渲染、管理和更新應用程式中的 UI 元素。讓我們逐行查看元件,看看發生了什麼事
export default function Square() {
return <button className="square">X</button>;
}
第一行定義了一個名為`Square`的函式。 JavaScript 關鍵字 `export` 使此函式可以在此檔案外部存取。關鍵字 `default` 告訴其他使用您的程式碼的檔案,它是您檔案中的主要函式。
export default function Square() {
return <button className="square">X</button>;
}
第二行返回一個按鈕。 JavaScript 關鍵字 `return` 表示後面的任何內容都將作為值返回給函式的呼叫者。`<button>` 是一個*JSX 元素*。 JSX 元素是 JavaScript 程式碼和 HTML 標籤的組合,描述您想要顯示的內容。`className="square"` 是一個按鈕屬性或*prop*,它告訴 CSS 如何設定按鈕的樣式。`X` 是顯示在按鈕內的文字,`</button>` 關閉 JSX 元素,表示任何後續內容都不應放在按鈕內。
`styles.css`
點擊 CodeSandbox 的*檔案*區段中標記為 `styles.css` 的檔案。此檔案定義 React 應用程式的樣式。前兩個*CSS 選擇器*(`*` 和 `body`)定義應用程式大部分的樣式,而 `.square` 選擇器定義 `className` 屬性設定為 `square` 的任何元件的樣式。在您的程式碼中,這將與 `App.js` 檔案中 Square 元件的按鈕相符。
`index.js`
點擊 CodeSandbox 的*檔案*區段中標記為 `index.js` 的檔案。您在本教學課程中不會編輯此檔案,但它是您在 `App.js` 檔案中建立的元件與網路瀏覽器之間的橋樑。
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import App from './App';
第 1-5 行將所有必要的片段組合在一起
- React
- React 與網路瀏覽器通訊的程式庫(React DOM)
- 元件的樣式
- 您在 `App.js` 中建立的元件。
檔案的其餘部分將所有片段組合在一起,並將最終產品注入 `public` 資料夾中的 `index.html`。
建構棋盤
讓我們回到 `App.js`。這是您在本教學課程其餘部分將花時間的地方。
目前棋盤只有一個方格,但您需要九個!如果您只是嘗試複製貼上您的方格來製作兩個方格,如下所示
export default function Square() {
return <button className="square">X</button><button className="square">X</button>;
}
您將收到此錯誤
React 元件需要返回單個 JSX 元素,而不是多個相鄰的 JSX 元素,例如兩個按鈕。要解決此問題,您可以使用*片段*(`<>` 和 `</>`)來包裝多個相鄰的 JSX 元素,如下所示
export default function Square() {
return (
<>
<button className="square">X</button>
<button className="square">X</button>
</>
);
}
現在您應該看到

太好了!現在您只需要複製貼上幾次即可新增九個方格,然後…

糟糕!方格都在一條線上,而不是像我們的棋盤所需的網格狀。要解決此問題,您需要使用 `div` 將方格分組成列,並新增一些 CSS 類別。同時,您將為每個方格指定一個編號,以確保您知道每個方格的顯示位置。
在 `App.js` 檔案中,將 `Square` 元件更新為如下所示
export default function Square() {
return (
<>
<div className="board-row">
<button className="square">1</button>
<button className="square">2</button>
<button className="square">3</button>
</div>
<div className="board-row">
<button className="square">4</button>
<button className="square">5</button>
<button className="square">6</button>
</div>
<div className="board-row">
<button className="square">7</button>
<button className="square">8</button>
<button className="square">9</button>
</div>
</>
);
}
在 styles.css
中定義的 CSS 樣式會設定具有 className
為 board-row
的 div。現在您已將組件分組成具有樣式的 div
的列,您就有了井字遊戲棋盤。

但您現在遇到一個問題。您名為 Square
的組件實際上不再是正方形了。讓我們通過將名稱更改為 Board
來解決這個問題。
export default function Board() {
//...
}
此時您的程式碼應該看起來像這樣
export default function Board() { return ( <> <div className="board-row"> <button className="square">1</button> <button className="square">2</button> <button className="square">3</button> </div> <div className="board-row"> <button className="square">4</button> <button className="square">5</button> <button className="square">6</button> </div> <div className="board-row"> <button className="square">7</button> <button className="square">8</button> <button className="square">9</button> </div> </> ); }
透過屬性傳遞資料
接下來,您需要在使用者點擊方塊時將方塊的值從空更改為「X」。以您目前構建棋盤的方式,您需要複製貼上更新方塊的程式碼九次(每個方塊一次)!React 的組件架構允許您創建可重複使用的組件,以避免混亂、重複的程式碼,而不是複製貼上。
首先,您要將定義第一個方塊的行(<button className="square">1</button>
)從 Board
組件複製到新的 Square
組件中。
function Square() {
return <button className="square">1</button>;
}
export default function Board() {
// ...
}
然後,您將更新 Board 組件以使用 JSX 語法渲染該 Square
組件。
// ...
export default function Board() {
return (
<>
<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
不同,您自己的組件 Board
和 Square
必須以大寫字母開頭。
讓我們來看看

糟糕!您丟失了之前的編號方塊。現在每個方塊都顯示「1」。要解決此問題,您將使用*屬性*將每個方塊應具有的值從父組件(Board
)傳遞給其子組件(Square
)。
更新 Square
組件以讀取您將從 Board
傳遞的 value
屬性。
function Square({ value }) {
return <button className="square">1</button>;
}
function Square({ value })
表示可以將名為 value
的屬性傳遞給 Square 組件。
現在,您希望在每個方塊內顯示該 value
而不是 1
。嘗試這樣做
function Square({ value }) {
return <button className="square">value</button>;
}
糟糕,這不是您想要的

您想要渲染組件中名為 value
的 JavaScript 變數,而不是單詞「value」。要從 JSX「跳脫到 JavaScript」,您需要使用大括號。像這樣在大括號中加入 JSX 中的 value
function Square({ value }) {
return <button className="square">{value}</button>;
}
目前,您應該會看到一個空的棋盤

這是因為 Board
組件尚未將 value
屬性傳遞給它渲染的每個 Square
組件。要修復它,您需要將 value
屬性添加到 Board
組件渲染的每個 Square
組件中。
export default function Board() {
return (
<>
<div className="board-row">
<Square value="1" />
<Square value="2" />
<Square value="3" />
</div>
<div className="board-row">
<Square value="4" />
<Square value="5" />
<Square value="6" />
</div>
<div className="board-row">
<Square value="7" />
<Square value="8" />
<Square value="9" />
</div>
</>
);
}
現在您應該再次看到數字網格

您更新後的程式碼應該看起來像這樣
function Square({ value }) { return <button className="square">{value}</button>; } export default function Board() { return ( <> <div className="board-row"> <Square value="1" /> <Square value="2" /> <Square value="3" /> </div> <div className="board-row"> <Square value="4" /> <Square value="5" /> <Square value="6" /> </div> <div className="board-row"> <Square value="7" /> <Square value="8" /> <Square value="9" /> </div> </> ); }
製作互動式組件
讓我們在點擊 Square
組件時,用 X
填充它。在 Square
內宣告一個名為 handleClick
的函式。然後,將 onClick
添加到從 Square
返回的按鈕 JSX 元素的屬性中。
function Square({ value }) {
function handleClick() {
console.log('clicked!');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}
如果您現在點擊一個方塊,您應該會在 CodeSandbox 中*瀏覽器*部分底部的*主控台*索引標籤中看到一條日誌,顯示 "clicked!"
。多次點擊該方塊將再次記錄 "clicked!"
。具有相同訊息的重複主控台日誌不會在主控台中創建更多行。相反,您會在第一個 "clicked!"
日誌旁邊看到一個遞增的計數器。
接下來,您希望 Square 組件「記住」它已被點擊,並用「X」標記填充它。為了「記住」事情,組件使用*狀態*。
React 提供了一個名為 useState
的特殊函式,您可以從組件中呼叫它來讓它「記住」事情。讓我們將 Square
的目前值存儲在狀態中,並在點擊 Square
時更改它。
在檔案的頂部導入 useState
。從 Square
元件中移除 value
prop。取而代之,在 Square
的開頭新增一行呼叫 useState
。讓它返回一個名為 value
的狀態變數。
import { useState } from 'react';
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
//...
value
儲存值,而 setValue
是一個可以用來改變值的函式。傳遞給 useState
的 null
被用作此狀態變數的初始值,因此這裡的 value
初始值等於 null
。
由於 Square
元件不再接受 props,您將從 Board 元件建立的所有九個 Square 元件中移除 value
prop。
// ...
export default function Board() {
return (
<>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
</>
);
}
現在您將更改 Square
,使其在被點擊時顯示「X」。將 console.log("clicked!");
事件處理程式替換為 setValue('X');
。現在您的 Square
元件看起來像這樣。
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
setValue('X');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}
透過在 onClick
處理程式中呼叫此 set
函式,您是在告訴 React 每當其 <button>
被點擊時就重新渲染該 Square
。更新後,Square
的 value
將為 'X'
,因此您將在遊戲板上看到「X」。點擊任何一個 Square,「X」就會出現。

每個 Square 都有自己的狀態:儲存在每個 Square 中的 value
與其他 Square 完全獨立。當您在元件中呼叫 set
函式時,React 也會自動更新裡面的子元件。
完成上述更改後,您的程式碼將如下所示。
import { useState } from 'react'; function Square() { const [value, setValue] = useState(null); function handleClick() { setValue('X'); } return ( <button className="square" onClick={handleClick} > {value} </button> ); } export default function Board() { return ( <> <div className="board-row"> <Square /> <Square /> <Square /> </div> <div className="board-row"> <Square /> <Square /> <Square /> </div> <div className="board-row"> <Square /> <Square /> <Square /> </div> </> ); }
React 開發者工具 ...
React DevTools 可讓您檢查 React 元件的 props 和狀態。您可以在 CodeSandbox 中瀏覽器部分的底部找到 React DevTools 標籤頁。

要檢查螢幕上的特定元件,請使用 React DevTools 左上角的按鈕。

完成遊戲 ...
到目前為止,您已經具備了井字遊戲的所有基本組成部分。要完成一個完整的遊戲,您現在需要在棋盤上交替放置「X」和「O」,並且需要一種方法來確定勝利者。
提升狀態 ...
目前,每個 Square
元件都維護著遊戲狀態的一部分。要在井字遊戲中檢查勝利者,Board
需要以某種方式知道 9 個 Square
元件的狀態。
您會如何處理這個問題?起初,您可能會認為 Board
需要「詢問」每個 Square
該 Square
的狀態。雖然這種方法在 React 中技術上是可行的,但我們不鼓勵這樣做,因為程式碼會變得難以理解、容易出錯且難以重構。相反,最好的方法是將遊戲狀態儲存在父元件 Board
中,而不是在每個 Square
中。Board
元件可以透過傳遞 prop 來告訴每個 Square
要顯示什麼,就像您之前將數字傳遞給每個 Square 一樣。
要從多個子元件收集資料,或讓兩個子元件彼此通訊,請在其父元件中宣告共享狀態。父元件可以透過 props 將該狀態傳遞回子元件。這可使子元件彼此之間及其父元件保持同步。
在重構 React 元件時,將狀態提升到父元件中是很常見的做法。
讓我們藉此機會嘗試一下。編輯 Board
元件,使其宣告一個名為 squares
的狀態變數,預設值為一個包含 9 個 null 的陣列,對應於 9 個方格。
// ...
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
// ...
);
}
Array(9).fill(null)
會建立一個包含九個元素的陣列,並將每個元素設為 null
。 外層的 useState()
呼叫會宣告一個 squares
狀態變數,並將其初始值設為該陣列。陣列中的每個項目對應到一個方格的值。稍後在填滿棋盤時,squares
陣列看起來會像這樣:
['O', null, 'X', 'X', 'X', 'O', 'O', null, null]
現在,您的 Board
元件需要將 value
屬性傳遞給它渲染的每個 Square
元件。
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
<>
<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>
</>
);
}
接下來,您將編輯 Square
元件,以接收來自 Board 元件的 value
屬性。這需要移除 Square 元件自身對 value
的狀態追蹤,以及按鈕的 onClick
屬性。
function Square({value}) {
return <button className="square">{value}</button>;
}
此時,您應該會看到一個空的井字遊戲棋盤。

您的程式碼應該如下所示:
import { useState } from 'react'; function Square({ value }) { return <button className="square">{value}</button>; } export default function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); return ( <> <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> </> ); }
現在每個 Square 都會收到一個 value
屬性,其值可能是 'X'
、'O'
或 null
(表示空方格)。
接下來,您需要更改點擊 Square
時會發生的情況。Board
元件現在會維護哪些方格已被填滿。您需要建立一種方法,讓 Square
更新 Board
的狀態。由於狀態是定義它的元件的私有屬性,因此您無法直接從 Square
更新 Board
的狀態。
相反地,您將從 Board
元件向下傳遞一個函式給 Square
元件,並讓 Square
在點擊方格時呼叫該函式。您將從 Square
元件在被點擊時會呼叫的函式開始。您將該函式命名為 onSquareClick
。
function Square({ value }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
接下來,您將 onSquareClick
函式新增到 Square
元件的屬性中。
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
現在,您要將 onSquareClick
屬性連接到 Board
元件中的一個函式,您將其命名為 handleClick
。要將 onSquareClick
連接到 handleClick
,您需要將一個函式傳遞給第一個 Square
元件的 onSquareClick
屬性。
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={handleClick} />
//...
);
}
最後,您將在 Board 元件內定義 handleClick
函式,以更新保存棋盤狀態的 squares
陣列。
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick() {
const nextSquares = squares.slice();
nextSquares[0] = "X";
setSquares(nextSquares);
}
return (
// ...
)
}
handleClick
函式會使用 JavaScript 的 slice()
陣列方法建立 squares
陣列的副本(nextSquares
)。然後,handleClick
更新 nextSquares
陣列,將 X
新增到第一個(索引 [0]
)方格。
呼叫 setSquares
函式會讓 React 知道元件的狀態已更改。這將觸發使用 squares
狀態的元件(Board
)及其子元件(組成棋盤的 Square
元件)重新渲染。
現在您可以將 X 新增到棋盤上…但只能新增到左上角的方格。您的 handleClick
函式被寫死只能更新左上角方格(0
)的索引。讓我們更新 handleClick
,使其能夠更新任何方格。在 handleClick
函式中新增一個參數 i
,用於接收要更新的方格的索引。
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick(i) {
const nextSquares = squares.slice();
nextSquares[i] = "X";
setSquares(nextSquares);
}
return (
// ...
)
}
接下來,您需要將 i
傳遞給 handleClick
。您可以嘗試直接在 JSX 中將方格的 onSquareClick
屬性設為 handleClick(0)
,如下所示,但這行不通。
<Square value={squares[0]} onSquareClick={handleClick(0)} />
這就是為什麼這樣做行不通的原因。呼叫 handleClick(0)
會是渲染棋盤元件的一部分。因為 handleClick(0)
會透過呼叫 setSquares
來改變棋盤元件的狀態,你的整個棋盤元件會再次被重新渲染。但這又會再次執行 handleClick(0)
,導致無限迴圈。
為什麼這個問題之前沒有發生?
當你傳遞 onSquareClick={handleClick}
時,你是將 handleClick
函式作為一個屬性傳遞下去。你並沒有呼叫它!但現在你*立即*呼叫了該函式——注意 handleClick(0)
中的括號——這就是它過早執行的緣故。你不*希望*在使用者點擊之前就呼叫 handleClick
!
你可以透過建立一個像 handleFirstSquareClick
這樣的函式來呼叫 handleClick(0)
,一個像 handleSecondSquareClick
這樣的函式來呼叫 handleClick(1)
,依此類推來解決這個問題。你會將這些函式作為屬性傳遞下去(而不是呼叫它們),例如 onSquareClick={handleFirstSquareClick}
。這將解決無限迴圈的問題。
然而,定義九個不同的函式並為每個函式命名太過冗長。讓我們這樣做:
export default function Board() {
// ...
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
// ...
);
}
注意新的 () =>
語法。這裡,() => handleClick(0)
是一個*箭頭函式*,它是定義函式的簡短方式。當方格被點擊時,=>
「箭頭」後面的程式碼將會運行,呼叫 handleClick(0)
。
現在你需要更新其他八個方格,讓它們從你傳遞的箭頭函式中呼叫 handleClick
。確保每次呼叫 handleClick
的參數都對應到正確方格的索引。
export default function Board() {
// ...
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
<Square value={squares[1]} onSquareClick={() => handleClick(1)} />
<Square value={squares[2]} onSquareClick={() => handleClick(2)} />
</div>
<div className="board-row">
<Square value={squares[3]} onSquareClick={() => handleClick(3)} />
<Square value={squares[4]} onSquareClick={() => handleClick(4)} />
<Square value={squares[5]} onSquareClick={() => handleClick(5)} />
</div>
<div className="board-row">
<Square value={squares[6]} onSquareClick={() => handleClick(6)} />
<Square value={squares[7]} onSquareClick={() => handleClick(7)} />
<Square value={squares[8]} onSquareClick={() => handleClick(8)} />
</div>
</>
);
};
現在你可以再次透過點擊在棋盤上的任何方格中新增 X。

但這次所有的狀態管理都由 Board
元件處理!
你的程式碼看起來應該像這樣。
import { useState } from 'react'; function Square({ value, onSquareClick }) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } export default function Board() { const [squares, setSquares] = useState(Array(9).fill(null)); function handleClick(i) { const nextSquares = squares.slice(); nextSquares[i] = 'X'; setSquares(nextSquares); } return ( <> <div className="board-row"> <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> <Square value={squares[1]} onSquareClick={() => handleClick(1)} /> <Square value={squares[2]} onSquareClick={() => handleClick(2)} /> </div> <div className="board-row"> <Square value={squares[3]} onSquareClick={() => handleClick(3)} /> <Square value={squares[4]} onSquareClick={() => handleClick(4)} /> <Square value={squares[5]} onSquareClick={() => handleClick(5)} /> </div> <div className="board-row"> <Square value={squares[6]} onSquareClick={() => handleClick(6)} /> <Square value={squares[7]} onSquareClick={() => handleClick(7)} /> <Square value={squares[8]} onSquareClick={() => handleClick(8)} /> </div> </> ); }
現在你的狀態處理在 Board
元件中,父元件 Board
將屬性傳遞給子元件 Square
,以便它們能夠正確顯示。當點擊一個 Square
時,子元件 Square
現在會要求父元件 Board
更新棋盤的狀態。當 Board
的狀態改變時,Board
元件和每個子元件 Square
都會自動重新渲染。將所有方格的狀態保存在 Board
元件中,將允許它在未來判斷勝負。
讓我們回顧一下,當使用者點擊棋盤左上角的方格以新增一個 X
時會發生什麼。
- 點擊左上角的方格會執行
button
從Square
接收的作為其onClick
屬性的函式。Square
元件從Board
接收該函式作為其onSquareClick
屬性。Board
元件直接在 JSX 中定義了該函式。它以0
作為參數呼叫handleClick
。 handleClick
使用參數(0
)將squares
陣列的第一個元素從null
更新為X
。Board
元件的squares
狀態已更新,因此Board
及其所有子元件都會重新渲染。這會導致索引為0
的Square
元件的value
屬性從null
變更為X
。
最後,使用者會看到左上角的方格在點擊後從空白變成了 X
。
為何不可變性很重要
請注意,在 handleClick
中,您呼叫了 .slice()
來建立 squares
陣列的副本,而不是直接修改現有陣列。為了解釋原因,我們需要討論不可變性以及為何學習不可變性很重要。
通常有兩種方法可以更改資料。第一種方法是通過直接更改資料的值來使其*變異*。第二種方法是用具有所需更改的新副本替換資料。如果您變異了 squares
陣列,它看起來會像這樣:
const squares = [null, null, null, null, null, null, null, null, null];
squares[0] = 'X';
// Now `squares` is ["X", null, null, null, null, null, null, null, null];
如果您在不變異 squares
陣列的情況下更改資料,它看起來會像這樣:
const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
// Now `squares` is unchanged, but `nextSquares` first element is 'X' rather than `null`
結果相同,但通過不直接變異(更改底層資料),您可以獲得一些好處。
不可變性使複雜功能更容易實現。在本教學課程的稍後部分,您將實現一個「時光旅行」功能,讓您可以查看遊戲的歷史記錄並「跳回」之前的步驟。此功能並非遊戲專屬 — 在應用程式中,撤消和重做某些操作是常見的需求。避免直接資料變異可讓您保持資料的先前版本完整,並在稍後重複使用它們。
不可變性還有另一個好處。預設情況下,當父組件的狀態發生變化時,所有子組件都會自動重新渲染。這甚至包括那些不受更改影響的子組件。雖然重新渲染本身對使用者來說並不明顯(您不應該主動嘗試避免它!),但您可能希望基於效能考量,跳過重新渲染明顯不受其影響的樹狀結構部分。不可變性使組件可以非常輕鬆地比較其資料是否已更改。您可以在 memo
API 參考中瞭解更多關於 React 如何選擇何時重新渲染組件的資訊。
輪流
現在該修復這個圈圈叉叉遊戲中的一個主要缺陷了:「O」無法標記在棋盤上。
您將預設第一個步數設為「X」。讓我們通過向 Board 組件添加另一個狀態來追蹤它。
function Board() {
const [xIsNext, setXIsNext] = useState(true);
const [squares, setSquares] = useState(Array(9).fill(null));
// ...
}
每次玩家移動時,xIsNext
(一個布林值)將被翻轉以確定下一個玩家,並且遊戲的狀態將被儲存。您將更新 Board
的 handleClick
函式以翻轉 xIsNext
的值。
export default function Board() {
const [xIsNext, setXIsNext] = useState(true);
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick(i) {
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = "X";
} else {
nextSquares[i] = "O";
}
setSquares(nextSquares);
setXIsNext(!xIsNext);
}
return (
//...
);
}
現在,當您點擊不同的方格時,它們將在 X
和 O
之間交替,正如預期的那樣!
但是等等,有個問題。嘗試多次點擊同一方格。

X
被 O
覆蓋了!雖然這會為遊戲增添一個非常有趣的變化,但我們現在將繼續遵循原始規則。
當您用 X
或 O
標記一個方格時,您沒有先檢查該方格是否已經有 X
或 O
值。您可以通過*提前返回*來解決此問題。您將檢查該方格是否已經有 X
或 O
。如果方格已填滿,您將在 handleClick
函式中*提前返回* — 在它嘗試更新棋盤狀態之前。
function handleClick(i) {
if (squares[i]) {
return;
}
const nextSquares = squares.slice();
//...
}
現在您只能將 X
或 O
添加到空的方格中!此時您的程式碼應如下所示:
import { useState } from 'react'; function Square({value, onSquareClick}) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } export default function Board() { const [xIsNext, setXIsNext] = useState(true); const [squares, setSquares] = useState(Array(9).fill(null)); function handleClick(i) { if (squares[i]) { return; } const nextSquares = squares.slice(); if (xIsNext) { nextSquares[i] = 'X'; } else { nextSquares[i] = 'O'; } setSquares(nextSquares); setXIsNext(!xIsNext); } return ( <> <div className="board-row"> <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> <Square value={squares[1]} onSquareClick={() => handleClick(1)} /> <Square value={squares[2]} onSquareClick={() => handleClick(2)} /> </div> <div className="board-row"> <Square value={squares[3]} onSquareClick={() => handleClick(3)} /> <Square value={squares[4]} onSquareClick={() => handleClick(4)} /> <Square value={squares[5]} onSquareClick={() => handleClick(5)} /> </div> <div className="board-row"> <Square value={squares[6]} onSquareClick={() => handleClick(6)} /> <Square value={squares[7]} onSquareClick={() => handleClick(7)} /> <Square value={squares[8]} onSquareClick={() => handleClick(8)} /> </div> </> ); }
宣告獲勝者
現在玩家可以輪流了,您需要顯示遊戲何時獲勝以及沒有更多回合可玩。為此,您將添加一個名為 calculateWinner
的輔助函式,該函式接受一個包含 9 個方格的陣列,檢查是否有獲勝者,並根據情況返回 'X'
、'O'
或 null
。不用太擔心 calculateWinner
函式;它不是 React 特定的。
export default function Board() {
//...
}
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
組件的 handleClick
函式中呼叫 calculateWinner(squares)
來檢查是否有玩家獲勝。您可以在檢查使用者是否點擊了已具有 X
或 O
的方格時同時執行此檢查。我們希望在兩種情況下都提前返回。
function handleClick(i) {
if (squares[i] || calculateWinner(squares)) {
return;
}
const nextSquares = squares.slice();
//...
}
為了讓玩家知道遊戲何時結束,您可以顯示文字,例如「獲勝者:X」或「獲勝者:O」。為此,您將在 Board
組件中添加一個 status
區段。如果遊戲結束,狀態將顯示獲勝者;如果遊戲正在進行,您將顯示下一個玩家的回合。
export default function Board() {
// ...
const winner = calculateWinner(squares);
let status;
if (winner) {
status = "Winner: " + winner;
} else {
status = "Next player: " + (xIsNext ? "X" : "O");
}
return (
<>
<div className="status">{status}</div>
<div className="board-row">
// ...
)
}
恭喜!您現在擁有一個可以運作的圈圈叉叉遊戲。您也學習了 React 的基礎知識。所以*您*才是真正的贏家。程式碼應該如下所示:
import { useState } from 'react'; function Square({value, onSquareClick}) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } export default function Board() { const [xIsNext, setXIsNext] = useState(true); const [squares, setSquares] = useState(Array(9).fill(null)); function handleClick(i) { if (calculateWinner(squares) || squares[i]) { return; } const nextSquares = squares.slice(); if (xIsNext) { nextSquares[i] = 'X'; } else { nextSquares[i] = 'O'; } setSquares(nextSquares); setXIsNext(!xIsNext); } const winner = calculateWinner(squares); let status; if (winner) { status = 'Winner: ' + winner; } else { status = 'Next player: ' + (xIsNext ? 'X' : 'O'); } return ( <> <div className="status">{status}</div> <div className="board-row"> <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> <Square value={squares[1]} onSquareClick={() => handleClick(1)} /> <Square value={squares[2]} onSquareClick={() => handleClick(2)} /> </div> <div className="board-row"> <Square value={squares[3]} onSquareClick={() => handleClick(3)} /> <Square value={squares[4]} onSquareClick={() => handleClick(4)} /> <Square value={squares[5]} onSquareClick={() => handleClick(5)} /> </div> <div className="board-row"> <Square value={squares[6]} onSquareClick={() => handleClick(6)} /> <Square value={squares[7]} onSquareClick={() => handleClick(7)} /> <Square value={squares[8]} onSquareClick={() => handleClick(8)} /> </div> </> ); } 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; }
新增時光旅行
作為最後一個練習,讓我們可以「回到過去」,回到遊戲中之前的步驟。
儲存移動歷史紀錄
如果您直接修改了 squares
陣列,實現時間旅行的功能將會非常困難。
然而,您使用了 slice()
在每次移動後創建 squares
陣列的副本,並將其視為不可變的。這將允許您儲存 squares
陣列的每個過去版本,並在已發生的回合之間導航。
您將把過去的 squares
陣列儲存在另一個名為 history
的陣列中,您將其儲存為一個新的狀態變數。 history
陣列表示從第一步到最後一步的所有棋盤狀態,其形狀如下:
[
// Before first move
[null, null, null, null, null, null, null, null, null],
// After first move
[null, null, null, null, 'X', null, null, null, null],
// After second move
[null, null, null, null, 'X', null, null, null, 'O'],
// ...
]
再次提升狀態
您現在將編寫一個新的頂層組件,稱為 Game
,以顯示過去移動的列表。這就是您放置包含整個遊戲歷史記錄的 history
狀態的地方。
將 history
狀態放入 Game
組件將允許您從其子組件 Board
中移除 squares
狀態。就像您將狀態從 Square
組件「提升」到 Board
組件一樣,您現在將其從 Board
提升到頂層 Game
組件。這使 Game
組件可以完全控制 Board
的數據,並讓它指示 Board
從 history
渲染之前的回合。
首先,添加一個帶有 export default
的 Game
組件。讓它渲染 Board
組件和一些標記。
function Board() {
// ...
}
export default function Game() {
return (
<div className="game">
<div className="game-board">
<Board />
</div>
<div className="game-info">
<ol>{/*TODO*/}</ol>
</div>
</div>
);
}
請注意,您正在 function Board() {
宣告之前移除 export default
關鍵字,並在 function Game() {
宣告之前添加它們。這告訴您的 index.js
文件使用 Game
組件作為頂層組件,而不是您的 Board
組件。 Game
組件返回的額外 div
正在為您稍後將添加到棋盤的遊戲信息騰出空間。
向 Game
組件添加一些狀態,以追蹤下一個玩家是誰以及移動的歷史記錄。
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
// ...
請注意 [Array(9).fill(null)]
是一個只有一個項目的陣列,它本身是一個包含 9 個 null
的陣列。
要渲染當前移動的方塊,您需要從 history
中讀取最後一個方塊陣列。您不需要 useState
來執行此操作——您在渲染過程中已經有足夠的信息來計算它。
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
const currentSquares = history[history.length - 1];
// ...
接下來,在 Game
組件內創建一個 handlePlay
函數,該函數將由 Board
組件調用以更新遊戲。將 xIsNext
、currentSquares
和 handlePlay
作為屬性傳遞給 Board
組件。
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
const currentSquares = history[history.length - 1];
function handlePlay(nextSquares) {
// TODO
}
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
//...
)
}
讓我們使 Board
組件完全由其接收的屬性控制。更改 Board
組件以採用三個屬性:xIsNext
、squares
和一個新的 onPlay
函數,當玩家移動時,Board
可以使用更新的方塊陣列調用該函數。接下來,移除調用 useState
的 Board
函數的前兩行。
function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
//...
}
// ...
}
現在,將 Board
組件中 handleClick
中的 setSquares
和 setXIsNext
調用替換為對新 onPlay
函數的單個調用,以便當用戶單擊方塊時,Game
組件可以更新 Board
。
function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
if (calculateWinner(squares) || squares[i]) {
return;
}
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = "X";
} else {
nextSquares[i] = "O";
}
onPlay(nextSquares);
}
//...
}
Board
組件完全由 Game
組件傳遞給它的屬性控制。您需要在 Game
組件中實作 handlePlay
函數才能讓遊戲再次正常運作。
當被調用時,handlePlay
應該做什麼?請記住,Board
過去常常使用更新的陣列調用 setSquares
;現在它將更新的 squares
陣列傳遞給 onPlay
。
`handlePlay` 函式需要更新 `Game` 的狀態來觸發重新渲染,但您現在無法再呼叫 `setSquares` 函式 — 您現在使用 `history` 狀態變數來儲存這些資訊。您需要透過將更新後的 `squares` 陣列附加為新的歷史記錄項目來更新 `history`。您還需要切換 `xIsNext`,就像 `Board` 以前做的那樣。
export default function Game() {
//...
function handlePlay(nextSquares) {
setHistory([...history, nextSquares]);
setXIsNext(!xIsNext);
}
//...
}
在這裡,`[...history, nextSquares]` 建立了一個新的陣列,其中包含 `history` 中的所有項目,後面接著 `nextSquares`。(您可以將 `...history` 展開語法 讀作「列舉 `history` 中的所有項目」。)
例如,如果 `history` 是 `[[null,null,null], ["X",null,null]]` 且 `nextSquares` 是 `["X",null,"O"]`,則新的 `[...history, nextSquares]` 陣列將是 `[[null,null,null], ["X",null,null], ["X",null,"O"]]`。
此時,您已將狀態移動到 `Game` 組件中,UI 應該可以完全正常運作,就像重構之前一樣。以下是此時程式碼的樣子。
import { useState } from 'react'; function Square({ value, onSquareClick }) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } function Board({ xIsNext, squares, onPlay }) { function handleClick(i) { if (calculateWinner(squares) || squares[i]) { return; } const nextSquares = squares.slice(); if (xIsNext) { nextSquares[i] = 'X'; } else { nextSquares[i] = 'O'; } onPlay(nextSquares); } const winner = calculateWinner(squares); let status; if (winner) { status = 'Winner: ' + winner; } else { status = 'Next player: ' + (xIsNext ? 'X' : 'O'); } return ( <> <div className="status">{status}</div> <div className="board-row"> <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> <Square value={squares[1]} onSquareClick={() => handleClick(1)} /> <Square value={squares[2]} onSquareClick={() => handleClick(2)} /> </div> <div className="board-row"> <Square value={squares[3]} onSquareClick={() => handleClick(3)} /> <Square value={squares[4]} onSquareClick={() => handleClick(4)} /> <Square value={squares[5]} onSquareClick={() => handleClick(5)} /> </div> <div className="board-row"> <Square value={squares[6]} onSquareClick={() => handleClick(6)} /> <Square value={squares[7]} onSquareClick={() => handleClick(7)} /> <Square value={squares[8]} onSquareClick={() => handleClick(8)} /> </div> </> ); } export default function Game() { const [xIsNext, setXIsNext] = useState(true); const [history, setHistory] = useState([Array(9).fill(null)]); const currentSquares = history[history.length - 1]; function handlePlay(nextSquares) { setHistory([...history, nextSquares]); setXIsNext(!xIsNext); } return ( <div className="game"> <div className="game-board"> <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} /> </div> <div className="game-info"> <ol>{/*TODO*/}</ol> </div> </div> ); } 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; }
顯示先前的步驟
由於您正在記錄井字遊戲的歷史記錄,因此您現在可以向玩家顯示先前步驟的列表。
像 `<button>` 這樣的 React 元素是普通的 JavaScript 物件;您可以在應用程式中傳遞它們。要在 React 中渲染多個項目,您可以使用 React 元素的陣列。
您在狀態中已經有一個 `history` 步驟的陣列,所以現在您需要將它轉換為 React 元素的陣列。在 JavaScript 中,要將一個陣列轉換為另一個陣列,您可以使用 陣列 `map` 方法:
[1, 2, 3].map((x) => x * 2) // [2, 4, 6]
您將使用 `map` 將您的 `history` 步驟轉換為代表螢幕上按鈕的 React 元素,並顯示一個按鈕列表以「跳轉」到先前的步驟。讓我們在 `Game` 組件中 `map` 遍歷 `history`。
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
const currentSquares = history[history.length - 1];
function handlePlay(nextSquares) {
setHistory([...history, nextSquares]);
setXIsNext(!xIsNext);
}
function jumpTo(nextMove) {
// TODO
}
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
description = 'Go to move #' + move;
} else {
description = 'Go to game start';
}
return (
<li>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div className="game-info">
<ol>{moves}</ol>
</div>
</div>
);
}
您可以在下方看到您的程式碼應該是什麼樣子。請注意,您應該在開發者工具主控台中看到一個錯誤訊息,指出:
您將在下一節中修復此錯誤。
import { useState } from 'react'; function Square({ value, onSquareClick }) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } function Board({ xIsNext, squares, onPlay }) { function handleClick(i) { if (calculateWinner(squares) || squares[i]) { return; } const nextSquares = squares.slice(); if (xIsNext) { nextSquares[i] = 'X'; } else { nextSquares[i] = 'O'; } onPlay(nextSquares); } const winner = calculateWinner(squares); let status; if (winner) { status = 'Winner: ' + winner; } else { status = 'Next player: ' + (xIsNext ? 'X' : 'O'); } return ( <> <div className="status">{status}</div> <div className="board-row"> <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> <Square value={squares[1]} onSquareClick={() => handleClick(1)} /> <Square value={squares[2]} onSquareClick={() => handleClick(2)} /> </div> <div className="board-row"> <Square value={squares[3]} onSquareClick={() => handleClick(3)} /> <Square value={squares[4]} onSquareClick={() => handleClick(4)} /> <Square value={squares[5]} onSquareClick={() => handleClick(5)} /> </div> <div className="board-row"> <Square value={squares[6]} onSquareClick={() => handleClick(6)} /> <Square value={squares[7]} onSquareClick={() => handleClick(7)} /> <Square value={squares[8]} onSquareClick={() => handleClick(8)} /> </div> </> ); } export default function Game() { const [xIsNext, setXIsNext] = useState(true); const [history, setHistory] = useState([Array(9).fill(null)]); const currentSquares = history[history.length - 1]; function handlePlay(nextSquares) { setHistory([...history, nextSquares]); setXIsNext(!xIsNext); } function jumpTo(nextMove) { // TODO } const moves = history.map((squares, move) => { let description; if (move > 0) { description = 'Go to move #' + move; } else { description = 'Go to game start'; } return ( <li> <button onClick={() => jumpTo(move)}>{description}</button> </li> ); }); return ( <div className="game"> <div className="game-board"> <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} /> </div> <div className="game-info"> <ol>{moves}</ol> </div> </div> ); } 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; }
當您在傳遞給 `map` 的函式內迭代 `history` 陣列時,`squares` 參數會遍歷 `history` 的每個元素,而 `move` 參數會遍歷每個陣列索引:`0`、`1`、`2`、…。(在大多數情況下,您需要實際的陣列元素,但要渲染步驟列表,您只需要索引。)
對於井字遊戲歷史記錄中的每個步驟,您都會建立一個包含按鈕 `<button>` 的清單項目 `<li>`。該按鈕有一個 `onClick` 處理程式,它會呼叫一個名為 `jumpTo` 的函式(您尚未實作)。
目前,您應該會看到遊戲中發生的步驟列表,以及開發者工具主控台中的一個錯誤。讓我們討論一下「key」錯誤的含義。
選擇 key
當您渲染一個列表時,React 會儲存一些關於每個已渲染清單項目的資訊。當您更新列表時,React 需要確定哪些內容已更改。您可能已新增、移除、重新排列或更新列表的項目。
想像一下從
<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>
到
<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>
除了更新的計數之外,閱讀此內容的人可能會說您交換了 Alexa 和 Ben 的順序,並在 Alexa 和 Ben 之間插入了 Claudia。然而,React 是一個電腦程式,不知道您的意圖,因此您需要為每個清單項目指定一個 `key` 屬性,以區分每個清單項目与其兄弟姐妹。如果您的數據來自資料庫,則可以使用 Alexa、Ben 和 Claudia 的資料庫 ID 作為 key。
<li key={user.id}>
{user.name}: {user.taskCount} tasks left
</li>
當重新渲染列表時,React 會取得每個清單項目的 key,並在先前列表的項目中搜尋匹配的 key。如果目前列表中有一個先前不存在的 key,React 會建立一個組件。如果目前列表缺少先前列表中存在的 key,React 會銷毀先前的組件。如果兩個 key 匹配,則相應的組件會被移動。
Key 告訴 React 每個組件的識別身分,這允許 React 在重新渲染之間維護狀態。如果組件的 key 更改,則該組件將被銷毀並使用新狀態重新建立。
`key` 是 React 中一個特殊的保留屬性。建立元素時,React 會提取 `key` 屬性並將 key 直接儲存在返回的元素上。即使 `key` 看起來像是作為 props 傳遞的,React 也會自動使用 `key` 來決定要更新哪些組件。組件無法詢問其父組件指定了哪個 `key`。
強烈建議您在構建動態列表時分配適當的 key。 如果您沒有適當的 key,您可能需要考慮重構您的數據,以便您擁有適當的 key。
如果未指定 key,React 將會回報錯誤,並預設使用陣列索引作為 key。當嘗試重新排序列表項目或插入/刪除列表項目時,使用陣列索引作為 key 會產生問題。明確傳遞 key={i}
可以抑制錯誤,但會產生與陣列索引相同的問題,因此在大多數情況下不建議使用。
Key 不需要全局唯一;它們只需要在組件及其兄弟組件之間唯一即可。
實作時間旅行
在井字遊戲的歷史記錄中,每個過去的步驟都有一個與之關聯的唯一 ID:它是步驟的順序號。步驟永遠不會被重新排序、刪除或插入到中間,因此使用步驟索引作為 key 是安全的。
在 Game
函式中,您可以將 key 添加為 <li key={move}>
,如果您重新載入渲染的遊戲,React 的「key」錯誤應該會消失。
const moves = history.map((squares, move) => {
//...
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
import { useState } from 'react'; function Square({ value, onSquareClick }) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } function Board({ xIsNext, squares, onPlay }) { function handleClick(i) { if (calculateWinner(squares) || squares[i]) { return; } const nextSquares = squares.slice(); if (xIsNext) { nextSquares[i] = 'X'; } else { nextSquares[i] = 'O'; } onPlay(nextSquares); } const winner = calculateWinner(squares); let status; if (winner) { status = 'Winner: ' + winner; } else { status = 'Next player: ' + (xIsNext ? 'X' : 'O'); } return ( <> <div className="status">{status}</div> <div className="board-row"> <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> <Square value={squares[1]} onSquareClick={() => handleClick(1)} /> <Square value={squares[2]} onSquareClick={() => handleClick(2)} /> </div> <div className="board-row"> <Square value={squares[3]} onSquareClick={() => handleClick(3)} /> <Square value={squares[4]} onSquareClick={() => handleClick(4)} /> <Square value={squares[5]} onSquareClick={() => handleClick(5)} /> </div> <div className="board-row"> <Square value={squares[6]} onSquareClick={() => handleClick(6)} /> <Square value={squares[7]} onSquareClick={() => handleClick(7)} /> <Square value={squares[8]} onSquareClick={() => handleClick(8)} /> </div> </> ); } export default function Game() { const [xIsNext, setXIsNext] = useState(true); const [history, setHistory] = useState([Array(9).fill(null)]); const currentSquares = history[history.length - 1]; function handlePlay(nextSquares) { setHistory([...history, nextSquares]); setXIsNext(!xIsNext); } function jumpTo(nextMove) { // TODO } const moves = history.map((squares, move) => { let description; if (move > 0) { description = 'Go to move #' + move; } else { description = 'Go to game start'; } return ( <li key={move}> <button onClick={() => jumpTo(move)}>{description}</button> </li> ); }); return ( <div className="game"> <div className="game-board"> <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} /> </div> <div className="game-info"> <ol>{moves}</ol> </div> </div> ); } 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; }
在實作 jumpTo
之前,您需要 Game
組件來追蹤使用者目前正在查看的步驟。為此,定義一個名為 currentMove
的新狀態變數,預設值為 0
。
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
const currentSquares = history[history.length - 1];
//...
}
接下來,更新 Game
內部的 jumpTo
函式以更新 currentMove
。如果您將 currentMove
更改為偶數,則還需要將 xIsNext
設定為 true
。
export default function Game() {
// ...
function jumpTo(nextMove) {
setCurrentMove(nextMove);
setXIsNext(nextMove % 2 === 0);
}
//...
}
現在,您將對在點擊方塊時呼叫的 Game
的 handlePlay
函式進行兩項更改。
- 如果您「回到過去」然後從該點開始進行新的步驟,您只想保留到該點的歷史記錄。您將在
history.slice(0, currentMove + 1)
中的所有項目之後添加nextSquares
,而不是在history
中的所有項目(...
展開語法)之後添加它,以便您只保留舊歷史記錄的那一部分。 - 每次進行步驟時,您都需要更新
currentMove
以指向最新的歷史記錄條目。
function handlePlay(nextSquares) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
setXIsNext(!xIsNext);
}
最後,您將修改 Game
組件以渲染當前選擇的步驟,而不是始終渲染最後一個步驟。
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
const currentSquares = history[currentMove];
// ...
}
如果您點擊遊戲歷史記錄中的任何步驟,井字遊戲棋盤應該立即更新以顯示該步驟發生後棋盤的樣子。
import { useState } from 'react'; function Square({value, onSquareClick}) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } function Board({ xIsNext, squares, onPlay }) { function handleClick(i) { if (calculateWinner(squares) || squares[i]) { return; } const nextSquares = squares.slice(); if (xIsNext) { nextSquares[i] = 'X'; } else { nextSquares[i] = 'O'; } onPlay(nextSquares); } const winner = calculateWinner(squares); let status; if (winner) { status = 'Winner: ' + winner; } else { status = 'Next player: ' + (xIsNext ? 'X' : 'O'); } return ( <> <div className="status">{status}</div> <div className="board-row"> <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> <Square value={squares[1]} onSquareClick={() => handleClick(1)} /> <Square value={squares[2]} onSquareClick={() => handleClick(2)} /> </div> <div className="board-row"> <Square value={squares[3]} onSquareClick={() => handleClick(3)} /> <Square value={squares[4]} onSquareClick={() => handleClick(4)} /> <Square value={squares[5]} onSquareClick={() => handleClick(5)} /> </div> <div className="board-row"> <Square value={squares[6]} onSquareClick={() => handleClick(6)} /> <Square value={squares[7]} onSquareClick={() => handleClick(7)} /> <Square value={squares[8]} onSquareClick={() => handleClick(8)} /> </div> </> ); } export default function Game() { const [xIsNext, setXIsNext] = useState(true); const [history, setHistory] = useState([Array(9).fill(null)]); const [currentMove, setCurrentMove] = useState(0); const currentSquares = history[currentMove]; function handlePlay(nextSquares) { const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; setHistory(nextHistory); setCurrentMove(nextHistory.length - 1); setXIsNext(!xIsNext); } function jumpTo(nextMove) { setCurrentMove(nextMove); setXIsNext(nextMove % 2 === 0); } const moves = history.map((squares, move) => { let description; if (move > 0) { description = 'Go to move #' + move; } else { description = 'Go to game start'; } return ( <li key={move}> <button onClick={() => jumpTo(move)}>{description}</button> </li> ); }); return ( <div className="game"> <div className="game-board"> <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} /> </div> <div className="game-info"> <ol>{moves}</ol> </div> </div> ); } 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; }
最終清理
如果您仔細查看程式碼,您可能會注意到當 currentMove
為偶數時 xIsNext === true
,而當 currentMove
為奇數時 xIsNext === false
。換句話說,如果您知道 currentMove
的值,那麼您始終可以知道 xIsNext
應該是什麼。
沒有理由將這兩者都儲存在狀態中。事實上,始終盡量避免冗餘狀態。簡化您在狀態中儲存的內容可以減少錯誤,並使您的程式碼更易於理解。更改 Game
,使其不將 xIsNext
作為單獨的狀態變數儲存,而是根據 currentMove
計算出來。
export default function Game() {
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
const xIsNext = currentMove % 2 === 0;
const currentSquares = history[currentMove];
function handlePlay(nextSquares) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
}
function jumpTo(nextMove) {
setCurrentMove(nextMove);
}
// ...
}
您不再需要 xIsNext
狀態宣告或對 setXIsNext
的呼叫。現在,即使您在編寫組件程式碼時犯了錯誤,xIsNext
也不可能與 currentMove
不同步。
總結
恭喜!您已經建立了一個井字遊戲,它可以
- 讓您玩井字遊戲,
- 指示玩家何時贏得遊戲,
- 在遊戲進行時儲存遊戲的歷史記錄,
- 允許玩家查看遊戲的歷史記錄並查看遊戲棋盤的先前版本。
做得好!我們希望您現在覺得您對 React 的運作方式有了一定的了解。
在此處查看最終結果
import { useState } from 'react'; function Square({ value, onSquareClick }) { return ( <button className="square" onClick={onSquareClick}> {value} </button> ); } function Board({ xIsNext, squares, onPlay }) { function handleClick(i) { if (calculateWinner(squares) || squares[i]) { return; } const nextSquares = squares.slice(); if (xIsNext) { nextSquares[i] = 'X'; } else { nextSquares[i] = 'O'; } onPlay(nextSquares); } const winner = calculateWinner(squares); let status; if (winner) { status = 'Winner: ' + winner; } else { status = 'Next player: ' + (xIsNext ? 'X' : 'O'); } return ( <> <div className="status">{status}</div> <div className="board-row"> <Square value={squares[0]} onSquareClick={() => handleClick(0)} /> <Square value={squares[1]} onSquareClick={() => handleClick(1)} /> <Square value={squares[2]} onSquareClick={() => handleClick(2)} /> </div> <div className="board-row"> <Square value={squares[3]} onSquareClick={() => handleClick(3)} /> <Square value={squares[4]} onSquareClick={() => handleClick(4)} /> <Square value={squares[5]} onSquareClick={() => handleClick(5)} /> </div> <div className="board-row"> <Square value={squares[6]} onSquareClick={() => handleClick(6)} /> <Square value={squares[7]} onSquareClick={() => handleClick(7)} /> <Square value={squares[8]} onSquareClick={() => handleClick(8)} /> </div> </> ); } export default function Game() { const [history, setHistory] = useState([Array(9).fill(null)]); const [currentMove, setCurrentMove] = useState(0); const xIsNext = currentMove % 2 === 0; const currentSquares = history[currentMove]; function handlePlay(nextSquares) { const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]; setHistory(nextHistory); setCurrentMove(nextHistory.length - 1); } function jumpTo(nextMove) { setCurrentMove(nextMove); } const moves = history.map((squares, move) => { let description; if (move > 0) { description = 'Go to move #' + move; } else { description = 'Go to game start'; } return ( <li key={move}> <button onClick={() => jumpTo(move)}>{description}</button> </li> ); }); return ( <div className="game"> <div className="game-board"> <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} /> </div> <div className="game-info"> <ol>{moves}</ol> </div> </div> ); } 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; }
如果您有額外的時間或想練習您新的 React 技能,以下是一些您可以對井字遊戲進行改進的想法,按難度遞增順序排列:
- 僅針對當前步驟,顯示「您位於步驟 #…」而不是按鈕。
- 重寫
Board
以使用兩個迴圈來製作方塊,而不是硬編碼它們。 - 新增一個切換按鈕,讓您可以按升序或降序對步驟進行排序。
- 當有人獲勝時,突出顯示導致獲勝的三個方塊(當沒有人獲勝時,顯示一條關於結果為平局的消息)。
- 在步驟歷史記錄列表中以 (列, 行) 的格式顯示每個步驟的位置。
在本教學課程中,您已經接觸了 React 概念,包括元素、組件、屬性和狀態。現在您已經了解了這些概念在構建遊戲時的運作方式,請查看 用 React 思考 以了解相同的 React 概念在構建應用程式 UI 時是如何運作的。