教程:井字棋
在本教程中,你将构建一个小的井字棋游戏。本教程不假定你具备任何 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 使此函数可以在文件外部访问。关键字 default 告诉使用你代码的其他文件,这是你文件中的主要函数。
第二行返回一个按钮。JavaScript 关键字return 表示其后的内容将作为值返回给函数的调用者。<button>是一个JSX 元素。JSX 元素是 JavaScript 代码和 HTML 标签的组合,用于描述你想要显示的内容。className="square"是按钮的一个属性或prop,它告诉 CSS 如何设置按钮的样式。X 是显示在按钮内部的文本,而 </button> 则关闭 JSX 元素,表示任何后续内容不应放置在按钮内部。
styles.css
点击 CodeSandbox 的文件区域中标有styles.css的文件。此文件定义了你的 React 应用的样式。前两个CSS 选择器(* 和 body)定义了你应用大部分区域的样式,而.square 选择器则定义了任何 className 属性设置为 square 的组件的样式。在你的代码中,这将匹配 App.js文件中 Square 组件的按钮。
index.js
点击 CodeSandbox 的文件区域中标有index.js 的文件。在本教程中你不会编辑此文件,但它是你在 App.js文件中创建的组件与 Web 浏览器之间的桥梁。
第 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组件,使其读取你将从Board 传递的 valueprop:
function Square({ value })表示 Square 组件可以接收一个名为value的 prop。
现在你希望在每个方格内显示那个value,而不是1。尝试这样做:
哎呀,这不是你想要的结果:

你希望渲染的是组件中名为 value的 JavaScript 变量,而不是单词“value”。要从 JSX 中“转义到 JavaScript”,你需要使用花括号。在 JSX 中像这样给value加上花括号:
现在,你应该会看到一个空的棋盘:

这是因为 Board 组件还没有将 valueprop 传递给它所渲染的每个Square 组件。要修复这个问题,你需要在 Board组件渲染的每个Square 组件上添加 valueprop:
现在你应该又能看到一个数字网格了:

你更新后的代码应该如下所示:
制作交互式组件
让我们实现点击Square组件时在其中填充一个X。在 Square组件内部声明一个名为handleClick的函数。然后,将onClick 添加到 Square返回的按钮 JSX 元素的 props 中:
如果你现在点击一个方格,你应该会在 CodeSandbox 的"clicked!"。多次点击同一个方格会再次记录浏览器 区域底部的 控制台 选项卡中看到一条日志,显示 "clicked!"。控制台中相同消息的重复日志不会创建新的行。相反,你会在第一条 "clicked!"日志旁边看到一个递增的计数器。
注意
如果你是在本地开发环境中跟随本教程,你需要打开浏览器的控制台。例如,如果你使用 Chrome 浏览器,可以通过键盘快捷键 Shift + Ctrl + J(在 Windows/Linux 上)或Option + ⌘ + J(在 macOS 上)来查看控制台。
下一步,你希望 Square 组件能够“记住”它被点击过,并用“X”标记填充它。为了“记住”事物,组件使用状态。
React 提供了一个名为useState 的特殊函数,你可以从组件中调用它来让它“记住”事物。让我们将 Square的当前值存储在状态中,并在Square被点击时更改它。
在文件顶部导入useState。从 value属性。相反,在Square 组件中移除 Square的开头添加一行新代码,调用useState。让它返回一个名为 value的状态变量:
value 存储值,而 setValue 是一个可用于更改值的函数。传递给 null用作此状态变量的初始值,因此这里的useState 的 value 起始值等于 null。
由于 Square组件不再接受属性,你需要从 Board 组件创建的九个 Square 组件中移除value 属性:
现在你将修改Square,使其在点击时显示一个“X”。将事件处理器console.log("clicked!"); 替换为 setValue('X');。现在你的Square组件看起来像这样:
通过在 set函数,你告诉 React 每当其onClick 处理器中调用这个 Square。更新后,<button> 被点击时重新渲染该 Square的value 将为 'X',因此你会在游戏棋盘上看到“X”。点击任意方格,“X”应该会出现:

每个方格都有自己的状态:每个方格中存储的value 完全独立于其他方格。当你在组件中调用 set 函数时,React 也会自动更新其内部的子组件。
完成上述更改后,你的代码将如下所示:
React 开发者工具
React DevTools 让你可以检查 React 组件的 props 和 state。你可以在 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:
此时你应该看到一个空的井字棋棋盘:

你的代码应该如下所示:
现在每个 Square 组件将接收一个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 中将 square 的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。你还想像 Board 过去所做的那样切换xIsNext:
这里,[...history, nextSquares]创建了一个新数组,其中包含history中的所有项,后面跟着nextSquares。(你可以将...history展开语法理解为“枚举history)
例如,如果history是[[null,null,null], ["X",null,null]],而nextSquares是["X",null,"O"],那么新的[...history, nextSquares]数组将是[[null,null,null], ["X",null,null], ["X",null,"O"]]。
至此,你已经将状态移至Game组件中,用户界面应该完全正常工作,就像重构之前一样。以下是此时代码应有的样子:
显示历史步骤
既然你记录了井字棋游戏的历史,现在就可以向玩家显示一个历史步骤列表。
像<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 是一个计算机程序,不知道你的意图,因此你需要为每个列表项指定一个键属性,以区分每个列表项与其兄弟项。如果你的数据来自数据库,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。如果要将 currentMove更改为偶数,你还需要将xIsNext 设置为 true。
现在,你需要对 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 概念在构建应用用户界面时是如何工作的。
