Snake Saga - 使用 Redux 生成器构建游戏
在面试前端工作的过程中,我开始做一些可以在周末完成的短期项目,既是为了练习编写易于访问的 HTML/CSS,也是为了探索 JavaScript 的新特性。最近我做的一个比较有趣的项目是用 React 开发一个贪吃蛇游戏——这不仅仅是因为这是我做的第一个“电子游戏”。
这个项目对我来说特别有趣,因为它引入了我过去一年里遇到的最独特的 JavaScript 特性之一:生成器。具体来说,因为我需要让蛇每次移动时都执行相应的操作,所以我研究了 Redux 生态系统中主要的副作用库。
我的想法是,蛇的移动是“主要事件”,所有由其移动引发的潜在行动(吃水果、输掉游戏等等)都应该在到达每个新方格后立即处理。因此,我的策略是将移动后的逻辑写入副作用中,这些副作用可以访问所有游戏信息,并能够更新游戏、完全停止游戏,或者在没有发生任何值得注意的事件时允许游戏继续进行。
我之前用过 Redux Thunk,而且我相信用 Redux Thunk 编写副作用逻辑也不会太麻烦。但是,由于这些项目的目的是学习新技能,而且 Redux Saga 的生成器模型似乎更灵活(如果我能克服最初的学习曲线的话),所以这个库就成了自然而然的选择。
而且,谁不喜欢自己的代码谱写一段传奇故事呢?想象一下,一群小蛇戴着维京帽,乘着长船航行,告诉我,这难道不会让你会心一笑吗?
等等,当我没说。在构思的过程中,我现在意识到,海蛇其实很可怕。
在深入了解之前,如果您只想查看代码,请访问项目仓库:https://github.com/mariowhowrites/react-snake。请注意,这只是一个周末项目,并非正式上线项目。值得注意的是,如果将来要发布这款游戏,我需要解决一些性能和样式方面的问题——更不用说还需要编写一些测试了。
发电机:简要概述
我见过对生成器最直接的解释是,它们是程序可以随意启动和停止的函数。调用普通函数通常无法控制函数的运行方式和时间。程序只会运行函数,直到它返回值或抛出错误为止。如果函数触发了无限循环,程序就会像“骨头先生的疯狂之旅”( Mr Bones' Wild Ride,适合工作场合,类似《过山车大亨》)里的乘客一样,被困在里面等待出口。
生成器的工作方式不同,它将执行控制权交给了程序本身。换句话说,可以将生成器视为程序可以自行递增的循环。例如,以下代码:
// the '*' marks this function as a generator
function* loopSayings() {
yield "hello"
yield "goodbye"
}
loopSayings()首次调用将启动生成器。为了进一步使用它,您需要将生成器保存为变量,例如const loopGenerator = loopSayings()。
从那里,你的程序可以通过调用来控制生成器loopGenerator.next()。每次next()调用该方法时,生成器都会执行yield函数中的下一条语句。
每当yield遇到语句时,生成器就会停止执行,并返回一个具有两个属性的对象:
valueyield将返回生成器停止时语句右侧的所有内容。done是一个布尔值,指示生成器是否已执行到最后yield一条语句。在此之后继续调用next()将返回valueundefined。
因此,生成器首次启动后loopGenerator.next().value会返回“hello”。loopGenerator.next().value再次调用会返回值“goodbye”,此时该done属性为真,之后所有next()调用都会返回未定义的值。
综上所述,生成器的示例用法可能如下所示:
function* loopSayings() {
yield "hello"
yield "goodbye"
}
const loopGenerator = loopSayings() // starts the generator
console.log(loopGenerator.next().value) // 'hello'
console.log(loopGenerator.next().value) // 'goodbye'
console.log(loopGenerator.next().value) // undefined, generator has finished
Redux Saga 中的生成器
现在我们已经对生成器的工作原理有了基本的了解,接下来看看这种模式如何在 Redux Saga 库中应用。首先,Redux Saga 是一个构建在 Redux 状态管理库之上的库,而 Redux 本身是 React 应用中管理复杂状态最流行的工具。
具体来说,Redux Saga 主要用作 Redux中间件。对于不熟悉 Redux 的人来说,中间件本质上是指在给定流程中间运行的任何逻辑。
例如,如果我们正在构建一个 Web 服务器,我们可以编写一个中间件来判断特定用户是否可以访问某个资源。这个中间件会在请求过程中执行,即在用户发出请求之后、服务器开始获取资源之前。如果用户无法访问该资源(例如,他们未登录,或者他们请求访问属于其他用户的受保护数据),这个中间件可以立即阻止请求,从而避免应用程序泄露敏感信息。
将此模型应用于 Redux,所有中间件都会在收到更新状态的请求后运行,但在 reducer 实际更新以反映新状态之前运行。这使得中间件能够在传入的状态请求到达 reducer 之前对其进行修改,从而提供了一种基于外部事件自定义 Redux 逻辑的强大方法。
对于 Redux Saga 来说,由于该库主要处理副作用,我们不会直接修改状态请求。然而,Redux Saga 充分利用了中间件不仅可以查看传入的 action,还可以查看 action 分发时 reducer 的当前状态这一特性。在我们的贪吃蛇游戏中,这种设置允许我们将当前棋盘状态与要分发的 action 结合起来,从而确定应该执行哪个 action。
换句话说,根据我的经验,Redux Saga 可以很好地类比其他语言和框架中的监听器或观察器。它们观察外部事件,并可能根据观察到的事件触发新的操作。
实践中的传奇
到目前为止,描述还比较抽象——让我们用一些实际的贪吃蛇代码来具体说明一下。在我的贪吃蛇实现中,我将棋盘设置成一个由方块组成的正方形网格。在我的 Redux 库中,我记录了哪些方块代表墙壁、水果、空地以及蛇本身。每个游戏刻,蛇向前移动一个方块,并将新的位置作为 Redux action 发送出去。
就我而言,我写了四个不同的故事,来讲述游戏棋盘上发生的各种事件:
import { all } from "redux-saga/effects"
import watchPosition from "./watchPosition"
import watchFruitCollection from "./watchFruitCollection"
import { watchGameStart, watchGameEnd } from "./watchGameChange"
export default function* rootSaga() {
yield all([
watchPosition(),
watchFruitCollection(),
watchGameStart(),
watchGameEnd(),
])
}
该all()方法接受一组 saga 并将它们合并到一个中间件中,该中间件在加载主应用程序之前不久附加到 Redux store。
我们来看看水果采集剧情,每当采集到新水果时就会触发该剧情:
import { takeEvery, put, select } from "redux-saga/effects"
import * as types from "../store/types"
import { makeFruit } from "../utils"
export default function* watchFruitCollection() {
yield takeEvery(types.FRUIT_COLLECT, handleFruitCollection)
}
function* handleFruitCollection({ payload }) {
const size = yield select(state => state.game.size)
yield put({ type: types.FRUIT_ADD, payload: [makeFruit(size)] })
yield put({ type: types.ADD_SCORE })
}
请注意,该 Saga 本身仅包含一行代码,以takeEvery调用开头。此函数指示 Redux Saga “获取”所有指定类型的 action FRUIT_COLLECT,并将该 action 传递给相应handleFruitCollection的方法。
由此,因为我们知道该动作的类型为“采集” FRUIT_COLLECT,所以我们知道蛇刚刚采集到一个新的果实,我们可以据此安排相应的动作。具体来说,当蛇采集到一个新的果实时,应该执行以下两个动作:
- 玩家得分需要加一。
- 游戏棋盘上需要添加一种新的水果。
要在棋盘上添加新水果,我们首先需要知道棋盘的大小,以免误将水果添加到不应该添加的地方——例如墙壁内或墙壁外。为了获取棋盘大小,我们首先使用Redux Saga 提供的函数从reducer 中select获取该属性。然后,我们使用新创建的水果创建一个新的 action ,该 action 会返回棋盘上一个随机的有效位置的新水果。sizegameFRUIT_ADDmakeFruit
完成上述步骤后,剩下的唯一工作就是增加当前分数。我们不在 saga 中处理状态变更,而是派发一个类型为 `Score` 的新 action ADD_SCORE,我们的gamereducer 会捕获该 action 并使用它来更新玩家的分数。
这里涉及两个重要过程:
- 所有状态修改都由 reducer 处理,而不是直接在 saga 中处理。这是一种有意为之的设计模式——Redux saga 应该是副作用,而不是次要的 reducer。
- 我们的处理程序生成器不会被直接调用。相反,Redux Saga 中间件负责调用我们的生成器,它通过遍历每个 saga 直到
done生成器中的属性返回为止true。
为什么要使用发电机?
由于生成器进程是由我们的 Redux Saga 中间件以同步方式处理的,你可能会疑惑为什么在这种情况下还要使用生成器。将所有状态更新逻辑都放在 reducer 内部岂不是更快更直接?我们为什么不直接在 reducer 中增加玩家分数和添加新水果,COLLECT_FRUIT而完全跳过 Redux Saga 呢?
Redux Saga 是否适合你的应用主要取决于应用的规模。对于简单的项目,将所有 Redux 数据变更都写在 reducer 函数内部或许是合理的。然而,更复杂的应用通常需要更清晰地划分因果关系,而将所有逻辑放在同一个文件中则无法实现这一点。通过将更新的所有“副作用”与更新本身分离,我们可以保持 reducer 的简洁性,并在不修改 reducer 代码的情况下添加额外的副作用,从而避免引入与状态相关的 bug。
为了更好地说明这一点,我们来看看watchPosition贪吃蛇游戏中的剧情:
export default function* watchPosition() {
yield takeEvery(types.CHANGE_POSITION, handlePositionChange)
}
const getState = state => ({
fruitPositions: state.fruit.fruitPositions,
snakeQueue: state.snake.snakeQueue,
snake: state.snake.snake,
})
function* handlePositionChange({ payload: newPosition }) {
const { fruitPositions, snakeQueue, snake } = yield select(getState)
const gameIsOver = collidedWithSelf(snake, newPosition)
if (gameIsOver) {
yield put({ type: types.GAME_END })
return
}
const fruitToRemove = findFruitToRemove(fruitPositions, newPosition)
if (fruitToRemove >= 0) {
yield put({ type: types.FRUIT_COLLECT, payload: fruitToRemove })
yield put({ type: types.SNAKE_QUEUE, payload: newPosition })
}
if (snakeQueue.length >= 1) {
yield put({ type: types.SNAKE_GROW })
}
}
我们看到它的watchPosition结构与watchFruitCollection上述几乎完全相同。所有类型的操作CHANGE_POSITION都在由生成器引导的新传奇中执行handlePositionChange。
然而,接下来会发生一系列更为复杂的事件。该生成器使用辅助方法检查各种游戏条件,例如蛇是否与自身碰撞或是否收集到水果。
在位置 reducer 中处理水果收集逻辑是否合理?我的答案是否定的。通过将所有 effect 工作委托给 saga,我的每个 reducer case 最多只需五行左右的代码。我可以根据watchPosition需要向这个生成器添加任意多的功能,而无需改变蛇在棋盘上移动的基本机制。而且,由于 `and`put返回的是简单的 JavaScript 对象,所有这些代码都可以通过手动启动和迭代 saga 来轻松测试,就像我们在生成器入门部分select所做的那样。loopSayings