diff --git a/src/events/Events.ts b/src/events/Events.ts new file mode 100644 index 0000000..a749c25 --- /dev/null +++ b/src/events/Events.ts @@ -0,0 +1,130 @@ +import { isFunction } from '../support/Utils' + +export type EventArgs = T extends any[] ? T : never + +export type EventListener = ( + ...args: EventArgs +) => void + +export type EventSubscriberArgs = { + [K in keyof T]: { event: K; args: EventArgs } +}[keyof T] + +export type EventSubscriber = (arg: EventSubscriberArgs) => void + +/** + * Events class for listening to and emitting of events. + * + * @public + */ +export class Events { + /** + * The registry for listeners. + */ + protected listeners: { [K in keyof T]?: EventListener[] } + + /** + * The registry for subscribers. + */ + protected subscribers: EventSubscriber[] + + /** + * Creates an Events instance. + */ + constructor() { + this.listeners = Object.create(null) + this.subscribers = [] + } + + /** + * Register a listener for a given event. + * + * @returns A function that, when called, will unregister the handler. + */ + on(event: K, callback: EventListener): () => void { + if (!event || !isFunction(callback)) { + return () => {} // Non-blocking noop. + } + + ;(this.listeners[event] = this.listeners[event]! || []).push(callback) + + return () => { + if (callback) { + this.off(event, callback) + ;(callback as any) = null // Free up memory. + } + } + } + + /** + * Register a one-time listener for a given event. + * + * @returns A function that, when called, will self-execute and unregister the handler. + */ + once( + event: K, + callback: EventListener + ): EventListener { + const fn = (...args: EventArgs) => { + this.off(event, fn) + + return callback(...args) + } + + this.on(event, fn) + + return fn + } + + /** + * Unregister a listener for a given event. + */ + off(event: K, callback: EventListener): void { + const stack = this.listeners[event] + + if (!stack) { + return + } + + const i = stack.indexOf(callback) + + i > -1 && stack.splice(i, 1) + + stack.length === 0 && delete this.listeners[event] + } + + /** + * Register a handler for wildcard event subscriber. + * + * @returns A function that, when called, will unregister the handler. + */ + subscribe(callback: EventSubscriber): () => void { + this.subscribers.push(callback) + + return () => { + const i = this.subscribers.indexOf(callback) + + i > -1 && this.subscribers.splice(i, 1) + } + } + + /** + * Call all handlers for a given event with the specified args(?). + */ + emit(event: K, ...args: EventArgs): void { + const stack = this.listeners[event] + + if (stack) { + stack.slice().forEach((listener) => listener(...args)) + } + + this.subscribers.slice().forEach((sub) => sub({ event, args })) + } + + /** + * Remove all listeners for a given event. + */ + protected removeAllListeners(event: K): void { + event && this.listeners[event] && delete this.listeners[event] + } +} diff --git a/test/unit/events/Events.spec.ts b/test/unit/events/Events.spec.ts new file mode 100644 index 0000000..59392a8 --- /dev/null +++ b/test/unit/events/Events.spec.ts @@ -0,0 +1,162 @@ +import { Events } from '@/events/Events' + +describe('unit/events/Events', () => { + interface TEvents { + test: [boolean] + trial: [] + } + + it('can register event listeners', () => { + const events = new Events() + + const spy = jest.fn() + + events.on('test', spy) + + expect(events['listeners']).toHaveProperty('test') + expect(events['listeners'].test).toHaveLength(1) + expect(events['listeners'].test).toEqual([spy]) + }) + + it('can ignore empty event names', () => { + const events = new Events() + + ;[0, '', null, undefined].forEach((e) => { + events.on(e as any, () => {}) + }) + + expect(events['listeners']).toEqual({}) + }) + + it('can ignore non-function handlers', () => { + const events = new Events() + + ;[0, '', null, undefined].forEach((e) => { + const cb = events.on('test', e as any) + cb() + }) + + expect(events['listeners']).toEqual({}) + }) + + it('can emit events', () => { + const events = new Events() + + const spy = jest.fn() + + events.on('test', spy) + events.emit('test', true) + + events.off('test', spy) + events.emit('test', false) + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenLastCalledWith(true) + expect(events['listeners']).toEqual({}) + }) + + it('can noop when removing unknown listeners', () => { + const events = new Events() + + const spy1 = jest.fn() + const spy2 = jest.fn() + + expect(events['listeners'].test).toBeUndefined() + + events.off('test', spy1) + + expect(events['listeners'].test).toBeUndefined() + + events.on('test', spy2) + events.off('test', spy1) + + expect(events['listeners'].test).toEqual([spy2]) + }) + + it('can unregister itself', () => { + const events = new Events() + + const spy = jest.fn() + + events.on('test', spy) + const unsub = events.on('test', spy) + + expect(events['listeners'].test).toHaveLength(2) + + unsub() + unsub() + + expect(events['listeners'].test).toHaveLength(1) + expect(events['listeners'].test).toEqual([spy]) + }) + + it('can register one-time listeners', () => { + const events = new Events() + + const spy1 = jest.fn() + const spy2 = jest.fn() + + events.once('test', spy1) + events.on('test', spy2) + + expect(events['listeners'].test).toHaveLength(2) + + events.emit('test', true) + events.emit('test', false) + + expect(events['listeners'].test).toHaveLength(1) + expect(spy1).toHaveBeenCalledTimes(1) + expect(spy1).toHaveBeenCalledWith(true) + expect(spy2).toHaveBeenCalledTimes(2) + expect(spy2).toHaveBeenLastCalledWith(false) + }) + + it('can emit events to subscribers', () => { + const events = new Events() + + const spy = jest.fn() + + const unsub = events.subscribe(spy) + + events.emit('test', true) + unsub() + events.emit('trial') + + expect(events['subscribers']).toEqual([]) + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith({ event: 'test', args: [true] }) + }) + + it('can forward events within subscribers', () => { + const events1 = new Events() + const events2 = new Events>() + + const spy = jest.fn() + + events2.subscribe(({ event, args }) => { + events1.emit(event, ...args) + }) + + events1.on('test', spy) + events2.emit('test', true) + + expect(spy).toHaveBeenLastCalledWith(true) + }) + + it('can remove all event listeners', () => { + const events = new Events() + + const spy = jest.fn() + + events.on('test', spy) + events.on('trial', spy) + events.on('test', spy) + + expect(events['listeners'].test).toHaveLength(2) + + events['removeAllListeners']('test') + + expect(events['listeners'].test).toBeUndefined() + expect(events['listeners'].trial).toHaveLength(1) + }) +})