本教程将引导你逐步实现一个简单的井字棋游戏,并且不需要你对 React 有任何了解。在此过程中你会学习到一些编写 React 程序的基本知识,完全理解它们可以让你对 React 有比较深入的理解。
教程分成以下几个部分:
配置 是一些准备工作。
概览 介绍了 React 的 基础知识:组件、props 和 state。
完成游戏 介绍了 React 开发中 最常用的技术。
添加“时间旅行” 可以让你更深入地了解 React 的独特优势。
实现的是什么程序?
本教程将使用 React 实现一个交互式的井字棋游戏。
你可以在下面预览最终成果:
如果你还不是很明白上面的代码,不用担心!本教程的目的就是帮你理解 React 及其语法。
建议你在继续本教程之前,先看看上面的井字棋游戏。你会注意到的一项功能是,棋盘右侧有一个编号列表,它记录了游戏中落子的历史,并随着游戏的进行而更新。
体验完游戏以后,继续阅读本教程吧!我们将从一个更简单的模板开始。下一步将介绍相关配置,以便于你着手实现这个游戏。
配置
在下面的实时代码编辑器中,单击右上角的 Fork 来在新选项卡中打开 CodeSandbox 编辑器。CodeSandbox 让你能够在浏览器中编写代码并预览效果。一切顺利的话,你应该会看见一个空方块和本教程的初始代码。
export default function Square() {return <button className="square">X</button>;
}
注意
如果你想要使用本地开发环境来学习这个教程,需要按照下面的流程进行:
- 安装 Node.js
- 在之前打开的 CodeSandbox 选项卡中,按左上角的按钮打开菜单,然后选择 Download Sandbox,将代码压缩包下载到本地。
- 将压缩包解压,打开终端并使用
cd
命令切换到你解压后的目录。 - 使用
npm install
安装依赖。 - 运行
npm start
启动本地服务器,按照提示在浏览器中查看运行效果。
如果你遇到了困难,不要花费时间去找解决方案。请改为在线进行操作,稍后再尝试本地配置。
概览
完成配置以后,我们先来大致了解一下 React 吧!
看一下刚刚的代码
在 CodeSandbox 中,你将看到三个主要的部分:
- Files 部分列出了一些文件:
App.js
、index.js
、styles.css
和public
文件夹。 - code editor 部分可以看到你所选中文件的源码。
- browser 部分可以预览代码的实时结果。
App.js
文件里面的内容应该是这样的:
export default function Square() {return <button className="square">X</button>;
}
现在,让我们仔细研究一下这些文件吧。
App.js
App.js
的代码创建了一个 组件。在 React 中,组件是一段可重用代码,它通常作为 UI 界面的一部分。组件用于渲染、管理和更新应用中的 UI 元素。让我们逐行查看这段代码,看看发生了什么:
第一行定义了一个名为 Square
的函数。JavaScript 的 export
关键字使此函数可以在此文件之外访问。default
关键字表明它是文件中的主要函数。
第二行返回一个按钮。JavaScript 的 return
关键字意味着后面的内容都作为值返回给函数的调用者。<button>
是一个 JSX 元素。JSX 元素是 JavaScript 代码和 HTML 标签的组合,用于描述要显示的内容。className="square"
是一个 button 属性,它决定 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
文件中创建的组件与 Web 浏览器之间的桥梁。
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';import App from './App';
第 1-5 行将所有必要的部分组合在一起:
- React
- React 与 Web 浏览器对话的库(React DOM)
- 组件的样式
App.js
里面创建的组件
其他文件将它们组合在一起,并将最终成果注入 public
文件夹里面的 index.html
中。
构建棋盘
让我们回到 App.js
。接下来我们将专注于这个文件。
目前棋盘只有一个方块,但你需要九个!如果你只是想着复制粘贴来制作两个像这样的方块:
export default function Square() {return <button className="square">X</button><button className="square">X</button>;
}
你将会得到如下错误:
React 组件必须返回单个 JSX 元素,不能像两个按钮那样返回多个相邻的 JSX 元素。要解决此问题,可以使用 Fragment(<>
与 </>
)包裹多个相邻的 JSX 元素,如下所示:
export default function Square() {return (<><button className="square">X</button><button className="square">X</button></>);
}
你现在可以看到
非常棒!现在你只需要通过复制粘贴来添加九个方块,然后……
但事与愿违的是这些方块并没有排列成网格,而是都在一条线上。要解决此问题,需要使用 div
将方块分到每一行中并添加一些 CSS 样式。当你这样做的时候,需要给每个方块一个数字,以确保你知道每个方块的位置。
App.js
文件中,Square
组件看起来像这样:
export default function Square() {return (<><div className="board-row"><button className="square">1</button><button className="square">2</button><button className="square">3</button></div><div className="board-row"><button className="square">4</button><button className="square">5</button><button className="square">6</button></div><div className="board-row"><button className="square">7</button><button className="square">8</button><button className="square">9</button></div></>);
}
借助 styles.css
中定义的 board-row
样式,我们将组件分到每一行的 div
中。最终完成了井字棋棋盘:
但是现在有个问题,名为 Square
的组件实际上不再是方块了。让我们通过将名称更改为 Board 来解决这个问题:
export default function Board() {//...
}
此时你的代码应如下所示:
export default function Board() {return (<><div className="board-row"><button className="square">1</button><button className="square">2</button><button className="square">3</button></div><div className="board-row"><button className="square">4</button><button className="square">5</button><button className="square">6</button></div><div className="board-row"><button className="square">7</button><button className="square">8</button><button className="square">9</button></div></>);
}
注意
嘶……要改的内容也太多了!从该页面复制和粘贴代码是很好的办法。不过如果你愿意挑战一下自己,可以只复制手动输入过的代码。
通过 props 传递数据
接下来,当用户单击方块时,我们要将方块的值从空更改为“X”。根据目前构建的棋盘,你需要复制并粘贴九次更新方块的代码(每个方块都需要一次)!但是,React 的组件架构可以创建可重用的组件,以避免混乱、重复的代码。
首先,要将定义第一个方块(<button className="square">1</button>
)的这行代码从 Board
组件复制到新的 Square
组件中:
function Square() {return <button className="square">1</button>;
}export default function Board() {// ...
}
然后,更新 Board 组件并使用 JSX 语法渲染 Square
组件:
// ...
export default function Board() {return (<><div className="board-row"><Square /><Square /><Square /></div><div className="board-row"><Square /><Square /><Square /></div><div className="board-row"><Square /><Square /><Square /></div></>);
}
需要注意的是,这并不像 div
,这些你自己的组件如 Board
和 Square
,必须以大写字母开头。
让我们来看一看效果:
哦不!你失去了你以前有正确编号的方块。现在每个方块都写着“1”。要解决此问题,需要使用 props 将每个方块应有的值从父组件(Board
)传递到其子组件(Square
)。
更新 Square
组件,读取从 Board
传递的 value
props:
function Square({ value }) {return <button className="square">1</button>;
}
function Square({ value })
表示可以向 Square 组件传递一个名为 value
的 props。
现在你如果想要显示对应的 value
而不是 1
,可以试一下像下面这样:
function Square({ value }) {return <button className="square">value</button>;
}
糟糕!这还不是你想要的:
我们需要从组件中渲染名为 value
的 JavaScript 变量,而不是“value”这个词。要从 JSX“转义到 JavaScript”,你需要使用大括号。在 JSX 中的 value
周围添加大括号,如下所示:
function Square({ value }) {return <button className="square">{value}</button>;
}
现在,你应该会看到一个空的棋盘了:
这是因为 Board
组件还没有将 value
props 传递给它渲染的每个 Square
组件。要修复这个问题,需要向 Board
组件里面的每个 Square
组件添加 value
props:
export default function Board() {return (<><div className="board-row"><Square value="1" /><Square value="2" /><Square value="3" /></div><div className="board-row"><Square value="4" /><Square value="5" /><Square value="6" /></div><div className="board-row"><Square value="7" /><Square value="8" /><Square value="9" /></div></>);
}
现在你应该能再次看到数字网格:
更新后的代码应该是这样:
function Square({ value }) {return <button className="square">{value}</button>;
}export default function Board() {return (<><div className="board-row"><Square value="1" /><Square value="2" /><Square value="3" /></div><div className="board-row"><Square value="4" /><Square value="5" /><Square value="6" /></div><div className="board-row"><Square value="7" /><Square value="8" /><Square value="9" /></div></>);
}
创建一个具有交互性的组件
当你单击它的时候,Square
组件需要显示“X”。在 Square
内部声明一个名为 handleClick
的函数。然后,将 onClick
添加到由 Square
返回的 JSX 元素的 button 的 props 中:
function Square({ value }) {function handleClick() {console.log('clicked!');}return (<buttonclassName="square"onClick={handleClick}>{value}</button>);
}
如果现在单击一个方块,你应该会看到一条日志,上面写着 "clicked!"
。在 CodeSandbox 中 Browser 部分底部的 Console 选项卡中。多次单击方块将再次记录 "clicked!"
。具有相同消息的重复控制台日志不会在控制台中重复创建。而你会在第一次 "clicked!"
旁边看到一个递增的计数器。
下一步,我们希望 Square 组件能够“记住”它被单击过,并用“X”填充它。为了“记住”一些东西,组件使用 state。
React 提供了一个名为 useState
的特殊函数,可以从组件中调用它来让它“记住”一些东西。让我们将 Square
的当前值存储在 state 中,并在单击 Square
时更改它。
在文件的顶部导入 useState
。从 Square
组件中移除 value
props。在调用 useState
的 Square
的开头添加一个新行。让它返回一个名为 value 的 state 变量:
import { useState } from 'react';function Square() {const [value, setValue] = useState(null);function handleClick() {
value
存储值,而 setValue
是可用于更改值的函数。传递给 useState
的 null
用作这个 state 变量的初始值,因此此处 value
的值开始时等于 null
。
由于 Square
组件不再接受 props,我们从 Board 组件创建的所有九个 Square 组件中删除 value
props:
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 (<buttonclassName="square"onClick={handleClick}>{value}</button>);
}
通过从 onClick
处理程序调用此 set
函数,你告诉 React
在单击其 <button>
时要重新渲染该 Square
。更新后,方块的值将为“X”,因此会在棋盘上看到“X”。点击任意方块,“X”应该出现:
每个 Square 都有自己的 state:存储在每个 Square 中的 value
完全独立于其他的 Square。当你在组件中调用 set 函数时,React 也会自动更新内部的子组件。
完成上述更改后,代码将如下所示:
import { useState } from 'react';function Square() {const [value, setValue] = useState(null);function handleClick() {setValue('X');}return (<buttonclassName="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 开发者工具可以检查 React 组件的 props 和 state。可以在 CodeSandbox 的 Browser 部分底部找到 React DevTools 选项卡:
完成这个游戏
至此,你已经拥有井字棋游戏的所有基本构建块。要玩完整的游戏,你现在需要在棋盘上交替放置“X”和“O”,并且你需要一种确定获胜者的方法。
状态提升
目前,每个 Square
组件都维护着游戏 state 的一部分。要检查井字棋游戏中的赢家,Board
需要以某种方式知道 9 个 Square
组件中每个组件的 state。
你会如何处理?起初,你可能会猜测 Board
需要向每个 Square
“询问”Square
的 state。尽管这种方法在 React 中在技术上是可行的,但我们不鼓励这样做,因为代码变得难以理解、容易出现错误并且难以重构。相反,最好的方法是将游戏的 state 存储在 Board
父组件中,而不是每个 Square
中。Board
组件可以通过传递一个 props 来告诉每个 Square
显示什么,就像你将数字传递给每个 Square 时所做的那样。
要从多个子组件收集数据,或让两个子组件相互通信,请改为在其父组件中声明共享 state。父组件可以通过 props 将该 state 传回给子组件。这使子组件彼此同步并与其父组件保持同步。
重构 React 组件时,将状态提升到父组件中很常见。
让我们借此机会尝试一下。编辑 Board
组件,使其声明一个名为 squares
的 state 变量,该变量默认为对应于 9 个方块的 9 个空值数组:
// ...
export default function Board() {const [squares, setSquares] = useState(Array(9).fill(null));return (// ...);
}
Array(9).fill(null)
创建了一个包含九个元素的数组,并将它们中的每一个都设置为 null
。包裹它的 useState()
声明了一个初始设置为该数组的 squares
state 变量。数组中的每个元素对应于一个 square 的值。当你稍后填写棋盘时,squares
数组将如下所示:
['O', null, 'X', 'X', 'X', 'O', 'O', null, null]
现在你的 Board
组件需要将 value
props 向下传递给它渲染的每个 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
props。这将需要删除 Square 组件自己的 value
state 和按钮的 onClick
props:
function Square({value}) {return <button className="square">{value}</button>;
}
此时你应该看到一个空的井字棋棋盘:
你的代码应该是这样的:
import { useState } from 'react';function Square({ value }) {return <button className="square">{value}</button>;
}export default function Board() {const [squares, setSquares] = useState(Array(9).fill(null));return (<><div className="board-row"><Square value={squares[0]} /><Square value={squares[1]} /><Square value={squares[2]} /></div><div className="board-row"><Square value={squares[3]} /><Square value={squares[4]} /><Square value={squares[5]} /></div><div className="board-row"><Square value={squares[6]} /><Square value={squares[7]} /><Square value={squares[8]} /></div></>);
}
现在,每个 Square 都会收到一个 value
props,对于空方块,该 props 将是 'X'
、'O'
或 null
。
接下来,你需要更改单击 Square
时发生的情况。Board
组件现在维护已经填充过的方块。你需要为 Square
创建一种更新 Board
state 的方法。由于 state 对于定义它的组件是私有的,因此你不能直接从 Square
更新 Board
的 state。
你将从 Board
组件向下传递一个函数到 Square
组件,然后让 Square
在单击方块时调用该函数。我们将从单击 Square
组件时将调用的函数开始。调用该函数 onSquareClick
:
function Square({ value }) {return (<button className="square" onClick={onSquareClick}>{value}</button>);
}
接下来,将 onSquareClick
函数添加到 Square
组件的 props 中:
function Square({ value, onSquareClick }) {return (<button className="square" onClick={onSquareClick}>{value}</button>);
}
现在,你将把 onSquareClick
props 连接到 Board
组件中的一个函数,命名为 handleClick
。要将 onSquareClick
连接到 handleClick
,需要将一个函数传递给第一个 Square
组件的 onSquareClick
props:
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
函数来更新并保存棋盘 state 的 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 知道组件的 state 已经改变。这将触发使用 squares
state 的组件(Board
)及其子组件(构成棋盘的 Square
组件)的重新渲染。
如果你也想学习React也可以阅读官方文档