事件驅動程式設計:以實作「老虎機」為例

前言

本文使用 OOP 封裝邏輯,練習多種事件設計,透過「老虎機」範例理解事件驅動程式設計的核心概念。

簡介

程式的流程不靠單一線性執行,而是由物件狀態或外部行為觸發事件,事件觸發相應回呼(處理函式)。

核心概念

  • 事件(Event):物件狀態改變或操作完成的訊號
  • 回呼(Callback/Handler):對事件的響應函式
  • 主動或被動觸發:
    • 主動:呼叫函式執行動作
    • 被動:事件發生時自動執行回呼
  • 例子:
    • 按鈕點擊:onClick
    • 倒數計時器:onTick

事件驅動是 callback 的進階抽象,用於多事件、多訂閱者的情境,降低耦合。

簡單練習

以下做一個簡單的「倒數計時」練習,理解「事件」和「回呼」的概念。

建立非同步函式

建立 playground/countdown.js 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

const countdown = (seconds) => {
let remaining = seconds;
const timer = setInterval(() => {
remaining--;

if (remaining <= 0) {
console.log('⏰ Time up!');
clearInterval(timer);
}
}, 1000);
};

// 3 seconds countdown
countdown(3);

執行腳本。

1
node playground/countdown.js

經過 3 秒,輸出如下:

1
⏰ Time up!

提供回呼方法

如果想要在倒數計時結束時通知,就要做一個叫 onTimeUp 的 callback 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const countdown = (seconds, onTimeUp) => {
let remaining = seconds;
const timer = setInterval(() => {
remaining--;

if (remaining <= 0) {
console.log('⏰ Time up!');
clearInterval(timer);
if (onTimeUp) onTimeUp();
}
}, 1000);
};

// 3 seconds countdown
countdown(3, () => {
console.log('🎉 Countdown finished!');
});

執行腳本。

1
node playground/countdown.js

經過 3 秒,輸出如下:

1
2
⏰ Time up!
🎉 Countdown finished!

實現中途通知

如果想要進一步在每一秒時通知,就要再做一個 onTick 的 callback 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const countdown = ({ seconds, onTick, onTimeUp }) => {
let remaining = seconds;
const timer = setInterval(() => {
remaining--;

if (remaining > 0) {
if (onTick) onTick(remaining);
}

if (remaining <= 0) {
clearInterval(timer);
console.log('⏰ Time up!');
if (onTimeUp) onTimeUp();
}
}, 1000);
};

// 3 seconds countdown
countdown({
seconds: 3,
onTick: (remaining) => console.log(`⏳ ${remaining} seconds remaining...`),
onTimeUp: () => console.log('🎉 Countdown finished!'),
});

執行腳本。

1
node playground/countdown.js

經過 3 秒,輸出如下:

1
2
3
4
⏳ 2 seconds remaining...
⏳ 1 seconds remaining...
⏰ Time up!
🎉 Countdown finished!

事件/回呼 vs async/await

建立 playground/countdownAsync.js 檔。

1
2
3
4
5
6
7
8
9
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

const countdownAsync = async (ms) => {
await delay(ms);
console.log('⏰ Time up!');
};

await countdownAsync(3000);
console.log('🎉 Countdown finished!');

執行腳本。

1
node playground/countdownAsync.js

經過 3 秒,輸出如下:

1
2
⏰ Time up!
🎉 Countdown finished!
特性 回呼 / 事件 async/await
支援多 handler ❌(需額外設計)
每秒 tick / 中途通知 ❌(需額外迴圈和 await)
寫法線性可讀 ❌(callback 可能巢狀)

建立專案

建立專案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
npm create vite@latest

> npx
> create-vite


◇ Project name:
│ event-driven-slot-machine

◇ Select a framework:
│ Vue

◇ Select a variant:
│ JavaScript

◇ Scaffolding project in

實作類別

屬性指派式

把事件 handler 直接指派給物件屬性。

特點:

  • 舊式 DOM API / 早期 JS 常見:像 element.onclick = ...
  • 每個事件只保留一個 handler,新的指派會覆蓋舊的 handler。
  • 簡單易懂,但缺乏多重訂閱能力。

建立 lib/OldSlotMachine.js 檔案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class OldSlotMachine {
constructor(reels = 3, symbols = ['7️⃣', '🍒', '🍋', '🍊', '🍇', '🍌', '🍓', '🔔']) {
this.reels = reels; // Number of reels
this.symbols = symbols; // Possible symbols
this.currentReels = Array(this.reels).fill(null);

// Old-style event properties
this.onReset = null;
this.onSpinStart = null;
this.onReelStop = null;
this.onAllReelsStop = null;
this.onWin = null;
this.onLose = null;
this.onJackpot = null;
}

// Reset reels
reset() {
this.currentReels = Array(this.reels).fill(null);
if (this.onReset) this.onReset();
}

// Generate symbol for single reel
spinReel() {
const idx = Math.floor(Math.random() * this.symbols.length);
return this.symbols[idx];
}

// Player pulls lever
spin() {
if (this.onSpinStart) this.onSpinStart();

this.currentReels = [];

// Simulate each reel stopping
for (let i = 0; i < this.reels; i++) {
const symbol = this.spinReel();
this.currentReels.push(symbol);
if (this.onReelStop) this.onReelStop(i + 1, symbol);
}

if (this.onAllReelsStop) this.onAllReelsStop(this.currentReels);

// Determine result
const [first] = this.currentReels;
const win = this.currentReels.every(s => s === first);

if (!win) {
if (this.onLose) this.onLose(this.currentReels);
return;
}

if (first === '7️⃣') {
if (this.onJackpot) this.onJackpot(this.currentReels);
return;
}

if (this.onWin) this.onWin(this.currentReels);
}
}

export default OldSlotMachine;

建立 lib/demoOldSlotMachine.js 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import OldSlotMachine from './OldSlotMachine.js';

const machine = new OldSlotMachine();

// Register events
machine.onSpinStart = () => console.log('Pull the lever! Start spinning!');
machine.onReelStop = (i, symbol) => console.log(`Reel ${i} stopped: ${symbol}`);
machine.onAllReelsStop = reels => console.log('All reels stopped!');
machine.onWin = reels => console.log(`You win! Reels: ${reels}`);
machine.onLose = reels => console.log(`You lose! Reels: ${reels}`);
machine.onJackpot = reels => console.log(`JACKPOT!!! Reels: ${reels}`);
machine.onReset = () => console.log('Reels have been reset!');

// Demo
machine.reset();
machine.spin();

執行腳本。

1
node lib/demoOldSlotMachine.js

輸出結果如下:

1
2
3
4
5
6
7
Reels have been reset!
Pull the lever! Start spinning!
Reel 1 stopped: 🍊
Reel 2 stopped: 🍒
Reel 3 stopped: 🍌
All reels stopped!
You lose! Reels: 🍊,🍒,🍌

註冊函式式

呼叫一個方法,將 handler 註冊到事件列表中。

特點:

  • 可以註冊多個 handler,互不覆蓋。
  • 類似 DOM 現代 API 的 addEventListener。
  • 適合 OOP 封裝的事件驅動架構。

建立 lib/SlotMachine.js 檔案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class SlotMachine {

constructor(reels = 3, symbols = ['7️⃣', '🍒', '🍋', '🍊', '🍇', '🍌', '🍓', '🔔']) {
this.reels = reels; // Number of reels
this.symbols = symbols; // Possible symbols
this.events = {}; // Store event callbacks
}

// Register event callback
on(eventName, callback) {
if (!this.events[eventName]) this.events[eventName] = [];
this.events[eventName].push(callback);
}

// Trigger event
emit(eventName, ...args) {
(this.events[eventName] || []).forEach(cb => cb(...args));
}

// Reset reels
reset() {
this.currentReels = Array(this.reels).fill(null);
this.emit('reset');
}

// Generate symbol for single reel
spinReel() {
const idx = Math.floor(Math.random() * this.symbols.length);
return this.symbols[idx];
}

// Player pulls lever
spin() {
this.emit('spinStart');

this.currentReels = [];

// Simulate each reel stopping
for (let i = 0; i < this.reels; i++) {
const symbol = this.spinReel();
this.currentReels.push(symbol);
this.emit('reelStop', i + 1, symbol);
}

this.emit('allReelsStop', this.currentReels);

// Determine result
const [first] = this.currentReels;
const win = this.currentReels.every(s => s === first);

if (!win) {
this.emit('lose', this.currentReels);
return;
}
if (first === '7️⃣') {
this.emit('jackpot', this.currentReels);
return;
}
this.emit('win', this.currentReels);
}
}

export default SlotMachine;

建立 lib/demoSlotMachine.js 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import SlotMachine from './SlotMachine.js';

const machine = new SlotMachine();

// Register events
machine.on('spinStart', () => console.log('Pull the lever! Start spinning!'));
machine.on('reelStop', (i, symbol) => console.log(`Reel ${i} stopped: ${symbol}`));
machine.on('allReelsStop', reels => console.log('All reels stopped!'));
machine.on('win', reels => console.log(`You win! Reels: ${reels}`));
machine.on('lose', reels => console.log(`You lose! Reels: ${reels}`));
machine.on('jackpot', reels => console.log(`JACKPOT!!! Reels: ${reels}`));
machine.on('reset', () => console.log('Reels have been reset!'));

// Demo
machine.reset();
machine.spin();

執行腳本。

1
node lib/demoSlotMachine.js

輸出結果如下:

1
2
3
4
5
6
7
Reels have been reset!
Pull the lever! Start spinning!
Reel 1 stopped: 🍓
Reel 2 stopped: 7️⃣
Reel 3 stopped: 🍇
All reels stopped!
You lose! Reels: 🍓,7️⃣,🍇

方法鏈

如果希望可以連續註冊多個事件回呼,可以讓 on 方法回傳自身(this),以實現方法鏈(method chaining)。

1
2
3
4
5
6
7
// Register event callback
on(eventName, callback) {
if (!this.events[eventName]) this.events[eventName] = [];
this.events[eventName].push(callback);

return this;
}

實作介面

建立 src/components/SlotMachine.vue 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<script setup>
import SlotMachine from '../../lib/SlotMachine';

const props = defineProps({
reels: {
type: Number,
default: undefined,
},
symbols: {
type: Array,
default: undefined,
},
onSpinStart: {
type: Function,
default: () => {},
},
onReelStop: {
type: Function,
default: () => {},
},
onAllReelsStop: {
type: Function,
default: () => {},
},
onWin: {
type: Function,
default: () => {},
},
onLose: {
type: Function,
default: () => {},
},
onJackpot: {
type: Function,
default: () => {},
},
onReset: {
type: Function,
default: () => {},
},
});

const machine = new SlotMachine(props.reels, props.symbols);

machine.on('spinStart', () => props.onSpinStart);
machine.on('reelStop', (i, symbol) => props.onReelStop(i, symbol));
machine.on('allReelsStop', reels => props.onAllReelsStop(reels));
machine.on('win', reels => props.onWin(reels));
machine.on('lose', reels => props.onLose(reels));
machine.on('jackpot', reels => props.onJackpot(reels));
machine.on('reset', () => props.onReset());

const handleClick = () => {
machine.reset();
machine.spin();
};
</script>

<template>
<button
@click="handleClick"
>
Spin
</button>
</template>

建立 src/components/SlotMachineBasic.vue 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<script setup>
import { ref } from 'vue';
import SlotMachine from './SlotMachine.vue';

const resultRef = ref();
const prizeRef = ref();

const awardPrize = (amount) => {};
const playAnimation = () => {};
const notifyLeaderboard = () => {};

const handleWin = reels => {
const amount = 100;
awardPrize(amount);
notifyLeaderboard();
resultRef.value.textContent = `You win! Reels: ${reels}`;
prizeRef.value.textContent = `+${amount} coins`;
};

const handleLose = reels => {
const amount = 0;
resultRef.value.textContent = `You lose! Reels: ${reels}`;
prizeRef.value.textContent = `+${amount} coins`;
};

const handleJackpot = reels => {
const amount = 1000;
awardPrize(amount);
notifyLeaderboard();
playAnimation();
resultRef.value.textContent = `JACKPOT!!! Reels: ${reels}`;
prizeRef.value.textContent = `+${amount} coins`;
};
</script>

<template>
<div class="card">
<h3>The Pro Slot Machine</h3>
<SlotMachine
:reels="3"
:symbols="['7️⃣', '🍒', '🍋', '🍊', '🍇']"
:on-win="handleWin"
:on-lose="handleLose"
:on-jackpot="handleJackpot"
/>
<h4>
<span ref="resultRef">&nbsp;</span>
<br>
<span ref="prizeRef" style="margin-left: 0.5em">&nbsp;</span>
</h4>
</div>
</template>

建立 src/components/SlotMachinePro.vue 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<script setup>
import { ref } from 'vue';
import SlotMachine from './SlotMachine.vue';

const resultRef = ref();
const prizeRef = ref();

const awardPrize = (amount) => {};
const playAnimation = () => {};
const notifyLeaderboard = () => {};

const handleWin = reels => {
const amount = 500;
awardPrize(amount);
notifyLeaderboard();
resultRef.value.textContent = `You win! Reels: ${reels}`;
prizeRef.value.textContent = `+${amount} coins`;
};

const handleLose = reels => {
const amount = 0;
resultRef.value.textContent = `You lose! Reels: ${reels}`;
prizeRef.value.textContent = `+${amount} coins`;
};

const handleJackpot = reels => {
const amount = 5000;
awardPrize(amount);
notifyLeaderboard();
playAnimation();
resultRef.value.textContent = `JACKPOT!!! Reels: ${reels}`;
prizeRef.value.textContent = `+${amount} coins`;
};
</script>

<template>
<div class="card">
<h3>The Pro Slot Machine</h3>
<SlotMachine
:reels="5"
:symbols="['7️⃣', '🍒', '🍋', '🍊', '🍇', '🍌', '🍓']"
:on-win="handleWin"
:on-lose="handleLose"
:on-jackpot="handleJackpot"
/>
<h4>
<span ref="resultRef">&nbsp;</span>
<br>
<span ref="prizeRef" style="margin-left: 0.5em">&nbsp;</span>
</h4>
</div>
</template>

修改 src/App.vue 檔。

1
2
3
4
5
6
7
8
9
10
<script setup>
import SlotMachineBasic from './components/SlotMachineBasic.vue';
import SlotMachinePro from './components/SlotMachinePro.vue';

</script>

<template>
<SlotMachineBasic />
<SlotMachinePro />
</template>

啟動專案。

1
npm run dev

程式碼