教學:井字遊戲

在本教學中,你將建構一個小型井字遊戲。本教學不假設你具備任何 React 知識。你在教學中學到的技巧是建構任何 React 應用程式的基礎,充分理解它將讓你深入了解 React。

注意

本教學適用於喜歡**透過實作學習**並希望快速嘗試製作具體事物的人。如果你更喜歡逐步學習每個概念,請從**描述 UI**開始。

本教學分為幾個部分:

  • **教學準備**將提供你一個**起點**來 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>;
}

注意

你也可以使用本機開發環境來學習本教學。要做到這一點,你需要:

  1. 安裝 Node.js
  2. 在你先前開啟的 CodeSandbox 分頁中,按下左上角的按鈕以開啟選單,然後在該選單中選擇**下載沙盒**,將檔案的壓縮檔下載到本地。
  3. 解壓縮壓縮檔,然後開啟終端機並使用 `cd` 命令進入你解壓縮的目錄。
  4. 使用 `npm install` 安裝相依套件。
  5. 執行 `npm start` 以啟動本地伺服器,並按照提示在瀏覽器中查看正在執行的程式碼。

如果你遇到問題,不要讓它阻止你!請改為線上學習,稍後再嘗試本機設定。

概觀

現在你已經準備好了,讓我們來了解一下 React!

檢查起始程式碼

在 CodeSandbox 中,你將看到三個主要區塊:

CodeSandbox with starter code
  1. **檔案**區塊,其中包含 `App.js`、`index.js`、`styles.css` 等檔案列表,以及一個名為 `public` 的資料夾。
  2. **程式碼編輯器**,你將在其中看到所選檔案的原始碼。
  3. **瀏覽器**區塊,你將在其中看到你撰寫的程式碼將如何顯示。

檔案區段中,應該選取`App.js`檔案。該檔案在程式碼編輯器中的內容應為

export default function Square() {
return <button className="square">X</button>;
}

瀏覽器區段應顯示一個帶有 X 的正方形,如下所示

x-filled square

現在讓我們看一下起始程式碼中的檔案。

`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>;
}

您將收到此錯誤

主控台
/src/App.js: 相鄰的 JSX 元素必須包裝在一個封閉標籤中。您是否想要一個 JSX 片段 `<>...</>`?

React 元件需要返回單個 JSX 元素,而不是多個相鄰的 JSX 元素,例如兩個按鈕。要解決此問題,您可以使用*片段*(`<>` 和 `</>`)來包裝多個相鄰的 JSX 元素,如下所示

export default function Square() {
return (
<>
<button className="square">X</button>
<button className="square">X</button>
</>
);
}

現在您應該看到

two x-filled squares

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

nine x-filled squares in a line

糟糕!方格都在一條線上,而不是像我們的棋盤所需的網格狀。要解決此問題,您需要使用 `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 樣式會設定具有 classNameboard-row 的 div。現在您已將組件分組成具有樣式的 div 的列,您就有了井字遊戲棋盤。

tic-tac-toe board filled with numbers 1 through 9

但您現在遇到一個問題。您名為 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 不同,您自己的組件 BoardSquare 必須以大寫字母開頭。

讓我們來看看

one-filled board

糟糕!您丟失了之前的編號方塊。現在每個方塊都顯示「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-filled board

您想要渲染組件中名為 value 的 JavaScript 變數,而不是單詞「value」。要從 JSX「跳脫到 JavaScript」,您需要使用大括號。像這樣在大括號中加入 JSX 中的 value

function Square({ value }) {
return <button className="square">{value}</button>;
}

目前,您應該會看到一個空的棋盤

empty board

這是因為 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>
</>
);
}

現在您應該再次看到數字網格

tic-tac-toe board filled with numbers 1 through 9

您更新後的程式碼應該看起來像這樣

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!" 日誌旁邊看到一個遞增的計數器。

注意

如果您使用本機開發環境 mengikuti 教程 ini,則需要打開瀏覽器的控制台。例如,如果您使用 Chrome 瀏覽器,則可以使用鍵盤快捷鍵 Shift + Ctrl + J(在 Windows/Linux 上)或 Option + ⌘ + J(在 macOS 上)查看控制台。

接下來,您希望 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 是一個可以用來改變值的函式。傳遞給 useStatenull 被用作此狀態變數的初始值,因此這裡的 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。更新後,Squarevalue 將為 'X',因此您將在遊戲板上看到「X」。點擊任何一個 Square,「X」就會出現。

adding xes to board

每個 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 in CodeSandbox

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

Selecting components on the page with React DevTools

注意

對於本地開發,React DevTools 可作為 Chrome、Firefox 和 Edge 瀏覽器的擴充功能使用。安裝它後,元件標籤頁將會出現在您瀏覽器開發者工具中,適用於使用 React 的網站。 Chrome, Firefox, and Edge

完成遊戲 ...

到目前為止,您已經具備了井字遊戲的所有基本組成部分。要完成一個完整的遊戲,您現在需要在棋盤上交替放置「X」和「O」,並且需要一種方法來確定勝利者。

提升狀態 ...

目前,每個 Square 元件都維護著遊戲狀態的一部分。要在井字遊戲中檢查勝利者,Board 需要以某種方式知道 9 個 Square 元件的狀態。

您會如何處理這個問題?起初,您可能會認為 Board 需要「詢問」每個 SquareSquare 的狀態。雖然這種方法在 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>;
}

此時,您應該會看到一個空的井字遊戲棋盤。

empty board

您的程式碼應該如下所示:

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 元件)重新渲染。

注意

JavaScript 支援閉包,這表示內部函式(例如 handleClick)可以存取外部函式(例如 Board)中定義的變數和函式。handleClick 函式可以讀取 squares 狀態並呼叫 setSquares 方法,因為它們都是在 Board 函式內定義的。

現在您可以將 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),導致無限迴圈。

主控台
太多次重新渲染。React 限制了渲染的次數以防止無限迴圈。

為什麼這個問題之前沒有發生?

當你傳遞 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。

filling the board with 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 時會發生什麼。

  1. 點擊左上角的方格會執行 buttonSquare 接收的作為其 onClick 屬性的函式。Square 元件從 Board 接收該函式作為其 onSquareClick 屬性。Board 元件直接在 JSX 中定義了該函式。它以 0 作為參數呼叫 handleClick
  2. handleClick 使用參數(0)將 squares 陣列的第一個元素從 null 更新為 X
  3. Board 元件的 squares 狀態已更新,因此 Board 及其所有子元件都會重新渲染。這會導致索引為 0Square 元件的 value 屬性從 null 變更為 X

最後,使用者會看到左上角的方格在點擊後從空白變成了 X

注意

DOM <button> 元素的 onClick 屬性對 React 來說具有特殊含義,因為它是一個內建元件。對於像 Square 這樣的自定義元件,命名由你決定。你可以為 Square 的 onSquareClick 屬性或 Board 的 handleClick 函式取任何名稱,程式碼的功能都相同。在 React 中,慣例是使用 onSomething 命名代表事件的屬性,並使用 handleSomething 命名處理這些事件的函式定義。

為何不可變性很重要

請注意,在 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(一個布林值)將被翻轉以確定下一個玩家,並且遊戲的狀態將被儲存。您將更新 BoardhandleClick 函式以翻轉 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 (
//...
);
}

現在,當您點擊不同的方格時,它們將在 XO 之間交替,正如預期的那樣!

但是等等,有個問題。嘗試多次點擊同一方格。

O overwriting an X

XO 覆蓋了!雖然這會為遊戲增添一個非常有趣的變化,但我們現在將繼續遵循原始規則。

當您用 XO 標記一個方格時,您沒有先檢查該方格是否已經有 XO 值。您可以通過*提前返回*來解決此問題。您將檢查該方格是否已經有 XO。如果方格已填滿,您將在 handleClick 函式中*提前返回* — 在它嘗試更新棋盤狀態之前。

function handleClick(i) {
if (squares[i]) {
return;
}
const nextSquares = squares.slice();
//...
}

現在您只能將 XO 添加到空的方格中!此時您的程式碼應如下所示:

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 之前或之後定義 calculateWinner 並不重要。讓我們將它放在最後,這樣您每次編輯組件時都不必滾動瀏覽它。

您將在 Board 組件的 handleClick 函式中呼叫 calculateWinner(squares) 來檢查是否有玩家獲勝。您可以在檢查使用者是否點擊了已具有 XO 的方格時同時執行此檢查。我們希望在兩種情況下都提前返回。

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 的數據,並讓它指示 Boardhistory 渲染之前的回合。

首先,添加一個帶有 export defaultGame 組件。讓它渲染 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 組件調用以更新遊戲。將 xIsNextcurrentSquareshandlePlay 作為屬性傳遞給 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 組件以採用三個屬性:xIsNextsquares 和一個新的 onPlay 函數,當玩家移動時,Board 可以使用更新的方塊陣列調用該函數。接下來,移除調用 useStateBoard 函數的前兩行。

function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
//...
}
// ...
}

現在,將 Board 組件中 handleClick 中的 setSquaressetXIsNext 調用替換為對新 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>
);
}

您可以在下方看到您的程式碼應該是什麼樣子。請注意,您應該在開發者工具主控台中看到一個錯誤訊息,指出:

主控台
警告:陣列或迭代器中的每個子項都應該有一個唯一的「key」屬性。請檢查 `Game` 的渲染方法。

您將在下一節中修復此錯誤。

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);
}
//...
}

現在,您將對在點擊方塊時呼叫的 GamehandlePlay 函式進行兩項更改。

  • 如果您「回到過去」然後從該點開始進行新的步驟,您只想保留到該點的歷史記錄。您將在 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 技能,以下是一些您可以對井字遊戲進行改進的想法,按難度遞增順序排列:

  1. 僅針對當前步驟,顯示「您位於步驟 #…」而不是按鈕。
  2. 重寫 Board 以使用兩個迴圈來製作方塊,而不是硬編碼它們。
  3. 新增一個切換按鈕,讓您可以按升序或降序對步驟進行排序。
  4. 當有人獲勝時,突出顯示導致獲勝的三個方塊(當沒有人獲勝時,顯示一條關於結果為平局的消息)。
  5. 在步驟歷史記錄列表中以 (列, 行) 的格式顯示每個步驟的位置。

在本教學課程中,您已經接觸了 React 概念,包括元素、組件、屬性和狀態。現在您已經了解了這些概念在構建遊戲時的運作方式,請查看 用 React 思考 以了解相同的 React 概念在構建應用程式 UI 時是如何運作的。