Try   HackMD

Developer Guide: Lightweight-tokens, making components tree shakable

tags: design

Overview

This document describes best practices around using injection tokens in a way which supports tree-shaking. Tree-shakable-providers are the preferred way to included services only when they are used. This document describes pattern to be used with components to achieves proper tree-shaking.

Having your codebase designed for tree-shaking is especially important for the library developers. Libraries often have many capabilities. When an application uses a library it often uses only a small set of those capabilities, therefore we want to ensure that the unused capabilities are properly tree-shaken.

The problem which we are trying to solve is that in order to inject or query for something, we need to be able to explicitly identify that something. In Angular we use the Type as the injection-token to identify that something which we are looking for. The implication of this is that the injection-token is than retained regardless if the injection-token gets injected or not. This causes the injection-token to be retained, which prevents tree-shaking.

The solution is to separate the injection-token from the implementation so that the injection-token is very lightweight. The injection-token will still be retained by the tree-shaker but because it is lightweight it is not a problem.

Content Query Injection

To better explain the condition under which this occurs lets look at this hypothetical situation which demonstrates the problem.

<lib-card> <lib-header>...</lib-header> </lib-card>

Lets assume that we are using a library which provides <lib-card> component. The <lib-card> component can optionally take <lib-header>, and <lib-body> as configuration. The most likely implementation is that <lib-card> component uses @ContanteChild/@ContentChildren to get a hold of <lib-header>, and <lib-body>. Here is a straw man implementation.

@Component({ selector: 'lib-header', ..., }) class LibHeaderComponent {} @Component({ selector: 'lib-card', ..., }) class LibCardComponent { @ContentChild(LibHeaderComponent) header: LibHeaderComponent|null = null; }

Lets further assume that <lib-header> is optional therefore a simplified usage would be:

<lib-card></lib-card>

In such usage one would expect that <lib-header> should be tree shaken as it is not used. This is not so because LibCardComponent refers to it like so @ContentChild(LibHeaderComponent) header: LibHeaderComponent; Notice that there are two references to the LibHeaderComponent:

  1. type position: A type position is a reference to LibHeaderComponent as a type. In our example header: LibHeaderComponent refers to LibHeaderComponent in a type position. TypeScript elides the type position references, therefore this reference is erased after TypeScript compilation and as a result has no impact on tree-shaking.
  2. value position: A value position is a reference to LibHeaderComponent which must be retained at runtime (can't be elided.) In our example @ContentChild(LibHeaderComponent) refers to LibHeaderComponent in a value position. The retention of the reference influences the tree-shaking process.

Because value positions are retained they prevent the value from being tree shaken. In our example LibHeaderComponent will be retained regardless of if <lib-header> is used by the developer in the application. The retention is an undesirable property of this design. If LibHeaderComponent is large (code + template + styles) it will have a negative impact on the size of the application

Library developer vs application developer

It is important to separate what code is written by library vs application developer.

Template written by the application developer:

<lib-card> <lib-header>...</lib-header> </lib-card>

Implementation written by the library developer:

@Component({ selector: 'lib-header', ..., }) class LibHeaderComponent {} @Component({ selector: 'lib-card', ..., }) class LibCardComponent { @ContentChild(LibHeaderComponent) header: LibHeaderComponent|null = null; }

It is important to realize that the library developer has no idea at the time of implementing the library how it will be used by the application. The implication is that the library developer must design for all possibilities.

On the other hand the application developer should not worry about the library implementation details. Therefore from the application developer it comes as a great surprise that usage of <lib-card> retains <lib-header> even if it is not used in the application.

The core problem is that the sub-optimal choices in the library design effect the application. This is an issue because the developer (library) which is a source of the issue is not the same developer (application) as the one who suffers the consequences of that choice (retaining too much).

For this reason it is very important to avoid this pattern as it will lead to unnecessarily large bundles.

Using lightweight token

In order to prevent the retention of unused components it is necessary for the library author to use the lightweight token pattern. The pattern consist of using a small abstract class as injection token. Later implement that abstract class with actual implementation. The result is that the abstract class will be retained but it is small and so it has almost no impact on the application developer.

abstract class LibHeaderToken {} @Component({ selector: 'lib-header', providers: [ {provide: LibHeaderToken, useExisting: LibHeaderComponent} ] ..., }) class LibHeaderComponent extends LibHeaderToken {} @Component({ selector: 'lib-card', ..., }) class LibCardComponent { @ContentChild(LibHeaderToken) header: LibHeaderToken|null = null; }

Notice:

  • The LibCardComponent implementation no longer refers to LibHeaderToken (neither in value not type position). This allows full tree shaking of LibHeaderComponent to take place.
  • The LibHeaderToken consists of just class declaration but no concrete implementation. The implication is that abstract class is small and will not materially impact the application.
  • The LibHeaderComponent must tie the lightweight token with itself (in providers) so that Angular can correctly inject the concrete type. Without this Angular has no way of knowing that LibHeaderToken should inject LibHeaderComponent. Having such a knowledge would prevent the tree-shaking of LibHeaderComponent which would defeat the purpose.

To summarize the lightweight token pattern consists of:

  1. A lightweight token which is represented as an abstract class.
  2. A injection of the lightweight pattern (such as @ContentChild or @ContentChildren).
  3. A provider in the implementation of the lightweight token which associates the lightweight token with the implementation.

Using lightweight token as API definition

A key thing to notice is that LibCard now injects LibHeaderToken rather than LibHeaderComponent. What if LibCardComponent needs to invoke methods on the LibHeaderComponent, how to do that in a type safe manner? The solution is to add abstract methods to the LibHeaderToken which allows the LibCardComponent to communicate with the LibHeaderComponent without actually referring to LibHeaderComponent. Because LibHeaderComponent implements LibHeaderToken TypeScript will ensure type safety.

abstract class LibHeaderToken { abstract doSomething(): void; } @Component({ selector: 'lib-header', providers: [ {provide: LibHeaderToken, useExisting: LibHeader} ] ..., }) class LibHeaderComponent extends LibHeaderToken { doSomething(): void { // Concrete implemantation of `doSomething` } } @Component({ selector: 'lib-card', ..., }) class LibCardComponent implement AfterContentInit { @ContentChild(LibHeaderToken) header: LibHeaderToken|null = null; ngAfterContentInit(): void { this.header && this.header.doSomething(); } }

When to use the lightweight-token pattern

This problem arises when component is used as a injection-token. There are two cases when that can happen: 1)with query and 2) with constructor injection.

class MyComponent { constructor(@Optional() other: OtherComponent) {} @ContentQuery(OtherComponent) other: OtherComponent|null; @ViewQuery(OtherComponent) other: OtherComponent|null; }

In the above example, all three uses of OtherComponent will cause its retention (not tree-shakable.) This is because in all cases the OtherComponent is used in the value position. The constructor usage is in value position, but TypeScript --emitDecoratorMetadata converts the type position to value position. Effectively changing constructor(@Optional() other: OtherComponent) to constructor(@Optional() @Inject(OtherComponent) other) which makes it a value position. This causes the tree-shaker to retain the reference.

For services use tree-shakable-providers

Suggested naming strategies

Angular style guide suggest how things should be name but these are only suggestions and are not enforced. Therefore you are free to pick any name for the component and the injection-token.

Lightweight tokens are only useful with components. Angular style guide suggest that components are name with Component suffix (such as FooComponent.) Therefore our suggestion is to suffix lightweight tokens with Token suffix (such as FooToken.) By using Component and Token the relationship is communicated through the name.