发布于 2026-01-06 7 阅读
0

通过构建打地鼠游戏来开始使用 React

通过构建打地鼠游戏来开始使用 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')
Enter fullscreen mode Exit fullscreen mode

我们可以把它做得更小一些,像这样:

render(<h1>{`Time: ${Date.now()}`}</h1>, document.getElementById('app'))
Enter fullscreen mode Exit fullscreen mode

在第一个版本中,` 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()}`)
Enter fullscreen mode Exit fullscreen mode

使用 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>
Enter fullscreen mode Exit fullscreen mode

注:您可以 在这里查看所有属性差异

渲染

如何将 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>
Enter fullscreen mode Exit fullscreen mode

在这个例子中,我们在一些常规 HTML 代码之间渲染了两次应用程序。我们应该会看到标题“Many React Apps”,后面跟着一些文本。然后出现我们应用程序的第一次渲染,接着是一些文本,最后是应用程序的第二次渲染。

要深入了解渲染,请查看文档

组件和属性

这是理解 React 最关键的部分之一。组件是可复用的 UI 块,但其底层都是函数。组件本质上是函数,它们的参数被称为 props props。我们可以使用这些“props”来决定组件应该渲染什么。props 是“只读”的,你可以传递任何内容,甚至是其他组件。组件标签内的任何内容都可以通过一个特殊的 prop 来访问children

组件是返回元素的函数。如果我们不想显示任何内容,则返回空值null

我们可以用多种方式编写组件,但最终结果都是一样的。

使用函数

function App() {
  return <h1>{`Time: ${Date.now()}`}</h1>
}
Enter fullscreen mode Exit fullscreen mode

使用类

class App extends React.Component {
  render() {
    return <h1>{`Time: ${Date.now()}`}</h1>
  }
}
Enter fullscreen mode Exit fullscreen mode

在 Hooks(即将推出)发布之前,我们大量使用基于类的组件。我们需要它们来管理状态和访问组件 API。但是,随着 Hooks 的出现,基于类的组件的使用量有所减少。现在,我们通常选择基于函数的组件。这有很多好处。首先,它用更少的代码就能实现相同的功能。Hooks 也使得在组件之间共享和重用逻辑变得更加容易。此外,类可能会让人感到困惑。它们要求开发人员理解绑定和上下文。

我们将采用基于函数的方式,你会注意到我们的App组件使用了不同的样式。

const App = () => <h1>{`Time: ${Date.now()}`}</h1>
Enter fullscreen mode Exit fullscreen mode

这没错。关键在于我们的组件要返回我们想要渲染的内容。在这个例子中,就是一个显示当前时间的 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'))
Enter fullscreen mode Exit fullscreen mode

这样可行,现在我们可以更改messageprop,App从而渲染出不同的消息。我们本来可以创建组件Time,但是,创建Message组件意味着有很多机会可以重用它。这正是 React 的核心所在:围绕架构/设计做出决策。

如果我们忘记将 prop 传递给组件怎么办?我们可以提供一个默认值。以下是一些实现方法。

const Message = ({message = "You forgot me!"}) => <h1>{message}</h1>
Enter fullscreen mode Exit fullscreen mode

或者通过defaultProps在我们的组件上进行指定。我们还可以提供propTypes,我建议您了解一下。它提供了一种对组件的 props 进行类型检查的方法。

Message.defaultProps = {
  message: "You forgot me!"
}
Enter fullscreen mode Exit fullscreen mode

我们可以通过不同的方式访问 props。我们使用了 ES6 的便捷机制来解构 props。但是,我们的Message组件也可以像这样写,并且功能相同。

const Message = (props) => <h1>{props.message}</h1>
Enter fullscreen mode Exit fullscreen mode

props 是传递给组件的对象。我们可以用任何我们喜欢的方式读取它们。

我们的App组件甚至可能是这个

const App = (props) => <Message {...props}/>
Enter fullscreen mode Exit fullscreen mode

这样会得到相同的结果。我们称之为“散户效应”。不过,最好还是明确说明我们传递的是什么。

我们也可以把它message当作孩子来看待。

const Message = ({ children }) => <h1>{children}</h1>
const App = ({ message }) => <Message>{message}</Message>
Enter fullscreen mode Exit fullscreen mode

然后我们通过特殊道具引用该消息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'))
Enter fullscreen mode Exit fullscreen mode

在这个例子中,我们创建了两个应用,一个渲染时间,另一个渲染消息。注意我们在解构赋值时是如何重命名messageRendererprop 的?React 不会将任何以小写字母开头的元素视为组件。这是因为任何以小写字母开头的元素都会被视为一个元素,并被渲染成 `<a> `。我们很少会用到这种模式,但它展示了任何元素都可以作为 prop,并且可以随意使用它。Renderer<messageRenderer>

需要明确的一点是,作为 prop 传递的任何内容都需要组件进行处理。例如,如果要将样式传递给组件,则需要读取这些样式并将其应用到正在渲染的元素上。

不要害怕尝试不同的方法。尝试不同的模式并加以练习。判断哪些代码应该作为组件的能力来自于实践。在某些情况下,答案显而易见;而在另一些情况下,你可能稍后才会意识到这一点并进行重构。

一个常见的例子就是应用程序的布局。从宏观角度思考一下它可能是什么样子。一个包含子元素的布局,包括页眉、页脚和一些主要内容。它会是什么样子呢?它可能看起来像这样。

const Layout = ({ children }) => (
  <div className="layout">
    <Header/>
    <main>{children}</main>
    <Footer/>
  </div>
)
Enter fullscreen mode Exit fullscreen mode

一切都像搭建积木一样。你可以把它想象成应用版的乐高积木。

事实上,我建议大家尽快熟悉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'))
Enter fullscreen mode Exit fullscreen mode

这里的内容确实不少。不过,我们先来介绍一下“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>
  )
}
Enter fullscreen mode Exit fullscreen mode

我们的新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>
}
Enter fullscreen mode Exit fullscreen mode

试玩一下演示版,看看有哪些变化!


如果你想更深入地了解这些概念,我建议你查阅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'))
Enter fullscreen mode Exit fullscreen mode

启动/停止

在进行任何操作之前,我们需要能够启动和停止游戏。启动游戏会触发计时器和鼹鼠等元素。这时我们就可以引入条件渲染了。

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

我们有一个状态变量,playing并用它来渲染所需的元素。在 JSX 中,我们可以使用“&&”条件语句,在条件为真时渲染某些内容true。这里,我们表示如果正在玩游戏,则渲染棋盘及其内容。这也会影响按钮文本,我们可以使用三元运算符

定时器

让我们启动计时器。默认情况下,我们将设置一个时间限制30000ms。我们可以将此值声明为 React 组件之外的常量。

const TIME_LIMIT = 30000
Enter fullscreen mode Exit fullscreen mode

养成将常量集中放在一处的好习惯。所有用于配置应用程序的内容都可以放在同一个地方。

我们的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>
}
Enter fullscreen mode Exit fullscreen mode

但是,它只更新一次?

我们沿用了之前的间隔技术。但是,问题在于我们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])
Enter fullscreen mode Exit fullscreen mode

如果你查看这个演示,你会发现它使用了两个不同的定时器,并将偏差记录到开发者控制台。间隔越短或时间越长,偏差就越大。

我们可以使用一个方法ref来解决这个问题。我们可以用它来跟踪效果internalTime,避免每隔一段时间就运行一次效果。

const timeRef = useRef(time)
useEffect(() => {
  timerRef.current = setInterval(
    () => setInternalTime((timeRef.current -= interval)),
    interval
  )
  return () => {
    clearInterval(timerRef.current)
  }
}, [interval])
Enter fullscreen mode Exit fullscreen mode

而且,即使时间间隔更短,这种方法也能显著降低漂移。定时器算是一种特殊情况。但是,它是一个很好的例子,让我们思考如何在 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

注意onWhack回调函数是如何传递给每个组件的Mole。回调函数会更新我们的score状态。这些更新会触发渲染。

现在是时候在浏览器中安装React Developer Tools 扩展了。它有一个很棒的功能,可以高亮显示 DOM 中的组件渲染。打开 Dev Tools 中的“组件”选项卡,点击设置齿轮图标,然后选择“组件渲染时高亮显示更新”。

设置 React DevTools

如果您点击此链接打开我们的演示,并将扩展程序设置为高亮显示渲染过程,您会看到计时器会随着时间变化而渲染。但是,当我们打地鼠时,所有组件都会重新渲染。

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>
)
Enter fullscreen mode Exit fullscreen mode

另一种方法是在 for 循环中生成内容,然后通过函数渲染返回值。

return (
  <ul>{getLoopContent(DATA)}</ul>
)
Enter fullscreen mode Exit fullscreen mode

这个key属性是做什么用的?它帮助 React 确定需要渲染哪些更改。如果可以使用唯一标识符,那就用!作为最后的选择,可以使用集合中元素的索引。更多信息请阅读列表相关的文档。

在我们的示例中,我们没有任何数据可供使用。如果您需要生成一组数据,可以使用一个技巧。

new Array(NUMBER_OF_THINGS).fill().map()
Enter fullscreen mode Exit fullscreen mode

在某些情况下,这可能对你有用。

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>
)
Enter fullscreen mode Exit fullscreen mode

或者,如果您想要一个持久化的集合,可以使用类似这样的方法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} />
)}
Enter fullscreen mode Exit fullscreen mode

游戏结束

我们只能通过开始按钮结束游戏。而且,当我们结束游戏后,下次开始游戏时分数会保留。目前,onEnd我们的游戏进程Timer也没有任何反应。

我们需要第三种状态,即我们已经完成playing但尚未结束的状态。在更复杂的应用中,我建议使用XStatereducer。但对于我们的应用,我们可以引入一个新的状态变量finished。当状态为 `state`!playing且`state` 为 `true` 时finished,我们可以显示分数、重置计时器,并提供重新开始的选项。

现在我们需要理清思路。如果游戏结束,那么除了切换之外playing,我们还需要切换另一个状态finished。我们可以创建一个“endGame与”startGame函数。

const endGame = () => {
  setPlaying(false)
  setFinished(true)
}

const startGame = () => {
  setScore(0)
  setPlaying(true)
  setFinished(false)
}
Enter fullscreen mode Exit fullscreen mode

游戏开始时,我们会重置游戏状态score,并将游戏置于初始playing状态。这会触发游戏界面渲染。游戏结束时,我们会将游戏状态设置finished为结束true。之所以不重置游戏状态score,是为了能够将其作为游戏结果显示出来。

而且,当我们的Timer程序结束时,它应该调用相同的函数。

<Timer time={TIME_LIMIT} onEnd={endGame} />
Enter fullscreen mode Exit fullscreen mode

它可以在一个效果中做到这一点。如果internalTime命中0,则卸载并调用onEnd

useEffect(() => {
  if (internalTime === 0 && onEnd) {
    onEnd()
  }
}, [internalTime, onEnd])
Enter fullscreen mode Exit fullscreen mode

我们可以调整 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>
Enter fullscreen mode Exit fullscreen mode

现在我们拥有了一个可以正常运行的游戏,只是缺少会移动的鼹鼠。

注意我们是如何复用这个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>
  )
}
Enter fullscreen mode Exit fullscreen mode

我们为它添加了一个包装器,button这样就可以显示/隐藏它Mole。我们还给它添加了button一个ref。使用效果,我们可以创建一个缓动动画(GSAP 动画),使按钮上下移动。

你还会注意到,我们使用了 `className`,className它在 JSX 中相当于 `className` 属性class,用于应用类名。为什么不在classNameGSAP 中使用 `className` 呢?因为如果有很多元素都使用了 `className` className,我们的效果会尝试应用所有这些类名。这就是为什么 ` useRefclassName` 是一个不错的选择。

太棒了,现在我们有了上下摆动的MoleS,从功能上来说,我们的游戏就完整了。不过它们移动的速度都完全一样,这并不理想。它们应该以不同的速度移动。此外,S被击中的时间越长,得分也应该越低Mole

我们的 Mole 组件内部逻辑可以处理得分和速度的更新。将初始值作为 props 传递speeddelay可以points使组件更加灵活。

<Mole key={index} onWhack={onWhack} points={MOLE_SCORE} delay={0} speed={2} />
Enter fullscreen mode Exit fullscreen mode

现在,让我们来分析一下我们的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])
Enter fullscreen mode Exit fullscreen mode

我们还创建了一个ref用于保存 GSAP 动画引用的变量。当组件Mole被移除时,我们会用到它。注意,我们还返回了一个在组件卸载时终止动画的函数。如果我们不在组件卸载时终止动画,重复执行的代码就会一直运行。

如果我们的内奸被干掉了怎么办?我们需要为此设立一个新的州。

const [whacked, setWhacked] = useState(false)
Enter fullscreen mode Exit fullscreen mode

与其在`<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>
)
Enter fullscreen mode Exit fullscreen mode

最后一步是响应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])
Enter fullscreen mode Exit fullscreen mode

这让我们

最后一步是将属性传递给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>
Enter fullscreen mode Exit fullscreen mode

这样做会引发问题,因为每次渲染生成鼹鼠时,道具都会发生变化。更好的解决方案是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>
Enter fullscreen mode Exit fullscreen mode

这就是最终效果!我还在按钮上添加了一些样式,并配上了鼹鼠的图片。

我们现在用 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]
}
Enter fullscreen mode Exit fullscreen mode

然后我们就可以把它运用到我们的游戏中。

const [highScore, setHighScore] = usePersistentState('whac-high-score', 0)
Enter fullscreen mode Exit fullscreen mode

我们使用方法与 . 完全相同useState。我们可以onWhack在适当的时候连接到 . 来在游戏中创造新的最高分。

const endGame = points => {
  if (score > highScore) setHighScore(score) // play fanfare!
}
Enter fullscreen mode Exit fullscreen mode

我们如何才能知道我们的游戏结果是新的最高分?还是又一个州?很有可能。

奇思妙想

至此,我们已经涵盖了所有需要讲解的内容,甚至包括如何制作自己的定制鱼钩。现在,你可以自由发挥,打造属于你自己的风格了。

还来吗?我们来创建另一个自定义钩子,为游戏添加音频。

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
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

这是一个用于播放音频的简易钩子实现。我们提供一段音频src,然后获取用于播放它的 API。我们可以在“打地鼠”时添加噪音。接下来需要决定的是:这是否是某个函数的一部分Mole?是否需要传递给某个函数Mole?是否需要在某个函数中调用onWhack

这些都是组件驱动开发中会遇到的决策类型。我们需要考虑可移植性。另外,如果我们想让音频静音该怎么办?如何全局实现?或许先在组件内部控制音频会更合理Game

// Inside Game
const { play: playAudio } = useAudio('/audio/some-audio.mp3')
const onWhack = () => {
  playAudio()
  setScore(score + points)
}
Enter fullscreen mode Exit fullscreen mode

一切都取决于设计和决策。如果我们引入大量音频,重命名play变量可能会很繁琐。如果我们的钩子函数返回一个数组,useState就可以随意命名变量。但是,这样一来,记住数组的哪个索引对应哪个 API 方法也可能很困难。

就是这样!

这足以让你开启 React 之旅。而且我们还能做出一些有趣的东西。

我们讨论了很多内容!

  • 创建应用
  • JSX
  • 组件和属性
  • 创建计时器
  • 使用引用
  • 创建自定义钩子

我们制作了一款游戏!现在你可以运用你的新技能来添加新功能,或者打造属于你自己的游戏。

我把它带到哪里去了?目前它处于这个阶段。

接下来去哪儿!

我希望“打地鼠”游戏的开发能激励你开启 React 之旅。接下来呢?

如果您想深入了解,这里有一些资源链接供您参考。其中一些是我在研究过程中发现很有用的。

保持精彩!ʕ •ᴥ•ʔ

文章来源:https://dev.to/jh3y/get-started-with-react-by-building-a-whac-a-mole-game-20ee