Hướng dẫn: Tic-Tac-Toe
Trong hướng dẫn này, bạn sẽ xây dựng một trò chơi tic-tac-toe nhỏ. Hướng dẫn này không yêu cầu kiến thức React sẵn có. Các kỹ thuật bạn sẽ học trong hướng dẫn là nền tảng để xây dựng bất kỳ ứng dụng React nào, và việc hiểu đầy đủ nó sẽ giúp bạn có hiểu biết sâu sắc về React.
Hướng dẫn được chia thành nhiều phần:
- Thiết lập cho hướng dẫn sẽ cung cấp cho bạn điểm bắt đầu để theo dõi hướng dẫn.
- Tổng quan sẽ dạy bạn những điều cơ bản của React: components, props, và state.
- Hoàn thiện trò chơi sẽ dạy bạn các kỹ thuật phổ biến nhất trong phát triển React.
- Thêm tính năng du hành thời gian sẽ cho bạn cái nhìn sâu sắc hơn về những điểm mạnh độc đáo của React.
Bạn sẽ xây dựng gì?
Trong hướng dẫn này, bạn sẽ xây dựng một trò chơi tic-tac-toe tương tác với React.
Bạn có thể xem trò chơi sẽ trông như thế nào khi hoàn thành ở đây:
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; }
Nếu mã code chưa có ý nghĩa với bạn, hoặc nếu bạn chưa quen với cú pháp của code, đừng lo lắng! Mục tiêu của hướng dẫn này là giúp bạn hiểu React và cú pháp của nó.
Chúng tôi khuyên bạn nên xem qua trò chơi tic-tac-toe ở trên trước khi tiếp tục với hướng dẫn. Một trong những tính năng mà bạn sẽ nhận thấy là có một danh sách đánh số ở bên phải bảng chơi. Danh sách này cung cấp lịch sử tất cả các nước đi đã xảy ra trong trò chơi, và nó được cập nhật khi trò chơi diễn ra.
Sau khi bạn đã chơi thử với trò chơi tic-tac-toe đã hoàn thành, hãy tiếp tục cuộn xuống. Bạn sẽ bắt đầu với một template đơn giản hơn trong hướng dẫn này. Bước tiếp theo của chúng tôi là thiết lập để bạn có thể bắt đầu xây dựng trò chơi.
Thiết lập cho hướng dẫn
Trong trình soạn thảo code trực tiếp bên dưới, nhấp vào Fork ở góc trên bên phải để mở trình soạn thảo trong tab mới sử dụng trang web CodeSandbox. CodeSandbox cho phép bạn viết code trong trình duyệt và xem trước cách người dùng sẽ thấy ứng dụng mà bạn đã tạo. Tab mới sẽ hiển thị một ô vuông trống và mã code khởi đầu cho hướng dẫn này.
export default function Square() { return <button className="square">X</button>; }
Tổng quan
Bây giờ bạn đã thiết lập xong, hãy tìm hiểu tổng quan về React!
Kiểm tra mã code khởi đầu
Trong CodeSandbox, bạn sẽ thấy ba phần chính:
- Phần Files với danh sách các file như
App.js,index.js,styles.cssvà một thư mục có tênpublic - Trình soạn thảo code nơi bạn sẽ thấy mã nguồn của file bạn đã chọn
- Phần browser nơi bạn sẽ thấy cách code bạn đã viết sẽ được hiển thị
File App.js nên được chọn trong phần Files. Nội dung của file đó trong trình soạn thảo code sẽ là:
export default function Square() {
return <button className="square">X</button>;
}Phần browser nên hiển thị một ô vuông có chữ X bên trong như sau:
Bây giờ hãy xem các file trong mã code khởi đầu.
App.js
Mã code trong App.js tạo ra một component. Trong React, một component là một đoạn code có thể tái sử dụng đại diện cho một phần của giao diện người dùng. Components được sử dụng để render, quản lý và cập nhật các phần tử UI trong ứng dụng của bạn. Hãy xem component từng dòng một để hiểu điều gì đang xảy ra:
export default function Square() {
return <button className="square">X</button>;
}Dòng đầu tiên định nghĩa một function có tên Square. Từ khóa JavaScript export làm cho function này có thể truy cập được từ bên ngoài file này. Từ khóa default cho các file khác sử dụng code của bạn biết rằng đây là function chính trong file của bạn.
export default function Square() {
return <button className="square">X</button>;
}Dòng thứ hai trả về một button. Từ khóa JavaScript return có nghĩa là bất cứ thứ gì đi sau nó sẽ được trả về như một giá trị cho người gọi function. <button> là một phần tử JSX. Một phần tử JSX là sự kết hợp giữa mã JavaScript và các thẻ HTML mô tả những gì bạn muốn hiển thị. className="square" là một thuộc tính button hoặc prop cho CSS biết cách tạo kiểu cho button. X là văn bản được hiển thị bên trong button và </button> đóng phần tử JSX để chỉ ra rằng bất kỳ nội dung nào sau đó không nên được đặt bên trong button.
styles.css
Nhấp vào file có nhãn styles.css trong phần Files của CodeSandbox. File này định nghĩa các kiểu cho ứng dụng React của bạn. Hai bộ chọn CSS đầu tiên (* và body) định nghĩa kiểu cho các phần lớn của ứng dụng của bạn trong khi bộ chọn .square định nghĩa kiểu cho bất kỳ component nào có thuộc tính className được đặt thành square. Trong code của bạn, điều đó sẽ khớp với button từ component Square của bạn trong file App.js.
index.js
Nhấp vào file có nhãn index.js trong phần Files của CodeSandbox. Bạn sẽ không chỉnh sửa file này trong suốt hướng dẫn nhưng nó là cầu nối giữa component bạn đã tạo trong file App.js và trình duyệt web.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import App from './App';Các dòng 1-5 tập hợp tất cả các phần cần thiết lại với nhau:
- React
- Thư viện React để giao tiếp với trình duyệt web (React DOM)
- các kiểu cho components của bạn
- component bạn đã tạo trong
App.js.
Phần còn lại của file tập hợp tất cả các phần lại với nhau và chèn sản phẩm cuối cùng vào index.html trong thư mục public.
Xây dựng bảng chơi
Hãy quay lại App.js. Đây là nơi bạn sẽ dành phần còn lại của hướng dẫn.
Hiện tại bảng chơi chỉ có một ô vuông, nhưng bạn cần chín ô! Nếu bạn chỉ thử và sao chép dán ô vuông của mình để tạo hai ô vuông như thế này:
export default function Square() {
return <button className="square">X</button><button className="square">X</button>;
}Bạn sẽ gặp lỗi này:
<>...</>?React components cần trả về một phần tử JSX duy nhất và không phải nhiều phần tử JSX liền kề như hai button. Để sửa lỗi này, bạn có thể sử dụng Fragments (<> và </>) để bọc nhiều phần tử JSX liền kề như sau:
export default function Square() {
return (
<>
<button className="square">X</button>
<button className="square">X</button>
</>
);
}Bây giờ bạn sẽ thấy:
Tuyệt vời! Bây giờ bạn chỉ cần sao chép-dán vài lần để thêm chín ô vuông và…
Ồ không! Các ô vuông đều nằm trên một dòng duy nhất, không phải trong một lưới như bạn cần cho bảng chơi. Để sửa lỗi này, bạn cần nhóm các ô vuông của mình thành các hàng bằng divs và thêm một số lớp CSS. Trong khi làm điều đó, bạn sẽ đặt cho mỗi ô vuông một số để đảm bảo bạn biết mỗi ô vuông được hiển thị ở đâu.
Trong file App.js, cập nhật component Square để trông như thế này:
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>
</>
);
}CSS được định nghĩa trong styles.css tạo kiểu cho các divs có className là board-row. Bây giờ bạn đã nhóm các component của mình thành các hàng với các divs đã được tạo kiểu, bạn đã có bảng chơi tic-tac-toe của mình:
Nhưng bây giờ bạn có một vấn đề. Component của bạn có tên Square, thực sự không còn là một ô vuông nữa. Hãy sửa điều đó bằng cách đổi tên thành Board:
export default function Board() {
//...
}Ở thời điểm này, code của bạn nên trông giống như thế này:
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> </> ); }
Truyền dữ liệu qua props
Tiếp theo, bạn sẽ muốn thay đổi giá trị của một ô vuông từ trống sang “X” khi người dùng nhấp vào ô vuông đó. Với cách bạn đã xây dựng bảng chơi cho đến nay, bạn sẽ cần sao chép-dán code cập nhật ô vuông chín lần (một lần cho mỗi ô vuông bạn có)! Thay vì sao chép-dán, kiến trúc component của React cho phép bạn tạo một component có thể tái sử dụng để tránh code lộn xộn, trùng lặp.
Đầu tiên, bạn sẽ sao chép dòng định nghĩa ô vuông đầu tiên của bạn (<button className="square">1</button>) từ component Board của bạn vào một component Square mới:
function Square() {
return <button className="square">1</button>;
}
export default function Board() {
// ...
}Sau đó bạn sẽ cập nhật component Board để render component Square đó bằng cú pháp JSX:
// ...
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>
</>
);
}Lưu ý rằng không giống như các divs của trình duyệt, các component của riêng bạn Board và Square phải bắt đầu bằng chữ cái viết hoa.
Hãy xem:
Ồ không! Bạn đã mất các ô vuông có số mà bạn đã có trước đó. Bây giờ mỗi ô vuông đều hiển thị “1”. Để sửa lỗi này, bạn sẽ sử dụng props để truyền giá trị mà mỗi ô vuông nên có từ component cha (Board) đến component con của nó (Square).
Cập nhật component Square để đọc prop value mà bạn sẽ truyền từ Board:
function Square({ value }) {
return <button className="square">1</button>;
}function Square({ value }) cho biết component Square có thể được truyền một prop có tên value.
Bây giờ bạn muốn hiển thị value đó thay vì 1 bên trong mỗi ô vuông. Hãy thử làm như thế này:
function Square({ value }) {
return <button className="square">value</button>;
}Ồ, đây không phải là điều bạn muốn:
Bạn muốn render biến JavaScript có tên value từ component của mình, không phải từ “value”. Để “thoát vào JavaScript” từ JSX, bạn cần dấu ngoặc nhọn. Thêm dấu ngoặc nhọn xung quanh value trong JSX như sau:
function Square({ value }) {
return <button className="square">{value}</button>;
}Hiện tại, bạn sẽ thấy một bảng trống:
Điều này là do component Board chưa truyền prop value cho mỗi component Square mà nó render. Để sửa lỗi này, bạn sẽ thêm prop value vào mỗi component Square được render bởi component Board:
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>
</>
);
}Bây giờ bạn sẽ lại thấy một lưới số:
Code đã cập nhật của bạn nên trông như thế này:
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> </> ); }
Tạo một component tương tác
Hãy điền component Square với một X khi bạn nhấp vào nó. Khai báo một function có tên handleClick bên trong Square. Sau đó, thêm onClick vào props của phần tử JSX button được trả về từ Square:
function Square({ value }) {
function handleClick() {
console.log('clicked!');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}Nếu bạn nhấp vào một ô vuông bây giờ, bạn sẽ thấy một log nói "clicked!" trong tab Console ở cuối phần Browser trong CodeSandbox. Nhấp vào ô vuông nhiều hơn một lần sẽ log "clicked!" lại. Các console log lặp lại với cùng một thông báo sẽ không tạo thêm dòng trong console. Thay vào đó, bạn sẽ thấy một bộ đếm tăng dần bên cạnh log "clicked!" đầu tiên của bạn.
Như một bước tiếp theo, bạn muốn component Square “nhớ” rằng nó đã được nhấp, và điền nó bằng dấu “X”. Để “nhớ” mọi thứ, components sử dụng state.
React cung cấp một function đặc biệt có tên useState mà bạn có thể gọi từ component của mình để cho phép nó “nhớ” mọi thứ. Hãy lưu trữ giá trị hiện tại của Square trong state, và thay đổi nó khi Square được nhấp.
Import useState ở đầu file. Xóa prop value khỏi component Square. Thay vào đó, thêm một dòng mới ở đầu Square gọi useState. Cho nó trả về một biến state có tên value:
import { useState } from 'react';
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
//...value lưu trữ giá trị và setValue là một function có thể được sử dụng để thay đổi giá trị. null được truyền vào useState được sử dụng làm giá trị ban đầu cho biến state này, vì vậy value ở đây bắt đầu bằng null.
Vì component Square không còn nhận props nữa, bạn sẽ xóa prop value khỏi tất cả chín component Square được tạo bởi component Board:
// ...
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>
</>
);
}Bây giờ bạn sẽ thay đổi Square để hiển thị một “X” khi được nhấp. Thay thế event handler console.log("clicked!"); bằng setValue('X');. Bây giờ component Square của bạn trông như thế này:
function Square() {
const [value, setValue] = useState(null);
function handleClick() {
setValue('X');
}
return (
<button
className="square"
onClick={handleClick}
>
{value}
</button>
);
}Bằng cách gọi function set này từ một handler onClick, bạn đang báo cho React biết để re-render Square đó bất cứ khi nào <button> của nó được nhấp. Sau khi cập nhật, value của Square sẽ là 'X', vì vậy bạn sẽ thấy “X” trên bảng chơi. Nhấp vào bất kỳ Square nào, và “X” sẽ xuất hiện:
Mỗi Square có state riêng của nó: value được lưu trữ trong mỗi Square hoàn toàn độc lập với các Square khác. Khi bạn gọi một function set trong một component, React tự động cập nhật các component con bên trong cũng vậy.
Sau khi bạn đã thực hiện các thay đổi ở trên, code của bạn sẽ trông như thế này:
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 Developer Tools
React DevTools cho phép bạn kiểm tra props và state của các React components của bạn. Bạn có thể tìm thấy tab React DevTools ở cuối phần browser trong CodeSandbox:
Để kiểm tra một component cụ thể trên màn hình, sử dụng nút ở góc trên bên trái của React DevTools:
Hoàn thiện trò chơi
Đến thời điểm này, bạn đã có tất cả các khối xây dựng cơ bản cho trò chơi tic-tac-toe của mình. Để có một trò chơi hoàn chỉnh, bây giờ bạn cần luân phiên đặt “X” và “O” trên bảng, và bạn cần một cách để xác định người thắng.
Nâng state lên
Hiện tại, mỗi component Square duy trì một phần state của trò chơi. Để kiểm tra người thắng trong trò chơi tic-tac-toe, Board sẽ cần bằng cách nào đó biết state của mỗi trong số 9 component Square.
Bạn sẽ tiếp cận điều đó như thế nào? Lúc đầu, bạn có thể đoán rằng Board cần “hỏi” mỗi Square về state của Square đó. Mặc dù cách tiếp cận này về mặt kỹ thuật có thể thực hiện được trong React, chúng tôi không khuyến khích vì code trở nên khó hiểu, dễ bị lỗi, và khó refactor. Thay vào đó, cách tiếp cận tốt nhất là lưu trữ state của trò chơi trong component cha Board thay vì trong mỗi Square. Component Board có thể cho mỗi Square biết cần hiển thị gì bằng cách truyền một prop, giống như bạn đã làm khi truyền một số cho mỗi Square.
Để thu thập dữ liệu từ nhiều component con, hoặc để có hai component con giao tiếp với nhau, hãy khai báo state dùng chung trong component cha của chúng. Component cha có thể truyền state đó xuống các component con thông qua props. Điều này giữ cho các component con đồng bộ với nhau và với component cha của chúng.
Nâng state lên component cha là điều phổ biến khi các React components được refactor.
Hãy tận dụng cơ hội này để thử nó. Chỉnh sửa component Board để nó khai báo một biến state có tên squares mặc định là một mảng gồm 9 null tương ứng với 9 ô vuông:
// ...
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
// ...
);
}Array(9).fill(null) tạo một mảng với chín phần tử và đặt mỗi phần tử thành null. Lời gọi useState() xung quanh nó khai báo một biến state squares ban đầu được đặt thành mảng đó. Mỗi mục trong mảng tương ứng với giá trị của một ô vuông. Khi bạn điền bảng sau này, mảng squares sẽ trông như thế này:
['O', null, 'X', 'X', 'X', 'O', 'O', null, null]Bây giờ component Board của bạn cần truyền prop value xuống mỗi Square mà nó render:
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>
</>
);
}Tiếp theo, bạn sẽ chỉnh sửa component Square để nhận prop value từ component Board. Điều này sẽ yêu cầu xóa việc theo dõi state của value trong component Square và prop onClick của button:
function Square({value}) {
return <button className="square">{value}</button>;
}Ở thời điểm này bạn sẽ thấy một bảng chơi tic-tac-toe trống:
Và code của bạn nên trông như thế này:
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> </> ); }
Mỗi Square bây giờ sẽ nhận một prop value sẽ là 'X', 'O', hoặc null cho các ô vuông trống.
Tiếp theo, bạn cần thay đổi điều gì xảy ra khi một Square được nhấp. Component Board bây giờ duy trì các ô vuông nào đã được điền. Bạn sẽ cần tạo một cách để Square cập nhật state của Board. Vì state là riêng tư đối với component định nghĩa nó, bạn không thể cập nhật state của Board trực tiếp từ Square.
Thay vào đó, bạn sẽ truyền một function từ component Board xuống component Square, và bạn sẽ có Square gọi function đó khi một ô vuông được nhấp. Bạn sẽ bắt đầu với function mà component Square sẽ gọi khi nó được nhấp. Bạn sẽ gọi function đó là onSquareClick:
function Square({ value }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}Tiếp theo, bạn sẽ thêm function onSquareClick vào props của component Square:
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}Bây giờ bạn sẽ kết nối prop onSquareClick với một function trong component Board mà bạn sẽ đặt tên là handleClick. Để kết nối onSquareClick với handleClick, bạn sẽ truyền một function vào prop onSquareClick của component Square đầu tiên:
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={handleClick} />
//...
);
}Cuối cùng, bạn sẽ định nghĩa function handleClick bên trong component Board để cập nhật mảng squares chứa state của bảng chơi:
export default function Board() {
const [squares, setSquares] = useState(Array(9).fill(null));
function handleClick() {
const nextSquares = squares.slice();
nextSquares[0] = "X";
setSquares(nextSquares);
}
return (
// ...
)
}Function handleClick tạo một bản sao của mảng squares (nextSquares) bằng phương thức Array slice() của JavaScript. Sau đó, handleClick cập nhật mảng nextSquares để thêm X vào ô vuông đầu tiên (index [0]).
Gọi function setSquares cho React biết state của component đã thay đổi. Điều này sẽ kích hoạt re-render của các components sử dụng state squares (Board) cũng như các component con của nó (các component Square tạo nên bảng chơi).
Bây giờ bạn có thể thêm X vào bảng… nhưng chỉ vào ô vuông trên bên trái. Function handleClick của bạn được hardcode để cập nhật index cho ô vuông trên bên trái (0). Hãy cập nhật handleClick để có thể cập nhật bất kỳ ô vuông nào. Thêm một đối số i vào function handleClick nhận index của ô vuông cần cập nhật:
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 (
// ...
)
}Tiếp theo, bạn sẽ cần truyền i đó vào handleClick. Bạn có thể thử đặt prop onSquareClick của square thành handleClick(0) trực tiếp trong JSX như thế này, nhưng nó sẽ không hoạt động:
<Square value={squares[0]} onSquareClick={handleClick(0)} />Đây là lý do tại sao điều này không hoạt động. Lời gọi handleClick(0) sẽ là một phần của việc render component board. Vì handleClick(0) thay đổi state của component board bằng cách gọi setSquares, toàn bộ component board của bạn sẽ được re-render lại. Nhưng điều này lại chạy handleClick(0) một lần nữa, dẫn đến một vòng lặp vô hạn:
Tại sao vấn đề này không xảy ra trước đó?
Khi bạn truyền onSquareClick={handleClick}, bạn đang truyền function handleClick xuống như một prop. Bạn không gọi nó! Nhưng bây giờ bạn đang gọi function đó ngay lập tức—chú ý dấu ngoặc đơn trong handleClick(0)—và đó là lý do tại sao nó chạy quá sớm. Bạn không muốn gọi handleClick cho đến khi người dùng nhấp!
Bạn có thể sửa lỗi này bằng cách tạo một function như handleFirstSquareClick gọi handleClick(0), một function như handleSecondSquareClick gọi handleClick(1), và cứ như vậy. Bạn sẽ truyền (thay vì gọi) các functions này xuống như props như onSquareClick={handleFirstSquareClick}. Điều này sẽ giải quyết vòng lặp vô hạn.
Tuy nhiên, định nghĩa chín functions khác nhau và đặt tên cho mỗi function là quá dài dòng. Thay vào đó, hãy làm như sau:
export default function Board() {
// ...
return (
<>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
// ...
);
}Chú ý cú pháp mới () =>. Ở đây, () => handleClick(0) là một arrow function, đây là cách ngắn gọn hơn để định nghĩa functions. Khi ô vuông được nhấp, code sau dấu “mũi tên” => sẽ chạy, gọi handleClick(0).
Bây giờ bạn cần cập nhật tám ô vuông còn lại để gọi handleClick từ các arrow functions bạn truyền. Đảm bảo rằng đối số cho mỗi lần gọi handleClick tương ứng với index của ô vuông đúng:
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>
</>
);
};Bây giờ bạn có thể lại thêm X vào bất kỳ ô vuông nào trên bảng bằng cách nhấp vào chúng:
Nhưng lần này tất cả việc quản lý state được xử lý bởi component Board!
Đây là cách code của bạn nên trông như thế:
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> </> ); }
Bây giờ việc xử lý state của bạn đã ở trong component Board, component cha Board truyền props xuống các component con Square để chúng có thể được hiển thị đúng. Khi nhấp vào một Square, component con Square bây giờ yêu cầu component cha Board cập nhật state của bảng. Khi state của Board thay đổi, cả component Board và mọi component con Square đều tự động re-render. Giữ state của tất cả các ô vuông trong component Board sẽ cho phép nó xác định người thắng trong tương lai.
Hãy tóm tắt lại những gì xảy ra khi người dùng nhấp vào ô vuông trên bên trái trên bảng của bạn để thêm một X vào đó:
- Nhấp vào ô vuông trên bên trái chạy function mà
buttonnhận được như proponClickcủa nó từSquare. ComponentSquarenhận được function đó như proponSquareClickcủa nó từBoard. ComponentBoardđã định nghĩa function đó trực tiếp trong JSX. Nó gọihandleClickvới đối số là0. handleClicksử dụng đối số (0) để cập nhật phần tử đầu tiên của mảngsquarestừnullthànhX.- State
squarescủa componentBoardđã được cập nhật, vì vậyBoardvà tất cả các component con của nó re-render. Điều này làm cho propvaluecủa componentSquarevới index0thay đổi từnullthànhX.
Cuối cùng người dùng thấy rằng ô vuông trên bên trái đã thay đổi từ trống thành có X sau khi nhấp vào nó.
Tại sao tính bất biến lại quan trọng
Chú ý cách trong handleClick, bạn gọi .slice() để tạo một bản sao của mảng squares thay vì sửa đổi mảng hiện có. Để giải thích tại sao, chúng ta cần thảo luận về tính bất biến và tại sao tính bất biến lại quan trọng để học.
Nhìn chung có hai cách tiếp cận để thay đổi dữ liệu. Cách tiếp cận đầu tiên là mutate (đột biến) dữ liệu bằng cách trực tiếp thay đổi các giá trị của dữ liệu. Cách tiếp cận thứ hai là thay thế dữ liệu bằng một bản sao mới có các thay đổi mong muốn. Đây là cách nó sẽ trông như thế nào nếu bạn mutate mảng squares:
const squares = [null, null, null, null, null, null, null, null, null];
squares[0] = 'X';
// Bây giờ `squares` là ["X", null, null, null, null, null, null, null, null];Và đây là cách nó sẽ trông như thế nào nếu bạn thay đổi dữ liệu mà không mutate mảng squares:
const squares = [null, null, null, null, null, null, null, null, null];
const nextSquares = ['X', null, null, null, null, null, null, null, null];
// Bây giờ `squares` không thay đổi, nhưng phần tử đầu tiên của `nextSquares` là 'X' thay vì `null`Kết quả là giống nhau nhưng bằng cách không mutate (thay đổi dữ liệu cơ bản) trực tiếp, bạn có được một số lợi ích.
Tính bất biến làm cho các tính năng phức tạp dễ triển khai hơn nhiều. Sau này trong hướng dẫn này, bạn sẽ triển khai tính năng “du hành thời gian” cho phép bạn xem lại lịch sử trò chơi và “nhảy ngược” về các nước đi trước đó. Chức năng này không chỉ dành riêng cho trò chơi—khả năng undo và redo các hành động nhất định là yêu cầu phổ biến cho các ứng dụng. Tránh đột biến dữ liệu trực tiếp cho phép bạn giữ nguyên các phiên bản trước đó của dữ liệu và sử dụng lại chúng sau này.
Cũng có một lợi ích khác của tính bất biến. Theo mặc định, tất cả các component con tự động re-render khi state của component cha thay đổi. Điều này bao gồm cả các component con không bị ảnh hưởng bởi thay đổi. Mặc dù re-rendering tự nó không đáng chú ý đối với người dùng (bạn không nên cố gắng tránh nó!), bạn có thể muốn bỏ qua re-rendering một phần của cây rõ ràng không bị ảnh hưởng bởi nó vì lý do hiệu suất. Tính bất biến làm cho việc so sánh xem dữ liệu của components đã thay đổi hay chưa trở nên rất rẻ. Bạn có thể tìm hiểu thêm về cách React chọn khi nào để re-render một component trong tham chiếu API memo.
Luân phiên lượt chơi
Bây giờ đã đến lúc sửa một lỗi lớn trong trò chơi tic-tac-toe này: các “O” không thể được đánh dấu trên bảng.
Bạn sẽ đặt nước đi đầu tiên là “X” theo mặc định. Hãy theo dõi điều này bằng cách thêm một phần state khác vào component Board:
function Board() {
const [xIsNext, setXIsNext] = useState(true);
const [squares, setSquares] = useState(Array(9).fill(null));
// ...
}Mỗi khi người chơi di chuyển, xIsNext (một boolean) sẽ được đảo ngược để xác định người chơi nào đi tiếp theo và state của trò chơi sẽ được lưu. Bạn sẽ cập nhật function handleClick của Board để đảo ngược giá trị của 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 (
//...
);
}Bây giờ, khi bạn nhấp vào các ô vuông khác nhau, chúng sẽ luân phiên giữa X và O, như chúng nên!
Nhưng đợi đã, có một vấn đề. Hãy thử nhấp vào cùng một ô vuông nhiều lần:
X bị ghi đè bởi một O! Mặc dù điều này sẽ thêm một biến thể rất thú vị cho trò chơi, chúng ta sẽ tuân theo các quy tắc gốc cho bây giờ.
Khi bạn đánh dấu một ô vuông bằng X hoặc O, bạn không kiểm tra trước xem ô vuông đó đã có giá trị X hoặc O chưa. Bạn có thể sửa lỗi này bằng cách return sớm. Bạn sẽ kiểm tra xem ô vuông đã có X hoặc O chưa. Nếu ô vuông đã được điền, bạn sẽ return trong function handleClick sớm—trước khi nó cố gắng cập nhật state của bảng.
function handleClick(i) {
if (squares[i]) {
return;
}
const nextSquares = squares.slice();
//...
}Bây giờ bạn chỉ có thể thêm X hoặc O vào các ô vuông trống! Đây là cách code của bạn nên trông như ở thời điểm này:
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> </> ); }
Khai báo người thắng
Bây giờ người chơi có thể luân phiên, bạn sẽ muốn hiển thị khi trò chơi đã thắng và không còn nước đi nào để thực hiện. Để làm điều này, bạn sẽ thêm một helper function có tên calculateWinner nhận một mảng gồm 9 ô vuông, kiểm tra người thắng và trả về 'X', 'O', hoặc null tùy theo trường hợp. Đừng lo lắng quá nhiều về function calculateWinner; nó không đặc biệt dành cho 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;
}Bạn sẽ gọi calculateWinner(squares) trong function handleClick của component Board để kiểm tra xem người chơi đã thắng chưa. Bạn có thể thực hiện kiểm tra này cùng lúc với việc kiểm tra xem người dùng đã nhấp vào một ô vuông đã có X hoặc O chưa. Chúng ta muốn return sớm trong cả hai trường hợp:
function handleClick(i) {
if (squares[i] || calculateWinner(squares)) {
return;
}
const nextSquares = squares.slice();
//...
}Để cho người chơi biết khi trò chơi kết thúc, bạn có thể hiển thị văn bản như “Winner: X” hoặc “Winner: O”. Để làm điều đó, bạn sẽ thêm một phần status vào component Board. Status sẽ hiển thị người thắng nếu trò chơi đã kết thúc và nếu trò chơi đang diễn ra, bạn sẽ hiển thị lượt chơi tiếp theo của người chơi nào:
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">
// ...
)
}Chúc mừng! Bây giờ bạn đã có một trò chơi tic-tac-toe hoạt động. Và bạn cũng vừa học được những điều cơ bản của React. Vì vậy bạn là người thắng thực sự ở đây. Đây là cách code của bạn nên trông như thế:
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; }
Thêm tính năng du hành thời gian
Như một bài tập cuối cùng, hãy làm cho việc “quay ngược thời gian” về các nước đi trước đó trong trò chơi trở nên có thể.
Lưu trữ lịch sử các nước đi
Nếu bạn mutate mảng squares, việc triển khai tính năng du hành thời gian sẽ rất khó khăn.
Tuy nhiên, bạn đã sử dụng slice() để tạo một bản sao mới của mảng squares sau mỗi nước đi, và xử lý nó như bất biến. Điều này sẽ cho phép bạn lưu trữ mọi phiên bản trước đó của mảng squares, và điều hướng giữa các lượt đã xảy ra.
Bạn sẽ lưu trữ các mảng squares trước đó trong một mảng khác có tên history, mà bạn sẽ lưu trữ như một biến state mới. Mảng history đại diện cho tất cả các trạng thái bảng, từ nước đi đầu tiên đến nước đi cuối cùng, và có hình dạng như thế này:
[
// 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'],
// ...
]Nâng state lên, một lần nữa
Bây giờ bạn sẽ viết một component cấp cao mới có tên Game để hiển thị danh sách các nước đi trước đó. Đó là nơi bạn sẽ đặt state history chứa toàn bộ lịch sử trò chơi.
Đặt state history vào component Game sẽ cho phép bạn xóa state squares khỏi component con Board của nó. Giống như bạn đã “nâng state lên” từ component Square vào component Board, bây giờ bạn sẽ nâng nó lên từ Board vào component cấp cao Game. Điều này cho component Game toàn quyền kiểm soát dữ liệu của Board và cho phép nó hướng dẫn Board render các lượt trước đó từ history.
Đầu tiên, thêm một component Game với export default. Cho nó render component Board và một số markup:
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>
);
}Lưu ý rằng bạn đang xóa các từ khóa export default trước khai báo function Board() { và thêm chúng trước khai báo function Game() {. Điều này cho file index.js của bạn biết sử dụng component Game như component cấp cao thay vì component Board của bạn. Các divs bổ sung được trả về bởi component Game đang tạo chỗ cho thông tin trò chơi mà bạn sẽ thêm vào bảng sau này.
Thêm một số state vào component Game để theo dõi người chơi nào đi tiếp theo và lịch sử các nước đi:
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
// ...Chú ý cách [Array(9).fill(null)] là một mảng với một mục duy nhất, bản thân nó là một mảng gồm 9 null.
Để render các ô vuông cho nước đi hiện tại, bạn sẽ muốn đọc mảng squares cuối cùng từ history. Bạn không cần useState cho việc này—bạn đã có đủ thông tin để tính toán nó trong quá trình render:
export default function Game() {
const [xIsNext, setXIsNext] = useState(true);
const [history, setHistory] = useState([Array(9).fill(null)]);
const currentSquares = history[history.length - 1];
// ...Tiếp theo, tạo một function handlePlay bên trong component Game sẽ được gọi bởi component Board để cập nhật trò chơi. Truyền xIsNext, currentSquares và handlePlay như props cho component 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} />
//...
)
}Hãy làm cho component Board hoàn toàn được điều khiển bởi các props mà nó nhận được. Thay đổi component Board để nhận ba props: xIsNext, squares, và một function onPlay mới mà Board có thể gọi với mảng squares đã cập nhật khi người chơi thực hiện một nước đi. Tiếp theo, xóa hai dòng đầu tiên của function Board gọi useState:
function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
//...
}
// ...
}Bây giờ thay thế các lời gọi setSquares và setXIsNext trong handleClick trong component Board bằng một lời gọi duy nhất đến function onPlay mới của bạn để component Game có thể cập nhật Board khi người dùng nhấp vào một ô vuông:
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);
}
//...
}Component Board hoàn toàn được điều khiển bởi các props được truyền cho nó bởi component Game. Bạn cần triển khai function handlePlay trong component Game để trò chơi hoạt động lại.
handlePlay nên làm gì khi được gọi? Hãy nhớ rằng Board trước đây gọi setSquares với một mảng đã cập nhật; bây giờ nó truyền mảng squares đã cập nhật cho onPlay.
Function handlePlay cần cập nhật state của Game để kích hoạt re-render, nhưng bạn không còn có function setSquares để gọi nữa—bây giờ bạn đang sử dụng biến state history để lưu trữ thông tin này. Bạn sẽ muốn cập nhật history bằng cách thêm mảng squares đã cập nhật như một mục lịch sử mới. Bạn cũng muốn đảo ngược xIsNext, giống như Board đã từng làm:
export default function Game() {
//...
function handlePlay(nextSquares) {
setHistory([...history, nextSquares]);
setXIsNext(!xIsNext);
}
//...
}Ở đây, [...history, nextSquares] tạo một mảng mới chứa tất cả các mục trong history, theo sau bởi nextSquares. (Bạn có thể đọc ...history spread syntax như “liệt kê tất cả các mục trong history”.)
Ví dụ, nếu history là [[null,null,null], ["X",null,null]] và nextSquares là ["X",null,"O"], thì mảng [...history, nextSquares] mới sẽ là [[null,null,null], ["X",null,null], ["X",null,"O"]].
Ở thời điểm này, bạn đã di chuyển state để sống trong component Game, và UI sẽ hoạt động đầy đủ, giống như trước khi refactor. Đây là cách code của bạn nên trông như ở thời điểm này:
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; }
Hiển thị các nước đi trước đó
Vì bạn đang ghi lại lịch sử trò chơi tic-tac-toe, bạn có thể hiển thị danh sách các nước đi trước đó cho người chơi.
Các phần tử React như <button> là các đối tượng JavaScript thông thường; bạn có thể truyền chúng xung quanh trong ứng dụng của mình. Để render nhiều mục trong React, bạn có thể sử dụng một mảng các phần tử React.
Bạn đã có một mảng các nước đi history trong state, vì vậy bây giờ bạn cần biến đổi nó thành một mảng các phần tử React. Trong JavaScript, để biến đổi một mảng thành mảng khác, bạn có thể sử dụng phương thức map của mảng:
[1, 2, 3].map((x) => x * 2) // [2, 4, 6]Bạn sẽ sử dụng map để biến đổi history các nước đi của bạn thành các phần tử React đại diện cho các button trên màn hình, và hiển thị một danh sách các button để “nhảy” đến các nước đi trước đó. Hãy map qua history trong component Game:
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>
);
}Bạn có thể xem code của bạn nên trông như thế nào bên dưới. Lưu ý rằng bạn sẽ thấy một lỗi trong console của developer tools nói rằng:
Bạn sẽ sửa lỗi này trong phần tiếp theo.
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; }
Khi bạn lặp qua mảng history bên trong function bạn đã truyền cho map, đối số squares đi qua từng phần tử của history, và đối số move đi qua từng chỉ số mảng: 0, 1, 2, …. (Trong hầu hết các trường hợp, bạn sẽ cần các phần tử mảng thực tế, nhưng để render danh sách các nước đi, bạn sẽ chỉ cần các chỉ số.)
Đối với mỗi nước đi trong lịch sử trò chơi tic-tac-toe, bạn tạo một mục danh sách <li> chứa một button <button>. Button có một handler onClick gọi một function có tên jumpTo (mà bạn chưa triển khai).
Bây giờ, bạn sẽ thấy một danh sách các nước đi đã xảy ra trong trò chơi và một lỗi trong console của developer tools. Hãy thảo luận về ý nghĩa của lỗi “key”.
Chọn một key
Khi bạn render một danh sách, React lưu trữ một số thông tin về mỗi mục danh sách đã render. Khi bạn cập nhật một danh sách, React cần xác định những gì đã thay đổi. Bạn có thể đã thêm, xóa, sắp xếp lại, hoặc cập nhật các mục của danh sách.
Hãy tưởng tượng chuyển đổi từ
<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>sang
<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>Ngoài các số đếm đã cập nhật, một người đọc điều này có thể sẽ nói rằng bạn đã đổi thứ tự của Alexa và Ben và chèn Claudia vào giữa Alexa và Ben. Tuy nhiên, React là một chương trình máy tính và không biết bạn muốn làm gì, vì vậy bạn cần chỉ định một thuộc tính key cho mỗi mục danh sách để phân biệt mỗi mục danh sách với các mục anh em của nó. Nếu dữ liệu của bạn đến từ cơ sở dữ liệu, các ID cơ sở dữ liệu của Alexa, Ben, và Claudia có thể được sử dụng làm keys.
<li key={user.id}>
{user.name}: {user.taskCount} tasks left
</li>Khi một danh sách được re-render, React lấy key của mỗi mục danh sách và tìm kiếm các mục của danh sách trước đó để tìm một key khớp. Nếu danh sách hiện tại có một key không tồn tại trước đó, React tạo một component. Nếu danh sách hiện tại thiếu một key đã tồn tại trong danh sách trước đó, React hủy component trước đó. Nếu hai keys khớp, component tương ứng được di chuyển.
Keys cho React biết về danh tính của mỗi component, điều này cho phép React duy trì state giữa các lần re-render. Nếu key của một component thay đổi, component sẽ bị hủy và được tạo lại với một state mới.
key là một thuộc tính đặc biệt và được bảo lưu trong React. Khi một phần tử được tạo, React trích xuất thuộc tính key và lưu key trực tiếp trên phần tử được trả về. Mặc dù key có thể trông như nó được truyền như props, React tự động sử dụng key để quyết định component nào cần cập nhật. Không có cách nào để một component hỏi key mà component cha của nó đã chỉ định.
Được khuyến nghị mạnh mẽ rằng bạn gán keys phù hợp bất cứ khi nào bạn xây dựng danh sách động. Nếu bạn không có một key phù hợp, bạn có thể muốn xem xét cấu trúc lại dữ liệu của mình để có.
Nếu không có key nào được chỉ định, React sẽ báo lỗi và sử dụng chỉ số mảng làm key theo mặc định. Sử dụng chỉ số mảng làm key có vấn đề khi cố gắng sắp xếp lại các mục của danh sách hoặc chèn/xóa các mục danh sách. Truyền rõ ràng key={i} làm im lặng lỗi nhưng có cùng vấn đề như chỉ số mảng và không được khuyến nghị trong hầu hết các trường hợp.
Keys không cần phải duy nhất toàn cục; chúng chỉ cần duy nhất giữa các component và các anh em của chúng.
Triển khai tính năng du hành thời gian
Trong lịch sử trò chơi tic-tac-toe, mỗi nước đi trước đó có một ID duy nhất liên quan đến nó: đó là số thứ tự tuần tự của nước đi. Các nước đi sẽ không bao giờ được sắp xếp lại, xóa, hoặc chèn vào giữa, vì vậy an toàn khi sử dụng chỉ số nước đi làm key.
Trong function Game, bạn có thể thêm key như <li key={move}>, và nếu bạn tải lại trò chơi đã render, lỗi “key” của React sẽ biến mất:
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; }
Trước khi bạn có thể triển khai jumpTo, bạn cần component Game theo dõi bước nào người dùng đang xem. Để làm điều này, định nghĩa một biến state mới có tên currentMove, mặc định là 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];
//...
}Tiếp theo, cập nhật function jumpTo bên trong Game để cập nhật currentMove đó. Bạn cũng sẽ đặt xIsNext thành true nếu số mà bạn đang thay đổi currentMove thành là số chẵn.
export default function Game() {
// ...
function jumpTo(nextMove) {
setCurrentMove(nextMove);
setXIsNext(nextMove % 2 === 0);
}
//...
}Bây giờ bạn sẽ thực hiện hai thay đổi cho function handlePlay của Game được gọi khi bạn nhấp vào một ô vuông.
- Nếu bạn “quay ngược thời gian” và sau đó thực hiện một nước đi mới từ điểm đó, bạn chỉ muốn giữ lại lịch sử đến điểm đó. Thay vì thêm
nextSquaressau tất cả các mục (cú pháp...spread) tronghistory, bạn sẽ thêm nó sau tất cả các mục tronghistory.slice(0, currentMove + 1)để bạn chỉ giữ lại phần đó của lịch sử cũ. - Mỗi khi một nước đi được thực hiện, bạn cần cập nhật
currentMoveđể trỏ đến mục lịch sử mới nhất.
function handlePlay(nextSquares) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
setXIsNext(!xIsNext);
}Cuối cùng, bạn sẽ sửa đổi component Game để render nước đi hiện tại được chọn, thay vì luôn render nước đi cuối cùng:
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];
// ...
}Nếu bạn nhấp vào bất kỳ bước nào trong lịch sử trò chơi, bảng chơi tic-tac-toe sẽ ngay lập tức cập nhật để hiển thị bảng chơi trông như thế nào sau khi bước đó xảy ra.
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; }
Dọn dẹp cuối cùng
Nếu bạn nhìn code rất kỹ, bạn có thể nhận thấy rằng xIsNext === true khi currentMove là số chẵn và xIsNext === false khi currentMove là số lẻ. Nói cách khác, nếu bạn biết giá trị của currentMove, thì bạn luôn có thể tính ra xIsNext nên là gì.
Không có lý do gì để bạn lưu trữ cả hai trong state. Trên thực tế, luôn cố gắng tránh state dư thừa. Đơn giản hóa những gì bạn lưu trữ trong state giảm lỗi và làm cho code của bạn dễ hiểu hơn. Thay đổi Game để nó không lưu trữ xIsNext như một biến state riêng biệt và thay vào đó tính toán nó dựa trên 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);
}
// ...
}Bạn không còn cần khai báo state xIsNext hoặc các lời gọi đến setXIsNext. Bây giờ, không có khả năng xIsNext bị mất đồng bộ với currentMove, ngay cả khi bạn mắc lỗi khi code các components.
Tổng kết
Chúc mừng! Bạn đã tạo một trò chơi tic-tac-toe mà:
- Cho phép bạn chơi tic-tac-toe,
- Cho biết khi nào một người chơi đã thắng trò chơi,
- Lưu trữ lịch sử trò chơi khi trò chơi diễn ra,
- Cho phép người chơi xem lại lịch sử trò chơi và xem các phiên bản trước đó của bảng chơi.
Làm tốt lắm! Chúng tôi hy vọng bây giờ bạn cảm thấy như bạn đã nắm được cách React hoạt động.
Xem kết quả cuối cùng ở đây:
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; }
Nếu bạn có thời gian rảnh hoặc muốn luyện tập các kỹ năng React mới của mình, đây là một số ý tưởng cải tiến mà bạn có thể thực hiện cho trò chơi tic-tac-toe, được liệt kê theo thứ tự độ khó tăng dần:
- Chỉ cho nước đi hiện tại, hiển thị “Bạn đang ở nước đi #…” thay vì một button.
- Viết lại
Boardđể sử dụng hai vòng lặp để tạo các ô vuông thay vì hardcode chúng. - Thêm một toggle button cho phép bạn sắp xếp các nước đi theo thứ tự tăng dần hoặc giảm dần.
- Khi ai đó thắng, làm nổi bật ba ô vuông gây ra chiến thắng (và khi không ai thắng, hiển thị một thông báo về kết quả là hòa).
- Hiển thị vị trí cho mỗi nước đi theo định dạng (row, col) trong danh sách lịch sử nước đi.
Trong suốt hướng dẫn này, bạn đã tiếp xúc với các khái niệm React bao gồm elements, components, props, và state. Bây giờ bạn đã thấy cách các khái niệm này hoạt động khi xây dựng một trò chơi, hãy xem Tư duy trong React để xem cách các khái niệm React tương tự hoạt động khi xây dựng UI của một ứng dụng.