React 井字棋
参考 React 的文档,用 React 搞个井字棋。代码实现主要还是参考的文档,不过也在原有的基础上也做了点优化和美化。
原型
先看原型的构成(其实是最终做完的效果,暂且当原型用):
且页面的 HTML 结构和 CSS 样式已经完成:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Hello Qiyuanc!</title>
<script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<script type="text/babel" src="./game.js"></script>
<link rel="stylesheet" type="text/css" href="game.css">
</head>
<body>
<div id="root"></div>
<script type="text/babel">
ReactDOM.render(<Game />, document.getElementById('root'))
</script>
</body>
</html>
/* 游戏界面样式 */
body {
background-color: #fafafa;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
/*background-image: url('./GimRt.jpg');*/
background-size: cover;
/*https://ss.im5i.com/2021/08/02/G8uZy.jpg*/
}
#root {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.game {
border: 2px solid #ccc;
padding: 20px;
margin-bottom: 20px;
}
.board {
display: flex;
flex-wrap: wrap;
width: 240px;
height: 240px;
margin: 0 auto;
border: 2px solid #ccc;
border-radius: 5px;
}
.square {
width: 80px;
height: 80px;
border: 1px solid #ccc;
text-align: center;
font-size: 3rem;
line-height: 80px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s ease;
}
.square:focus,
.square:hover {
background-color: #f2f2f2;
}
/* 按钮样式 */
button {
background-color: #4caf50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1.2rem;
margin: 4px;
margin-right: 10px;
}
button:hover {
background-color: #3e8e41;
}
button:disabled {
background-color: #aaa;
cursor: not-allowed;
}
下面就可以开始对页面的搭建了。
组件
其中,页面有一个游戏区域(方框区域),其中有井字棋棋盘和下方的回溯按钮(每走一步就多一个,可以回到之前的某一步);棋盘中的每个位置也可以看作由一个个方格组件构成。进行组件的拆分,可以分解为:
- 方格组件 Square,点击这个组件就相当于在此位置落子;
- 棋盘组件 Board,由九个方格组件构成棋盘,并且在上方显示当前落子方;
- 历史记录组件 Record,是一个包含了回溯按钮的列表,点击按钮可以回到之前某一步;
- 游戏组件 Game,程序最大的组件,包含其他组件,构成游戏。
分解完组件,再分析一下状态(本来应该先根据组件实现静态页面,再寻找状态添加交互的)。
根据状态的寻找思路(随时间会改变且不可计算),我们可以很快发现,随着时间推进,玩家在棋盘上落子,棋盘的布局会发生改变,且这种改变不可被计算,因此棋盘的布局属于状态。
同时,由于有历史记录功能,随着时间推进,每次落子都会更新历史记录,所以当前步数也是状态。回溯功能就是通过改变当前步数,触发重新渲染实现回溯。
也因为需要回溯,因此我们需要保存的不仅是当前棋盘,而是每一步的棋盘,既然如此,我们可以直接将棋盘的历史记录作为状态:
- 状态 history,是棋盘的历史记录,结构为一个数组,其中的每个元素都是某一步的棋盘布局;
- 状态 currentMove,是当前步数,记录当前走到第几步,对应 history 中这一步的棋盘布局。
对于这种小程序,流程应该是自底向上先实现静态页面,再添加交互,不过这样的流程有点长,官方文档也写的够详细,我就直接从完成的组件写,记录一下其中比较重要的地方好了。
方格组件
方格组件实现比较简单:
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
Square 组件内部由按钮构成,应用了 square 样式(不然就是一个很丑的按钮)。这是已经完成了自底向上数据流的组件,所以直接看起来会有点奇怪,但还好要素比较少。
其中,接收的 props 为当前方格的棋子(value)和在当前位置落子时触发的方法(onSquareClick)。value 仅用于显示,是自顶向下流到 Square 组件的数据,告知 Square 它当前位置是什么内容,onSquareClick 是自顶向下传递给 Square 组件的方法,这个方法的作用是为组件提供反向数据流,使得 Square 组件能将变化反馈给高层次的组件,触发重新渲染。
棋盘组件
棋盘组件的实现稍稍有点复杂:
function Board({ xIsNext, squares, onPlay }) {
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;
}
function handleClick(i) {
if (calculateWinner(squares) || squares[i]) {
return;
}
// 在 React 中,状态(state)对象和属性(props)对象都应该被视为不可变的(immutable)
// slice() 方法创建一个副本,slice 为浅拷贝(若元素为对象,则创建一个引用指向相同的对象),但此处是基本数据类型,因此修改新数组不会影响原数组
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>
<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>
</div>
);
}
在 props 中,xIsNext 表明下一步是 O 还是 X,squares 为当前的棋盘布局,是一个具有9个元素的数组,对应棋盘的9个位置。这两个参数都是由上层组件传递过来,Board 组件需要用到的。当棋盘上的某一个方格被点击时,通过将 squares 中对应的位置设置为 O 或 X,就改变了棋盘的布局。
但这个改变需要反馈给上层组件,让上层组件传递新的参数触发重新渲染才行。这就是 onPlay 方法的工作,构建了 Board 到其上层组件的反向数据流。
此处有一个关键点:即
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
中传递给 Square 组件的方法是 () => handleClick(0)
,这是一个箭头函数,与
function() {
handleClick(0)
}
类似,但箭头函数不会创建新的作用域,因此才能直接调用到 handleClick
。此处如果直接写
<Square value={squares[0]} onSquareClick={handleClick(0)} />
则执行时会报错,因为传递的不是一个方法,而是 handleClick(0)
执行的返回值了。
将这个方法传递给 Square 组件,点击 Square 组件时会触发该方法,在 handleClick
中,通过 slice()
方法复制之前的棋盘布局,然后根据当前点击的方格 i 和下一步的落子 xIsNext 创造出一个最新落子的棋盘,再通过向上的数据流 onPlay 将这个新数据交给上层组件。
历史记录组件
历史记录组件也比较简单:
function Record({ history , jumpTo }) {
// 遍历数组创建回溯按钮列表, 数组.map((元素,下标) => function(...))
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 (
<ol>
{moves}
</ol>
)
}
接收的 props 为棋盘的历史,和一个能改变当前步数的方法。棋盘的历史用于创建回溯按钮列表,有多少种棋盘就有多少个按钮;由于当前步数状态并不由此组件掌控,因此需要上层组件给出改变当前步数的入口,即 jumpTo 方法,这也是反向数据流的一部分。
游戏组件
到了最大的游戏组件了,这个组件虽然层次最高,但不算最复杂,它只要管理状态和传递数据/方法就可以了:
function Game() {
// 状态:棋盘和步数都随时间(操作)变化
// history 二维数组,其中的每个一维数组都是某一时刻的棋盘
const [history, setHistory] = React.useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = React.useState(0);
const xIsNext = currentMove % 2 === 0;
const currentSquares = history[currentMove];
// 通过改变 棋盘历史和当前步数 的状态,触发对页面的重新渲染
function handlePlay(nextSquares) {
// 截取 历史棋盘 下标从 0 到 currentMove 的子数组,与最新的棋盘组成新的 历史棋盘
// 如 当前下完了第2步,此时 currentMove 还是 1(set在之后),因此截取历史棋盘的 0、1 与当前的 2 组成新历史棋盘
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
}
// 通过改变 当前步数 的状态,触发对页面的重新渲染
function jump(nextMove) {
// 回到第0步,Remake棋盘
if(nextMove === 0) {
setHistory([Array(9).fill(null)])
}
setCurrentMove(nextMove);
}
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div className="game-info">
<Record history={history} jumpTo={jump} />
</div>
</div>
);
}
这个组件的核心在于创建了两个状态,并将要渲染的数据和改变状态的入口自顶向下传递,下层组件利用数据渲染自己负责的部分,同时可以调用改变状态的方法触发数据的重新渲染。
最终效果
我感觉还不错,但这篇东西我写的确实是依托谢特,也就我自己看得懂。
标签:井字棋,棋盘,const,handleClick,React,Square,组件,squares From: https://www.cnblogs.com/qiyuanc/p/Front8.html