Skip to content

Instantly share code, notes, and snippets.

@Leedehai
Last active November 23, 2019 07:16
Show Gist options
  • Save Leedehai/1f8aa937d7d34590b2ab9bc8a0158136 to your computer and use it in GitHub Desktop.
Save Leedehai/1f8aa937d7d34590b2ab9bc8a0158136 to your computer and use it in GitHub Desktop.
Play: a custom event system like Chrome DevTools
/**
* Copyright (c) 2019 Leedehai. All rights reserved.
* Licensed under the MIT License. For details, refer to LICENSE.txt
* under the project root.
*
* File: events.js
* -----------------------------------------
* A custom event system so that event dispatching and listening is
* available to ordinary objects that are not instances of EventTarget's
* child classes.
* To distinguish from the native event system described by the Web
* API standard, class names in this custom event system are prefixed
* with "Site", e.g. `SiteEvent` (cannot be named `CustomEvent` because
* that is used in the Web API standard).
* @sideeffect false
*/
/**
* The custom event target, corresponding to the native `EventTarget`
* class prescribed by the Web API. Classes that wish to participate
* in the custom event system should inherit this class.
* @example
* class Foo extends SiteEventTarget { ... }
* Foo.Events = { Bar : Symbol('Bar') };
* class Baz {
* constructor(id) {
* this._id = id;
* this._foo = Foo.getInstance();
* this._foo.addEventListener(
* Foo.Events.Bar, this._action, this); // note 'this' here
* }
* _action(event) {
* console.log(`${this._id} says: ${event.data.answer}`);
* }
* }
* const baz = new Baz('baz');
* Foo.getInstance().dispatchEventToListeners(
* Foo.Events.Bar, { answer: 42 });
* // print: 'baz says: 42'
* // NOTE if we remove the 3rd param to the addEventListener() call,
* // the printout would be 'undefined says: 42', because in
* // absence of a bound `this`, when the event callback _action()
* // is executed, its `this` will refer to the SiteEventTarget
* // object, in which `_id` is undefined.
* @struct All properties are declared in constructor.
*/
export class SiteEventTarget {
constructor() {
/** @type {!Map<SiteEventKind, SiteEventListenerTupleArray>} */
this._listenerTable = new Map();
}
/**
* Deliver an event to this target's event listeners. Listeners will
* be executed in the same order they were registered on this target.
* @note Execution is synchronous.
* @param {SiteEventKind} kind
* @param {*} data Whatever event data
*/
dispatchEventToListeners(kind, data) {
const listeners = this._listenerTable.get(kind);
if (!listeners) { return; }
const timestamp = (new Date()).valueOf(); // msec since Epoch
// Iterate over the callbacks synchronously. NOTE We iterate
// over a cloned array instead of the original (elements are
// shallow-cloned so it's inexpensive), in case some callbacks
// modify the original callback array (via SiteEvent.target).
listeners.slice(0).forEach(tuple => {
const callback = tuple.callback;
const thisObj = tuple.thisObj;
callback.call(thisObj || this, new SiteEvent(this, data, timestamp));
});
}
/**
* Let the target execute a given listener callback when a
* given event is delivered to this target.
* @note It does not attempt to deduplicate listeners
* @param {SiteEventKind} kind
* @param {SiteEventCallback} eventCallback
* @param {object=} thisObj When the callback is executed, bind its `this`
* variable to the context referenced by `thisObj`. If undefined, `this`
* will be the bound to the event target.
* @return {SiteEventCallback} The added listener
*/
addEventListener(kind, eventCallback, thisObj) {
if (!this._listenerTable.has(kind)) {
this._listenerTable.set(kind, []);
}
const listenerTuples = /** @type {SiteEventListenerTupleArray} */(
this._listenerTable.get(kind));
listenerTuples.push(new SiteEventListenerTuple(eventCallback, thisObj));
return eventCallback;
}
/**
* Let the target no longer execute a given listener callback
* when a given event is delivered to this target.
* @note a listener is removed only if the callback function
* itself and `thisObj` entries both match.
* @param {SiteEventKind} kind
* @param {SiteEventCallback} eventCallback
* @param {object=} thisObj When the callback is executed, bind its `this`
* variable to the context referenced by `thisObj`. If undefined, the
* listener's context will be the event target.
* @return {?SiteEventCallback} The removed listener; null if not found
*/
removeEventListener(kind, eventCallback, thisObj) {
if (!this._listenerTable.has(kind)) {
return null;
}
const listenerTuples = /** @type {SiteEventListenerTupleArray} */(
this._listenerTable.get(kind));
const index = SiteEventTarget._indexOfListener(
listenerTuples, eventCallback, thisObj); // the first match
if (index === -1) { return null; }
listenerTuples.splice(index, 1); // delete in-place
if (listenerTuples.length === 0) {
this._listenerTable.delete(kind);
}
return eventCallback;
}
/**
* @param {SiteEventKind} kind
* @return {boolean}
*/
hasEventListeners(kind) {
return this._listenerTable.has(kind);
}
/**
* @param {SiteEventKind} kind
*/
clearEventListeners(kind) {
this._listenerTable.delete(kind);
}
/**
* @param {SiteEventListenerTupleArray} tupleArray
* @param {SiteEventCallback} eventCallback
* @param {object=} thisObj
* @return {number} The index of the found; -1 if not found
*/
static _indexOfListener(tupleArray, eventCallback, thisObj) {
for (let idx = 0; idx < tupleArray.length; ++idx) {
const tuple = tupleArray[idx];
if (tuple.callback === eventCallback
&& tuple.thisObj === thisObj) {
return idx;
}
}
return -1;
}
}
/**
* The custom event class carrying event data, corresponding to
* the native `Event` class prescribed by the Web API.
* This is to be fed into a listener callback as the only argument.
*/
export class SiteEvent {
/**
* @param {SiteEventTarget} target Reference to the target
* to which the event was dispatched. That target will
* call its matching listeners with this event object.
* @param {*} data Whatever event data
* @param {number=} timestamp If missing, will generate one
*/
constructor(target, data, timestamp) {
this.target = target;
this.data = data;
this.timestamp = timestamp || (new Date()).valueOf(); // msec since Epoch
}
}
/**
* A simple wrapper around the event callback and the `this`
* variable whose referenced context the callback will be
* bound to while executing.
* If `thisObj` is undefined, when the event callback is
* executed, its `this` will refer to the SiteEventTarget
* object which calls the callback.
*/
class SiteEventListenerTuple {
/**
* @param {SiteEventCallback} callback
* @param {object=} thisObj
*/
constructor(callback, thisObj) {
this.callback = callback;
this.thisObj = thisObj;
}
}
/**
* @typedef {symbol} SiteEventKind
* @typedef {function(SiteEvent): *} SiteEventCallback
* @typedef {!Array<SiteEventListenerTuple>} SiteEventListenerTupleArray
*/
@Leedehai
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment