Injection de dépendances
@fluixi/di partage services, clients et configuration sans les faire descendre par les props.
Il fonctionne comme l'injecteur d'Angular, avec deux différences qui comptent dans une
application rendue côté serveur : l'injecteur racine est un par requête sur le serveur, si
bien que l'état d'un utilisateur ne fuit jamais dans le rendu d'un autre, et la hiérarchie des
composants est l'arbre de propriété réactif, si bien que les services d'un composant sont
libérés quand il est démonté.
import { injectionToken, provideRoot, inject } from '@fluixi/di';
const Api = injectionToken<ApiClient>('Api');
provideRoot([{ provide: Api, useFactory: () => new ApiClient(env.API_URL) }]);
function Tickets() {
const api = inject(Api);
// ...
}
Quel import
- Les packages (une bibliothèque de composants, un SDK, des éléments partagés) importent
@fluixi/diet le déclarent en dépendance pair (peerDependencies). Ils dépendent alors de l'injecteur seul, pas du méta-framework. - Les applications construites avec
@fluixi/startpeuvent importer@fluixi/start/di, qui réexporte@fluixi/ditel quel. Les bundlers ne gardent que ce qui sert.
Les deux mènent aux mêmes injecteurs : un service fourni par l'application est trouvé par le
inject() d'un package.
Jetons
Un jeton est une identité, pas une valeur : deux jetons du même nom restent distincts, et le
paramètre de type est ce qui fait renvoyer à inject le bon type.
const Config = injectionToken<AppConfig>('Config');
Un jeton avec une factory se fournit lui-même là où il est demandé la première fois : inject
fonctionne sans rien fournir. Un fournisseur sur le chemin passe quand même avant, et c'est ainsi
qu'un test le remplace.
const Now = injectionToken('Now', { factory: () => () => new Date() });
const Pool = injectionToken('Pool', { factory: () => createPool(), scope: 'platform' });
La factory construit une fois par racine par défaut (par requête sur le serveur, par
application dans le navigateur), ou une fois par processus avec scope: 'platform'. Un service
que personne n'injecte n'est jamais construit.
Formes de fournisseur
provideRoot([
{ provide: Config, useValue: { apiUrl: '/api' } }, // une valeur prête
{ provide: Clock, useClass: SystemClock }, // construite une fois
{ provide: Api, useFactory: (c) => new Api(c.apiUrl), deps: [Config] },
{ provide: LegacyApi, useExisting: Api }, // un alias
]);
deps liste ce qu'il faut résoudre et passer, dans l'ordre, à la factory ou au constructeur.
Une dépendance peut porter des options : deps: [Store, { token: Logger, optional: true }].
Une classe ou une factory peut aussi appeler inject() elle-même. La résolution part de
l'injecteur qui détient le fournisseur, pas de l'endroit où le service a été demandé la
première fois :
class CheckoutService {
#payments = inject(Payments);
#log = inject(Logger, { optional: true });
}
Tout est construit à la première demande, puis réutilisé par cet injecteur.
Fournisseurs multiples
multi: true ajoute une valeur à une liste au lieu de remplacer la valeur du jeton. Les
intercepteurs, les extensions et les validateurs en sont le cas habituel :
const Interceptors = injectionToken<Interceptor[]>('Interceptors');
provideRoot([
{ provide: Interceptors, useValue: authInterceptor, multi: true },
{ provide: Interceptors, useValue: retryInterceptor, multi: true },
]);
inject(Interceptors); // [authInterceptor, retryInterceptor], dans l'ordre d'enregistrement
Un composant qui fournit ses propres valeurs multi pour le jeton a sa propre liste ; elle ne
prolonge pas celle du dessus.
La hiérarchie
| Injecteur | Créé par | Vit |
|---|---|---|
| plateforme | providePlatform(), jetons scope: 'platform' |
tout le processus |
| racine | provideRoot(), modules d'application, jetons scope: 'root' |
une requête sur le serveur, la page dans le navigateur |
| composant | provide() dans un composant |
jusqu'au démontage du composant |
| autonome | createInjector() |
jusqu'à votre appel à dispose() |
inject() résout depuis le contexte courant : un injecteur explicite (plus bas), sinon
l'injecteur de composant le plus proche en remontant l'arbre de propriété, sinon la racine,
puis la plateforme, puis la factory du jeton.
function Editor() {
provide([{ provide: Draft, useClass: DraftStore }]); // le brouillon de cet éditeur seul
return <EditorBody />; // inject(Draft) à l'intérieur le trouve
}
Choisir une portée
provideRoot: une instance pour l'application. Une session, un journal, un client d'API. Créée par requête sur le serveur, une fois par page dans le navigateur.providedans un composant : la valeur appartient à ce qui est à l'écran. Une réponse en cours de rédaction, l'état partiel d'un assistant, le tampon d'un éditeur. Libérée avec le composant.providePlatform: coûteux à créer et sans risque à partager entre utilisateurs. Un pool de connexions, des réglages analysés. Jamais d'état propre à un utilisateur.- Un simple export de module : ni durée de vie ni identité par requête. Fonctions pures, constantes.
Pour choisir entre racine et composant, demandez-vous ce qui doit arriver quand l'utilisateur
quitte la page : gardé, c'est la racine ; jeté, c'est le composant.
examples/ticket-desk/src/routes/tickets/[id].tsx fournit un DraftStore sur le composant, pour
qu'une réponse à moitié écrite ne vous suive pas au ticket suivant.
Options de résolution
inject(token, options) et les entrées de deps acceptent :
| Option | Effet |
|---|---|
optional |
null au lieu d'une erreur quand rien ne fournit le jeton |
self |
chercher seulement dans l'injecteur courant |
skipSelf |
commencer par le parent : lire ce que fournit un ancêtre tout en fournissant le même jeton plus bas |
host |
s'arrêter à l'injecteur de composant le plus proche |
const parentForm = inject(FormGroup, { skipSelf: true, optional: true });
Cycle de vie
Quand un injecteur est libéré (un composant démonté, une requête serveur terminée, dispose()
sur un injecteur que vous avez créé), il libère ce qu'il a construit :
- les fonctions enregistrées sur son
DestroyRef, la dernière d'abord ; - la méthode
onDestroy()de chaque service construit, le dernier construit d'abord.
import { DestroyRef, inject } from '@fluixi/di';
class Poller {
#timer = setInterval(() => this.poll(), 5_000);
constructor() {
inject(DestroyRef).onDestroy(() => clearInterval(this.#timer));
}
}
onDestroy renvoie une fonction qui désenregistre la fonction. Une fonction qui lève une erreur
est journalisée et les autres s'exécutent quand même.
Initialisation de l'application
Ce qui doit être terminé avant le rendu va dans un initialiseur. Ils s'exécutent dans l'ordre d'enregistrement, chacun attendu ; le premier qui échoue arrête les suivants.
import { provideAppInitializer, provideRoot, inject } from '@fluixi/di';
provideRoot([
provideAppInitializer(async () => {
await inject(Settings).load();
}),
]);
Racines par requête
Sur le serveur, l'injecteur racine est créé par requête, si bien que deux requêtes simultanées ne partagent jamais d'instance, et il est libéré à la fin de la requête : les services rendent alors leurs connexions et leurs minuteries. Dans le navigateur, il y a une seule racine pour la vie de la page.
C'est la raison de préférer la DI à un singleton de module dans une application rendue côté serveur : une variable de module est partagée par toutes les requêtes du processus, et la session d'un utilisateur qui fuit dans le rendu d'un autre est un bogue qui n'apparaît qu'en charge.
Où inject() ne fonctionne pas
inject() lit le contexte d'injection courant, qui n'existe pas à trois endroits :
- Au niveau du module. Un appel en haut d'un fichier s'exécute avant tout composant et toute requête. On y fournit ; on injecte dans les composants et les services.
- Après un
await. Le contexte est synchrone. Résolvez tout avant le premierawait: une route d'API qui attendaitrequest.json()avant d'injecter son dépôt ne trouvait aucun contexte. - Dans un rappel qui survit à son propriétaire. Un intercepteur, un
setTimeout, un écouteur ajouté hors d'un composant. Gardez l'injecteur tant que le contexte existe et exécutez le rappel dedans :
const injector = inject(INJECTOR);
socket.on('message', () => runInInjectionContext(injector, () => inject(Inbox).add()));
isInInjectionContext() indique si un contexte est actif, et
assertInInjectionContext('useCart') donne une erreur claire à vos propres fonctions.
Erreurs
Un fournisseur manquant ou un cycle lève une InjectionError qui nomme tout le chemin de
résolution, pour distinguer la pièce manquante de ce qui en avait besoin :
No provider for "ApiKey" (Checkout → Payments → ApiKey).
Circular dependency: A → B → A.
error.path contient la même chaîne sous forme de tableau.
Fonctions serveur et DI
Une fonction "use server" s'exécute dans la requête, et inject() y résout depuis l'injecteur
racine de cette requête. Deux utilisateurs qui appellent la même fonction au même instant
obtiennent deux instances différentes d'un service racine. examples/ticket-desk/src/actions.ts
injecte TicketRepoToken dans une fonction serveur ; tests/di.spec.ts vérifie que deux
requêtes simultanées ne voient jamais les tickets de l'autre. Injectez avant le premier await.
La DI ne détermine pas l'identité lors du rendu serveur
Le middleware écrit l'utilisateur dans les locals de la requête (getRequestLocals(request)), et
le rendu les lit avec getLocals() ; les hooks de @fluixi/auth y lisent déjà. La DI décide
quelle instance d'un service utiliser ; les locals portent ce que le middleware a appris de la
requête.
Tests
@fluixi/di/testing construit des injecteurs pour les tests, séparé du point d'entrée
principal pour qu'aucune application ne l'embarque.
import { createTestInjector, resetInjection } from '@fluixi/di/testing';
afterEach(() => resetInjection());
it('débite le total du panier', async () => {
const payments = { charge: vi.fn() };
const injector = createTestInjector({
providers: [{ provide: Checkout, useClass: CheckoutService }],
overrides: [{ provide: Payments, useValue: payments }],
});
await injector.get(Checkout).pay();
expect(payments.charge).toHaveBeenCalled();
});
createTestInjector construit une racine à lui, sans les fournisseurs de l'application.
overrides remplace un jeton partout où il serait fourni, sa propre factory comprise, jusqu'à
resetInjection(), qui libère aussi ce qui a été construit.
Ensuite : Internationalisation.