Cas habituel : provisionner une ressource synchrone
En terme général, on va mettre à disposition sur l'injecteur des services ou des valeurs qui sont disponibles immédiatement.
Par exemple :
@Injectable()
export class SyncService {
config = { appName: 'Async provider demo' };
}
@Component({
selector: 'app-root',
standalone: true,
providers: [SyncService],
template: `
<p>appName = {{appName}}</p>
`,
})
export class App {
appName = inject(SyncService).config.appName;
}
On a un service qui est provisionné et est injecté dans notre composant de manière synchrone. Ce service est extrêmement simple mais suffit pour montrer que tout fonctionne.
Provisionner une ressource asynchrone
Si maintenant on veut faire la même chose mais avec des données qu'on récupèrerait de manière asynchrone via un fetch, on fait comment ?
Déjà on ne peut pas juste passer par la syntaxe courte comme pour le SyncService
, il faut passer par la syntaxe complète et pour rappel la syntaxe complète pour le SyncService
donne :
@Component({
providers: [{ provide: SyncService, useClass: SyncService }],
})
export class App {}
Il existe plusieurs options pour faire du provisionnement, mais la plus logique ici serait un FactoryProvider
qui pour un cas très simple se résume a quelque chose du genre :
const AsyncConfig = new InjectionToken<{ url: string }>("AsyncConfig");
@Component({
providers: [{ provide: AsyncService, useFactory: () => ({ url: 'static' }) }],
})
export class App {}
Naïvement, on se dit qu'il suffit de rendre la fonction async
, faire un fetch
et renvoyer la valeur ?
@Component({
providers: [
{
provide: AsyncConfig,
useFactory: async () => {
const config = await fetch("/config.json").then((r) => r.json());
return config;
},
},
],
template: `
<h1>Hello from {{ name }}!</h1>
<p>appName = {{ appName }}</p>
<p>dynamicUrl = {{ asyncConfig | json }}</p>
`,
})
export class App {
// ...
asyncConfig = inject(AsyncConfig);
}
Malheureusement, on aura ça à l'affichage : dynamicUrl = { "__zone_symbol__state": null, "__zone_symbol__value": [] }
ou dynamicUrl = { "__zone_symbol__state": true, "__zone_symbol__value": { "url": "https://dynamic-url.example.com" } }
… un objet Zone et non pas la config 😰
Pourquoi ? En fait aucun provider n'est prévu pour être asynchrone donc ce n'est pas du tout la bonne route pour faire ça… 🤔
Option de semi-facilité : configuration asynchrone
Cette solution serait de ne pas chercher à provisionner en asynchrone la configuration, mais avoir une configuration asynchrone porté par un service provisionné en synchrone. Sous forme de code :
interface AsyncConfig {
url: string;
}
@Injectable()
export class AsyncConfigService {
config$ = inject(HttpClient).get<AsyncConfig>("/config.json");
}
@Component({
providers: [AsyncConfigService],
template: `
<p>dynamicUrl = {{ asyncConfig | async | json }}</p>
`,
})
export class App {
asyncConfig = inject(AsyncConfigService).config$;
}
Ça fonctionne, mais ça a un inconvénient majeur : on va devoir résoudre l'Observable à chaque accès ! Par contre rien de compliqué à mettre en place.
Vrai provider asynchrone
On ne peut toujours pas magiquement définir un provider asynchrone, mais on peut fournir de manière asynchrone à Angular un provider.
Prenons un cas courant synchrone :
bootstrapApplication(App, { providers: [provideHttpClient()] });
Ici on provisionne le HttpClient
dès le bootstrap de l'application. Rien ne nous empêche de faire un appel à bootstrapApplication
après avoir résolu la réponse à un fetch
de manière asynchrone !
interface AsyncConfig {
url: string;
}
export class AsyncConfigService {
constructor(public readonly config: AsyncConfig) {}
}
(async () => {
const config = await fetch("/config.json").then((r) => r.json());
bootstrapApplication(App, {
providers: [
{ provide: AsyncConfigService, useValue: new AsyncConfigService(config) },
],
});
})();
Ici je suis passé directement par un provider par valeur pour directement fournir une instance du service. J'aurai pu passer par un InjectionToken
comme plus haut, mais ça me semble beaucoup plus simple comme ça, sans forcément avoir d'argument.
Pour récupérer notre configuration :
@Component({
template: `
<p>dynamicUrl = {{ asyncConfig | json }}</p>
`,
})
export class App {
asyncConfig = inject(AsyncConfigService).config;
}
Mettre au propre et prévoir l'usage dans les tests
Histoire de rendre le code plus lisible et organiser, je vous propose de sortir tout ce qui concerne notre AsyncConfigService
dans un fichier dédié et exporter une fonction du même style que provideHttpClient()
:
// async-config.service.ts
import { StaticProvider } from "@angular/core";
interface AsyncConfig {
url: string;
}
export class AsyncConfigService {
constructor(public readonly config: AsyncConfig) {}
}
export async function provideConfigAsync(): Promise<StaticProvider> {
const config = await fetch("/config.json").then((r) => r.json());
return {
provide: AsyncConfigService,
useValue: new AsyncConfigService(config),
};
}
Et adapter le bootstrap comme ça :
// main.ts
(async () => {
bootstrapApplication(App, { providers: [await provideConfigAsync()] });
})();
Et quitte à s'inspirer du module http, on peut aussi fournir une fonction pour provisionner la configuration de test sans avoir à faire d'appel asynchrone en exposant aussi une fonction provideConfigAsyncTest
:
export async function provideConfigAsyncTest(
config: Partial<AsyncConfig> = {}
): Promise<StaticProvider> {
return {
provide: AsyncConfigService,
useValue: new AsyncConfigService({
url: "http://default-url.local",
...config,
}),
};
}
Cette fonction est très simple : on prend ou pas une partie de la configuration en paramètre, si une configuration est fournie on s'en sert sinon prend des valeurs par défaut.
L'idée c'est de pouvoir écrire ça côté test :
TestBed.configureTestingModule({
providers: [provideConfigAsyncTest()]
});
Mais aussi :
TestBed.configureTestingModule({
providers: [provideConfigAsyncTest({ url: 'http://specific-url.example.com' })]
});
APP_INITIALIZER
(update 16/11/2024)
L'ami Denis Souron m'a partagé une autre solution : l'APP_INITIALIZER
. Ici on est face à une solution purement Angular, mais elle ne me semble pas tellement plus intuitive.
L'idée c'est qu'au lieu d'avoir un appel asynchrone qui conduit à l'ajout dans l'injecteur Angular d'un service déjà initialisé, on va d'abord provisionner le service (avec une valeur par défaut ou un null
/undefined
en fonction de votre préférence), puis on va provisionner une factory d'un type particulier pour faire l'appel asynchrone et mettre à jour notre service.
En termes de code ça donne ça :
@Injectable()
export class AsyncConfigService {
public config: AsyncConfig = { url: "" };
}
export function provideConfigAsync(): Provider[] {
return [
// provider for the service
AsyncConfigService,
// provider for function that will feed the service
{
provide: APP_INITIALIZER,
multi: true,
useFactory:
(asyncConfigService: AsyncConfigService) =>
async () => {
const config = await fetch("/config.json").then((r) => r.json());
asyncConfigService.config = config;
},
deps: [AsyncConfigService],
},
];
}
Avec cette solution on peut d'ailleurs passer le HttpClient
et un Observable
(au lieu de la Promise
) :
export function provideConfigAsync(): Provider[] {
return [
AsyncConfigService,
{
provide: APP_INITIALIZER,
multi: true,
useFactory:
(asyncConfigService: AsyncConfigService, http: HttpClient) => () =>
http
.get<AsyncConfig>("/config.json")
.pipe(tap((config) => (asyncConfigService.config = config))),
deps: [AsyncConfigService, HttpClient],
},
];
}
Ce qui permet aussi d'alléger la syntaxe de notre boostrap :
bootstrapApplication(App, { providers: [...provideConfigAsync()] });
Conclusion
Si ce n'est pas très intuitif et pas décrit dans la documentation Angular comment réaliser un provisionnement asynchrone, c'est tout à fait possible. Je vous ai montré la technique que j'ai récemment employée, si vous en connaissez d'autres, je suis évidemment curieux ! 🤓
Source :
- Code source montré dans l'article dans un projet avec les différentes étapes
- Configuring dependency providers (documentation officielle)
- APP_INITIALIZER
Crédit photo : Générée via Microsoft Designer avec le prompt suivant :
inject, async, asynchrone, injection, angular, provisionning, resources, web, frontend