UNPKG

djipevents

Version:

A clean and opinionated event-handling library for the browser and Node.js.

584 lines (502 loc) 21.8 kB
/** * The `EventEmitter` class provides methods to implement the _observable_ design pattern. This * pattern allows one to _register_ a function to execute when a specific event is _emitted_ by the * emitter. * * It is intended to be an abstract class meant to be extended by (or mixed into) other objects. */ export class EventEmitter { /** * Creates a new `EventEmitter`object. * * @param {boolean} [eventsSuspended=false] Whether the `EventEmitter` is initially in a suspended * state (i.e. not executing callbacks). */ constructor(eventsSuspended = false) { /** * An object containing a property for each event with at least one registered listener. Each * event property contains an array of all the [`Listener`]{@link Listener} objects registered * for the event. * * @type {Object} * @readonly */ this.eventMap = {}; /** * Whether or not the execution of callbacks is currently suspended for this emitter. * * @type {boolean} */ this.eventsSuspended = eventsSuspended == true ? true : false; } /** * The callback function is executed when the associated event is triggered via [`emit()`](#emit). * The [`emit()`](#emit) method relays all additional arguments it received to the callback * functions. Since [`emit()`](#emit) can be passed a variable number of arguments, it is up to * the developer to make sure the arguments match those of the associated callback. In addition, * the callback also separately receives all the arguments present in the listener's * [`arguments`](Listener#arguments) property. This makes it easy to pass data from where the * listener is added to where the listener is executed. * * @callback EventEmitter~callback * @param {...*} [args] A variable number of arguments matching the ones (if any) that were passed * to the [`emit()`](#emit) method (except, the first one) followed by the arguments found in the * listener's [`arguments`](Listener#arguments) array. */ /** * Adds a listener for the specified event. It returns the [`Listener`]{@link Listener} object * that was created and attached to the event. * * To attach a global listener that will be triggered for any events, use * [`EventEmitter.ANY_EVENT`]{@link #ANY_EVENT} as the first parameter. Note that a global * listener will also be triggered by non-registered events. * * @param {string|Symbol} event The event to listen to. * @param {EventEmitter~callback} callback The callback function to execute when the event occurs. * @param {Object} [options={}] * @param {Object} [options.context=this] The value of `this` in the callback function. * @param {boolean} [options.prepend=false] Whether the listener should be added at the beginning * of the listeners array and thus executed first. * @param {number} [options.duration=Infinity] The number of milliseconds before the listener * automatically expires. * @param {number} [options.remaining=Infinity] The number of times after which the callback * should automatically be removed. * @param {array} [options.arguments] An array of arguments which will be passed separately to the * callback function. This array is stored in the [`arguments`]{@link Listener#arguments} * property of the [`Listener`]{@link Listener} object and can be retrieved or modified as * desired. * * @returns {Listener} The newly created [`Listener`]{@link Listener} object. * * @throws {TypeError} The `event` parameter must be a string or * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}. * @throws {TypeError} The `callback` parameter must be a function. */ addListener(event, callback, options = {}) { if ( (typeof event === "string" && event.length < 1) || (event instanceof String && event.length < 1) || (typeof event !== "string" && !(event instanceof String) && event !== EventEmitter.ANY_EVENT) ) { throw new TypeError("The 'event' parameter must be a string or EventEmitter.ANY_EVENT."); } if (typeof callback !== "function") throw new TypeError("The callback must be a function."); const listener = new Listener(event, this, callback, options); if (!this.eventMap[event]) this.eventMap[event] = []; if (options.prepend) { this.eventMap[event].unshift(listener); } else { this.eventMap[event].push(listener); } return listener; } /** * Adds a one-time listener for the specified event. The listener will be executed once and then * destroyed. It returns the [`Listener`]{@link Listener} object that was created and attached * to the event. * * To attach a global listener that will be triggered for any events, use * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} as the first parameter. Note that a * global listener will also be triggered by non-registered events. * * @param {string|Symbol} event The event to listen to * @param {EventEmitter~callback} callback The callback function to execute when the event occurs * @param {Object} [options={}] * @param {Object} [options.context=this] The context to invoke the callback function in. * @param {boolean} [options.prepend=false] Whether the listener should be added at the beginning * of the listeners array and thus executed first. * @param {number} [options.duration=Infinity] The number of milliseconds before the listener * automatically expires. * @param {array} [options.arguments] An array of arguments which will be passed separately to the * callback function. This array is stored in the [`arguments`]{@link Listener#arguments} * property of the [`Listener`]{@link Listener} object and can be retrieved or modified as * desired. * * @returns {Listener} The newly created [`Listener`]{@link Listener} object. * * @throws {TypeError} The `event` parameter must be a string or * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}. * @throws {TypeError} The `callback` parameter must be a function. */ addOneTimeListener(event, callback, options = {}) { options.remaining = 1; this.addListener(event, callback, options); } /** * Identifier to use when adding or removing a listener that should be triggered when any events * occur. * * @type {Symbol} */ static get ANY_EVENT() { return Symbol.for("Any event"); } /** * Returns `true` if the specified event has at least one registered listener. If no event is * specified, the method returns `true` if any event has at least one listener registered (this * includes global listeners registered to * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}). * * Note: to specifically check for global listeners added with * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}, use * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} as the parameter. * * @param {string|Symbol} [event=(any event)] The event to check * @param {function|Listener} [callback=(any callback)] The actual function that was added to the * event or the {@link Listener} object returned by `addListener()`. * @returns {boolean} */ hasListener(event, callback) { if (event === undefined) { // Check for ANY_EVENT if ( this.eventMap[EventEmitter.ANY_EVENT] && this.eventMap[EventEmitter.ANY_EVENT].length > 0 ) { return true; } // Check for any regular events return Object.entries(this.eventMap).some(([, value]) => { return value.length > 0; }); } else { if (this.eventMap[event] && this.eventMap[event].length > 0) { if (callback instanceof Listener) { let result = this.eventMap[event].filter(listener => listener === callback); return result.length > 0; } else if (typeof callback === "function") { let result = this.eventMap[event].filter(listener => listener.callback === callback); return result.length > 0; } else if (callback != undefined) { return false; } return true; } else { return false; } } } /** * An array of all the unique event names for which the emitter has at least one registered * listener. * * Note: this excludes global events registered with * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} because they are not tied to a * specific event. * * @type {string[]} * @readonly */ get eventNames() { return Object.keys(this.eventMap); } /** * Returns an array of all the [`Listener`]{@link Listener} objects that have been registered for * a specific event. * * Please note that global events (those added with * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}) are not returned for "regular" * events. To get the list of global listeners, specifically use * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} as the parameter. * * @param {string|Symbol} event The event to get listeners for. * @returns {Listener[]} An array of [`Listener`]{@link Listener} objects. */ getListeners(event) { return this.eventMap[event] || []; } /** * Suspends execution of all callbacks functions registered for the specified event type. * * You can suspend execution of callbacks registered with * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} by passing * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} to `suspendEvent()`. Beware that this * will not suspend all callbacks but only those registered with * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}. While this may seem counter-intuitive * at first glance, it allows the selective suspension of global listeners while leaving other * listeners alone. If you truly want to suspends all callbacks for a specific * [`EventEmitter`]{@link EventEmitter}, simply set its `eventsSuspended` property to `true`. * * @param {string|Symbol} event The event name (or `EventEmitter.ANY_EVENT`) for which to suspend * execution of all callback functions. */ suspendEvent(event) { this.getListeners(event).forEach(listener => { listener.suspended = true; }); } /** * Resumes execution of all suspended callback functions registered for the specified event type. * * You can resume execution of callbacks registered with * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} by passing * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} to `unsuspendEvent()`. Beware that * this will not resume all callbacks but only those registered with * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}. While this may seem * counter-intuitive, it allows the selective unsuspension of global listeners while leaving other * callbacks alone. * * @param {string|Symbol} event The event name (or `EventEmitter.ANY_EVENT`) for which to resume * execution of all callback functions. */ unsuspendEvent(event) { this.getListeners(event).forEach(listener => { listener.suspended = false; }); } /** * Returns the number of listeners registered for a specific event. * * Please note that global events (those added with * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}) do not count towards the remaining * number for a "regular" event. To get the number of global listeners, specifically use * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} as the parameter. * * @param {string|Symbol} event The event which is usually a string but can also be the special * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} symbol. * @returns {number} An integer representing the number of listeners registered for the specified * event. */ getListenerCount(event) { return this.getListeners(event).length; } /** * Executes the callback function of all the [`Listener`]{@link Listener} objects registered for * a given event. The callback functions are passed the additional arguments passed to `emit()` * (if any) followed by the arguments present in the [`arguments`](Listener#arguments) property of * the [`Listener`](Listener) object (if any). * * If the [`eventsSuspended`]{@link #eventsSuspended} property is `true` or the * [`Listener.suspended`]{@link Listener#suspended} property is `true`, the callback functions * will not be executed. * * This function returns an array containing the return values of each of the callbacks. * * It should be noted that the regular listeners are triggered first followed by the global * listeners (those added with [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}). * * @param {string} event The event * @param {...*} args Arbitrary number of arguments to pass along to the callback functions * * @returns {Array} An array containing the return value of each of the executed listener * functions. * * @throws {TypeError} The `event` parameter must be a string. */ emit(event, ...args) { if (typeof event !== "string" && !(event instanceof String)) { throw new TypeError("The 'event' parameter must be a string."); } if (this.eventsSuspended) return; // We collect return values from all listeners here let results = []; // We must make sure that we do not have undefined otherwise concat() will add an undefined // entry in the array. let listeners = this.eventMap[EventEmitter.ANY_EVENT] || []; if (this.eventMap[event]) listeners = listeners.concat(this.eventMap[event]); listeners.forEach(listener => { // This is the per-listener suspension check if (listener.suspended) return; let params = [...args]; if (Array.isArray(listener.arguments)) params = params.concat(listener.arguments); if (listener.remaining > 0) { results.push(listener.callback.apply(listener.context, params)); listener.count++; } if (--listener.remaining < 1) listener.remove(); }); return results; } /** * Removes all the listeners that were added to the object upon which the method is called and * that match the specified criterias. If no parameters are passed, all listeners added to this * object will be removed. If only the `event` parameter is passed, all listeners for that event * will be removed from that object. You can remove global listeners by using * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} as the first parameter. * * To use more granular options, you must at least define the `event`. Then, you can specify the * callback to match or one or more of the additional options. * * @param {string} [event] The event name. * @param {EventEmitter~callback} [callback] Only remove the listeners that match this exact * callback function. * @param {Object} [options] * @param {*} [options.context] Only remove the listeners that have this exact context. * @param {number} [options.remaining] Only remove the listener if it has exactly that many * remaining times to be executed. */ removeListener(event, callback, options = {}) { if (event === undefined) { this.eventMap = {}; return; } else if (!this.eventMap[event]) { return; } // Find listeners that do not match the criterias (those are the ones we will keep) let listeners = this.eventMap[event].filter(listener => { return (callback && listener.callback !== callback) || (options.remaining && options.remaining !== listener.remaining) || (options.context && options.context !== listener.context); }); if (listeners.length) { this.eventMap[event] = listeners; } else { delete this.eventMap[event]; } } /** * The `waitFor()` method is an async function which returns a promise. The promise is fulfilled * when the specified event occurs. The event can be a regular event or * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} (if you want to resolve as soon as any * event is emitted). * * If the `duration` option is set, the promise will only be fulfilled if the event is emitted * within the specified duration. If the event has not been fulfilled after the specified * duration, the promise is rejected. This makes it super easy to wait for an event and timeout * after a certain time if the event is not triggered. * * @param {string|Symbol} event The event to wait for * @param {Object} [options={}] * @param {number} [options.duration=Infinity] The number of milliseconds to wait before the * promise is automatically rejected. */ async waitFor(event, options = {}) { options.duration = parseInt(options.duration); if (isNaN(options.duration) || options.duration <= 0) options.duration = Infinity; return new Promise((resolve, reject) => { let timeout; let listener = this.addListener(event, () => { clearTimeout(timeout); resolve(); }, {remaining: 1}); if (options.duration !== Infinity) { timeout = setTimeout(() => { listener.remove(); reject("The duration expired before the event was emitted."); }, options.duration); } }); } /** * The number of unique events that have registered listeners. * * Note: this excludes global events registered with * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT} because they are not tied to a * specific event. * * @type {number} * @readonly */ get eventCount() { return Object.keys(this.eventMap).length; } } /** * The `Listener` class represents a single event listener object. Such objects keep all relevant * contextual information such as the event being listened to, the object the listener was attached * to, the callback function and so on. * */ export class Listener { /** * Creates a new `Listener` object * * @param {string|Symbol} event The event being listened to * @param {EventEmitter} target The [`EventEmitter`]{@link EventEmitter} object that the listener * is attached to. * @param {EventEmitter~callback} callback The function to call when the listener is triggered * @param {Object} [options={}] * @param {Object} [options.context=target] The context to invoke the listener in (a.k.a. the * value of `this` inside the callback function). * @param {number} [options.remaining=Infinity] The remaining number of times after which the * callback should automatically be removed. * @param {array} [options.arguments] An array of arguments that will be passed separately to the * callback function upon execution. The array is stored in the [`arguments`]{@link #arguments} * property and can be retrieved or modified as desired. * * @throws {TypeError} The `event` parameter must be a string or * [`EventEmitter.ANY_EVENT`]{@link EventEmitter#ANY_EVENT}. * @throws {ReferenceError} The `target` parameter is mandatory. * @throws {TypeError} The `callback` must be a function. */ constructor(event, target, callback, options = {}) { if ( typeof event !== "string" && !(event instanceof String) && event !== EventEmitter.ANY_EVENT ) { throw new TypeError("The 'event' parameter must be a string or EventEmitter.ANY_EVENT."); } if (!target) { throw new ReferenceError("The 'target' parameter is mandatory."); } if (typeof callback !== "function") { throw new TypeError("The 'callback' must be a function."); } // Convert single value argument to array if (options.arguments !== undefined && !Array.isArray(options.arguments)) { options.arguments = [options.arguments]; } // Define default options and merge declared options into them, options = Object.assign({ context: target, remaining: Infinity, arguments: undefined, duration: Infinity, }, options); // Make sure it is eventually deleted if a duration is supplied if (options.duration !== Infinity) { setTimeout(() => this.remove(), options.duration); } /** * An array of arguments to pass to the callback function upon execution. * @type {array} */ this.arguments = options.arguments; /** * The callback function to execute. * @type {Function} */ this.callback = callback; /** * The context to execute the callback function in (a.k.a. the value of `this` inside the * callback function) * @type {Object} */ this.context = options.context; /** * The number of times the listener function was executed. * @type {number} */ this.count = 0; /** * The event name. * @type {string} */ this.event = event; /** * The remaining number of times after which the callback should automatically be removed. * @type {number} */ this.remaining = parseInt(options.remaining) >= 1 ? parseInt(options.remaining) : Infinity; /** * Whether this listener is currently suspended or not. * @type {boolean} */ this.suspended = false; /** * The object that the event is attached to (or that emitted the event). * @type {EventEmitter} */ this.target = target; } /** * Removes the listener from its target. */ remove() { this.target.removeListener( this.event, this.callback, {context: this.context, remaining: this.remaining} ); } }