1

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();
  } 
}
2
  • ... be aware of the typo's consequences ... cancellable versus cancelable. Commented Nov 29, 2022 at 11:38
  • const eventTargetMixin = (superclass) => class extends superclass { /* How to mixin EventTarget? */ } is not a mixin pattern it's pure inheritance. Because of this and JavaScript implementing just single inheritance this so called and widely promoted "mixin" pattern unsurprisingly fails. Therefore and because the OP does not want 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. Commented Nov 29, 2022 at 12:05

2 Answers 2

1
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>

4
  • Thanks for the detailed answer. I actually ended up doing something similar last night with manually proxying the 3 functions EventTarget exposes as a hope of a temporary workaround. Sounds like that is actually the only way forward with this unfortunately. Commented Nov 29, 2022 at 18:52
  • @JoeJankowiak ... "proxying the 3 functions EventTarget exposes as a hope of a temporary workaround" ... with the provided approach of a forwarding proxy mixin-pattern and its implementation for EventTarget such tasks are not anymore workarounds. The latter (the implemented EventTarget mixin) now can be re-used when and wherever needed. Commented Nov 30, 2022 at 11:09
  • When I said "temporary workaround" I meant because if EventTarget spec is ever changed, you have to 1. Know that it changed and 2. Keep it in sync with the spec. Its unfortunate, but the reality of JS limitations. Gave ya the upvote too :) Commented Nov 30, 2022 at 19:09
  • @JoeJankowiak ... an API change always would effect the code (in any PL capable of classes and/or mixins/traits/roles) even in case one would extend from EventTarget. Then one would need to change all method names of any instance which is used by the code. Btw ... maybe you already saw the extra code (last edit of the above code) which generates forwarding proxy mixins from class constructors. Commented Nov 30, 2022 at 19:16
0

Because of recently having answered ..."How can I "augment" a JS class with methods from another class, without extending it?" .., I had to have a look into this thread/question again.

A year ago I did argue ...

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.

Especially the last sentence is not quite correct, because one actually can achieve the result wanted by the OP, in case one gets the dynamic subclassing approach right.

For a recall, the OP's problem is as follows ... a Router class has to extend the UniversalRouter class, but also needs to feature all characteristics described by the EventTarget class.

A class-creating factory function where a custom class implementation extends any other class that has been passed to the factory is indeed suitable for solving such a task (and similar ones).

As for the OP's problem again, one had to implement a factory function named e.g. asEventTargetProxy which excepts another class as its sole argument. This function accomplishes an ad-hoc creation of an e.g. EventTargetProxy class which extends the super class passed to the factory.

Due to the mechanics of single inheritance the feature implementation of most of such dynamically subclassed types will be somehow proxy based. An EventTargetProxy for instance would create a private EventTarget instance. Then it would implement (either prototypal or directly owned) all of an event-target's methods by forwarding every invocation to the related method of its private instance.

function asEventTargetProxy(SuperType) {
  class EventTargetProxy extends SuperType {
    #proxy;

    constructor(...args) {
      super(...args);

      this.#proxy = new EventTarget;
      this.#proxy.proxied = this;
    }

    // - prototypal implementation of forwarding
    //   upon a private `EventTarget` instance.

    dispatchEvent(...args) {
      return this.#proxy.dispatchEvent(...args);
    }
    addEventListener(...args) {
      return this.#proxy.addEventListener(...args);
    }
    removeEventListener(...args) {
      return this.#proxy.removeEventListener(...args);
    }
  }
  return EventTargetProxy;
}

The code of how a Router class implementation then extends an UniversalRouter while also applying EventTarget functionality then looks like this ...

class Router extends asEventTargetProxy(UniversalRouter) {
  willNavigate(location) {

    const canceled = this.dispatchEvent(
      new Event('navigate', { cancelable: true })
    );

    // ... ... ...
  }
}

If it comes to naming, I'd like to coin the term "Interpositioned Dynamic Subclassing" in order to describe what really happens with the application of this pattern instead of the incorrectly used and thus entirely misleading name Mixin. A factory following this pattern very likely will be forced to return a dynamically subclassed proxy.

Everything put together then works like that ...

function asEventTargetProxy(SuperType) {
  class EventTargetProxy extends SuperType {
    #proxy;

    constructor(...args) {
      super(...args);

      this.#proxy = new EventTarget;
      this.#proxy.proxied = this;
    }

    // - prototypal implementation of forwarding
    //   upon a private `EventTarget` instance.

    dispatchEvent(...args) {
      return this.#proxy.dispatchEvent(...args);
    }
    addEventListener(...args) {
      return this.#proxy.addEventListener(...args);
    }
    removeEventListener(...args) {
      return this.#proxy.removeEventListener(...args);
    }
  }
  return EventTargetProxy;
}

class UniversalRouter {
  navigate(...args) {

    console.log(
      'invoked `navigate` method ...',
      { instance: this, args },
    );
  }
}

class ObservableRouter extends asEventTargetProxy(UniversalRouter) {
  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(
    "handle 'navigate' type ...",
    { type, cancelable, target },
  );
});

console.log(
  'prototype chain of `router` ...',
  assemblePrototypeChainGraph(router),
);
router.willNavigate('somewhere');
.as-console-wrapper { min-height: 100%!important; top: 0; }
<script>
  function assemblePrototypeChainGraph(value) {
    let result = '';
    let depth = 0;

    while (value = Object.getPrototypeOf(value)) {
      result = [

        result,
        '\n',
        Array(depth++).fill('  ').join(''),
        '=> ',
        value.constructor.name,

      ].join('');
    }
    return result;
  }
</script>

1
  • @JoeJankowiak ... FYI ... There is yet another, this time inheritance based, approach which does get the underlaying dynamic subclassing pattern right. Commented Dec 4, 2023 at 17:10

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.