4

I'm working on a small Single Page Application with Web Components custom elements and I wanted to use InversifyJS for dependency injection.

This posed an interesting challenge as custom elements are instantiated by the browser and require a parameter-less constructor. My solution to this problem was to programmatically create anonymous parameter-less classes that would extend my component classes and in their constructor, programmatically resolve the dependencies from the container and pass them in to my component class with a call to super.

Here is the code I use to "register" my component classes.

registerComponent(component: ComponentConstructor): void {
    const container = this.container;

    const newClass = class extends component {
        constructor() {
            // Create a child container per component instance to preserve context
            const childContainer = container.createChild();

            // Get the inversify:paramtypes and inversify:tagged metadata
            const paramTypes: any[] = Reflect.getMetadata('inversify:paramtypes', component) || [];
            const inversifyMetadata: Record < number, any[] > = Reflect.getMetadata('inversify:tagged', component) || {};

            // Create a map of parameter index to service identifier
            const serviceIdentifierMap: Map < number, any > = new Map();
            Object.entries(inversifyMetadata).forEach(([index, metadataList]) => {
                metadataList.forEach((metadata) => {
                    if (metadata.key === 'inject') {
                        serviceIdentifierMap.set(parseInt(index), metadata.value);
                    }
                });
            });

            // Resolve the dependencies from the child container
            const dependencies: any[] = paramTypes.map((type, index) => {
                return serviceIdentifierMap.has(index) ? childContainer.get(serviceIdentifierMap.get(index)) : childContainer.get(type);
            });

            // Bind the current component class to the child container
            childContainer.bind('currentComponent').toConstantValue(component);

            super(...dependencies);
        }
    };

    customElements.define(Reflect.getMetadata("x-tag-name", component), newClass);
}

I've also made a custom class decorator for convenience that I apply to my component classes:

export type ComponentConstructor<T = LitElement> = new (...args: any[]) => T;
type ComponentConstructorDecorator = <T extends ComponentConstructor>(target: T) => T | void;

/**
 * Decorator that marks a class as a UI component and
 * provides configuration metadata that determines how the component
 * should be processed, instantiated, and used at runtime.
 *
 * Components are the most basic UI building block of an app. An app
 * contains a tree of components.
 *
 * Components are extendend from [LitElements](https://lit.dev/docs/components/overview/).
 */
export function Component(tagName: string): ComponentConstructorDecorator {
    return function (target) {
        Reflect.defineMetadata("x-tag-name", tagName, target);
        return injectable()(target);
    };
}

This works like a charm with simple class dependencies but falls apart when trying to use it with interfaces or the named or tagged param decorators.

Here is an example of something I was trying to do:

Imagine that I had the following classes and interfaces:

interface ServiceConfig;
class Service<T extends ServiceConfig> { 
  constructor(config: T) {} 
}
class ConfigA implements ServiceConfig;
class ConfigB implements ServiceConfig;
class ConfigC implements ServiceConfig;

class ComponentA {
  constructor(service: Service<ConfigA>) {}
}

class ComponentB {
  constructor(service: Service<ConfigB>) {}
}

class ComponentC {
  constructor(service: Service<ConfigC>) {}
}

I would have liked to use bindings like these for this situation but my current implementation won't allow it:

container.bind<ServiceConfig("ServiceConfig").to(ConfigA).whenAnyAncestorIs(ComponentA); 
container.bind<ServiceConfig("ServiceConfig").to(ConfigB).whenAnyAncestorIs(ComponentB); 
container.bind<ServiceConfig("ServiceConfig").to(ConfigC).whenAnyAncestorIs(ComponentC); 

Admittedly, one might consider my registerComponent solution a bit of a hack. If you're aware of a better way to make constructor dependency injection with custom elements work, I'm all ears.

If not, I'm looking for a way to resolve dependencies programmatically that would take into account tagged and named decorators that might have been applied to dependencies in my components' constructors. Or at the very least allow for being able to contextually resolve dependencies programmatically.

snowfrogdev
  • 5,963
  • 3
  • 31
  • 58
  • Disclaimer: author here :) I've created a "simpler" DI library to work hands in hands with frontend frameworks like react. Might be useful for Lit too `iti` https://www.npmjs.com/package/iti – Nick Olszanski May 15 '23 at 14:30

0 Answers0