12

So, in a library that I'm creating that uses custom elements, you obviously need to define the class in the CustomElementsRegistry before you may instantiate it.

As of right now, this is being solved with a decorator:

class Component extends HTMLElement {

    static register (componentName) {
        return component => {
            window.customElements.define(componentName, component);
            return component;
        }
    }
}

@Component.register('my-element')
class MyElement extends Component { }

document.body.appendChild(new MyElement());

This works, however, I would like to automatically register the custom element upon instantiation of the class (so that the author does not have to add the decorator to every single component that they write). This could maybe be accomplished via a Proxy.


My problem, though, is that when I attempt to use a Proxy on the constructor, and attempt to return an instance of the target, I still get Illegal Constructor, as if the element has never been defined in the registry.

This obviously has to do with the way I am instantiating the class inside of the proxy, but I'm unsure of how to do it otherwise. My code is as follows:

Please run in latest Chrome:

class Component extends HTMLElement {

    static get componentName () {
        return this.name.replace(/[A-Z]/g, char => `-${ char.toLowerCase() }`).substring(1);
    }
}

const ProxiedComponent = new Proxy(Component, {

    construct (target, args, extender) {
        const { componentName } = extender;
   
        if (!window.customElements.get(componentName)) {
            window.customElements.define(componentName, extender);
        }
    
        return new target(); // culprit
    }
});

class MyElement extends ProxiedComponent { }

document.body.appendChild(new MyElement());

How can I continue the chain of inheritance inside of the proxy without losing the context of the fact that I'm instantiating the MyElement class so that it doesn't throw the Illegal Constructor exception?

ndugger
  • 7,373
  • 5
  • 31
  • 42
  • I believe this is the expected behavior. You should be able to do `document.createElement(LibElement.componentName)`. The classes created by extending the native `HTMLElement` aren't actually valid for constructing elements, and should be constructed using `document.createElement` instead. – shamsup Dec 12 '17 at 21:05
  • as a solution, you could do `return document.createElement(target.componentName)` in the proxy construct. – shamsup Dec 12 '17 at 21:08
  • @shamsup Are you saying that they aren't valid for constructing elements according to the specification, or that it simply doesn't work--because it does work (in chrome). – ndugger Dec 12 '17 at 21:13
  • I mean even just `class Custom extends HTMLElement {}; new Custom();` will throw the illegal constructor error because `HTMLElement` cannot be created with the `new` keyword and must be created through `document.createElement` – shamsup Dec 12 '17 at 21:17
  • That only applies if the class has not been added to the `CustomElementsRegistry` – ndugger Dec 12 '17 at 21:18

3 Answers3

11

There were 2 problems:

  • new target() created LibElement instance, that is not registered as custom element. And here you got Illegal Constructor error.
  • even if you register LibElement resulting DOM element will be <lib-element>, cause you call new target and at this point javascript have no idea about child class.

The only way i found is to use Reflect API to create right instance of object.

class LibElement extends HTMLElement {
    static get componentName () {
        return this.name.replace(/[A-Z]/g, char => `-${ char.toLowerCase() }`).substring(1);
    }
}

const LibElementProxy = new Proxy(LibElement, {
    construct (base, args, extended) {
        if (!customElements.get(extended.componentName)) {
            customElements.define(extended.componentName, extended);
        }
    
        return Reflect.construct(base, args, extended);
    }
});

class MyCustomComponent extends LibElementProxy {}
class MyCustomComponentExtended extends MyCustomComponent {}

document.body.appendChild(new MyCustomComponent());
document.body.appendChild(new MyCustomComponentExtended());

And I really liked this idea of proxied constructor for auto-registration of custom elements )

Gheljenor
  • 362
  • 3
  • 3
  • 2
    This is the solution! You're amazing. I thought I was going to have to resort to `Reflect`, but I wasn't sure when or where in the code. – ndugger Dec 12 '17 at 21:46
0

You are extremely close to a solution here, and the only problem is that the native HTMLElement cannot be instantiated with the new keyword, and must be created through document.createElement. You can reuse everything you have and only replace the return value of the construct method in the proxy:

class Component extends HTMLElement {

  static get componentName() {
    return this.name.replace(/[A-Z]/g, char => `-${ char.toLowerCase() }`).substring(1);
  }
}

const ProxiedComponent = new Proxy(Component, {

  construct(target, arguments, extender) {
    const {
      componentName
    } = target;

    if (!window.customElements.get(componentName)) {
      window.customElements.define(componentName, extender);
    }

    return document.createElement(target.componentName); // Properly constructs the new element
  }
});

class MyElement extends ProxiedComponent {}

document.body.appendChild(new MyElement());
shamsup
  • 1,952
  • 14
  • 18
  • Except that you can use the `new` keyword on inheriting classes as long as the class has been registered in the `CustomElementsRegistry`. I will see if this works, though. – ndugger Dec 12 '17 at 21:17
  • Yeah, this does not work properly. It either creates a `` node, or if I change `target` to `extender`, it creates a `` node, but it's an `HTMLUnknownElement` instead of inheriting from `Component`. – ndugger Dec 12 '17 at 21:20
0

I was trying to do the same recently and was completely overthinking it. In my case, I wanted to implement dependency injection.

Here's a solution with a TypeScript class decorator, no proxy:

const customElement = ((tagName: string, elementOptions?: ElementDefinitionOptions) =>
    (<TARGET extends Constructor<HTMLElement>>(target: TARGET): any => {
      class CustomElementClass extends target {

        public constructor(..._args: any[]) {
          super(...yourArgumentReplacementFunc());
        }

      }

      customElements.define(tagName, CustomElementClass, elementOptions);

      return CustomElementClass;
    }));

The decorator replaces the class, with it as super, and calls it's constructor with replaced arguments (injected in my case). A custom element must have no constructor parameters.

Oliver
  • 1,465
  • 4
  • 17