javascriptinheritanceproxymixinses6-class

How to implement Web-API EventTarget functionality into a class which already extends another class?


I often run into the problem of wanting to extend a class from a library (a class I do not control) but also have the class have the functionality of an EventTarget/EventEmitter.

class Router extends UniversalRouter { 
  ...
  // Add functionality of EventTarget
}

I'd also like to make this class an EventTarget so it can dispatch events and listen to events. Its not important that its an instance of EventTarget, just that its functionality is callable directly on the object.

I've tried merging the prototypes, and while this does copy over the prototype functions, when trying to add an event listener, I get an error:

Uncaught TypeError: Illegal invocation

class Router extends UniversalRouter { 
  willNavigate(location) {
    const cancelled = this.dispatchEvent(new Event('navigate', { cancellable: true }));
    if(cancelled === false) {
      this.navigate(location);
    }
  }
}
Object.assign(Router.prototype, EventTarget.prototype);

I'm aware of the Mixin pattern, but I do not see how you could use that to extend an existing class:

const eventTargetMixin = (superclass) => class extends superclass {
  // How to mixin EventTarget?
}

I do not want a HAS-A relationship where I make a new EventTarget as a property inside my object:

class Router extends UniversalRouter { 
  constructor() {
    this.events = new EventTarget();
  } 
}

Solution

  • const eventTargetMixin = superclass =>
      class extends superclass {
        // How to mixin EventTarget?
      }
    

    ... is not a mixin pattern, it's pure inheritance (which might be names "dynamic sub-classing" or "dynamic sub-typing"). Because of this and JavaScript implementing just single inheritance this so called and widely promoted "mixin" pattern unsurprisingly fails for the scenario described by the OP.

    Therefore and due to the OP not wanting to rely on aggregation (not wanting ... this.events = new EventTarget();) one has to come up with a real mixin which assures true Web-API EventTarget behavior for any of the OP's custom router instances.

    But first one might have a look into the OP's changed code that already implements proxyfied EventTarget behavior ...

    class UniversalRouter {
      navigate(...args) {
    
        console.log('navigate ...', { reference: this, args });
      }
    }
    class ObservableRouter extends UniversalRouter {
    
      // the proxy.
      #eventTarget = new EventTarget;
    
      constructor() {
        // inheritance ... `UniversalRouter` super call.
        super();
      }
      willNavigate(location) {
        const canceled = this.dispatchEvent(
          new Event('navigate', { cancelable: true })
        );
        if (canceled === false) {
          this.navigate(location);
        }
      }
    
      // the forwarding behavior.
      removeEventListener(...args) {
        return this.#eventTarget.removeEventListener(...args);
      }
      addEventListener(...args) {
        return this.#eventTarget.addEventListener(...args);
      }
      dispatchEvent(...args) {
        return this.#eventTarget.dispatchEvent(...args);
      }
    };
    const router = new ObservableRouter;
    
    router.addEventListener('navigate', evt => {
      evt.preventDefault();
    
      const { type, cancelable, target } = evt;
    
      console.log({ type, cancelable, target });
    });
    
    router.willNavigate('somewhere');
    .as-console-wrapper { min-height: 100%!important; top: 0; }

    ... which works upon the private field #eventTarget where the latter is a true EventTarget instance which gets accessed via the forwarding prototypal methods one does expect an event target to have.

    Though the above implementation works as intended, one finds oneself wanting to abstract the proxy-based forwarding away as soon as one starts experiencing scenarios similar to the ones explained by the OP ...

    I often run into the problem of wanting to extend a class from a library (a class I do not control) but also have the class have the functionality of an EventTarget/EventEmitter.

    I'd also like to make this class an EventTarget so it can dispatch events and listen to events. It's not important that it's an instance of EventTarget, just that its functionality is callable directly on the object.

    Since functions (except arrow functions) are capable of accessing a this context one can implement the forwarding proxy functionality into what I personally like to refer to as function-based mixin.

    In addition, even though such an implementation could be used as constructor function, it is not and also is discouraged to be used as such. Instead, it always has to be applied to any object like this ... withProxyfiedWebApiEventTarget.call(anyObject) ... where anyObject afterwards features all of an event target's methods like dispatchEvent, addEventListener and removeEventListener.

    // function-based `this`-context aware mixin
    // which implements a forwarding proxy for a
    // real Web-API EventTarget behavior/experience.
    function withProxyfiedWebApiEventTarget() {
      const observable = this;
    
      // the proxy.
      const eventTarget = new EventTarget;
    
      // the forwarding behavior.
      function removeEventListener(...args) {
        return eventTarget.removeEventListener(...args);
      }
      function addEventListener(...args) {
        return eventTarget.addEventListener(...args);
      }
      function dispatchEvent(...args) {
        return eventTarget.dispatchEvent(...args);
      }
    
      // apply behavior to the mixin's observable `this`.
      Object.defineProperties(observable, {
        removeEventListener: {
          value: removeEventListener,
        },
        addEventListener: {
          value: addEventListener,
        },
        dispatchEvent: {
          value: dispatchEvent,
        },
      });
    
      // return observable target/type.
      return observable
    }
    
    class UniversalRouter {
      navigate(...args) {
    
        console.log('navigate ...', { reference: this, args });
      }
    }
    
    class ObservableRouter extends UniversalRouter {
      constructor() {
    
        // inheritance ... `UniversalRouter` super call.
        super();
    
        // mixin ... apply the function based
        //           proxyfied `EventTarget` behavior.
        withProxyfiedWebApiEventTarget.call(this);
      }
      willNavigate(location) {
        const canceled = this.dispatchEvent(
          new Event('navigate', { cancelable: true })
        );
        if (canceled === false) {
          this.navigate(location);
        }
      }
    };
    const router = new ObservableRouter;
    
    router.addEventListener('navigate', evt => {
      evt.preventDefault();
    
      const { type, cancelable, target } = evt;
    
      console.log({ type, cancelable, target });
    });
    
    router.willNavigate('somewhere');
    .as-console-wrapper { min-height: 100%!important; top: 0; }

    This answer is closely related to other questions targeting observable or event target behavior. Therefore other use cases / scenarios than the one asked by the OP will be hereby linked to ...

    1. extending the Web-API EventTarget

    2. implementing an own/custom event dispatching system for ES/JS object types

    Edit ... pushing the above approach / pattern of an EventTarget-specific proxyfied forwarder / forwarding mixin even further, there is another implementation which generically creates such mixins from passed class constructors ...

    const withProxyfiedWebApiEventTarget =
      createProxyfiedForwarderMixinFromClass(
        EventTarget, 'removeEventListener', 'addEventListener', 'dispatchEvent'
      //EventTarget, ['removeEventListener', 'addEventListener', 'dispatchEvent']
      );
    
    class UniversalRouter {
      navigate(...args) {
    
        console.log('navigate ...', { reference: this, args });
      }
    }
    
    class ObservableRouter extends UniversalRouter {
      constructor() {
    
        // inheritance ... `UniversalRouter` super call.
        super();
    
        // mixin ... apply the function based
        //           proxyfied `EventTarget` behavior.
        withProxyfiedWebApiEventTarget.call(this);
      }
      willNavigate(location) {
        const canceled = this.dispatchEvent(
          new Event('navigate', { cancelable: true })
        );
        if (canceled === false) {
          this.navigate(location);
        }
      }
    };
    const router = new ObservableRouter;
    
    router.addEventListener('navigate', evt => {
      evt.preventDefault();
    
      const { type, cancelable, target } = evt;
    
      console.log({ type, cancelable, target });
    });
    
    router.willNavigate('somewhere');
    .as-console-wrapper { min-height: 100%!important; top: 0; }
    <script>
    function isFunction(value) {
      return (
        'function' === typeof value &&
        'function' === typeof value.call &&
        'function' === typeof value.apply
      );
    }
    function isClass(value) {
      let result = (
        isFunction(value) &&
        (/class(\s+[^{]+)?\s*{/).test(
          Function.prototype.toString.call(value)
        )
      );
      if (!result) {
        // - e.g. as for `EventTarget` where
        //   Function.prototype.toString.call(EventTarget)
        //   returns ... 'function EventTarget() { [native code] }'.
        try { value(); } catch({ message }) {
          result = (/construct/).test(message);
        }
      }
      return result;
    }
    
    function createProxyfiedForwarderMixinFromClass(
      classConstructor, ...methodNames
    ) {
      // guards.
      if (!isClass(classConstructor)) {
        throw new TypeError(
          'The 1st arguments needs to be a class constructor.'
        );
      }
      methodNames = methodNames
        .flat()
        .filter(value => ('string' === typeof value));
    
      if (methodNames.length === 0) {
        throw new ReferenceError(
          'Not even a single to be forwarded method name got provided with the rest parameter.'
        );
      }
    
      // mixin implementation which gets created/applied dynamically.
      function withProxyfiedForwarderMixin(...args) {
        const mixIntoThisType = this;
    
        const forwarderTarget = new classConstructor(...args) ?? {};
        const proxyDescriptor = methodNames
          .reduce((descriptor, methodName) =>
            Object.assign(descriptor, {
    
              [ methodName ]: {
                value: (...args) =>
                  forwarderTarget[methodName]?.(...args),
              },
            }), {}
          );
        Object.defineProperties(mixIntoThisType, proxyDescriptor);
    
        return mixIntoThisType;
      }
      return withProxyfiedForwarderMixin;
    }
    </script>