A browser implementation of the event emitter, with a split between listenable events and emittable events
Created
April 11, 2022 04:16
-
-
Save saltyJeff/0b627c77ac3c50e3512d8bcfd9d87c9e to your computer and use it in GitHub Desktop.
Typescript Event Emitter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { EventEmitter } from './eventemitter' | |
| describe('EventEmitter Listen/Remove Funcs', () => { | |
| const ee = new EventEmitter() | |
| it('adds listeners', () => { | |
| ee.addListener('foo', console.log) | |
| expect(ee.listenerCount('foo')).toBe(1) | |
| expect(ee.eventNames()).toContain('foo') | |
| }) | |
| it('removes listeners', () => { | |
| ee.addListener('bar', console.log) | |
| ee.removeListener('bar') | |
| expect(ee.listenerCount('bar')).toBe(0) | |
| expect(ee.eventNames()).not.toContain('bar') | |
| }) | |
| it('removes with condition', () => { | |
| const a = () => 1 | |
| const b = () => 2 | |
| const c = () => 3 | |
| ee.addListener('baz', a) | |
| ee.addListener('baz', b) | |
| ee.addListener('baz', c) | |
| expect(ee.listenerCount('baz')).toBe(3) | |
| ee.removeListener('baz', a) | |
| expect(ee.listenerCount('baz')).toBe(2) | |
| expect(ee.eventNames()).toContain('baz') | |
| ee.removeAllListeners('baz') | |
| expect(ee.listenerCount('baz')).toBe(0) | |
| expect(ee.eventNames()).not.toContain('baz') | |
| }) | |
| }) | |
| describe('EventEmitter Emit Funcs', () => { | |
| const ee = new EventEmitter() | |
| let fooReg = 0, barReg = 0, bazReg = 0 | |
| beforeAll(() => { | |
| ee.addListener('foo', () => fooReg++) | |
| ee.on('bar', () => barReg++) | |
| ee.once('baz', () => bazReg++) | |
| expect(ee.listenerCount('foo')).toBe(1) | |
| }) | |
| it('dispatches events', () => { | |
| ee.emit('foo') | |
| ee.emit('foo') | |
| ee.emit('foo') | |
| expect(fooReg).toBe(3) | |
| }) | |
| it('stops dispatching when removed', () => { | |
| ee.emit('bar') | |
| ee.removeAllListeners('bar') | |
| ee.emit('bar') | |
| expect(barReg).toBe(1) | |
| }) | |
| it('doesnt multi-dispatch onces', () => { | |
| ee.emit('baz') | |
| ee.emit('baz') | |
| expect(bazReg).toBe(1) | |
| expect(ee.listenerCount('baz')).toBe(0) | |
| }) | |
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // #region type definitions | |
| namespace EventEmitter { | |
| /** | |
| * `object` should be in either of the following forms: | |
| * ``` | |
| * interface EventTypes { | |
| * 'event-with-parameters': any[] | |
| * 'event-with-example-handler': (...args: any[]) => void | |
| * } | |
| * ``` | |
| */ | |
| export type ValidEventTypes = string | symbol | object; | |
| export type EventNames<T extends ValidEventTypes> = T extends string | symbol | |
| ? T | |
| : keyof T; | |
| export type ArgumentMap<T extends object> = { | |
| [K in keyof T]: T[K] extends (...args: any[]) => void | |
| ? Parameters<T[K]> | |
| : T[K] extends any[] | |
| ? T[K] | |
| : any[]; | |
| }; | |
| export type EventListener< | |
| T extends ValidEventTypes, | |
| K extends EventNames<T> | |
| > = T extends string | symbol | |
| ? (...args: any[]) => void | |
| : ( | |
| ...args: ArgumentMap<Exclude<T, string | symbol>>[Extract<K, keyof T>] | |
| ) => void; | |
| export type EventArgs< | |
| T extends ValidEventTypes, | |
| K extends EventNames<T> | |
| > = Parameters<EventListener<T, K>>; | |
| export interface ListenerData { | |
| fn: Function; | |
| context: any; | |
| once: boolean; | |
| } | |
| } | |
| // #endregion | |
| // #region implementation | |
| /** | |
| * Minimal `EventEmitter` interface that is molded against the Node.js | |
| * `EventEmitter` interface. | |
| */ | |
| export class EventEmitter<ListenEvents extends EventEmitter.ValidEventTypes = string | symbol, | |
| EmitEvents extends EventEmitter.ValidEventTypes = ListenEvents, | |
| Context extends any = any> { | |
| private events = new Map<EventEmitter.EventNames<ListenEvents> | EventEmitter.EventNames<EmitEvents>, | |
| EventEmitter.ListenerData[]>() | |
| private _addListener<T extends EventEmitter.EventNames<ListenEvents>>( | |
| event: T, | |
| fn: EventEmitter.EventListener<ListenEvents, T>, | |
| context: Context = this as any, | |
| once: boolean = false | |
| ): this { | |
| if (typeof fn !== 'function') { | |
| throw new TypeError('The listener must be a function'); | |
| } | |
| const listener = { fn, context, once } | |
| const allListeners = this.events.get(event) | |
| if(allListeners) { | |
| allListeners.push(listener) | |
| } | |
| else { | |
| this.events.set(event, [listener]) | |
| } | |
| return this | |
| } | |
| addListener<T extends EventEmitter.EventNames<ListenEvents>>( | |
| event: T, | |
| fn: EventEmitter.EventListener<ListenEvents, T>, | |
| context?: Context | |
| ): this { | |
| return this._addListener(event, fn, context, false) | |
| } | |
| on = this.addListener | |
| once: typeof this.addListener = (event, fn, context?) => this._addListener(event, fn ,context, true) | |
| eventNames() { | |
| return Array.from(this.events.keys()) | |
| } | |
| listeners<T extends EventEmitter.EventNames<ListenEvents>>( | |
| event: T | |
| ): Array<EventEmitter.EventListener<ListenEvents, T>> { | |
| return this.events.get(event)?.map(data => data.fn as any) || [] | |
| } | |
| listenerCount(event: EventEmitter.EventNames<ListenEvents>) { | |
| return this.events.get(event)?.length || 0 | |
| } | |
| /** | |
| * Remove the listeners of a given event. | |
| */ | |
| removeListener<T extends EventEmitter.EventNames<ListenEvents>>( | |
| event: T, | |
| fn?: EventEmitter.EventListener<ListenEvents, T>, | |
| context?: Context, | |
| once?: boolean | |
| ): this { | |
| const allEvents = this.events.get(event) | |
| if(!allEvents) { | |
| return this | |
| } | |
| if(!fn && !context && once === undefined) { | |
| return this.removeAllListeners(event) | |
| } | |
| inplaceFilter(allEvents, listener => (!!fn && listener.fn !== fn) || | |
| (!!context && listener.context !== context) || | |
| (!!once && !listener.once) | |
| ) | |
| if(allEvents.length == 0) { | |
| this.events.delete(event) | |
| } | |
| return this | |
| } | |
| off = this.removeListener | |
| /** | |
| * Remove all listeners, or those of the specified event. | |
| */ | |
| removeAllListeners(event?: EventEmitter.EventNames<ListenEvents>): this { | |
| if(!event) { | |
| this.events.clear() | |
| } | |
| else { | |
| this.events.delete(event) | |
| } | |
| return this | |
| } | |
| /** | |
| * Calls each of the listeners registered for a given event. | |
| */ | |
| emit<T extends EventEmitter.EventNames<EmitEvents>>( | |
| event: T, | |
| ...args: EventEmitter.EventArgs<EmitEvents, T> | |
| ): boolean { | |
| const listeners = this.events.get(event) | |
| if(!listeners) { | |
| return false | |
| } | |
| inplaceFilter(listeners, (listener) => { | |
| listener.fn?.apply(listener.context as any, args as any) | |
| return !listener.once | |
| }) | |
| if(listeners.length == 0) { | |
| this.events.delete(event) | |
| } | |
| return true | |
| } | |
| } | |
| // #endregion | |
| function inplaceFilter<T>(arr: Array<T>, cond: (val: T, i: number, a: Array<T>) => boolean) { | |
| let i = 0 | |
| while(i < arr.length) { | |
| if(!cond(arr[i], i , arr)) { | |
| arr.splice(i, 1) | |
| } | |
| else { | |
| i++ | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment