Skip to content

Explicit component/directive/pipe dependencies (getting rid of the need for NgModules for most cases) #35646

@Maximaximum

Description

@Maximaximum

🚀 feature request

Relevant Package

This feature request is mainly for @angular/compiler and @angular/core, I guess.

Description

It would be awesome if we could explicitly specify which components, directives and pipes a component actually depends on, instead of relying on hierarchical NgModule declarations, which are difficult to read and manage.

How DI works for @Injectable()s

Currently, the DI for services can be used like this:

export const SERVICE2 = new InjectionToken<Service2>('service2', { providedIn: 'root', factory: () => new Service2() });

@Component(...)
export class SomeComponent {
  constructor(
    private service1: Service1,
    private @Inject(SERVICE2) service2: Service2
  ) {}
}

Whenever we need to inject a service into a constructor, we simply do es6 import of the required service, and put it as a type annotation on the corresponding constructor argument. For this to work we only need to make sure that the @Injectable() decorator contains { providedIn: 'root' | SomeModule } or, if using InjectionToken, the new InjectionToken() call contains { providedIn: 'root', factory: ... }. We generally don't need NgModules to manage service dependencies at all. In unit tests we can also easily mock the dependencies. This approach is extremely convenient.

How DI for CDPs works currently

However, the way we work with component, directive and pipe (="CDP" for short) dependency system is completely different. Imagine a setup:

@Component({
  selector: 'app-some-component',
  template: `<div some-directive>{{ value | somePipe }}<app-other-component></app-other-component></div>`
})
export class SomeComponent {
   value: string = 'someValue';
}

@NgModule({
  declarations: [
    SomeComponent,
    SomeDirective, // This can be declared in a different ngModule, which is then imported into SomeModule
    SomePipe, // This can be declared in a different ngModule, which is then imported into SomeModule
    OtherComponent // This can be declared in a different ngModule, which is then imported into SomeModule
  ]
})
export class SomeModule

The differences are:

  1. There's no way to explicitly specify and to know which CDPs SomeComponent is using in its template. In order to understand that, a developer has to look through the component template and try to guess which components/directives are used in the template, and which pipe names are used. Then the developer has to find the module where SomeComponent is declared, and to check all the other declarations there, along with all the other declarations in all the other imported NgModules in order to find out which CDPs SomeComponent actually depends on.
  2. Because of that, dependency management for angular CDPs is quite complicated: we have to make use of NgModules in order to build up the view hierarchy. However, it's very difficult to trace back actual dependencies for each component. Effectively, this also means that there is no way to do tree shaking of unused CDPs. As long as they are added to NgModule's declarations, they will not be removed from the output bundle, regardless if they are ever actually used in the application or not.
  3. One more drawback of the current approach is that every CDP is global, ie if a CDP is imported/declared in the AppModule, it is available globally throughout the whole application. Because of this we can run into CDP selector / pipe name collisions, and that is why we are forced to use selector prefixes, ie. [app-other-component].

Suggested solution

Declaring CDP dependencies

I would imagine 5 possible ways to declare component/directive/pipe dependencies. All of these effectively desugar to a key-value dictionary, where the key is the component/directive selector (or, in case of a pipe, a pipe name), and the optional value is the actual CDP implementation class:

  • Option 1 (using selector / pipe name). We only declare the selectors / pipe names that are used in the template and that are required by the component, without providing actual CDP implementations. If the compiler can't find a CDP to use (no matching CDP has been declared on an NgModule), it throws an error.
@Component({
    selector: 'app-some-component',
    template: `<div app-some-directive>{{ value | somePipe }}<app-other-component></app-other-component></div>`,
    declarations: [
        '[app-some-directive]',
        'app-other-component',
        'somePipe'
    ]
})
export class SomeComponent {
    value: string = 'someValue';
}
  • Option 2 (using selector / pipe name and providing a default implementation). This adds up to the previous option, by providing an implementation for a CDP. These implementations should be used similarly to how component-level service providers are used. Ie, the given CDP implementation is only used within SomeComponent template. If, for example, a <div some-directive></div> markup is found somewhere outside of SomeComponent's template, SomeDirective should not be injected there.
@Component({
    selector: 'app-some-component',
    template: `<div app-some-directive>{{ value | somePipe }}<app-other-component></app-other-component></div>`,
    declarations: [
        {provide: '[app-some-directive]', useClass: SomeDirective},
        {provide: 'app-other-component', useClass: OtherComponent},
        {provide: 'somePipe', useClass: SomePipe}
    ]
})
export class SomeComponent {
    value: string = 'someValue';
}
  • Option 3 (using CDP class). This effectively desugars to the previous syntax option by using CDP's selector / pipe name contained in its declaration (@Component({selector: ...}) / @Directive({selector: ...}) / @Pipe({name: ...})) as the key (provide), and the CDP class as the value (useClass)
@Component({
    selector: 'app-some-component',
    template: `<div app-some-directive>{{ value | somePipe }}<app-other-component></app-other-component></div>`,
    declarations: [
        SomeDirective,
        OtherComponent,
        SomePipe
    ]
})
export class SomeComponent {
    value: string = 'someValue';
}
  • Option 4 (using CDP class as a key and CPD class as a value). This effectively desugars to Syntax Option 2 by using CDP's selector / pipe name contained in its declaration (@Component({selector: ...}) / @Directive({selector: ...}) / @Pipe({name: ...})) as the key (provide), and the CDP class itself as the value (useClass):
@Component({
    selector: 'app-some-component',
    template: `<div app-some-directive>{{ value | somePipe }}<app-other-component></app-other-component></div>`,
    declarations: [
        {provide: SomeDirective, useClass: SomeAlternativeDirective},
        {provide: OtherComponent, useClass: OtherAlternativeComponent},
        {provide: SomePipe, useClass: SomeAlternativePipe}
    ]
})
export class SomeComponent {
    value: string = 'someValue';
}
  • Option 5 (using a CDP class as a key and not providing a value). This effectively desugars to Syntax Option 1 by using CDP's selector / pipe name contained in its declaration (@Component({selector: ...}) / @Directive({selector: ...}) / @Pipe({name: ...})) as the key (provide), and not providing a value:
@Component({
    selector: 'app-some-component',
    template: `<div app-some-directive>{{ value | somePipe }}<app-other-component></app-other-component></div>`,
    declarations: [
        {provide: SomeDirective},
        {provide: OtherComponent},
        {provide: SomePipe}
    ]
})
export class SomeComponent {
    value: string = 'someValue';
}

Regardless of the actual syntax used, the CDP component-level dependency declarations are effectively an array of key-value pairs, where the key is of type string (effectively, a directive selector or a pipe name), and the value is of type Type<any> | undefined.

What CDPs should be injected into the component templates

Before the CDPs are injected into a component, its CDP declarations (declarations property in the decorator) should be merged with the declarations of the NgModules used in the app, with NgModules declarations taking precedence. If the component-level CDP declarations contains a declaration with an undefined value, and a CDP for the same key (selector / pipe name) is not found in the NgModule declarations, the compiler should throw an error.

IMPORTANT: If the value (a CDP class) is provided at the component level, it does not have to be added to an NgModule. This would effectively mean the we could get rid of NgModules in most cases. They would still be necessary for app bootstrapping, testing and, probably, some other edge cases including lazy loading.

It is crucial that the NgModule CDP providers have priority over the component-level CDP providers. This is exactly the way it currently works for angular services: contructor argument type annotation only defines the default service implementation, but it can be overriden by the NgModule provider. And this is crucial in order to be able to override the default CDP provider in unit tests.

CDP selectors

The selector defined on the consuming component (SomeComponent) should override the original selector defined on the injected CDP (OriginalDirective). I.e., the following would work fine:

@Component({ 
    declarations: [ {
        provide: '[app-some-other-selector]', 
        useClass: OriginalDirective 
    } ],
    templateUrl: '<div app-some-other-selector></div>'
})
export class SomeComponent {}

@Component({
    selector: '[app-original-directive]'
}) 
class OriginalDirective {}

However, this would not work (the OriginalDirective would not be injected into the template, unless OriginalDirective is also added to an NgModule declarations):

@Component({ 
    declarations: [ {
        provide: '[app-some-other-selector]', 
        useClass: OriginalDirective 
    } ],
    templateUrl: '<div app-original-directive></div>'
})
export class SomeComponent {}

@Component({
    selector: '[app-original-directive]'
}) 
class OriginalDirective {}

Describe alternatives you've considered

As far as I know, there is currently no usable alternative. We can only use NgModule declarations property to add required CDPs to an application, and there is no way at all to localize a CDP scope (ie, make it not global).

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions