FRP与带Lenses的状态机在游戏循环中的实用差异探究
Let’s break down where FRP really shines compared to a lens-driven state machine, especially as your game grows beyond simple full-frame redraws. Your initial implementations look similar because you’re collapsing FRP into a single state cell—but that’s not where FRP’s power lies. Let’s dig into the scenarios where FRP pulls ahead:
1. Fine-Grained Dependency Tracking (No Wasted Work)
In your state machine setup, every update runs all your update functions (updateCharacter, updateBackground) every time an event fires—even if only one part of the state changed. With FRP, you can split your state into granular cells, and only the systems that depend on a changed cell will re-run.
For example, if your game later adds:
- A collision system that only cares about the character’s position
- A UI that only updates when the score changes
- Particle effects that trigger when the character attacks
FRP lets you wire these up to only react to their specific dependencies automatically:
// Split state into focused cells const cCharacter = sUpdate.accum(initialCharacter, updateCharacter); const cBackground = sUpdate.accum(initialBackground, updateBackground); // Collision system only runs when character state changes const cCollisionState = cCharacter.map(char => checkCollisions(char, gameObstacles)); cCollisionState.listen(handleCollisionResponse); // UI only updates when score (in character state) changes const cUIState = cCharacter.map(char => ({ score: char.score, lives: char.lives })); cUIState.listen(updateGameUI); // Full render still uses merged state (for your current full-redraw setup) const cFullState = cCharacter.lift(cBackground, mergeGameObjects); cFullState.listen(render);
With a lens state machine, you’d have to manually track which parts of the state changed, add conditional checks to skip unnecessary work, or accept running every system on every update—wasting cycles as your game scales.
2. Declarative Handling of Complex Event Flows
Games are full of asynchronous, time-based, or conditional events: enemy spawn timers, skill cooldowns, animation sequences, button hold delays, etc. FRP’s stream operators let you model these in a declarative way, avoiding the messy manual state management (timers, flag variables, cleanup logic) that plagues state machines.
For example, spawning enemies only when the game is running (not paused):
// Stream of pause/resume events const sGamePauseToggle = Sodium.fromEvent(document, "keydown", e => e.key === "Escape"); const cIsGamePaused = sGamePauseToggle.accum(false, isPaused => !isPaused); // Spawn enemy every 3 seconds, but only if game isn't paused const sEnemySpawn = cIsGamePaused.switchMap(isPaused => isPaused ? Sodium.never() : Sodium.periodic(3000).map(() => createNewEnemy()) ); // Merge enemy spawn events with player input events const sAllUpdates = Sodium.merge(sPlayerInput, sEnemySpawn); // Update state with all events const cState = sAllUpdates.accum(initialState, (state, update) => { if (isPlayerInput(update)) return updateCharacter(state, update); if (isEnemyUpdate(update)) return addEnemyToState(state, update); return state; });
With a state machine, you’d need to manually create setInterval timers, track their IDs, clear them when the game pauses, and re-enable them when resuming—all while juggling flag variables for pause state. FRP handles all this coordination declaratively.
3. Enforced Pure Updates & Clear Side Effect Boundaries
While both patterns encourage pure functions (lenses modify state immutably, FRP accumulators use pure updaters), FRP enforces a cleaner separation between pure state transformations and side effects. All side effects (like rendering, playing sounds, or updating the DOM) are confined to listen callbacks, making it harder to accidentally introduce side effects in your state update logic.
In a state machine, it’s easy to slip a side effect into an update function (e.g., playing a sound directly inside updateCharacter when the character jumps) which can lead to unpredictable state changes and harder debugging. FRP’s structure pushes you to keep all state logic pure and isolate side effects.
4. Easier Testing of Async/Event-Driven Logic
FRP streams are inherently testable. You can simulate events directly (e.g., send a fake enemy spawn event, or fast-forward time in a timer stream) without mocking timers or manually triggering state updates.
For example, testing your enemy spawn logic:
// In tests: simulate a pause event, then a resume event sGamePauseToggle.send(true); // Assert no enemies are spawned sEnemySpawn.sample() // Should be empty sGamePauseToggle.send(false); // Fast-forward 3 seconds (using Sodium's test utilities) Sodium.advanceTime(3000); // Assert an enemy was spawned assert.equal(sEnemySpawn.sample().length, 1);
Testing this with a state machine would require mocking setInterval, tracking timer IDs, and manually triggering the spawn logic—much more boilerplate.
A Quick Note on "Global Cells"
You mentioned storing cells in global references makes FRP feel like a state machine—and you’re right! That’s an anti-pattern in FRP. The real value comes from declaring dependencies explicitly between streams and cells, letting the FRP library handle the propagation of updates automatically. Global cells turn FRP into a manual state management tool, losing all its core advantages.
In your current full-redraw scenario, the two patterns feel similar because you’re using FRP as a single-state wrapper. But as your game adds more complex systems, async logic, or needs to optimize performance by avoiding unnecessary work, FRP’s declarative flow management and automatic dependency tracking will save you a ton of boilerplate and reduce bugs.
内容的提问来源于stack exchange,提问作者davidkomer

