教學:井字遊戲
在本教學中,您將構建一個小型井字遊戲。本教學不假設您具備任何現有的 React 知識。您在教學中學習的技術是構建任何 React 應用程式的基礎,完全理解它將讓您深入瞭解 React。
注意
本教學專為偏好動手實作學習並希望快速嘗試製作具體成果的人設計。如果您偏好逐步學習每個概念,請從描述使用者介面開始。
本教學分為以下幾個部分:
您將構建什麼?
在本教學中,您將使用 React 構建一個互動式井字遊戲。
您可以在這裡看到完成後的外觀:
如果您現在還不理解程式碼,或者不熟悉程式碼的語法,請不用擔心!本教學的目標是幫助您理解 React 及其語法。
我們建議您在繼續教學之前先查看上面的井字遊戲。您會注意到的一個功能是遊戲棋盤右側有一個編號清單。此清單提供了遊戲中所有移動的歷史記錄,並會隨著遊戲的進行而更新。
玩過完成的井字遊戲後,請繼續往下看。在本教學中,您將從一個更簡單的範本開始。我們的下一步是為您設定環境,以便您可以開始構建遊戲。
教學設定
在下面的即時程式碼編輯器中,點擊右上角的Fork,使用 CodeSandbox 網站在新分頁中開啟編輯器。CodeSandbox 允許您在瀏覽器中編寫程式碼並預覽使用者將如何看到您建立的應用程式。新分頁應顯示一個空方格和本教學的起始程式碼。
注意
您也可以使用本地開發環境來跟隨本教學。為此,您需要:
- 安裝Node.js
- 在您先前開啟的 CodeSandbox 分頁中,按下左上角按鈕開啟選單,然後在該選單中選擇下載沙盒以下載檔案的本機存檔
- 解壓縮存檔,然後開啟終端機並
cd到您解壓縮的目錄 - 使用
npm install安裝相依套件 - 執行
npm start以啟動本地伺服器,並按照提示在瀏覽器中查看執行的程式碼
如果您遇到困難,不要讓這阻止您!請改為線上跟隨,稍後再嘗試本地設定。
概覽
現在您已經設定好了,讓我們來概覽一下 React!
檢查起始程式碼
在 CodeSandbox 中,您會看到三個主要部分:

- 包含檔案清單的檔案 區段,例如
App.js、index.js、styles.css位於src資料夾中,以及一個名為public的資料夾 - 您將在其中看到所選檔案原始碼的程式碼編輯器
- 您將在其中看到所撰寫程式碼顯示效果的瀏覽器 區段
在 檔案 區段中應已選取 App.js 檔案。該檔案在 程式碼編輯器中的內容應為:
在 瀏覽器區段應會顯示一個內部有 X 的方塊,如下所示:

現在讓我們來看看入門程式碼中的檔案。
App.js
在 App.js中的程式碼建立了一個元件。在 React 中,元件是一段可重複使用的程式碼,代表使用者介面的一部分。元件用於渲染、管理和更新應用程式中的 UI 元素。讓我們逐行查看這個元件,了解其運作方式:
第一行定義了一個名為 Square的函式。export這個 JavaScript 關鍵字讓此函式可以在這個檔案外部存取。default關鍵字告訴其他使用您程式碼的檔案,這是您檔案中的主要函式。
第二行回傳一個按鈕。return這個 JavaScript 關鍵字表示其後的內容將作為值回傳給函式的呼叫者。<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檔案中建立的元件與網頁瀏覽器之間的橋樑。
第 1-5 行將所有必要的部分組合在一起:
- React
- 與網頁瀏覽器溝通的 React 函式庫 (React DOM)
- 元件的樣式
- 您在
App.js中建立的元件。
檔案的其餘部分將所有片段組合在一起,並將最終成品注入 public資料夾中的index.html。
建立棋盤
讓我們回到 App.js。您將在本教學的剩餘部分在此檔案中進行操作。
目前棋盤只有一個方格,但您需要九個!如果您只是嘗試複製貼上您的方格來建立兩個方格,像這樣:
您會得到這個錯誤:
控制台
/src/App.js:相鄰的 JSX 元素必須包裝在一個封閉標籤中。您是否想要一個 JSX 片段<>...</>?
React 元件需要回傳單個 JSX 元素,而不是多個相鄰的 JSX 元素(例如兩個按鈕)。要解決這個問題,您可以使用片段(<> 和 </>)來包裝多個相鄰的 JSX 元素,像這樣:
現在您應該看到:

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

糟糕!方格都在同一條線上,而不是您棋盤所需的網格狀。要解決這個問題,您需要用 div將您的方格分組為列,並新增一些 CSS 類別。同時,您將為每個方格指定一個數字,以確保您知道每個方格顯示在哪裡。
在 App.js 檔案中,將 Square元件更新為如下所示:
在 styles.css中定義的 CSS 會為具有className 為 board-row的 div 設定樣式。現在您已將元件分組到具有樣式的div列中,您就有了井字遊戲棋盤:

但您現在遇到一個問題。您名為 Square的元件,實際上已不再是單一方格。讓我們透過將名稱更改為Board來解決這個問題:
此時您的程式碼應該類似這樣:
注意
噓…這要打很多字!從此頁面複製貼上程式碼是可以的。但是,如果您想接受一點挑戰,我們建議只複製您自己至少手動輸入過一次的程式碼。
透過 props 傳遞資料
接下來,您將希望在用戶點選方格時,將方格的值從空白更改為「X」。根據您目前建構棋盤的方式,您需要複製貼上更新方格的程式碼九次(每個方格一次)!與其複製貼上,React 的元件架構允許您建立可重複使用的元件,以避免混亂、重複的程式碼。
首先,您要將定義第一個方格的行(<button className="square">1</button>)從您的Board元件複製到一個新的Square元件中:
接著,您將更新 BoardSquare 元件,使用 JSX 語法來渲染該 元件:
請注意,與瀏覽器的div不同,您自己的元件Board 和 Square 必須以大寫字母開頭。
讓我們來看看:

糟糕!您失去了之前擁有的數字方格。現在每個方格都顯示「1」。為了解決這個問題,您將使用props 將每個方格應有的值從父元件(Board)傳遞給其子元件(Square)。
更新 Square元件,使其讀取您將從Boardvalue傳遞的prop:
function Square({ value })表示 Square 元件可以接收一個名為value的 prop。
現在您希望在每個方格內顯示該value,而不是1。嘗試這樣做:
糟糕,這不是您想要的結果:

您想要渲染的是元件中名為 value的 JavaScript 變數,而不是「value」這個單字。要從 JSX「跳脫到 JavaScript」,您需要使用大括號。在 JSX 中為value加上大括號,如下所示:
目前,您應該會看到一個空的棋盤:

這是因為 Board 元件尚未將 valueprop 傳遞給它所渲染的每個Square 元件。為了解決這個問題,您將為 Boardvalue元件渲染的每個 prop:Square元件添加
現在您應該會再次看到一個數字網格:

您更新後的程式碼應如下所示:
建立互動式元件
讓我們在點擊Square元件時填入一個X。在 SquarehandleClick內部宣告一個名為的函式。然後,將onClick 添加到 Square返回的按鈕 JSX 元素的 props 中:
如果你現在點擊一個方格,你應該會在 CodeSandbox 的"clicked!"的日誌。多次點擊同一個方格會再次記錄瀏覽器 區域底部的 控制台 分頁中看到一條顯示 "clicked!"。重複出現相同訊息的日誌不會在控制台中建立新的行。相反地,你會在第一次 "clicked!"日誌旁邊看到一個遞增的計數器。
注意
如果你是在本地開發環境中進行本教學,你需要開啟瀏覽器的控制台。例如,如果你使用 Chrome 瀏覽器,可以透過鍵盤快捷鍵 Shift + Ctrl + J(在 Windows/Linux 上)或Option + ⌘ + J(在 macOS 上)來檢視控制台。
下一步,你希望 Square 元件能夠「記住」它被點擊過,並填入一個「X」標記。為了「記住」事物,元件會使用狀態。
React 提供了一個名為useState的特殊函式,你可以從你的元件中呼叫它來讓元件「記住」事物。讓我們將Square的當前值儲存在狀態中,並在Square被點擊時改變它。
在檔案頂部匯入useState。移除 Square 元件的 value 屬性。相反地,在 Square的開頭新增一行,呼叫useState。讓它回傳一個名為 value的狀態變數:
value 儲存值,而 setValue 是一個可用於改變值的函式。傳遞給 useState 的 null 用作此狀態變數的初始值,因此這裡的 value 一開始等於 null。
由於 Square元件不再接收屬性,你將從 Board 元件建立的九個 Square 元件中移除value 屬性:
現在你將修改Square,使其在被點擊時顯示一個「X」。將事件處理器console.log("clicked!"); 替換為 setValue('X');。現在你的Square元件看起來像這樣:
透過從 onClick處理器中呼叫這個set函式,你是在告訴 React 每當其<button>被點擊時重新渲染該Square。更新後,該 Square的value 將是 'X',因此你會在遊戲板上看到「X」。點擊任何方格,「X」應該會出現:

每個方格都有自己的狀態:儲存在每個方格中的value 完全獨立於其他方格。當你在一個元件中呼叫 set 函式時,React 也會自動更新其內部的子元件。
完成上述修改後,你的程式碼將如下所示:
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 個方格:
Array(9).fill(null)建立一個包含九個元素的陣列,並將每個元素設為null。圍繞它的useState()呼叫宣告了一個squares狀態變數,該變數最初設定為該陣列。陣列中的每個條目對應一個方格的值。稍後當你填滿棋盤時,squares陣列將如下所示:
現在你的Board元件需要將valueprop 向下傳遞給它渲染的每個Square:
接下來,你將編輯Square元件以接收來自 Board 元件的valueprop。這將需要移除 Square 元件自身對value的狀態追蹤以及按鈕的onClickprop:
此時你應該會看到一個空的井字棋棋盤:

而你的程式碼應該看起來像這樣:
現在每個方格將接收一個value屬性,其值可能是'X'、'O',或是代表空格的null。
接下來,你需要改變當一個 Square被點擊時會發生什麼事。Board元件現在負責維護哪些方格已被填寫。你需要建立一種方式,讓Square 能夠更新 Board 的狀態。由於狀態是定義它的元件私有的,你無法直接從 Square 更新 Board的狀態。
相反地,你將從 Board元件向下傳遞一個函式到Square 元件,並且讓 Square 在方格被點擊時呼叫該函式。你將從 Square元件被點擊時將呼叫的函式開始。你將稱該函式為onSquareClick:
接下來,你將把onSquareClick 函式加入 Square元件的屬性中:
現在你將把 onSquareClick 屬性連接到 Board元件中一個你將命名為handleClick的函式。要將onSquareClick連接到handleClick,你將傳遞一個函式給第一個Square 元件的 onSquareClick 屬性:
最後,你將在 Board 元件內部定義handleClick 函式,以更新儲存你棋盤狀態的 squares 陣列:
這個handleClick函式使用 JavaScript 的squares陣列的副本(nextSquares)。接著,slice()陣列方法建立一個handleClick 更新 nextSquares 陣列,將 X加到第一個(索引[0])方格。
呼叫 setSquares 函式會讓 React 知道元件的狀態已改變。這將觸發使用 squares狀態的元件(Board)及其子元件(組成棋盤的Square 元件)重新渲染。
注意
JavaScript 支援閉包,這意味著內部函式(例如handleClick)可以存取外部函式(例如 Board)中定義的變數和函式。handleClick 函式可以讀取 squares 狀態並呼叫 setSquares 方法,因為它們都定義在 Board函式內部。
現在你可以在棋盤上加入 X 了……但只能加到左上角的方格。你的handleClick 函式是硬編碼為更新左上角方格(索引 0)的。讓我們更新handleClick,使其能夠更新任何方格。為 handleClick函式新增一個參數i,用於接收要更新的方格索引:
接下來,你需要將那個i傳遞給handleClick。你可以嘗試直接在 JSX 中將方格的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}。這將解決無限迴圈的問題。
然而,定義九個不同的函式並為每個函式命名太過冗長。相反地,讓我們這樣做:
請注意新的() =>語法。在這裡,() => handleClick(0)是一個箭頭函式, 這是一種定義函式的簡短方式。當方格被點擊時,=>「箭頭」後面的程式碼將會執行,呼叫handleClick(0)。
現在你需要更新其他八個方格,讓它們從你傳遞的箭頭函式中呼叫handleClick。請確保每次呼叫 handleClick 的參數對應到正確方格的索引:
現在你可以再次透過點擊棋盤上的任何方格來加入 X:

但這次所有的狀態管理都由 Board元件處理!
你的程式碼應該看起來像這樣:
現在你的狀態處理位於 Board元件中,父元件Board 將屬性傳遞給子元件 Square,以便它們能正確顯示。當點擊一個 Square時,子元件Square 現在會要求父元件 Board 更新棋盤的狀態。當 Board 的狀態改變時,Board 元件和每個子元件 Square 都會自動重新渲染。將所有方格的狀態保留在 Board 元件中,將允許它在未來判斷勝利者。
讓我們回顧一下當用戶點擊棋盤左上角的方格以加入一個 X時會發生什麼:
- 點擊左上角的方格會執行
button從Square接收到的onClickprop 所傳遞的函數。該Square元件從Board接收該函數作為其onSquareClickprop。Board元件直接在 JSX 中定義了該函數。它呼叫handleClick並傳入參數0。 handleClick使用參數 (0) 將squares陣列的第一個元素從null更新為X。- 由於
Board元件的squares狀態已更新,因此Board及其所有子元件會重新渲染。這導致索引為0的Square元件的valueprop 從null變更為X。
最終,使用者會看到左上角的方格在點擊後從空白變為顯示 X。
注意
DOM 元素<button> 的 onClick屬性對 React 具有特殊意義,因為它是內建元件。對於像 Square 這樣的自訂元件,命名則由您決定。您可以為Square的onSquareClickprop 或Board的handleClick函數指定任何名稱,程式碼的執行效果都相同。在 React 中,慣例是使用onSomething來命名代表事件的 props,並使用handleSomething 來命名處理這些事件的函數定義。
為何不可變性很重要
請注意在handleClick中,您呼叫了.slice() 來建立 squares陣列的副本,而不是修改現有陣列。為了說明原因,我們需要討論不可變性以及為何學習不可變性很重要。
通常有兩種更改資料的方法。第一種方法是透過直接更改資料的值來突變 資料。第二種方法是用具有所需更改的新副本替換資料。如果您突變了 squares陣列,情況會如下所示:
而如果您在不突變 squares陣列的情況下更改資料,情況則會如下所示:
結果是相同的,但透過不直接突變(更改底層資料),您可以獲得幾個好處。
不可變性讓複雜功能的實現變得更加容易。在本教學的後續部分,您將實作一個「時間旅行」功能,讓您可以檢視遊戲歷史並「跳回」過去的移動。此功能並非遊戲專屬——能夠復原和重做特定操作是應用程式的常見需求。避免直接資料突變可以讓您保持資料的先前版本完好無損,並在之後重複使用它們。
不可變性還有另一個好處。預設情況下,當父元件的狀態發生變化時,所有子元件都會自動重新渲染。這甚至包括未受更改影響的子元件。雖然重新渲染本身對使用者來說並不明顯(您不應主動嘗試避免它!),但出於效能考量,您可能希望跳過明顯未受影響的樹狀結構部分的重新渲染。不可變性讓元件能夠非常低成本地比較其資料是否已更改。您可以在 memo API 參考中了解更多關於 React 如何選擇何時重新渲染元件的資訊。
輪流下棋
現在是時候修復這個井字遊戲的一個主要缺陷了:棋盤上無法標記「O」。
您將預設將第一步設為「X」。讓我們透過在 Board 元件中新增另一個狀態片段來追蹤這一點:
每次玩家移動時,xIsNext(一個布林值)將會被翻轉以決定下一位玩家是誰,並且遊戲狀態會被儲存。您將更新Board的handleClick函數來翻轉xIsNext的值:
現在,當您點擊不同的方格時,它們將在X和O之間交替,正如預期!
但是等等,有個問題。試著多次點擊同一個方格:

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