Skip to content

Commit 465c5d5

Browse files
authored
feat: Events API (#8)
1 parent cc98785 commit 465c5d5

File tree

2 files changed

+292
-0
lines changed

2 files changed

+292
-0
lines changed

src/events/Events.ts

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { isFunction } from '../support/Utils'
2+
3+
export type EventArgs<T> = T extends any[] ? T : never
4+
5+
export type EventListener<T, K extends keyof T> = (
6+
...args: EventArgs<T[K]>
7+
) => void
8+
9+
export type EventSubscriberArgs<T> = {
10+
[K in keyof T]: { event: K; args: EventArgs<T[K]> }
11+
}[keyof T]
12+
13+
export type EventSubscriber<T> = (arg: EventSubscriberArgs<T>) => void
14+
15+
/**
16+
* Events class for listening to and emitting of events.
17+
*
18+
* @public
19+
*/
20+
export class Events<T> {
21+
/**
22+
* The registry for listeners.
23+
*/
24+
protected listeners: { [K in keyof T]?: EventListener<T, K>[] }
25+
26+
/**
27+
* The registry for subscribers.
28+
*/
29+
protected subscribers: EventSubscriber<T>[]
30+
31+
/**
32+
* Creates an Events instance.
33+
*/
34+
constructor() {
35+
this.listeners = Object.create(null)
36+
this.subscribers = []
37+
}
38+
39+
/**
40+
* Register a listener for a given event.
41+
*
42+
* @returns A function that, when called, will unregister the handler.
43+
*/
44+
on<K extends keyof T>(event: K, callback: EventListener<T, K>): () => void {
45+
if (!event || !isFunction(callback)) {
46+
return () => {} // Non-blocking noop.
47+
}
48+
49+
;(this.listeners[event] = this.listeners[event]! || []).push(callback)
50+
51+
return () => {
52+
if (callback) {
53+
this.off(event, callback)
54+
;(callback as any) = null // Free up memory.
55+
}
56+
}
57+
}
58+
59+
/**
60+
* Register a one-time listener for a given event.
61+
*
62+
* @returns A function that, when called, will self-execute and unregister the handler.
63+
*/
64+
once<K extends keyof T>(
65+
event: K,
66+
callback: EventListener<T, K>
67+
): EventListener<T, K> {
68+
const fn = (...args: EventArgs<T[K]>) => {
69+
this.off(event, fn)
70+
71+
return callback(...args)
72+
}
73+
74+
this.on(event, fn)
75+
76+
return fn
77+
}
78+
79+
/**
80+
* Unregister a listener for a given event.
81+
*/
82+
off<K extends keyof T>(event: K, callback: EventListener<T, K>): void {
83+
const stack = this.listeners[event]
84+
85+
if (!stack) {
86+
return
87+
}
88+
89+
const i = stack.indexOf(callback)
90+
91+
i > -1 && stack.splice(i, 1)
92+
93+
stack.length === 0 && delete this.listeners[event]
94+
}
95+
96+
/**
97+
* Register a handler for wildcard event subscriber.
98+
*
99+
* @returns A function that, when called, will unregister the handler.
100+
*/
101+
subscribe(callback: EventSubscriber<T>): () => void {
102+
this.subscribers.push(callback)
103+
104+
return () => {
105+
const i = this.subscribers.indexOf(callback)
106+
107+
i > -1 && this.subscribers.splice(i, 1)
108+
}
109+
}
110+
111+
/**
112+
* Call all handlers for a given event with the specified args(?).
113+
*/
114+
emit<K extends keyof T>(event: K, ...args: EventArgs<T[K]>): void {
115+
const stack = this.listeners[event]
116+
117+
if (stack) {
118+
stack.slice().forEach((listener) => listener(...args))
119+
}
120+
121+
this.subscribers.slice().forEach((sub) => sub({ event, args }))
122+
}
123+
124+
/**
125+
* Remove all listeners for a given event.
126+
*/
127+
protected removeAllListeners<K extends keyof T>(event: K): void {
128+
event && this.listeners[event] && delete this.listeners[event]
129+
}
130+
}

test/unit/events/Events.spec.ts

+162
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { Events } from '@/events/Events'
2+
3+
describe('unit/events/Events', () => {
4+
interface TEvents {
5+
test: [boolean]
6+
trial: []
7+
}
8+
9+
it('can register event listeners', () => {
10+
const events = new Events<TEvents>()
11+
12+
const spy = jest.fn()
13+
14+
events.on('test', spy)
15+
16+
expect(events['listeners']).toHaveProperty('test')
17+
expect(events['listeners'].test).toHaveLength(1)
18+
expect(events['listeners'].test).toEqual([spy])
19+
})
20+
21+
it('can ignore empty event names', () => {
22+
const events = new Events<TEvents>()
23+
24+
;[0, '', null, undefined].forEach((e) => {
25+
events.on(e as any, () => {})
26+
})
27+
28+
expect(events['listeners']).toEqual({})
29+
})
30+
31+
it('can ignore non-function handlers', () => {
32+
const events = new Events<TEvents>()
33+
34+
;[0, '', null, undefined].forEach((e) => {
35+
const cb = events.on('test', e as any)
36+
cb()
37+
})
38+
39+
expect(events['listeners']).toEqual({})
40+
})
41+
42+
it('can emit events', () => {
43+
const events = new Events<TEvents>()
44+
45+
const spy = jest.fn()
46+
47+
events.on('test', spy)
48+
events.emit('test', true)
49+
50+
events.off('test', spy)
51+
events.emit('test', false)
52+
53+
expect(spy).toHaveBeenCalledTimes(1)
54+
expect(spy).toHaveBeenLastCalledWith(true)
55+
expect(events['listeners']).toEqual({})
56+
})
57+
58+
it('can noop when removing unknown listeners', () => {
59+
const events = new Events<TEvents>()
60+
61+
const spy1 = jest.fn()
62+
const spy2 = jest.fn()
63+
64+
expect(events['listeners'].test).toBeUndefined()
65+
66+
events.off('test', spy1)
67+
68+
expect(events['listeners'].test).toBeUndefined()
69+
70+
events.on('test', spy2)
71+
events.off('test', spy1)
72+
73+
expect(events['listeners'].test).toEqual([spy2])
74+
})
75+
76+
it('can unregister itself', () => {
77+
const events = new Events<TEvents>()
78+
79+
const spy = jest.fn()
80+
81+
events.on('test', spy)
82+
const unsub = events.on('test', spy)
83+
84+
expect(events['listeners'].test).toHaveLength(2)
85+
86+
unsub()
87+
unsub()
88+
89+
expect(events['listeners'].test).toHaveLength(1)
90+
expect(events['listeners'].test).toEqual([spy])
91+
})
92+
93+
it('can register one-time listeners', () => {
94+
const events = new Events<TEvents>()
95+
96+
const spy1 = jest.fn()
97+
const spy2 = jest.fn()
98+
99+
events.once('test', spy1)
100+
events.on('test', spy2)
101+
102+
expect(events['listeners'].test).toHaveLength(2)
103+
104+
events.emit('test', true)
105+
events.emit('test', false)
106+
107+
expect(events['listeners'].test).toHaveLength(1)
108+
expect(spy1).toHaveBeenCalledTimes(1)
109+
expect(spy1).toHaveBeenCalledWith(true)
110+
expect(spy2).toHaveBeenCalledTimes(2)
111+
expect(spy2).toHaveBeenLastCalledWith(false)
112+
})
113+
114+
it('can emit events to subscribers', () => {
115+
const events = new Events<TEvents>()
116+
117+
const spy = jest.fn()
118+
119+
const unsub = events.subscribe(spy)
120+
121+
events.emit('test', true)
122+
unsub()
123+
events.emit('trial')
124+
125+
expect(events['subscribers']).toEqual([])
126+
expect(spy).toHaveBeenCalledTimes(1)
127+
expect(spy).toHaveBeenCalledWith({ event: 'test', args: [true] })
128+
})
129+
130+
it('can forward events within subscribers', () => {
131+
const events1 = new Events<TEvents>()
132+
const events2 = new Events<Pick<TEvents, 'test'>>()
133+
134+
const spy = jest.fn()
135+
136+
events2.subscribe(({ event, args }) => {
137+
events1.emit(event, ...args)
138+
})
139+
140+
events1.on('test', spy)
141+
events2.emit('test', true)
142+
143+
expect(spy).toHaveBeenLastCalledWith(true)
144+
})
145+
146+
it('can remove all event listeners', () => {
147+
const events = new Events<TEvents>()
148+
149+
const spy = jest.fn()
150+
151+
events.on('test', spy)
152+
events.on('trial', spy)
153+
events.on('test', spy)
154+
155+
expect(events['listeners'].test).toHaveLength(2)
156+
157+
events['removeAllListeners']('test')
158+
159+
expect(events['listeners'].test).toBeUndefined()
160+
expect(events['listeners'].trial).toHaveLength(1)
161+
})
162+
})

0 commit comments

Comments
 (0)