Skip to content

Instantly share code, notes, and snippets.

@buschtoens
Created August 31, 2020 12:21
Show Gist options
  • Save buschtoens/5490dbd6ebd83c7f0ac8fc5c2a094a8a to your computer and use it in GitHub Desktop.
Save buschtoens/5490dbd6ebd83c7f0ac8fc5c2a094a8a to your computer and use it in GitHub Desktop.
import { addListener, trigger, removeListener } from './events';
import type { EVENTS } from './events';
class Foo {
declare [EVENTS]: {
bar(a: boolean, b: string, c: number): void;
qux(): void;
};
}
test('basic test', () => {
const foo = new Foo();
// eslint-disable-next-line @typescript-eslint/no-unused-vars ,@typescript-eslint/no-empty-function
const barListener = jest.fn((a: boolean, b: string, c: number) => {});
// eslint-disable-next-line @typescript-eslint/no-empty-function
const quxListener = jest.fn(() => {});
addListener(foo, 'bar', barListener);
addListener(foo, 'bar', barListener);
addListener(foo, 'qux', quxListener);
expect(barListener).not.toHaveBeenCalled();
expect(quxListener).not.toHaveBeenCalled();
trigger(foo, 'bar', true, 'foo', 1337);
expect(barListener).toHaveBeenCalledWith(true, 'foo', 1337);
barListener.mockReset();
expect(quxListener).not.toHaveBeenCalled();
trigger(foo, 'qux');
expect(barListener).not.toHaveBeenCalled();
expect(quxListener).toHaveBeenCalledWith();
quxListener.mockReset();
removeListener(foo, 'bar', barListener);
trigger(foo, 'bar', false, 'bye', 42);
expect(barListener).not.toHaveBeenCalled();
expect(quxListener).not.toHaveBeenCalled();
});
export const EVENTS: unique symbol = Symbol('events');
/**
* @note Theoretically this could include `symbol` as well, but TypeScript does
* not allow the generic `symbol` as an index signature, so we can't use it in
* `Record<K, V>` down below.
*/
type EventName = string | number;
type Listener = (...args: any[]) => void | boolean;
type Events = Record<EventName, Listener>;
/**
* Every class that is supposed to behave as an event target needs to implement
* this interface. To make it a zero-cost abstraction, you can do it using the
* `declare` modifier.
*
* @example
*
* ```ts
* class Foo {
* declare [EVENTS]: {
* bar(a: boolean, b: string): void;
* }
* }
* ```
*/
interface EventTarget {
[EVENTS]: Events;
}
const EVENT_TARGETS = new WeakMap<
EventTarget,
Map<EventName, Set<(...args: any[]) => void>>
>();
/**
* Returns all registered listeners for a given target and event name as a `Set`.
*/
function getListenersForEvent(
target: EventTarget,
event: EventName
): Set<EventTarget[typeof EVENTS][EventName]> {
if (!EVENT_TARGETS.has(target)) EVENT_TARGETS.set(target, new Map());
const allListeners = EVENT_TARGETS.get(target)!;
if (!allListeners.has(event)) allListeners.set(event, new Set());
return allListeners.get(event)!;
}
/**
* Adds a listener for `event` on the given `target`. Calling this function
* repeatedly has no effect. A listener is only added once.
*
* `listener` is called with `target` as the `this` context and receives the
* arguments passed to `trigger`, as defined in the `EventTarget` interface.
*
* The `target` needs to implement the `EventTarget` interface, like so:
*
* @example
* ```ts
* class Foo {
* declare [EVENTS]: {
* bar(a: boolean, b: string): void;
* }
* }
*
* const foo = new Foo();
*
* addListener(foo, 'bar', (a: boolean, b: string) => {
* console.log({ a, b });
* });
*
* trigger(foo, 'bar', true, 'hello'); // => logs `{ a: true, b: 'hello' }`
* ```
*/
export function addListener<
T extends EventTarget,
E extends keyof T[typeof EVENTS] & EventName
>(target: T, event: E, listener: T[typeof EVENTS][E]): void {
const listeners = getListenersForEvent(target, event);
listeners.add(listener);
}
export const on = addListener;
/**
* Removes a listener for `event` on the given `target`, if it exists. If it
* does not exist, nothing happens.
*
* The `target` needs to implement the `EventTarget` interface, like so:
*
* @example
* ```ts
* class Foo {
* declare [EVENTS]: {
* bar(a: boolean, b: string): void;
* }
* }
*
* const foo = new Foo();
*
* function listener(a: boolean, b: string) {
* console.log({ a, b });
* }
*
* addListener(foo, 'bar', listener);
*
* removeListener(foo, 'bar', listener);
*
* trigger(foo, 'bar', true, 'hello'); // => nothing happens
* ```
*/
export function removeListener<
T extends EventTarget,
E extends keyof T[typeof EVENTS] & EventName
>(target: T, event: E, listener: T[typeof EVENTS][E]): void {
const listeners = getListenersForEvent(target, event);
listeners.delete(listener);
}
export const off = removeListener;
/**
* Calls all registered listeners for `event` on `target` in the order that they
* were registered in.
*
* @example
* ```ts
* class Foo {
* declare [EVENTS]: {
* bar(a: boolean, b: string): void;
* }
* }
*
* const foo = new Foo();
*
* addListener(foo, 'bar', (a: boolean, b: string) => {
* console.log({ i: 0, a, b });
* });
* addListener(foo, 'bar', (a: boolean, b: string) => {
* console.log({ i: 1, a, b });
* });
*
* trigger(foo, 'bar', true, 'hello');
* // => { i: 0, a: true, b: 'hello' }
* // => { i: 1, a: true, b: 'hello' }
* ```
*/
export function trigger<
T extends EventTarget,
E extends keyof T[typeof EVENTS] & EventName
>(target: T, event: E, ...args: Parameters<T[typeof EVENTS][E]>): void {
const listeners = getListenersForEvent(target, event);
for (const listener of listeners) listener.apply(target, args);
}
export const emit = trigger;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment