通过构建打地鼠游戏来开始使用 React
想开始学习 React 却苦于找不到合适的入门途径?这篇文章应该能帮到你。我们将重点介绍 React 的一些核心概念,然后从零开始构建一个游戏!我们假设你已经掌握了 JavaScript 的基本知识——对了,如果你是冲着游戏来的,请直接向下滚动。
我使用 React 已经很久了,从 v0.12 版本左右就开始用了(2014 年!哇,时间都去哪儿了?)。它变化很大,我还记得一路走来的一些“顿悟”时刻。但有一点始终没变,那就是使用 React 的思维方式。我们思考问题的方式与直接操作 DOM 完全不同。
我的学习方式是尽快把事情做起来并运行起来。然后,在必要时,我会深入研究文档等等。在实践中学习,享受乐趣,不断尝试。
目的
本文旨在向您展示足够的 React 知识,让您体验到一些“顿悟”时刻,并激发您的好奇心,促使您自行深入研究并创建自己的应用程序。我建议您查阅官方文档,以便深入了解任何您想探究的内容。本文不会重复文档内容。
请注意,您可以在CodePen中找到所有示例,但您也可以访问我的Github 仓库获取一个完整可运行的游戏。
第一个应用程序
你可以通过多种方式快速搭建一个 React 应用。下面是一个示例——除了 HTML 代码之外,这几乎包含了创建你的第一个 React 应用所需的一切。
import React from 'https://cdn.skypack.dev/react'
import { render } from 'https://cdn.skypack.dev/react-dom'
const App = () => <h1>{`Time: ${Date.now()}`}</h1>
render(<App/>, document.getElementById('app')
我们可以把它做得更小一些,像这样:
render(<h1>{`Time: ${Date.now()}`}</h1>, document.getElementById('app'))
在第一个版本中,` App<component>` 是一个组件。但是,这个例子告诉 React DOM渲染一个元素,而不是一个组件。元素指的是我们在两个例子中看到的 HTML 元素。组件的本质在于返回这些元素的函数。
在开始学习组件之前,我们先来了解一下“JS 中的 HTML”到底是怎么回事?
JSX
那个“JS 中的 HTML”就是 JSX。你可以在 React 文档中找到关于 JSX 的所有信息。简而言之,它是 JavaScript 的一个语法扩展,允许我们用 JavaScript 编写 HTML。它就像一种模板语言,拥有完整的 JavaScript 功能。实际上,它是对底层 API 的一种抽象。我们为什么要使用它?对大多数人来说,它比 HTML 更容易理解和使用。
React.createElement('h1', null, `Time: ${Date.now()}`)
使用 JSX 的关键在于,它几乎涵盖了 React 中 99% 的 DOM 元素添加方式。同时,它也是我们绑定事件处理的主要方式。至于剩下的 1%,则超出了本文的讨论范围。不过,有时我们需要渲染 React 应用之外的元素。我们可以使用 React DOM 的 Portal 来实现这一点。此外,我们还可以在组件生命周期内直接访问 DOM(稍后会详细介绍)。
JSX 中的属性采用驼峰命名法。例如,`{{name}}`onclick会变成`{{name}} onClick`。也有一些特殊情况,例如 `{{name}} class` 会变成 `{{name} } className`。此外,像 `{{name}}` 这样的属性style现在接受 `{{name Object}}` 而不是 `{{name}}` string。
const style = { backgroundColor: 'red' }
<div className="awesome-class" style={style}>Cool</div>
注:您可以 在这里查看所有属性差异。
渲染
如何将 JSX 代码注入 DOM?我们需要注入它。大多数情况下,我们的应用程序只有一个入口点。如果我们使用 React,我们会使用 React DOM 在该入口点插入元素/组件。不过,你也可以在不使用 React 的情况下使用 JSX。正如我们提到的,它是一种语法扩展。你可以改变 Babel 对 JSX 的解析方式,让它输出不同的内容。
内部所有操作都由 React 管理。当频繁修改 DOM 时,这可以带来一定的性能优势。这是因为 React 使用了虚拟 DOM。DOM 更新本身并不慢,但其对浏览器性能的影响才是真正影响性能的因素。每次更新 DOM 时,浏览器都需要计算需要进行的渲染更改,这可能会消耗大量资源。而使用虚拟 DOM,这些 DOM 更新会被保存在内存中,并在需要时分批与浏览器 DOM 同步。
我们完全可以在一个页面上放置多个应用程序,或者只用 React 管理页面的一部分。
举个例子。同一个应用程序在一些常规 HTML 代码之间渲染了两次。我们的 React 应用程序使用Date.now.
const App = () => <h1>{`Time: ${Date.now()}`}</h1>
在这个例子中,我们在一些常规 HTML 代码之间渲染了两次应用程序。我们应该会看到标题“Many React Apps”,后面跟着一些文本。然后出现我们应用程序的第一次渲染,接着是一些文本,最后是应用程序的第二次渲染。
要深入了解渲染,请查看文档。
组件和属性
这是理解 React 最关键的部分之一。组件是可复用的 UI 块,但其底层都是函数。组件本质上是函数,它们的参数被称为 props props。我们可以使用这些“props”来决定组件应该渲染什么。props 是“只读”的,你可以传递任何内容,甚至是其他组件。组件标签内的任何内容都可以通过一个特殊的 prop 来访问children。
组件是返回元素的函数。如果我们不想显示任何内容,则返回空值null。
我们可以用多种方式编写组件,但最终结果都是一样的。
使用函数
function App() {
return <h1>{`Time: ${Date.now()}`}</h1>
}
使用类
class App extends React.Component {
render() {
return <h1>{`Time: ${Date.now()}`}</h1>
}
}
在 Hooks(即将推出)发布之前,我们大量使用基于类的组件。我们需要它们来管理状态和访问组件 API。但是,随着 Hooks 的出现,基于类的组件的使用量有所减少。现在,我们通常选择基于函数的组件。这有很多好处。首先,它用更少的代码就能实现相同的功能。Hooks 也使得在组件之间共享和重用逻辑变得更加容易。此外,类可能会让人感到困惑。它们要求开发人员理解绑定和上下文。
我们将采用基于函数的方式,你会注意到我们的App组件使用了不同的样式。
const App = () => <h1>{`Time: ${Date.now()}`}</h1>
这没错。关键在于我们的组件要返回我们想要渲染的内容。在这个例子中,就是一个显示当前时间的 h1 元素。如果不需要写 `<head>`return等,那就不用写。不过,这完全取决于个人偏好。不同的项目可能会采用不同的风格。
如果我们更新多应用示例,使其接受props并将其提取h1为组件,会怎么样?
const Message = ({ message }) => <h1>{message}</h1>
const App = ({ message }) => <Message message={message} />
render(<App message={`Time: ${Date.now()}`}/>, document.getElementById('app'))
这样可行,现在我们可以更改messageprop,App从而渲染出不同的消息。我们本来可以创建组件Time,但是,创建Message组件意味着有很多机会可以重用它。这正是 React 的核心所在:围绕架构/设计做出决策。
如果我们忘记将 prop 传递给组件怎么办?我们可以提供一个默认值。以下是一些实现方法。
const Message = ({message = "You forgot me!"}) => <h1>{message}</h1>
或者通过defaultProps在我们的组件上进行指定。我们还可以提供propTypes,我建议您了解一下。它提供了一种对组件的 props 进行类型检查的方法。
Message.defaultProps = {
message: "You forgot me!"
}
我们可以通过不同的方式访问 props。我们使用了 ES6 的便捷机制来解构 props。但是,我们的Message组件也可以像这样写,并且功能相同。
const Message = (props) => <h1>{props.message}</h1>
props 是传递给组件的对象。我们可以用任何我们喜欢的方式读取它们。
我们的App组件甚至可能是这个
const App = (props) => <Message {...props}/>
这样会得到相同的结果。我们称之为“散户效应”。不过,最好还是明确说明我们传递的是什么。
我们也可以把它message当作孩子来看待。
const Message = ({ children }) => <h1>{children}</h1>
const App = ({ message }) => <Message>{message}</Message>
然后我们通过特殊道具引用该消息children。
我们不妨更进一步,比如让我们将一个属性App传递message给一个组件。
const Time = ({ children }) => <h1>{`Time: ${children}`}</h1>
const App = ({ message, messageRenderer: Renderer }) => <Renderer>{message}</Renderer>
render(<App message={`${Date.now()}`} messageRenderer={Time} />, document.getElementById('app'))
在这个例子中,我们创建了两个应用,一个渲染时间,另一个渲染消息。注意我们在解构赋值时是如何重命名messageRendererprop 的?React 不会将任何以小写字母开头的元素视为组件。这是因为任何以小写字母开头的元素都会被视为一个元素,并被渲染成 `<a> `。我们很少会用到这种模式,但它展示了任何元素都可以作为 prop,并且可以随意使用它。Renderer<messageRenderer>
需要明确的一点是,作为 prop 传递的任何内容都需要组件进行处理。例如,如果要将样式传递给组件,则需要读取这些样式并将其应用到正在渲染的元素上。
不要害怕尝试不同的方法。尝试不同的模式并加以练习。判断哪些代码应该作为组件的能力来自于实践。在某些情况下,答案显而易见;而在另一些情况下,你可能稍后才会意识到这一点并进行重构。
一个常见的例子就是应用程序的布局。从宏观角度思考一下它可能是什么样子。一个包含子元素的布局,包括页眉、页脚和一些主要内容。它会是什么样子呢?它可能看起来像这样。
const Layout = ({ children }) => (
<div className="layout">
<Header/>
<main>{children}</main>
<Footer/>
</div>
)
一切都像搭建积木一样。你可以把它想象成应用版的乐高积木。
事实上,我建议大家尽快熟悉Storybook(如果大家感兴趣,我可以专门写一些相关内容)。组件驱动开发并非 React 独有,其他框架也有类似的理念。转变思维方式,采用这种思路会大有裨益。
做出改变
到目前为止,我们只接触了静态渲染。一切都没有改变。学习 React 最重要的一点是理解 React 的工作原理。我们需要明白组件可以拥有状态。而且我们必须理解并尊重状态驱动一切的机制。我们的元素会根据状态的变化做出反应。React 只会在必要时重新渲染。
数据流也是单向的。就像瀑布一样,状态变化沿着 UI 层级向下流动。组件并不关心数据来自哪里。例如,一个组件可能希望通过 props 将状态传递给子组件。而这种变化可能会触发子组件的更新。或者,组件可以选择管理自己的内部状态,这些状态不会共享。
这些都是设计决策,随着你使用 React 的经验积累,这些决策会变得越来越容易。需要记住的关键是这种流程的单向性。要触发上层代码的更改,必须通过事件或其他方式(例如通过 props 传递)来实现。
我们来举个例子。
import React, { useEffect, useRef, useState } from 'https://cdn.skypack.dev/react'
import { render } from 'https://cdn.skypack.dev/react-dom'
const Time = () => {
const [time, setTime] = useState(Date.now())
const timer = useRef(null)
useEffect(() => {
timer.current = setInterval(() => setTime(Date.now()), 1000)
return () => clearInterval(timer.current)
}, [])
return <h1>{`Time: ${time}`}</h1>
}
const App = () => <Time/>
render(<App/>, document.getElementById('app'))
这里的内容确实不少。不过,我们先来介绍一下“Hooks”的用法。我们会用到“useEffect”、“useRef”和“useState”。这些都是实用函数,可以让我们访问组件的API。
如果你查看示例,会发现时间每秒更新一次1000ms。这是因为我们更新了time状态变量。我们是在 `on` 组件内部进行更新的setInterval。注意我们并没有time直接修改状态变量。状态变量被视为不可变的。我们通过setTime调用 `on` 组件返回的 `on` 方法来更新状态useState。每次状态更新时,如果该状态包含在渲染中,我们的组件都会重新渲染。`on`useState总是返回一个状态变量以及更新该状态的方法。传递的参数是该状态的初始值。
我们过去常常useEffect会介入组件的生命周期,例如状态变化等事件。组件在插入 DOM 时会被挂载,在从 DOM 中移除时会被卸载。为了介入这些生命周期阶段,我们使用 effects。我们可以在 effect 中返回一个函数,该函数会在组件卸载时触发。第二个参数useEffect决定了 effect 的运行时机,我们称之为依赖数组。任何列表中发生变化的项都会触发 effect 的运行。如果没有第二个参数,则 effect 会在每次渲染时运行。空数组则表示 effect 只会在第一次渲染时运行。这个数组通常包含状态变量或 props。
我们使用一种效果器,在组件安装和卸载时设置和拆除计时器。
我们使用一个引用ref来指向该计时器。引用ref提供了一种方法来保存对不会触发渲染的对象(例如定时器)的引用。我们不需要为计时器使用状态,因为它不影响渲染。但是,我们需要保留对它的引用,以便在卸载时清除它。
想在继续学习之前深入了解一下 Hooks 吗?我之前写过一篇关于它们的文章—— 《5 分钟了解 React Hooks》 。React官方文档里也有很多很棒的信息。
我们的Time组件有自己的内部状态来触发渲染。但是,如果我们想改变渲染间隔的长度呢?我们可以从App组件的上方进行管理。
const App = () => {
const [interval, updateInterval] = useState(1000)
return (
<Fragment>
<Time interval={interval} />
<h2>{`Interval: ${interval}`}</h2>
<input type="range" min="1" value={interval} max="10000" onChange={e => updateInterval(e.target.value)}/>
</Fragment>
)
}
我们的新interval值存储在状态中App。它决定了组件更新的频率Time。
该Fragment组件是一种特殊的组件,我们可以通过 `<component>` 访问它React。在`<component>` 中React,一个组件必须返回一个子元素或一个 `<div>`null元素。我们不能返回相邻的元素。但是,有时我们不想将内容包裹在 `<div>` 元素中div。` Fragment<component>` 允许我们避免使用包装元素,同时又能满足 React 的要求。
你还会注意到我们的第一个事件绑定发生在那里。我们使用它onChange作为属性input来更新interval。
更新后的值interval随后被传递给函数Time,并且该值的改变interval会触发我们的效果运行。这是因为我们useEffect钩子的第二个参数现在包含该值interval。
const Time = ({ interval }) => {
const [time, setTime] = useState(Date.now())
const timer = useRef(null)
useEffect(() => {
timer.current = setInterval(() => setTime(Date.now()), interval)
return () => clearInterval(timer.current)
}, [interval])
return <h1>{`Time: ${time}`}</h1>
}
试玩一下演示版,看看有哪些变化!
如果你想更深入地了解这些概念,我建议你查阅React 官方文档。不过,我们已经掌握了足够的 React 知识,可以开始做一些有趣的事情了!让我们开始吧!
打地鼠 React 游戏
准备好了吗?我们将用 React 创建我们自己的“打地鼠”游戏!这款广为人知的游戏理论上很简单,但实际开发起来却充满挑战。关键在于我们如何使用 React。我会略过样式和美化界面的部分,那是你们的任务!当然,如果你们有任何相关问题,我很乐意解答。
另外,这款游戏不会很“完善”。但是,它能玩。你可以把它变成你自己的游戏!添加你自己的功能等等。
设计
我们先来想想我们要制作什么。我们需要哪些组件等等。
- 开始/停止游戏
- 定时器
- 记分
- 布局
- 摩尔组分

起点
我们已经学会了如何制造一个组件,并且可以大致估算出我们需要什么。
import React, { Fragment } from 'https://cdn.skypack.dev/react'
import { render } from 'https://cdn.skypack.dev/react-dom'
const Moles = ({ children }) => <div>{children}</div>
const Mole = () => <button>Mole</button>
const Timer = () => <div>Time: 00:00</div>
const Score = () => <div>Score: 0</div>
const Game = () => (
<Fragment>
<h1>Whac a Mole</h1>
<button>Start/Stop</button>
<Score/>
<Timer/>
<Moles>
<Mole/>
<Mole/>
<Mole/>
<Mole/>
<Mole/>
</Moles>
</Fragment>
)
render(<Game/>, document.getElementById('app'))
启动/停止
在进行任何操作之前,我们需要能够启动和停止游戏。启动游戏会触发计时器和鼹鼠等元素。这时我们就可以引入条件渲染了。
const Game = () => {
const [playing, setPlaying] = useState(false)
return (
<Fragment>
{!playing && <h1>Whac a Mole</h1>}
<button onClick={() => setPlaying(!playing)}>
{playing ? 'Stop' : 'Start'}
</button>
{playing && (
<Fragment>
<Score />
<Timer />
<Moles>
<Mole />
<Mole />
<Mole />
<Mole />
<Mole />
</Moles>
</Fragment>
)}
</Fragment>
)
}
我们有一个状态变量,playing并用它来渲染所需的元素。在 JSX 中,我们可以使用“&&”条件语句,在条件为真时渲染某些内容true。这里,我们表示如果正在玩游戏,则渲染棋盘及其内容。这也会影响按钮文本,我们可以使用三元运算符。
定时器
让我们启动计时器。默认情况下,我们将设置一个时间限制30000ms。我们可以将此值声明为 React 组件之外的常量。
const TIME_LIMIT = 30000
养成将常量集中放在一处的好习惯。所有用于配置应用程序的内容都可以放在同一个地方。
我们的Timer组件只关心三件事。
- 时间正在倒计时;
- 更新间隔是多久?
- 它结束时会发生什么。
第一次尝试可能看起来像这样。
const Timer = ({ time, interval = 1000, onEnd }) => {
const [internalTime, setInternalTime] = useState(time)
const timerRef = useRef(time)
useEffect(() => {
if (internalTime === 0 && onEnd) onEnd()
}, [internalTime, onEnd])
useEffect(() => {
timerRef.current = setInterval(
() => setInternalTime(internalTime - interval),
interval
)
return () => {
clearInterval(timerRef.current)
}
}, [])
return <span>{`Time: ${internalTime}`}</span>
}
但是,它只更新一次?
我们沿用了之前的间隔技术。但是,问题在于我们state在间隔回调中使用了 `if` 函数。这是我们遇到的第一个“陷阱”。因为我们的 effect 依赖数组为空,所以它只会运行一次。`if` 函数的闭包setInterval使用了第一次渲染时 `if` 函数的值internalTime。这是一个很有意思的问题,它促使我们思考处理问题的方式。
注: 我强烈推荐阅读 Dan Abramov 的这篇文章,它深入探讨了定时器以及如何解决这个问题。这篇文章值得一读,能帮助你更深入地理解相关概念。其中一个问题是,空的依赖数组通常会在 React 代码中引入 bug。我还推荐使用一个eslint 插件来帮助指出这些问题。React 文档也强调了使用空依赖数组的潜在风险。
一种解决方法Timer是更新效果器的依赖数组。这意味着我们的算法timerRef会每隔一段时间更新一次。然而,这会导致精度漂移的问题。
useEffect(() => {
timerRef.current = setInterval(
() => setInternalTime(internalTime - interval),
interval
)
return () => {
clearInterval(timerRef.current)
}
}, [internalTime, interval])
如果你查看这个演示,你会发现它使用了两个不同的定时器,并将偏差记录到开发者控制台。间隔越短或时间越长,偏差就越大。
我们可以使用一个方法ref来解决这个问题。我们可以用它来跟踪效果internalTime,避免每隔一段时间就运行一次效果。
const timeRef = useRef(time)
useEffect(() => {
timerRef.current = setInterval(
() => setInternalTime((timeRef.current -= interval)),
interval
)
return () => {
clearInterval(timerRef.current)
}
}, [interval])
而且,即使时间间隔更短,这种方法也能显著降低漂移。定时器算是一种特殊情况。但是,它是一个很好的例子,让我们思考如何在 React 中使用 Hooks。这个例子让我印象深刻,也帮助我理解了“为什么”。
更新渲染结果,将时间除以1000并附加一个s,我们就得到了一个秒计时器。
这个计时器还比较简陋,时间长了肯定会有偏差。不过对于我们的游戏来说,这已经足够了。如果你想深入了解如何制作精确的计时器,可以看看这个关于用 JavaScript 创建精确计时器的视频。
得分
让我们实现更新分数的功能。如何计分呢?打地鼠!在我们的例子中,这意味着点击一个按钮button。现在,我们先给每个地鼠打一个 0 分100。我们可以将onWhack回调函数传递给我们的Mole程序。
const MOLE_SCORE = 100
const Mole = ({ onWhack }) => (
<button onClick={() => onWhack(MOLE_SCORE)}>Mole</button>
)
const Score = ({ value }) => <div>{`Score: ${value}`}</div>
const Game = () => {
const [playing, setPlaying] = useState(false)
const [score, setScore] = useState(0)
const onWhack = points => setScore(score + points)
return (
<Fragment>
{!playing && <h1>Whac a Mole</h1>}
<button onClick={() => setPlaying(!playing)}>{playing ? 'Stop' : 'Start'}</button>
{playing &&
<Fragment>
<Score value={score} />
<Timer
time={TIME_LIMIT}
onEnd={() => setPlaying(false)}
/>
<Moles>
<Mole onWhack={onWhack} />
<Mole onWhack={onWhack} />
<Mole onWhack={onWhack} />
<Mole onWhack={onWhack} />
<Mole onWhack={onWhack} />
</Moles>
</Fragment>
}
</Fragment>
)
}
注意onWhack回调函数是如何传递给每个组件的Mole。回调函数会更新我们的score状态。这些更新会触发渲染。
现在是时候在浏览器中安装React Developer Tools 扩展了。它有一个很棒的功能,可以高亮显示 DOM 中的组件渲染。打开 Dev Tools 中的“组件”选项卡,点击设置齿轮图标,然后选择“组件渲染时高亮显示更新”。
如果您点击此链接打开我们的演示,并将扩展程序设置为高亮显示渲染过程,您会看到计时器会随着时间变化而渲染。但是,当我们打地鼠时,所有组件都会重新渲染。
JSX 中的循环
你可能觉得我们渲染这些元素的方式Mole效率不高。你的想法是对的。我们可以考虑用循环的方式来渲染它们。
Array.map使用 JSX 时,我们99% 的时间都用来渲染一组对象。例如,
const USERS = [
{ id: 1, name: 'Sally' },
{ id: 2, name: 'Jack' },
]
const App = () => (
<ul>
{USERS.map(({ id, name }) => <li key={id}>{name}</li>)}
</ul>
)
另一种方法是在 for 循环中生成内容,然后通过函数渲染返回值。
return (
<ul>{getLoopContent(DATA)}</ul>
)
这个key属性是做什么用的?它帮助 React 确定需要渲染哪些更改。如果可以使用唯一标识符,那就用!作为最后的选择,可以使用集合中元素的索引。更多信息请阅读列表相关的文档。
在我们的示例中,我们没有任何数据可供使用。如果您需要生成一组数据,可以使用一个技巧。
new Array(NUMBER_OF_THINGS).fill().map()
在某些情况下,这可能对你有用。
return (
<Fragment>
<h1>Whac a Mole</h1>
<button onClick={() => setPlaying(!playing)}>{playing ? 'Stop' : 'Start'}</button>
{playing &&
<Board>
<Score value={score} />
<Timer time={TIME_LIMIT} onEnd={() => console.info('Ended')}/>
{new Array(5).fill().map((_, id) =>
<Mole key={id} onWhack={onWhack} />
)}
</Board>
}
</Fragment>
)
或者,如果您想要一个持久化的集合,可以使用类似这样的方法uuid。
import { v4 as uuid } from 'https://cdn.skypack.dev/uuid'
const MOLE_COLLECTION = new Array(5).fill().map(() => uuid())
// In our JSX
{MOLE_COLLECTION.map((id) =>
<Mole key={id} onWhack={onWhack} />
)}
游戏结束
我们只能通过开始按钮结束游戏。而且,当我们结束游戏后,下次开始游戏时分数会保留。目前,onEnd我们的游戏进程Timer也没有任何反应。
我们需要第三种状态,即我们已经完成playing但尚未结束的状态。在更复杂的应用中,我建议使用XState或reducer。但对于我们的应用,我们可以引入一个新的状态变量finished。当状态为 `state`!playing且`state` 为 `true` 时finished,我们可以显示分数、重置计时器,并提供重新开始的选项。
现在我们需要理清思路。如果游戏结束,那么除了切换之外playing,我们还需要切换另一个状态finished。我们可以创建一个“endGame与”startGame函数。
const endGame = () => {
setPlaying(false)
setFinished(true)
}
const startGame = () => {
setScore(0)
setPlaying(true)
setFinished(false)
}
游戏开始时,我们会重置游戏状态score,并将游戏置于初始playing状态。这会触发游戏界面渲染。游戏结束时,我们会将游戏状态设置finished为结束true。之所以不重置游戏状态score,是为了能够将其作为游戏结果显示出来。
而且,当我们的Timer程序结束时,它应该调用相同的函数。
<Timer time={TIME_LIMIT} onEnd={endGame} />
它可以在一个效果中做到这一点。如果internalTime命中0,则卸载并调用onEnd。
useEffect(() => {
if (internalTime === 0 && onEnd) {
onEnd()
}
}, [internalTime, onEnd])
我们可以调整 UI 渲染顺序,以渲染 3 种状态:
- 新鲜的
- 玩耍
- 完成的
<Fragment>
{!playing && !finished &&
<Fragment>
<h1>Whac a Mole</h1>
<button onClick={startGame}>Start Game</button>
</Fragment>
}
{playing &&
<Fragment>
<button
className="end-game"
onClick={endGame}
>
End Game
</button>
<Score value={score} />
<Timer
time={TIME_LIMIT}
onEnd={endGame}
/>
<Moles>
{new Array(NUMBER_OF_MOLES).fill().map((_, index) => (
<Mole key={index} onWhack={onWhack} />
))}
</Moles>
</Fragment>
}
{finished &&
<Fragment>
<Score value={score} />
<button onClick={startGame}>Play Again</button>
</Fragment>
}
</Fragment>
现在我们拥有了一个可以正常运行的游戏,只是缺少会移动的鼹鼠。
注意我们是如何复用这个Score组件的。有没有办法避免重复使用Score?能否把它放在单独的条件语句中?或者它必须出现在 DOM 中?这最终取决于你的设计。
你最终会不会用一个更通用的组件来处理这个问题?这些都是需要不断思考的问题。目标是让组件之间保持关注点分离。但是,你也需要考虑可移植性。
鼹鼠
鼹鼠是我们游戏的核心。它们对应用的其他部分漠不关心,但它们会告诉你它们的得分onWhack。这突出了游戏的便携性。
本“指南”不深入探讨样式。但是,对于我们的鼹鼠,我们可以创建一个容器,overflow: hidden按钮Mole可以在其中进出。鼹鼠的默认位置是隐藏在视图之外。
我们将引入第三方解决方案,让鼹鼠上下摆动。这是一个如何引入与 DOM 交互的第三方解决方案的示例。大多数情况下,我们使用 refs 来获取 DOM 元素。然后,我们在 effect 中使用我们的解决方案。
我们将使用GreenSock (GSAP)来实现鼹鼠的上下摆动功能。今天我们不会深入探讨 GSAP API。但是,如果您对它们的工作原理有任何疑问,请随时问我!
以下是更新Mole后的版本GSAP。
import gsap from 'https://cdn.skypack.dev/gsap'
const Mole = ({ onWhack }) => {
const buttonRef = useRef(null)
useEffect(() => {
gsap.set(buttonRef.current, { yPercent: 100 })
gsap.to(buttonRef.current, {
yPercent: 0,
yoyo: true,
repeat: -1,
})
}, [])
return (
<div className="mole-hole">
<button
className="mole"
ref={buttonRef}
onClick={() => onWhack(MOLE_SCORE)}>
Mole
</button>
</div>
)
}
我们为它添加了一个包装器,button这样就可以显示/隐藏它Mole。我们还给它添加了button一个ref。使用效果,我们可以创建一个缓动动画(GSAP 动画),使按钮上下移动。
你还会注意到,我们使用了 `className`,className它在 JSX 中相当于 `className` 属性class,用于应用类名。为什么不在classNameGSAP 中使用 `className` 呢?因为如果有很多元素都使用了 `className` className,我们的效果会尝试应用所有这些类名。这就是为什么 ` useRefclassName` 是一个不错的选择。
太棒了,现在我们有了上下摆动的MoleS,从功能上来说,我们的游戏就完整了。不过它们移动的速度都完全一样,这并不理想。它们应该以不同的速度移动。此外,S被击中的时间越长,得分也应该越低Mole。
我们的 Mole 组件内部逻辑可以处理得分和速度的更新。将初始值作为 props 传递speed,delay可以points使组件更加灵活。
<Mole key={index} onWhack={onWhack} points={MOLE_SCORE} delay={0} speed={2} />
现在,让我们来分析一下我们的Mole逻辑。
我们先来看看分数如何随时间减少。这或许是一个不错的选择ref。我们有一些不影响渲染的元素,它们的值可能会在闭包中丢失。我们在效果中创建动画,并且该效果不会重新创建。每次动画重复播放时,我们希望将分数points乘以一个倍数。分数可以有一个由属性定义的最小值pointsMin。
const bobRef = useRef(null)
const pointsRef = useRef(points)
useEffect(() => {
bobRef.current = gsap.to(buttonRef.current, {
yPercent: -100,
duration: speed,
yoyo: true,
repeat: -1,
delay: delay,
repeatDelay: delay,
onRepeat: () => {
pointsRef.current = Math.floor(
Math.max(pointsRef.current * POINTS_MULTIPLIER, pointsMin)
)
},
})
return () => {
bobRef.current.kill()
}
}, [delay, pointsMin, speed])
我们还创建了一个ref用于保存 GSAP 动画引用的变量。当组件Mole被移除时,我们会用到它。注意,我们还返回了一个在组件卸载时终止动画的函数。如果我们不在组件卸载时终止动画,重复执行的代码就会一直运行。
如果我们的内奸被干掉了怎么办?我们需要为此设立一个新的州。
const [whacked, setWhacked] = useState(false)
与其在`<class>`onWhack中使用 `prop` ,我们可以创建一个新函数。该函数会将 `value` 设置为 ` true` 并使用当前值调用 `get()` 方法。onClickbuttonwhackwhackedtrueonWhackpointsRef
const whack = () => {
setWhacked(true)
onWhack(pointsRef.current)
}
return (
<div className="mole-hole">
<button className="mole" ref={buttonRef} onClick={whack}>
Mole
</button>
</div>
)
最后一步是响应whacked效果中的状态变化useEffect。利用依赖数组,我们可以确保仅在whacked状态发生变化时运行效果。如果whacked状态为真true,则重置点,暂停动画,并开始Mole地下动画。进入地下后,等待一段随机延迟,然后再重新启动动画。动画会使用 `flash` 参数加快速度timescale,并将状态值设置whacked回真false。
useEffect(() => {
if (whacked) {
pointsRef.current = points
bobRef.current.pause()
gsap.to(buttonRef.current, {
yPercent: 100,
duration: 0.1,
onComplete: () => {
gsap.delayedCall(gsap.utils.random(1, 3), () => {
setWhacked(false)
bobRef.current
.restart()
.timeScale(bobRef.current.timeScale() * TIME_MULTIPLIER)
})
},
})
}
}, [whacked])
这让我们
最后一步是将属性传递给Mole实例,使其行为有所不同。但是,生成这些属性的方式可能会引发问题。
<div className="moles">
{new Array(MOLES).fill().map((_, id) => (
<Mole
key={id}
onWhack={onWhack}
speed={gsap.utils.random(0.5, 1)}
delay={gsap.utils.random(0.5, 4)}
points={MOLE_SCORE}
/>
))}
</div>
这样做会引发问题,因为每次渲染生成鼹鼠时,道具都会发生变化。更好的解决方案是Mole每次启动游戏时生成一个新的数组,然后遍历该数组。这样既能保持游戏的随机性,又不会出现问题。
const generateMoles = () => new Array(MOLES).fill().map(() => ({
speed: gsap.utils.random(0.5, 1),
delay: gsap.utils.random(0.5, 4),
points: MOLE_SCORE
}))
// Create state for moles
const [moles, setMoles] = useState(generateMoles())
// Update moles on game start
const startGame = () => {
setScore(0)
setMoles(generateMoles())
setPlaying(true)
setFinished(false)
}
// Destructure mole objects as props
<div className="moles">
{moles.map(({speed, delay, points}, id) => (
<Mole
key={id}
onWhack={onWhack}
speed={speed}
delay={delay}
points={points}
/>
))}
</div>
这就是最终效果!我还在按钮上添加了一些样式,并配上了鼹鼠的图片。
我们现在用 React 构建了一个功能齐全的“打地鼠”游戏。它只用了不到 200 行代码。现在,您可以直接使用它,并根据自己的喜好进行修改。您可以按照自己的喜好进行样式设计、添加新功能等等。或者,您也可以继续关注我们,我们会为您添加一些额外的功能。
追踪高分
我们已经实现了“打地鼠”游戏。但是,如何记录最高分呢?我们可以使用副作用在localStorage每次游戏结束时写入分数。但是,如果持久化数据是一个常见需求呢?我们可以创建一个名为“usePersistentState”的自定义钩子。它可以是“useState”的封装,用于读写localStorage。
const usePersistentState = (key, initialValue) => {
const [state, setState] = useState(
window.localStorage.getItem(key)
? JSON.parse(window.localStorage.getItem(key))
: initialValue
)
useEffect(() => {
window.localStorage.setItem(key, state)
}, [key, state])
return [state, setState]
}
然后我们就可以把它运用到我们的游戏中。
const [highScore, setHighScore] = usePersistentState('whac-high-score', 0)
我们使用方法与 . 完全相同useState。我们可以onWhack在适当的时候连接到 . 来在游戏中创造新的最高分。
const endGame = points => {
if (score > highScore) setHighScore(score) // play fanfare!
}
我们如何才能知道我们的游戏结果是新的最高分?还是又一个州?很有可能。
奇思妙想
至此,我们已经涵盖了所有需要讲解的内容,甚至包括如何制作自己的定制鱼钩。现在,你可以自由发挥,打造属于你自己的风格了。
还来吗?我们来创建另一个自定义钩子,为游戏添加音频。
const useAudio = (src, volume = 1) => {
const [audio, setAudio] = useState(null)
useEffect(() => {
const AUDIO = new Audio(src)
AUDIO.volume = volume
setAudio(AUDIO)
}, [src])
return {
play: () => audio.play(),
pause: () => audio.pause(),
stop: () => {
audio.pause()
audio.currentTime = 0
},
}
}
这是一个用于播放音频的简易钩子实现。我们提供一段音频src,然后获取用于播放它的 API。我们可以在“打地鼠”时添加噪音。接下来需要决定的是:这是否是某个函数的一部分Mole?是否需要传递给某个函数Mole?是否需要在某个函数中调用onWhack?
这些都是组件驱动开发中会遇到的决策类型。我们需要考虑可移植性。另外,如果我们想让音频静音该怎么办?如何全局实现?或许先在组件内部控制音频会更合理Game。
// Inside Game
const { play: playAudio } = useAudio('/audio/some-audio.mp3')
const onWhack = () => {
playAudio()
setScore(score + points)
}
一切都取决于设计和决策。如果我们引入大量音频,重命名play变量可能会很繁琐。如果我们的钩子函数返回一个数组,useState就可以随意命名变量。但是,这样一来,记住数组的哪个索引对应哪个 API 方法也可能很困难。
就是这样!
这足以让你开启 React 之旅。而且我们还能做出一些有趣的东西。
我们讨论了很多内容!
- 创建应用
- JSX
- 组件和属性
- 创建计时器
- 使用引用
- 创建自定义钩子
我们制作了一款游戏!现在你可以运用你的新技能来添加新功能,或者打造属于你自己的游戏。
我把它带到哪里去了?目前它处于这个阶段。
接下来去哪儿!
我希望“打地鼠”游戏的开发能激励你开启 React 之旅。接下来呢?
如果您想深入了解,这里有一些资源链接供您参考。其中一些是我在研究过程中发现很有用的。
- React 文档
- “使用 React Hooks 实现声明式 setInterval ”——Dan Abramov
- “如何使用 React Hooks 获取数据”——Robin Wieruch
- “何时使用 Memo,何时使用 Callback ”——肯特·C·多兹
保持精彩!ʕ •ᴥ•ʔ
文章来源:https://dev.to/jh3y/get-started-with-react-by-building-a-whac-a-mole-game-20ee

