Skip to content
/ eventer Public
generated from BYOJS/.template

Event emitter with optional async-emit and weak-listener support

License

Notifications You must be signed in to change notification settings

BYOJS/eventer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Eventer

npm Module License

Eventer is a zero-dependency event emitter, with optional support for async emit(), and weak event listeners.

const onUpdate = data => {
    console.log(`Data updated: ${data}`);
};

events.on("update",onUpdate);

events.emit("update",{ hello: "world" })
// Data updated: { hello: "world" }

Library Tests (Demo)


Overview

The main purpose of Eventer is to provide a basic event emitter that supports two specific helpful features that most event emitters (in JS land) do not have:

  1. async emit(): asynchronous event handling sometimes makes it easier to work around difficult issues with event handling.

    For example, if the listener for one event subscribes or unsubscribes other event handlers, you can run into events that fire when they shouldn't (or vice versa). Or you may encounter infinite event loops (events calling each other mutually, for ever).

    On the other hand, asynchrony is always more intricate to manage propperly. Developers should use caution when deciding how to handle events.

    Eventer supports both sync and async modes for event emission; this mode is configured at emitter instance creation instead of at every emit() call.

  2. weak event listeners: this is a pattern for managing the subscription of events, which holds a reference to the listener (function) weakly; the emitter instance DOES NOT prevent the listener function -- and particularly, anything the function has a closure over! -- from being cleaned up by GC (garbage collection).

    Typically, developers have to remember to remove an event subscription if the listener (or any object it belongs to) is intentionally being unset for GC purposes; otherwise, an event emitter's default strong reference keeps that listener value (and its closure!) alive, preventing GC.

    Eventer supports both strong and weak modes for listener subscription; this mode is configured at emitter instance creation instead of every on() / once() call.

Deployment / Import

npm install @byojs/eventer

The @byojs/eventer npm package includes a dist/ directory with all files you need to deploy Eventer (and its dependencies) into your application/project.

Note: If you obtain this library via git instead of npm, you'll need to build dist/ manually before deployment.

Using a bundler

If you are using a bundler (Astro, Vite, Webpack, etc) for your web application, you should not need to manually copy any files from dist/.

Just import like so:

import Eventer from "@byojs/eventer";

The bundler tool should pick up and find whatever files (and dependencies) are needed.

Without using a bundler

If you are not using a bundler (Astro, Vite, Webpack, etc) for your web application, and just deploying the contents of dist/ as-is without changes (e.g., to /path/to/js-assets/eventer/), you'll need an Import Map in your app's HTML:

<script type="importmap">
{
    "imports": {
        "eventer": "/path/to/js-assets/eventer.mjs"
    }
}
</script>

Now, you'll be able to import the library in your app in a friendly/readable way:

import Eventer from "eventer";

Note: If you omit the above eventer import-map entry, you can still import Eventer by specifying the proper full path to the eventer.mjs file.

Eventer API

The API provided by Eventer is a single constructor function, to create emitter instances:

import Eventer from "..";

var events = new Eventer({ /* options */ });

The options that can be passed to the constructor:

  • asyncEmit (default: false): controls whether emit() calls will immediately trigger event listeners, or wait for the next asynchronous microtask to trigger them.

  • weakListeners (default: true): controls whether any listeners (function callbacks) are held strongly (as typical) or weakly (for more advanced memory management).

Class-based composition

The exposed API function (Eventer() above) can act as a constructable class (as seen with the new call), which means it can be used in an extends clause of a child/derived class:

class myGreatStuff extends Eventer {
    constructor(eventerOptions,otherOptions) {
        super(eventerOptions);
        // ..
    }

    // ..
}

var thing = new myGreatStuff(..);

thing instanceof MyGreatStuff;  // true
thing instanceof Eventer;       // true

thing.emit("whatever");

Composition through inheritance essentially mixes in event emitter capabilities to your own data structure definition. Many prefer this approach.

Others prefer a more explicit form of composition (over/instead of inheritance) to maintain an Eventer instance as a clean, separate object. For example:

class myGreatStuff {
    eventer = new Eventer(..)

    // ..
}

var thing = new myGreatStuff(..);

thing.eventer.emit("whatever");

Without classes

If you're not using Eventer() as an inheritable parent class, you don't really have to use class design at all.

In fact, Eventer() can be called as a factory function without new, if you prefer:

var events = Eventer({ /* options */ });

Be aware of this!

Even if Eventer() is called without new, a class instance is still created underneath. That means that the methods on the returned object instance (e.g., events.emit(..)) are this-aware of their host context (instance).

The following approaches will break:

var myEmit = events.emit;

myEmit("whatever");         // broken!
someAsyncTask().then(events.emit);  // broken!
events.emit.call(myOtherObject,"whatever");  // broken!

Instead, you'll need to ensure methods are always called against their original instance as this context.

events.emit("whatever");  // safe, preferred
var myEmit = events.emit;

myEmit.call(events,"whatever");  // safe
someAsyncTask().then(
    evtName => events.emit(evtName)  // safe, preferred
);

someAsyncTask().then(
    events.emit.bind(events)  // safe
);

Instance API

Each instance of Eventer provides the following methods.

on(..) Method

The on(..) method subscribes a listener (function) to an event (by string name, or Symbol value):

function onWhatever() {
    console.log("'whatever' event fired!");
}

// subscribe to "whatever" event
events.on("whatever",onWhatever);
function onSpecialEvent() {
    console.log("special event fired!");
}

// subscribe to `specialEvent` event
var specialEvent = Symbol("special event");
events.on(specialEvent,onSpecialEvent);

Event listener functions are invoked with this-context of the emitter instance, if possible; => arrow functions never have this binding, and already this-hard-bound (via .bind(..)) functions cannot be this-overridden -- and class constructors require new invocation!

Event subscriptions must be unique, meaning the event+listener combination must not have already been subscribed. This makes Eventer safer, preventing duplicate event subscriptions -- a common bug in event-oriented program design.

The on(..) method returns true if successfully subscribed, or false (if subscription was skipped).

Event arguments

An event listener function may optionally declare one or more parameters, which are passed in as arguments when the event is emit(..)ed.

For example:

function onPositionUpdate(x,y) {
    console.log(`Map position: (${x},${y})`);
}

myMap.on("position-update",onPositionUpdate);

// elsewhere:
myMap.emit("position-update",centerX,centerY);

AbortSignal unsubscription

A recent welcomed change to the native addEventListener(..) browser API is the ability to pass in an AbortSignal instance (from an AbortController instance); if the "abort" event is fired, the associated event listener is unsubscribed, instead of having to manually call removeEventListener(..) to unsubscribe. This is helpful because you don't need keep around any reference to the listener function to unsubscribe it.

Eventer also supports this functionality:

function onWhatever() {
    console.log("'whatever' event fired!");
}

var ac = new AbortController();

// subscribe to "whatever" event, but set up
// the abort-signal to unsubscribe
events.on("whatever",onWhatever,{ signal: ac.signal });

// later:
ac.abort("Unsubscribe!");

Note: An AbortSignal instance is also held weakly by Eventer, so any GC of either the listener or the signal will drop the relationship between them as desired -- without preventing GC of each other.

Inline event listeners (functions)

It's very common in modern JS programming, and especially with event handling code, to pass inline functions (e.g., => arrow functions) as event listeners. However, there are some very important details/gotchas to be aware of when doing so with Eventer.

NOT inline event listeners

Before we explain those gotchas, let's highlight the preferred alternatives to inline functions (as already implied in previous snippets!):

function onWhatever() {
    // this is a safe and stable event listener
    console.log("'whatever' event fired!");
}

events.on("whatever",onWhatever);
var myApp = {
    // ..
    onWhatever() {
        // this is a safe and stable event listener,
        // as long as it's not `this`-dependent
        console.log("'whatever' event fired!");
    }
    // ..
};

events.on("whatever",myApp.onWhatever);
class App {
    // ..
    onWhatever = () => {
        // this is a safe and stable event listener,
        // even if it uses `this` (since it's a
        // lexical-`this` arrow function)
        console.log("'whatever' event fired!");
    }
    // ..
}

var myApp = new App();

events.on("whatever",myApp.onWhatever);

All of these approaches are safe and avoid the issues we will now cover with using inline function listeners.

Inline handler gotchas

First of all, the subscription (on(..) / once(..)) mechanism uses function reference identity to determine uniqueness of event+listener subscription. If you pass an inline function expression (or a dynamically this-bound function instance), each subscription will use a new function; the duplicate-subscription prevention will be defeated, potentially leading to bugs.

For example:

function listenToWhatever() {
    events.on(
        "whatever",
        () => console.log("'whatever' event fired!")
    );
}

listenToWhatever();

// later, elsewhere:
listenToWhatever();

Here, each => arrow function is unique (per listenToWhatever() call), so there are now two distinct event subscriptions. When the "whatever" event is fired, both listeners will fire. This may be desired, but it's often a confusing gotcha bug.

It's generally a good idea to pass non-inline functions (with stable definitions), as listeners; this enables Eventer's helpful duplicate event handler prevention.

Unsubscribe what?

Another concern with passing inline functions as listeners: the most common/preferred off(..) unsubscription approach requires the same function reference for unsubscription as was originally subscribed. You almost certainly will not hold another reference to an inline function -- by definition, it was defined only inline at the subscription site -- to use in its later unsubscription.

events.on(
    "whatever",
    () => console.log("'whatever' event fired!")
);

// later:
events.off("whatever", /* OOPS, what do I pass here!? */)

Note: This unsubscription concern is not unworkable, though. There are other ways to use off(..) unsubscription that avoid this issue, or you can use an AbortSignal to unsubscribe.

Accidental unsubscription

The most pressing concern with inline event listeners arises when using the weak event listeners mode. Since this is the default mode of Eventer, it's of particular importance to be aware of this very likely gotcha.

Since there is almost certainly no other reference to an inline function reference other than the one passed into on(..) / once(..), once the lexical scope (i.e., surrounding function, etc) of the subscription has finished, and its contents are now subject to GC cleanup, the listener function itself will likely be GC removed.

By design, Eventer's weak event listeners mode ensures event subscriptions are discarded if the listener itself is GC'd. This helps prevent accidental memory leaks when forgetting to unsubscribe events that are no longer relevant.

However, GC is inherently and intentionally somewhat unpredictable. It's not guaranteed, or even likely, that GC will happen immediately on a lexical scope being completed; it may happen sometime in the near future -- and, only if there are no intentional or accidental closures keeping all or part of the lexical scope alive!

That means your event subscriptions with inline functions are subject to fairly unpredictable behavior. They may fire for awhile and then silently stop, even with no further affirmative action from your controlling app code.

For illustration:

function listenToWhatever() {
    events.on(
        "whatever",
        () => console.log("'whatever' event fired!")
    );
}

listenToWhatever();

After the call to listenToWhatever(), any "whatever" events fired, may be handled or not, unpredictably, because the inner => arrow function is now subject to GC cleanup at any point the JS engine feels like it!

Hopefully it's clear that you should avoid inline function listeners, at least when using the weak event listeners mode of Eventer.

once(..) Method

The once(..) method subscribes like on(..), except that as soons as the event is emitted the first time, the listener is unsubscribed. This guarantees a specific event+listener will first at most "once".

function onWhateverOnce() {
    console.log("'whatever' event fired (just once)!");
}

// subscribe to "whatever" event, but only once!
events.once("whatever",onWhateverOnce);

once(..) and on(..) perform the same kind of event subscription. Subsequent calls of once(..) or on(..) (in any combination) will skip any subsequent subscriptions (returning false). You cannot switch from on(..) to once(..) style subscription (or vice versa) by calling one method after the other (with the same event+listener); to switch, you must first unsubscribe with off(..) before re-subscribing.

off(..) Method

The off(..) method unsubscribes an event+listener that was previously subscribed with the on(..) or once(..) methods.

function onWhatever() {
    console.log("'whatever' event fired!");
}

// unsubscribe from "whatever" event
events.off("whatever",onWhatever);

The method will return true if the event was unsubscribed, or false if no matching event+listener subscription could be found.

Alternate unsubscription

The two arguments to off(..) are both optional.

If you pass only the first event-name argument, but leave off the listener argument, all liseners for that event will be removed:

// remove any 'whatever' listeners
events.off("whatever");

true will be returned if any event listeners are currently subscribed, or false otherwise.

If you instead pass only the second listener argument (with null or undefined for the first event-name argument), it will unsubscribe all events that have included that specific listener:

function onEvent() { /* .. */ }

events.off(null,onEvent);

true will be returned if the listener is subscribed to any events, or false otherwise.

If you call off() with no arguments, all events with any event listeners are unsubscribed:

// clear out all event subscriptions unconditionally!
events.off();

true will be returned if any event+listener subscription is found to remove, or false otherwise.

emit(..) Method

To emit an event against all listeners on an emitter instance, call emit(..):

events.emit("whatever");
// specialEvent: Symbol("special event")

events.emit(specialEvent);

Note: If a listener function throws an exception, this error will be reported to the consolve (via console.error()), but will not stop the emit() call. All handlers will be given a fair chance to execute.

Any subscription/unsubscription operations from/during a listener execution will NOT take effect until after all event listeners queued by emit() have had a chance to be invoked (synchronously or asynchronously).

You can optionally pass one or more arguments after the event name, which will be passed to the event listener(s):

events.emit("whatever",42,[ "hello", "world" ]);

Note: This call will pass two arguments to the listener function(s), 42 and the array ["hello","world"].

Sync vs Async modes

If the emitter is in sync-emit mode (default, configured at instance construction), any matching listener function(s) will be called synchronously during the emit(..) call.

function onWhatever() {
    console.log("'whatever' event fired!");
}

var events = new Eventer({ asyncEmit: false });

events.on("whatever",onWhatever);
events.emit("whatever");
console.log("Done.");
// 'whatever' event fired!
// Done.

Note: The emit() call in sync mode invokes all event listeners while it is running, which is why Done. message is printed last.

If the emitter is in async-emit mode (configured at instance construction), any matching listener function(s) at the time of emit() call will be asynchronously scheduled for the next microtask. However, emit() always still completes immediately.

function onWhatever() {
    console.log("'whatever' event fired!");
}

var events = new Eventer({ asyncEmit: true });

events.on("whatever",onWhatever);
events.emit("whatever");
console.log("Done.");
// Done.
// 'whatever' event fired!

Note: Here (async mode), the Done. message is printed first, because the current stack of execution completes before the next microtask runs (and processes async scheduled event listener invocations).

releaseListeners(..) Method

If using weak event listeners mode (default), the releaseListeners(..) method is no-op (does nothing).

But if that mode is turned off (i.e., strong event listeners mode), the releaseListeners(..) mode can be used to release a specific listener, or all listeners if no argument is passed.

// release specific event listener
events.releaseListeners(onWhatever);
// release all event listeners
events.releaseListeners();

This method is intended for use as proactive-cleanup, under the specific circumstance when you know that the subscribed listener(s) in question will go out of scope (and otherwise be GC'd) in the future, and you want the event(s)+listener(s) to be implicitly unsubscribed when doing so.

In other words, it's a way to opt-in to weak event listeners mode, on an otherwise strong event listeners mode emitter instance, but only for currently subscribed listeners (not future subscriptions on the instance).

This differs from calling off(null,onWhatever) / off() in that releaseListeners() does not affirmatively unsubscribe the events (as off(..) does), but merely allow future implicit unsubscription.

Note: If you're in the circumstance where all listener(s) have already gone out of scope, and you might be tempted to call releaseListeners() (no arguments) to allow the GC, this circumstance is better suited to use off() (no arguments) instead.

Re-building dist/*

If you need to rebuild the dist/* files for any reason, run:

# only needed one time
npm install

npm run build:all

Tests

This library only works in a browser, so its test suite must also be run in a browser.

Visit https://byojs.dev/eventer/ and click the "run tests" button.

Run Locally

To instead run the tests locally, first make sure you've already run the build, then:

npm test

This will start a static file webserver (no server logic), serving the interactive test page from http://localhost:8080/; visit this page in your browser and click the "run tests" button.

By default, the test/test.js file imports the code from the src/* directly. However, to test against the dist/* files (as included in the npm package), you can modify test/test.js, updating the /src in its import statements to /dist (see the import-map in test/index.html for more details).

License

License

All code and documentation are (c) 2024 Kyle Simpson and released under the MIT License. A copy of the MIT License is also included.