Luke Wood 原作,New Frontend 翻译,CC BY-NC 4.0 许可。
我是网页游戏 bulletz.io 的唯一作者。最近我重构了前端代码,更贴合后端代码。后端代码使用函数式编程语言 Elixir,前端使用原生 JavaScript。前后端编程语言大不相同,基于截然不同的编程范式。
前端原本基于通用的面向对象模型编写,搞出了一大堆技术债、复杂的界面交互、让人困惑的代码。
我花了一个晚上使用事件驱动模型重写了前端代码,结果好极了。重构用的是 tiny-pubsub 这个库。

随着时间的推移,我的前端代码出现了上帝对象,搞得到处都是反模式。这篇文章会讲述这种巨类是怎么出现的,以及我最后是怎么用 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.io 得到了高度解耦的逻辑。重构后代码明显更简单、更容易理解,顺便也修复了一些用户界面的 bug。用户界面更新和状态更新都写成了自包含的模块,响应其他地方发出的数据。
如果你最近打算脱离框架编写网页,我建议了解下事件驱动编程!我为了解决这一问题写的库叫做 tiny-pubsub,GitHub 链接是 LukeWood/tiny-pubsub。
另外,也别忘了试下 bulletz.io。