A Simple JavaScript Observer

I see a lot of scripts out there where the original author decided they needed an observer and knocked a simple one together. I’m not complaining about this situation, the observer is an extremely useful design pattern and it’s always reassuring to see one – a good observer can allow very simple and loosely-coupled integration and extension beyond the original developer’s idea.

The Problem

As great as observers are and as simple as they can be to write, the average one is very fragile. Let’s take a very simple example which shows a technique I tend to call “the list of callbacks”:

function makeObserver() {

    var events = {};

    function on(name, handler) {

        if (!events[name]) {
            events[name] = [];
        }

        if (events[name].indexOf(handler) < 0) {
            events[name].push(handler);
        }

    }

    function off(name, handler) {

        var index = Array.isArray(events[name])
            ? events[name].indexOf(handler)
            : -1;

        if (index > -1) {
            events[name].splice(index, 1);
        }

    }

    function trigger(name, data) {

        (events[name] || []).forEach(function (handler) {
            handler(data);
        });

    }

    return Object.freeze({
        on: on,
        off: off,
        subscribe: subscribe
    });

}

Sticking all the callbacks in an array is probably the most obvious approach and its simplicity is part of its charm. You would use the observer like this:

var observer = makeObserver();
observer.on("my-event", function (data) {
    console.log(data);
});
observer.trigger("my-event", "hello"); // -> logs "hello"

The example could have any number of handlers bound to a single event and any number of events (there is actually an upper limit of just over 32,000 for the number of handlers and number of events, but realistically you’ll never get close to that number). However, the simplicity hides a flaw: any error thrown in the handler will stop all the other handlers from executing.

observer.on("broken-event", function () {
    console.log(1);
    DOES_NOT_EXIST += 1;
});
observer.on("broken-event", function () {
    console.log(2);
});
observer.trigger("broken-event");
// -> logs 1
// -> ReferenceError: DOES_NOT_EXIST is not defined

This can be easily remedied with a try ... catch block and I’ve seen a few observers that take advantage of this.

function makeObserver() {

    // ...

    function trigger(name, data) {

        (events[name] || []).forEach(function (handler) {

            try {
                handler(data);
            } catch (ignore) {
            }

        });

    }

    // ...

}

// ...

observer.trigger("broken-event");
// -> logs 1
// -> logs 2

The downside of this approach is that the error is swallowed and the developer never knows about it. This has the potential to stop other code working; the code under the line that causes the error, for example. It is possible to trigger another event when that happens and pass the error, giving the developer a chance to catch it, but that involves the developer thinking ahead and binding something to that event. After all, the observer should be able to trigger events that don’t have any handlers bound to them, right?

The Solution

We need a simple system that can show the errors but also allow other handlers to execute. It turns out that we already have one of those – addEventListener. Behold:

document.addEventListener("DOMContentLoaded", function () {
    console.log(1);
    DOES_NOT_EXIST += 1;
});
document.addEventListener("DOMContentLoaded", function () {
    console.log(2);
});
// -> logs 1
// -> ReferenceError: DOES_NOT_EXIST is not defined
// -> logs 2

Dean Edwards first showed this in 2009 and he showed how to use custom events for browsers that didn’t understand DOMContentLoaded or addEventListener because, back in 2009, these were actually issues. Unless I’ve missed something, it’s no longer 2009, we don’t have to support IE7 or 8 and Dean Edwards’ solution can be made event simpler – simple enough to form the basis of an observer.

function makeObserver() {

    var dummy = document.createElement("div");

    function on(name, handler) {
        dummy.addEventListener(name, handler);
    }

    function off(name, handler) {
        dummy.removeEventListener(name, handler);
    }

    function trigger(name, data) {

        dummy.dispatchEvent(new CustomEvent(name, {
            bubbles: true,
            cancelable: true,
            detail: data
        }));

    }

    return Object.freeze({
        on: on,
        off: off,
        trigger: trigger
    });

}

If you need to support older browsers, you may need to pass false as a third argument to addEventListener and removeEventListener.

The new observer doesn’t require us to change any code (so you can replace your current solution with one based on this technique without having to change any other code) and it shows errors without stopping execution.

observer.trigger("broken-event");
// -> logs 1
// -> ReferenceError: DOES_NOT_EXIST is not defined
// -> logs 2

Taking it Further

The issue with the observer as I’ve written it is that the handlers don’t get passed the data, they get an event argument which exposes dummy. That may not be a big issue for you, but I’ll show a simple technique for working around that if you want a true replacement for the first observer at the beginning. We just need to modify the incoming handler so it only gets some of the information from its actual argument and provide a way of retrieving the modified handler from the original when the developer tries to remove the handler.

There are a couple of ways of achieving that fix. We could add the modified handler to the original as a property, but that exposes the modification and has the potential to break if a developer plays with that and we may accidentally replace a property that the developer needs:

function makeObserver() {

    // ...

    function modify(handler) {

        var modified = handler.$modified;

        if (!modified) {

            modified = function (e) {
                handler(e.detail);
            };
            handler.$modified = modified;

        }

        return modified;

    }

    function getModified(handler) {
        return handler.$modified || handler;
    }

    function on(name, handler) {
        dummy.addEventListener(name, modify(handler));
    }

    function off(name, handler) {
        dummy.removeEventListener(name, getModified(handler));
    }

    // ...

}

Another possibly is to keep a reference to the handlers in an array and their modified versions in another array, keeping the indices the same. This would keep the modification hidden but mean that we have to waste memory keeping track of functions that we may not need anymore, as well as the additional work of checking both arrays.

function makeObserver() {

    // ...

    var handlers = [];
    var modified = [];

    function modify(handler) {

        var index = handlers.indexOf(handler);

        if (index < 0) {

            index = handlers.push(handler) - 1;
            modified[index] = function (e) {
                handler(e.detail);
            };

        }

        return modified[index];

    }

    function getModified(handler) {

        var index = handlers.indexOf(handler)

        return index < 0
            ? handler
            : modified[index];

    }

    // ...

}

The best solution is to rely of WeakMap to handle the storage for us. This has he advantage that the first solution offered (removing all references to the handler also removes all references to the modified version, saving memory) but also hides the modified version like the second solution does.

function makeObserver() {

    // ...

    var handlers = new WeakMap();

    function modify(handler) {

        var modified = handlers.get(handler);

        if (!modified) {

            modified = function (e) {
                handler(e.detail);
            };
            handlers.set(handler, modified);

        }

        return modified;

    }

    function getModified(handler) {
        return handlers.get(handler) || hander;
    }

    // ...

}

Final Words

These techniques are designed to show you what’s possible and how little code that takes. The combination of addEventListener and WeakMap can create the smallest and most robust observer that I’ve ever seen (the entire observer weighs a little over 250 bytes when gzipped). If you want to see the full code (and a minified version) you can check out this Gist.

Leave a Reply

Your email address will not be published. Required fields are marked *