使用事件驅動編程解耦 JavaScript 代碼

Luke Wood 原做,New Frontend 翻譯,CC BY-NC 4.0 許可。javascript

我是網頁遊戲 bulletz.io 的惟一做者。最近我重構了前端代碼,更貼合後端代碼。後端代碼使用函數式編程語言 Elixir,前端使用原生 JavaScript。先後端編程語言大不相同,基於大相徑庭的編程範式html

前端本來基於通用的面向對象模型編寫,搞出了一大堆技術債、複雜的界面交互、讓人困惑的代碼。前端

我花了一個晚上使用事件驅動模型重寫了前端代碼,結果好極了。重構用的是 tiny-pubsub 這個庫。java

bulletz.io 截屏

隨着時間的推移,個人前端代碼出現了上帝對象,搞獲得處都是反模式。這篇文章會講述這種巨類是怎麼出現的,以及我最後是怎麼用 tiny-pubsub 解決這個問題的。git

本來的模型

本來的狀態管理模型採用了面向對象模型。有一箇中央的狀態管理類(StateManager)管理整個遊戲的狀態,將實體(entity)分派(delegate)給子管理類。github

基於服務端經過 websocket 推送的最近更新,這些子管理類嘗試推測實體的當前狀態。這讓遊戲僅需使用不多的流量就能夠顯示每一個實體的實時狀態。web

狀態管理分派系統

隨着時間的推移,這逐漸致使了一大堆問題,主要集中在可讀性和可維護性方面。各類實體的狀態隨着時間的推移而糾纏不清,調查和狀態相關的 bug 變得很困難。編程

這個舊模型最大的反模式是有一個上帝對象——頂層的狀態管理類。 最終這個狀態管理類負責處理各類事情,做爲參數被傳給一大批用戶界面函數。後端

下面是舊系統中處理顯示活躍玩家數的代碼:player_counter.jsbash

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

相關文章
相關標籤/搜索