使用事件驱动编程解耦 JavaScript 代码

2,691 阅读4分钟

Luke Wood 原作,New Frontend 翻译,CC BY-NC 4.0 许可。

我是网页游戏 bulletz.io 的唯一作者。最近我重构了前端代码,更贴合后端代码。后端代码使用函数式编程语言 Elixir,前端使用原生 JavaScript。前后端编程语言大不相同,基于截然不同的编程范式

前端原本基于通用的面向对象模型编写,搞出了一大堆技术债、复杂的界面交互、让人困惑的代码。

我花了一个晚上使用事件驱动模型重写了前端代码,结果好极了。重构用的是 tiny-pubsub 这个库。

bulletz.io 截屏

随着时间的推移,我的前端代码出现了上帝对象,搞得到处都是反模式。这篇文章会讲述这种巨类是怎么出现的,以及我最后是怎么用 tiny-pubsub 解决这个问题的。

原本的模型

原本的状态管理模型采用了面向对象模型。有一个中央的状态管理类(StateManager)管理整个游戏的状态,将实体(entity)分派(delegate)给子管理类。

基于服务端通过 websocket 推送的最近更新,这些子管理类尝试推测实体的当前状态。这让游戏仅需使用很少的流量就可以显示每个实体的实时状态。

状态管理分派系统

随着时间的推移,这逐渐导致了一大堆问题,主要集中在可读性和可维护性方面。各种实体的状态随着时间的推移而纠缠不清,调查和状态相关的 bug 变得很困难。

这个旧模型最大的反模式是有一个上帝对象——顶层的状态管理类。 最终这个状态管理类负责处理各种事情,作为参数被传给一大批用户界面函数。

下面是旧系统中处理显示活跃玩家数的代码:player_counter.js

const player_count = document.getElementById("score-div");
function update_player_counter(state_handler) {
  const score = state_handler.player_registry.get_players().length;
  player_count.innerText = `${players}/20`;
}
export {update_player_counter}

玩家生成和死亡的每个地方都需要调用这个 update_player_counter 函数。所有代码中这个函数出现了三次。两次在 player_registery.js:

import {update_player_counter} from '../../ui/update_player_counter'
class PlayerRegistry {
  constructor(state_handler) {
    this.state_handler = state_handler
  }
  ...
  add_player(player) {
    ...
    update_player_counter(this.state_handler)
  }
  ...
  remove_player(player) {
    ...
    update_player_counter(this.state_handler)
  }
}

一次在 state_handle.js:

import {update_player_counter} from '../../ui/update_player_counter'
class StateHandler {
  ...
  listen_for_polls() {
    update_socket.on("poll", (game_state) => {
      ...
      update_player_counter(this);
    })
  }
  ...
}

单看这个例子也没有多糟糕,但是由于所有的用户界面交互逻辑中都需要调用这些函数,最终就使代码难以理解和维护。用户界面交互和状态管理高度耦合,其他类最终需要负责触发用户界面更新。

state_handler 最终需要负责触发用户界面更新,几乎牵涉到所有东西。几乎每个方法,用户界面交互,等等,都需要储存 state_handler 的一个副本。这意味着,每次注册一个事件监听器时,附近都要存个 state_handler。整个前端代码中,有 70% 的文件中出现了 state_handler。

使用 Tiny Pubsub 解耦代码

我在这里无耻地打个广告,我为了解决这个问题,写了一个 javascript 库:tiny-pubsub。它没什么特别的,不过是维护了事件和响应相应事件需要调用的函数之间的关系。函数响应其他地方发送的数据,而不是显式地调用。

不过它确实利用事件驱动编程这一范式,鼓励解耦代码

下面是一个完整的例子:

import {subscribe, publish, unsubscribe} from 'tiny-pubsub'
import {CHATROOM_JOIN} from './event_definitions'
let logJoin = (name) => console.log(`${name} 进入了房间!`);
subscribe(CHATROOM_JOIN, logJoin)
publish(CHATROOM_JOIN, "Luke")
// > Luke 进入了房间!
unsubscribe(CHATROOM_JOIN, logJoin)
publish(CHATROOM_JOIN, "Luke")
// 什么也不会打印出来

// 你也可以使用字符串作为事件标识符
subscribe("chatroom-join", logJoin)
publish("chatroom-join", "Luke")
// > Luke 进入了房间!

重构后的模型

从代码组织上来说,重构后的代码明显更加分布式了。在新模型中,每个实体通过单个文件中定义的一系列回调表述。回调响应发布的事件,并更新实体的状态。这些事件由其他自包含的模块发布,这些模块只负责发布事件。

例如,tick.js 文件看起来是这样的:

import {TICK} from '../events'
import {game_time} from '../util/game_time'

function game_loop() {
  publish(TICK, game_time());
  requestAnimationFrame(game_loop)
}

document.addEventListener("load", game_loop);

每个事件文件只负责一种事件。有些事件是由其他事件触发的,会对数据略加修改,以便其他模块使用。

用户界面交互也由自包含的模块处理。下面是新版的 score.js:

import {subscribe} from 'tiny-pubsub'
import {PLAYER, POLL, PLAYER_DEATH} from '../events'
import {get_players} from '../entities/players'
const player_count = document.getElementById("score-div");
const update_player_count =  ({players: players}) => player_count.innerText = `${get_players().length}/20`;

subscribe(PLAYER, update_player_count);
subscribe(PLAYER_DEATH, update_player_count);
subscribe(POLL, update_player_count);

状态管理同样由小的自包含模块实现。下面是一个子弹状态管理的例子:

import {subscribe} from "tiny-pubsub"
import {BULLET, POLL, REMOVE_BULLET, TICK} from '../events'
import {update_bullet} from './update_bullet'
import {array_to_map_on_key} from '../util/array_to_map_on_key'

// 状态
let bullets = {};

// 订阅
subscribe(BULLET, bullet => bullets[bullet.id] = bullet)
subscribe(TICK, (current_time, world) => {
  bullets = bullets
    .map(bullet => update_bullet(bullet, current_time, world))
    .filter(bullet => bullet != null);
})
subscribe(POLL, ({ bullets: bullets_poll }) => {
  bullets = array_to_map_on_key(bullets_poll, "id")
})
subscribe(REMOVE_BULLET, (id) => delete bullets[id])

// 暴露出的函数
function get_bullets() {
  return Object.keys(bullets).map((uuid) => bullets[uuid])
}

export {get_bullets}

所有的东西都是自包含的,也很简单。下面的组织示意图展示了基于事件的前端架构。

重构过的 bulletz 前端状态管理系统

事件驱动编程的应用效果

应用事件驱动模型重写 bulletz.io 得到了高度解耦的逻辑。重构后代码明显更简单、更容易理解,顺便也修复了一些用户界面的 bug。用户界面更新和状态更新都写成了自包含的模块,响应其他地方发出的数据。

如果你最近打算脱离框架编写网页,我建议了解下事件驱动编程!我为了解决这一问题写的库叫做 tiny-pubsub,GitHub 链接是 LukeWood/tiny-pubsub

另外,也别忘了试下 bulletz.io